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
11 changes: 9 additions & 2 deletions src/agent/posix-tool-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { editFileLineRangePlugin } from "../plugins/edit-file-line-range-plugin.
import { ripgrepPlugin } from "../plugins/ripgrep-plugin.js";
import { toolOutputUriPlugin } from "../plugins/tool-output-uri-plugin.js";
import { lspHintPlugin } from "../plugins/lsp-hint-plugin.js";
import { resultTruncationPlugin } from "../plugins/result-truncation-plugin.js";
import {
resultTruncationPlugin,
type SpillBlobWriter,
} from "../plugins/result-truncation-plugin.js";
import { toolResultSecretScrubPlugin } from "../plugins/tool-result-secret-scrub-plugin.js";
import { shellGuardPlugin, type ShellTimeoutConfig } from "../plugins/shell-guard-plugin.js";
import {
Expand All @@ -27,6 +30,9 @@ export interface CorePosixToolPluginsArgs {
shellTimeout?: ShellTimeoutConfig;
extraToolPlugins?: ToolPlugin[];
readFileGuard?: ReadFileGuardPluginOptions;
// Session blob-store writer oversized tool results spill their full content
// into. See result-truncation-plugin.ts.
getBlobWriter?: () => SpillBlobWriter | undefined;
// Per-project settings.env, merged into the run_shell spawn environment.
shellEnv?: Record<string, string>;
}
Expand Down Expand Up @@ -60,6 +66,7 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
shellTimeout,
extraToolPlugins = [],
readFileGuard = {},
getBlobWriter,
shellEnv,
} = args;
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
Expand All @@ -69,7 +76,7 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
// regardless.
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
return [
resultTruncationPlugin(),
resultTruncationPlugin(getBlobWriter !== undefined ? { getBlobWriter } : {}),
toolResultSecretScrubPlugin(),
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }),
deleteFilePlugin(cwd, { allowOutside }),
Expand Down
14 changes: 13 additions & 1 deletion src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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 { connectMCPServer, type MCPClient } from "../mcp/client.js";
import { mcpClientToAgentTools } from "../mcp/plugin.js";
import { createDynamicToolRunner, type DynamicToolRunner } from "../tui/dynamic-tool-runner.js";
Expand Down Expand Up @@ -101,6 +102,15 @@ export interface AgentToolsetArgs {
// Session blob store for tool-output:// reads; resolved when tools run so agent
// rebuilds do not require recreating the posix toolset.
getBlobReader?: () => BlobReader | undefined;
// Session blob-store writer oversized tool results spill their full,
// untruncated content into (see result-truncation-plugin.ts) — the same
// context store getBlobReader reads from, keyed distinctly so the reactor's
// own downstream size-cap transform never overwrites the spill. Resolved
// lazily like getBlobReader so a mid-process session rotation spills into
// the new session's store. Omitted only where there is no session store to
// write into (tests). Persists with the rest of the session's committed
// history — no separate cleanup.
getBlobWriter?: () => SpillBlobWriter | undefined;
// Per-project settings.env, merged into the run_shell tool's spawn environment.
shellEnv?: Record<string, string>;
// Whether a workflow is currently running. advance_workflow rides the wire
Expand Down Expand Up @@ -190,6 +200,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
shellTimeout,
toolWatchdog,
getBlobReader,
getBlobWriter,
sessionMode = "orchestrator",
shellEnv,
toolAvailability = { languageServerAvailable: true },
Expand All @@ -212,6 +223,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
...(sessionBlobReader !== undefined
? { readFileGuard: { blobReader: sessionBlobReader } }
: {}),
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
...(shellEnv !== undefined ? { shellEnv } : {}),
}),
});
Expand Down Expand Up @@ -435,7 +447,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
}
connectedClients.push(result.client);
permissionGate.registerMcpClient(result.client);
const mcpTools = mcpClientToAgentTools(result.client, permissionGate);
const mcpTools = mcpClientToAgentTools(result.client, permissionGate, getBlobWriter);
inheritedMcpTools.push(...mcpTools);
dynamicRunner.addTools(mcpTools);
callbacks.onStatus({
Expand Down
10 changes: 9 additions & 1 deletion src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
import { resolveSessionMode, type SessionMode } from "../config/session-mode.js";
import { createSubAgentSessionStore, type SubAgentProvider } from "../subagent/index.js";
import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/types/runtime";
import type {
ContextStore,
InferenceSource,
ToolDefinition,
InboundMessage,
} from "@intx/types/runtime";
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
import { createChatDirector } from "../agent/director.js";
import { loadAgentProfiles } from "../agent/profiles.js";
Expand Down Expand Up @@ -384,6 +389,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
};

let currentAgent: Agent | null = null;
let currentStorage: ContextStore | null = null;

const overlay = resolveExecDirectorOverlay(config.director);

Expand All @@ -396,6 +402,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
...(shellTimeout !== undefined ? { shellTimeout } : {}),
...(toolWatchdog !== undefined ? { toolWatchdog } : {}),
...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}),
getBlobWriter: () => currentStorage?.writeBlob,
getBlobReader: () => {
if (currentAgent === null) {
throw new Error("blob reader requested before agent init");
Expand Down Expand Up @@ -610,6 +617,7 @@ export async function runExec(config: Config): Promise<ExecResult> {

const buildAgent = async (): Promise<Agent> => {
const storage = await createOptimizedContextStore(workdir);
currentStorage = storage;
const sources = liveSources.length > 0 ? liveSources : [liveSource];
const defaultSource = liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id;
// Prefer liveSource credentials on the active id when OAuth was refreshed.
Expand Down
22 changes: 17 additions & 5 deletions src/mcp/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@ import type { ToolCall, ToolResult } from "@intx/types/runtime";
import type { PermissionGate } from "../permission/gate.js";
import { gateToolCall } from "../plugins/permission-plugin.js";
import { scrubSecretShapedToolResultContent } from "../plugins/tool-result-secret-scrub.js";
import { truncateToolResultContent } from "../plugins/result-truncation-plugin.js";
import {
truncateToolResultContent,
type SpillBlobWriter,
} from "../plugins/result-truncation-plugin.js";
import type { MCPClient } from "./client.js";
import { mcpToolName } from "./tool-name.js";

// MCP results never reach the posix runner, so the secret-scrub and truncation
// middleware in src/plugins never see them. Apply the same scrub-then-truncate
// order here directly (see buildCorePosixToolPlugins) so a compromised MCP
// server cannot leak credential-shaped strings or flood the transcript.
function sanitizeMcpResultContent(content: string): string {
return truncateToolResultContent(scrubSecretShapedToolResultContent(content));
function sanitizeMcpResultContent(
content: string,
spill?: { callId: string; writeBlob: SpillBlobWriter },
): Promise<string> {
return truncateToolResultContent(scrubSecretShapedToolResultContent(content), undefined, spill);
}

// Convert a connected client's tools into AgentTools for the dynamic runner used
// by the TUI. These tools live in a separate runner from the posix tool plugin
// chain, so each handler is wrapped with the permission gate directly.
export function mcpClientToAgentTools(client: MCPClient, gate: PermissionGate): AgentTool[] {
export function mcpClientToAgentTools(
client: MCPClient,
gate: PermissionGate,
getBlobWriter?: () => SpillBlobWriter | undefined,
): AgentTool[] {
return client.tools.map((tool) => ({
kind: "full" as const,
definition: {
Expand All @@ -30,7 +40,9 @@ export function mcpClientToAgentTools(client: MCPClient, gate: PermissionGate):
gateToolCall(gate, call, signal, async () => {
try {
const content = await client.call(tool.name, call.arguments, signal);
return { callId: call.id, content: sanitizeMcpResultContent(content) };
const writeBlob = getBlobWriter?.();
const spill = writeBlob !== undefined ? { callId: call.id, writeBlob } : undefined;
return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) };
} catch (err) {
return {
callId: call.id,
Expand Down
187 changes: 148 additions & 39 deletions src/plugins/result-truncation-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,170 @@
import { describe, expect, test } from "bun:test";
import { createSizeCapTransform } from "@intx/inference";
import type { StrategyContext, ToolResult } from "@intx/types/runtime";
import { MAX_RESULT_CHARS, truncateToolResultContent } from "./result-truncation-plugin.js";
import { createBlobReader, type StrategyContext, type ToolResult } from "@intx/types/runtime";
import {
MAX_RESULT_CHARS,
resultTruncationPlugin,
spillBlobKey,
truncateToolResultContent,
} from "./result-truncation-plugin.js";

/** In-memory stand-in for ContextStore's writeBlob/readBlob pair, for tests. */
function fakeBlobStore() {
const blobs = new Map<string, Uint8Array>();
return {
blobs,
writeBlob: async (key: string, bytes: Uint8Array) => {
blobs.set(key, bytes);
},
readBlob: async (key: string) => {
const bytes = blobs.get(key);
if (bytes === undefined) throw new Error(`Blob not found: ${key}`);
return bytes;
},
};
}

describe("truncateToolResultContent", () => {
test("within-cap content passes through unchanged", () => {
test("within-cap content passes through unchanged", async () => {
const content = "x".repeat(100);
expect(truncateToolResultContent(content)).toBe(content);
expect(await truncateToolResultContent(content)).toBe(content);
});

test("oversized content gets a marker that never promises retrievable remainder", () => {
test("oversized content with no blob store gets a marker that never promises retrievable remainder", async () => {
const content = "x".repeat(MAX_RESULT_CHARS + 500);
const truncated = truncateToolResultContent(content);
const truncated = await truncateToolResultContent(content);

expect(truncated).toContain("[output truncated");
expect(truncated).toContain("NOT retrievable");
// The pre-cap discard must never be described as recoverable elsewhere.
expect(truncated).not.toContain("see the rest");
expect(truncated).not.toContain("Full output available");
// And it must never promise a lifetime it doesn't control either way.
expect(truncated).not.toContain("removed");
expect(truncated).not.toContain("session ends");
});

test("truncation marker survives the size-cap blob spill", async () => {
// Reproduce the production pipeline for an output over MAX_RESULT_CHARS:
// truncation runs first (at the tool), size-cap spills the already-cut
// text to a blob and tells the model the blob holds the full output. The
// blob's tail must therefore carry the honest "discarded, NOT retrievable"
// marker so the model does not loop re-running the command.
const original = "x".repeat(MAX_RESULT_CHARS + 500);
const truncated = truncateToolResultContent(original);

const blobs = new Map<string, string>();
const transform = createSizeCapTransform({
maxChars: 10_000,
contextStore: {
writeBlob: async (key: string, bytes: Uint8Array) => {
blobs.set(key, new TextDecoder().decode(bytes));
},
},
test("the inlined portion stays bounded regardless of blob-store support", async () => {
const content = "x".repeat(MAX_RESULT_CHARS * 3);
const truncated = await truncateToolResultContent(content);
// The marker text itself adds a bounded amount of overhead on top of the cap.
expect(truncated.length).toBeLessThan(MAX_RESULT_CHARS + 1000);
});

describe("with a blob store", () => {
test("a result over the cap is fully recoverable by following the notice's read_file instructions verbatim", async () => {
const store = fakeBlobStore();
const original = `${"x".repeat(MAX_RESULT_CHARS)}TAIL-MARKER-${"y".repeat(500)}`;
const truncated = await truncateToolResultContent(original, MAX_RESULT_CHARS, {
callId: "call-42",
writeBlob: store.writeBlob,
});

// Inline content is bounded and does not itself contain the discarded tail.
expect(truncated).not.toContain("TAIL-MARKER");
expect(truncated.length).toBeLessThan(MAX_RESULT_CHARS + 1000);

const uriMatch = /tool-output:\/\/\/\S+/.exec(truncated);
expect(uriMatch).not.toBeNull();
const uri = uriMatch?.[0].replace(/[.\]]+$/, "") ?? "";
expect(uri).toBe(`tool-output:///${spillBlobKey("call-42")}`);

// Follow the notice's instructions literally: read_file with that URI,
// via the real BlobReader machinery read_file itself uses.
const blobReader = createBlobReader(store);
const recoveredBytes = await blobReader.read(uri);
const recovered = new TextDecoder().decode(recoveredBytes);
expect(recovered).toBe(original);
expect(recovered).toContain("TAIL-MARKER");
expect(recovered.length).toBe(original.length);

// No false lifetime claim: the blob is part of the committed session
// history, not something with its own expiry.
expect(truncated).not.toContain("removed");
expect(truncated).not.toContain("session ends");
});

const result: ToolResult = {
callId: "call-1",
content: truncated,
isError: false,
};
const { output } = await transform.apply(
{ call: { id: "call-1", name: "run_shell", arguments: {} }, result },
{} as StrategyContext,
test("within-cap content never writes a blob", async () => {
const store = fakeBlobStore();
await truncateToolResultContent("x".repeat(100), MAX_RESULT_CHARS, {
callId: "call-1",
writeBlob: store.writeBlob,
});
expect(store.blobs.size).toBe(0);
});

test(
"the full spill survives the reactor's own downstream size-cap transform " +
"(CL-6908 regression: a same-keyed write here would let that second write clobber it)",
async () => {
const store = fakeBlobStore();
const original = "p".repeat(500_000);
const truncated = await truncateToolResultContent(original, MAX_RESULT_CHARS, {
callId: "call-1",
writeBlob: store.writeBlob,
});

// Reproduce the production pipeline: this middleware's ToolResult
// continues into the reactor, which always runs its own size-cap
// transform (vendor/intx-inference, default cap 10,000 chars) on
// every result, keyed by the bare call id.
const reactorCap = createSizeCapTransform({
maxChars: 10_000,
contextStore: { writeBlob: store.writeBlob },
});
const result: ToolResult = { callId: "call-1", content: truncated, isError: false };
await reactorCap.apply(
{ call: { id: "call-1", name: "run_shell", arguments: {} }, result },
{} as StrategyContext,
);

// The reactor wrote its own (lossy) blob under the bare "call-1" key.
expect(store.blobs.has("call-1")).toBe(true);
// Our full spill lives under a distinct key and is untouched.
const blobReader = createBlobReader(store);
const recovered = new TextDecoder().decode(
await blobReader.read(`tool-output:///${spillBlobKey("call-1")}`),
);
expect(recovered).toBe(original);
expect(recovered.length).toBe(500_000);
},
);
});
});

describe("resultTruncationPlugin", () => {
test("spills oversized run_shell/grep/search_files/web_fetch results via the live getBlobWriter getter", async () => {
const store = fakeBlobStore();
const original = "q".repeat(MAX_RESULT_CHARS + 200);
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-99", name: "run_shell", arguments: {} },
new AbortController().signal,
);

const spilled = blobs.get("call-1");
expect(spilled).toBe(truncated);
// The blob's tail tells the truth about the pre-spill discard.
expect(spilled).toContain("NOT retrievable");
expect(spilled?.endsWith("Use offset/limit or a narrower query.]")).toBe(true);
// The inline marker's blob promise is now genuine: the blob really does
// hold everything that still exists.
expect(output.content).toContain("tool-output:///call-1");
const uri = `tool-output:///${spillBlobKey("call-99")}`;
expect(result.content).toContain(uri);
const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri));
expect(recovered).toBe(original);
});

test("falls back to the honest no-store notice when getBlobWriter resolves undefined", async () => {
const plugin = resultTruncationPlugin({ getBlobWriter: () => undefined });
if (plugin.middleware === undefined) throw new Error("expected middleware");
const middleware = plugin.middleware(async (call) => ({
callId: call.id,
content: "r".repeat(MAX_RESULT_CHARS + 1),
}));
const result = await middleware(
{ id: "call-1", name: "grep", arguments: {} },
new AbortController().signal,
);
expect(result.content).toContain("NOT retrievable");
});
});
Loading
Loading