Skip to content

Commit 7267001

Browse files
Merge pull request #872 from corbitsdev/cl-7618-materialize-leisure-on-all-tool-results-not-an-allowlist
Spill oversized tool results without a name allowlist
2 parents 07416ba + 9aa2ab7 commit 7267001

5 files changed

Lines changed: 370 additions & 54 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ tool call
363363

364364
**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.
365365

366-
- **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.
366+
- **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.
367367
- **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.
368368
- **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched).
369369
- **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.

‎src/agent/tools.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import type { PermissionGate } from "../permission/gate.js";
2121
import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
2222
import { createLazyBlobReader } from "./lazy-blob-reader.js";
2323
import type { BlobReader } from "@intx/types/runtime";
24-
import type { SpillBlobWriter } from "../plugins/result-truncation-plugin.js";
24+
import {
25+
wrapAgentToolResultTruncation,
26+
wrapAgentToolsWithResultTruncation,
27+
type SpillBlobWriter,
28+
} from "../plugins/result-truncation-plugin.js";
2529
import {
2630
connectMCPServer as connectMCPClient,
2731
type MCPClient,
@@ -413,6 +417,10 @@ export async function createAgentToolset(
413417
);
414418
}
415419

420+
const truncationOptions = {
421+
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
422+
...(getContextDir !== undefined ? { getContextDir } : {}),
423+
};
416424
const posixTools = createPosixTools({
417425
cwd,
418426
...(sessionBlobReader !== undefined
@@ -426,8 +434,7 @@ export async function createAgentToolset(
426434
...(sessionBlobReader !== undefined
427435
? { readFileGuard: { blobReader: sessionBlobReader } }
428436
: {}),
429-
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
430-
...(getContextDir !== undefined ? { getContextDir } : {}),
437+
...truncationOptions,
431438
...(shellEnv !== undefined ? { shellEnv } : {}),
432439
getBackgroundShellRegistry: () => backgroundShells,
433440
}),
@@ -698,8 +705,9 @@ export async function createAgentToolset(
698705
}),
699706
);
700707

701-
const primaryTools = baseTools.filter(
702-
(tool) => tool.definition.name !== "apply_patch",
708+
const primaryTools = wrapAgentToolsWithResultTruncation(
709+
baseTools.filter((tool) => tool.definition.name !== "apply_patch"),
710+
truncationOptions,
703711
);
704712

705713
const dynamicRunner = createDynamicToolRunner(primaryTools, toolWatchdog);
@@ -796,9 +804,10 @@ export async function createAgentToolset(
796804
};
797805

798806
const mountWebFetch = (tool: AgentTool): void => {
807+
const wrapped = wrapAgentToolResultTruncation(tool, truncationOptions);
799808
dynamicRunner.removeTools(["web_fetch"]);
800-
dynamicRunner.addTools([tool]);
801-
replaceInheritedTool("web_fetch", tool);
809+
dynamicRunner.addTools([wrapped]);
810+
replaceInheritedTool("web_fetch", wrapped);
802811
};
803812

804813
const swapBuiltinExaToNative = (): void => {

‎src/plugins/result-truncation-plugin.test.ts‎

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
resultTruncationPlugin,
1212
spillBlobKey,
1313
truncateToolResultContent,
14+
wrapAgentToolResultTruncation,
15+
wrapAgentToolsWithResultTruncation,
16+
type SpillBlobWriter,
1417
} from "./result-truncation-plugin.js";
1518
import { toolOutputAbsolutePath } from "./tool-result-materialize.js";
1619
import { CREDENTIAL_REDACTION } from "./tool-result-secret-scrub.js";
@@ -373,6 +376,239 @@ describe("resultTruncationPlugin", () => {
373376
expect(result.content).toEqual(record);
374377
expect(store.blobs.size).toBe(0);
375378
});
379+
380+
test("spills oversized minified fleet JSON for wait_agents and list_agents", async () => {
381+
const store = fakeBlobStore();
382+
const obj = {
383+
results: Array.from({ length: 80 }, (_, i) => ({
384+
agent_id: `agent-${i}`,
385+
report: "x".repeat(200),
386+
})),
387+
timed_out: false,
388+
};
389+
const minified = JSON.stringify(obj);
390+
expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS);
391+
const pretty = JSON.stringify(obj, null, 2);
392+
const plugin = resultTruncationPlugin({
393+
getBlobWriter: () => store.writeBlob,
394+
});
395+
if (plugin.middleware === undefined) throw new Error("expected middleware");
396+
const middleware = plugin.middleware(async (call) => ({
397+
callId: call.id,
398+
content: minified,
399+
}));
400+
401+
for (const name of ["wait_agents", "list_agents"] as const) {
402+
const callId = `call-${name}`;
403+
const result = await middleware(
404+
{ id: callId, name, arguments: {} },
405+
new AbortController().signal,
406+
);
407+
expect(typeof result.content).toBe("string");
408+
expect(String(result.content).length).toBeLessThanOrEqual(
409+
MAX_RESULT_CHARS,
410+
);
411+
const uri = `tool-output:///${spillBlobKey(callId)}`;
412+
expect(String(result.content)).toContain(uri);
413+
const recovered = new TextDecoder().decode(
414+
await createBlobReader(store).read(uri),
415+
);
416+
expect(recovered).toBe(pretty);
417+
}
418+
});
419+
420+
test("spills an oversized search_agents string payload", async () => {
421+
const store = fakeBlobStore();
422+
const original = `Matching agent profiles:\n\n${"body ".repeat(MAX_RESULT_CHARS)}`;
423+
expect(original.length).toBeGreaterThan(MAX_RESULT_CHARS);
424+
const plugin = resultTruncationPlugin({
425+
getBlobWriter: () => store.writeBlob,
426+
});
427+
if (plugin.middleware === undefined) throw new Error("expected middleware");
428+
const middleware = plugin.middleware(async (call) => ({
429+
callId: call.id,
430+
content: original,
431+
}));
432+
const result = await middleware(
433+
{ id: "call-search", name: "search_agents", arguments: {} },
434+
new AbortController().signal,
435+
);
436+
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
437+
const uri = `tool-output:///${spillBlobKey("call-search")}`;
438+
expect(String(result.content)).toContain(uri);
439+
const recovered = new TextDecoder().decode(
440+
await createBlobReader(store).read(uri),
441+
);
442+
expect(recovered).toBe(original);
443+
});
444+
445+
test("does not truncate isError results even when over the gate", async () => {
446+
const store = fakeBlobStore();
447+
const original = `Error: ${"x".repeat(MAX_RESULT_CHARS + 500)}`;
448+
const plugin = resultTruncationPlugin({
449+
getBlobWriter: () => store.writeBlob,
450+
});
451+
if (plugin.middleware === undefined) throw new Error("expected middleware");
452+
const middleware = plugin.middleware(async (call) => ({
453+
callId: call.id,
454+
content: original,
455+
isError: true,
456+
}));
457+
const result = await middleware(
458+
{ id: "call-err", name: "wait_agents", arguments: {} },
459+
new AbortController().signal,
460+
);
461+
expect(result.content).toBe(original);
462+
expect(result.isError).toBe(true);
463+
expect(store.blobs.size).toBe(0);
464+
});
465+
});
466+
467+
describe("wrapAgentToolResultTruncation", () => {
468+
test("spills oversized wait_agents JSON from a kind:full handler", async () => {
469+
const store = fakeBlobStore();
470+
const payload = {
471+
results: [{ report: "x".repeat(MAX_RESULT_CHARS + 500) }],
472+
};
473+
const minified = JSON.stringify(payload);
474+
expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS);
475+
const pretty = JSON.stringify(payload, null, 2);
476+
const wrapped = wrapAgentToolResultTruncation(
477+
{
478+
kind: "full",
479+
definition: {
480+
name: "wait_agents",
481+
description: "wait",
482+
inputSchema: { type: "object" },
483+
},
484+
handler: async (call) => ({ callId: call.id, content: minified }),
485+
},
486+
{ getBlobWriter: () => store.writeBlob },
487+
);
488+
if (wrapped.kind !== "full") throw new Error("expected full tool");
489+
const result = await wrapped.handler(
490+
{ id: "call-wrap-wait", name: "wait_agents", arguments: {} },
491+
new AbortController().signal,
492+
);
493+
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
494+
const uri = `tool-output:///${spillBlobKey("call-wrap-wait")}`;
495+
expect(String(result.content)).toContain(uri);
496+
const recovered = new TextDecoder().decode(
497+
await createBlobReader(store).read(uri),
498+
);
499+
expect(recovered).toBe(pretty);
500+
});
501+
502+
test("spills oversized search_agents string from a kind:string handler", async () => {
503+
const store = fakeBlobStore();
504+
const original = `Matching agent profiles:\n\n${"z".repeat(MAX_RESULT_CHARS + 500)}`;
505+
const wrapped = wrapAgentToolResultTruncation(
506+
{
507+
kind: "string",
508+
definition: {
509+
name: "search_agents",
510+
description: "search",
511+
inputSchema: { type: "object" },
512+
},
513+
handler: async () => original,
514+
},
515+
{ getBlobWriter: () => store.writeBlob },
516+
);
517+
if (wrapped.kind !== "full")
518+
throw new Error("expected full wrapper so spill can use callId");
519+
const result = await wrapped.handler(
520+
{ id: "call-wrap-search", name: "search_agents", arguments: {} },
521+
new AbortController().signal,
522+
);
523+
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
524+
const uri = `tool-output:///${spillBlobKey("call-wrap-search")}`;
525+
expect(String(result.content)).toContain(uri);
526+
const recovered = new TextDecoder().decode(
527+
await createBlobReader(store).read(uri),
528+
);
529+
expect(recovered).toBe(original);
530+
});
531+
532+
test("does not truncate isError results from a kind:full handler", async () => {
533+
const store = fakeBlobStore();
534+
const original = `Error: ${"e".repeat(MAX_RESULT_CHARS + 500)}`;
535+
const wrapped = wrapAgentToolResultTruncation(
536+
{
537+
kind: "full",
538+
definition: {
539+
name: "wait_agents",
540+
description: "wait",
541+
inputSchema: { type: "object" },
542+
},
543+
handler: async (call) => ({
544+
callId: call.id,
545+
content: original,
546+
isError: true,
547+
}),
548+
},
549+
{ getBlobWriter: () => store.writeBlob },
550+
);
551+
if (wrapped.kind !== "full") throw new Error("expected full tool");
552+
const result = await wrapped.handler(
553+
{ id: "call-wrap-err", name: "wait_agents", arguments: {} },
554+
new AbortController().signal,
555+
);
556+
expect(result.content).toBe(original);
557+
expect(result.isError).toBe(true);
558+
expect(store.blobs.size).toBe(0);
559+
});
560+
});
561+
562+
describe("wrapAgentToolsWithResultTruncation", () => {
563+
test("late-binds a blob writer after wrap so oversized wait_agents JSON spills to a readable URI", async () => {
564+
const payload = {
565+
results: [{ report: "n".repeat(MAX_RESULT_CHARS + 500) }],
566+
};
567+
const minified = JSON.stringify(payload);
568+
expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS);
569+
const pretty = JSON.stringify(payload, null, 2);
570+
571+
const childSpill: { writer?: SpillBlobWriter } = {};
572+
const [wrapped] = wrapAgentToolsWithResultTruncation(
573+
[
574+
{
575+
kind: "full",
576+
definition: {
577+
name: "wait_agents",
578+
description: "wait",
579+
inputSchema: { type: "object" },
580+
},
581+
handler: async (call) => ({ callId: call.id, content: minified }),
582+
},
583+
],
584+
{ getBlobWriter: () => childSpill.writer },
585+
);
586+
if (wrapped === undefined || wrapped.kind !== "full") {
587+
throw new Error("expected full wrapped tool");
588+
}
589+
590+
const before = await wrapped.handler(
591+
{ id: "call-nested-before", name: "wait_agents", arguments: {} },
592+
new AbortController().signal,
593+
);
594+
expect(String(before.content)).toContain("NOT retrievable");
595+
expect(String(before.content)).not.toContain("tool-output:///");
596+
597+
const store = fakeBlobStore();
598+
childSpill.writer = store.writeBlob;
599+
const result = await wrapped.handler(
600+
{ id: "call-nested-wait", name: "wait_agents", arguments: {} },
601+
new AbortController().signal,
602+
);
603+
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
604+
expect(String(result.content)).not.toContain("NOT retrievable");
605+
const uri = `tool-output:///${spillBlobKey("call-nested-wait")}`;
606+
expect(String(result.content)).toContain(uri);
607+
const recovered = new TextDecoder().decode(
608+
await createBlobReader(store).read(uri),
609+
);
610+
expect(recovered).toBe(pretty);
611+
});
376612
});
377613

378614
describe("scrub-before-spill", () => {

0 commit comments

Comments
 (0)