@aragon-agent/cli 0.5.8 → 0.5.9

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/README.md +1429 -1163
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +40 -0
  9. package/dist/agent/reducer.d.ts +245 -3
  10. package/dist/agent/reducer.js +490 -17
  11. package/dist/agent/retry-view.d.ts +68 -0
  12. package/dist/agent/retry-view.js +95 -0
  13. package/dist/agent/system-prompt.d.ts +11 -0
  14. package/dist/agent/system-prompt.js +2 -0
  15. package/dist/cli.js +86 -2
  16. package/dist/commands/builtins.d.ts +1 -1
  17. package/dist/commands/builtins.js +308 -2
  18. package/dist/config/cli-commands.d.ts +53 -2
  19. package/dist/config/cli-commands.js +136 -3
  20. package/dist/config/env.d.ts +6 -3
  21. package/dist/config/env.js +103 -3
  22. package/dist/config/load.d.ts +49 -0
  23. package/dist/config/load.js +83 -1
  24. package/dist/config/schema.d.ts +212 -2
  25. package/dist/config/schema.js +207 -1
  26. package/dist/config/store.js +20 -1
  27. package/dist/fast/critique.d.ts +43 -0
  28. package/dist/fast/critique.js +62 -0
  29. package/dist/fast/digest.d.ts +42 -0
  30. package/dist/fast/digest.js +85 -0
  31. package/dist/fast/frames.d.ts +99 -0
  32. package/dist/fast/frames.js +180 -0
  33. package/dist/fast/limits.d.ts +98 -0
  34. package/dist/fast/limits.js +98 -0
  35. package/dist/fast/prompt.d.ts +72 -0
  36. package/dist/fast/prompt.js +94 -0
  37. package/dist/fast/resolve.d.ts +43 -0
  38. package/dist/fast/resolve.js +105 -0
  39. package/dist/fast/review-call.d.ts +99 -0
  40. package/dist/fast/review-call.js +130 -0
  41. package/dist/fast/reviewer.d.ts +248 -0
  42. package/dist/fast/reviewer.js +664 -0
  43. package/dist/fast/types.d.ts +128 -0
  44. package/dist/fast/types.js +14 -0
  45. package/dist/fast/wiring.d.ts +170 -0
  46. package/dist/fast/wiring.js +316 -0
  47. package/dist/logging/install.d.ts +15 -1
  48. package/dist/logging/install.js +37 -4
  49. package/dist/logging/logger.d.ts +1 -1
  50. package/dist/session/persist.js +57 -1
  51. package/dist/team/normalize.d.ts +26 -1
  52. package/dist/team/normalize.js +23 -3
  53. package/dist/team/prompt.d.ts +11 -0
  54. package/dist/team/prompt.js +10 -0
  55. package/dist/team/report.d.ts +18 -0
  56. package/dist/team/report.js +27 -1
  57. package/dist/team/runtime.d.ts +13 -2
  58. package/dist/team/runtime.js +33 -6
  59. package/dist/team/subagent.d.ts +19 -1
  60. package/dist/team/subagent.js +72 -10
  61. package/dist/team/task-tool.d.ts +26 -0
  62. package/dist/team/task-tool.js +48 -4
  63. package/dist/team/types.d.ts +80 -1
  64. package/dist/tools/bash-tool.js +19 -3
  65. package/dist/tools/diff.d.ts +25 -5
  66. package/dist/tools/diff.js +72 -41
  67. package/dist/tools/file-change-store.d.ts +34 -0
  68. package/dist/tools/file-change-store.js +57 -0
  69. package/dist/tools/fs-tools.d.ts +26 -0
  70. package/dist/tools/fs-tools.js +62 -5
  71. package/dist/tools/index.d.ts +31 -0
  72. package/dist/tools/index.js +7 -1
  73. package/dist/tools/patch.d.ts +115 -0
  74. package/dist/tools/patch.js +453 -0
  75. package/dist/tools/terminal-output.d.ts +107 -0
  76. package/dist/tools/terminal-output.js +198 -0
  77. package/dist/tools/tool-output-store.d.ts +68 -0
  78. package/dist/tools/tool-output-store.js +102 -0
  79. package/dist/ui/ActivityLine.d.ts +48 -0
  80. package/dist/ui/ActivityLine.js +22 -0
  81. package/dist/ui/App.d.ts +1 -4
  82. package/dist/ui/App.js +273 -37
  83. package/dist/ui/BottomStatusRow.d.ts +43 -0
  84. package/dist/ui/BottomStatusRow.js +14 -0
  85. package/dist/ui/Composer.d.ts +1 -6
  86. package/dist/ui/Composer.js +2 -2
  87. package/dist/ui/PromptInput.d.ts +1 -15
  88. package/dist/ui/PromptInput.js +1 -38
  89. package/dist/ui/StatusBar.d.ts +37 -0
  90. package/dist/ui/StatusBar.js +5 -3
  91. package/dist/ui/TeamPanel.d.ts +5 -4
  92. package/dist/ui/TeamPanel.js +1 -1
  93. package/dist/ui/TodoPanel.d.ts +5 -4
  94. package/dist/ui/Transcript.d.ts +26 -3
  95. package/dist/ui/Transcript.js +76 -10
  96. package/dist/ui/activity-phrases.d.ts +33 -0
  97. package/dist/ui/activity-phrases.js +50 -0
  98. package/dist/ui/entries/AssistantEntry.d.ts +20 -0
  99. package/dist/ui/entries/AssistantEntry.js +3 -2
  100. package/dist/ui/entries/DiffView.d.ts +67 -0
  101. package/dist/ui/entries/DiffView.js +103 -0
  102. package/dist/ui/entries/FastCard.d.ts +53 -0
  103. package/dist/ui/entries/FastCard.js +87 -0
  104. package/dist/ui/entries/RetryCard.d.ts +56 -0
  105. package/dist/ui/entries/RetryCard.js +60 -0
  106. package/dist/ui/entries/TeamCard.js +6 -1
  107. package/dist/ui/entries/ToolCard.d.ts +38 -1
  108. package/dist/ui/entries/ToolCard.js +68 -5
  109. package/dist/ui/glyphs.d.ts +9 -0
  110. package/dist/ui/glyphs.js +2 -0
  111. package/dist/ui/layout/AppShell.d.ts +13 -22
  112. package/dist/ui/layout/AppShell.js +3 -38
  113. package/dist/ui/layout/virtual-window.d.ts +1 -1
  114. package/dist/ui/layout/virtual-window.js +109 -5
  115. package/dist/ui/overlays/HelpOverlay.js +16 -2
  116. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  117. package/dist/ui/overlays/SettingsScreen.js +147 -1
  118. package/dist/ui/transcript-text.js +27 -0
  119. package/dist/ui/use-wheel-routing.d.ts +16 -32
  120. package/dist/ui/use-wheel-routing.js +21 -38
  121. package/package.json +2 -2
  122. package/dist/ui/layout/regions.d.ts +0 -55
  123. package/dist/ui/layout/regions.js +0 -52
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Runtime shapes for the fast tier (fast-model-tier §5.2 / §5.3).
3
+ *
4
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
5
+ *
6
+ * `FastEvent` is a CLI-LOCAL stream and must stay that way (D-10 / I-1), for
7
+ * the reason `TeamEvent` and `TodoEvent` each record: `packages/core` freezes
8
+ * its runtime export list (`public-api.test.ts`) and forbids host coupling
9
+ * (`no-host-coupling.test.ts`), so a new member of core's `AgentEvent` union
10
+ * would break the first and teach core about a host shape it has no business
11
+ * defining. This whole feature therefore changes ZERO files under
12
+ * `packages/core/` (AC-30).
13
+ */
14
+ import type { ModelRef, ThinkingLevel, TokenUsage } from '@aragon-agent/core';
15
+ /** Which model runs a piece of work. Two tiers, and deliberately not three. */
16
+ export type FastTierName = 'main' | 'fast';
17
+ /**
18
+ * The resolved fast tier, or the reason there isn't one.
19
+ *
20
+ * A DISCRIMINATED UNION rather than a nullable `ModelRef`, because every UI
21
+ * surface has to be able to say WHY the tier is off: "enabled but no model" and
22
+ * "enabled but no key for openai" send the user to two different settings.
23
+ */
24
+ export type FastTier = {
25
+ ok: true;
26
+ ref: ModelRef;
27
+ thinkingLevel: ThinkingLevel;
28
+ sameAsMain: boolean;
29
+ } | {
30
+ ok: false;
31
+ reason: FastTierOffReason;
32
+ };
33
+ export type FastTierOffReason = 'disabled' | 'no_model' | 'no_adapter' | 'no_key';
34
+ /**
35
+ * One completed turn of the lead, as the reviewer records it.
36
+ *
37
+ * `sealed` is what §3.5.1 turns on: a frame is complete only once every
38
+ * `tool_execution_end` of its batch has landed, because `isError` and `ms` do
39
+ * not exist before then (`agent-loop.ts:249-256`). A digest built from an
40
+ * unsealed frame is structurally missing the single fact a reviewer most needs -
41
+ * whether the batch it is reviewing just failed (RV-2 / D-22).
42
+ */
43
+ export interface TurnFrame {
44
+ /** 1-based within the run. */
45
+ index: number;
46
+ /** <= FAST_LIMITS.turnTailChars, appended and clipped per `text_delta`. */
47
+ textTail: string;
48
+ tools: Array<{
49
+ name: string;
50
+ arg?: string;
51
+ isError?: boolean;
52
+ ms?: number;
53
+ }>;
54
+ /** `formatStreamError` output, when the turn errored. */
55
+ error?: string;
56
+ /** Complete, and safe to digest (§3.5.1). */
57
+ sealed: boolean;
58
+ }
59
+ /** One review, in whatever state it reached. Rendered by `FastCard`. */
60
+ export interface FastReview {
61
+ /** 1-based within the SESSION, so `/fast status` can count them. */
62
+ index: number;
63
+ runId: number;
64
+ /** The turn that triggered it. */
65
+ turn: number;
66
+ /** The resolved fast model id, for the card and the injected wrapper. */
67
+ model: string;
68
+ kind: 'ok' | 'advice' | 'empty' | 'failed' | 'dropped';
69
+ /** Present only for `advice`. */
70
+ text?: string;
71
+ /** Failure reason / drop reason. */
72
+ detail?: string;
73
+ durationMs: number;
74
+ usage?: TokenUsage;
75
+ injected: boolean;
76
+ }
77
+ /** Everything `/fast status`, the chip and the settings line read. */
78
+ export interface FastSnapshot {
79
+ /** The tier resolved AND the live switch is on. */
80
+ live: boolean;
81
+ model: string;
82
+ sameAsMain: boolean;
83
+ reviews: number;
84
+ /** The live session budget, for the `n/N` render (fast-model-tier-hardening
85
+ * §5). Read in the SAME expression as `reviews`, so the numerator and the
86
+ * denominator cannot describe different moments. */
87
+ reviewBudget: number;
88
+ /**
89
+ * Whether the session budget is exhausted. CARRIED, NOT DERIVED: callers MUST
90
+ * NOT recompute `reviews >= reviewBudget`. In `offFastStatus()` both numbers
91
+ * are `0` placeholders for "there is no tier", and that comparison would
92
+ * report a budget reached for a session that never had one (RV-H10).
93
+ */
94
+ budgetReached: boolean;
95
+ /** Fast-tier children dispatched this session. */
96
+ delegated: number;
97
+ usage: TokenUsage;
98
+ /**
99
+ * No price table for `model` (C-11 / RV-4).
100
+ *
101
+ * `ModelRegistry.buildRuntimeModel` returns `cost: { input: 0, output: 0 }`
102
+ * for a model the static table has never seen, and the fast tier is precisely
103
+ * where an unrecognised id is LIKELY. Rendering that as `$0.00` would make the
104
+ * feature look free while it is spending money.
105
+ */
106
+ pricingUnknown: boolean;
107
+ /** A review call is open right now. */
108
+ inFlight: boolean;
109
+ }
110
+ /**
111
+ * The CLI-local event stream. `FastReviewer` emits; `FastWiring` forwards;
112
+ * `AgentController.subscribeFast` exposes; `App` and `runHeadless` consume.
113
+ */
114
+ export type FastEvent = {
115
+ type: 'review_start';
116
+ index: number;
117
+ turn: number;
118
+ } | {
119
+ type: 'review_end';
120
+ review: FastReview;
121
+ } | {
122
+ type: 'usage';
123
+ usage: TokenUsage;
124
+ } | {
125
+ type: 'tier_changed';
126
+ snapshot: FastSnapshot;
127
+ };
128
+ export type FastEventListener = (event: FastEvent) => void;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Runtime shapes for the fast tier (fast-model-tier §5.2 / §5.3).
3
+ *
4
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
5
+ *
6
+ * `FastEvent` is a CLI-LOCAL stream and must stay that way (D-10 / I-1), for
7
+ * the reason `TeamEvent` and `TodoEvent` each record: `packages/core` freezes
8
+ * its runtime export list (`public-api.test.ts`) and forbids host coupling
9
+ * (`no-host-coupling.test.ts`), so a new member of core's `AgentEvent` union
10
+ * would break the first and teach core about a host shape it has no business
11
+ * defining. This whole feature therefore changes ZERO files under
12
+ * `packages/core/` (AC-30).
13
+ */
14
+ export {};
@@ -0,0 +1,170 @@
1
+ /**
2
+ * FastWiring — the controller-side glue for the fast tier (fast-model-tier §3.1
3
+ * / §3.3).
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THIS CLASS EXISTS FOR A BUDGET REASON, NOT AN AESTHETIC ONE (C-12 / RV-5).
8
+ * `CLAUDE.md` caps a source file at 1000 lines and `controller.ts` was at 960
9
+ * before this feature, which handed it nine new responsibilities. `FastWiring`
10
+ * owns the flag pair, the tier cache, the reviewer's lifetime and the
11
+ * `resolveTier` closure; `AgentController` keeps one field and four thin
12
+ * forwarders. It is still CLI-local and still holds no `ui/` import.
13
+ *
14
+ * TWO FLAGS AND ONE LIVE PREDICATE (§3.3), which is the shape every optional
15
+ * subsystem in this package already uses:
16
+ *
17
+ * fastRegistered decided ONCE at construction = resolveFastTier(config).ok
18
+ * fastEnabled live, flipped by /fast on|off = starts equal to registered
19
+ * available() derived, read at EVERY use = registered && enabled
20
+ * && resolveFastTier(config).ok
21
+ *
22
+ * THE THIRD TERM IS NOT REDUNDANT, and leaving it out is RV-3. `fastRegistered`
23
+ * is frozen by C-2, but the tier it was derived from is not: `fast.provider: ''`
24
+ * and `fast.baseUrl: ''` INHERIT from `config.provider` / `config.baseUrl`, and
25
+ * the key check reads `apiKeys` - all of which `/model`, `/provider`, `/reload`,
26
+ * the settings screen's save and a one-shot key edit rewrite mid-session. So
27
+ * `registered && enabled` can be true at the exact moment `resolveFastTier` has
28
+ * started returning `no_key`, at which point `task` keeps advertising
29
+ * `model:"fast"`, the child is built against a provider with no key, and it dies
30
+ * inside its own loop with an error naming neither the tier nor the setting that
31
+ * caused it (`agent-loop.ts:140-145`).
32
+ */
33
+ import { type AgentEvent, type AssistantMessage, type LLMRequest, type ModelRef, type ThinkingLevel } from '@aragon-agent/core';
34
+ import type { CliConfig } from '../config/schema.js';
35
+ import type { NoticeLevel } from '../agent/reducer.js';
36
+ import type { FastEventListener, FastSnapshot, FastTier, FastTierName } from './types.js';
37
+ export interface FastWiringDeps {
38
+ /** Read LIVE on every call - the controller replaces the object on mutation. */
39
+ getConfig: () => CliConfig;
40
+ /** `AgentController.hasApiKey`, so there is exactly one key resolver. */
41
+ hasKey: (providerId: string) => boolean;
42
+ getApiKey: (providerId: string) => string | undefined;
43
+ /** Whether the static price table knows this model (C-11 / RV-4). */
44
+ isPricedModel: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => boolean;
45
+ /** The lead agent's event stream and steering surface. */
46
+ subscribe: (listener: (event: AgentEvent) => void) => () => void;
47
+ /**
48
+ * NOT THE REVIEWER'S TRANSPORT SINCE ROUND 2 (W2 / §3.2): the reviewer calls
49
+ * through `getFastRegistry()`, which is fail-fast and does not observe the
50
+ * user's `/retry`. Retained because the only alternative is a `controller.ts`
51
+ * edit that the round-2 change plan forbids (D-H13), and because the next
52
+ * caller that needs the LEAD's registry from here already has it. Do not
53
+ * "clean up" without reading §3.2.
54
+ */
55
+ complete: (providerId: string, request: LLMRequest) => Promise<AssistantMessage>;
56
+ steer: (text: string) => void;
57
+ isRunning: () => boolean;
58
+ isAbortRequested: () => boolean;
59
+ userSteerCount: () => number;
60
+ clearAllQueues: () => void;
61
+ notify: (level: NoticeLevel, text: string) => void;
62
+ /** Recompose the system prompt: `<fast_tier>` interpolates the model id, so a
63
+ * tier that stops resolving must not leave the prompt naming it (§3.9). */
64
+ onPromptChanged: () => void;
65
+ }
66
+ /** What `/fast status`, the settings line and the status chip all read. */
67
+ export interface FastStatus {
68
+ registered: boolean;
69
+ enabled: boolean;
70
+ tier: FastTier;
71
+ snapshot: FastSnapshot;
72
+ }
73
+ /** The status of a session that never had the tier (wiring is `null`). */
74
+ export declare function offFastStatus(): FastStatus;
75
+ export declare class FastWiring {
76
+ private readonly deps;
77
+ /** Scope `fast` since round 2 (W4), for the reason `FastReviewer` records. */
78
+ private readonly log;
79
+ private readonly listeners;
80
+ private readonly reviewer;
81
+ /** The reviewer's OWN transport (W2 / §3.2). Lazy - see `getFastRegistry()`. */
82
+ private fastRegistry;
83
+ /** Frozen at construction (C-2): the `task` schema cannot grow a field later. */
84
+ private readonly registered;
85
+ private enabled;
86
+ /** The last resolution. Recomputed by `onConfigChanged()`, never trusted stale. */
87
+ private tier;
88
+ /** Warned once per TRANSITION, so a broken tier does not shout every turn. */
89
+ private lastReportedReason;
90
+ private delegated;
91
+ constructor(deps: FastWiringDeps);
92
+ /**
93
+ * The registry the REVIEW calls through — separate from the lead's, and
94
+ * fail-fast.
95
+ *
96
+ * THE PROBLEM IT SOLVES. `ProviderRegistry.complete()` routes through a
97
+ * retrying `stream()`, the policy is per-registry, and `LLMRequest` carries no
98
+ * override (IF-3) - so before this the background advisory inherited the
99
+ * user's `retry` config, up to ten attempts with backoff. During the one
100
+ * condition where the fast tier is most likely to be failing (a rate-limited
101
+ * or overloaded provider), an unrequested call competed with the user's actual
102
+ * work for the same quota. It needs NO core change: `initProviders()` is
103
+ * exported and returns a FRESH registry per call with all three adapters
104
+ * registered, which is exactly what `resolveFastTier` rule 4 gates on.
105
+ *
106
+ * IT DOES NOT OBSERVE THE USER'S `retry` CONFIG OR `/retry`, deliberately
107
+ * (D-H4): that policy states how hard to fight for the USER'S answer, and
108
+ * inheriting it for a call nobody asked for is the bug. Logged at construction
109
+ * so a support reader can see which policy a review ran under.
110
+ *
111
+ * LAZY, SO OFF STAYS BYTE-IDENTICAL (I-2 / AC-H13). Nothing is constructed
112
+ * until a review actually calls, so a session that never uses the tier
113
+ * allocates no registry and registers no adapters.
114
+ */
115
+ private getFastRegistry;
116
+ isRegistered(): boolean;
117
+ isEnabled(): boolean;
118
+ /** §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header. */
119
+ available(): boolean;
120
+ /**
121
+ * Whether `model: "fast"` on `task` is honoured right now.
122
+ *
123
+ * Evaluated AT DISPATCH TIME, never snapshot (§3.4 / RV-3). `fast.delegate:
124
+ * false` and "the tier stopped resolving twenty minutes ago" are the same
125
+ * observable to the model - a downgrade, counted and reported - which is the
126
+ * honest reading of both.
127
+ */
128
+ delegationAvailable(): boolean;
129
+ setEnabled(enabled: boolean): void;
130
+ /**
131
+ * Re-resolve the tier after ANY config mutation (§3.2 rule 8 / §3.9 / RV-3).
132
+ *
133
+ * Called from `setModel`, `setApiKey`, `setFastConfig`, `/reload` and the
134
+ * settings screen's save - the five live inputs rules 2, 5 and 6 read. It also
135
+ * recomposes the system prompt, because `<fast_tier>` interpolates the
136
+ * resolved model id and a prompt naming a model the tier will not use is worse
137
+ * than no prompt at all.
138
+ */
139
+ onConfigChanged(): void;
140
+ /**
141
+ * The startup notice for a configuration mistake that is otherwise invisible
142
+ * (§3.3). Raised once, at construction, through the controller's `notify`.
143
+ */
144
+ startupNotice(): string | null;
145
+ /**
146
+ * The `ModelRef` and thinking level one subagent tier runs on.
147
+ *
148
+ * `'fast'` FALLS BACK TO MAIN when delegation is not available, so the two
149
+ * guards agree BY CONSTRUCTION: the normalizer can never produce a
150
+ * `tier: 'fast'` spec this factory would refuse, and this factory can never
151
+ * build a child against a `ModelRef` the tier no longer resolves (RV-3).
152
+ */
153
+ resolveTier(tier: FastTierName): {
154
+ ref: ModelRef;
155
+ thinkingLevel: ThinkingLevel;
156
+ };
157
+ /** The resolved fast `ModelRef`, or `null` - for per-tier cost lookup (§3.6). */
158
+ fastRef(): ModelRef | null;
159
+ /** Count fast-tier children so `/fast status` can answer "is this saving me
160
+ * anything?" — the aggregate on the status bar cannot. */
161
+ noteDelegated(count: number): void;
162
+ setGoal(text: string): void;
163
+ abort(): void;
164
+ dispose(): void;
165
+ getTier(): FastTier;
166
+ snapshot(): FastSnapshot;
167
+ status(): FastStatus;
168
+ subscribe(listener: FastEventListener): () => void;
169
+ private emit;
170
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * FastWiring — the controller-side glue for the fast tier (fast-model-tier §3.1
3
+ * / §3.3).
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THIS CLASS EXISTS FOR A BUDGET REASON, NOT AN AESTHETIC ONE (C-12 / RV-5).
8
+ * `CLAUDE.md` caps a source file at 1000 lines and `controller.ts` was at 960
9
+ * before this feature, which handed it nine new responsibilities. `FastWiring`
10
+ * owns the flag pair, the tier cache, the reviewer's lifetime and the
11
+ * `resolveTier` closure; `AgentController` keeps one field and four thin
12
+ * forwarders. It is still CLI-local and still holds no `ui/` import.
13
+ *
14
+ * TWO FLAGS AND ONE LIVE PREDICATE (§3.3), which is the shape every optional
15
+ * subsystem in this package already uses:
16
+ *
17
+ * fastRegistered decided ONCE at construction = resolveFastTier(config).ok
18
+ * fastEnabled live, flipped by /fast on|off = starts equal to registered
19
+ * available() derived, read at EVERY use = registered && enabled
20
+ * && resolveFastTier(config).ok
21
+ *
22
+ * THE THIRD TERM IS NOT REDUNDANT, and leaving it out is RV-3. `fastRegistered`
23
+ * is frozen by C-2, but the tier it was derived from is not: `fast.provider: ''`
24
+ * and `fast.baseUrl: ''` INHERIT from `config.provider` / `config.baseUrl`, and
25
+ * the key check reads `apiKeys` - all of which `/model`, `/provider`, `/reload`,
26
+ * the settings screen's save and a one-shot key edit rewrite mid-session. So
27
+ * `registered && enabled` can be true at the exact moment `resolveFastTier` has
28
+ * started returning `no_key`, at which point `task` keeps advertising
29
+ * `model:"fast"`, the child is built against a provider with no key, and it dies
30
+ * inside its own loop with an error naming neither the tier nor the setting that
31
+ * caused it (`agent-loop.ts:140-145`).
32
+ */
33
+ import { DEFAULT_RETRY_POLICY, initProviders, } from '@aragon-agent/core';
34
+ import { getLogger } from '../logging/logger.js';
35
+ import { describeFastTierProblem, fastProviderOf, resolveFastTier } from './resolve.js';
36
+ import { FastReviewer } from './reviewer.js';
37
+ /**
38
+ * One retry, not ten and not zero (D-H3).
39
+ *
40
+ * ZERO loses a review to a single connection reset; TEN is what makes a
41
+ * background advisory compete with the user's own work for a rate-limited
42
+ * provider's quota, which is the whole defect W2 exists to close. It spreads
43
+ * `DEFAULT_RETRY_POLICY` rather than replacing it, so `respectRetryAfter` and
44
+ * the transient classification stay exactly as the lead's - only the attempt
45
+ * count differs.
46
+ */
47
+ const FAST_RETRY_POLICY = { ...DEFAULT_RETRY_POLICY, maxRetries: 1 };
48
+ /** The status of a session that never had the tier (wiring is `null`). */
49
+ export function offFastStatus() {
50
+ return {
51
+ registered: false,
52
+ enabled: false,
53
+ tier: { ok: false, reason: 'disabled' },
54
+ snapshot: {
55
+ live: false,
56
+ model: '',
57
+ sameAsMain: false,
58
+ reviews: 0,
59
+ // BOTH ARE PLACEHOLDERS FOR "THERE IS NO TIER", NOT A LIMIT (RV-H10).
60
+ // `reviews >= reviewBudget` here is `0 >= 0` -> `true`, which is why
61
+ // `budgetReached` is CARRIED and callers must never re-derive it.
62
+ reviewBudget: 0,
63
+ budgetReached: false,
64
+ delegated: 0,
65
+ usage: { inputTokens: 0, outputTokens: 0 },
66
+ pricingUnknown: false,
67
+ inFlight: false,
68
+ },
69
+ };
70
+ }
71
+ export class FastWiring {
72
+ deps;
73
+ /** Scope `fast` since round 2 (W4), for the reason `FastReviewer` records. */
74
+ log = getLogger().child('fast');
75
+ listeners = new Set();
76
+ reviewer;
77
+ /** The reviewer's OWN transport (W2 / §3.2). Lazy - see `getFastRegistry()`. */
78
+ fastRegistry = null;
79
+ /** Frozen at construction (C-2): the `task` schema cannot grow a field later. */
80
+ registered;
81
+ enabled;
82
+ /** The last resolution. Recomputed by `onConfigChanged()`, never trusted stale. */
83
+ tier;
84
+ /** Warned once per TRANSITION, so a broken tier does not shout every turn. */
85
+ lastReportedReason = null;
86
+ delegated = 0;
87
+ constructor(deps) {
88
+ this.deps = deps;
89
+ this.tier = resolveFastTier(deps.getConfig(), deps.hasKey);
90
+ this.registered = this.tier.ok;
91
+ this.enabled = this.registered;
92
+ this.lastReportedReason = this.tier.ok ? null : this.tier.reason;
93
+ this.log.info('fast_tier_resolved', {
94
+ enabled: deps.getConfig().fast.enabled,
95
+ provider: this.tier.ok ? this.tier.ref.providerId : fastProviderOf(deps.getConfig()),
96
+ model: this.tier.ok ? this.tier.ref.modelId : deps.getConfig().fast.model,
97
+ sameAsMain: this.tier.ok ? this.tier.sameAsMain : false,
98
+ ...(this.tier.ok ? {} : { reason: this.tier.reason }),
99
+ });
100
+ this.reviewer = new FastReviewer({
101
+ subscribe: deps.subscribe,
102
+ // W2: the reviewer's own fail-fast registry, NOT `deps.complete`. Bound
103
+ // through a closure so the registry stays lazy - the reviewer holds no
104
+ // reference to it and never learns a second one exists.
105
+ complete: (providerId, request) => this.getFastRegistry().complete(providerId, request),
106
+ steer: deps.steer,
107
+ isRunning: deps.isRunning,
108
+ isAbortRequested: deps.isAbortRequested,
109
+ userSteerCount: deps.userSteerCount,
110
+ clearAllQueues: deps.clearAllQueues,
111
+ getTier: () => this.tier,
112
+ getConfig: () => this.deps.getConfig().fast,
113
+ available: () => this.available(),
114
+ getApiKey: deps.getApiKey,
115
+ emit: (event) => this.emit(event),
116
+ notify: deps.notify,
117
+ });
118
+ }
119
+ // -----------------------------------------------------------------------
120
+ // The reviewer's transport (W2 / §3.2)
121
+ // -----------------------------------------------------------------------
122
+ /**
123
+ * The registry the REVIEW calls through — separate from the lead's, and
124
+ * fail-fast.
125
+ *
126
+ * THE PROBLEM IT SOLVES. `ProviderRegistry.complete()` routes through a
127
+ * retrying `stream()`, the policy is per-registry, and `LLMRequest` carries no
128
+ * override (IF-3) - so before this the background advisory inherited the
129
+ * user's `retry` config, up to ten attempts with backoff. During the one
130
+ * condition where the fast tier is most likely to be failing (a rate-limited
131
+ * or overloaded provider), an unrequested call competed with the user's actual
132
+ * work for the same quota. It needs NO core change: `initProviders()` is
133
+ * exported and returns a FRESH registry per call with all three adapters
134
+ * registered, which is exactly what `resolveFastTier` rule 4 gates on.
135
+ *
136
+ * IT DOES NOT OBSERVE THE USER'S `retry` CONFIG OR `/retry`, deliberately
137
+ * (D-H4): that policy states how hard to fight for the USER'S answer, and
138
+ * inheriting it for a call nobody asked for is the bug. Logged at construction
139
+ * so a support reader can see which policy a review ran under.
140
+ *
141
+ * LAZY, SO OFF STAYS BYTE-IDENTICAL (I-2 / AC-H13). Nothing is constructed
142
+ * until a review actually calls, so a session that never uses the tier
143
+ * allocates no registry and registers no adapters.
144
+ */
145
+ getFastRegistry() {
146
+ if (this.fastRegistry === null) {
147
+ this.fastRegistry = initProviders({ retryPolicy: FAST_RETRY_POLICY });
148
+ this.log.info('fast_registry_created', { maxRetries: FAST_RETRY_POLICY.maxRetries });
149
+ }
150
+ return this.fastRegistry;
151
+ }
152
+ // -----------------------------------------------------------------------
153
+ // Flags and the live predicate
154
+ // -----------------------------------------------------------------------
155
+ isRegistered() {
156
+ return this.registered;
157
+ }
158
+ isEnabled() {
159
+ return this.enabled;
160
+ }
161
+ /** §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header. */
162
+ available() {
163
+ return this.registered && this.enabled && this.tier.ok;
164
+ }
165
+ /**
166
+ * Whether `model: "fast"` on `task` is honoured right now.
167
+ *
168
+ * Evaluated AT DISPATCH TIME, never snapshot (§3.4 / RV-3). `fast.delegate:
169
+ * false` and "the tier stopped resolving twenty minutes ago" are the same
170
+ * observable to the model - a downgrade, counted and reported - which is the
171
+ * honest reading of both.
172
+ */
173
+ delegationAvailable() {
174
+ return this.available() && this.deps.getConfig().fast.delegate === true;
175
+ }
176
+ setEnabled(enabled) {
177
+ this.enabled = enabled;
178
+ this.deps.onPromptChanged();
179
+ this.emit({ type: 'tier_changed', snapshot: this.snapshot() });
180
+ }
181
+ /**
182
+ * Re-resolve the tier after ANY config mutation (§3.2 rule 8 / §3.9 / RV-3).
183
+ *
184
+ * Called from `setModel`, `setApiKey`, `setFastConfig`, `/reload` and the
185
+ * settings screen's save - the five live inputs rules 2, 5 and 6 read. It also
186
+ * recomposes the system prompt, because `<fast_tier>` interpolates the
187
+ * resolved model id and a prompt naming a model the tier will not use is worse
188
+ * than no prompt at all.
189
+ */
190
+ onConfigChanged() {
191
+ const next = resolveFastTier(this.deps.getConfig(), this.deps.hasKey);
192
+ const before = this.tier;
193
+ this.tier = next;
194
+ const reason = next.ok ? null : next.reason;
195
+ if (reason !== this.lastReportedReason) {
196
+ this.lastReportedReason = reason;
197
+ if (reason && before.ok) {
198
+ // A tier that stopped resolving MID-SESSION is the case R-14 is about:
199
+ // without a word here the only symptom is a child dying with
200
+ // `No API key available for provider "openai"`.
201
+ this.log.warn('fast_tier_unavailable', { reason });
202
+ const text = describeFastTierProblem(next, fastProviderOf(this.deps.getConfig()));
203
+ if (text)
204
+ this.deps.notify('warn', text);
205
+ }
206
+ }
207
+ this.deps.onPromptChanged();
208
+ this.emit({ type: 'tier_changed', snapshot: this.snapshot() });
209
+ }
210
+ /**
211
+ * The startup notice for a configuration mistake that is otherwise invisible
212
+ * (§3.3). Raised once, at construction, through the controller's `notify`.
213
+ */
214
+ startupNotice() {
215
+ return describeFastTierProblem(this.tier, fastProviderOf(this.deps.getConfig()));
216
+ }
217
+ // -----------------------------------------------------------------------
218
+ // Tier resolution for subagents (§3.4)
219
+ // -----------------------------------------------------------------------
220
+ /**
221
+ * The `ModelRef` and thinking level one subagent tier runs on.
222
+ *
223
+ * `'fast'` FALLS BACK TO MAIN when delegation is not available, so the two
224
+ * guards agree BY CONSTRUCTION: the normalizer can never produce a
225
+ * `tier: 'fast'` spec this factory would refuse, and this factory can never
226
+ * build a child against a `ModelRef` the tier no longer resolves (RV-3).
227
+ */
228
+ resolveTier(tier) {
229
+ const config = this.deps.getConfig();
230
+ if (tier === 'fast' && this.delegationAvailable() && this.tier.ok) {
231
+ return { ref: this.tier.ref, thinkingLevel: this.tier.thinkingLevel };
232
+ }
233
+ return {
234
+ ref: {
235
+ providerId: config.provider,
236
+ modelId: config.model,
237
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
238
+ },
239
+ thinkingLevel: config.thinkingLevel,
240
+ };
241
+ }
242
+ /** The resolved fast `ModelRef`, or `null` - for per-tier cost lookup (§3.6). */
243
+ fastRef() {
244
+ return this.tier.ok ? this.tier.ref : null;
245
+ }
246
+ /** Count fast-tier children so `/fast status` can answer "is this saving me
247
+ * anything?" — the aggregate on the status bar cannot. */
248
+ noteDelegated(count) {
249
+ if (count > 0)
250
+ this.delegated += count;
251
+ }
252
+ // -----------------------------------------------------------------------
253
+ // Reviewer lifetime
254
+ // -----------------------------------------------------------------------
255
+ setGoal(text) {
256
+ this.reviewer.setGoal(text);
257
+ }
258
+ abort() {
259
+ this.reviewer.abort();
260
+ }
261
+ dispose() {
262
+ this.reviewer.dispose();
263
+ this.listeners.clear();
264
+ }
265
+ // -----------------------------------------------------------------------
266
+ // Status and events
267
+ // -----------------------------------------------------------------------
268
+ getTier() {
269
+ return this.tier;
270
+ }
271
+ snapshot() {
272
+ const ref = this.tier.ok ? this.tier.ref : null;
273
+ return {
274
+ live: this.available(),
275
+ model: ref ? ref.modelId : '',
276
+ sameAsMain: this.tier.ok ? this.tier.sameAsMain : false,
277
+ // THE NUMERATOR, THE DENOMINATOR AND THE FLAG ARE READ IN ONE EXPRESSION,
278
+ // so they cannot describe different moments (§5 / R-H8). `reviewBudget`
279
+ // comes from live config, which is what lets `/fast budget <n>` change the
280
+ // denominator on the status chip with no restart.
281
+ reviews: this.reviewer.reviewCount(),
282
+ reviewBudget: this.reviewer.budgetLimit(),
283
+ budgetReached: this.reviewer.isBudgetReached(),
284
+ delegated: this.delegated,
285
+ usage: this.reviewer.sessionUsage(),
286
+ // UNKNOWN PRICING IS NOT ZERO PRICING (C-11 / RV-4). The fast tier is
287
+ // precisely where an unrecognised model id is likely, and rendering it as
288
+ // `$0.00` would make the feature look free while it is spending money.
289
+ pricingUnknown: ref ? !this.deps.isPricedModel(ref) : false,
290
+ inFlight: this.reviewer.isReviewInFlight(),
291
+ };
292
+ }
293
+ status() {
294
+ return {
295
+ registered: this.registered,
296
+ enabled: this.enabled,
297
+ tier: this.tier,
298
+ snapshot: this.snapshot(),
299
+ };
300
+ }
301
+ subscribe(listener) {
302
+ this.listeners.add(listener);
303
+ return () => this.listeners.delete(listener);
304
+ }
305
+ emit(event) {
306
+ for (const listener of [...this.listeners]) {
307
+ try {
308
+ listener(event);
309
+ }
310
+ catch {
311
+ // One bad subscriber must not take the run down with it — the rule
312
+ // `TeamRuntime.emit` already states.
313
+ }
314
+ }
315
+ }
316
+ }
@@ -121,11 +121,25 @@ export type AgentLogEvent = {
121
121
  inputTokens?: number;
122
122
  outputTokens?: number;
123
123
  };
124
- } | {
124
+ }
125
+ /**
126
+ * `streamEvent` carries the retry fields OPTIONALLY (llm-api-retry-backoff
127
+ * §6.8): this union is a deliberately loose structural mirror of core's
128
+ * `AgentEvent` so a stub controller can satisfy it with an object literal, and
129
+ * narrowing on `streamEvent.type` inside the handler is what makes the extra
130
+ * fields safe to read.
131
+ */
132
+ | {
125
133
  type: 'message_update';
126
134
  streamEvent?: {
127
135
  type?: string;
128
136
  delta?: unknown;
137
+ attempt?: number;
138
+ maxRetries?: number;
139
+ delayMs?: number;
140
+ errorType?: string;
141
+ retryAfterMs?: number;
142
+ discardedToolCallIds?: string[];
129
143
  };
130
144
  } | {
131
145
  type: 'tool_execution_start';