@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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +48 -0
- package/dist/index.d.ts +92 -1
- package/dist/index.js +191 -61
- package/package.json +2 -2
- package/src/agent-parser.ts +15 -1
- package/src/compaction.ts +163 -15
- package/src/harness.ts +18 -2
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/orchestrator.ts +75 -44
- package/src/orchestrator/run-conversation-turn.ts +7 -0
- package/src/orchestrator/turn.ts +130 -0
- package/src/state.ts +13 -0
- package/test/compaction.test.ts +124 -2
- package/test/resume-transcript-integrity.test.ts +156 -0
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
|
-
|
|
12
|
+
keepRecentTurns: 4,
|
|
13
|
+
keepRecentMessages: 6,
|
|
13
14
|
};
|
|
14
|
-
const SUMMARIZATION_MESSAGE_TRUNCATION_CHARS =
|
|
15
|
-
const SUMMARIZATION_MAX_OUTPUT_TOKENS =
|
|
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. **
|
|
23
|
-
5. **
|
|
24
|
-
6. **
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 =
|
|
467
|
-
|
|
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
|
-
:
|
|
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.
|
|
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
|
-
:
|
|
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
|
},
|