Skip to content

Commit 9aa2ab7

Browse files
committed
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.
1 parent b14a01c commit 9aa2ab7

2 files changed

Lines changed: 105 additions & 13 deletions

File tree

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

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
spillBlobKey,
1313
truncateToolResultContent,
1414
wrapAgentToolResultTruncation,
15+
wrapAgentToolsWithResultTruncation,
16+
type SpillBlobWriter,
1517
} from "./result-truncation-plugin.js";
1618
import { toolOutputAbsolutePath } from "./tool-result-materialize.js";
1719
import { CREDENTIAL_REDACTION } from "./tool-result-secret-scrub.js";
@@ -387,7 +389,9 @@ describe("resultTruncationPlugin", () => {
387389
const minified = JSON.stringify(obj);
388390
expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS);
389391
const pretty = JSON.stringify(obj, null, 2);
390-
const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob });
392+
const plugin = resultTruncationPlugin({
393+
getBlobWriter: () => store.writeBlob,
394+
});
391395
if (plugin.middleware === undefined) throw new Error("expected middleware");
392396
const middleware = plugin.middleware(async (call) => ({
393397
callId: call.id,
@@ -401,10 +405,14 @@ describe("resultTruncationPlugin", () => {
401405
new AbortController().signal,
402406
);
403407
expect(typeof result.content).toBe("string");
404-
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
408+
expect(String(result.content).length).toBeLessThanOrEqual(
409+
MAX_RESULT_CHARS,
410+
);
405411
const uri = `tool-output:///${spillBlobKey(callId)}`;
406412
expect(String(result.content)).toContain(uri);
407-
const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri));
413+
const recovered = new TextDecoder().decode(
414+
await createBlobReader(store).read(uri),
415+
);
408416
expect(recovered).toBe(pretty);
409417
}
410418
});
@@ -413,7 +421,9 @@ describe("resultTruncationPlugin", () => {
413421
const store = fakeBlobStore();
414422
const original = `Matching agent profiles:\n\n${"body ".repeat(MAX_RESULT_CHARS)}`;
415423
expect(original.length).toBeGreaterThan(MAX_RESULT_CHARS);
416-
const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob });
424+
const plugin = resultTruncationPlugin({
425+
getBlobWriter: () => store.writeBlob,
426+
});
417427
if (plugin.middleware === undefined) throw new Error("expected middleware");
418428
const middleware = plugin.middleware(async (call) => ({
419429
callId: call.id,
@@ -426,14 +436,18 @@ describe("resultTruncationPlugin", () => {
426436
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
427437
const uri = `tool-output:///${spillBlobKey("call-search")}`;
428438
expect(String(result.content)).toContain(uri);
429-
const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri));
439+
const recovered = new TextDecoder().decode(
440+
await createBlobReader(store).read(uri),
441+
);
430442
expect(recovered).toBe(original);
431443
});
432444

433445
test("does not truncate isError results even when over the gate", async () => {
434446
const store = fakeBlobStore();
435447
const original = `Error: ${"x".repeat(MAX_RESULT_CHARS + 500)}`;
436-
const plugin = resultTruncationPlugin({ getBlobWriter: () => store.writeBlob });
448+
const plugin = resultTruncationPlugin({
449+
getBlobWriter: () => store.writeBlob,
450+
});
437451
if (plugin.middleware === undefined) throw new Error("expected middleware");
438452
const middleware = plugin.middleware(async (call) => ({
439453
callId: call.id,
@@ -453,7 +467,9 @@ describe("resultTruncationPlugin", () => {
453467
describe("wrapAgentToolResultTruncation", () => {
454468
test("spills oversized wait_agents JSON from a kind:full handler", async () => {
455469
const store = fakeBlobStore();
456-
const payload = { results: [{ report: "x".repeat(MAX_RESULT_CHARS + 500) }] };
470+
const payload = {
471+
results: [{ report: "x".repeat(MAX_RESULT_CHARS + 500) }],
472+
};
457473
const minified = JSON.stringify(payload);
458474
expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS);
459475
const pretty = JSON.stringify(payload, null, 2);
@@ -477,7 +493,9 @@ describe("wrapAgentToolResultTruncation", () => {
477493
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
478494
const uri = `tool-output:///${spillBlobKey("call-wrap-wait")}`;
479495
expect(String(result.content)).toContain(uri);
480-
const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri));
496+
const recovered = new TextDecoder().decode(
497+
await createBlobReader(store).read(uri),
498+
);
481499
expect(recovered).toBe(pretty);
482500
});
483501

@@ -496,15 +514,18 @@ describe("wrapAgentToolResultTruncation", () => {
496514
},
497515
{ getBlobWriter: () => store.writeBlob },
498516
);
499-
if (wrapped.kind !== "full") throw new Error("expected full wrapper so spill can use callId");
517+
if (wrapped.kind !== "full")
518+
throw new Error("expected full wrapper so spill can use callId");
500519
const result = await wrapped.handler(
501520
{ id: "call-wrap-search", name: "search_agents", arguments: {} },
502521
new AbortController().signal,
503522
);
504523
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
505524
const uri = `tool-output:///${spillBlobKey("call-wrap-search")}`;
506525
expect(String(result.content)).toContain(uri);
507-
const recovered = new TextDecoder().decode(await createBlobReader(store).read(uri));
526+
const recovered = new TextDecoder().decode(
527+
await createBlobReader(store).read(uri),
528+
);
508529
expect(recovered).toBe(original);
509530
});
510531

@@ -519,7 +540,11 @@ describe("wrapAgentToolResultTruncation", () => {
519540
description: "wait",
520541
inputSchema: { type: "object" },
521542
},
522-
handler: async (call) => ({ callId: call.id, content: original, isError: true }),
543+
handler: async (call) => ({
544+
callId: call.id,
545+
content: original,
546+
isError: true,
547+
}),
523548
},
524549
{ getBlobWriter: () => store.writeBlob },
525550
);
@@ -534,6 +559,58 @@ describe("wrapAgentToolResultTruncation", () => {
534559
});
535560
});
536561

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+
});
612+
});
613+
537614
describe("scrub-before-spill", () => {
538615
test("secret scrub runs on the full content before truncation spills", async () => {
539616
const store = fakeBlobStore();

src/subagent/run.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js";
4949
import { createWebFetchTool } from "../tools/web-fetch.js";
5050
import { createWebSearchTool } from "../tools/web-search.js";
5151
import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js";
52-
import { wrapAgentToolsWithResultTruncation } from "../plugins/result-truncation-plugin.js";
52+
import {
53+
wrapAgentToolsWithResultTruncation,
54+
type SpillBlobWriter,
55+
} from "../plugins/result-truncation-plugin.js";
5356
import {
5457
allowDeleteFromCapabilities,
5558
allowShellFromCapabilities,
@@ -518,7 +521,11 @@ async function runSubAgentInner(
518521
// Child tools resolve spills against the child's own store first, then
519522
// the parent's: parent tool-output:// URIs handed in the brief must
520523
// remain readable after spawn, and the child's own spills stay local.
524+
// Writer/context dir bind after createSessionStores — tools wrap first,
525+
// same late-bind as primary getBlobWriter.
521526
let childBlobReader: BlobReader | undefined;
527+
let childBlobWriter: SpillBlobWriter | undefined;
528+
let childContextDir: string | undefined;
522529
const sessionBlobReader = createCompositeBlobReader(
523530
() => childBlobReader,
524531
params.getBlobReader,
@@ -535,6 +542,8 @@ async function runSubAgentInner(
535542
...(params.shellEnv !== undefined ? { shellEnv: params.shellEnv } : {}),
536543
readFileGuard: { blobReader: sessionBlobReader },
537544
getBackgroundShellRegistry: () => backgroundShells,
545+
getBlobWriter: () => childBlobWriter,
546+
getContextDir: () => childContextDir,
538547
extraToolPlugins: [
539548
...(params.extraToolPlugins ?? []),
540549
spawnRegistry.plugin,
@@ -843,7 +852,10 @@ async function runSubAgentInner(
843852
];
844853
}
845854

846-
tools = wrapAgentToolsWithResultTruncation(tools);
855+
tools = wrapAgentToolsWithResultTruncation(tools, {
856+
getBlobWriter: () => childBlobWriter,
857+
getContextDir: () => childContextDir,
858+
});
847859

848860
const environment = await gatherEnvironment(params.cwd);
849861
const extensions =
@@ -973,6 +985,7 @@ async function runSubAgentInner(
973985
const sessionId = safeRequestedId ?? generateSessionId();
974986
const workdir = join(params.workdirBase, "subagents", sessionId);
975987
await mkdir(workdir, { recursive: true });
988+
childContextDir = workdir;
976989
// One record per stop/nudge, with its measured value beside its
977990
// threshold, written into this leaf's own trace dir.
978991
interventions = createInterventionLog(workdir, {
@@ -1000,6 +1013,8 @@ async function runSubAgentInner(
10001013
});
10011014

10021015
const { storage, audit } = await createSessionStores(workdir);
1016+
childBlobWriter = (key, bytes, contentType) =>
1017+
storage.writeBlob(key, bytes, contentType);
10031018
const authorize = createWorkerAuthorize(params.permissionGate);
10041019

10051020
const head = {

0 commit comments

Comments
 (0)