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:
7
.changeset/fn-8614-tool-output-budget.md
Normal file
7
.changeset/fn-8614-tool-output-budget.md
Normal 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.
|
||||
@@ -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.
|
||||
- `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.
|
||||
|
||||
58
packages/core/src/__tests__/tool-output-budget.test.ts
Normal file
58
packages/core/src/__tests__/tool-output-budget.test.ts
Normal 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
97
packages/core/src/tool-output-budget.ts
Normal file
97
packages/core/src/tool-output-budget.ts
Normal 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -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<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(
|
||||
tools: ToolDefinition[],
|
||||
options: RtkRewriteOptions = resolveRtkRewriteOptions(),
|
||||
@@ -2519,12 +2568,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user