diff --git a/.changeset/fn-8614-tool-output-budget.md b/.changeset/fn-8614-tool-output-budget.md new file mode 100644 index 0000000000..6d66167041 --- /dev/null +++ b/.changeset/fn-8614-tool-output-budget.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Bound agent tool output so large reads preserve context capacity. +category: performance +dev: Applies a 16,000-character total budget to every engine-injected tool result. diff --git a/docs/agents.md b/docs/agents.md index 0f423184a3..fb55923b97 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -48,6 +48,14 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] [-- - `agent.taskId` is an active-execution linkage, not durable ownership. It may legitimately point at a `todo`/`triage` task only while the agent has live run or executor-active proof; task-move sync and self-healing clear stale parked, terminal, or unresolved links otherwise. `fn_list_agents` and `fn_agent_show` therefore include column context in the human-readable `Current Task` line, such as `(triage)`, `(in-progress)`, `(not active — done)`, or `(unresolved)`, so coordinators can distinguish transient planning ownership from drift. - `fn_agent_show` prints `Last Error`, `Pause Reason`, and compact `Error Recovery` counter details when present. `fn_list_agents` prints the same diagnostics only for agents currently in `error` or `paused`, keeping healthy rows compact while making durable-agent recovery state inspectable without direct DB/log access. +### Tool output budget + +Every engine-injected tool result has a **16,000-character** budget across the concatenated `text` values of all of its text content blocks. This is a per-result total, not a per-block limit; non-text blocks, `details`, and error status remain intact. + +When a result overflows, Fusion reserves the canonical marker (`[Tool output truncated to fit the context budget; narrow your query or use limit/offset for more.]`) inside that budget. Text is allocated deterministically in document order: complete earlier text blocks are retained while room remains, the first block that no longer fits receives the retained prefix and marker, and later text blocks become empty strings. Results already ending in that exact marker are not marked again, making the clamp idempotent; pre-existing markers from other tool-specific clamps remain ordinary counted text. + +`TOOL_OUTPUT_BUDGET_OVERRIDES` may set a named tool to a different **finite positive integer** budget, either larger or smaller. There is no opt-out, sentinel, `Infinity`, zero, or negative budget: a missing entry uses the default, and invalid entries throw in development/test or fall back to the default in production. Pi applies the clamp as its outermost tool wrapper, while non-pi plugin runtimes apply it once in their custom-tool wrapper. + ### Artifact registry tools Artifact tools operate on the shared artifact registry, so artifacts are visible across agents and tasks when the caller has the artifact ID or can discover it through filters. diff --git a/packages/core/src/__tests__/tool-output-budget.test.ts b/packages/core/src/__tests__/tool-output-budget.test.ts new file mode 100644 index 0000000000..0369e83d70 --- /dev/null +++ b/packages/core/src/__tests__/tool-output-budget.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_TOOL_OUTPUT_MAX_CHARS, + buildToolOutputTruncationMarker, + clampToolOutputBlocks, + clampToolOutputText, + resolveToolOutputBudget, +} from "../tool-output-budget.js"; + +describe("tool output budget", () => { + it("leaves empty, under-budget, and exactly-at-budget text unchanged", () => { + expect(clampToolOutputText("", { maxChars: 5 })).toBe(""); + expect(clampToolOutputText("short", { maxChars: 5 })).toBe("short"); + expect(clampToolOutputText("exact", { maxChars: 5 })).toBe("exact"); + }); + + it("reserves its marker within the string budget and is idempotent", () => { + const maxChars = 100; + const marker = buildToolOutputTruncationMarker(); + const output = clampToolOutputText("x".repeat(300), { maxChars }); + expect(output.length).toBeLessThanOrEqual(maxChars); + expect(output.endsWith(marker)).toBe(true); + expect(clampToolOutputText(output, { maxChars })).toBe(output); + }); + + it("supports custom, degenerate, and newline/multibyte input budgets", () => { + const tiny = clampToolOutputText("non-empty", { maxChars: 3 }); + expect(tiny).toHaveLength(3); + expect(clampToolOutputText("😀\n".repeat(100), { maxChars: 77 })).toHaveLength(77); + }); + + it("allocates a multi-block result in order under one total budget", () => { + const maxChars = 300; + const output = clampToolOutputBlocks(["a".repeat(30), "b".repeat(280), "c".repeat(30)], { maxChars }); + expect(output).toHaveLength(3); + expect(output[0]).toBe("a".repeat(30)); + expect(output[1]).toContain("[Tool output truncated"); + expect(output[2]).toBe(""); + expect(output.join("").length).toBeLessThanOrEqual(maxChars); + }); + + it("reserves a marker when overflow lands on a block boundary and handles undefined", () => { + const marker = buildToolOutputTruncationMarker(); + const maxChars = marker.length + 4; + const output = clampToolOutputBlocks(["abcd", "overflow".repeat(20), undefined], { maxChars }); + expect(output).toEqual(["abcd", marker, ""]); + expect(output.join("")).toHaveLength(maxChars); + }); + + it("resolves only finite positive integer overrides", () => { + expect(resolveToolOutputBudget("missing", {})).toBe(DEFAULT_TOOL_OUTPUT_MAX_CHARS); + expect(resolveToolOutputBudget("large", { large: 20_000 })).toBe(20_000); + expect(resolveToolOutputBudget("small", { small: 10 })).toBe(10); + for (const value of [Infinity, 0, -1, Number.NaN, null]) { + expect(() => resolveToolOutputBudget("bad", { bad: value })).toThrow(/finite positive integers/); + } + }); +}); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 4d98c70bb8..0c5e730b90 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -105,6 +105,13 @@ export type { RankAssignedTasksForWakeDeltaResult, } from "./assigned-task-ranking.js"; export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js"; +export { + DEFAULT_TOOL_OUTPUT_MAX_CHARS, + buildToolOutputTruncationMarker, + clampToolOutputText, + clampToolOutputBlocks, + resolveToolOutputBudget, +} from "./tool-output-budget.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 864600d3e1..f4b6d490de 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -119,6 +119,13 @@ export * from "./original-description-policy.js"; export * from "./planning-plan-md.js"; export * from "./file-scope-classification.js"; export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js"; +export { + DEFAULT_TOOL_OUTPUT_MAX_CHARS, + buildToolOutputTruncationMarker, + clampToolOutputText, + clampToolOutputBlocks, + resolveToolOutputBudget, +} from "./tool-output-budget.js"; export { WAKE_DELTA_ASSIGNED_TASKS_CAP, rankAssignedTasksForWakeDelta, diff --git a/packages/core/src/tool-output-budget.ts b/packages/core/src/tool-output-budget.ts new file mode 100644 index 0000000000..b16ded82b9 --- /dev/null +++ b/packages/core/src/tool-output-budget.ts @@ -0,0 +1,97 @@ +/** Default maximum model-visible characters in one engine-injected tool result. */ +export const DEFAULT_TOOL_OUTPUT_MAX_CHARS = 16_000; + +const DEFAULT_TRUNCATION_HINT = "narrow your query or use limit/offset for more"; + +/** + * FNXC:ToolOutputBudget 2026-08-06-12:00: + * FN-8614 bounds the total text returned by each engine-injected tool result so a + * large log, document, or JSON response cannot consume an agent's context window. + * 16,000 characters keeps ordinary PROMPT.md and durable-document reads useful while + * placing a finite low-tens-of-thousands ceiling on one result. + */ +export function buildToolOutputTruncationMarker(hint = DEFAULT_TRUNCATION_HINT): string { + return `\n[Tool output truncated to fit the context budget; ${hint}.]`; +} + +function normalizeMaxChars(maxChars: number | undefined): number { + const candidate = maxChars ?? DEFAULT_TOOL_OUTPUT_MAX_CHARS; + if (!Number.isFinite(candidate)) return DEFAULT_TOOL_OUTPUT_MAX_CHARS; + return Math.max(1, Math.floor(candidate)); +} + +/** Clamp one text result, reserving the canonical truncation marker inside its cap. */ +export function clampToolOutputText( + text: string, + opts: { maxChars?: number; hint?: string } = {}, +): string { + const maxChars = normalizeMaxChars(opts.maxChars); + if (text.length <= maxChars) return text; + + const marker = buildToolOutputTruncationMarker(opts.hint); + // A re-clamp at the same or a larger budget must not add a second marker. + if (text.endsWith(marker)) return text.slice(0, maxChars); + if (maxChars <= marker.length) return marker.slice(0, maxChars); + return text.slice(0, maxChars - marker.length) + marker; +} + +/** + * Clamp all text blocks in one result as a single budget, allocating retained text + * in document order. Non-text blocks are deliberately handled by the engine wrapper. + */ +export function clampToolOutputBlocks( + texts: readonly (string | undefined)[], + opts: { maxChars?: number; hint?: string } = {}, +): string[] { + const normalized = texts.map((text) => typeof text === "string" ? text : ""); + const maxChars = normalizeMaxChars(opts.maxChars); + const total = normalized.reduce((sum, text) => sum + text.length, 0); + if (total <= maxChars) return normalized; + + const marker = buildToolOutputTruncationMarker(opts.hint); + const joined = normalized.join(""); + if (joined.endsWith(marker)) { + let remaining = maxChars; + return normalized.map((text) => { + const retained = text.slice(0, remaining); + remaining -= retained.length; + return retained; + }); + } + + // Reserve the marker before allocating any source text. This also preserves the + // marker when overflow begins exactly at a text-block boundary. + const sourceBudget = Math.max(0, maxChars - marker.length); + let remaining = sourceBudget; + let markerWritten = false; + return normalized.map((text) => { + if (markerWritten) return ""; + if (text.length <= remaining) { + remaining -= text.length; + return text; + } + markerWritten = true; + return text.slice(0, remaining) + marker.slice(0, maxChars - sourceBudget); + }); +} + +/** Resolve an optional named override; every valid result remains finitely bounded. */ +export function resolveToolOutputBudget( + toolName: string, + overrides: Readonly> | undefined, +): number { + if (!overrides || !Object.prototype.hasOwnProperty.call(overrides, toolName)) { + return DEFAULT_TOOL_OUTPUT_MAX_CHARS; + } + const candidate = overrides[toolName]; + if (typeof candidate === "number" && Number.isFinite(candidate) && Number.isInteger(candidate) && candidate > 0) { + return candidate; + } + + const error = new Error(`Invalid tool output budget for ${toolName}; overrides must be finite positive integers.`); + if (process.env.NODE_ENV === "production") { + console.warn(error.message); + return DEFAULT_TOOL_OUTPUT_MAX_CHARS; + } + throw error; +} diff --git a/packages/engine/src/__tests__/agent-artifact-tools.test.ts b/packages/engine/src/__tests__/agent-artifact-tools.test.ts index df50cff180..445db1500d 100644 --- a/packages/engine/src/__tests__/agent-artifact-tools.test.ts +++ b/packages/engine/src/__tests__/agent-artifact-tools.test.ts @@ -761,6 +761,16 @@ describe("artifact view tool", () => { expect(getText(result)).toContain("Inline markdown body"); }); + it("caps long inline content while retaining artifact identity and a narrowing hint", async () => { + const { store, getArtifact } = createMockStore(); + getArtifact.mockResolvedValue(createMockArtifact({ id: "art-long", content: "x".repeat(20_000) })); + + const result = await runTool(createArtifactViewTool(store), "call-view-long", { id: "art-long" }); + expect(getText(result).length).toBeLessThanOrEqual(12_000); + expect(getText(result)).toContain("Artifact: Implementation notes"); + expect(getText(result)).toContain("focused artifact read"); + }); + it("renders binary uri artifacts", async () => { const { store, getArtifact } = createMockStore(); getArtifact.mockResolvedValue(createMockArtifact({ diff --git a/packages/engine/src/__tests__/agent-document-tools.test.ts b/packages/engine/src/__tests__/agent-document-tools.test.ts index ad9583481b..0a192a63c3 100644 --- a/packages/engine/src/__tests__/agent-document-tools.test.ts +++ b/packages/engine/src/__tests__/agent-document-tools.test.ts @@ -232,6 +232,16 @@ describe("task_document_read tool", () => { expect(getText(result)).toContain("Detailed execution checklist"); }); + it("caps a long document while retaining its identity and a narrowing hint", async () => { + const { store, getTaskDocument } = createMockStore(); + getTaskDocument.mockResolvedValue(createMockDocument({ key: "plan", content: "x".repeat(20_000) })); + + const result = await runTool(createTaskDocumentReadTool(store, TASK_ID), "call-long", { key: "plan" }); + expect(getText(result).length).toBeLessThanOrEqual(12_000); + expect(getText(result)).toContain("Document: plan"); + expect(getText(result)).toContain("read a narrower document"); + }); + it("returns not found message when the requested key does not exist", async () => { const { store, getTaskDocument } = createMockStore(); getTaskDocument.mockResolvedValue(null); diff --git a/packages/engine/src/__tests__/agent-task-logs-read-tools.test.ts b/packages/engine/src/__tests__/agent-task-logs-read-tools.test.ts index 8f4707ece7..f24f0e88b3 100644 --- a/packages/engine/src/__tests__/agent-task-logs-read-tools.test.ts +++ b/packages/engine/src/__tests__/agent-task-logs-read-tools.test.ts @@ -62,6 +62,14 @@ describe("fn_task_logs_read", () => { expect(text).toContain("\n\n["); }); + it("caps long logs while retaining the paging header and narrowing hint", async () => { + const { store } = storeWith([entry("x".repeat(20_000), "tool_result")]); + const result = await run(createTaskLogsReadTool(store, TASK_ID), { limit: 1 }); + expect(result.content[0].text.length).toBeLessThanOrEqual(12_000); + expect(result.content[0].text).toContain("Agent log: 1/1 entries"); + expect(result.content[0].text).toContain("smaller limit, offset, or type filter"); + }); + it("requires task_id in chat and reads the named task", async () => { const { store, getAgentLogs } = storeWith([entry("chat", "status")]); const tool = createChatTaskLogsReadTool(store); diff --git a/packages/engine/src/__tests__/tool-output-budget-wrapper.test.ts b/packages/engine/src/__tests__/tool-output-budget-wrapper.test.ts new file mode 100644 index 0000000000..eaf5c04d54 --- /dev/null +++ b/packages/engine/src/__tests__/tool-output-budget-wrapper.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_TOOL_OUTPUT_MAX_CHARS, buildToolOutputTruncationMarker } from "@fusion/core"; +import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { wrapCustomToolsForPluginRuntime } from "../agent-session-helpers.js"; +import { wrapToolsWithOutputBudget } from "../pi.js"; + +function toolWithResult(result: unknown, name = "fn_budget_test"): ToolDefinition { + return { name, label: name, description: name, parameters: {} as never, execute: async () => result } as ToolDefinition; +} + +async function execute(tool: ToolDefinition): Promise { + return (tool.execute as any)("call", {}, undefined); +} + +describe("tool output budget wrapper", () => { + it("enforces a single total cap across text blocks while preserving mixed blocks and details", async () => { + const details = { nested: { untouched: true } }; + const original = { + content: [ + { type: "text", text: "a".repeat(12_000) }, + { type: "image", data: "unchanged" }, + { type: "text", text: "b".repeat(12_000) }, + { type: "text", text: "c".repeat(100) }, + ], + details, + isError: true, + }; + const result = await execute(wrapToolsWithOutputBudget([toolWithResult(original)])[0]); + const texts = result.content.filter((block: any) => block.type === "text").map((block: any) => block.text); + expect(texts.join("").length).toBeLessThanOrEqual(DEFAULT_TOOL_OUTPUT_MAX_CHARS); + expect(texts[1]).toContain("Tool output truncated"); + expect(texts[2]).toBe(""); + expect(result.content[1]).toEqual(original.content[1]); + expect(result.details).toBe(details); + expect(result.isError).toBe(true); + expect(texts[0]).not.toBe(""); + }); + + it("preserves empty and undefined text, and puts a boundary overflow marker in document order", async () => { + const marker = buildToolOutputTruncationMarker(); + const result = await execute(wrapToolsWithOutputBudget([ + toolWithResult({ content: [{ type: "text", text: "abcd" }, { type: "text", text: "z".repeat(200) }, { type: "text", text: undefined }] }), + ], { overrides: { fn_budget_test: marker.length + 4 } })[0]); + expect(result.content.map((block: any) => block.text)).toEqual(["abcd", marker, ""]); + }); + + it("honors finite larger and smaller overrides, rejects invalid values, and does not double-mark", async () => { + const source = "x".repeat(200); + const large = await execute(wrapToolsWithOutputBudget([toolWithResult({ content: [{ type: "text", text: source }] })], { overrides: { fn_budget_test: 300 } })[0]); + const small = await execute(wrapToolsWithOutputBudget([toolWithResult({ content: [{ type: "text", text: source }] })], { overrides: { fn_budget_test: 100 } })[0]); + expect(large.content[0].text).toBe(source); + expect(small.content[0].text.length).toBeLessThanOrEqual(100); + await expect(execute(wrapToolsWithOutputBudget([toolWithResult({ content: [{ type: "text", text: source }] })], { overrides: { fn_budget_test: Infinity } })[0])).rejects.toThrow(/finite positive integers/); + const once = wrapToolsWithOutputBudget([toolWithResult({ content: [{ type: "text", text: source }] })], { overrides: { fn_budget_test: 100 } }); + const twice = wrapToolsWithOutputBudget(once, { overrides: { fn_budget_test: 100 } }); + const result = await execute(twice[0]); + expect(result.content[0].text.match(/Tool output truncated/g)).toHaveLength(1); + }); + + it("applies the same clamp on the non-pi plugin-runtime path exactly once", async () => { + const result = await execute(wrapCustomToolsForPluginRuntime([ + toolWithResult({ content: [{ type: "text", text: "x".repeat(DEFAULT_TOOL_OUTPUT_MAX_CHARS + 1) }] }), + ], {})![0]); + expect(result.content[0].text.length).toBeLessThanOrEqual(DEFAULT_TOOL_OUTPUT_MAX_CHARS); + expect(result.content[0].text.match(/Tool output truncated/g)).toHaveLength(1); + }); +}); diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 393fa0888c..3ff7a546e0 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -37,6 +37,7 @@ import { isRetryableModelSelectionError, wrapToolsWithActionGate, wrapToolsWithPermanentAgentGating, + wrapToolsWithOutputBudget, wrapToolsWithRtkRewrite, type FallbackModelUsedPayload, } from "./pi.js"; @@ -95,7 +96,11 @@ export function wrapCustomToolsForPluginRuntime( } const withRtk = wrapToolsWithRtkRewrite(tools); const withPermanent = wrapToolsWithPermanentAgentGating(withRtk, options.permanentAgentGating); - return wrapToolsWithActionGate(withPermanent, options.actionGateContext); + const withActionGate = wrapToolsWithActionGate(withPermanent, options.actionGateContext); + // FNXC:ToolOutputBudget 2026-08-06-12:00: + // Non-pi runtimes do not pass through createFnAgent, so apply the same outermost + // per-result clamp here exactly once rather than letting plugin tool output bypass it. + return wrapToolsWithOutputBudget(withActionGate); } function shouldWrapCustomToolsForRuntime(runtimeId: string): boolean { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index eddf4fd4f4..c324389337 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -1605,6 +1605,20 @@ function formatTaskReadLines(lines: string[], emptyStateText: string): string { return text.trim().length > 0 ? text : emptyStateText; } +/* +FNXC:ToolOutputBudget 2026-08-06-12:00: +FN-8614 requires high-volume read tools to preserve their identifying headers while +providing a useful source-level stop before the universal per-result wrapper runs. +The hint names the narrowing surface instead of silently tail-cutting an agent's context. +*/ +const SEMANTIC_TOOL_READ_MAX_CHARS = 12_000; + +function trimSemanticToolRead(text: string, hint: string): string { + if (text.length <= SEMANTIC_TOOL_READ_MAX_CHARS) return text; + const marker = `\n\n[Output truncated; ${hint}]`; + return text.slice(0, Math.max(0, SEMANTIC_TOOL_READ_MAX_CHARS - marker.length)) + marker; +} + function formatTaskSummaryLine(task: { id: string; column: string; title?: string | null; description: string; dependencies: string[] }): string { const desc = task.title || task.description.slice(0, 80) || "(no description)"; const deps = task.dependencies.length ? ` [deps: ${task.dependencies.join(", ")}]` : ""; @@ -1699,7 +1713,13 @@ export function createTaskShowTool(store: TaskStore): ToolDefinition { task.prompt || "(not yet specified)", ].filter((part): part is string => typeof part === "string"); return { - content: [{ type: "text" as const, text: parts.join("\n") || `Task ${params.id} has no details.` }], + content: [{ + type: "text" as const, + text: trimSemanticToolRead( + parts.join("\n") || `Task ${params.id} has no details.`, + "use fn_task_document_read or a focused task query for more", + ), + }], details: { taskId: task.id }, }; } catch { @@ -1831,7 +1851,11 @@ async function readTaskAgentLogs( ]); const filter = params.type ? `, type=${params.type}` : ""; const header = `Agent log: ${entries.length}/${total} entries (limit=${limit}, offset=${offset}${filter})`; - return { content: [{ type: "text" as const, text: entries.length > 0 ? `${header}\n\n${renderAgentLogEntries(entries)}` : `${header}\n\n(no matching log entries)` }], details: { taskId, total, limit, offset, type: params.type } }; + const text = entries.length > 0 ? `${header}\n\n${renderAgentLogEntries(entries)}` : `${header}\n\n(no matching log entries)`; + return { + content: [{ type: "text" as const, text: trimSemanticToolRead(text, "use a smaller limit, offset, or type filter for more") }], + details: { taskId, total, limit, offset, type: params.type }, + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { return { content: [{ type: "text" as const, text: `ERROR: Failed to read agent log for task ${taskId}: ${err.message}` }], details: {} }; @@ -2633,7 +2657,10 @@ async function viewArtifactForAgent(store: TaskStore, id: string) { if (artifact.content) lines.push("", artifact.content); return { - content: [{ type: "text" as const, text: lines.join("\n") }], + content: [{ + type: "text" as const, + text: trimSemanticToolRead(lines.join("\n"), "use artifact metadata or a more focused artifact read for more"), + }], details: { artifactId: artifact.id }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -2662,11 +2689,13 @@ async function readTaskDocuments(store: TaskStore, taskId: string, key?: string) return { content: [{ type: "text" as const, - text: + text: trimSemanticToolRead( `Document: ${document.key}\n` + - `Revision: ${document.revision}\n` + - `Updated: ${document.updatedAt}\n\n` + - document.content, + `Revision: ${document.revision}\n` + + `Updated: ${document.updatedAt}\n\n` + + document.content, + "read a narrower document or use its revision metadata before requesting more", + ), }], details: {}, }; diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 5fca5e3a78..275d59e75e 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -36,6 +36,7 @@ import { type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { + clampToolOutputBlocks, customProviderRegistryKey, getEnabledPiExtensionPaths, getFusionAgentDir, @@ -50,6 +51,7 @@ import { registerBuiltInGrokProvider, registerBuiltInZaiProvider, resolvePiExtensionProjectRoot, + resolveToolOutputBudget, } from "@fusion/core"; import type { AgentPermissionPolicyActionCategory, @@ -1873,6 +1875,53 @@ export function wrapToolsWithBoundary( }); } +/* +FNXC:ToolOutputBudget 2026-08-06-12:00: +FN-8614 requires one finite budget for the total model-visible text in every +engine-injected tool result. Overrides may choose only another finite positive +integer cap; there is deliberately no unbounded sentinel or opt-out. The audit +found no legitimate result that needs more than the shared 16,000-character cap. +*/ +export const TOOL_OUTPUT_BUDGET_OVERRIDES: Readonly> = {}; + +/** + * Enforce the shared per-result text budget without changing result metadata or + * non-text content blocks. This is intentionally the outermost pi wrapper. + */ +export function wrapToolsWithOutputBudget( + tools: ToolDefinition[], + options: { overrides?: Readonly> } = {}, +): ToolDefinition[] { + return tools.map((tool) => { + const originalExecute = tool.execute as any; + return { + ...tool, + execute: async (...args: any[]) => { + const result = await originalExecute(...args); + if (!result || !Array.isArray(result.content)) return result; + + const textPositions: number[] = []; + const texts: (string | undefined)[] = []; + result.content.forEach((block: { type?: unknown; text?: unknown }, index: number) => { + if (block?.type === "text") { + textPositions.push(index); + texts.push(typeof block.text === "string" ? block.text : undefined); + } + }); + if (textPositions.length === 0) return result; + + const budget = resolveToolOutputBudget(tool.name, options.overrides ?? TOOL_OUTPUT_BUDGET_OVERRIDES); + const clamped = clampToolOutputBlocks(texts, { maxChars: budget }); + const content = result.content.map((block: unknown) => block); + textPositions.forEach((position, index) => { + content[position] = { ...(content[position] as Record), text: clamped[index] }; + }); + return { ...result, content }; + }, + }; + }); +} + export function wrapToolsWithRtkRewrite( tools: ToolDefinition[], options: RtkRewriteOptions = resolveRtkRewriteOptions(), @@ -2519,12 +2568,15 @@ export async function createFnAgent(options: AgentOptions): Promise toolsWithPermanentGating, options.actionGateContext, ); - const customToolList: ToolDefinition[] = wrapToolsWithBoundary( + const boundaryWrappedTools = wrapToolsWithBoundary( toolsWithActionGate, boundaryContext.worktreePath, boundaryContext.worktreeProjectRoot, normalizedAdditionalSkillPaths, ); + // FNXC:ToolOutputBudget 2026-08-06-12:00: + // Keep this outermost so policy-gate and boundary rejection text is bounded too. + const customToolList: ToolDefinition[] = wrapToolsWithOutputBudget(boundaryWrappedTools); // Sort tools alphabetically by name for deterministic ordering. // Prompt caching requires the tool list to be byte-identical across // sessions — reordering breaks cache prefix matching.