From b14a01c8ddd3a6dd59546d644168ae0c59b0b06d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 00:01:26 -0700 Subject: [PATCH 1/2] Spill oversized tool results without a name allowlist Fleet verbs are AgentTools and never entered the posix truncation plugin, so an allowlist of posix names could not cover wait_agents or search_agents. Leisure now applies to every non-error result over the gate, and the same helper wraps AgentTools at mount. --- docs/ARCHITECTURE.md | 2 +- src/agent/tools.ts | 23 ++- src/plugins/result-truncation-plugin.test.ts | 159 +++++++++++++++++++ src/plugins/result-truncation-plugin.ts | 145 +++++++++++------ src/subagent/run.ts | 3 + 5 files changed, 278 insertions(+), 54 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 44ccdb55c..b96ff740a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -363,7 +363,7 @@ tool call **Rejection behavior:** Any plugin can short-circuit by returning a `ToolResult` with `isError: true`; the error propagates to the agent and downstream plugins/execution are skipped. -- **Result truncation / leisure materialization** (`result-truncation-plugin.ts`, `tool-result-materialize.ts`) — Caps model-facing tool results at 10,000 chars (aligned with the reactor size-cap). Over the gate, content is leisure-materialized first (minified JSON → pretty `application/json`; NDJSON preserved; else `text/plain`), then the formatted bytes are spilled to the session blob store under `{callId}:full` and truncated inline with a `tool-output:///` URI plus absolute `contextDir/tool-output/…` path when plumbed. Under-gate results are unchanged (no pretty, no spill). MCP tools apply the same scrub-then-truncate path via `mcpClientToAgentTools` since they skip the posix middleware chain. +- **Result truncation / leisure materialization** (`result-truncation-plugin.ts`, `tool-result-materialize.ts`) — Caps model-facing tool results at 10,000 chars (aligned with the reactor size-cap). Over the gate, any non-error result is leisure-materialized first (minified JSON → pretty `application/json`; NDJSON preserved; else `text/plain`), then the formatted bytes are spilled to the session blob store under `{callId}:full` and truncated inline with a `tool-output:///` URI plus absolute `contextDir/tool-output/…` path when plumbed. Under-gate results are unchanged (no pretty, no spill). Posix tools go through the middleware in `buildCorePosixToolPlugins` (Codex `posixTools.run` included). Fleet AgentTools (`wait_agents`, `search_agents`, …) skip that posix chain, so the same helper wraps them at mount in `createAgentToolset` and nested `runSubAgent`. MCP tools apply the same scrub-then-truncate path via `mcpClientToAgentTools` since they skip both. - **Path Escape** (`path-escape-plugin.ts`) — Canonicalizes path-like arguments against `cwd` and blocks `..` escapes, except into a root the permission layer's worktree-roots provider allowlists (e.g. a sibling git worktree of the same repo). Runs first so later plugins see resolved paths. - **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched). - **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output. diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 542ad9690..c819dabbf 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -21,7 +21,11 @@ import type { PermissionGate } from "../permission/gate.js"; import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js"; import { createLazyBlobReader } from "./lazy-blob-reader.js"; import type { BlobReader } from "@intx/types/runtime"; -import type { SpillBlobWriter } from "../plugins/result-truncation-plugin.js"; +import { + wrapAgentToolResultTruncation, + wrapAgentToolsWithResultTruncation, + type SpillBlobWriter, +} from "../plugins/result-truncation-plugin.js"; import { connectMCPServer as connectMCPClient, type MCPClient, @@ -413,6 +417,10 @@ export async function createAgentToolset( ); } + const truncationOptions = { + ...(getBlobWriter !== undefined ? { getBlobWriter } : {}), + ...(getContextDir !== undefined ? { getContextDir } : {}), + }; const posixTools = createPosixTools({ cwd, ...(sessionBlobReader !== undefined @@ -426,8 +434,7 @@ export async function createAgentToolset( ...(sessionBlobReader !== undefined ? { readFileGuard: { blobReader: sessionBlobReader } } : {}), - ...(getBlobWriter !== undefined ? { getBlobWriter } : {}), - ...(getContextDir !== undefined ? { getContextDir } : {}), + ...truncationOptions, ...(shellEnv !== undefined ? { shellEnv } : {}), getBackgroundShellRegistry: () => backgroundShells, }), @@ -698,8 +705,9 @@ export async function createAgentToolset( }), ); - const primaryTools = baseTools.filter( - (tool) => tool.definition.name !== "apply_patch", + const primaryTools = wrapAgentToolsWithResultTruncation( + baseTools.filter((tool) => tool.definition.name !== "apply_patch"), + truncationOptions, ); const dynamicRunner = createDynamicToolRunner(primaryTools, toolWatchdog); @@ -796,9 +804,10 @@ export async function createAgentToolset( }; const mountWebFetch = (tool: AgentTool): void => { + const wrapped = wrapAgentToolResultTruncation(tool, truncationOptions); dynamicRunner.removeTools(["web_fetch"]); - dynamicRunner.addTools([tool]); - replaceInheritedTool("web_fetch", tool); + dynamicRunner.addTools([wrapped]); + replaceInheritedTool("web_fetch", wrapped); }; const swapBuiltinExaToNative = (): void => { diff --git a/src/plugins/result-truncation-plugin.test.ts b/src/plugins/result-truncation-plugin.test.ts index cf2ea7b11..cfb8e1cd7 100644 --- a/src/plugins/result-truncation-plugin.test.ts +++ b/src/plugins/result-truncation-plugin.test.ts @@ -11,6 +11,7 @@ import { resultTruncationPlugin, spillBlobKey, truncateToolResultContent, + wrapAgentToolResultTruncation, } from "./result-truncation-plugin.js"; import { toolOutputAbsolutePath } from "./tool-result-materialize.js"; import { CREDENTIAL_REDACTION } from "./tool-result-secret-scrub.js"; @@ -373,6 +374,164 @@ describe("resultTruncationPlugin", () => { expect(result.content).toEqual(record); expect(store.blobs.size).toBe(0); }); + + test("spills oversized minified fleet JSON for wait_agents and list_agents", async () => { + const store = fakeBlobStore(); + const obj = { + results: Array.from({ length: 80 }, (_, i) => ({ + agent_id: `agent-${i}`, + report: "x".repeat(200), + })), + timed_out: false, + }; + const minified = JSON.stringify(obj); + expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS); + const pretty = JSON.stringify(obj, null, 2); + const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob }); + if (plugin.middleware === undefined) throw new Error("expected middleware"); + const middleware = plugin.middleware(async (call) => ({ + callId: call.id, + content: minified, + })); + + for (const name of ["wait_agents", "list_agents"] as const) { + const callId = `call-${name}`; + const result = await middleware( + { id: callId, name, arguments: {} }, + new AbortController().signal, + ); + expect(typeof result.content).toBe("string"); + expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); + const uri = `tool-output:///${spillBlobKey(callId)}`; + expect(String(result.content)).toContain(uri); + const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + expect(recovered).toBe(pretty); + } + }); + + test("spills an oversized search_agents string payload", async () => { + const store = fakeBlobStore(); + const original = `Matching agent profiles:\n\n${"body ".repeat(MAX_RESULT_CHARS)}`; + expect(original.length).toBeGreaterThan(MAX_RESULT_CHARS); + const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob }); + if (plugin.middleware === undefined) throw new Error("expected middleware"); + const middleware = plugin.middleware(async (call) => ({ + callId: call.id, + content: original, + })); + const result = await middleware( + { id: "call-search", name: "search_agents", arguments: {} }, + new AbortController().signal, + ); + expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); + const uri = `tool-output:///${spillBlobKey("call-search")}`; + expect(String(result.content)).toContain(uri); + const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + expect(recovered).toBe(original); + }); + + test("does not truncate isError results even when over the gate", async () => { + const store = fakeBlobStore(); + const original = `Error: ${"x".repeat(MAX_RESULT_CHARS + 500)}`; + const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob }); + if (plugin.middleware === undefined) throw new Error("expected middleware"); + const middleware = plugin.middleware(async (call) => ({ + callId: call.id, + content: original, + isError: true, + })); + const result = await middleware( + { id: "call-err", name: "wait_agents", arguments: {} }, + new AbortController().signal, + ); + expect(result.content).toBe(original); + expect(result.isError).toBe(true); + expect(store.blobs.size).toBe(0); + }); +}); + +describe("wrapAgentToolResultTruncation", () => { + test("spills oversized wait_agents JSON from a kind:full handler", async () => { + const store = fakeBlobStore(); + const payload = { results: [{ report: "x".repeat(MAX_RESULT_CHARS + 500) }] }; + const minified = JSON.stringify(payload); + expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS); + const pretty = JSON.stringify(payload, null, 2); + const wrapped = wrapAgentToolResultTruncation( + { + kind: "full", + definition: { + name: "wait_agents", + description: "wait", + inputSchema: { type: "object" }, + }, + handler: async (call) => ({ callId: call.id, content: minified }), + }, + { getBlobWriter: () => store.writeBlob }, + ); + if (wrapped.kind !== "full") throw new Error("expected full tool"); + const result = await wrapped.handler( + { id: "call-wrap-wait", name: "wait_agents", arguments: {} }, + new AbortController().signal, + ); + expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); + const uri = `tool-output:///${spillBlobKey("call-wrap-wait")}`; + expect(String(result.content)).toContain(uri); + const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + expect(recovered).toBe(pretty); + }); + + test("spills oversized search_agents string from a kind:string handler", async () => { + const store = fakeBlobStore(); + const original = `Matching agent profiles:\n\n${"z".repeat(MAX_RESULT_CHARS + 500)}`; + const wrapped = wrapAgentToolResultTruncation( + { + kind: "string", + definition: { + name: "search_agents", + description: "search", + inputSchema: { type: "object" }, + }, + handler: async () => original, + }, + { getBlobWriter: () => store.writeBlob }, + ); + if (wrapped.kind !== "full") throw new Error("expected full wrapper so spill can use callId"); + const result = await wrapped.handler( + { id: "call-wrap-search", name: "search_agents", arguments: {} }, + new AbortController().signal, + ); + expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); + const uri = `tool-output:///${spillBlobKey("call-wrap-search")}`; + expect(String(result.content)).toContain(uri); + const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + expect(recovered).toBe(original); + }); + + test("does not truncate isError results from a kind:full handler", async () => { + const store = fakeBlobStore(); + const original = `Error: ${"e".repeat(MAX_RESULT_CHARS + 500)}`; + const wrapped = wrapAgentToolResultTruncation( + { + kind: "full", + definition: { + name: "wait_agents", + description: "wait", + inputSchema: { type: "object" }, + }, + handler: async (call) => ({ callId: call.id, content: original, isError: true }), + }, + { getBlobWriter: () => store.writeBlob }, + ); + if (wrapped.kind !== "full") throw new Error("expected full tool"); + const result = await wrapped.handler( + { id: "call-wrap-err", name: "wait_agents", arguments: {} }, + new AbortController().signal, + ); + expect(result.content).toBe(original); + expect(result.isError).toBe(true); + expect(store.blobs.size).toBe(0); + }); }); describe("scrub-before-spill", () => { diff --git a/src/plugins/result-truncation-plugin.ts b/src/plugins/result-truncation-plugin.ts index 7a0600d0d..6362647da 100644 --- a/src/plugins/result-truncation-plugin.ts +++ b/src/plugins/result-truncation-plugin.ts @@ -1,4 +1,6 @@ +import type { AgentTool } from "@intx/agent"; import type { ToolPlugin } from "@intx/tools-posix"; +import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { materializeToolResultContent, materializeToolResultRecord, @@ -7,14 +9,6 @@ import { } from "./tool-result-materialize.js"; import { scrubSecretShapedContent } from "./tool-result-secret-scrub.js"; -const TRUNCATABLE_TOOLS = new Set([ - "read_file", - "grep", - "run_shell", - "search_files", - "web_fetch", -]); - // Characters, not tokens — conversion ratio is roughly 4 chars/token. // Match the reactor's default size-cap (vendor/intx-inference assembly.ts) so // leisure materialization owns the spill of the pretty/full bytes under the @@ -224,50 +218,109 @@ export interface ResultTruncationPluginOptions { getContextDir?: () => string | undefined; } +function spillOptionsForCall( + callId: string, + options: ResultTruncationPluginOptions, +): TruncationSpillOptions | undefined { + const writeBlob = options.getBlobWriter?.(); + const contextDir = options.getContextDir?.(); + return writeBlob !== undefined + ? { + callId, + writeBlob, + ...(contextDir !== undefined ? { contextDir } : {}), + } + : undefined; +} + +/** + * Leisure-materialize and spill a tool result when its compact payload exceeds + * {@link MAX_RESULT_CHARS}. Error results are returned unchanged. Shared by the + * posix middleware and the AgentTool wrapper so fleet verbs (which never enter + * the posix plugin chain) take the same path. + */ +export async function applyToolResultTruncation( + result: ToolResult, + spill?: TruncationSpillOptions, +): Promise { + if (result.isError) return result; + + const { content } = result; + if (typeof content === "string") { + const truncated = await truncateToolResultContent( + content, + MAX_RESULT_CHARS, + spill, + ); + if (truncated === content) return result; + return { ...result, content: truncated }; + } + + if (content !== null && typeof content === "object") { + const record = content as Record; + const compact = JSON.stringify(record); + if (compact.length <= MAX_RESULT_CHARS) return result; + const truncated = await truncateToolResultRecord( + record, + MAX_RESULT_CHARS, + spill, + ); + return { ...result, content: truncated }; + } + + return result; +} + +/** + * Wrap an AgentTool so its result hits {@link applyToolResultTruncation}. + * `kind: "string"` handlers are lifted to `kind: "full"` so the spill can use + * the call id. Factories such as createSearchAgentsTool stay `kind: "string"` + * until mount. + */ +export function wrapAgentToolResultTruncation( + tool: AgentTool, + options: ResultTruncationPluginOptions = {}, +): AgentTool { + if (tool.kind === "full") { + const inner = tool.handler; + return { + ...tool, + handler: async (call: ToolCall, signal: AbortSignal) => + applyToolResultTruncation( + await inner(call, signal), + spillOptionsForCall(call.id, options), + ), + }; + } + const inner = tool.handler; + return { + kind: "full", + definition: tool.definition, + handler: async (call: ToolCall, signal: AbortSignal) => + applyToolResultTruncation( + { callId: call.id, content: await inner(call.arguments, signal) }, + spillOptionsForCall(call.id, options), + ), + }; +} + +export function wrapAgentToolsWithResultTruncation( + tools: readonly AgentTool[], + options: ResultTruncationPluginOptions = {}, +): AgentTool[] { + return tools.map((tool) => wrapAgentToolResultTruncation(tool, options)); +} + export function resultTruncationPlugin( options: ResultTruncationPluginOptions = {}, ): ToolPlugin { - const { getBlobWriter, getContextDir } = options; return { middleware: (next) => async (call, signal) => { const result = await next(call, signal); - if (!TRUNCATABLE_TOOLS.has(call.name) || result.isError) return result; - - const writeBlob = getBlobWriter?.(); - const contextDir = getContextDir?.(); - const spill = - writeBlob !== undefined - ? { - callId: call.id, - writeBlob, - ...(contextDir !== undefined ? { contextDir } : {}), - } - : undefined; - - const { content } = result; - if (typeof content === "string") { - const truncated = await truncateToolResultContent( - content, - MAX_RESULT_CHARS, - spill, - ); - if (truncated === content) return result; - return { ...result, content: truncated }; - } - - if (content !== null && typeof content === "object") { - const record = content as Record; - const compact = JSON.stringify(record); - if (compact.length <= MAX_RESULT_CHARS) return result; - const truncated = await truncateToolResultRecord( - record, - MAX_RESULT_CHARS, - spill, - ); - return { ...result, content: truncated }; - } - - return result; + return applyToolResultTruncation( + result, + spillOptionsForCall(call.id, options), + ); }, }; } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index d9cc3ecb9..017235f6f 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -49,6 +49,7 @@ import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js"; import { createWebFetchTool } from "../tools/web-fetch.js"; import { createWebSearchTool } from "../tools/web-search.js"; import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js"; +import { wrapAgentToolsWithResultTruncation } from "../plugins/result-truncation-plugin.js"; import { allowDeleteFromCapabilities, allowShellFromCapabilities, @@ -842,6 +843,8 @@ async function runSubAgentInner( ]; } + tools = wrapAgentToolsWithResultTruncation(tools); + const environment = await gatherEnvironment(params.cwd); const extensions = params.systemPromptRole !== undefined From 9aa2ab70561a13ead5755f7f2fd5620b0876ab7f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:13:45 -0700 Subject: [PATCH 2/2] Late-bind a blob writer for nested tool-result truncation Nested tools wrap before the child session store exists. Live getters bind the writer after createSessionStores so oversized fleet and web results spill to a fetchable tool-output URI instead of a no-store notice. --- src/plugins/result-truncation-plugin.test.ts | 99 +++++++++++++++++--- src/subagent/run.ts | 19 +++- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/plugins/result-truncation-plugin.test.ts b/src/plugins/result-truncation-plugin.test.ts index cfb8e1cd7..3ee409406 100644 --- a/src/plugins/result-truncation-plugin.test.ts +++ b/src/plugins/result-truncation-plugin.test.ts @@ -12,6 +12,8 @@ import { spillBlobKey, truncateToolResultContent, wrapAgentToolResultTruncation, + wrapAgentToolsWithResultTruncation, + type SpillBlobWriter, } from "./result-truncation-plugin.js"; import { toolOutputAbsolutePath } from "./tool-result-materialize.js"; import { CREDENTIAL_REDACTION } from "./tool-result-secret-scrub.js"; @@ -387,7 +389,9 @@ describe("resultTruncationPlugin", () => { const minified = JSON.stringify(obj); expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS); const pretty = JSON.stringify(obj, null, 2); - const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob }); + const plugin = resultTruncationPlugin({ + getBlobWriter: () => store.writeBlob, + }); if (plugin.middleware === undefined) throw new Error("expected middleware"); const middleware = plugin.middleware(async (call) => ({ callId: call.id, @@ -401,10 +405,14 @@ describe("resultTruncationPlugin", () => { new AbortController().signal, ); expect(typeof result.content).toBe("string"); - expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); + expect(String(result.content).length).toBeLessThanOrEqual( + MAX_RESULT_CHARS, + ); const uri = `tool-output:///${spillBlobKey(callId)}`; expect(String(result.content)).toContain(uri); - const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + const recovered = new TextDecoder().decode( + await createBlobReader(store).read(uri), + ); expect(recovered).toBe(pretty); } }); @@ -413,7 +421,9 @@ describe("resultTruncationPlugin", () => { const store = fakeBlobStore(); const original = `Matching agent profiles:\n\n${"body ".repeat(MAX_RESULT_CHARS)}`; expect(original.length).toBeGreaterThan(MAX_RESULT_CHARS); - const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob }); + const plugin = resultTruncationPlugin({ + getBlobWriter: () => store.writeBlob, + }); if (plugin.middleware === undefined) throw new Error("expected middleware"); const middleware = plugin.middleware(async (call) => ({ callId: call.id, @@ -426,14 +436,18 @@ describe("resultTruncationPlugin", () => { expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); const uri = `tool-output:///${spillBlobKey("call-search")}`; expect(String(result.content)).toContain(uri); - const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + const recovered = new TextDecoder().decode( + await createBlobReader(store).read(uri), + ); expect(recovered).toBe(original); }); test("does not truncate isError results even when over the gate", async () => { const store = fakeBlobStore(); const original = `Error: ${"x".repeat(MAX_RESULT_CHARS + 500)}`; - const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob }); + const plugin = resultTruncationPlugin({ + getBlobWriter: () => store.writeBlob, + }); if (plugin.middleware === undefined) throw new Error("expected middleware"); const middleware = plugin.middleware(async (call) => ({ callId: call.id, @@ -453,7 +467,9 @@ describe("resultTruncationPlugin", () => { describe("wrapAgentToolResultTruncation", () => { test("spills oversized wait_agents JSON from a kind:full handler", async () => { const store = fakeBlobStore(); - const payload = { results: [{ report: "x".repeat(MAX_RESULT_CHARS + 500) }] }; + const payload = { + results: [{ report: "x".repeat(MAX_RESULT_CHARS + 500) }], + }; const minified = JSON.stringify(payload); expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS); const pretty = JSON.stringify(payload, null, 2); @@ -477,7 +493,9 @@ describe("wrapAgentToolResultTruncation", () => { expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); const uri = `tool-output:///${spillBlobKey("call-wrap-wait")}`; expect(String(result.content)).toContain(uri); - const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + const recovered = new TextDecoder().decode( + await createBlobReader(store).read(uri), + ); expect(recovered).toBe(pretty); }); @@ -496,7 +514,8 @@ describe("wrapAgentToolResultTruncation", () => { }, { getBlobWriter: () => store.writeBlob }, ); - if (wrapped.kind !== "full") throw new Error("expected full wrapper so spill can use callId"); + if (wrapped.kind !== "full") + throw new Error("expected full wrapper so spill can use callId"); const result = await wrapped.handler( { id: "call-wrap-search", name: "search_agents", arguments: {} }, new AbortController().signal, @@ -504,7 +523,9 @@ describe("wrapAgentToolResultTruncation", () => { expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); const uri = `tool-output:///${spillBlobKey("call-wrap-search")}`; expect(String(result.content)).toContain(uri); - const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri)); + const recovered = new TextDecoder().decode( + await createBlobReader(store).read(uri), + ); expect(recovered).toBe(original); }); @@ -519,7 +540,11 @@ describe("wrapAgentToolResultTruncation", () => { description: "wait", inputSchema: { type: "object" }, }, - handler: async (call) => ({ callId: call.id, content: original, isError: true }), + handler: async (call) => ({ + callId: call.id, + content: original, + isError: true, + }), }, { getBlobWriter: () => store.writeBlob }, ); @@ -534,6 +559,58 @@ describe("wrapAgentToolResultTruncation", () => { }); }); +describe("wrapAgentToolsWithResultTruncation", () => { + test("late-binds a blob writer after wrap so oversized wait_agents JSON spills to a readable URI", async () => { + const payload = { + results: [{ report: "n".repeat(MAX_RESULT_CHARS + 500) }], + }; + const minified = JSON.stringify(payload); + expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS); + const pretty = JSON.stringify(payload, null, 2); + + const childSpill: { writer?: SpillBlobWriter } = {}; + const [wrapped] = wrapAgentToolsWithResultTruncation( + [ + { + kind: "full", + definition: { + name: "wait_agents", + description: "wait", + inputSchema: { type: "object" }, + }, + handler: async (call) => ({ callId: call.id, content: minified }), + }, + ], + { getBlobWriter: () => childSpill.writer }, + ); + if (wrapped === undefined || wrapped.kind !== "full") { + throw new Error("expected full wrapped tool"); + } + + const before = await wrapped.handler( + { id: "call-nested-before", name: "wait_agents", arguments: {} }, + new AbortController().signal, + ); + expect(String(before.content)).toContain("NOT retrievable"); + expect(String(before.content)).not.toContain("tool-output:///"); + + const store = fakeBlobStore(); + childSpill.writer = store.writeBlob; + const result = await wrapped.handler( + { id: "call-nested-wait", name: "wait_agents", arguments: {} }, + new AbortController().signal, + ); + expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS); + expect(String(result.content)).not.toContain("NOT retrievable"); + const uri = `tool-output:///${spillBlobKey("call-nested-wait")}`; + expect(String(result.content)).toContain(uri); + const recovered = new TextDecoder().decode( + await createBlobReader(store).read(uri), + ); + expect(recovered).toBe(pretty); + }); +}); + describe("scrub-before-spill", () => { test("secret scrub runs on the full content before truncation spills", async () => { const store = fakeBlobStore(); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 017235f6f..d7f53a040 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -49,7 +49,10 @@ import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js"; import { createWebFetchTool } from "../tools/web-fetch.js"; import { createWebSearchTool } from "../tools/web-search.js"; import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js"; -import { wrapAgentToolsWithResultTruncation } from "../plugins/result-truncation-plugin.js"; +import { + wrapAgentToolsWithResultTruncation, + type SpillBlobWriter, +} from "../plugins/result-truncation-plugin.js"; import { allowDeleteFromCapabilities, allowShellFromCapabilities, @@ -518,7 +521,11 @@ async function runSubAgentInner( // Child tools resolve spills against the child's own store first, then // the parent's: parent tool-output:// URIs handed in the brief must // remain readable after spawn, and the child's own spills stay local. + // Writer/context dir bind after createSessionStores — tools wrap first, + // same late-bind as primary getBlobWriter. let childBlobReader: BlobReader | undefined; + let childBlobWriter: SpillBlobWriter | undefined; + let childContextDir: string | undefined; const sessionBlobReader = createCompositeBlobReader( () => childBlobReader, params.getBlobReader, @@ -535,6 +542,8 @@ async function runSubAgentInner( ...(params.shellEnv !== undefined ? { shellEnv: params.shellEnv } : {}), readFileGuard: { blobReader: sessionBlobReader }, getBackgroundShellRegistry: () => backgroundShells, + getBlobWriter: () => childBlobWriter, + getContextDir: () => childContextDir, extraToolPlugins: [ ...(params.extraToolPlugins ?? []), spawnRegistry.plugin, @@ -843,7 +852,10 @@ async function runSubAgentInner( ]; } - tools = wrapAgentToolsWithResultTruncation(tools); + tools = wrapAgentToolsWithResultTruncation(tools, { + getBlobWriter: () => childBlobWriter, + getContextDir: () => childContextDir, + }); const environment = await gatherEnvironment(params.cwd); const extensions = @@ -973,6 +985,7 @@ async function runSubAgentInner( const sessionId = safeRequestedId ?? generateSessionId(); const workdir = join(params.workdirBase, "subagents", sessionId); await mkdir(workdir, { recursive: true }); + childContextDir = workdir; // One record per stop/nudge, with its measured value beside its // threshold, written into this leaf's own trace dir. interventions = createInterventionLog(workdir, { @@ -1000,6 +1013,8 @@ async function runSubAgentInner( }); const { storage, audit } = await createSessionStores(workdir); + childBlobWriter = (key, bytes, contentType) => + storage.writeBlob(key, bytes, contentType); const authorize = createWorkerAuthorize(params.permissionGate); const head = {