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/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 contain the full subagent conversation — the
23
- // dir must be owner-only (0o700) so other local users cannot list the files.
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): transcripts hold the full conversation — append
56
- // owner-only (0o600) on file CREATE (mode is not retroactive).
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): transcripts hold the full conversation — append
83
- // owner-only (0o600) on file CREATE (mode is not retroactive).
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.findRunById(ctx, agentId)?.fullOutput || "";
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
- r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted"
2807
- ? theme.fg("success", "\u2713")
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
- return result.exitCode !== 0
669
- || result.stopReason === "error"
670
- || result.stopReason === "aborted";
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 {