Skip to content

Commit 3a0cc46

Browse files
committed
fix(subagents): harden exact-result recovery without path leakage
Keep canonical settlement separate from bounded projection, persist artifacts fail-soft, and recover through subagent_result(id, offset, limit) instead of cache pathnames.
1 parent 7e0fff5 commit 3a0cc46

12 files changed

Lines changed: 2661 additions & 455 deletions

File tree

extensions/subagents/index.ts

Lines changed: 158 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* - subagent_wait: block until the listed subagents settle, return results.
1111
* - subagent_cancel: stop one or more running subagents.
1212
* - subagent_check: peek at a subagent's status and recent activity.
13+
* - subagent_result: read bounded pages of a settled exact result by subagent id.
1314
* - subagent_list: list all subagents.
1415
*
1516
* Unawaited subagents queue their result as a follow-up message when they
@@ -117,6 +118,8 @@ import {
117118
SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS,
118119
SUBAGENT_CHECK_TOOL_DESCRIPTION,
119120
SUBAGENT_LIST_TOOL_DESCRIPTION,
121+
SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS,
122+
SUBAGENT_RESULT_TOOL_DESCRIPTION,
120123
SUBAGENT_SEND_PARAMETER_DESCRIPTIONS,
121124
SUBAGENT_SEND_TOOL_DESCRIPTION,
122125
SUBAGENT_SPAWN_PROMPT_GUIDELINES,
@@ -125,9 +128,14 @@ import {
125128
SUBAGENT_WAIT_TOOL_DESCRIPTION,
126129
} from "./src/prompt.ts";
127130
import {
131+
isResultArtifactRef,
132+
MAX_RESULT_PAGE_BYTES,
133+
MAX_RESULT_PAGE_LINES,
134+
pageResultText,
128135
persistResultArtifact,
129136
projectResult,
130137
readResultArtifact,
138+
resolveExactResultText,
131139
} from "./src/result-artifact.ts";
132140
import { createSubagentResultDelivery } from "./src/result-delivery.ts";
133141
import {
@@ -184,7 +192,7 @@ interface SubagentProjectionDetails {
184192
readonly truncated: boolean;
185193
readonly omittedBytes: number;
186194
readonly omitted: NonNullable<SubagentSnapshot["snapshot"]>["omitted"];
187-
readonly resultArtifact?: string;
195+
readonly exactResultAvailable?: boolean;
188196
}
189197

190198
interface SubagentResultEntryData {
@@ -214,39 +222,45 @@ function describeSubagent(snap: SubagentSnapshot) {
214222
}
215223

216224
function exactResultText(snap: SubagentSnapshot): string | undefined {
217-
if (!snap.resultArtifact) return undefined;
218-
return readResultArtifact(snap.resultArtifact);
225+
if (!isResultArtifactRef(snap.resultArtifact)) return undefined;
226+
return readResultArtifact(getAgentDir(), snap.resultArtifact);
219227
}
220228

221229
function resultText(snap: SubagentSnapshot): string {
222230
const artifact = exactResultText(snap);
223-
if (snap.resultArtifact && artifact === undefined) {
224-
throw new Error(
225-
`The exact subagent result artifact is unavailable: ${snap.resultArtifact}`,
226-
);
227-
}
231+
// Retention and best-effort writes can make a prior recovery path disappear.
232+
// A cache miss must degrade to the retained result, never suppress delivery.
228233
return artifact !== undefined
229234
? artifact || "(no output)"
230235
: snap.finalText || "(no output)";
231236
}
232237

238+
function withCanonicalResult(
239+
snap: SubagentSnapshot,
240+
result: Pick<SubagentSnapshot, "finalText" | "resultArtifact"> | undefined,
241+
) {
242+
if (!result) return { snap, resultIsCanonical: false };
243+
return {
244+
snap: {
245+
...snap,
246+
finalText: result.finalText,
247+
resultArtifact: result.resultArtifact,
248+
},
249+
resultIsCanonical: true,
250+
};
251+
}
252+
233253
function projectionDetails(
234254
snap: SubagentSnapshot,
235255
): SubagentProjectionDetails | undefined {
236256
const projection = snap.snapshot;
237-
if (!projection?.truncated && !snap.resultArtifact) return undefined;
257+
if (!projection?.truncated) return undefined;
258+
const exactResultAvailable = exactResultText(snap) !== undefined;
238259
return {
239-
truncated: projection?.truncated ?? false,
240-
omittedBytes: projection?.omittedBytes ?? 0,
241-
omitted: projection?.omitted ?? {
242-
transcriptItems: 0,
243-
liveTools: 0,
244-
queued: 0,
245-
liveAssistantBytes: 0,
246-
finalTextBytes: 0,
247-
promptBytes: 0,
248-
},
249-
...(snap.resultArtifact ? { resultArtifact: snap.resultArtifact } : {}),
260+
truncated: true,
261+
omittedBytes: projection.omittedBytes,
262+
omitted: projection.omitted,
263+
...(exactResultAvailable ? { exactResultAvailable: true } : {}),
250264
};
251265
}
252266

@@ -266,39 +280,42 @@ function projectionNotice(snap: SubagentSnapshot): string | undefined {
266280
projection.omitted.finalTextBytes > 0 ? "final output" : undefined,
267281
].filter((value): value is string => value !== undefined);
268282
const detail = omitted.length > 0 ? omitted.join(", ") : "display data";
269-
return `snapshot truncated: ${detail} omitted${snap.resultArtifact ? "; exact result artifact available" : ""}`;
283+
const hasExactArtifact = exactResultText(snap) !== undefined;
284+
return `snapshot truncated: ${detail} omitted${hasExactArtifact ? "; exact result artifact available" : ""}`;
270285
}
271286

272287
export function truncatedOutput(
273288
snap: SubagentSnapshot,
274289
maxBytes = SUBAGENT_OUTPUT_MAX_BYTES,
275-
writeArtifact: (content: string) => string = (content) =>
290+
writeArtifact: (content: string) => unknown = (content) =>
276291
persistResultArtifact(getAgentDir(), content),
292+
resultOptions: { readonly resultIsCanonical?: boolean } = {},
277293
): string {
278-
const artifactPath = snap.resultArtifact;
279294
const artifact = exactResultText(snap);
280-
if (artifactPath && artifact === undefined) {
281-
throw new Error(
282-
`The exact subagent result artifact is unavailable: ${artifactPath}`,
283-
);
284-
}
285295
const output =
286-
artifact !== undefined ? artifact || "(no output)" : snap.finalText || "(no output)";
296+
artifact !== undefined
297+
? artifact || "(no output)"
298+
: snap.finalText || "(no output)";
287299
// A projected finalText is not authoritative. Do not create a second
288300
// artifact containing only that projection when the original artifact is
289-
// unavailable.
290-
const finalTextWasOmitted = (snap.snapshot?.omitted.finalTextBytes ?? 0) > 0;
291-
const persist =
292-
artifact !== undefined || !finalTextWasOmitted
293-
? artifact !== undefined && artifactPath
294-
? () => artifactPath
295-
: writeArtifact
301+
// unavailable. A cache miss with a complete retained result can safely
302+
// repopulate the cache on demand.
303+
const finalTextWasOmitted =
304+
!resultOptions.resultIsCanonical &&
305+
(snap.snapshot?.omitted.finalTextBytes ?? 0) > 0;
306+
const artifactAvailable = artifact !== undefined;
307+
const persist = artifactAvailable
308+
? () => undefined
309+
: !finalTextWasOmitted
310+
? writeArtifact
296311
: () => {
297312
throw new Error("The exact subagent result artifact is unavailable");
298313
};
299314
return projectResult(output, {
300315
maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
301316
maxLines: Math.min(600, DEFAULT_MAX_LINES),
317+
recoveryId: snap.id,
318+
artifactAvailable,
302319
writeArtifact: persist,
303320
}).text;
304321
}
@@ -1003,16 +1020,23 @@ export default function (pi: ExtensionAPI) {
10031020
readonly id: string;
10041021
readonly snap: SubagentSnapshot;
10051022
readonly header: string;
1023+
readonly resultIsCanonical: boolean;
10061024
}
10071025
> = ids.map((id) => {
10081026
const snap = manager.view.get(id);
10091027
if (!snap) return { id, section: `## ${id}\n\n(no longer tracked)` };
1028+
const result = withCanonicalResult(snap, manager.view.getResult?.(id));
10101029
const verb = snap.status === "error" ? "failed" : "finished";
10111030
let header = `## ${snap.id} "${snap.title}" ${verb}`;
10121031
if (snap.errorText) header += `\nError: ${snap.errorText}`;
10131032
const projection = projectionNotice(snap);
10141033
if (projection) header += `\n[${projection}]`;
1015-
return { id, snap, header };
1034+
return {
1035+
id,
1036+
snap: result.snap,
1037+
header,
1038+
resultIsCanonical: result.resultIsCanonical,
1039+
};
10161040
});
10171041
const separatorsBytes = Math.max(0, entries.length - 1) * 7;
10181042
const fixedBytes =
@@ -1033,14 +1057,17 @@ export default function (pi: ExtensionAPI) {
10331057
readonly id: string;
10341058
readonly snap: SubagentSnapshot;
10351059
readonly header: string;
1060+
readonly resultIsCanonical: boolean;
10361061
} => "snap" in entry,
10371062
);
10381063
const projectionBatchBytes = Math.max(
10391064
WAIT_MIN_RESULT_BYTES * resultEntries.length,
10401065
WAIT_OUTPUT_MAX_BYTES - fixedBytes,
10411066
);
10421067
const allocation = allocateResultBudgets(
1043-
resultEntries.map(({ snap }) => Buffer.byteLength(resultText(snap), "utf8")),
1068+
resultEntries.map(({ snap }) =>
1069+
Buffer.byteLength(resultText(snap), "utf8"),
1070+
),
10441071
ctx.getContextUsage(),
10451072
{
10461073
maxBatchBytes: projectionBatchBytes,
@@ -1055,7 +1082,12 @@ export default function (pi: ExtensionAPI) {
10551082
const sections = entries.map((entry) => {
10561083
if ("section" in entry) return entry.section;
10571084
const outputBudget = allocation.budgets[resultIndex++]!;
1058-
return `${entry.header}\n\n${truncatedOutput(entry.snap, outputBudget)}`;
1085+
return `${entry.header}\n\n${truncatedOutput(
1086+
entry.snap,
1087+
outputBudget,
1088+
undefined,
1089+
{ resultIsCanonical: entry.resultIsCanonical },
1090+
)}`;
10591091
});
10601092

10611093
const combined = sections.join("\n\n---\n\n");
@@ -1244,14 +1276,21 @@ export default function (pi: ExtensionAPI) {
12441276
let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`;
12451277
if (snap.errorText) text += `\nError: ${snap.errorText}`;
12461278

1279+
const result =
1280+
snap.status === "running"
1281+
? undefined
1282+
: withCanonicalResult(snap, manager.view.getResult?.(snap.id));
1283+
const resultSnap = result?.snap ?? snap;
12471284
const output =
1248-
snap.status === "running" ? latestText(snap) : resultText(snap);
1285+
snap.status === "running" ? latestText(snap) : resultText(resultSnap);
12491286
if (output && output !== "(no output)") {
12501287
const preview =
12511288
snap.status === "running"
12521289
? truncateHead(output, { maxBytes: 2048, maxLines: 20 })
12531290
: (() => {
1254-
const content = truncatedOutput(snap, 2048);
1291+
const content = truncatedOutput(resultSnap, 2048, undefined, {
1292+
resultIsCanonical: result?.resultIsCanonical,
1293+
});
12551294
return { content, truncated: content !== output };
12561295
})();
12571296
text += `\n\nLatest output:\n${preview.content}`;
@@ -1274,6 +1313,84 @@ export default function (pi: ExtensionAPI) {
12741313
},
12751314
});
12761315

1316+
pi.registerTool({
1317+
name: "subagent_result",
1318+
label: "Read Subagent Result",
1319+
description: SUBAGENT_RESULT_TOOL_DESCRIPTION,
1320+
parameters: Type.Object({
1321+
id: Type.String({
1322+
description: SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS.id,
1323+
}),
1324+
offset: Type.Optional(
1325+
Type.Integer({
1326+
minimum: 0,
1327+
description: SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS.offset,
1328+
}),
1329+
),
1330+
limit: Type.Optional(
1331+
Type.Integer({
1332+
minimum: 1,
1333+
maximum: MAX_RESULT_PAGE_LINES,
1334+
description: SUBAGENT_RESULT_PARAMETER_DESCRIPTIONS.limit,
1335+
}),
1336+
),
1337+
}),
1338+
async execute(_toolCallId, params) {
1339+
const manager = await getManager();
1340+
const snap = manager.view.get(params.id);
1341+
if (!snap || !isModelVisible(snap)) {
1342+
const known = manager.view
1343+
.list()
1344+
.filter(isModelVisible)
1345+
.map((s) => s.id);
1346+
throw new Error(
1347+
`Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`,
1348+
);
1349+
}
1350+
if (snap.status === "running") {
1351+
throw new Error(
1352+
`Subagent ${snap.id} is still running; use subagent_wait or wait for automatic delivery.`,
1353+
);
1354+
}
1355+
1356+
const canonical = withCanonicalResult(
1357+
snap,
1358+
manager.view.getResult?.(snap.id),
1359+
);
1360+
const artifact = exactResultText(canonical.snap);
1361+
// Prefer the protected artifact. Fall back to retained canonical
1362+
// finalText, never to a truncated projection.
1363+
const exact = resolveExactResultText({
1364+
artifactText: artifact,
1365+
retainedFinalText: canonical.snap.finalText,
1366+
resultIsCanonical: canonical.resultIsCanonical,
1367+
omittedFinalTextBytes:
1368+
canonical.snap.snapshot?.omitted.finalTextBytes ?? 0,
1369+
});
1370+
if (exact === undefined) {
1371+
throw new Error(
1372+
`Exact result for ${snap.id} is unavailable; the bounded projection is not a recovery source.`,
1373+
);
1374+
}
1375+
const page = pageResultText(exact, {
1376+
offset: params.offset,
1377+
limit: params.limit,
1378+
maxBytes: MAX_RESULT_PAGE_BYTES,
1379+
});
1380+
return {
1381+
content: [{ type: "text", text: page.text }],
1382+
details: {
1383+
id: snap.id,
1384+
offset: page.offset,
1385+
limit: page.limit,
1386+
totalLines: page.totalLines,
1387+
hasMore: page.hasMore,
1388+
...(artifact !== undefined ? { exactResultAvailable: true } : {}),
1389+
},
1390+
};
1391+
},
1392+
});
1393+
12771394
pi.registerTool({
12781395
name: "subagent_list",
12791396
label: "List Subagents",

extensions/subagents/src/domain.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ export interface QueuedMessage {
138138
readonly kind: "steer" | "follow-up";
139139
}
140140

141+
/** Path-free identity of an exact terminal-result cache entry. */
142+
export interface ResultArtifactRef {
143+
readonly version: 1;
144+
/** Lowercase SHA-256 digest of the UTF-8 artifact content. */
145+
readonly digest: string;
146+
}
147+
141148
/** Why a live snapshot does not contain the complete child conversation. */
142149
export interface SubagentSnapshotProjection {
143150
readonly maxBytes: number;
@@ -244,7 +251,7 @@ export interface SubagentSnapshot {
244251
/** Final text of the most recent completed run (v1 `finalOutput`). */
245252
readonly finalText: string;
246253
/** Content-addressed exact result, when the bounded projection omitted text. */
247-
readonly resultArtifact?: string;
254+
readonly resultArtifact?: ResultArtifactRef;
248255
/** Count of finalized assistant messages (for subagent_check). */
249256
readonly turns: number;
250257
/** Aggregate UTF-8 budget metadata for this in-memory projection. */

0 commit comments

Comments
 (0)