FN-8614: cap agent tool output

Bound every engine-injected tool result to preserve agent context capacity.

- Add shared 16,000-character total text budgets with deterministic truncation markers and validated overrides.
- Apply outermost output clamps to Pi and non-Pi plugin tool paths, with semantic caps for high-volume reads.
- Cover budget behavior and document the operator-facing configuration contract.

Files changed:
 .changeset/fn-8614-tool-output-budget.md           |  7 ++
 docs/agents.md                                     |  8 ++
 .../core/src/__tests__/tool-output-budget.test.ts  | 58 +++++++++++++
 packages/core/src/index.gate.ts                    |  7 ++
 packages/core/src/index.ts                         |  7 ++
 packages/core/src/tool-output-budget.ts            | 97 ++++++++++++++++++++++
 .../src/__tests__/agent-artifact-tools.test.ts     | 10 +++
 .../src/__tests__/agent-document-tools.test.ts     | 10 +++
 .../__tests__/agent-task-logs-read-tools.test.ts   |  8 ++
 .../__tests__/tool-output-budget-wrapper.test.ts   | 67 +++++++++++++++
 packages/engine/src/agent-session-helpers.ts       |  7 +-
 packages/engine/src/agent-tools.ts                 | 43 ++++++++--
 packages/engine/src/pi.ts                          | 54 +++++++++++-
 13 files changed, 374 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-8614

Fusion-Task-Lineage: b6a76ccd-d7b4-4b43-af7e-cfd16ffb7fc8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-26 14:42:06 -07:00
parent a6885b73f2
commit 07c8c95b10
13 changed files with 374 additions and 9 deletions

View File

@@ -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.

View File

@@ -48,6 +48,14 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--
- `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. - `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. - `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 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. 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.

View File

@@ -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/);
}
});
});

View File

@@ -105,6 +105,13 @@ export type {
RankAssignedTasksForWakeDeltaResult, RankAssignedTasksForWakeDeltaResult,
} from "./assigned-task-ranking.js"; } from "./assigned-task-ranking.js";
export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.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 { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export { export {

View File

@@ -119,6 +119,13 @@ export * from "./original-description-policy.js";
export * from "./planning-plan-md.js"; export * from "./planning-plan-md.js";
export * from "./file-scope-classification.js"; export * from "./file-scope-classification.js";
export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.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 { export {
WAKE_DELTA_ASSIGNED_TASKS_CAP, WAKE_DELTA_ASSIGNED_TASKS_CAP,
rankAssignedTasksForWakeDelta, rankAssignedTasksForWakeDelta,

View File

@@ -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<Record<string, number | null | undefined>> | 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;
}

View File

@@ -761,6 +761,16 @@ describe("artifact view tool", () => {
expect(getText(result)).toContain("Inline markdown body"); 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 () => { it("renders binary uri artifacts", async () => {
const { store, getArtifact } = createMockStore(); const { store, getArtifact } = createMockStore();
getArtifact.mockResolvedValue(createMockArtifact({ getArtifact.mockResolvedValue(createMockArtifact({

View File

@@ -232,6 +232,16 @@ describe("task_document_read tool", () => {
expect(getText(result)).toContain("Detailed execution checklist"); 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 () => { it("returns not found message when the requested key does not exist", async () => {
const { store, getTaskDocument } = createMockStore(); const { store, getTaskDocument } = createMockStore();
getTaskDocument.mockResolvedValue(null); getTaskDocument.mockResolvedValue(null);

View File

@@ -62,6 +62,14 @@ describe("fn_task_logs_read", () => {
expect(text).toContain("\n\n["); 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 () => { it("requires task_id in chat and reads the named task", async () => {
const { store, getAgentLogs } = storeWith([entry("chat", "status")]); const { store, getAgentLogs } = storeWith([entry("chat", "status")]);
const tool = createChatTaskLogsReadTool(store); const tool = createChatTaskLogsReadTool(store);

View File

@@ -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<any> {
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);
});
});

View File

@@ -37,6 +37,7 @@ import {
isRetryableModelSelectionError, isRetryableModelSelectionError,
wrapToolsWithActionGate, wrapToolsWithActionGate,
wrapToolsWithPermanentAgentGating, wrapToolsWithPermanentAgentGating,
wrapToolsWithOutputBudget,
wrapToolsWithRtkRewrite, wrapToolsWithRtkRewrite,
type FallbackModelUsedPayload, type FallbackModelUsedPayload,
} from "./pi.js"; } from "./pi.js";
@@ -95,7 +96,11 @@ export function wrapCustomToolsForPluginRuntime(
} }
const withRtk = wrapToolsWithRtkRewrite(tools); const withRtk = wrapToolsWithRtkRewrite(tools);
const withPermanent = wrapToolsWithPermanentAgentGating(withRtk, options.permanentAgentGating); 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 { function shouldWrapCustomToolsForRuntime(runtimeId: string): boolean {

View File

@@ -1605,6 +1605,20 @@ function formatTaskReadLines(lines: string[], emptyStateText: string): string {
return text.trim().length > 0 ? text : emptyStateText; 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 { 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 desc = task.title || task.description.slice(0, 80) || "(no description)";
const deps = task.dependencies.length ? ` [deps: ${task.dependencies.join(", ")}]` : ""; const deps = task.dependencies.length ? ` [deps: ${task.dependencies.join(", ")}]` : "";
@@ -1699,7 +1713,13 @@ export function createTaskShowTool(store: TaskStore): ToolDefinition {
task.prompt || "(not yet specified)", task.prompt || "(not yet specified)",
].filter((part): part is string => typeof part === "string"); ].filter((part): part is string => typeof part === "string");
return { 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 }, details: { taskId: task.id },
}; };
} catch { } catch {
@@ -1831,7 +1851,11 @@ async function readTaskAgentLogs(
]); ]);
const filter = params.type ? `, type=${params.type}` : ""; const filter = params.type ? `, type=${params.type}` : "";
const header = `Agent log: ${entries.length}/${total} entries (limit=${limit}, offset=${offset}${filter})`; 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
return { content: [{ type: "text" as const, text: `ERROR: Failed to read agent log for task ${taskId}: ${err.message}` }], details: {} }; 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); if (artifact.content) lines.push("", artifact.content);
return { 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 }, details: { artifactId: artifact.id },
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2662,11 +2689,13 @@ async function readTaskDocuments(store: TaskStore, taskId: string, key?: string)
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
text: text: trimSemanticToolRead(
`Document: ${document.key}\n` + `Document: ${document.key}\n` +
`Revision: ${document.revision}\n` + `Revision: ${document.revision}\n` +
`Updated: ${document.updatedAt}\n\n` + `Updated: ${document.updatedAt}\n\n` +
document.content, document.content,
"read a narrower document or use its revision metadata before requesting more",
),
}], }],
details: {}, details: {},
}; };

View File

@@ -36,6 +36,7 @@ import {
type ToolDefinition, type ToolDefinition,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { import {
clampToolOutputBlocks,
customProviderRegistryKey, customProviderRegistryKey,
getEnabledPiExtensionPaths, getEnabledPiExtensionPaths,
getFusionAgentDir, getFusionAgentDir,
@@ -50,6 +51,7 @@ import {
registerBuiltInGrokProvider, registerBuiltInGrokProvider,
registerBuiltInZaiProvider, registerBuiltInZaiProvider,
resolvePiExtensionProjectRoot, resolvePiExtensionProjectRoot,
resolveToolOutputBudget,
} from "@fusion/core"; } from "@fusion/core";
import type { import type {
AgentPermissionPolicyActionCategory, 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<Record<string, number>> = {};
/**
* 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<Record<string, number | null | undefined>> } = {},
): 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<string, unknown>), text: clamped[index] };
});
return { ...result, content };
},
};
});
}
export function wrapToolsWithRtkRewrite( export function wrapToolsWithRtkRewrite(
tools: ToolDefinition[], tools: ToolDefinition[],
options: RtkRewriteOptions = resolveRtkRewriteOptions(), options: RtkRewriteOptions = resolveRtkRewriteOptions(),
@@ -2519,12 +2568,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
toolsWithPermanentGating, toolsWithPermanentGating,
options.actionGateContext, options.actionGateContext,
); );
const customToolList: ToolDefinition[] = wrapToolsWithBoundary( const boundaryWrappedTools = wrapToolsWithBoundary(
toolsWithActionGate, toolsWithActionGate,
boundaryContext.worktreePath, boundaryContext.worktreePath,
boundaryContext.worktreeProjectRoot, boundaryContext.worktreeProjectRoot,
normalizedAdditionalSkillPaths, 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. // Sort tools alphabetically by name for deterministic ordering.
// Prompt caching requires the tool list to be byte-identical across // Prompt caching requires the tool list to be byte-identical across
// sessions — reordering breaks cache prefix matching. // sessions — reordering breaks cache prefix matching.