@poncho-ai/harness 0.60.1 → 0.61.1

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
@@ -9,22 +9,36 @@ const MIN_COMPACTABLE_MESSAGES = 4;
9
9
  const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
10
10
  enabled: true,
11
11
  trigger: 0.75,
12
- keepRecentMessages: 4,
12
+ keepRecentTurns: 4,
13
+ keepRecentMessages: 6,
13
14
  };
14
- const SUMMARIZATION_MESSAGE_TRUNCATION_CHARS = 1200;
15
- const SUMMARIZATION_MAX_OUTPUT_TOKENS = 768;
15
+ const SUMMARIZATION_MESSAGE_TRUNCATION_CHARS = 4000;
16
+ const SUMMARIZATION_MAX_OUTPUT_TOKENS = 8192;
17
+ /**
18
+ * Upper bound on the tokens fed INTO the summarization model call. With hundreds
19
+ * of compacted messages at 4000 chars each the joined transcript can exceed the
20
+ * summarizer's own window; we drop the oldest non-error, non-prior-summary
21
+ * messages first so recent context and every failure survive. Held well under a
22
+ * 200k window to leave room for the prompt + output.
23
+ */
24
+ const SUMMARIZATION_MAX_INPUT_TOKENS = 120_000;
16
25
 
17
26
  const SUMMARIZATION_PROMPT = `Summarize the following conversation into a structured working state that allows continuation without re-asking questions. Include:
18
27
 
19
28
  1. **User intent**: What the user originally asked for and any refinements
20
- 2. **Completed work**: What has been accomplished so far
29
+ 2. **Completed work**: What has been accomplished AND CONFIRMED so far
21
30
  3. **Key decisions**: Technical decisions made and their rationale
22
- 4. **Errors & fixes**: Errors encountered and how they were resolved
23
- 5. **Referenced resources**: Files, URLs, tools, or data referenced
24
- 6. **Pending next steps**: What remains to be done
31
+ 4. **Unresolved errors & failures**: Every error, failed or uncertain tool call, and failed or incomplete subagent — recorded verbatim enough to act on. NEVER drop a failure because it is old. This section is REQUIRED whenever any failure occurred; do not omit it.
32
+ 5. **Pending promises**: Anything the assistant said it WOULD do but has not confirmed done.
33
+ 6. **Referenced resources**: Files, URLs, tools, or data referenced
34
+ 7. **Pending next steps**: What remains to be done
35
+
36
+ Rules:
37
+ - NEVER describe work as "completed", "done", or "fixed" unless the conversation contains explicit confirmation (a successful tool result or the user acknowledging it). If completion is unverified, record it under "Pending next steps" as unconfirmed.
38
+ - Preserve identifiers verbatim — file paths, URLs, IDs, command names, tool names. Do not paraphrase or truncate them.
25
39
 
26
40
  Be concise but preserve all information needed to continue the task.
27
- Omit any section that has no relevant content.`;
41
+ Omit any section that has no relevant content, EXCEPT "Unresolved errors & failures" when a failure occurred.`;
28
42
 
29
43
  /**
30
44
  * Extra instruction appended when the first compacted message is itself a
@@ -34,8 +48,13 @@ Omit any section that has no relevant content.`;
34
48
  */
35
49
  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
50
 
37
- /** Max chars of a subagent result text kept verbatim in the ledger digest. */
38
- const SUBAGENT_DIGEST_CHARS = 500;
51
+ /**
52
+ * Max chars of a subagent result text kept verbatim in the ledger digest. Kept
53
+ * large (and NOT gated on outcome) so a failed subagent's own explanation
54
+ * survives compaction — the failing case is exactly the one that must not be
55
+ * clipped, and it can be mislabeled, so gating on status would be self-defeating.
56
+ */
57
+ const SUBAGENT_DIGEST_CHARS = 2000;
39
58
 
40
59
  /** Heading used for the verbatim, model-proof subagent ledger block. */
41
60
  const SUBAGENT_LEDGER_HEADING = "## Subagents";
@@ -47,6 +66,8 @@ export const resolveCompactionConfig = (
47
66
  return {
48
67
  enabled: explicit.enabled ?? DEFAULT_COMPACTION_CONFIG.enabled,
49
68
  trigger: explicit.trigger ?? DEFAULT_COMPACTION_CONFIG.trigger,
69
+ keepRecentTurns:
70
+ explicit.keepRecentTurns ?? DEFAULT_COMPACTION_CONFIG.keepRecentTurns,
50
71
  keepRecentMessages:
51
72
  explicit.keepRecentMessages ??
52
73
  DEFAULT_COMPACTION_CONFIG.keepRecentMessages,
@@ -162,6 +183,71 @@ export const findSafeSplitPoint = (
162
183
  return -1;
163
184
  };
164
185
 
186
+ /**
187
+ * Find the split index that preserves the last `keepRecentTurns` whole *turns*
188
+ * verbatim. A turn begins at a `role:"user"` message and runs until the next
189
+ * `user` message. Everything before the split is folded into the summary;
190
+ * everything from the split onward is kept as-is.
191
+ *
192
+ * Two guards make a candidate split acceptable:
193
+ * 1. The compacted side has at least `MIN_COMPACTABLE_MESSAGES` messages.
194
+ * 2. The preserved side estimates at most `maxPreservedTokens` — so keeping N
195
+ * large turns can never leave the post-compaction context above the
196
+ * compaction trigger (which caused re-compaction thrash / window overflow).
197
+ * The tool-call-orphan guard (`splitOrphansToolCalls`) is preserved: a split
198
+ * that would strand an assistant tool-call on the compacted side steps to an
199
+ * earlier user boundary (which only preserves more, always safe).
200
+ *
201
+ * We try the largest N (up to `keepRecentTurns`) first and decrement, so we
202
+ * keep as many turns as fit. If no turn boundary yields a safe split (e.g. a
203
+ * single giant turn near the window), fall back to the message-based
204
+ * `findSafeSplitPoint` so the middle of that turn still compacts.
205
+ *
206
+ * Returns -1 when nothing safe can be compacted.
207
+ */
208
+ export const findSafeSplitPointByTurns = (
209
+ messages: Message[],
210
+ keepRecentTurns: number,
211
+ keepRecentMessagesFallback: number,
212
+ maxPreservedTokens: number,
213
+ ): number => {
214
+ // Indices of every user message, in order.
215
+ const userIdx: number[] = [];
216
+ for (let i = 0; i < messages.length; i++) {
217
+ if (messages[i]!.role === "user") userIdx.push(i);
218
+ }
219
+
220
+ const maxN = Math.min(keepRecentTurns, userIdx.length);
221
+ for (let n = maxN; n >= 1; n--) {
222
+ // The Nth-from-last user message starts the preserved region.
223
+ let guardN = n;
224
+ let split = userIdx[userIdx.length - guardN]!;
225
+ // Orphan guard: step to an earlier user boundary if the compacted side
226
+ // would end on an assistant tool-call whose result moved to the preserved
227
+ // side. Walking earlier only preserves more, so it stays within budget
228
+ // checks below.
229
+ while (
230
+ splitOrphansToolCalls(messages, split) &&
231
+ guardN < userIdx.length
232
+ ) {
233
+ guardN++;
234
+ split = userIdx[userIdx.length - guardN]!;
235
+ }
236
+ if (split < MIN_COMPACTABLE_MESSAGES) continue;
237
+ const preservedTokens = estimateTokens(
238
+ messages
239
+ .slice(split)
240
+ .map((m) => getTextContent(m))
241
+ .join("\n"),
242
+ );
243
+ if (preservedTokens <= maxPreservedTokens) return split;
244
+ }
245
+
246
+ // No turn boundary fits: fall back to the legacy message-based split so a
247
+ // single giant turn still gets its middle compacted.
248
+ return findSafeSplitPoint(messages, keepRecentMessagesFallback);
249
+ };
250
+
165
251
  /**
166
252
  * Whether a message is itself a prior compaction summary.
167
253
  */
@@ -175,8 +261,17 @@ const isCompactionSummary = (msg: Message): boolean =>
175
261
  * compaction summary, it is passed in FULL (not truncated to
176
262
  * SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) and tagged `[prior-summary]`, and
177
263
  * the prompt instructs the model to merge-and-update rather than
178
- * re-summarize. All other messages keep the 1200-char truncation.
264
+ * re-summarize. All other messages keep the per-message truncation.
265
+ *
266
+ * Total-input budget: with many messages the joined transcript can exceed the
267
+ * summarizer's own window, so we cap it at SUMMARIZATION_MAX_INPUT_TOKENS and
268
+ * drop the OLDEST non-important lines first. A line is "important" (never
269
+ * dropped) if it is the prior summary or carries a failure/error signal — so
270
+ * every failure and the most recent context always reach the summarizer.
179
271
  */
272
+ const FAILURE_SIGNAL_RE =
273
+ /\berror\b|\bfailed\b|\bfailure\b|\bexception\b|could ?n[’']?t|cannot\b|unable to|\[subagent result\]/i;
274
+
180
275
  const buildSummarizationMessages = (
181
276
  messagesToCompact: Message[],
182
277
  instructions?: string,
@@ -184,7 +279,12 @@ const buildSummarizationMessages = (
184
279
  const hasPriorSummary =
185
280
  messagesToCompact.length > 0 && isCompactionSummary(messagesToCompact[0]!);
186
281
 
187
- const conversationLines: string[] = [];
282
+ interface Line {
283
+ text: string;
284
+ tokens: number;
285
+ important: boolean;
286
+ }
287
+ const lines: Line[] = [];
188
288
  for (let i = 0; i < messagesToCompact.length; i++) {
189
289
  const msg = messagesToCompact[i]!;
190
290
  const text = getTextContent(msg);
@@ -196,9 +296,29 @@ const buildSummarizationMessages = (
196
296
  : text.slice(0, SUMMARIZATION_MESSAGE_TRUNCATION_CHARS) +
197
297
  "\n...[truncated]";
198
298
  const tag = isPrior ? "prior-summary" : msg.role;
199
- conversationLines.push(`[${tag}]: ${rendered}`);
299
+ const line = `[${tag}]: ${rendered}`;
300
+ lines.push({
301
+ text: line,
302
+ tokens: estimateTokens(line),
303
+ important: isPrior || FAILURE_SIGNAL_RE.test(text),
304
+ });
200
305
  }
201
306
 
307
+ // Enforce the input budget: while over, drop the oldest non-important line.
308
+ let total = lines.reduce((sum, l) => sum + l.tokens, 0);
309
+ if (total > SUMMARIZATION_MAX_INPUT_TOKENS) {
310
+ for (let i = 0; i < lines.length && total > SUMMARIZATION_MAX_INPUT_TOKENS; ) {
311
+ if (lines[i]!.important) {
312
+ i++;
313
+ continue;
314
+ }
315
+ total -= lines[i]!.tokens;
316
+ lines.splice(i, 1);
317
+ }
318
+ }
319
+
320
+ const conversationLines = lines.map((l) => l.text);
321
+
202
322
  let prompt = SUMMARIZATION_PROMPT;
203
323
  if (hasPriorSummary) prompt = `${prompt}\n\n${CUMULATIVE_SUMMARY_PROMPT}`;
204
324
  if (instructions) prompt = `${prompt}\n\nAdditional focus: ${instructions}`;
@@ -246,7 +366,15 @@ const parseSubagentCallback = (msg: Message): SubagentLedgerEntry | null => {
246
366
  typeof meta.task === "string" && meta.task
247
367
  ? meta.task
248
368
  : headerMatch?.[1] ?? "";
249
- const status = headerMatch?.[3] ?? "completed";
369
+ // The ledger "status" is the subagent's TASK OUTCOME (succeeded/failed/
370
+ // partial/unknown), not its run status. Prefer the structured metadata; fall
371
+ // back to the header token. NEVER default to "completed" — an unparseable
372
+ // header must not silently assert success. `status` remains the field name
373
+ // for backward compat with parsePriorLedger's rendered shape.
374
+ const status =
375
+ (typeof meta.taskOutcome === "string" && meta.taskOutcome) ||
376
+ headerMatch?.[3] ||
377
+ "unknown";
250
378
 
251
379
  // Digest = the body after the header line (the result text), capped.
252
380
  const bodyStart = text.indexOf("\n\n");
@@ -338,8 +466,17 @@ const buildContinuationMessage = (summary: string): Message => ({
338
466
 
339
467
  export interface CompactMessagesOptions {
340
468
  instructions?: string;
469
+ /**
470
+ * The model's context window (tokens). Used to bound the preserved side of a
471
+ * turn-based split so keeping N recent turns can't leave the post-compaction
472
+ * context above the compaction trigger. Defaults to 200k when omitted.
473
+ */
474
+ contextWindow?: number;
341
475
  }
342
476
 
477
+ /** Fraction of the context window the preserved (verbatim) side may occupy. */
478
+ const MAX_PRESERVED_CONTEXT_FRACTION = 0.5;
479
+
343
480
  export interface CompactResult {
344
481
  compacted: boolean;
345
482
  messages: Message[];
@@ -363,7 +500,18 @@ export const compactMessages = async (
363
500
  config: CompactionConfig,
364
501
  options?: CompactMessagesOptions,
365
502
  ): Promise<CompactResult> => {
366
- const splitIdx = findSafeSplitPoint(messages, config.keepRecentMessages);
503
+ const contextWindow = options?.contextWindow && options.contextWindow > 0
504
+ ? options.contextWindow
505
+ : 200_000;
506
+ const maxPreservedTokens = Math.floor(
507
+ contextWindow * MAX_PRESERVED_CONTEXT_FRACTION,
508
+ );
509
+ const splitIdx = findSafeSplitPointByTurns(
510
+ messages,
511
+ config.keepRecentTurns,
512
+ config.keepRecentMessages,
513
+ maxPreservedTokens,
514
+ );
367
515
  if (splitIdx === -1) {
368
516
  return {
369
517
  compacted: false,
package/src/harness.ts CHANGED
@@ -2286,7 +2286,12 @@ export class AgentHarness {
2286
2286
  const modelName = agent.frontmatter.model?.name ?? "claude-opus-4-5";
2287
2287
  const modelInstance = this.modelProvider(modelName);
2288
2288
  const config = resolveCompactionConfig(agent.frontmatter.compaction);
2289
- return compactMessages(modelInstance, messages, config, options);
2289
+ const contextWindow =
2290
+ agent.frontmatter.model?.contextWindow ?? getModelContextWindow(modelName);
2291
+ return compactMessages(modelInstance, messages, config, {
2292
+ ...options,
2293
+ contextWindow: options?.contextWindow ?? contextWindow,
2294
+ });
2290
2295
  }
2291
2296
 
2292
2297
  async *run(input: RunInput): AsyncGenerator<AgentEvent> {
@@ -2317,7 +2322,7 @@ export class AgentHarness {
2317
2322
  let agent = this.parsedAgent as ParsedAgent;
2318
2323
  const runId = `run_${randomUUID()}`;
2319
2324
  const start = now();
2320
- const maxSteps = agent.frontmatter.limits?.maxSteps ?? 20;
2325
+ const maxSteps = input.maxSteps ?? agent.frontmatter.limits?.maxSteps ?? 20;
2321
2326
  // A constructor-level override (e.g. a longer budget for background
2322
2327
  // subagents) takes precedence over the agent definition's limits.timeout.
2323
2328
  const configuredTimeout = this.runTimeoutSecOverride ?? agent.frontmatter.limits?.timeout;
@@ -3082,6 +3087,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3082
3087
  modelInstance,
3083
3088
  messages,
3084
3089
  compactionConfig,
3090
+ { contextWindow },
3085
3091
  );
3086
3092
  if (compactResult.compacted) {
3087
3093
  messages.length = 0;
@@ -3096,6 +3102,16 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3096
3102
  emittedMessages.pop();
3097
3103
  }
3098
3104
  }
3105
+ // MID-RUN COMPACTION (step > 1) intentionally emits
3106
+ // `compactedMessages: undefined`. The live `messages` array is
3107
+ // already rewritten in place above (so the rest of the run and
3108
+ // the end-of-run `continuationMessages` reflect the compaction),
3109
+ // but we do NOT drive the runner's persisted `compactedHistory`
3110
+ // archive mid-turn: that would require a get→mutate→update on the
3111
+ // conversation row while the turn is still in flight, which is the
3112
+ // clobber pattern PonchOS forbids for non-terminal writers. The
3113
+ // compacted content is preserved in the summary; only the raw
3114
+ // pre-compaction transcript archive skips these mid-run events.
3099
3115
  const tokensAfterCompaction = estimateTotalTokens(systemPrompt, messages, toolDefsJsonForEstimate);
3100
3116
  latestContextTokens = tokensAfterCompaction;
3101
3117
  toolOutputEstimateSinceModel = 0;
@@ -19,6 +19,9 @@ export {
19
19
  executeConversationTurn,
20
20
  normalizeApprovalCheckpoint,
21
21
  buildApprovalCheckpoints,
22
+ assembleCheckpointMessages,
23
+ buildToolResultMessage,
24
+ buildResumeCheckpoints,
22
25
  applyTurnMetadata,
23
26
  type StoredApproval,
24
27
  type PendingToolCall,
@@ -10,6 +10,9 @@ import {
10
10
  applyTurnMetadata,
11
11
  normalizeApprovalCheckpoint,
12
12
  buildApprovalCheckpoints,
13
+ assembleCheckpointMessages,
14
+ buildToolResultMessage,
15
+ buildResumeCheckpoints,
13
16
  createTurnDraftState,
14
17
  recordStandardTurnEvent,
15
18
  type TurnDraftState,
@@ -107,6 +110,48 @@ export const abnormalEndResponse = (opts: {
107
110
  return opts.gathered ? `${head} ${recover}\n\n${opts.gathered}` : `${head} ${recover}`;
108
111
  };
109
112
 
113
+ /**
114
+ * Machine-readable verdict a subagent is asked to append to its final message,
115
+ * e.g. `[[OUTCOME: failed]] reason`. Parsed deterministically — see
116
+ * SUBAGENT_OUTCOME_INSTRUCTION and `deriveTaskOutcome`.
117
+ */
118
+ const SUBAGENT_OUTCOME_RE = /\[\[\s*OUTCOME\s*:\s*(succeeded|failed|partial)\s*\]\]/i;
119
+
120
+ /** Appended to a subagent's task so it self-reports whether it succeeded. */
121
+ export const SUBAGENT_OUTCOME_INSTRUCTION =
122
+ "\n\nAt the very end of your final message, on its own line, append a " +
123
+ "machine-readable outcome verdict for the task you were given: " +
124
+ "`[[OUTCOME: succeeded]]`, `[[OUTCOME: partial]]`, or `[[OUTCOME: failed]]`, " +
125
+ "followed by a one-line reason. Judge honestly by whether YOU actually " +
126
+ "accomplished the task (produced the real result / wrote the files), not by " +
127
+ "whether the run finished. If a tool or capability you needed was missing, " +
128
+ "that is `failed`.";
129
+
130
+ /**
131
+ * Derive a subagent's task outcome (distinct from run status). Abnormal ends and
132
+ * empty output are `failed`; otherwise we parse the self-declared verdict and
133
+ * default to `unknown` when absent (never assume success).
134
+ */
135
+ export const deriveTaskOutcome = (
136
+ gathered: string,
137
+ abnormal: boolean,
138
+ ): import("../state.js").SubagentTaskOutcome => {
139
+ if (abnormal) return "failed";
140
+ if (!gathered.trim()) return "failed";
141
+ const m = gathered.match(SUBAGENT_OUTCOME_RE);
142
+ if (!m) return "unknown";
143
+ const v = m[1]!.toLowerCase();
144
+ return v === "succeeded" ? "succeeded" : v === "partial" ? "partial" : "failed";
145
+ };
146
+
147
+ /**
148
+ * Remove the machine-readable `[[OUTCOME: …]]` verdict (and its one-line reason)
149
+ * from the text delivered to the parent — the outcome is carried structurally in
150
+ * the callback header/metadata, so the marker shouldn't leak into chat UI.
151
+ */
152
+ export const stripOutcomeVerdict = (text: string): string =>
153
+ text.replace(/\n*\[\[\s*OUTCOME\s*:\s*(?:succeeded|failed|partial)\s*\]\].*$/is, "").trimEnd();
154
+
110
155
  // ── Types ──
111
156
 
112
157
  export type ActiveConversationRun = {
@@ -379,38 +424,11 @@ export class AgentOrchestrator {
379
424
  let latestRunId = conversation.runtimeRunId ?? "";
380
425
  let checkpointedRun = false;
381
426
 
382
- const normalizedCheckpoint = normalizeApprovalCheckpoint(checkpoint, conversation.messages);
383
- const baseMessages = normalizedCheckpoint.baseMessageCount != null
384
- ? conversation.messages.slice(0, normalizedCheckpoint.baseMessageCount)
385
- : [];
386
- const fullCheckpointMessages = [...baseMessages, ...normalizedCheckpoint.checkpointMessages!];
387
-
388
- let resumeToolResultMsg: Message | undefined;
389
- const lastCpMsg = fullCheckpointMessages[fullCheckpointMessages.length - 1];
390
- if (lastCpMsg?.role === "assistant") {
391
- try {
392
- const parsed = JSON.parse(typeof lastCpMsg.content === "string" ? lastCpMsg.content : "");
393
- const cpToolCalls: Array<{ id: string; name: string }> = parsed.tool_calls ?? [];
394
- if (cpToolCalls.length > 0) {
395
- const providedMap = new Map(toolResults.map(r => [r.callId, r]));
396
- resumeToolResultMsg = {
397
- role: "tool",
398
- content: JSON.stringify(cpToolCalls.map(tc => {
399
- const provided = providedMap.get(tc.id);
400
- return {
401
- type: "tool_result",
402
- tool_use_id: tc.id,
403
- tool_name: provided?.toolName ?? tc.name,
404
- content: provided
405
- ? (provided.error ? `Tool error: ${provided.error}` : JSON.stringify(provided.result ?? null))
406
- : "Tool error: Tool execution deferred (pending approval checkpoint)",
407
- };
408
- })),
409
- metadata: { timestamp: Date.now() },
410
- };
411
- }
412
- } catch { /* last message is not a parseable assistant-with-tools -- skip */ }
413
- }
427
+ const fullCheckpointMessages = assembleCheckpointMessages(conversation, checkpoint);
428
+ const resumeToolResultMsg = buildToolResultMessage(
429
+ fullCheckpointMessages[fullCheckpointMessages.length - 1],
430
+ toolResults,
431
+ );
414
432
  const fullCheckpointWithResults = resumeToolResultMsg
415
433
  ? [...fullCheckpointMessages, resumeToolResultMsg]
416
434
  : fullCheckpointMessages;
@@ -463,12 +481,10 @@ export class AgentOrchestrator {
463
481
  };
464
482
  const conv = await this.conversationStore.get(conversationId);
465
483
  if (conv) {
466
- conv.pendingApprovals = buildApprovalCheckpoints({
467
- approvals: cpEvent.approvals,
484
+ conv.pendingApprovals = buildResumeCheckpoints({
485
+ priorMessages: fullCheckpointWithResults,
486
+ checkpointEvent: cpEvent,
468
487
  runId: latestRunId,
469
- checkpointMessages: [...fullCheckpointWithResults, ...cpEvent.checkpointMessages],
470
- baseMessageCount: 0,
471
- pendingToolCalls: cpEvent.pendingToolCalls,
472
488
  });
473
489
  conv.updatedAt = Date.now();
474
490
  await this.conversationStore.update(conv);
@@ -697,6 +713,7 @@ export class AgentOrchestrator {
697
713
  subagentId,
698
714
  task: conv.subagentMeta?.task ?? conv.title,
699
715
  status: "completed",
716
+ taskOutcome: deriveTaskOutcome(responseText, false),
700
717
  result: { status: "completed", response: responseText, steps: 0, tokens: { input: 0, output: 0, cached: 0 }, duration: 0 },
701
718
  timestamp: Date.now(),
702
719
  };
@@ -829,7 +846,11 @@ export class AgentOrchestrator {
829
846
  const recallParams = this.hooks?.buildRecallParams?.({ ownerId, tenantId: conversation.tenantId, excludeConversationId: childConversationId }) ?? {};
830
847
 
831
848
  for await (const event of childHarness.runWithTelemetry({
832
- task,
849
+ // Ask the subagent to self-report a machine-readable task outcome at the
850
+ // end of its final message; `deriveTaskOutcome` parses it so a failed
851
+ // task can never be recorded as "completed". Appended only to the model
852
+ // input, not the stored/displayed task name.
853
+ task: `${task}${SUBAGENT_OUTCOME_INSTRUCTION}`,
833
854
  conversationId: childConversationId,
834
855
  tenantId: conversation.tenantId ?? undefined,
835
856
  parameters: withToolResultArchiveParam({
@@ -1040,13 +1061,16 @@ export class AgentOrchestrator {
1040
1061
  // the work — deliver what it gathered, tagged so the parent knows it
1041
1062
  // didn't finish, and build a result so it never renders as "(no result)".
1042
1063
  const abnormal = !runResult;
1064
+ const taskOutcome = deriveTaskOutcome(gathered, abnormal);
1065
+ const cleanedGathered = stripOutcomeVerdict(gathered);
1043
1066
  const subagentResponse = abnormal
1044
- ? abnormalEndResponse({ subagentId: childConversationId, gathered, runError })
1045
- : gathered;
1067
+ ? abnormalEndResponse({ subagentId: childConversationId, gathered: cleanedGathered, runError })
1068
+ : cleanedGathered;
1046
1069
  const pendingResult: PendingSubagentResult = {
1047
1070
  subagentId: childConversationId,
1048
1071
  task,
1049
1072
  status: abnormal ? "error" : "completed",
1073
+ taskOutcome,
1050
1074
  result: {
1051
1075
  status: runResult?.status ?? "error",
1052
1076
  response: subagentResponse,
@@ -1096,6 +1120,7 @@ export class AgentOrchestrator {
1096
1120
  subagentId: childConversationId,
1097
1121
  task,
1098
1122
  status: "error",
1123
+ taskOutcome: "failed",
1099
1124
  error: { code: "SUBAGENT_ERROR", message: errMsg },
1100
1125
  timestamp: Date.now(),
1101
1126
  };
@@ -1185,8 +1210,8 @@ export class AgentOrchestrator {
1185
1210
  : "(no result)";
1186
1211
  const injected: Message = {
1187
1212
  role: "user",
1188
- content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.status}:\n\n${resultBody}`,
1189
- metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, timestamp: pr.timestamp } as Message["metadata"],
1213
+ content: `[Subagent Result] Subagent "${pr.task}" (${pr.subagentId}) ${pr.taskOutcome}:\n\n${resultBody}`,
1214
+ metadata: { _subagentCallback: true, subagentId: pr.subagentId, task: pr.task, taskOutcome: pr.taskOutcome, timestamp: pr.timestamp } as Message["metadata"],
1190
1215
  };
1191
1216
  injectedCallbackMessages.push(injected);
1192
1217
  conversation.messages.push(injected);
@@ -1516,9 +1541,11 @@ export class AgentOrchestrator {
1516
1541
  if (freshSubConv) gathered = realResponseText(lastAssistantText(freshSubConv.messages));
1517
1542
  }
1518
1543
  const abnormal = !runResult;
1544
+ const taskOutcome = deriveTaskOutcome(gathered, abnormal);
1545
+ const cleanedGathered = stripOutcomeVerdict(gathered);
1519
1546
  const subagentResponse = abnormal
1520
- ? abnormalEndResponse({ subagentId: conversationId, gathered, runError })
1521
- : gathered;
1547
+ ? abnormalEndResponse({ subagentId: conversationId, gathered: cleanedGathered, runError })
1548
+ : cleanedGathered;
1522
1549
 
1523
1550
  const parentConv = await this.conversationStore.get(parentConversationId);
1524
1551
  if (parentConv) {
@@ -1526,6 +1553,7 @@ export class AgentOrchestrator {
1526
1553
  subagentId: conversationId,
1527
1554
  task,
1528
1555
  status: abnormal ? "error" : "completed",
1556
+ taskOutcome,
1529
1557
  result: { status: runResult?.status ?? "error", response: subagentResponse, steps: runResult?.steps ?? 0, tokens: { input: 0, output: 0, cached: 0 }, duration: runResult?.duration ?? 0 },
1530
1558
  ...(abnormal
1531
1559
  ? { error: { code: runError?.code ?? "SUBAGENT_INCOMPLETE", message: runError?.message ?? "subagent ended without a result" } }
@@ -1576,6 +1604,7 @@ export class AgentOrchestrator {
1576
1604
  subagentId: conversationId,
1577
1605
  task,
1578
1606
  status: "error",
1607
+ taskOutcome: "failed",
1579
1608
  error: { code: "CONTINUATION_ERROR", message: err instanceof Error ? err.message : String(err) },
1580
1609
  timestamp: Date.now(),
1581
1610
  };
@@ -1858,6 +1887,7 @@ export class AgentOrchestrator {
1858
1887
  subagentId: childConversationId,
1859
1888
  task,
1860
1889
  status: "error",
1890
+ taskOutcome: "failed",
1861
1891
  error: { code: "SUBAGENT_SPAWN_FAILED", message },
1862
1892
  timestamp: Date.now(),
1863
1893
  });
@@ -1892,6 +1922,7 @@ export class AgentOrchestrator {
1892
1922
  subagentId: conv.conversationId,
1893
1923
  task: conv.subagentMeta.task,
1894
1924
  status: "error",
1925
+ taskOutcome: "failed",
1895
1926
  error: conv.subagentMeta.error,
1896
1927
  timestamp: Date.now(),
1897
1928
  };
@@ -84,6 +84,12 @@ export interface RunConversationTurnOpts {
84
84
  * built with an OTLP exporter attached.
85
85
  */
86
86
  suppressTelemetry?: boolean;
87
+ /**
88
+ * Forwarded to `RunInput.maxSteps`: per-run step-ceiling override. Lets the
89
+ * foreground chat turn run a higher ceiling than background/job turns that
90
+ * share the same agent definition.
91
+ */
92
+ maxSteps?: number;
87
93
  /**
88
94
  * Forwarded to `RunInput.telemetryAttributes` — extra attributes for the
89
95
  * `invoke_agent` root span (e.g. run kind / job name for segmentation).
@@ -252,6 +258,7 @@ export const runConversationTurn = async (
252
258
  disablePromptCache: opts.disablePromptCache,
253
259
  suppressTelemetry: opts.suppressTelemetry,
254
260
  model: opts.model,
261
+ maxSteps: opts.maxSteps,
255
262
  volatileContext: opts.volatileContext,
256
263
  telemetryAttributes: opts.telemetryAttributes,
257
264
  },