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
98 changes: 89 additions & 9 deletions extensions/workflows/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ const AGENT_RESULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
const WORKFLOW_MANIFEST_MAX_BYTES = 1024 * 1024;
const WORKFLOW_TRANSCRIPTS_MAX_BYTES = 2 * 1024 * 1024;
const WORKFLOW_COMMIT_MAX_BYTES = 3 * 1024 * 1024;

// safeStringify's node budget must never bite before the byte budget. Its
// default (serialization.ts DEFAULT_MAX_NODES = 20_000) is far below what these
// byte budgets allow, so it silently dropped trailing artifact fields and whole
// agent transcripts while the file sat well under its byte cap (issue #558). A
// value dense enough to reach N nodes always serializes to more than N bytes, so
// pinning maxNodes to the byte budget makes the honest, reported byte cap the
// only binding limit here.
const WORKFLOW_MANIFEST_MAX_NODES = WORKFLOW_MANIFEST_MAX_BYTES;
const WORKFLOW_TRANSCRIPTS_MAX_NODES = WORKFLOW_TRANSCRIPTS_MAX_BYTES;
export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500;
const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]";
const TRANSCRIPT_TRUNCATION_MARKER =
Expand Down Expand Up @@ -417,6 +427,70 @@ export function boundedArtifactTranscript(
return [initial, marker, ...tail];
}

export interface BoundedTranscriptsArtifact {
/** Ready-to-write `transcripts.json` content: `{ [agentIndex]: entries[] }`. */
content: string;
/** Whole agents dropped from the tail, and their bounded entry count. */
omitted: { agents: number; entries: number };
}

/**
* Assemble `transcripts.json`, keeping whole agents in index order until the
* next one would exceed the byte budget. The dropped tail is counted and
* reported (`transcriptsOmitted`) rather than silently discarded by the
* serializer's node budget, which previously stopped at 20_000 nodes — roughly
* 11 full agents — while the file sat at a quarter of its byte cap (issue #558).
*/
export function boundedTranscriptsArtifact(
agents: readonly { index: number; transcript: TranscriptEntry[] }[],
options: { maxBytes?: number; maxNodes?: number } = {},
): BoundedTranscriptsArtifact {
const maxBytes = Math.max(
256,
options.maxBytes ?? WORKFLOW_TRANSCRIPTS_MAX_BYTES,
);
const maxNodes = options.maxNodes ?? WORKFLOW_TRANSCRIPTS_MAX_NODES;
// Bound each agent first; a single agent is capped well under maxBytes by
// boundedArtifactTranscript, so at least one agent always fits.
const bounded = agents.map((agent) => ({
index: agent.index,
entries: boundedArtifactTranscript(agent.transcript),
}));
const prefixObject = (count: number) =>
Object.fromEntries(
bounded.slice(0, count).map((b) => [b.index, b.entries]),
);
// Measure the uncapped pretty-printed size (the shape safeStringify emits for
// these plain, pre-bounded entries) so a tail that overflows the byte cap is
// detected here instead of turning the whole file into a preview stub.
const prefixBytes = (count: number) =>
textBytes(JSON.stringify(prefixObject(count), null, 2));

let kept = bounded.length;
if (prefixBytes(bounded.length) > maxBytes) {
// Binary-search the largest whole-agent prefix that fits.
let lo = 0;
let hi = bounded.length;
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2);
if (prefixBytes(mid) <= maxBytes) lo = mid;
else hi = mid - 1;
}
kept = lo;
}

let omittedEntries = 0;
for (let i = kept; i < bounded.length; i++) {
omittedEntries += bounded[i]!.entries.length;
}
// The kept prefix already fits maxBytes and holds far fewer than maxNodes
// nodes, so safeStringify writes it in full without a second truncation.
return {
content: safeStringify(prefixObject(kept), { maxBytes, maxNodes }),
omitted: { agents: bounded.length - kept, entries: omittedEntries },
};
}

function writeRunFile(runDir: string, name: string, content: string) {
writeFileAtomic(path.join(runDir, name), content);
}
Expand All @@ -434,6 +508,7 @@ export function persistWorkflowTerminalState(
delete terminalManifest.transcriptArtifact;
const content = safeStringify(terminalManifest, {
maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
maxNodes: WORKFLOW_MANIFEST_MAX_NODES,
});
writeRunFile(runDir, "workflow.json", content);
return sha256(content);
Expand Down Expand Up @@ -478,12 +553,17 @@ export function persistWorkflowJson(
journal?: WorkflowJournalSource,
) {
refreshWorkflowGraph(details);
const transcripts = Object.fromEntries(
details.agents.map((agent) => [
agent.index,
boundedArtifactTranscript(agent.transcript),
]),
);
const boundedTranscripts = boundedTranscriptsArtifact(details.agents, {
maxBytes: WORKFLOW_TRANSCRIPTS_MAX_BYTES,
maxNodes: WORKFLOW_TRANSCRIPTS_MAX_NODES,
});
// Recomputed each persist, so set-or-clear rather than accumulate: the field
// must describe the transcripts.json actually written this time.
if (boundedTranscripts.omitted.agents > 0) {
details.transcriptsOmitted = boundedTranscripts.omitted;
} else {
delete details.transcriptsOmitted;
}

// Publish terminal execution facts before dependent side artifacts. If a
// later artifact write fails, readers still see an explained terminal run
Expand All @@ -502,9 +582,7 @@ export function persistWorkflowJson(
const artifactWrites: WorkflowArtifactWrite[] = [
{
name: "transcripts.json",
content: safeStringify(transcripts, {
maxBytes: WORKFLOW_TRANSCRIPTS_MAX_BYTES,
}),
content: boundedTranscripts.content,
},
];
// Written alongside the rest so it inherits atomic write, 500ms coalescing,
Expand All @@ -529,6 +607,7 @@ export function persistWorkflowJson(
name: "result.json",
content: safeStringify(details.result, {
maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
maxNodes: WORKFLOW_MANIFEST_MAX_NODES,
}),
});
}
Expand All @@ -542,6 +621,7 @@ export function persistWorkflowJson(
};
const manifest = safeStringify(compact, {
maxBytes: WORKFLOW_MANIFEST_MAX_BYTES,
maxNodes: WORKFLOW_MANIFEST_MAX_NODES,
});

if (predecessorSha256 !== undefined) {
Expand Down
10 changes: 10 additions & 0 deletions extensions/workflows/completion-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ function completionAlerts(details: WorkflowDetails) {
if (details.logsDropped) {
alerts.push(`${details.logsDropped} earlier log line(s) dropped`);
}
if (details.transcriptsOmitted) {
Comment thread
tt-a1i marked this conversation as resolved.
alerts.push(
`${details.transcriptsOmitted.agents} agent transcript(s) omitted from transcripts.json (byte budget)`,
);
}

for (const entry of details.logs ?? []) {
if (!isDroppedWorkLog(entry)) continue;
Expand Down Expand Up @@ -171,6 +176,11 @@ function buildOperatorReport(
: undefined,
].filter((entry): entry is string => entry !== undefined);
if (artifacts.length > 0) lines.push("", "Artifacts:", ...artifacts);
if (details.transcriptsOmitted) {
lines.push(
` (${details.transcriptsOmitted.agents} agent transcript(s), ${details.transcriptsOmitted.entries} entr${details.transcriptsOmitted.entries === 1 ? "y" : "ies"} omitted from transcripts.json to stay within its byte budget)`,
);
}

const replayed = details.agents.filter((agent) => agent.replayed).length;
if (details.resumedFrom) {
Expand Down
47 changes: 44 additions & 3 deletions extensions/workflows/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,22 @@ function normalizeDelivery(value: unknown): WorkflowDetails["delivery"] {
};
}

function normalizeTranscriptsOmitted(
value: unknown,
): WorkflowDetails["transcriptsOmitted"] {
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
const positiveInt = (candidate: unknown) =>
typeof candidate === "number" &&
Number.isSafeInteger(candidate) &&
candidate > 0
? candidate
: 0;
const agents = positiveInt(record.agents);
if (agents <= 0) return undefined;
return { agents, entries: positiveInt(record.entries) };
}

function normalizeTranscript(value: unknown): TranscriptEntry[] {
if (!Array.isArray(value)) return [];
const transcript: TranscriptEntry[] = [];
Expand Down Expand Up @@ -518,6 +534,10 @@ export function normalizePersistedWorkflowDetails(
}
}

const transcriptsOmitted = normalizeTranscriptsOmitted(
record.transcriptsOmitted,
);

return {
runId,
sessionId:
Expand Down Expand Up @@ -550,6 +570,7 @@ export function normalizePersistedWorkflowDetails(
...(typeof record.logsDropped === "number" && record.logsDropped > 0
? { logsDropped: record.logsDropped }
: {}),
...(transcriptsOmitted ? { transcriptsOmitted } : {}),
...(agents.some((agent) => agent.callId)
? {
graph: projectWorkflowGraph(workflowGraphRecords(agents)),
Expand Down Expand Up @@ -819,6 +840,13 @@ export function buildWorkflowReport(details: WorkflowDetails): string {
if (totals) lines.push(`- Usage: ${totals}`);
if (details.description) lines.push("", details.description);
if (details.error) lines.push("", `**Error:** ${details.error}`);
if (details.transcriptsOmitted) {
const { agents, entries } = details.transcriptsOmitted;
lines.push(
"",
`_${agents} of ${details.agents.length} agent transcript(s) (${entries} entries) omitted from transcripts.json to stay within its byte budget; execution facts above are complete._`,
);
}

for (const group of phaseGroups(details, true)) {
lines.push("", `## ${group.title}`, "");
Expand Down Expand Up @@ -1380,8 +1408,9 @@ export class WorkflowDashboard {
formatElapsed(agent.startedAt, agent.finishedAt),
],
errorText: agent.error,
emptyText:
"transcript unavailable (this run predates transcript capture)",
emptyText: details.transcriptsOmitted
? `transcript unavailable (this run omitted ${details.transcriptsOmitted.agents} agent transcript(s) from transcripts.json to stay within its byte budget)`
: "transcript unavailable (this run predates transcript capture)",
};
},
close: () => {
Expand Down Expand Up @@ -1558,6 +1587,17 @@ export class WorkflowDashboard {
),
);

// Run-level notice mirroring the saved report and completion alert: without
// it, opening a dropped agent's empty transcript reads as "predates capture"
// with no on-screen evidence anything was omitted (issue #558).
const omissionNotice = d.transcriptsOmitted
? theme.fg(
"warning",
` ${d.transcriptsOmitted.agents} agent transcript(s) (${d.transcriptsOmitted.entries} entries) omitted from transcripts.json (byte budget).`,
)
: undefined;
if (omissionNotice) lines.push(omissionNotice);

const groups = this.groups();
this.phaseIndex = Math.min(this.phaseIndex, Math.max(0, groups.length - 1));
const selectedGroup = groups[this.phaseIndex];
Expand All @@ -1568,7 +1608,8 @@ export class WorkflowDashboard {
// the agent list is what the view exists for.
const logBudget = Math.max(0, Math.min(3, height - 12));
const recentLogs = (d.logs ?? []).slice(-logBudget);
const panelHeight = height - 3 - recentLogs.length;
const panelHeight =
height - 3 - recentLogs.length - (omissionNotice ? 1 : 0);
const bodyHeight = Math.max(0, panelHeight - 2);

// Left: phases sidebar.
Expand Down
5 changes: 5 additions & 0 deletions extensions/workflows/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ export interface WorkflowDetails {
logs?: WorkflowLogEntry[];
/** Oldest lines discarded once the ring filled, reported rather than hidden. */
logsDropped?: number;
/**
* Whole agent transcripts dropped from transcripts.json when the run exceeded
* that artifact's byte budget, reported rather than silently truncated.
*/
transcriptsOmitted?: { agents: number; entries: number };
result?: unknown;
resultArtifact?: string;
transcriptArtifact?: string;
Expand Down
4 changes: 4 additions & 0 deletions extensions/workflows/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ function makeProjection(
agents,
...(logs.length > 0 ? { logs } : {}),
...(details.logsDropped ? { logsDropped: details.logsDropped } : {}),
...(details.transcriptsOmitted
? { transcriptsOmitted: details.transcriptsOmitted }
: {}),
...(details.delivery
? {
delivery: {
Expand Down Expand Up @@ -349,6 +352,7 @@ export function projectWorkflowDetails(
"name",
"sessionId",
"logsDropped",
"transcriptsOmitted",
"logs",
"graphOmitted",
"result",
Expand Down
Loading
Loading