brl-subagent 2.3.5 → 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/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();
@@ -209,8 +227,6 @@ export interface SubagentState {
209
227
  slaTrackingEnabled: boolean; // E4: SLA tracking toggle
210
228
  slaWindowSize: number; // E4: number of recent runs to analyze (10-500)
211
229
  lastSLAMetrics?: SLAMetrics; // E4: persisted baseline for degradation comparison
212
- updateCheckEnabled: boolean; // Version update notifier toggle
213
- lastUpdateCheck: number; // Epoch ms of last update check
214
230
  }
215
231
 
216
232
  /**
@@ -228,6 +244,27 @@ export interface SubagentRun {
228
244
  id: string;
229
245
  task: string;
230
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;
231
268
  // Issue #98: background run entries carry the agent description (the label
232
269
  // the caller passed at spawn) — kept separate from `label` for symmetry
233
270
  // with the background agent record.
@@ -556,8 +593,6 @@ export const TEMPLATE_PARAM_RE = /\$\{(\w+)\}/g;
556
593
 
557
594
  export const DEFAULT_SESSION_COST_LIMIT = 0;
558
595
 
559
- export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
560
-
561
596
  export const CUSTOM_ENTRY_TYPES = {
562
597
  run: "brl-subagent-run",
563
598
  state: "brl-subagent-state",
@@ -589,7 +624,7 @@ export const RESERVED_NAME_PATTERN = /^__.*__$/;
589
624
  export const RESERVED_COMMAND_NAMES = new Set([
590
625
  "model", "thinking", "concurrency", "depth", "history", "monitor",
591
626
  "preset", "retry", "reset", "priority", "templates", "dashboard", "approval",
592
- "costlimit", "historyentries", "sla", "update-check", "completionnotify",
627
+ "costlimit", "historyentries", "sla", "completionnotify",
593
628
  "graph", "sla-stats",
594
629
  ]);
595
630
 
@@ -669,9 +704,108 @@ export function getFinalOutput(messages: Array<Record<string, unknown>>): string
669
704
  }
670
705
 
671
706
  export function isSubagentError(result: SubagentResult): boolean {
672
- return result.exitCode !== 0
673
- || result.stopReason === "error"
674
- || 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
+ }
675
809
  }
676
810
 
677
811
  export function formatModel(m: { provider: string; id: string } | undefined): string {
@@ -750,14 +884,6 @@ export function isSubagentStateShape(value: unknown): value is SubagentState {
750
884
  if (typeof v.slaWindowSize !== "number" || v.slaWindowSize < 10 || v.slaWindowSize > 500) return false;
751
885
  }
752
886
 
753
- // updateCheckEnabled must be a boolean if present
754
- if (v.updateCheckEnabled !== undefined && typeof v.updateCheckEnabled !== "boolean") return false;
755
-
756
- // lastUpdateCheck must be a non-negative number if present
757
- if (v.lastUpdateCheck !== undefined) {
758
- if (typeof v.lastUpdateCheck !== "number" || v.lastUpdateCheck < 0) return false;
759
- }
760
-
761
887
  return true;
762
888
  }
763
889
 
package/src/update.ts DELETED
@@ -1,109 +0,0 @@
1
- /**
2
- * brl-subagent — Version Update Notifier
3
- *
4
- * Checks GitHub releases for newer versions of brl-subagent.
5
- * Runs non-blocking with a 5-second timeout on session start.
6
- */
7
-
8
- import type { Logger } from "./logging";
9
-
10
- // ---------------------------------------------------------------------------
11
- // Types
12
- // ---------------------------------------------------------------------------
13
-
14
- export interface UpdateCheckResult {
15
- available: boolean;
16
- version?: string;
17
- url?: string;
18
- }
19
-
20
- // ---------------------------------------------------------------------------
21
- // Semver comparison
22
- // ---------------------------------------------------------------------------
23
-
24
- /**
25
- * Compare two semver strings (e.g., "2.1.0" vs "2.0.1").
26
- * Returns 1 if a > b, -1 if a < b, 0 if equal.
27
- */
28
- function compareSemver(a: string, b: string): number {
29
- const aParts = a.split(".").map(Number);
30
- const bParts = b.split(".").map(Number);
31
- const len = Math.max(aParts.length, bParts.length);
32
-
33
- for (let i = 0; i < len; i++) {
34
- const aVal = aParts[i] ?? 0;
35
- const bVal = bParts[i] ?? 0;
36
- if (aVal > bVal) return 1;
37
- if (aVal < bVal) return -1;
38
- }
39
- return 0;
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
- // Main export
44
- // ---------------------------------------------------------------------------
45
-
46
- /**
47
- * Check GitHub for a newer release of brl-subagent.
48
- *
49
- * @param currentVersion - The current version string (e.g., "2.0.1")
50
- * @param log - Optional logger for debug output
51
- * @returns Update check result, or null on any error (never throws)
52
- */
53
- export async function checkForUpdates(
54
- currentVersion: string,
55
- log?: Logger,
56
- ): Promise<UpdateCheckResult | null> {
57
- try {
58
- const controller = new AbortController();
59
- const timeoutId = setTimeout(() => controller.abort(), 5_000);
60
-
61
- const response = await fetch(
62
- "https://api.github.com/repos/azmeenafandi/brl-subagent/releases/latest",
63
- {
64
- headers: {
65
- Accept: "application/vnd.github+json",
66
- "User-Agent": "brl-subagent",
67
- },
68
- signal: controller.signal,
69
- },
70
- );
71
-
72
- clearTimeout(timeoutId);
73
-
74
- if (!response.ok) {
75
- log?.debug("Update check: non-OK response", { status: response.status });
76
- return null;
77
- }
78
-
79
- const data = (await response.json()) as {
80
- tag_name?: string;
81
- html_url?: string;
82
- };
83
-
84
- if (!data.tag_name || !data.html_url) {
85
- log?.debug("Update check: missing tag_name or html_url in response");
86
- return null;
87
- }
88
-
89
- // Strip "v" prefix from tag_name (e.g., "v2.1.0" → "2.1.0")
90
- const latestVersion = data.tag_name.replace(/^v/, "");
91
-
92
- const cmp = compareSemver(latestVersion, currentVersion);
93
-
94
- if (cmp > 0) {
95
- return {
96
- available: true,
97
- version: latestVersion,
98
- url: data.html_url,
99
- };
100
- }
101
-
102
- return { available: false };
103
- } catch (err) {
104
- log?.debug("Update check failed", {
105
- error: err instanceof Error ? err.message : String(err),
106
- });
107
- return null;
108
- }
109
- }