brl-subagent 2.3.6 → 2.3.7
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/AGENT.md +6 -0
- package/README.md +1 -1
- package/package.json +5 -5
- package/src/index.ts +93 -57
- package/src/logging.ts +39 -15
- package/src/notify-completion.ts +38 -7
- package/src/runner.ts +2 -1
- package/src/session-manager.ts +199 -37
- package/src/templates.ts +1 -1
- package/src/transcript.ts +11 -6
- package/src/tui.ts +13 -52
- package/src/types.ts +142 -4
package/AGENT.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
> The `delegate_task` prompt guidelines are a derived summary of this file; if they disagree, this file wins.
|
|
4
4
|
|
|
5
|
+
> **Naming:** deliberately `AGENT.md`, **not** `AGENTS.md`. This is *not* a pi
|
|
6
|
+
> context file — it is read on demand via the extension's `delegate_task`
|
|
7
|
+
> guidance, not auto-loaded into the system prompt. Renaming it would change
|
|
8
|
+
> nothing for users (a package directory is not in pi's context-file lookup
|
|
9
|
+
> chain) while adding its full contents to every session run inside this repo.
|
|
10
|
+
|
|
5
11
|
This file is the authoritative reference for how the brl-subagent extension behaves
|
|
6
12
|
and how you (the conductor) should judge a delegation. Read it when planning
|
|
7
13
|
delegation-heavy work.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Multi-agent orchestration for [pi](https://github.com/earendil-works/pi) — chain, parallel, and dependency-graph delegation to isolated subagents with per-step model routing, preset-driven tool scoping, thinking-level control, and background execution with live monitoring, real abort, and per-agent timeouts.
|
|
4
4
|
|
|
5
|
-
**Version:** 2.3.
|
|
5
|
+
**Version:** 2.3.7 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brl-subagent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.7",
|
|
4
4
|
"description": "Subagent extension for pi with configurable model and thinking mode via selection UI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"typebox": "*"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
39
|
-
"@earendil-works/pi-ai": "^0.
|
|
40
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
41
|
-
"@earendil-works/pi-tui": "^0.
|
|
38
|
+
"@earendil-works/pi-agent-core": "^0.86.0",
|
|
39
|
+
"@earendil-works/pi-ai": "^0.86.0",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "^0.86.0",
|
|
41
|
+
"@earendil-works/pi-tui": "^0.86.0",
|
|
42
42
|
"jiti": "^2.7.0",
|
|
43
43
|
"typebox": "^1.3.25",
|
|
44
44
|
"typescript": "^5.9.3",
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
EMPTY_USAGE,
|
|
40
40
|
getFinalOutput,
|
|
41
41
|
isSubagentError,
|
|
42
|
+
countSubagentOutcomes,
|
|
42
43
|
classifyError,
|
|
43
44
|
MAX_CHAIN_STEPS,
|
|
44
45
|
MAX_PARALLEL_TASKS,
|
|
@@ -102,7 +103,7 @@ import {
|
|
|
102
103
|
renderDelegateCall,
|
|
103
104
|
renderDelegateResult,
|
|
104
105
|
} from "./tui";
|
|
105
|
-
import { createLogger, type Logger } from "./logging";
|
|
106
|
+
import { createLogger, setLogCwd, type Logger } from "./logging";
|
|
106
107
|
import { Intercom } from "./messaging";
|
|
107
108
|
import * as eventBus from "./event-bus";
|
|
108
109
|
import {
|
|
@@ -110,7 +111,6 @@ import {
|
|
|
110
111
|
resolveDelivery,
|
|
111
112
|
sendCompletionNotification,
|
|
112
113
|
markTerminalSeen,
|
|
113
|
-
normalizeCompletionStatus,
|
|
114
114
|
resolveRunEntry,
|
|
115
115
|
} from "./notify-completion";
|
|
116
116
|
|
|
@@ -796,7 +796,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
796
796
|
previousOutput = getFinalOutput(result.messages);
|
|
797
797
|
}
|
|
798
798
|
|
|
799
|
-
chainSuccess = chainResults
|
|
799
|
+
chainSuccess = countSubagentOutcomes(chainResults).failed === 0;
|
|
800
800
|
|
|
801
801
|
// Compute aggregated totals
|
|
802
802
|
const totalInput = chainResults.reduce(
|
|
@@ -1203,11 +1203,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1203
1203
|
}
|
|
1204
1204
|
|
|
1205
1205
|
// Emit progress update
|
|
1206
|
-
const
|
|
1207
|
-
const
|
|
1208
|
-
(r) => r && r.exitCode === 0,
|
|
1209
|
-
).length;
|
|
1210
|
-
const failed = completed - succeeded;
|
|
1206
|
+
const { succeeded, failed } = countSubagentOutcomes(results);
|
|
1207
|
+
const completed = succeeded + failed;
|
|
1211
1208
|
|
|
1212
1209
|
const partialDetails: ParallelDetails = {
|
|
1213
1210
|
mode: "parallel",
|
|
@@ -1306,8 +1303,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1306
1303
|
(s, r) => s + r.usage.turns,
|
|
1307
1304
|
0,
|
|
1308
1305
|
);
|
|
1309
|
-
const succeeded = finalResults
|
|
1310
|
-
const failed = finalResults.length - succeeded;
|
|
1306
|
+
const { succeeded, failed } = countSubagentOutcomes(finalResults);
|
|
1311
1307
|
|
|
1312
1308
|
const parallelDetails: ParallelDetails = {
|
|
1313
1309
|
mode: "parallel",
|
|
@@ -1810,9 +1806,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1810
1806
|
const totalCost = allResults.reduce((s, r) => s + r.usage.cost, 0);
|
|
1811
1807
|
const totalTurns = allResults.reduce((s, r) => s + r.usage.turns, 0);
|
|
1812
1808
|
|
|
1813
|
-
chainSuccess = allResults.
|
|
1814
|
-
(r) => r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted",
|
|
1815
|
-
);
|
|
1809
|
+
chainSuccess = countSubagentOutcomes(allResults).failed === 0;
|
|
1816
1810
|
|
|
1817
1811
|
const graphDetails: GraphDetails = {
|
|
1818
1812
|
mode: "graph",
|
|
@@ -2320,48 +2314,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2320
2314
|
}, { deliverAs: "followUp" });
|
|
2321
2315
|
}
|
|
2322
2316
|
|
|
2323
|
-
//
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
if (!hasChain && !hasParallel && !hasGraph) {
|
|
2328
|
-
// task is optional per the schema; single mode requires it. When absent,
|
|
2329
|
-
// feed "" to the sanitizer — same rejection path ("Task must not be
|
|
2330
|
-
// empty.") as when task was a required field.
|
|
2331
|
-
const taskResult = sanitizeTask(params.task ?? "");
|
|
2332
|
-
if (!taskResult.ok) {
|
|
2333
|
-
log.warn("Task rejected by sanitizer", { error: taskResult.error });
|
|
2334
|
-
return {
|
|
2335
|
-
content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
|
|
2336
|
-
details: undefined,
|
|
2337
|
-
isError: true,
|
|
2338
|
-
};
|
|
2339
|
-
}
|
|
2340
|
-
params.task = taskResult.value;
|
|
2341
|
-
}
|
|
2342
|
-
|
|
2343
|
-
// Handle retryRunId
|
|
2344
|
-
if (params.retryRunId) {
|
|
2345
|
-
const runEntry = state.findRunById(ctx, params.retryRunId);
|
|
2346
|
-
if (runEntry) {
|
|
2347
|
-
params = resolveRetryParams(params, runEntry);
|
|
2348
|
-
} else {
|
|
2349
|
-
// Issue #98: a silent no-op here made retries of background runs
|
|
2350
|
-
// (which previously never wrote run entries) start as FRESH runs
|
|
2351
|
-
// with no signal to the caller. Fail loudly instead.
|
|
2352
|
-
log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
|
|
2353
|
-
return {
|
|
2354
|
-
content: [{
|
|
2355
|
-
type: "text" as const,
|
|
2356
|
-
text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix (issue #98). Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
|
|
2357
|
-
}],
|
|
2358
|
-
details: undefined,
|
|
2359
|
-
isError: true,
|
|
2360
|
-
};
|
|
2361
|
-
}
|
|
2362
|
-
}
|
|
2363
|
-
|
|
2364
|
-
// Handle template resolution
|
|
2317
|
+
// Handle template resolution — MUST run before the single-mode sanitize
|
|
2318
|
+
// block below: a template ASSIGNS params.task, so resolving first lets the
|
|
2319
|
+
// sanitizer validate the real resolved body (a template-only call has no
|
|
2320
|
+
// task yet) and lets mode detection see a non-empty task (issue #175).
|
|
2365
2321
|
if (params.template) {
|
|
2366
2322
|
const templateEntry = state.config.templates.find((t) => t.name === params.template);
|
|
2367
2323
|
if (!templateEntry) {
|
|
@@ -2393,7 +2349,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
2393
2349
|
}
|
|
2394
2350
|
|
|
2395
2351
|
const tv = resolved.value;
|
|
2396
|
-
//
|
|
2352
|
+
// Issue #175 review (fix 4): template-wins silently replaces a
|
|
2353
|
+
// supplied `task`. If that task would itself have failed sanitization
|
|
2354
|
+
// (e.g. over-length), the caller gets no signal — pre-#175 `dev`
|
|
2355
|
+
// rejected it. Warn (never reject — #99/#110 precedent, same
|
|
2356
|
+
// pi.sendMessage channel as the unknown-param warning) so both the LLM
|
|
2357
|
+
// and the human see the ignored task. Emitted ONLY when the supplied
|
|
2358
|
+
// task actually fails sanitization: a valid task alongside a template
|
|
2359
|
+
// is the intended template-wins override (issue #57), not noise.
|
|
2360
|
+
if (params.task !== undefined) {
|
|
2361
|
+
const suppliedTask = sanitizeTask(params.task);
|
|
2362
|
+
if (!suppliedTask.ok) {
|
|
2363
|
+
log.warn("delegate_task: supplied task ignored because a template was given", {
|
|
2364
|
+
template: params.template,
|
|
2365
|
+
error: suppliedTask.error,
|
|
2366
|
+
});
|
|
2367
|
+
pi.sendMessage({
|
|
2368
|
+
customType: "delegate-notification",
|
|
2369
|
+
content: `delegate_task: the supplied 'task' was ignored because a template ('${params.template}') was given. The task would otherwise have been rejected: ${suppliedTask.error}`,
|
|
2370
|
+
display: true,
|
|
2371
|
+
details: { template: params.template, rejectedTask: suppliedTask.error },
|
|
2372
|
+
}, { deliverAs: "followUp" });
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
// Issue #175 review (fix direction item 4): this assignment is
|
|
2376
|
+
// UNCONDITIONAL — the resolved template task replaces any supplied
|
|
2377
|
+
// `task` (template-wins, issue #57); it is NOT a default the caller can
|
|
2378
|
+
// override. The fields below, by contrast, ARE defaults: an explicitly
|
|
2379
|
+
// provided param wins over the template's value.
|
|
2397
2380
|
params.task = tv.task;
|
|
2398
2381
|
if (tv.preset && !params.preset) params.preset = tv.preset;
|
|
2399
2382
|
if (tv.thinkingLevel && !params.thinkingLevel) params.thinkingLevel = tv.thinkingLevel;
|
|
@@ -2405,6 +2388,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
2405
2388
|
if (tv.inheritSystemPrompt !== undefined && params.inheritSystemPrompt === undefined) params.inheritSystemPrompt = tv.inheritSystemPrompt;
|
|
2406
2389
|
}
|
|
2407
2390
|
|
|
2391
|
+
// Handle retryRunId — MUST run after template resolution and before the
|
|
2392
|
+
// single-mode sanitize block. resolveRetryParams assigns params.task from
|
|
2393
|
+
// the recorded run, so the sanitizer must see the retried body (a
|
|
2394
|
+
// retry-only call has no task yet). It MUST run AFTER template resolution:
|
|
2395
|
+
// resolveRetryParams returns a fresh object that DROPS `template`, so
|
|
2396
|
+
// resolving templates first keeps a combined template + retryRunId call
|
|
2397
|
+
// from silently disabling template resolution (issue #175 review).
|
|
2398
|
+
if (params.retryRunId) {
|
|
2399
|
+
const runEntry = state.findRunById(ctx, params.retryRunId);
|
|
2400
|
+
if (runEntry) {
|
|
2401
|
+
params = resolveRetryParams(params, runEntry);
|
|
2402
|
+
} else {
|
|
2403
|
+
// Issue #98: a silent no-op here made retries of background runs
|
|
2404
|
+
// (which previously never wrote run entries) start as FRESH runs
|
|
2405
|
+
// with no signal to the caller. Fail loudly instead.
|
|
2406
|
+
log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
|
|
2407
|
+
return {
|
|
2408
|
+
content: [{
|
|
2409
|
+
type: "text" as const,
|
|
2410
|
+
text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix. Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
|
|
2411
|
+
}],
|
|
2412
|
+
details: undefined,
|
|
2413
|
+
isError: true,
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// F1: Sanitize task input — skip for chain/parallel/graph modes
|
|
2419
|
+
const hasChain = params.chain && params.chain.length > 0;
|
|
2420
|
+
const hasParallel = params.tasks && params.tasks.length > 0;
|
|
2421
|
+
const hasGraph = params.graph && params.graph.length > 0;
|
|
2422
|
+
if (!hasChain && !hasParallel && !hasGraph) {
|
|
2423
|
+
// task is optional per the schema; single mode requires it. When absent,
|
|
2424
|
+
// feed "" to the sanitizer — same rejection path ("Task must not be
|
|
2425
|
+
// empty.") as when task was a required field.
|
|
2426
|
+
const taskResult = sanitizeTask(params.task ?? "");
|
|
2427
|
+
if (!taskResult.ok) {
|
|
2428
|
+
log.warn("Task rejected by sanitizer", { error: taskResult.error });
|
|
2429
|
+
return {
|
|
2430
|
+
content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
|
|
2431
|
+
details: undefined,
|
|
2432
|
+
isError: true,
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
params.task = taskResult.value;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2408
2438
|
// P1+P2+P10: Mode detection — graph > chain > parallel > single
|
|
2409
2439
|
const isChain = hasChain;
|
|
2410
2440
|
const isParallel = hasParallel;
|
|
@@ -3681,6 +3711,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3681
3711
|
pi.on("session_start", async (_event, ctx) => {
|
|
3682
3712
|
// Issue #147: capture the session context for the completion-push subscriber.
|
|
3683
3713
|
sessionCtx = ctx;
|
|
3714
|
+
// Issue #179 (D6): createLogger ran at module load with no cwd — point
|
|
3715
|
+
// the shared file sink at this session's cwd now that it exists.
|
|
3716
|
+
setLogCwd(ctx.cwd);
|
|
3684
3717
|
// Load built-in presets
|
|
3685
3718
|
const presetsDir = pkgPath("presets");
|
|
3686
3719
|
state.builtinPresets = loadBuiltinPresets(presetsDir, log);
|
|
@@ -3787,7 +3820,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3787
3820
|
const knob = state.config.completionNotify ?? "all";
|
|
3788
3821
|
const run = resolveRunEntry(state.getRunEntries(ctx), id);
|
|
3789
3822
|
const message = buildCompletionMessage(agent, run);
|
|
3790
|
-
|
|
3823
|
+
// Issue #179 (D2): resolve delivery from the CLASSIFIED notification
|
|
3824
|
+
// status, not the raw agent status — otherwise a run whose completion
|
|
3825
|
+
// the builder downgraded to failed would still be delivered as a success.
|
|
3826
|
+
const delivery = resolveDelivery(message.details.status, knob);
|
|
3791
3827
|
sendCompletionNotification(pi, message, delivery);
|
|
3792
3828
|
} catch (err) {
|
|
3793
3829
|
log.warn(`completion-push handler failed for ${id}`, {
|
package/src/logging.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* const log = createLogger("runner", cwd);
|
|
9
9
|
* log.info("Subagent started", { model, thinkingLevel });
|
|
10
10
|
* log.error("Subagent failed", { error: err.message });
|
|
11
|
+
*
|
|
12
|
+
* Module-load callers pass no cwd; they call setLogCwd(ctx.cwd) once the
|
|
13
|
+
* session starts and all loggers share <cwd>/.pi/subagent-logs/brl-subagent.log.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import * as fs from "node:fs";
|
|
@@ -24,6 +27,12 @@ const MAX_LOG_SIZE = 5 * 1024 * 1024;
|
|
|
24
27
|
/** Maximum number of rotated log files to keep */
|
|
25
28
|
const MAX_LOG_FILES = 5;
|
|
26
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The single log file every logger writes to (issue #179). The JSON entry body
|
|
32
|
+
* already carries its `prefix`, so one shared file stays readable.
|
|
33
|
+
*/
|
|
34
|
+
const LOG_FILE_NAME = "brl-subagent.log";
|
|
35
|
+
|
|
27
36
|
/** Log levels in order of verbosity */
|
|
28
37
|
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
29
38
|
debug: 0,
|
|
@@ -50,23 +59,33 @@ export function setLogLevel(level: LogLevel): void {
|
|
|
50
59
|
minLevel = level;
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
// Issue #179 (D6): createLogger() runs at MODULE LOAD (index.ts,
|
|
63
|
+
// session-manager.ts) where the session cwd does not exist yet. The cwd is set
|
|
64
|
+
// later from the session_start hook (ctx.cwd). All logger instances share this
|
|
65
|
+
// module-level value so every entry lands in ONE
|
|
66
|
+
// `<cwd>/.pi/subagent-logs/brl-subagent.log`.
|
|
67
|
+
let logCwd: string | undefined;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Point file logging at a session cwd (or clear it with undefined). Called from
|
|
71
|
+
* the session-start hook so module-load loggers can write once a cwd exists.
|
|
72
|
+
*/
|
|
73
|
+
export function setLogCwd(cwd: string | undefined): void {
|
|
74
|
+
logCwd = cwd;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveLogDir(): string | undefined {
|
|
78
|
+
return logCwd ? path.join(logCwd, ".pi", "subagent-logs") : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
/**
|
|
54
82
|
* Create a logger for a specific module.
|
|
55
|
-
* Logs are written to
|
|
83
|
+
* Logs are written to `<cwd>/.pi/subagent-logs/brl-subagent.log` relative to the
|
|
84
|
+
* session cwd (set via setLogCwd or the optional `cwd` arg).
|
|
56
85
|
*/
|
|
57
86
|
export function createLogger(prefix: string, cwd?: string): Logger {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// Ensure log directory exists — owner-only (0o700) so other local users
|
|
61
|
-
// cannot list log files (F6 / issue #29). File writes below already use
|
|
62
|
-
// 0o600.
|
|
63
|
-
if (logDir) {
|
|
64
|
-
try {
|
|
65
|
-
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
66
|
-
} catch {
|
|
67
|
-
// Can't create log dir — fall back to console-only logging
|
|
68
|
-
}
|
|
69
|
-
}
|
|
87
|
+
// Compat: an explicit cwd argument still sets the shared location eagerly.
|
|
88
|
+
if (cwd) logCwd = cwd;
|
|
70
89
|
|
|
71
90
|
function log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
|
|
72
91
|
if (LOG_LEVELS[level] < LOG_LEVELS[minLevel]) return;
|
|
@@ -86,10 +105,15 @@ export function createLogger(prefix: string, cwd?: string): Logger {
|
|
|
86
105
|
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
87
106
|
consoleMethod(`[${prefix}] ${level.toUpperCase()}: ${message}`);
|
|
88
107
|
|
|
89
|
-
// File output
|
|
108
|
+
// File output — resolved on each call because the cwd may be set after
|
|
109
|
+
// createLogger ran (module load) but before the first entry.
|
|
110
|
+
const logDir = resolveLogDir();
|
|
90
111
|
if (logDir) {
|
|
91
112
|
try {
|
|
92
|
-
|
|
113
|
+
// Owner-only (0o700) so other local users cannot list log files
|
|
114
|
+
// (F6 / issue #29); the file writes below already use 0o600.
|
|
115
|
+
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
116
|
+
const logFile = path.join(logDir, LOG_FILE_NAME);
|
|
93
117
|
rotateIfNeeded(logFile);
|
|
94
118
|
fs.appendFileSync(logFile, line + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
95
119
|
} catch {
|
package/src/notify-completion.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
} from "./types";
|
|
26
26
|
import { truncateTail } from "./transcript-tail";
|
|
27
27
|
import { formatRunDuration } from "./history";
|
|
28
|
+
import { classifyTerminalOutcome } from "./types";
|
|
28
29
|
|
|
29
30
|
/** Terminal statuses the completion message can carry. */
|
|
30
31
|
export type CompletionStatus = "completed" | "failed" | "stopped";
|
|
@@ -35,6 +36,8 @@ export interface CompletionMessageDetails {
|
|
|
35
36
|
status: CompletionStatus;
|
|
36
37
|
errorCategory: string;
|
|
37
38
|
errorMessage?: string;
|
|
39
|
+
/** Issue #179 (D2): the terminal stopReason behind the classified status. */
|
|
40
|
+
stopReason?: string;
|
|
38
41
|
costUsd?: number;
|
|
39
42
|
tokensIn?: number;
|
|
40
43
|
tokensOut?: number;
|
|
@@ -59,12 +62,12 @@ const DEDUPE_CAP = 200;
|
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
64
|
* Soft directive appended to the content (D3): the conductor should process
|
|
62
|
-
* the completion silently unless action is needed
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
+
* the completion silently unless action is needed. If the run failed, it
|
|
66
|
+
* should investigate the cause before re-dispatching rather than re-issuing an
|
|
67
|
+
* identical task — any re-issue is confirmed with the user first.
|
|
65
68
|
*/
|
|
66
69
|
const COMPLETION_DIRECTIVE =
|
|
67
|
-
"Process this completion silently unless action is needed. If the run failed,
|
|
70
|
+
"Process this completion silently unless action is needed. If the run failed, investigate before re-dispatching — do not re-issue an identical task without confirming the cause with the user.";
|
|
68
71
|
|
|
69
72
|
/**
|
|
70
73
|
* Normalize a BackgroundAgent status to a terminal status word. The subscriber
|
|
@@ -128,23 +131,50 @@ export function buildCompletionMessage(
|
|
|
128
131
|
agent: BackgroundAgent,
|
|
129
132
|
run: SubagentRun | undefined,
|
|
130
133
|
): CompletionMessagePayload {
|
|
131
|
-
|
|
134
|
+
let status = normalizeCompletionStatus(agent.status);
|
|
135
|
+
// Issue #179 (D2): the notification category must NEVER default to success
|
|
136
|
+
// merely because `agent.status === "completed"`. The SDK resolves a mid-run
|
|
137
|
+
// provider death, so the run entry is the authoritative classified reason —
|
|
138
|
+
// downgrade a stale "completed" when the run says otherwise.
|
|
139
|
+
if (status === "completed" && run) {
|
|
140
|
+
const outcome = classifyTerminalOutcome(run.stopReason, run.errorMessage);
|
|
141
|
+
if (outcome.status !== "completed") {
|
|
142
|
+
status = outcome.status;
|
|
143
|
+
} else if (run.status === "failed") {
|
|
144
|
+
status = "failed";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
132
147
|
// BackgroundAgent has no `label` field — the caller's label is `description`
|
|
133
148
|
// (set from params.description at spawn). Fall back to the id.
|
|
134
149
|
const label = agent.description || agent.id;
|
|
135
150
|
// completed → "success"; failed/stopped → classified category from the run
|
|
136
151
|
// record (finalizeRunEntry stamps it), falling back to "unknown".
|
|
137
152
|
const errorCategory =
|
|
138
|
-
status === "completed"
|
|
153
|
+
status === "completed"
|
|
154
|
+
? "success"
|
|
155
|
+
: (run?.errorCategory ?? run?.originalParams?.errorCategory ?? "unknown");
|
|
139
156
|
const durationMs = run?.durationMs;
|
|
140
157
|
const costUsd = run?.cost;
|
|
141
158
|
const tokensIn = run?.tokensIn;
|
|
142
159
|
const tokensOut = run?.tokensOut;
|
|
160
|
+
const stopReason = run?.stopReason;
|
|
143
161
|
const errorMessage = status === "completed" ? undefined : agent.error ?? run?.errorMessage;
|
|
144
162
|
|
|
145
163
|
const summary = buildSummaryLine(label, agent.id, status, durationMs, costUsd, errorCategory);
|
|
146
|
-
|
|
164
|
+
// Issue #179 (C1 fix): truncateTail keeps the LAST 15 lines, but the failure
|
|
165
|
+
// headline is PREPENDED to the stored output — long earlier-turn prose would
|
|
166
|
+
// otherwise push the failure reason out of the notified body. Peel it off
|
|
167
|
+
// before tailing and re-add it as its own part so the reason is never
|
|
168
|
+
// truncated away.
|
|
169
|
+
const stored = agent.finalOutput ?? run?.fullOutput ?? "";
|
|
170
|
+
const headline = status === "completed" ? undefined : errorMessage;
|
|
171
|
+
const body =
|
|
172
|
+
headline && stored.startsWith(headline)
|
|
173
|
+
? stored.slice(headline.length).replace(/^\n+/, "")
|
|
174
|
+
: stored;
|
|
175
|
+
const tail = truncateTail(body, 15);
|
|
147
176
|
const parts = [summary];
|
|
177
|
+
if (headline) parts.push(headline);
|
|
148
178
|
if (tail) parts.push(tail);
|
|
149
179
|
parts.push(COMPLETION_DIRECTIVE);
|
|
150
180
|
const content = parts.join("\n\n");
|
|
@@ -154,6 +184,7 @@ export function buildCompletionMessage(
|
|
|
154
184
|
status,
|
|
155
185
|
errorCategory,
|
|
156
186
|
...(errorMessage ? { errorMessage } : {}),
|
|
187
|
+
...(stopReason ? { stopReason } : {}),
|
|
157
188
|
...(costUsd !== undefined ? { costUsd } : {}),
|
|
158
189
|
...(tokensIn !== undefined ? { tokensIn } : {}),
|
|
159
190
|
...(tokensOut !== undefined ? { tokensOut } : {}),
|
package/src/runner.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
SIGKILL_GRACE_MS,
|
|
28
28
|
TEMP_FILE_MODE,
|
|
29
29
|
MAX_TEMP_DIR_AGE_MS,
|
|
30
|
+
isProviderError,
|
|
30
31
|
classifyError,
|
|
31
32
|
SUBAGENT_ABORTED_MESSAGE,
|
|
32
33
|
} from "./types";
|
|
@@ -666,7 +667,7 @@ export function parseSubagentLine(
|
|
|
666
667
|
function countModelErrorTurns(messages: Array<Record<string, unknown>>): number {
|
|
667
668
|
let count = 0;
|
|
668
669
|
for (const msg of messages) {
|
|
669
|
-
if (msg.role === "assistant" && msg.stopReason
|
|
670
|
+
if (msg.role === "assistant" && isProviderError(msg.stopReason as string | undefined)) count++;
|
|
670
671
|
}
|
|
671
672
|
return count;
|
|
672
673
|
}
|
package/src/session-manager.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
|
|
4
|
-
import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats } from './types';
|
|
5
|
-
import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
4
|
+
import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
|
|
5
|
+
import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isSubagentError, isProviderError, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
6
6
|
import { accumulateUsage } from './runner';
|
|
7
7
|
import * as eventBus from './event-bus';
|
|
8
8
|
import * as transcript from './transcript';
|
|
@@ -230,7 +230,10 @@ export function setAgentResult(id: string, result: SubagentResult): BackgroundAg
|
|
|
230
230
|
if (!agent) return null;
|
|
231
231
|
|
|
232
232
|
agent.result = result;
|
|
233
|
-
|
|
233
|
+
// Issue #179 (D1): honor the full terminal reason (stopReason error/aborted/
|
|
234
|
+
// length/incomplete), not exitCode alone — a mid-run provider death resolves
|
|
235
|
+
// with exitCode 0 and was previously recorded as 'completed'.
|
|
236
|
+
agent.status = isSubagentError(result) ? 'failed' : 'completed';
|
|
234
237
|
agent.completedAt = Date.now();
|
|
235
238
|
|
|
236
239
|
agents.set(id, agent);
|
|
@@ -238,6 +241,56 @@ export function setAgentResult(id: string, result: SubagentResult): BackgroundAg
|
|
|
238
241
|
return agent;
|
|
239
242
|
}
|
|
240
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Issue #179 (D4): when a run ends in a failure, the prose extracted by
|
|
246
|
+
* extractFinalOutput may come from an EARLIER turn — the failing turn carries
|
|
247
|
+
* no text of its own. Prefixing the failure reason makes the output honest
|
|
248
|
+
* instead of a plausible-looking success narrative.
|
|
249
|
+
*/
|
|
250
|
+
export function buildFailureHeadline(reason: string, output: string): string {
|
|
251
|
+
const trimmed = output.trim();
|
|
252
|
+
return trimmed ? `${reason}\n\n${trimmed}` : reason;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Issue #179 (D3/D4): a run that ended without a terminal answer must still
|
|
257
|
+
* record WHY. When the provider supplied no message, synthesize one from the
|
|
258
|
+
* stopReason so the run entry never claims a failure with an empty reason.
|
|
259
|
+
*/
|
|
260
|
+
function defaultTerminalMessage(stopReason: string | undefined): string {
|
|
261
|
+
switch (stopReason) {
|
|
262
|
+
case 'error':
|
|
263
|
+
return 'Run ended with a provider error (stopReason "error")';
|
|
264
|
+
case 'length':
|
|
265
|
+
return 'Run ended early: output length limit reached (stopReason "length")';
|
|
266
|
+
case 'toolUse':
|
|
267
|
+
return 'Run ended mid-turn without a final answer (stopReason "toolUse")';
|
|
268
|
+
case 'deferred':
|
|
269
|
+
return 'Run ended deferred without a final answer (stopReason "deferred")';
|
|
270
|
+
case 'pending':
|
|
271
|
+
return 'Run ended while still pending (stopReason "pending")';
|
|
272
|
+
default:
|
|
273
|
+
return 'Run ended without completing';
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Issue #179 (C1 fix): a failed run entry/result must carry a terminal reason
|
|
279
|
+
* that AGREES with its status — never undefined, never "stop". Any reason the
|
|
280
|
+
* classifier already treats as a failure (error / length / toolUse / deferred
|
|
281
|
+
* / pending / aborted) is coherent and kept; a missing or success reason is
|
|
282
|
+
* coerced to the honest failure reason derived from the classified category.
|
|
283
|
+
*/
|
|
284
|
+
function coherentFailureReason(
|
|
285
|
+
stopReason: string | undefined,
|
|
286
|
+
errorCategory: ErrorCategory | undefined,
|
|
287
|
+
): string {
|
|
288
|
+
if (stopReason && classifyTerminalOutcome(stopReason).status !== 'completed') {
|
|
289
|
+
return stopReason;
|
|
290
|
+
}
|
|
291
|
+
return errorCategory === 'aborted' ? 'aborted' : 'error';
|
|
292
|
+
}
|
|
293
|
+
|
|
241
294
|
/**
|
|
242
295
|
* Extract the final assistant text from a session's messages.
|
|
243
296
|
* Falls back to the LAST assistant message with non-empty text content — the
|
|
@@ -531,29 +584,51 @@ export async function spawnBackgroundSession(
|
|
|
531
584
|
// ("done"/"failed"). The guard keeps the entry single-finalized even when a
|
|
532
585
|
// settle handler throws after finalizing (markTerminalBestEffort catch-all).
|
|
533
586
|
let runFinalized = false;
|
|
534
|
-
const finalizeRunEntry = (
|
|
587
|
+
const finalizeRunEntry = (
|
|
588
|
+
status: "done" | "failed",
|
|
589
|
+
error?: string,
|
|
590
|
+
terminal?: { stopReason?: string; errorCategory?: ErrorCategory },
|
|
591
|
+
): void => {
|
|
535
592
|
if (runFinalized) return;
|
|
536
593
|
runFinalized = true;
|
|
537
594
|
const usage = agent.result?.usage ?? EMPTY_USAGE;
|
|
538
595
|
const finalOutput = agent.finalOutput ?? "";
|
|
539
|
-
// Issue #
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
// invisible to the SLA errorCategoryBreakdown and to failure diagnostics.
|
|
596
|
+
// Issue #179 (D1/D2): the classifier's explicit category wins (it knows the
|
|
597
|
+
// terminal stopReason); otherwise fall back to the legacy message-based
|
|
598
|
+
// classification (refused spawns, sync throws, preflight rejections, the
|
|
599
|
+
// catch-all) so no failure is ever left uncategorized.
|
|
544
600
|
const errorCategory =
|
|
545
|
-
status === "failed"
|
|
546
|
-
?
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
601
|
+
status === "failed"
|
|
602
|
+
? terminal?.errorCategory ??
|
|
603
|
+
(error
|
|
604
|
+
? classifyError({
|
|
605
|
+
messages: [],
|
|
606
|
+
usage: { ...EMPTY_USAGE },
|
|
607
|
+
exitCode: 1,
|
|
608
|
+
stderr: "",
|
|
609
|
+
errorMessage: error,
|
|
610
|
+
})
|
|
611
|
+
: undefined)
|
|
553
612
|
: undefined;
|
|
613
|
+
const stopReason =
|
|
614
|
+
status === "failed"
|
|
615
|
+
? coherentFailureReason(
|
|
616
|
+
terminal?.stopReason ?? agent.result?.stopReason,
|
|
617
|
+
errorCategory,
|
|
618
|
+
)
|
|
619
|
+
: terminal?.stopReason ?? agent.result?.stopReason;
|
|
554
620
|
const entry: SubagentRun = {
|
|
555
621
|
...run,
|
|
556
622
|
status,
|
|
623
|
+
// Issue #179 (D2): the run entry carries the CLASSIFIED reason, not just
|
|
624
|
+
// an opaque error string.
|
|
625
|
+
stopReason,
|
|
626
|
+
errorCategory,
|
|
627
|
+
// Issue #179 (D3): work volume + terminal-error signal. `turns`/`tokensOut`
|
|
628
|
+
// are already in the usage data; recording them here makes "did nothing"
|
|
629
|
+
// (turns 0) explicit versus "did work then died" (turns > 0, error).
|
|
630
|
+
turns: usage.turns,
|
|
631
|
+
finalTurnError: isProviderError(stopReason),
|
|
557
632
|
errorMessage: error,
|
|
558
633
|
finishedAt: new Date().toISOString(),
|
|
559
634
|
durationMs: Date.now() - startedAtMs,
|
|
@@ -570,6 +645,17 @@ export async function spawnBackgroundSession(
|
|
|
570
645
|
: run.originalParams,
|
|
571
646
|
};
|
|
572
647
|
pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
|
|
648
|
+
// Issue #179 (D6): the explicit settle line — the log's job is to record the
|
|
649
|
+
// CLASSIFIED OUTCOME, which cwd alone can never convey.
|
|
650
|
+
log.info("Background run settled", {
|
|
651
|
+
agentId: id,
|
|
652
|
+
status,
|
|
653
|
+
stopReason,
|
|
654
|
+
turns: usage.turns,
|
|
655
|
+
tokensOut: usage.output,
|
|
656
|
+
errorCategory,
|
|
657
|
+
error: error ?? null,
|
|
658
|
+
});
|
|
573
659
|
};
|
|
574
660
|
|
|
575
661
|
// Start transcript for this agent
|
|
@@ -582,8 +668,16 @@ export async function spawnBackgroundSession(
|
|
|
582
668
|
// final output while the session is still live, THEN release the ref — the
|
|
583
669
|
// capture must always precede the release or the output dies with the
|
|
584
670
|
// session graph. Single helper for all four settlement paths.
|
|
585
|
-
|
|
586
|
-
|
|
671
|
+
//
|
|
672
|
+
// Issue #179 (D4): an optional failureHeadline is prepended when the run did
|
|
673
|
+
// NOT complete — the extracted prose may come from an earlier turn, so it
|
|
674
|
+
// must not be presented as the run's output unqualified.
|
|
675
|
+
const captureAndReleaseSession = (failureHeadline?: string): void => {
|
|
676
|
+
const output = extractFinalOutput(session);
|
|
677
|
+
setAgentFinalOutput(
|
|
678
|
+
id,
|
|
679
|
+
failureHeadline ? buildFailureHeadline(failureHeadline, output) : output,
|
|
680
|
+
);
|
|
587
681
|
agent._sessionRef = undefined;
|
|
588
682
|
};
|
|
589
683
|
|
|
@@ -895,7 +989,8 @@ export async function spawnBackgroundSession(
|
|
|
895
989
|
// 1. stopAgent pre-set status to 'stopped' before calling abort();
|
|
896
990
|
// 2. the last assistant message carries stopReason "aborted".
|
|
897
991
|
const lastAssistant = [...(session.messages ?? [])].reverse().find(m => m.role === 'assistant');
|
|
898
|
-
const
|
|
992
|
+
const terminalStopReason = lastAssistant?.stopReason;
|
|
993
|
+
const aborted = agent.status === 'stopped' || terminalStopReason === 'aborted';
|
|
899
994
|
if (aborted) {
|
|
900
995
|
// Session was aborted — mark stopped. If stopAgent already flipped the
|
|
901
996
|
// status (pre-set 'stopped' + completedAt + event + transcript), only
|
|
@@ -931,13 +1026,70 @@ export async function spawnBackgroundSession(
|
|
|
931
1026
|
// (stop_subagent) sets no error, so fall back to the honest user-abort
|
|
932
1027
|
// stamp (→ 'aborted') instead of the old ambiguous 'Aborted or timed out'.
|
|
933
1028
|
recordSessionUsage();
|
|
934
|
-
finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE
|
|
1029
|
+
finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE, {
|
|
1030
|
+
stopReason:
|
|
1031
|
+
terminalStopReason && terminalStopReason !== 'stop'
|
|
1032
|
+
? terminalStopReason
|
|
1033
|
+
: 'aborted',
|
|
1034
|
+
});
|
|
935
1035
|
// Issue #31: capture the final output while the session is still
|
|
936
1036
|
// live, then release the ref on the terminal path (memory retention;
|
|
937
1037
|
// the poller treats a nulled ref on a terminal agent as expected).
|
|
938
1038
|
captureAndReleaseSession();
|
|
939
1039
|
return;
|
|
940
1040
|
}
|
|
1041
|
+
// Issue #179 (D1/D2): prompt() RESOLVED — but a resolved prompt is NOT
|
|
1042
|
+
// proof of success. The SDK's runWithLifecycle catches a mid-run provider
|
|
1043
|
+
// death internally and resolves with an assistant message carrying
|
|
1044
|
+
// stopReason "error"; the completion branch below would record it as a
|
|
1045
|
+
// false success (exitCode 0, no category). Classify the terminal outcome
|
|
1046
|
+
// from the last assistant message + the session error, reusing
|
|
1047
|
+
// classifyError for the provider-error case.
|
|
1048
|
+
const providerErrorMessage =
|
|
1049
|
+
lastAssistant?.errorMessage ?? session.state?.errorMessage;
|
|
1050
|
+
const outcome = classifyTerminalOutcome(terminalStopReason, providerErrorMessage);
|
|
1051
|
+
|
|
1052
|
+
if (outcome.status === 'failed') {
|
|
1053
|
+
// Mid-run provider death / truncation / incomplete turn.
|
|
1054
|
+
agent.status = 'failed';
|
|
1055
|
+
agent.completedAt = Date.now();
|
|
1056
|
+
const sanitizedError = sanitizeErrorMessage(
|
|
1057
|
+
providerErrorMessage ?? defaultTerminalMessage(terminalStopReason),
|
|
1058
|
+
effectiveCwd,
|
|
1059
|
+
);
|
|
1060
|
+
agent.error = sanitizedError;
|
|
1061
|
+
const gitInfo = cleanupWorkBranch();
|
|
1062
|
+
agent.result = {
|
|
1063
|
+
...(agent.result ?? {}),
|
|
1064
|
+
exitCode: 1,
|
|
1065
|
+
messages: agent.result?.messages ?? [],
|
|
1066
|
+
stderr: agent.result?.stderr ?? '',
|
|
1067
|
+
usage: agent.result?.usage ?? EMPTY_USAGE,
|
|
1068
|
+
stopReason: terminalStopReason,
|
|
1069
|
+
errorMessage: sanitizedError,
|
|
1070
|
+
errorCategory: outcome.errorCategory,
|
|
1071
|
+
...(gitInfo.gitBranch
|
|
1072
|
+
? { gitBranch: gitInfo.gitBranch, gitDiff: gitInfo.gitDiff }
|
|
1073
|
+
: {}),
|
|
1074
|
+
} as SubagentResult;
|
|
1075
|
+
// Issue #31: capture the final output while the session is still live.
|
|
1076
|
+
// Issue #179 (D4): surface the failure as the HEADLINE — the extracted
|
|
1077
|
+
// prose may come from an earlier turn, so it must not be presented as an
|
|
1078
|
+
// unqualified success narrative.
|
|
1079
|
+
captureAndReleaseSession(sanitizedError);
|
|
1080
|
+
// Issue #122: fold the real usage (turns/tokens) the run burned.
|
|
1081
|
+
recordSessionUsage();
|
|
1082
|
+
agents.set(id, agent);
|
|
1083
|
+
persistAgent(agent);
|
|
1084
|
+
finalizeRunEntry('failed', sanitizedError, {
|
|
1085
|
+
stopReason: terminalStopReason,
|
|
1086
|
+
errorCategory: outcome.errorCategory,
|
|
1087
|
+
});
|
|
1088
|
+
transcript.completeTranscript(id, 'failed');
|
|
1089
|
+
eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
941
1093
|
// Session completed
|
|
942
1094
|
agent.status = 'completed';
|
|
943
1095
|
agent.completedAt = Date.now();
|
|
@@ -951,6 +1103,7 @@ export async function spawnBackgroundSession(
|
|
|
951
1103
|
messages: [],
|
|
952
1104
|
stderr: '',
|
|
953
1105
|
usage: agent.result?.usage ?? EMPTY_USAGE,
|
|
1106
|
+
stopReason: terminalStopReason ?? 'stop',
|
|
954
1107
|
gitBranch: gitInfo.gitBranch,
|
|
955
1108
|
gitDiff: gitInfo.gitDiff,
|
|
956
1109
|
} as SubagentResult;
|
|
@@ -965,7 +1118,7 @@ export async function spawnBackgroundSession(
|
|
|
965
1118
|
agents.set(id, agent);
|
|
966
1119
|
persistAgent(agent);
|
|
967
1120
|
// Issue #98: mirror the agent-record flip in the session run entry.
|
|
968
|
-
finalizeRunEntry('done');
|
|
1121
|
+
finalizeRunEntry('done', undefined, { stopReason: terminalStopReason ?? 'stop' });
|
|
969
1122
|
transcript.completeTranscript(id, 'completed');
|
|
970
1123
|
eventBus.emit(eventBus.createEvent('subagent:completed', id, {}));
|
|
971
1124
|
} catch (err) {
|
|
@@ -1009,7 +1162,9 @@ export async function spawnBackgroundSession(
|
|
|
1009
1162
|
// agent.error ('Timed out...' → 'timeout'); a user cancel has no error
|
|
1010
1163
|
// and stamps 'Subagent aborted by user' (→ 'aborted').
|
|
1011
1164
|
recordSessionUsage();
|
|
1012
|
-
finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE
|
|
1165
|
+
finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE, {
|
|
1166
|
+
stopReason: 'aborted',
|
|
1167
|
+
});
|
|
1013
1168
|
// Issue #31: the stopped path is terminal too — capture + release.
|
|
1014
1169
|
captureAndReleaseSession();
|
|
1015
1170
|
transcript.completeTranscript(id, 'stopped');
|
|
@@ -1022,29 +1177,36 @@ export async function spawnBackgroundSession(
|
|
|
1022
1177
|
// result errorMessage, and the subagent:failed event (DRY, PR #63 review).
|
|
1023
1178
|
const sanitizedError = sanitizeErrorMessage(err.message, effectiveCwd);
|
|
1024
1179
|
agent.error = sanitizedError;
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1180
|
+
// Issue #179: record the failure on the result UNCONDITIONALLY (not only
|
|
1181
|
+
// on the git-diff path) so a failed run never persists exitCode 0.
|
|
1182
|
+
agent.result = {
|
|
1183
|
+
...(agent.result ?? {}),
|
|
1184
|
+
exitCode: 1,
|
|
1185
|
+
messages: agent.result?.messages ?? [],
|
|
1186
|
+
stderr: agent.result?.stderr ?? '',
|
|
1187
|
+
usage: agent.result?.usage ?? EMPTY_USAGE,
|
|
1188
|
+
// Issue #179 (C1 fix): a genuine preflight/run failure always carries
|
|
1189
|
+
// the failure reason — never leave the result with an undefined
|
|
1190
|
+
// stopReason that downstream display/aggregate paths read as success.
|
|
1191
|
+
stopReason: 'error',
|
|
1192
|
+
errorMessage: sanitizedError,
|
|
1193
|
+
...(gitInfo.gitBranch
|
|
1194
|
+
? { gitBranch: gitInfo.gitBranch, gitDiff: gitInfo.gitDiff }
|
|
1195
|
+
: {}),
|
|
1196
|
+
} as SubagentResult;
|
|
1037
1197
|
// Issue #31: capture the final output while the session is still live,
|
|
1038
1198
|
// then release the ref before the branch's persist — the persisted
|
|
1039
1199
|
// record is consistent and the live session graph is freed.
|
|
1040
|
-
|
|
1200
|
+
// Issue #179 (D4): lead with the failure reason (the prose may be from an
|
|
1201
|
+
// earlier turn).
|
|
1202
|
+
captureAndReleaseSession(sanitizedError);
|
|
1041
1203
|
// Issue #122: fold any assistant turns the preflight/run did produce —
|
|
1042
1204
|
// usually none (auth/model rejection) → honest zeros, never a crash.
|
|
1043
1205
|
recordSessionUsage();
|
|
1044
1206
|
agents.set(id, agent);
|
|
1045
1207
|
persistAgent(agent);
|
|
1046
1208
|
// Issue #98: mirror the agent-record flip in the session run entry.
|
|
1047
|
-
finalizeRunEntry('failed', sanitizedError);
|
|
1209
|
+
finalizeRunEntry('failed', sanitizedError, { stopReason: 'error' });
|
|
1048
1210
|
transcript.completeTranscript(id, 'failed');
|
|
1049
1211
|
eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
|
|
1050
1212
|
} catch (err) {
|
package/src/templates.ts
CHANGED
|
@@ -407,7 +407,7 @@ export function validateTemplatePresetRefs(
|
|
|
407
407
|
dangling++;
|
|
408
408
|
log?.warn(
|
|
409
409
|
`Template "${template.name}" references preset "${template.preset}" which does not exist ` +
|
|
410
|
-
"— delegation will run preset-less with auto-route suppressed
|
|
410
|
+
"— delegation will run preset-less with auto-route suppressed",
|
|
411
411
|
{ template: template.name, preset: template.preset },
|
|
412
412
|
);
|
|
413
413
|
}
|
package/src/transcript.ts
CHANGED
|
@@ -19,8 +19,9 @@ export function __setOutputDir(dir: string): void {
|
|
|
19
19
|
* Ensure output directory exists
|
|
20
20
|
*/
|
|
21
21
|
function ensureOutputDir(): void {
|
|
22
|
-
// F6 (issue #29): transcripts
|
|
23
|
-
//
|
|
22
|
+
// F6 (issue #29): transcripts may hold task/steering text — the dir must be
|
|
23
|
+
// owner-only (0o700) so other local users cannot list the files. NOTE: the
|
|
24
|
+
// transcript is NOT the full conversation (see appendEntry below).
|
|
24
25
|
mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 });
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -52,8 +53,9 @@ export function startTranscript(agentId: string, task: string): string {
|
|
|
52
53
|
metadata: { task },
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
// F6 (issue #29):
|
|
56
|
-
//
|
|
56
|
+
// F6 (issue #29): append owner-only (0o600) on file CREATE (mode is not
|
|
57
|
+
// retroactive). The file holds the system start entry plus any steering
|
|
58
|
+
// entries appended below — NOT the full conversation.
|
|
57
59
|
appendFileSync(path, JSON.stringify(entry) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
58
60
|
return path;
|
|
59
61
|
}
|
|
@@ -79,8 +81,11 @@ export function appendEntry(
|
|
|
79
81
|
metadata,
|
|
80
82
|
};
|
|
81
83
|
|
|
82
|
-
// F6 (issue #29):
|
|
83
|
-
//
|
|
84
|
+
// F6 (issue #29): append owner-only (0o600) on file CREATE (mode is not
|
|
85
|
+
// retroactive). NOTE (D5 / issue #179): this is NOT a conversation recorder —
|
|
86
|
+
// the only caller is steering (steerAgent), so the transcript holds the
|
|
87
|
+
// system start entry and steering lines. Do NOT describe it as the full
|
|
88
|
+
// conversation without actually recording one.
|
|
84
89
|
appendFileSync(path, JSON.stringify(entry) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
85
90
|
}
|
|
86
91
|
|
package/src/tui.ts
CHANGED
|
@@ -2190,10 +2190,7 @@ function renderSubTaskSummary(
|
|
|
2190
2190
|
},
|
|
2191
2191
|
maxLines: number,
|
|
2192
2192
|
): string {
|
|
2193
|
-
const isError =
|
|
2194
|
-
result.exitCode !== 0 ||
|
|
2195
|
-
result.stopReason === "error" ||
|
|
2196
|
-
result.stopReason === "aborted";
|
|
2193
|
+
const isError = isSubagentError(result);
|
|
2197
2194
|
const icon = isError
|
|
2198
2195
|
? theme.fg("error", "\u2717")
|
|
2199
2196
|
: theme.fg("success", "\u2713");
|
|
@@ -2213,12 +2210,7 @@ function renderCollapsedChain(
|
|
|
2213
2210
|
details: ChainDetails,
|
|
2214
2211
|
theme: Theme,
|
|
2215
2212
|
): string {
|
|
2216
|
-
const allSucceeded = details.results.every(
|
|
2217
|
-
(r) =>
|
|
2218
|
-
r.exitCode === 0 &&
|
|
2219
|
-
r.stopReason !== "error" &&
|
|
2220
|
-
r.stopReason !== "aborted",
|
|
2221
|
-
);
|
|
2213
|
+
const allSucceeded = details.results.every((r) => !isSubagentError(r));
|
|
2222
2214
|
const icon = allSucceeded
|
|
2223
2215
|
? theme.fg("success", "\u2713")
|
|
2224
2216
|
: theme.fg("error", "\u2717");
|
|
@@ -2226,10 +2218,7 @@ function renderCollapsedChain(
|
|
|
2226
2218
|
let text = `${icon} ${theme.fg("toolTitle", theme.bold("chain"))} ${theme.fg("muted", `${details.completedSteps}/${details.totalSteps} steps${stoppedEarly}`)}`;
|
|
2227
2219
|
|
|
2228
2220
|
for (const r of details.results) {
|
|
2229
|
-
const isErr =
|
|
2230
|
-
r.exitCode !== 0 ||
|
|
2231
|
-
r.stopReason === "error" ||
|
|
2232
|
-
r.stopReason === "aborted";
|
|
2221
|
+
const isErr = isSubagentError(r);
|
|
2233
2222
|
const statusIcon = isErr
|
|
2234
2223
|
? theme.fg("error", "\u2717")
|
|
2235
2224
|
: theme.fg("success", "\u2713");
|
|
@@ -2263,10 +2252,7 @@ function renderCollapsedParallel(
|
|
|
2263
2252
|
let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel"))} ${theme.fg("muted", `${details.succeeded}/${details.failed}/${details.results.length} total`)}`;
|
|
2264
2253
|
|
|
2265
2254
|
for (const r of details.results) {
|
|
2266
|
-
const isErr =
|
|
2267
|
-
r.exitCode !== 0 ||
|
|
2268
|
-
r.stopReason === "error" ||
|
|
2269
|
-
r.stopReason === "aborted";
|
|
2255
|
+
const isErr = isSubagentError(r);
|
|
2270
2256
|
const statusIcon = isErr
|
|
2271
2257
|
? theme.fg("error", "\u2717")
|
|
2272
2258
|
: theme.fg("success", "\u2713");
|
|
@@ -2312,10 +2298,7 @@ function renderExpandedChain(
|
|
|
2312
2298
|
container.addChild(new Spacer(1));
|
|
2313
2299
|
|
|
2314
2300
|
for (const r of details.results) {
|
|
2315
|
-
const isErr =
|
|
2316
|
-
r.exitCode !== 0 ||
|
|
2317
|
-
r.stopReason === "error" ||
|
|
2318
|
-
r.stopReason === "aborted";
|
|
2301
|
+
const isErr = isSubagentError(r);
|
|
2319
2302
|
const statusIcon = isErr
|
|
2320
2303
|
? theme.fg("error", "\u2717")
|
|
2321
2304
|
: theme.fg("success", "\u2713");
|
|
@@ -2397,10 +2380,7 @@ function renderExpandedParallel(
|
|
|
2397
2380
|
container.addChild(new Spacer(1));
|
|
2398
2381
|
|
|
2399
2382
|
for (const r of details.results) {
|
|
2400
|
-
const isErr =
|
|
2401
|
-
r.exitCode !== 0 ||
|
|
2402
|
-
r.stopReason === "error" ||
|
|
2403
|
-
r.stopReason === "aborted";
|
|
2383
|
+
const isErr = isSubagentError(r);
|
|
2404
2384
|
const statusIcon = isErr
|
|
2405
2385
|
? theme.fg("error", "\u2717")
|
|
2406
2386
|
: theme.fg("success", "\u2713");
|
|
@@ -2774,12 +2754,7 @@ function renderCollapsedGraph(
|
|
|
2774
2754
|
): string {
|
|
2775
2755
|
const totalTasks = details.waves.reduce((s, w) => s + w.tasks.length, 0);
|
|
2776
2756
|
const allSucceeded = details.waves.every((w) =>
|
|
2777
|
-
w.tasks.every(
|
|
2778
|
-
(r) =>
|
|
2779
|
-
r.exitCode === 0 &&
|
|
2780
|
-
r.stopReason !== "error" &&
|
|
2781
|
-
r.stopReason !== "aborted",
|
|
2782
|
-
),
|
|
2757
|
+
w.tasks.every((r) => !isSubagentError(r)),
|
|
2783
2758
|
);
|
|
2784
2759
|
const icon = allSucceeded
|
|
2785
2760
|
? theme.fg("success", "\u2713")
|
|
@@ -2790,22 +2765,16 @@ function renderCollapsedGraph(
|
|
|
2790
2765
|
const wavesToRender = details.waves.slice(0, maxWavesToShow);
|
|
2791
2766
|
|
|
2792
2767
|
for (const wave of wavesToRender) {
|
|
2793
|
-
const waveOk = wave.tasks.every(
|
|
2794
|
-
(r) =>
|
|
2795
|
-
r.exitCode === 0 &&
|
|
2796
|
-
r.stopReason !== "error" &&
|
|
2797
|
-
r.stopReason !== "aborted",
|
|
2798
|
-
);
|
|
2768
|
+
const waveOk = wave.tasks.every((r) => !isSubagentError(r));
|
|
2799
2769
|
const waveIcon = waveOk
|
|
2800
2770
|
? theme.fg("success", "\u2713")
|
|
2801
2771
|
: theme.fg("error", "\u2717");
|
|
2802
2772
|
const modeLabel = wave.parallel ? "parallel" : "serial";
|
|
2803
2773
|
const taskLabels = wave.tasks.map((r) => {
|
|
2804
2774
|
const label = r.label || r.task.slice(0, 30);
|
|
2805
|
-
const taskIcon =
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
: theme.fg("error", "\u2717");
|
|
2775
|
+
const taskIcon = !isSubagentError(r)
|
|
2776
|
+
? theme.fg("success", "\u2713")
|
|
2777
|
+
: theme.fg("error", "\u2717");
|
|
2809
2778
|
return `${taskIcon} ${theme.fg("dim", label)}`;
|
|
2810
2779
|
});
|
|
2811
2780
|
text += `
|
|
@@ -2843,12 +2812,7 @@ function renderExpandedGraph(
|
|
|
2843
2812
|
container.addChild(new Spacer(1));
|
|
2844
2813
|
|
|
2845
2814
|
for (const wave of details.waves) {
|
|
2846
|
-
const waveOk = wave.tasks.every(
|
|
2847
|
-
(r) =>
|
|
2848
|
-
r.exitCode === 0 &&
|
|
2849
|
-
r.stopReason !== "error" &&
|
|
2850
|
-
r.stopReason !== "aborted",
|
|
2851
|
-
);
|
|
2815
|
+
const waveOk = wave.tasks.every((r) => !isSubagentError(r));
|
|
2852
2816
|
const waveIcon = waveOk
|
|
2853
2817
|
? theme.fg("success", "\u2713")
|
|
2854
2818
|
: theme.fg("error", "\u2717");
|
|
@@ -2862,10 +2826,7 @@ function renderExpandedGraph(
|
|
|
2862
2826
|
);
|
|
2863
2827
|
|
|
2864
2828
|
for (const r of wave.tasks) {
|
|
2865
|
-
const isErr =
|
|
2866
|
-
r.exitCode !== 0 ||
|
|
2867
|
-
r.stopReason === "error" ||
|
|
2868
|
-
r.stopReason === "aborted";
|
|
2829
|
+
const isErr = isSubagentError(r);
|
|
2869
2830
|
const statusIcon = isErr
|
|
2870
2831
|
? theme.fg("error", "\u2717")
|
|
2871
2832
|
: theme.fg("success", "\u2713");
|
package/src/types.ts
CHANGED
|
@@ -43,7 +43,14 @@ export type ErrorCategory =
|
|
|
43
43
|
| "aborted"
|
|
44
44
|
| "exit_error"
|
|
45
45
|
| "crash"
|
|
46
|
-
| "unknown"
|
|
46
|
+
| "unknown"
|
|
47
|
+
// Issue #179: additive terminal-reason categories. "truncated" = the model
|
|
48
|
+
// hit its output/context limit (stopReason "length"); "incomplete" = the
|
|
49
|
+
// agent stopped mid-turn without a terminal answer (stopReason
|
|
50
|
+
// "toolUse"/"deferred"/"pending"). Existing consumers treat categories as
|
|
51
|
+
// string keys (metrics/SLA breakdown), so both are additive.
|
|
52
|
+
| "truncated"
|
|
53
|
+
| "incomplete";
|
|
47
54
|
|
|
48
55
|
// Issue #120 (Review round): single source of truth for the user-abort stamp.
|
|
49
56
|
// Used by the abort handler (runner.ts), the stopped/aborted settlement paths
|
|
@@ -58,6 +65,17 @@ export const SUBAGENT_ABORTED_MESSAGE = "Subagent aborted by user";
|
|
|
58
65
|
*/
|
|
59
66
|
export function classifyError(result: SubagentResult): ErrorCategory {
|
|
60
67
|
if (result.stopReason === "aborted") return "aborted";
|
|
68
|
+
// Issue #179: a terminal stopReason with no terminal answer is a failure in
|
|
69
|
+
// its own right — classify it before the message/exitCode fallbacks so the
|
|
70
|
+
// honest category wins (length → "truncated"; mid-turn → "incomplete").
|
|
71
|
+
if (result.stopReason === "length") return "truncated";
|
|
72
|
+
if (
|
|
73
|
+
result.stopReason === "toolUse" ||
|
|
74
|
+
result.stopReason === "deferred" ||
|
|
75
|
+
result.stopReason === "pending"
|
|
76
|
+
) {
|
|
77
|
+
return "incomplete";
|
|
78
|
+
}
|
|
61
79
|
|
|
62
80
|
const msg = (result.errorMessage ?? "").toLowerCase();
|
|
63
81
|
const err = (result.stderr ?? "").toLowerCase();
|
|
@@ -226,6 +244,27 @@ export interface SubagentRun {
|
|
|
226
244
|
id: string;
|
|
227
245
|
task: string;
|
|
228
246
|
label?: string;
|
|
247
|
+
/**
|
|
248
|
+
* Issue #179 (D1/D2): terminal stopReason of the final assistant turn
|
|
249
|
+
* ("stop" | "error" | "aborted" | "length" | "toolUse" | "deferred" |
|
|
250
|
+
* "pending"). Recorded on the finalized entry so consumers can tell a real
|
|
251
|
+
* success from a resolved-but-failed run.
|
|
252
|
+
*/
|
|
253
|
+
stopReason?: string;
|
|
254
|
+
/**
|
|
255
|
+
* Issue #179 (D1/D2): classified error category at the TOP level. The legacy
|
|
256
|
+
* `originalParams.errorCategory` is kept populated for back-compat (metrics,
|
|
257
|
+
* retry snapshot), but new consumers should prefer this field.
|
|
258
|
+
*/
|
|
259
|
+
errorCategory?: ErrorCategory;
|
|
260
|
+
/**
|
|
261
|
+
* Issue #179 (D3): assistant turns folded from the session usage. 0 means the
|
|
262
|
+
* run burned nothing ("did nothing"); > 0 with `finalTurnError` means it did
|
|
263
|
+
* work and then died.
|
|
264
|
+
*/
|
|
265
|
+
turns?: number;
|
|
266
|
+
/** Issue #179 (D3): whether the final assistant turn was a provider error. */
|
|
267
|
+
finalTurnError?: boolean;
|
|
229
268
|
// Issue #98: background run entries carry the agent description (the label
|
|
230
269
|
// the caller passed at spawn) — kept separate from `label` for symmetry
|
|
231
270
|
// with the background agent record.
|
|
@@ -665,9 +704,108 @@ export function getFinalOutput(messages: Array<Record<string, unknown>>): string
|
|
|
665
704
|
}
|
|
666
705
|
|
|
667
706
|
export function isSubagentError(result: SubagentResult): boolean {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
707
|
+
if (result.exitCode !== 0) return true;
|
|
708
|
+
// Issue #179: a resolved-but-failed terminal reason is still an error. This
|
|
709
|
+
// is the foreground terminal-status derivation (finalizeRunRecord) — the
|
|
710
|
+
// background path classifies via classifyTerminalOutcome below.
|
|
711
|
+
return result.stopReason === "error"
|
|
712
|
+
|| result.stopReason === "aborted"
|
|
713
|
+
|| result.stopReason === "length"
|
|
714
|
+
|| result.stopReason === "toolUse"
|
|
715
|
+
|| result.stopReason === "deferred"
|
|
716
|
+
|| result.stopReason === "pending";
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Issue #179 (review ratchet): the NARROWER question — was the terminal turn a
|
|
721
|
+
* provider/model error specifically?
|
|
722
|
+
*
|
|
723
|
+
* This is distinct from `isSubagentError` (did the run fail at all): a `length`,
|
|
724
|
+
* `toolUse`, `deferred` or `pending` ending is a FAILED RUN but NOT a provider
|
|
725
|
+
* error, and callers asking this narrower question want only `"error"`.
|
|
726
|
+
*
|
|
727
|
+
* Named so the literal has exactly one home — a consistency ratchet test fails if
|
|
728
|
+
* any other module compares `stopReason` against a failure reason directly, which
|
|
729
|
+
* is how the private copies of the wider predicate silently desynced.
|
|
730
|
+
*/
|
|
731
|
+
export function isProviderError(stopReason: string | undefined): boolean {
|
|
732
|
+
return stopReason === "error";
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Issue #179 (C1 fix): tally subtask outcomes by the SHARED failure predicate.
|
|
737
|
+
* Deriving success from `exitCode === 0` alone mislabels a truncated or
|
|
738
|
+
* mid-turn run (a resolved prompt with stopReason length/toolUse/deferred/
|
|
739
|
+
* pending, exitCode 0) as a success — the exact "failure displayed as a
|
|
740
|
+
* success" defect. Every aggregate (parallel succeeded/failed, chain/graph
|
|
741
|
+
* success, the TUI verdicts) must route through isSubagentError so a future
|
|
742
|
+
* widening of the predicate cannot silently desync a private copy.
|
|
743
|
+
*/
|
|
744
|
+
export function countSubagentOutcomes(
|
|
745
|
+
results: ReadonlyArray<SubagentResult | undefined>,
|
|
746
|
+
): { succeeded: number; failed: number } {
|
|
747
|
+
let succeeded = 0;
|
|
748
|
+
let failed = 0;
|
|
749
|
+
for (const r of results) {
|
|
750
|
+
if (!r) continue;
|
|
751
|
+
if (isSubagentError(r)) failed++;
|
|
752
|
+
else succeeded++;
|
|
753
|
+
}
|
|
754
|
+
return { succeeded, failed };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ---------------------------------------------------------------------------
|
|
758
|
+
// Issue #179: terminal outcome classification
|
|
759
|
+
// ---------------------------------------------------------------------------
|
|
760
|
+
|
|
761
|
+
/** Terminal status a settled run can take. */
|
|
762
|
+
export type TerminalStatus = "completed" | "failed" | "stopped";
|
|
763
|
+
|
|
764
|
+
export interface TerminalOutcome {
|
|
765
|
+
status: TerminalStatus;
|
|
766
|
+
errorCategory?: ErrorCategory;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Issue #179 (D1): classify a run's terminal outcome from its final assistant
|
|
771
|
+
* stopReason. This is the settle-time counterpart to classifyError/isSubagentError
|
|
772
|
+
* and REUSES classifyError for every failure reason (single taxonomy).
|
|
773
|
+
*
|
|
774
|
+
* | stopReason | status | errorCategory |
|
|
775
|
+
* |--------------------------|-----------|----------------------------|
|
|
776
|
+
* | stop / undefined | completed | — |
|
|
777
|
+
* | aborted | stopped | aborted |
|
|
778
|
+
* | error | failed | from classifyError |
|
|
779
|
+
* | length | failed | truncated |
|
|
780
|
+
* | toolUse/deferred/pending | failed | incomplete |
|
|
781
|
+
*/
|
|
782
|
+
export function classifyTerminalOutcome(
|
|
783
|
+
stopReason: string | undefined,
|
|
784
|
+
errorMessage?: string,
|
|
785
|
+
extra?: { stderr?: string; exitCode?: number },
|
|
786
|
+
): TerminalOutcome {
|
|
787
|
+
switch (stopReason) {
|
|
788
|
+
case "aborted":
|
|
789
|
+
return { status: "stopped", errorCategory: "aborted" };
|
|
790
|
+
case "error":
|
|
791
|
+
case "length":
|
|
792
|
+
case "toolUse":
|
|
793
|
+
case "deferred":
|
|
794
|
+
case "pending":
|
|
795
|
+
return {
|
|
796
|
+
status: "failed",
|
|
797
|
+
errorCategory: classifyError({
|
|
798
|
+
messages: [],
|
|
799
|
+
usage: { ...EMPTY_USAGE },
|
|
800
|
+
exitCode: extra?.exitCode ?? 0,
|
|
801
|
+
stderr: extra?.stderr ?? "",
|
|
802
|
+
errorMessage,
|
|
803
|
+
stopReason,
|
|
804
|
+
}),
|
|
805
|
+
};
|
|
806
|
+
default:
|
|
807
|
+
return { status: "completed" };
|
|
808
|
+
}
|
|
671
809
|
}
|
|
672
810
|
|
|
673
811
|
export function formatModel(m: { provider: string; id: string } | undefined): string {
|