brl-subagent 2.3.6 → 2.3.8
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 +15 -4
- package/README.md +18 -5
- package/package.json +5 -5
- package/src/history.ts +8 -1
- package/src/index.ts +849 -442
- package/src/logging.ts +39 -15
- package/src/notify-completion.ts +45 -20
- package/src/runner.ts +2 -1
- package/src/session-manager.ts +186 -39
- package/src/state.ts +42 -2
- package/src/templates.ts +1 -1
- package/src/transcript.ts +11 -6
- package/src/tui.ts +14 -53
- package/src/types.ts +163 -4
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
|
@@ -1710,7 +1710,7 @@ export async function showAgentDetail(
|
|
|
1710
1710
|
// path persists it), mirroring the background path's
|
|
1711
1711
|
// `agent?.finalOutput` fallback.
|
|
1712
1712
|
const runOutput =
|
|
1713
|
-
cachedTail || state.
|
|
1713
|
+
cachedTail || state.findTerminalRunById(ctx, agentId)?.fullOutput || "";
|
|
1714
1714
|
const tailLines = runOutput.split("\n").filter(Boolean).slice(-10);
|
|
1715
1715
|
if (tailLines.length > 0) {
|
|
1716
1716
|
container.addChild(new Spacer(1));
|
|
@@ -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,129 @@ 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
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Issue #179 (C1 fix): a failed run entry/result must carry a terminal reason
|
|
813
|
+
* that AGREES with its status — never undefined, never "stop". Any reason the
|
|
814
|
+
* classifier already treats as a failure (error / length / toolUse / deferred
|
|
815
|
+
* / pending / aborted) is coherent and kept; a missing or success reason is
|
|
816
|
+
* coerced to the honest failure reason derived from the classified category.
|
|
817
|
+
*
|
|
818
|
+
* Issue #187: lives here (not session-manager) so BOTH the background
|
|
819
|
+
* finalize (session-manager.ts) and the foreground finalize (history.ts) apply
|
|
820
|
+
* the SAME rule through one implementation instead of two copies.
|
|
821
|
+
*/
|
|
822
|
+
export function coherentFailureReason(
|
|
823
|
+
stopReason: string | undefined,
|
|
824
|
+
errorCategory: ErrorCategory | undefined,
|
|
825
|
+
): string {
|
|
826
|
+
if (stopReason && classifyTerminalOutcome(stopReason).status !== "completed") {
|
|
827
|
+
return stopReason;
|
|
828
|
+
}
|
|
829
|
+
return errorCategory === "aborted" ? "aborted" : "error";
|
|
671
830
|
}
|
|
672
831
|
|
|
673
832
|
export function formatModel(m: { provider: string; id: string } | undefined): string {
|