Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 16 additions & 7 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -413,6 +417,10 @@ export async function createAgentToolset(
);
}

const truncationOptions = {
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
...(getContextDir !== undefined ? { getContextDir } : {}),
};
const posixTools = createPosixTools({
cwd,
...(sessionBlobReader !== undefined
Expand All @@ -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,
}),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 => {
Expand Down
236 changes: 236 additions & 0 deletions src/plugins/result-truncation-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
resultTruncationPlugin,
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";
Expand Down Expand Up @@ -373,6 +376,239 @@ 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("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", () => {
Expand Down
Loading
Loading