@poncho-ai/harness 0.52.2 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compaction.ts CHANGED
@@ -26,6 +26,20 @@ const SUMMARIZATION_PROMPT = `Summarize the following conversation into a struct
26
26
  Be concise but preserve all information needed to continue the task.
27
27
  Omit any section that has no relevant content.`;
28
28
 
29
+ /**
30
+ * Extra instruction appended when the first compacted message is itself a
31
+ * prior compaction summary. The model must treat that block as the existing
32
+ * working state and produce an updated, merged version rather than
33
+ * re-summarizing the (already lossy) summary from scratch.
34
+ */
35
+ const CUMULATIVE_SUMMARY_PROMPT = `The FIRST message below (tagged [prior-summary]) is an existing working-state summary produced by an earlier compaction. Treat it as the authoritative prior working state: MERGE AND UPDATE it with the newer messages that follow it, carrying forward all still-relevant detail. Do NOT discard or re-compress information from the prior summary just because it is older — only drop it if the newer messages explicitly supersede it.`;
36
+
37
+ /** Max chars of a subagent result text kept verbatim in the ledger digest. */
38
+ const SUBAGENT_DIGEST_CHARS = 500;
39
+
40
+ /** Heading used for the verbatim, model-proof subagent ledger block. */
41
+ const SUBAGENT_LEDGER_HEADING = "## Subagents";
42
+
29
43
  export const resolveCompactionConfig = (
30
44
  explicit?: Partial<CompactionConfig>,
31
45
  ): CompactionConfig => {
@@ -78,11 +92,48 @@ export const estimateTotalTokens = (
78
92
  return tokens;
79
93
  };
80
94
 
95
+ /**
96
+ * Whether an assistant message carries serialized tool_calls. Assistant
97
+ * tool-call turns serialize their content as a JSON string of the shape
98
+ * `{"text":...,"tool_calls":[...]}` (see the harness run loop). A plain-text
99
+ * assistant message returns false.
100
+ */
101
+ const assistantHasToolCalls = (msg: Message): boolean => {
102
+ if (msg.role !== "assistant") return false;
103
+ if (typeof msg.content !== "string") return false;
104
+ if (!msg.content.includes('"tool_calls"')) return false;
105
+ try {
106
+ const parsed = JSON.parse(msg.content) as { tool_calls?: unknown };
107
+ return Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0;
108
+ } catch {
109
+ return false;
110
+ }
111
+ };
112
+
113
+ /**
114
+ * Whether splitting at `idx` would orphan a tool-call relationship on the
115
+ * COMPACTED side — i.e. the last compacted message (`messages[idx-1]`) is an
116
+ * assistant message with tool_calls whose answering `role:"tool"` result
117
+ * lives on the PRESERVED side (`messages[idx]`). Folding only the assistant
118
+ * call into the summary strands the tool_calls with no matching result.
119
+ */
120
+ const splitOrphansToolCalls = (messages: Message[], idx: number): boolean => {
121
+ if (idx <= 0 || idx >= messages.length) return false;
122
+ const lastCompacted = messages[idx - 1]!;
123
+ return assistantHasToolCalls(lastCompacted);
124
+ };
125
+
81
126
  /**
82
127
  * Find the safe split index so that everything before it can be compacted
83
128
  * and everything from it onward is preserved. The split always lands just
84
129
  * before a `user` message to avoid breaking assistant+tool pairs.
85
130
  *
131
+ * Defensive guard: even at a `user` boundary, refuse a split whose compacted
132
+ * side would END on an assistant message with unanswered tool_calls (its
133
+ * `tool` result having moved to the preserved side). Such a split would
134
+ * orphan the tool_calls inside the summary boundary. When that happens we
135
+ * walk earlier to the next safe `user` boundary.
136
+ *
86
137
  * Returns -1 if no valid split point is found.
87
138
  */
88
139
  export const findSafeSplitPoint = (
@@ -92,16 +143,17 @@ export const findSafeSplitPoint = (
92
143
  const candidateIdx = messages.length - keepRecentMessages;
93
144
  if (candidateIdx < MIN_COMPACTABLE_MESSAGES) return -1;
94
145
 
95
- // Walk backwards from candidate to find a user message boundary
146
+ // Walk backwards from candidate to find a user message boundary that does
147
+ // not orphan a tool-call relationship on the compacted side.
96
148
  for (let i = candidateIdx; i >= MIN_COMPACTABLE_MESSAGES; i--) {
97
- if (messages[i]!.role === "user") {
149
+ if (messages[i]!.role === "user" && !splitOrphansToolCalls(messages, i)) {
98
150
  return i;
99
151
  }
100
152
  }
101
153
 
102
- // Walk forwards from candidate as fallback
154
+ // Walk forwards from candidate as fallback.
103
155
  for (let i = candidateIdx + 1; i < messages.length - 1; i++) {
104
- if (messages[i]!.role === "user") {
156
+ if (messages[i]!.role === "user" && !splitOrphansToolCalls(messages, i)) {
105
157
  if (i < MIN_COMPACTABLE_MESSAGES) return -1;
106
158
  return i;
107
159
  }
@@ -110,25 +162,46 @@ export const findSafeSplitPoint = (
110
162
  return -1;
111
163
  };
112
164
 
165
+ /**
166
+ * Whether a message is itself a prior compaction summary.
167
+ */
168
+ const isCompactionSummary = (msg: Message): boolean =>
169
+ msg.metadata?.isCompactionSummary === true;
170
+
113
171
  /**
114
172
  * Build the summarization messages for the generateText call.
173
+ *
174
+ * Cumulative behavior: when the FIRST compacted message is itself a prior
175
+ * compaction summary, it is passed in FULL (not truncated to
176
+ * SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) and tagged `[prior-summary]`, and
177
+ * the prompt instructs the model to merge-and-update rather than
178
+ * re-summarize. All other messages keep the 1200-char truncation.
115
179
  */
116
180
  const buildSummarizationMessages = (
117
181
  messagesToCompact: Message[],
118
182
  instructions?: string,
119
183
  ): Array<{ role: "user"; content: string }> => {
184
+ const hasPriorSummary =
185
+ messagesToCompact.length > 0 && isCompactionSummary(messagesToCompact[0]!);
186
+
120
187
  const conversationLines: string[] = [];
121
- for (const msg of messagesToCompact) {
188
+ for (let i = 0; i < messagesToCompact.length; i++) {
189
+ const msg = messagesToCompact[i]!;
122
190
  const text = getTextContent(msg);
123
- const truncated = text.length > SUMMARIZATION_MESSAGE_TRUNCATION_CHARS
124
- ? text.slice(0, SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) + "\n...[truncated]"
125
- : text;
126
- conversationLines.push(`[${msg.role}]: ${truncated}`);
191
+ const isPrior = i === 0 && hasPriorSummary;
192
+ // The prior summary is the working state we must not lose — pass it whole.
193
+ const rendered =
194
+ isPrior || text.length <= SUMMARIZATION_MESSAGE_TRUNCATION_CHARS
195
+ ? text
196
+ : text.slice(0, SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) +
197
+ "\n...[truncated]";
198
+ const tag = isPrior ? "prior-summary" : msg.role;
199
+ conversationLines.push(`[${tag}]: ${rendered}`);
127
200
  }
128
201
 
129
- const prompt = instructions
130
- ? `${SUMMARIZATION_PROMPT}\n\nAdditional focus: ${instructions}`
131
- : SUMMARIZATION_PROMPT;
202
+ let prompt = SUMMARIZATION_PROMPT;
203
+ if (hasPriorSummary) prompt = `${prompt}\n\n${CUMULATIVE_SUMMARY_PROMPT}`;
204
+ if (instructions) prompt = `${prompt}\n\nAdditional focus: ${instructions}`;
132
205
 
133
206
  return [
134
207
  {
@@ -138,6 +211,121 @@ const buildSummarizationMessages = (
138
211
  ];
139
212
  };
140
213
 
214
+ interface SubagentLedgerEntry {
215
+ subagentId: string;
216
+ task: string;
217
+ status: string;
218
+ digest: string;
219
+ }
220
+
221
+ /** Match the header line of an injected subagent callback message. */
222
+ const SUBAGENT_RESULT_HEADER =
223
+ /^\[Subagent Result\] Subagent "([^"]*)" \(([^)]*)\) (\S+):/;
224
+
225
+ /**
226
+ * Parse the metadata + text of a subagent-callback user message into a ledger
227
+ * entry. Returns null when the message is not a subagent callback.
228
+ */
229
+ const parseSubagentCallback = (msg: Message): SubagentLedgerEntry | null => {
230
+ if (msg.role !== "user") return null;
231
+ const meta = (msg.metadata ?? {}) as Record<string, unknown>;
232
+ const text = getTextContent(msg);
233
+ const hasMetaFlag =
234
+ meta._subagentCallback === true || meta.subagentCallback === true;
235
+ const hasTextMarker = text.startsWith("[Subagent Result]");
236
+ if (!hasMetaFlag && !hasTextMarker) return null;
237
+
238
+ // Prefer structured metadata, fall back to parsing the header line.
239
+ const headerMatch = text.match(SUBAGENT_RESULT_HEADER);
240
+ const subagentId =
241
+ typeof meta.subagentId === "string" && meta.subagentId
242
+ ? meta.subagentId
243
+ : headerMatch?.[2] ?? "";
244
+ if (!subagentId) return null;
245
+ const task =
246
+ typeof meta.task === "string" && meta.task
247
+ ? meta.task
248
+ : headerMatch?.[1] ?? "";
249
+ const status = headerMatch?.[3] ?? "completed";
250
+
251
+ // Digest = the body after the header line (the result text), capped.
252
+ const bodyStart = text.indexOf("\n\n");
253
+ const body = bodyStart >= 0 ? text.slice(bodyStart + 2) : text;
254
+ const digest =
255
+ body.length > SUBAGENT_DIGEST_CHARS
256
+ ? body.slice(0, SUBAGENT_DIGEST_CHARS) + "…"
257
+ : body;
258
+
259
+ return { subagentId, task, status, digest };
260
+ };
261
+
262
+ /**
263
+ * Parse a prior `## Subagents` ledger block out of an existing compaction
264
+ * summary's content so it can be carried forward cumulatively. The block is
265
+ * rendered by `renderSubagentLedger`, so we parse that same shape.
266
+ */
267
+ const parsePriorLedger = (summaryText: string): SubagentLedgerEntry[] => {
268
+ const headingIdx = summaryText.indexOf(SUBAGENT_LEDGER_HEADING);
269
+ if (headingIdx < 0) return [];
270
+ const block = summaryText.slice(headingIdx + SUBAGENT_LEDGER_HEADING.length);
271
+ const entries: SubagentLedgerEntry[] = [];
272
+ // Each entry: a bullet line "- **<task>** (<id>) — <status>" then a digest
273
+ // line. We tolerate missing digest lines.
274
+ const entryRe =
275
+ /^- \*\*(.*?)\*\* \((.+?)\) — (\S+)\n {2}(.*)$/gm;
276
+ let m: RegExpExecArray | null;
277
+ while ((m = entryRe.exec(block)) !== null) {
278
+ entries.push({
279
+ task: m[1]!,
280
+ subagentId: m[2]!,
281
+ status: m[3]!,
282
+ digest: m[4]!,
283
+ });
284
+ }
285
+ return entries;
286
+ };
287
+
288
+ /**
289
+ * Scan the messages being compacted for subagent-callback records and any
290
+ * prior ledger embedded in a compaction summary, returning a combined,
291
+ * deduped (by subagentId, last-write-wins) list in first-seen order.
292
+ */
293
+ const collectSubagentLedger = (
294
+ messagesToCompact: Message[],
295
+ ): SubagentLedgerEntry[] => {
296
+ const byId = new Map<string, SubagentLedgerEntry>();
297
+ const order: string[] = [];
298
+ const upsert = (entry: SubagentLedgerEntry) => {
299
+ if (!byId.has(entry.subagentId)) order.push(entry.subagentId);
300
+ byId.set(entry.subagentId, entry);
301
+ };
302
+
303
+ for (const msg of messagesToCompact) {
304
+ if (isCompactionSummary(msg)) {
305
+ for (const prior of parsePriorLedger(getTextContent(msg))) upsert(prior);
306
+ continue;
307
+ }
308
+ const entry = parseSubagentCallback(msg);
309
+ if (entry) upsert(entry);
310
+ }
311
+
312
+ return order.map((id) => byId.get(id)!);
313
+ };
314
+
315
+ /**
316
+ * Render the subagent ledger as a verbatim markdown block appended to the
317
+ * summary AFTER the LLM text, so the model can never paraphrase it away.
318
+ * Returns "" when there are no subagents.
319
+ */
320
+ const renderSubagentLedger = (entries: SubagentLedgerEntry[]): string => {
321
+ if (entries.length === 0) return "";
322
+ const lines = entries.map(
323
+ (e) =>
324
+ `- **${e.task}** (${e.subagentId}) — ${e.status}\n ${e.digest.replace(/\n/g, " ")}`,
325
+ );
326
+ return `${SUBAGENT_LEDGER_HEADING}\n${lines.join("\n")}`;
327
+ };
328
+
141
329
  /**
142
330
  * Build the continuation message that replaces compacted messages.
143
331
  */
@@ -217,7 +405,12 @@ export const compactMessages = async (
217
405
  };
218
406
  }
219
407
 
220
- const continuationMessage = buildContinuationMessage(summary);
408
+ // Append the subagent ledger AFTER the LLM summary, verbatim, so the
409
+ // model's paraphrasing can never drop or truncate subagent results.
410
+ const ledger = renderSubagentLedger(collectSubagentLedger(toCompact));
411
+ const summaryWithLedger = ledger ? `${summary}\n\n${ledger}` : summary;
412
+
413
+ const continuationMessage = buildContinuationMessage(summaryWithLedger);
221
414
  const compactedMessages = [continuationMessage, ...toPreserve];
222
415
 
223
416
  return {
package/src/harness.ts CHANGED
@@ -3344,7 +3344,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3344
3344
  return;
3345
3345
  }
3346
3346
  const runtimeToolName = exposedToolNames.get(call.name) ?? call.name;
3347
- yield pushEvent({ type: "tool:started", tool: runtimeToolName, input: call.input });
3347
+ yield pushEvent({ type: "tool:started", tool: runtimeToolName, toolCallId: call.id, input: call.input });
3348
3348
  if (this.requiresApprovalForToolCall(runtimeToolName, call.input)) {
3349
3349
  approvalNeeded.push({
3350
3350
  approvalId: `approval_${randomUUID()}`,
@@ -3563,6 +3563,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3563
3563
  yield pushEvent({
3564
3564
  type: "tool:error",
3565
3565
  tool: result.tool,
3566
+ toolCallId: result.callId,
3566
3567
  error: result.error,
3567
3568
  recoverable: true,
3568
3569
  });
@@ -3604,6 +3605,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3604
3605
  yield pushEvent({
3605
3606
  type: "tool:completed",
3606
3607
  tool: result.tool,
3608
+ toolCallId: result.callId,
3607
3609
  input: callInputMap.get(result.callId),
3608
3610
  output: result.output,
3609
3611
  duration: now() - batchStart,
@@ -693,12 +693,15 @@ export class AgentOrchestrator {
693
693
  result: { status: "completed", response: responseText, steps: 0, tokens: { input: 0, output: 0, cached: 0 }, duration: 0 },
694
694
  timestamp: Date.now(),
695
695
  };
696
- await this.conversationStore.appendSubagentResult(conv.parentConversationId, pendingResult);
696
+ await this.appendSubagentResultReliable(conv.parentConversationId, pendingResult);
697
697
 
698
698
  await this.eventSink(conv.parentConversationId, {
699
699
  type: "subagent:completed",
700
700
  subagentId,
701
701
  conversationId: subagentId,
702
+ task: conv.subagentMeta?.task ?? conv.title,
703
+ parentToolCallId: conv.subagentMeta?.parentToolCallId,
704
+ resultText: responseText,
702
705
  });
703
706
 
704
707
  await this.triggerParentCallback(conv.parentConversationId);
@@ -796,10 +799,14 @@ export class AgentOrchestrator {
796
799
  let latestRunId = "";
797
800
  let runResult: { status: "completed" | "error" | "cancelled"; response?: string; steps: number; duration: number; continuation?: boolean; continuationMessages?: Message[] } | undefined;
798
801
  let runError: { code?: string; message?: string } | undefined;
802
+ // The spawning tool call's id — echoed onto subagent:* events so the
803
+ // client can attach subagent state to that tool's pill.
804
+ let parentToolCallId: string | undefined;
799
805
 
800
806
  try {
801
807
  const conversation = await this.conversationStore.getWithArchive(childConversationId);
802
808
  if (!conversation) throw new Error("Subagent conversation not found");
809
+ parentToolCallId = conversation.subagentMeta?.parentToolCallId;
803
810
 
804
811
  if (conversation.subagentMeta?.status === "stopped") return;
805
812
 
@@ -1054,6 +1061,9 @@ export class AgentOrchestrator {
1054
1061
  type: "subagent:completed",
1055
1062
  subagentId: childConversationId,
1056
1063
  conversationId: childConversationId,
1064
+ task,
1065
+ parentToolCallId,
1066
+ resultText: subagentResponse,
1057
1067
  });
1058
1068
 
1059
1069
  this.triggerParentCallback(parentConversationId).catch(err =>
@@ -1090,6 +1100,8 @@ export class AgentOrchestrator {
1090
1100
  subagentId: childConversationId,
1091
1101
  conversationId: childConversationId,
1092
1102
  error: errMsg,
1103
+ task,
1104
+ parentToolCallId,
1093
1105
  });
1094
1106
 
1095
1107
  this.triggerParentCallback(parentConversationId).catch(err2 =>
@@ -1476,6 +1488,9 @@ export class AgentOrchestrator {
1476
1488
  type: "subagent:completed",
1477
1489
  subagentId: conversationId,
1478
1490
  conversationId,
1491
+ task,
1492
+ parentToolCallId: conversation.subagentMeta?.parentToolCallId,
1493
+ resultText: subagentResponse,
1479
1494
  });
1480
1495
 
1481
1496
  if (parentConv) {
@@ -1520,6 +1535,8 @@ export class AgentOrchestrator {
1520
1535
  type: "subagent:completed",
1521
1536
  subagentId: conversationId,
1522
1537
  conversationId,
1538
+ task,
1539
+ parentToolCallId: conversation.subagentMeta?.parentToolCallId,
1523
1540
  });
1524
1541
 
1525
1542
  if (parentConv) {
@@ -1553,7 +1570,7 @@ export class AgentOrchestrator {
1553
1570
  opts.tenantId ?? null,
1554
1571
  {
1555
1572
  parentConversationId: opts.parentConversationId,
1556
- subagentMeta: { task: opts.task, status: "running", suppressTelemetry: opts.suppressTelemetry },
1573
+ subagentMeta: { task: opts.task, status: "running", suppressTelemetry: opts.suppressTelemetry, parentToolCallId: opts.parentToolCallId },
1557
1574
  messages: [{ role: "user", content: opts.task }],
1558
1575
  },
1559
1576
  );
@@ -1568,6 +1585,7 @@ export class AgentOrchestrator {
1568
1585
  subagentId: conversation.conversationId,
1569
1586
  conversationId: conversation.conversationId,
1570
1587
  task: opts.task,
1588
+ parentToolCallId: opts.parentToolCallId,
1571
1589
  });
1572
1590
 
1573
1591
  if (this.isServerless) {
package/src/state.ts CHANGED
@@ -79,6 +79,9 @@ export interface Conversation {
79
79
  * subagent's runs emit no telemetry (e.g. spawned from an incognito
80
80
  * turn). Read by the orchestrator's runSubagent / continuation. */
81
81
  suppressTelemetry?: boolean;
82
+ /** The parent's `spawn_subagent` tool call id — echoed onto subagent:*
83
+ * events so a client can attach subagent state to that tool's pill. */
84
+ parentToolCallId?: string;
82
85
  };
83
86
  channelMeta?: {
84
87
  platform: string;
@@ -0,0 +1,204 @@
1
+ import type { Message } from "@poncho-ai/sdk";
2
+ import type { PendingSubagentResult } from "../state.js";
3
+
4
+ /**
5
+ * Append-only conversation entries (Phase 3 substrate).
6
+ *
7
+ * The eventual replacement for the mutable per-conversation JSON blob: a
8
+ * conversation becomes an ordered, append-only list of entries, and the
9
+ * mutable-blob clobber race (two writers serializing a stale whole-blob
10
+ * snapshot over each other — the root cause behind lost subagent results)
11
+ * stops being expressible.
12
+ *
13
+ * This module is intentionally PURE: it defines the entry shapes and the
14
+ * functions that rebuild a conversation's LLM context / display transcript
15
+ * / pending-subagent-results from an entry list. No storage engine, no DB,
16
+ * no wiring into the live run loop yet — so it deploys nothing and is
17
+ * fully unit-testable. The engine implementations (append/read on
18
+ * postgres/sqlite/memory) and the write-site conversions come in later PRs
19
+ * once this rebuild logic is proven.
20
+ *
21
+ * Ordering: every entry carries a monotonic per-conversation `seq`. Entries
22
+ * are assumed sorted by `seq` ascending when passed to the rebuild fns.
23
+ */
24
+
25
+ interface BaseEntry {
26
+ /** Stable cross-reference id (uuid). */
27
+ id: string;
28
+ /** Monotonic per-conversation order. */
29
+ seq: number;
30
+ createdAt: number;
31
+ }
32
+
33
+ /** A user-role display message (incl. typed subagent-callback messages). */
34
+ export interface UserMessageEntry extends BaseEntry {
35
+ type: "user_message";
36
+ message: Message;
37
+ turnId: string;
38
+ /** Hidden from the display transcript (e.g. a framed job prompt, an
39
+ * onboarding seed, or an injected subagent-result message). Still part
40
+ * of the record; just not rendered as a chat bubble. */
41
+ hidden?: boolean;
42
+ }
43
+
44
+ /** The final assistant bubble for a completed/cancelled/errored turn. */
45
+ export interface AssistantMessageEntry extends BaseEntry {
46
+ type: "assistant_message";
47
+ message: Message;
48
+ turnId: string;
49
+ runId: string;
50
+ }
51
+
52
+ /** A post-hoc edit to an already-emitted assistant message — replaces the
53
+ * orchestrator/resume "mutate the last assistant message in place" writes
54
+ * with an append. Applied at rebuild time. */
55
+ export interface AssistantAmendmentEntry extends BaseEntry {
56
+ type: "assistant_amendment";
57
+ targetEntryId: string;
58
+ appendText?: string;
59
+ }
60
+
61
+ /** One LLM-transcript message (the model-visible form). Appended from the
62
+ * run loop per step — never diffed from an array. */
63
+ export interface HarnessMessageEntry extends BaseEntry {
64
+ type: "harness_message";
65
+ message: Message;
66
+ turnId: string;
67
+ }
68
+
69
+ /** Compaction overlay: nothing is deleted. At rebuild, the LLM context is
70
+ * the latest compaction's `summaryMessage` followed by the harness
71
+ * messages from `firstKeptSeq` onward. */
72
+ export interface CompactionEntry extends BaseEntry {
73
+ type: "compaction";
74
+ summaryMessage: Message;
75
+ firstKeptSeq: number;
76
+ tokensBefore?: number;
77
+ tokensAfter?: number;
78
+ }
79
+
80
+ /** A finished subagent's result arriving for the parent. Pending = a
81
+ * subagent_result whose seq is not listed in any later callback_started. */
82
+ export interface SubagentResultEntry extends BaseEntry {
83
+ type: "subagent_result";
84
+ result: PendingSubagentResult;
85
+ }
86
+
87
+ /** Marks which subagent_result entries a callback turn consumed (by seq).
88
+ * Consumption is an append, never a delete. */
89
+ export interface CallbackStartedEntry extends BaseEntry {
90
+ type: "callback_started";
91
+ consumedSeqs: number[];
92
+ }
93
+
94
+ export type ConversationEntry =
95
+ | UserMessageEntry
96
+ | AssistantMessageEntry
97
+ | AssistantAmendmentEntry
98
+ | HarnessMessageEntry
99
+ | CompactionEntry
100
+ | SubagentResultEntry
101
+ | CallbackStartedEntry;
102
+
103
+ /**
104
+ * Rebuild the LLM-visible message context from the entry log.
105
+ *
106
+ * If a compaction overlay exists, the context is its summary message
107
+ * followed by every harness message with seq >= firstKeptSeq (a later
108
+ * compaction's firstKeptSeq can point at an earlier summary that was
109
+ * itself appended as a harness message, so layered compactions just work).
110
+ * With no compaction, it's every harness message in order.
111
+ */
112
+ export function buildLlmContext(entries: ConversationEntry[]): Message[] {
113
+ let latestCompaction: CompactionEntry | undefined;
114
+ for (const e of entries) {
115
+ if (e.type === "compaction" && (!latestCompaction || e.seq > latestCompaction.seq)) {
116
+ latestCompaction = e;
117
+ }
118
+ }
119
+
120
+ const harnessMsgs = entries.filter(
121
+ (e): e is HarnessMessageEntry => e.type === "harness_message",
122
+ );
123
+
124
+ if (latestCompaction) {
125
+ const kept = harnessMsgs
126
+ .filter((e) => e.seq >= latestCompaction!.firstKeptSeq)
127
+ .map((e) => e.message);
128
+ return [latestCompaction.summaryMessage, ...kept];
129
+ }
130
+ return harnessMsgs.map((e) => e.message);
131
+ }
132
+
133
+ export interface DisplaySnapshot {
134
+ messages: Message[];
135
+ /** Total display messages available (for pagination UIs). */
136
+ totalMessages: number;
137
+ /** seq of the first message returned (a `beforeSeq` pagination cursor). */
138
+ headSeq: number | null;
139
+ }
140
+
141
+ /**
142
+ * Rebuild the display transcript (the user-visible chat) from the entry
143
+ * log, returning the trailing `tailN` messages. Amendments are folded into
144
+ * their target assistant message; hidden user messages are dropped.
145
+ */
146
+ export function buildDisplaySnapshot(
147
+ entries: ConversationEntry[],
148
+ tailN: number,
149
+ ): DisplaySnapshot {
150
+ const amendmentsByTarget = new Map<string, AssistantAmendmentEntry[]>();
151
+ for (const e of entries) {
152
+ if (e.type === "assistant_amendment") {
153
+ const list = amendmentsByTarget.get(e.targetEntryId) ?? [];
154
+ list.push(e);
155
+ amendmentsByTarget.set(e.targetEntryId, list);
156
+ }
157
+ }
158
+
159
+ const built: { seq: number; message: Message }[] = [];
160
+ for (const e of entries) {
161
+ if (e.type === "user_message") {
162
+ if (e.hidden) continue;
163
+ built.push({ seq: e.seq, message: e.message });
164
+ } else if (e.type === "assistant_message") {
165
+ let content = typeof e.message.content === "string" ? e.message.content : "";
166
+ const amendments = amendmentsByTarget.get(e.id);
167
+ if (amendments) {
168
+ for (const a of amendments.sort((x, y) => x.seq - y.seq)) {
169
+ if (a.appendText) content += a.appendText;
170
+ }
171
+ }
172
+ built.push({ seq: e.seq, message: { ...e.message, content } });
173
+ }
174
+ }
175
+
176
+ const total = built.length;
177
+ const tail = tailN >= total ? built : built.slice(total - tailN);
178
+ return {
179
+ messages: tail.map((b) => b.message),
180
+ totalMessages: total,
181
+ headSeq: tail.length > 0 ? tail[0]!.seq : null,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Subagent results that have arrived but not yet been consumed by a
187
+ * callback turn — the append-only replacement for the mutable
188
+ * `pendingSubagentResults` array. A result is pending unless a later
189
+ * callback_started lists its seq in `consumedSeqs`.
190
+ */
191
+ export function getPendingSubagentResults(
192
+ entries: ConversationEntry[],
193
+ ): PendingSubagentResult[] {
194
+ const consumed = new Set<number>();
195
+ for (const e of entries) {
196
+ if (e.type === "callback_started") {
197
+ for (const s of e.consumedSeqs) consumed.add(s);
198
+ }
199
+ }
200
+ return entries
201
+ .filter((e): e is SubagentResultEntry => e.type === "subagent_result")
202
+ .filter((e) => !consumed.has(e.seq))
203
+ .map((e) => e.result);
204
+ }
@@ -40,6 +40,10 @@ export interface SubagentManager {
40
40
  /** Inherit the parent run's telemetry choice — when true, the subagent
41
41
  * run (and its re-runs) emit no telemetry. */
42
42
  suppressTelemetry?: boolean;
43
+ /** The id of the `spawn_subagent` tool call that produced this subagent,
44
+ * so its events can carry `parentToolCallId` and a client can attach
45
+ * subagent state to the spawning tool's pill. */
46
+ parentToolCallId?: string;
43
47
  }): Promise<SubagentSpawnResult>;
44
48
 
45
49
  sendMessage(subagentId: string, message: string): Promise<SubagentSpawnResult>;
@@ -46,6 +46,7 @@ export const createSubagentTools = (
46
46
  ownerId,
47
47
  tenantId: context.tenantId,
48
48
  suppressTelemetry: context.suppressTelemetry,
49
+ parentToolCallId: context.toolCallId,
49
50
  });
50
51
  return { subagentId, status: "running" };
51
52
  },
@@ -62,7 +62,10 @@ export class ToolDispatcher {
62
62
  }
63
63
 
64
64
  try {
65
- const output = await definition.handler(call.input, context);
65
+ // Per-call context: stamp the current tool call's id so handlers that
66
+ // spawn further work (spawn_subagent) can record `parentToolCallId`.
67
+ // A fresh object — `context` is shared across a batch, don't mutate it.
68
+ const output = await definition.handler(call.input, { ...context, toolCallId: call.id });
66
69
  if (context.abortSignal?.aborted) {
67
70
  return {
68
71
  callId: call.id,