FN-6963: disable tool output log details by default

Agent logs now keep tool timeline rows while requiring an explicit opt-in to persist verbose tool payloads.

- Default persistAgentToolOutput to false in global settings and direct AgentLogger construction.
- Update the settings UI, API expectations, documentation, and changeset to describe opt-in tool payload persistence.
- Adjust engine and dashboard tests for the new default-off behavior while preserving explicit opt-in coverage.

Files changed:
 .changeset/FN-6963-tool-output-default-off.md      |  5 ++++
 docs/settings-reference.md                         |  2 +-
 packages/core/src/settings-schema.ts               |  6 ++++-
 .../components/__tests__/SettingsModal.test.tsx    | 22 +++++++++++++---
 .../settings/sections/GlobalGeneralSection.tsx     |  2 +-
 .../src/__tests__/routes-settings.test.ts          |  4 +--
 packages/engine/src/__tests__/agent-logger.test.ts | 30 ++++++++++++++++++----
 .../src/__tests__/heartbeat-executor.test.ts       |  4 +--
 .../src/__tests__/merger-merge-details.test.ts     |  2 +-
 .../src/__tests__/merger-verification.test.ts      |  2 +-
 .../src/__tests__/step-session-executor.test.ts    |  4 +--
 packages/engine/src/__tests__/triage.test.ts       |  2 +-
 packages/engine/src/agent-logger.ts                |  8 ++++--
 13 files changed, 71 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-6963

Fusion-Task-Lineage: 7db32871-f539-4a27-a324-02c55ce5bd04
This commit is contained in:
gsxdsm
2026-06-23 21:37:45 -07:00
parent cf2f3ba5e4
commit 038ac3060b
13 changed files with 71 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Saved agent tool-output details now default off to reduce persisted log payloads, while timeline rows remain logged and detailed tool arguments/results stay available via the global `persistAgentToolOutput: true` opt-in.

View File

@@ -98,7 +98,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
> Mesh lifecycle note: settings sync is executed by the process-level `PeerExchangeService` started by `fn serve`/`fn dashboard`. `InProcessRuntime` does not instantiate settings-sync mesh services per project.
| `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. |
| `persistAgentToolOutput` | `boolean` | `true` | Controls whether detailed `detail` payloads are persisted for `tool`, `tool_result`, and `tool_error` agent log entries. When disabled, tool timeline rows are still recorded, but verbose payloads are omitted. |
| `persistAgentToolOutput` | `boolean` | `false` | Controls whether detailed `detail` payloads are persisted for `tool`, `tool_result`, and `tool_error` agent log entries. Tool timeline rows are still recorded by default; verbose tool arguments/results require opting in with `persistAgentToolOutput: true`. |
| `persistAgentThinkingLogPermanent` | `boolean` | `false` | Controls whether `thinking`/reasoning rows are persisted for permanent (non-ephemeral) agents. |
| `persistAgentThinkingLogEphemeral` | `boolean` | `false` | Controls whether `thinking`/reasoning rows are persisted for ephemeral/task-worker/spawned agents. |
| `persistAgentThinkingLog` *(deprecated)* | `boolean` | `false` | Legacy fallback alias for thinking-row persistence. When set and a granular key above is still undefined, this legacy value is used for that agent kind. Leaving both granular keys off preserves default-off behavior; assistant text and tool rows are unchanged. |

View File

@@ -159,7 +159,11 @@ export const DEFAULT_GLOBAL_SETTINGS = {
vitestAutoKillEnabled: true,
vitestKillThresholdPct: 90,
// Agent log persistence controls
persistAgentToolOutput: true,
/*
FNXC:AgentLogs 2026-06-23-00:00:
Verbose tool arguments and results are default-off to reduce persisted log volume and payload exposure. Operators who need saved tool details can explicitly opt in with persistAgentToolOutput: true; tool timeline rows remain logged either way.
*/
persistAgentToolOutput: false,
persistAgentThinkingLogPermanent: false,
persistAgentThinkingLogEphemeral: false,
persistAgentThinkingLog: false,

View File

@@ -893,8 +893,8 @@ describe("SettingsModal", () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
// persistAgentToolOutput defaults to checked; Star-on-GitHub control absent.
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).toBeChecked();
// persistAgentToolOutput defaults to unchecked; Star-on-GitHub control absent.
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).not.toBeChecked();
expect(screen.queryByRole("checkbox", { name: /Show "Star on GitHub" button in Settings header/i })).toBeNull();
// thinking-log checkboxes default to unchecked.
@@ -913,6 +913,22 @@ describe("SettingsModal", () => {
expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument();
});
it("reflects persisted checked value from global settings", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
persistAgentToolOutput: true,
});
mockFetchSettingsByScope.mockResolvedValue({
global: { ...defaultSettings, persistAgentToolOutput: true },
project: {},
});
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).toBeChecked();
});
it("reflects persisted unchecked value from global settings", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
@@ -958,7 +974,7 @@ describe("SettingsModal", () => {
});
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(globalPayload.persistAgentToolOutput).toBe(false);
expect(globalPayload.persistAgentToolOutput).toBe(true);
if (mockUpdateSettings.mock.calls.length > 0) {
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(projectPayload.persistAgentToolOutput).toBeUndefined();

View File

@@ -23,7 +23,7 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin
<CliBinaryPanel />
<div className="form-group">
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
<input id="persistAgentToolOutput" type="checkbox" checked={form.persistAgentToolOutput !== false} onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}/>{t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs ")}</label>
<input id="persistAgentToolOutput" type="checkbox" checked={form.persistAgentToolOutput === true} onChange={(e) => setForm((f) => ({ ...f, persistAgentToolOutput: e.target.checked }))}/>{t("settings.globalGeneral.saveToolOutputInAgentLogs", " Save tool output in agent logs ")}</label>
<small>{t("settings.globalGeneral.whenDisabledToolRowsAreStillLoggedBut", " When disabled, tool rows are still logged but detailed tool payloads are omitted. Very large tool payloads may still be clipped even when this stays enabled. ")}</small>
</div>
<div className="form-group">

View File

@@ -1382,7 +1382,7 @@ describe("GET /settings/scopes", () => {
global: {
themeMode: "dark",
defaultProvider: "anthropic",
persistAgentToolOutput: true,
persistAgentToolOutput: false,
persistAgentThinkingLogPermanent: false,
persistAgentThinkingLogEphemeral: false,
persistAgentThinkingLog: false,
@@ -1395,7 +1395,7 @@ describe("GET /settings/scopes", () => {
expect(res.status).toBe(200);
expect(res.body.global.themeMode).toBe("dark");
expect(res.body.global.defaultProvider).toBe("anthropic");
expect(res.body.global.persistAgentToolOutput).toBe(true);
expect(res.body.global.persistAgentToolOutput).toBe(false);
expect(res.body.global.persistAgentThinkingLogPermanent).toBe(false);
expect(res.body.global.persistAgentThinkingLogEphemeral).toBe(false);
expect(res.body.global.persistAgentThinkingLog).toBe(false);

View File

@@ -141,18 +141,32 @@ describe("AgentLogger", () => {
expect(calls.length).toBe(2);
// Text flushed first
expect(calls[0]).toEqual(["FN-003", "pending text", "text", undefined, undefined]);
// Tool logged second with detail
expect(calls[1]).toEqual(["FN-003", "Bash", "tool", "ls", undefined]);
// Tool logged second without detail by default.
expect(calls[1]).toEqual(["FN-003", "Bash", "tool", undefined, undefined]);
});
it("logs tool detail using summarizeToolArgs", async () => {
it("omits tool detail by default when persistAgentToolOutput is unset", async () => {
const store = createMockStore();
const logger = new AgentLogger({ store, taskId: "FN-004" });
logger.onToolStart("Read", { path: "src/index.ts" });
logger.onToolEnd("Read", false, "ok");
logger.onToolEnd("Read", true, "err");
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-004", "Read", "tool", "src/index.ts", undefined);
expect(store.appendAgentLog).toHaveBeenNthCalledWith(1, "FN-004", "Read", "tool", undefined, undefined);
expect(store.appendAgentLog).toHaveBeenNthCalledWith(2, "FN-004", "Read", "tool_result", undefined, undefined);
expect(store.appendAgentLog).toHaveBeenNthCalledWith(3, "FN-004", "Read", "tool_error", undefined, undefined);
});
it("logs tool detail using summarizeToolArgs when explicitly enabled", async () => {
const store = createMockStore();
const logger = new AgentLogger({ store, taskId: "FN-004A", persistAgentToolOutput: true });
logger.onToolStart("Read", { path: "src/index.ts" });
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-004A", "Read", "tool", "src/index.ts", undefined);
});
it("omits tool detail when persistAgentToolOutput is disabled", async () => {
@@ -264,7 +278,7 @@ describe("AgentLogger", () => {
(store.appendAgentLog as ReturnType<typeof vi.fn>).mockClear();
logger.onToolStart("Bash", { command: "ls" });
await vi.advanceTimersByTimeAsync(0);
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-010", "Bash", "tool", "ls", "executor");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-010", "Bash", "tool", undefined, "executor");
});
// ── Thinking buffer/flush ────────────────────────────────────────
@@ -344,6 +358,7 @@ describe("AgentLogger", () => {
store,
taskId: "FN-014",
agent: "executor",
persistAgentToolOutput: true,
persistAgentThinkingLog: true,
flushSizeBytes: 1024,
});
@@ -365,6 +380,7 @@ describe("AgentLogger", () => {
store,
taskId: "FN-015",
agent: "executor",
persistAgentToolOutput: true,
});
logger.onToolEnd("Bash", false, "command output");
@@ -378,6 +394,7 @@ describe("AgentLogger", () => {
store,
taskId: "FN-016",
agent: "executor",
persistAgentToolOutput: true,
});
logger.onToolEnd("Read", true, "file not found");
@@ -391,6 +408,7 @@ describe("AgentLogger", () => {
store,
taskId: "FN-016B",
agent: "executor",
persistAgentToolOutput: true,
});
const longError = "error:" + "y".repeat(1200);
@@ -407,6 +425,7 @@ describe("AgentLogger", () => {
store,
taskId: "FN-017",
agent: "executor",
persistAgentToolOutput: true,
});
const longResult = "x".repeat(600);
@@ -423,6 +442,7 @@ describe("AgentLogger", () => {
store,
taskId: "FN-017B",
agent: "executor",
persistAgentToolOutput: true,
});
const circular: Record<string, unknown> = {};
circular.self = circular;

View File

@@ -3528,8 +3528,8 @@ describe("executeHeartbeat", () => {
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "Heartbeat produced visible output", "text", undefined, "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", "README.md", "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", "done", "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", undefined, "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", undefined, "executor");
expect(result.contextSnapshot?.taskId).toBe("FN-001");
expect(result.stdoutExcerpt).toContain("Heartbeat produced visible output");
});

View File

@@ -522,7 +522,7 @@ describe("aiMergeTask — agent log persistence", () => {
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Bash", "tool", "git status", "merger");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Bash", "tool", undefined, "merger");
});
it("still fires onAgentText callback alongside logging", async () => {

View File

@@ -2354,7 +2354,7 @@ describe("aiMergeTask — in-merge verification fix", () => {
expect(capturedFixOptions.onToolStart).toBeTypeOf("function");
expect(capturedFixOptions.onToolEnd).toBeTypeOf("function");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Bash", "tool", "vitest run", "merger");
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Bash", "tool", undefined, "merger");
const logMessages = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls
.map((call: any[]) => call[1])

View File

@@ -2465,8 +2465,8 @@ describe("StepSessionExecutor", () => {
await executor.executeAll();
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "step output", "text", undefined, "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", "src/foo.ts", "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", "ok", "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", undefined, "executor");
expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", undefined, "executor");
});
it("flushes AgentLogger in attempt finally block", async () => {

View File

@@ -3978,7 +3978,7 @@ describe("tool callback behavior (FN-1500)", () => {
"FN-TOOL-002",
"read",
"tool",
"test.txt",
undefined,
"triage",
);
});

View File

@@ -145,7 +145,7 @@ export function summarizeToolArgs(name: string, args?: Record<string, unknown>):
* When both are provided, both sinks receive every entry.
*/
export interface AgentLoggerOptions {
/** When false, omit `detail` payloads for tool entries while preserving the rows. */
/** When true, persist `detail` payloads for tool entries; default false preserves rows without verbose payloads. */
persistAgentToolOutput?: boolean;
/** When true, persist `thinking` rows. Default: false (skip thinking persistence). */
persistAgentThinkingLog?: boolean;
@@ -233,7 +233,11 @@ export class AgentLogger {
this.externalToolCb = options.onAgentTool;
this.flushSizeBytes = options.flushSizeBytes ?? FLUSH_SIZE_BYTES;
this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS;
this.persistAgentToolOutput = options.persistAgentToolOutput !== false;
/*
FNXC:AgentLogs 2026-06-23-00:00:
Direct logger construction must match global settings: verbose tool payload persistence is default-off and only explicit persistAgentToolOutput: true saves tool entry detail. Tool/tool_result/tool_error rows still persist so timelines and usage telemetry remain intact.
*/
this.persistAgentToolOutput = options.persistAgentToolOutput === true;
this.persistAgentThinkingLog = options.persistAgentThinkingLog === true;
this.usageContext = options.usageContext;