Skip to content

Commit 535806d

Browse files
committed
fix(subagents): enforce UTF-8 snapshot budgets
1 parent 7d2189c commit 535806d

8 files changed

Lines changed: 1388 additions & 69 deletions

File tree

extensions/subagents/index.ts

Lines changed: 156 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ import {
6767
planModeAllowsDeclaredTools,
6868
planModeChildTools,
6969
} from "../shared/plan-mode-state.ts";
70+
import {
71+
allocateResultBudgets,
72+
type ParentContextUsage,
73+
} from "../shared/result-budget.ts";
7074
import { loadSetupConfig } from "../shared/setup-config.ts";
7175
import {
7276
OPENPI_TOOL_SURFACE,
@@ -120,11 +124,11 @@ import {
120124
SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS,
121125
SUBAGENT_WAIT_TOOL_DESCRIPTION,
122126
} from "./src/prompt.ts";
123-
import { persistResultArtifact, projectResult } from "./src/result-artifact.ts";
124127
import {
125-
allocateResultBudgets,
126-
type ParentContextUsage,
127-
} from "../shared/result-budget.ts";
128+
persistResultArtifact,
129+
projectResult,
130+
readResultArtifact,
131+
} from "./src/result-artifact.ts";
128132
import { createSubagentResultDelivery } from "./src/result-delivery.ts";
129133
import {
130134
createSubagentRuntime,
@@ -167,13 +171,22 @@ interface SubagentResultDetails {
167171
readonly title?: string;
168172
readonly status?: SubagentSnapshot["status"];
169173
readonly count?: number;
174+
readonly projection?: SubagentProjectionDetails;
170175
readonly results?: ReadonlyArray<{
171176
readonly id: string;
172177
readonly title: string;
173178
readonly status: SubagentSnapshot["status"];
179+
readonly projection?: SubagentProjectionDetails;
174180
}>;
175181
}
176182

183+
interface SubagentProjectionDetails {
184+
readonly truncated: boolean;
185+
readonly omittedBytes: number;
186+
readonly omitted: NonNullable<SubagentSnapshot["snapshot"]>["omitted"];
187+
readonly resultArtifact?: string;
188+
}
189+
177190
interface SubagentResultEntryData {
178191
readonly content: string;
179192
readonly details: SubagentResultDetails;
@@ -196,7 +209,64 @@ function describeSubagent(snap: SubagentSnapshot) {
196209
formatElapsed(snap),
197210
snap.cwd,
198211
].filter(Boolean);
199-
return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`;
212+
const projection = projectionNotice(snap);
213+
return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})${projection ? ` · ${projection}` : ""}`;
214+
}
215+
216+
function exactResultText(snap: SubagentSnapshot): string | undefined {
217+
if (!snap.resultArtifact) return undefined;
218+
return readResultArtifact(snap.resultArtifact);
219+
}
220+
221+
function resultText(snap: SubagentSnapshot): string {
222+
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+
}
228+
return artifact !== undefined
229+
? artifact || "(no output)"
230+
: snap.finalText || "(no output)";
231+
}
232+
233+
function projectionDetails(
234+
snap: SubagentSnapshot,
235+
): SubagentProjectionDetails | undefined {
236+
const projection = snap.snapshot;
237+
if (!projection?.truncated && !snap.resultArtifact) return undefined;
238+
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 } : {}),
250+
};
251+
}
252+
253+
function projectionNotice(snap: SubagentSnapshot): string | undefined {
254+
const projection = snap.snapshot;
255+
if (!projection?.truncated) return undefined;
256+
const omitted = [
257+
projection.omitted.transcriptItems > 0
258+
? `${projection.omitted.transcriptItems} transcript item(s)`
259+
: undefined,
260+
projection.omitted.liveTools > 0
261+
? `${projection.omitted.liveTools} live tool(s)`
262+
: undefined,
263+
projection.omitted.queued > 0
264+
? `${projection.omitted.queued} queued message(s)`
265+
: undefined,
266+
projection.omitted.finalTextBytes > 0 ? "final output" : undefined,
267+
].filter((value): value is string => value !== undefined);
268+
const detail = omitted.length > 0 ? omitted.join(", ") : "display data";
269+
return `snapshot truncated: ${detail} omitted${snap.resultArtifact ? "; exact result artifact available" : ""}`;
200270
}
201271

202272
export function truncatedOutput(
@@ -205,11 +275,31 @@ export function truncatedOutput(
205275
writeArtifact: (content: string) => string = (content) =>
206276
persistResultArtifact(getAgentDir(), content),
207277
): string {
208-
const output = snap.finalText || "(no output)";
278+
const artifactPath = snap.resultArtifact;
279+
const artifact = exactResultText(snap);
280+
if (artifactPath && artifact === undefined) {
281+
throw new Error(
282+
`The exact subagent result artifact is unavailable: ${artifactPath}`,
283+
);
284+
}
285+
const output =
286+
artifact !== undefined ? artifact || "(no output)" : snap.finalText || "(no output)";
287+
// A projected finalText is not authoritative. Do not create a second
288+
// 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
296+
: () => {
297+
throw new Error("The exact subagent result artifact is unavailable");
298+
};
209299
return projectResult(output, {
210300
maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
211301
maxLines: Math.min(600, DEFAULT_MAX_LINES),
212-
writeArtifact,
302+
writeArtifact: persist,
213303
}).text;
214304
}
215305

@@ -243,9 +333,7 @@ export function createSubagentResultDispatcher(
243333
AUTOMATIC_OUTPUT_MAX_BYTES - wrapperBytes,
244334
);
245335
const allocation = allocateResultBudgets(
246-
snaps.map((snap) =>
247-
Buffer.byteLength(snap.finalText || "(no output)", "utf8"),
248-
),
336+
snaps.map((snap) => Buffer.byteLength(resultText(snap), "utf8")),
249337
getContextUsage(),
250338
{
251339
maxBatchBytes: projectionBatchBytes,
@@ -269,18 +357,26 @@ export function createSubagentResultDispatcher(
269357
.join("\n\n");
270358
const details: SubagentResultDetails =
271359
snaps.length === 1
272-
? {
273-
id: snaps[0]!.id,
274-
title: snaps[0]!.title,
275-
status: snaps[0]!.status,
276-
}
360+
? (() => {
361+
const projection = projectionDetails(snaps[0]!);
362+
return {
363+
id: snaps[0]!.id,
364+
title: snaps[0]!.title,
365+
status: snaps[0]!.status,
366+
...(projection ? { projection } : {}),
367+
};
368+
})()
277369
: {
278370
count: snaps.length,
279-
results: snaps.map((snap) => ({
280-
id: snap.id,
281-
title: snap.title,
282-
status: snap.status,
283-
})),
371+
results: snaps.map((snap) => {
372+
const projection = projectionDetails(snap);
373+
return {
374+
id: snap.id,
375+
title: snap.title,
376+
status: snap.status,
377+
...(projection ? { projection } : {}),
378+
};
379+
}),
284380
};
285381
pi.appendEntry<SubagentResultEntryData>("subagent-result", {
286382
content,
@@ -391,6 +487,8 @@ export default function (pi: ExtensionAPI) {
391487
(runtime ??= createSubagentRuntime({
392488
initialModelCounter: restoredIdCounters.modelCounter,
393489
initialBtwCounter: restoredIdCounters.btwCounter,
490+
persistResultArtifact: (content) =>
491+
persistResultArtifact(getAgentDir(), content),
394492
}));
395493

396494
const persistId = (id: string) =>
@@ -912,6 +1010,8 @@ export default function (pi: ExtensionAPI) {
9121010
const verb = snap.status === "error" ? "failed" : "finished";
9131011
let header = `## ${snap.id} "${snap.title}" ${verb}`;
9141012
if (snap.errorText) header += `\nError: ${snap.errorText}`;
1013+
const projection = projectionNotice(snap);
1014+
if (projection) header += `\n[${projection}]`;
9151015
return { id, snap, header };
9161016
});
9171017
const separatorsBytes = Math.max(0, entries.length - 1) * 7;
@@ -940,9 +1040,7 @@ export default function (pi: ExtensionAPI) {
9401040
WAIT_OUTPUT_MAX_BYTES - fixedBytes,
9411041
);
9421042
const allocation = allocateResultBudgets(
943-
resultEntries.map(({ snap }) =>
944-
Buffer.byteLength(snap.finalText || "(no output)", "utf8"),
945-
),
1043+
resultEntries.map(({ snap }) => Buffer.byteLength(resultText(snap), "utf8")),
9461044
ctx.getContextUsage(),
9471045
{
9481046
maxBatchBytes: projectionBatchBytes,
@@ -973,7 +1071,13 @@ export default function (pi: ExtensionAPI) {
9731071
details: {
9741072
results: ids.map((id) => {
9751073
const snap = manager.view.get(id);
976-
return { id, title: snap?.title, status: snap?.status };
1074+
const projection = snap ? projectionDetails(snap) : undefined;
1075+
return {
1076+
id,
1077+
title: snap?.title,
1078+
status: snap?.status,
1079+
...(projection ? { projection } : {}),
1080+
};
9771081
}),
9781082
},
9791083
};
@@ -1140,9 +1244,16 @@ export default function (pi: ExtensionAPI) {
11401244
let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`;
11411245
if (snap.errorText) text += `\nError: ${snap.errorText}`;
11421246

1143-
const output = latestText(snap);
1144-
if (output) {
1145-
const preview = truncateHead(output, { maxBytes: 2048, maxLines: 20 });
1247+
const output =
1248+
snap.status === "running" ? latestText(snap) : resultText(snap);
1249+
if (output && output !== "(no output)") {
1250+
const preview =
1251+
snap.status === "running"
1252+
? truncateHead(output, { maxBytes: 2048, maxLines: 20 })
1253+
: (() => {
1254+
const content = truncatedOutput(snap, 2048);
1255+
return { content, truncated: content !== output };
1256+
})();
11461257
text += `\n\nLatest output:\n${preview.content}`;
11471258
if (preview.truncated) text += "\n[...]";
11481259
} else if (snap.status === "running") {
@@ -1151,7 +1262,14 @@ export default function (pi: ExtensionAPI) {
11511262

11521263
return {
11531264
content: [{ type: "text", text }],
1154-
details: { id: snap.id, status: snap.status, turns: snap.turns },
1265+
details: {
1266+
id: snap.id,
1267+
status: snap.status,
1268+
turns: snap.turns,
1269+
...(projectionDetails(snap)
1270+
? { projection: projectionDetails(snap) }
1271+
: {}),
1272+
},
11551273
};
11561274
},
11571275
});
@@ -1171,12 +1289,16 @@ export default function (pi: ExtensionAPI) {
11711289
return {
11721290
content: [{ type: "text", text }],
11731291
details: {
1174-
subagents: subs.map((snap) => ({
1175-
id: snap.id,
1176-
title: snap.title,
1177-
harness: snap.backend,
1178-
status: snap.status,
1179-
})),
1292+
subagents: subs.map((snap) => {
1293+
const projection = projectionDetails(snap);
1294+
return {
1295+
id: snap.id,
1296+
title: snap.title,
1297+
harness: snap.backend,
1298+
status: snap.status,
1299+
...(projection ? { projection } : {}),
1300+
};
1301+
}),
11801302
},
11811303
};
11821304
},

extensions/subagents/src/domain.ts

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

141+
/** Why a live snapshot does not contain the complete child conversation. */
142+
export interface SubagentSnapshotProjection {
143+
readonly maxBytes: number;
144+
readonly bytes: number;
145+
readonly truncated: boolean;
146+
readonly omittedBytes: number;
147+
readonly omitted: {
148+
readonly transcriptItems: number;
149+
readonly liveTools: number;
150+
readonly queued: number;
151+
readonly liveAssistantBytes: number;
152+
readonly finalTextBytes: number;
153+
readonly promptBytes: number;
154+
};
155+
}
156+
141157
// --- Events ------------------------------------------------------------------
142158

143159
export type RunOutcome =
@@ -227,8 +243,12 @@ export interface SubagentSnapshot {
227243
readonly queued: ReadonlyArray<QueuedMessage>;
228244
/** Final text of the most recent completed run (v1 `finalOutput`). */
229245
readonly finalText: string;
246+
/** Content-addressed exact result, when the bounded projection omitted text. */
247+
readonly resultArtifact?: string;
230248
/** Count of finalized assistant messages (for subagent_check). */
231249
readonly turns: number;
250+
/** Aggregate UTF-8 budget metadata for this in-memory projection. */
251+
readonly snapshot?: SubagentSnapshotProjection;
232252
}
233253

234254
/** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */

0 commit comments

Comments
 (0)