@aragon-agent/core 0.2.8 → 0.2.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.
@@ -3,18 +3,36 @@
3
3
  * convenience methods for streaming and completion.
4
4
  */
5
5
  import { LLMError } from '../provider.js';
6
+ import { DEFAULT_RETRY_POLICY, withRetry } from '../retry.js';
7
+ import { consumeStream } from '../stream-utils.js';
6
8
  import { AnthropicProvider } from './anthropic.js';
7
9
  import { OpenAIProvider } from './openai.js';
8
10
  import { GoogleProvider } from './google.js';
9
- // ---------------------------------------------------------------------------
10
- // ProviderRegistry
11
- // ---------------------------------------------------------------------------
12
11
  export class ProviderRegistry {
13
12
  providers = new Map();
13
+ retryPolicy;
14
+ constructor(options = {}) {
15
+ this.retryPolicy =
16
+ options.retryPolicy === undefined ? DEFAULT_RETRY_POLICY : options.retryPolicy;
17
+ }
14
18
  /** Register a provider. Overwrites any existing registration with the same id. */
15
19
  register(provider) {
16
20
  this.providers.set(provider.id, provider);
17
21
  }
22
+ /**
23
+ * Install a retry policy live, or `null` to opt out entirely.
24
+ *
25
+ * A METHOD RATHER THAN A CONSTRUCTOR-ONLY CONCERN because the CLI's `/retry
26
+ * off` has to take effect in the running session: persisting the setting and
27
+ * changing nothing until the next launch is the failure users report as "the
28
+ * setting does nothing".
29
+ */
30
+ setRetryPolicy(policy) {
31
+ this.retryPolicy = policy;
32
+ }
33
+ getRetryPolicy() {
34
+ return this.retryPolicy;
35
+ }
18
36
  /** Retrieve a provider by id. */
19
37
  get(id) {
20
38
  return this.providers.get(id);
@@ -28,22 +46,52 @@ export class ProviderRegistry {
28
46
  return this.providers.has(id);
29
47
  }
30
48
  /**
31
- * Convenience: stream a completion through the named provider.
49
+ * Convenience: stream a completion through the named provider, WITH RETRY.
50
+ *
51
+ * THIS IS THE SEAM THE WHOLE RETRY FEATURE HANGS ON (llm-api-retry-backoff
52
+ * §5.2). Wrapping here rather than inside each adapter is what lets the retry
53
+ * see the commit point, the abort signal and the whole event stream at once,
54
+ * and it is why the lead agent, every subagent and `completeLLM` all inherit
55
+ * retry with zero call-site changes.
56
+ *
57
+ * The thunk captures `request` WHOLE, so every attempt sends the same body —
58
+ * including any `max_tokens` that `sendWithOutputLimitRecovery` repaired on the
59
+ * way through, because that repair lives inside `provider.stream()` and re-runs
60
+ * per attempt.
61
+ *
62
+ * `maxRetries <= 0` returns the adapter's iterator DIRECTLY rather than a
63
+ * wrapper that happens to never retry: an opted-out caller is then byte- and
64
+ * allocation-identical to a pre-feature build.
32
65
  *
33
66
  * @throws {LLMError} if the provider is not registered.
34
67
  */
35
68
  stream(providerId, request) {
36
69
  const provider = this.resolveProvider(providerId);
37
- return provider.stream(request);
70
+ const policy = this.retryPolicy;
71
+ if (!policy || policy.maxRetries <= 0)
72
+ return provider.stream(request);
73
+ return withRetry(() => provider.stream(request), {
74
+ policy,
75
+ providerId,
76
+ modelId: request.model,
77
+ ...(request.signal ? { signal: request.signal } : {}),
78
+ });
38
79
  }
39
80
  /**
40
81
  * Convenience: non-streaming completion through the named provider.
41
82
  *
83
+ * ROUTED THROUGH `this.stream()`, NOT `provider.complete()`, and that is not
84
+ * optional (§5.4). All three adapters implement `complete` as exactly
85
+ * `consumeStream(this.stream(request))`, so this is behaviour-preserving — the
86
+ * only difference is that the registry's version is now the RETRYING one.
87
+ * Calling `provider.complete()` here would leave `completeLLM` as the one
88
+ * public entry point with no retry at all, which is exactly the kind of hole
89
+ * that gets discovered in production.
90
+ *
42
91
  * @throws {LLMError} if the provider is not registered.
43
92
  */
44
93
  async complete(providerId, request) {
45
- const provider = this.resolveProvider(providerId);
46
- return provider.complete(request);
94
+ return consumeStream(this.stream(providerId, request));
47
95
  }
48
96
  /**
49
97
  * Convenience: list models from the named provider.
@@ -75,9 +123,12 @@ export class ProviderRegistry {
75
123
  /**
76
124
  * Create and populate a {@link ProviderRegistry} with all built-in providers.
77
125
  * Call this once at server startup.
126
+ *
127
+ * `options` is additive and source-compatible: every existing `initProviders()`
128
+ * call site compiles unchanged and gets `DEFAULT_RETRY_POLICY`.
78
129
  */
79
- export function initProviders() {
80
- const registry = new ProviderRegistry();
130
+ export function initProviders(options) {
131
+ const registry = new ProviderRegistry(options);
81
132
  registry.register(new AnthropicProvider());
82
133
  registry.register(new OpenAIProvider());
83
134
  registry.register(new GoogleProvider());
@@ -93,6 +144,16 @@ let _singleton;
93
144
  * Prefer calling `initProviders()` explicitly at startup and passing the
94
145
  * registry around. This singleton exists as a convenience for modules that
95
146
  * cannot easily receive the registry through dependency injection.
147
+ *
148
+ * IT IS A SEPARATE REGISTRY AND IT KEEPS THE PRODUCT DEFAULT RETRY POLICY
149
+ * (llm-api-retry-backoff §5.3). It is built here with `initProviders()` and no
150
+ * arguments, and a host that constructs its own registry (the CLI does) never
151
+ * installs it — so `streamLLM` / `completeLLM` retry with
152
+ * `DEFAULT_RETRY_POLICY` and do NOT observe that host's `/retry off` equivalent.
153
+ * Harmless while nothing calls those two helpers, and deliberately not "fixed"
154
+ * by having a UI mutate process-wide state. An embedder that wants one policy
155
+ * everywhere calls `setProviderRegistry(initProviders({ retryPolicy }))` once at
156
+ * startup.
96
157
  */
97
158
  export function getProviderRegistry() {
98
159
  if (!_singleton) {
@@ -98,7 +98,12 @@ export class OpenAIProvider {
98
98
  }
99
99
  if (!response.ok) {
100
100
  // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
101
- yield { type: 'error', error: classifyHttpError(response.status, errorBody, this.id) };
101
+ // `response.headers` carries `Retry-After` / `x-ratelimit-reset-after` out
102
+ // on `LLMError.retryAfterMs` so a 429 waits as long as the server asked.
103
+ yield {
104
+ type: 'error',
105
+ error: classifyHttpError(response.status, errorBody, this.id, response.headers),
106
+ };
102
107
  return;
103
108
  }
104
109
  if (!response.body) {
@@ -112,12 +117,30 @@ export class OpenAIProvider {
112
117
  let stopReason;
113
118
  // Accumulate tool calls by index
114
119
  const toolAccumulators = new Map();
120
+ /**
121
+ * Whether this stream reached a terminal frame (§4.5a). Unlike Anthropic's
122
+ * `message_stop`, neither of OpenAI's terminal signals `return`s, so the
123
+ * absence of a return cannot stand in for the flag.
124
+ *
125
+ * TWO SIGNALS, NOT ONE, and the second is not optional. The `[DONE]`
126
+ * sentinel is the documented terminator, but it is a convention of OpenAI's
127
+ * own server rather than a property of the wire format: several
128
+ * OpenAI-compatible endpoints (the ones `--base-url` exists for) close the
129
+ * connection after the last chunk without sending it. Keying truncation on
130
+ * `[DONE]` alone would make every request against such a server a retryable
131
+ * error, i.e. ten retries and then a failure, for a stream that completed
132
+ * perfectly. A `finish_reason` on any choice is the model's own statement
133
+ * that it stopped, and a stream cut mid-answer carries neither.
134
+ */
135
+ let sawTerminal = false;
115
136
  try {
116
137
  for await (const sse of parseSSEStream(response.body, request.signal)) {
117
138
  const raw = sse.data.trim();
118
139
  // [DONE] sentinel
119
- if (raw === '[DONE]')
140
+ if (raw === '[DONE]') {
141
+ sawTerminal = true;
120
142
  break;
143
+ }
121
144
  let data;
122
145
  try {
123
146
  data = JSON.parse(raw);
@@ -183,9 +206,24 @@ export class OpenAIProvider {
183
206
  }
184
207
  // Finish reason handling
185
208
  if (finishReason) {
209
+ sawTerminal = true;
186
210
  stopReason = mapFinishReason(finishReason);
187
211
  }
188
212
  }
213
+ // ---- The terminal-sentinel contract (§4.5a) -------------------------
214
+ //
215
+ // An abort is NOT a truncation: `parseSSEStream` `break`s on
216
+ // `signal.aborted`, which lands on exactly this path, and turning that into
217
+ // a retryable `network_error` would re-create the trap `isRetryableError`
218
+ // check 1 exists to close (constraint 1 / R-17). The signal is therefore
219
+ // tested FIRST and that path keeps yielding `done`.
220
+ if (!sawTerminal && !request.signal?.aborted) {
221
+ yield {
222
+ type: 'error',
223
+ error: new LLMError(`${this.id} stream truncated before a terminal chunk`, this.id, 'network_error', true),
224
+ };
225
+ return;
226
+ }
189
227
  // Flush text block
190
228
  if (textAccumulator) {
191
229
  contentBlocks.push({ type: 'text', text: textAccumulator });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Retry & exponential backoff — THE authority for "a provider call failed, now
3
+ * what" (llm-api-retry-backoff §4).
4
+ *
5
+ * One module owns the policy type, the classification predicate, the backoff
6
+ * arithmetic and a single generator `withRetry()` that wraps ANY
7
+ * `AsyncIterableIterator<StreamEvent>`. `ProviderRegistry.stream()` applies it,
8
+ * so all three adapters, the lead agent and every subagent inherit retry with
9
+ * zero call-site changes.
10
+ *
11
+ * THE WRAPPER IS THE ONLY LAYER THAT CAN DO THIS JOB, and that is why it lives
12
+ * here rather than in the adapters or in `runAgentLoop` (§3.1 / §3.2):
13
+ *
14
+ * - it knows whether any CONTENT has already been forwarded downstream (the
15
+ * commit point), which decides whether a replay would duplicate text on the
16
+ * user's screen;
17
+ * - it holds the `AbortSignal`, which is what keeps a user pressing Esc from
18
+ * being mistaken for a retryable timeout;
19
+ * - it can emit its own events, which is what turns a silent 40-second stall
20
+ * into a countdown a user can read.
21
+ *
22
+ * NO `node:*` IMPORTS. `no-host-coupling.test.ts` rule 2 bans every one of them
23
+ * under core `src/` with a single grandfathered exception, so the abortable
24
+ * `sleep` below uses the GLOBAL `setTimeout` plus one `abort` listener rather
25
+ * than `node:timers/promises` (whose `setTimeout` takes `{ signal }` natively
26
+ * and would be the idiomatic choice anywhere else).
27
+ */
28
+ import type { StreamEvent } from './types.js';
29
+ /**
30
+ * Structural bounds. NOT user policy: these describe what the mechanism can
31
+ * physically do, and a hand-edited config must not be able to exceed them.
32
+ * (The same split `TEAM_LIMITS` states for team mode, one package over.)
33
+ *
34
+ * ENFORCED AT THIS BOUNDARY by `normalizePolicy`, not only by the CLI's
35
+ * `clampRetryConfig` (§4.5 G4). Hardening one path and leaving the other open
36
+ * would let an embedder passing `{ maxRetries: 5000 }` get 5000.
37
+ */
38
+ export declare const RETRY_LIMITS: {
39
+ /** Ceiling on `maxRetries` from any layer. */
40
+ readonly hardMaxRetries: 20;
41
+ /** Ceiling on one wait, whatever the config or a `Retry-After` header says. */
42
+ readonly absoluteMaxDelayMs: 120000;
43
+ /**
44
+ * A `Retry-After` LARGER than this is not honoured and not retried — it is
45
+ * surfaced immediately with the provider's own number in the message. A
46
+ * provider asking us to come back in an hour is telling the user something,
47
+ * and silently wedging the terminal for an hour is not relaying it.
48
+ */
49
+ readonly retryAfterCeilingMs: 60000;
50
+ /** Floor under any computed wait, so jitter can never produce a 0 ms "retry". */
51
+ readonly minDelayMs: 100;
52
+ };
53
+ export interface RetryPolicy {
54
+ /** Retries AFTER the initial attempt. `10` => up to 11 total requests. */
55
+ maxRetries: number;
56
+ initialDelayMs: number;
57
+ maxDelayMs: number;
58
+ /** Growth factor per retry. `2` doubles. */
59
+ multiplier: number;
60
+ /** Equal jitter (`d/2 + rand*d/2`). See `computeBackoffDelay` for why not full. */
61
+ jitter: boolean;
62
+ /** Honour `Retry-After` / `anthropic-ratelimit-*-reset` when present. */
63
+ respectRetryAfter: boolean;
64
+ /** Wall-clock budget from the FIRST failure of this request. */
65
+ maxElapsedMs: number;
66
+ /** Allow a restart after content has already been forwarded (§3.5). */
67
+ onPartialStream: boolean;
68
+ }
69
+ export declare const DEFAULT_RETRY_POLICY: RetryPolicy;
70
+ /**
71
+ * The events that mean "the consumer has already rendered answer content", i.e.
72
+ * the commit point (§4.5a).
73
+ *
74
+ * `thinking_start` is in the set even though it carries no text: it flips
75
+ * `thinkingOpen` in the CLI reducer, which is visible state a restart must
76
+ * rewind. The tool events are in the set because `tool_call_start` appends a
77
+ * transcript card — that is why `stream_restart` carries `discardedToolCallIds`
78
+ * at all. `done` and `error` are terminal, not content; the three `retry_*`
79
+ * variants are ours and never round-trip through `makeStream()`.
80
+ */
81
+ export declare const CONTENT_EVENTS: ReadonlySet<StreamEvent['type']>;
82
+ /**
83
+ * Clamp a policy to `RETRY_LIMITS`. Pure, total, and it NEVER THROWS — a bad
84
+ * policy is corrected rather than rejected, because the alternative is a
85
+ * library that crashes on a config typo.
86
+ *
87
+ * Module-internal by intent: exported so `llm-retry.test.ts` may import it
88
+ * directly, but deliberately NOT re-exported from the barrel. The public
89
+ * assertion is made THROUGH `withRetry`, which is also the only way to catch
90
+ * this being present but unwired.
91
+ */
92
+ export declare function normalizePolicy(policy: RetryPolicy): RetryPolicy;
93
+ export interface RetryDecision {
94
+ retry: boolean;
95
+ /** Machine reason, for logs and for the acceptance tests. */
96
+ reason: 'retryable' | 'aborted' | 'not_retryable' | 'budget_exhausted' | 'attempts_exhausted' | 'committed' | 'retry_after_too_long';
97
+ }
98
+ /**
99
+ * Whether this failure may be retried at all, ignoring budgets and counters.
100
+ *
101
+ * THE ORDER OF THE TWO CHECKS IS LOAD-BEARING.
102
+ *
103
+ * 1. `signal?.aborted === true` COMES FIRST. `wrapFetchError` classifies an
104
+ * `AbortError` as `errorType: 'timeout', retryable: true`, so every user Esc,
105
+ * every `AgentController.abort()`, every dispatch timeout and every
106
+ * tool-timeout cancellation arrives here wearing a retryable badge. The CLI's
107
+ * `team/retry.ts` records this trap in thirty lines of comment because it
108
+ * cost someone an afternoon; reading the signal BEFORE `err.retryable` is
109
+ * what makes Esc mean Esc.
110
+ * 2. `retryable` is read STRUCTURALLY, never with `instanceof LLMError`. The
111
+ * error crosses a package boundary, and a duplicated core instance (a linked
112
+ * checkout, a hoisting accident) would make `instanceof` false while the
113
+ * field is plainly there — and the failure mode would be "retries silently
114
+ * stopped happening". This is the rule `isRetryableStreamError` and
115
+ * `formatStreamError` already follow.
116
+ */
117
+ export declare function isRetryableError(err: unknown, signal?: AbortSignal): RetryDecision;
118
+ interface DecideInput {
119
+ error: unknown;
120
+ signal?: AbortSignal;
121
+ /** Any content event was forwarded during the failed attempt. */
122
+ committed: boolean;
123
+ /** Retries COMPLETED so far (pre-increment). */
124
+ retryIndex: number;
125
+ /** Anchored on the FIRST failure of this request; a number by construction. */
126
+ firstFailureAt: number;
127
+ /** The wait that WOULD be used for the next attempt. */
128
+ candidateDelay: number;
129
+ retryAfterMs?: number;
130
+ policy: RetryPolicy;
131
+ now: () => number;
132
+ }
133
+ /**
134
+ * The retry decision, taking every input EXPLICITLY — no closure reads — and
135
+ * returning the first matching reason (§4.5).
136
+ *
137
+ * Module-internal by intent, like `normalizePolicy` above.
138
+ */
139
+ export declare function decide(input: DecideInput): RetryDecision;
140
+ /**
141
+ * The wait before retry number `retryIndex` (1-BASED: the first retry is 1).
142
+ *
143
+ * EQUAL JITTER, NOT FULL JITTER. Full jitter (`rand() * base`) is the textbook
144
+ * contention minimiser, but it can return 40 ms on the sixth retry, and a UI
145
+ * that just announced "retrying in 30s" and then fires in 40 ms reads as a bug.
146
+ * Equal jitter keeps the announced number within a factor of two of the truth
147
+ * while still de-correlating N concurrent subagents hitting the same 429. The
148
+ * user-visible countdown is computed from the ACTUAL chosen delay, never from
149
+ * `base`, so what the card says is always what happens.
150
+ */
151
+ export declare function computeBackoffDelay(retryIndex: number, policy: RetryPolicy, opts?: {
152
+ retryAfterMs?: number;
153
+ random?: () => number;
154
+ }): number;
155
+ /**
156
+ * The server-stated wait, in ms, or `undefined` when absent or unparseable.
157
+ *
158
+ * Read order: `retry-after` (integer seconds, or an HTTP-date), then
159
+ * `x-ratelimit-reset-after` (seconds, OpenAI-flavoured), then
160
+ * `anthropic-ratelimit-requests-reset`, then
161
+ * `anthropic-ratelimit-input-tokens-reset` (both RFC-3339 instants).
162
+ *
163
+ * THE TOKEN HEADER IS READ, not skipped: on a long context it is the limit that
164
+ * actually bites, and a request throttled on tokens that waits out only the
165
+ * REQUEST window comes back to the same 429.
166
+ *
167
+ * A value above `RETRY_LIMITS.retryAfterCeilingMs` is returned AS-IS — the
168
+ * CALLER (`withRetry`) decides to stop, so the honest number reaches the user's
169
+ * error message rather than being silently rounded down to a wait we would then
170
+ * actually perform.
171
+ */
172
+ export declare function parseRetryAfterMs(headers: Headers | undefined, now?: () => number): number | undefined;
173
+ export interface WithRetryOptions {
174
+ policy: RetryPolicy;
175
+ providerId: string;
176
+ modelId: string;
177
+ signal?: AbortSignal;
178
+ /** All three injected so tests are deterministic and never sleep. */
179
+ now?: () => number;
180
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
181
+ random?: () => number;
182
+ }
183
+ /**
184
+ * Wrap a stream factory with retry.
185
+ *
186
+ * Six properties are load-bearing and easy to break:
187
+ *
188
+ * - G1 — THE ABORT GUARD IS CHECKED ON EVERY ITERATION, matching
189
+ * `agent-loop.ts`'s own guard. Combined with G3 below, an aborted request
190
+ * never forwards a trailing error to a subscriber.
191
+ * - G2 — `sleep` MUST OBSERVE `signal`. See `defaultSleep`.
192
+ * - G3 — `aborted` RETURNS SILENTLY. It is the one non-retry reason that must
193
+ * not `yield` an `error`. `consumeStream` (the body of `complete()`) does not
194
+ * consult the signal, so a yielded error would become a thrown, FABRICATED
195
+ * `network_error` on user Esc — blaming the network for something the user
196
+ * just did is worse than saying nothing. Three silent return sites: the
197
+ * in-loop guard, the `decide` short-circuit, and the post-sleep check.
198
+ * - G4 — `normalizePolicy` RUNS ONCE, at generator entry, so `RETRY_LIMITS`
199
+ * bounds an embedder's policy and not merely a CLI-resolved one.
200
+ * - THE SUCCESS PATH IS UNTOUCHED. A request that works yields exactly the
201
+ * events the adapter produced, in the same order, with no extra allocation
202
+ * beyond one boolean and one empty array. The wrapper is not installed at all
203
+ * when `maxRetries <= 0`, so an opted-out embedder is byte-identical to a
204
+ * pre-feature build.
205
+ * - THE TERMINAL ERROR IS FORWARDED, NOT REPLACED. After exhaustion the
206
+ * consumer sees the PROVIDER's own `LLMError`, so `formatStreamError`, the
207
+ * `retryable` field and any downstream `instanceof` continue to behave. Only
208
+ * the retry COUNT is new information, and it travels on the events.
209
+ */
210
+ export declare function withRetry(makeStream: () => AsyncIterableIterator<StreamEvent>, opts: WithRetryOptions): AsyncIterableIterator<StreamEvent>;
211
+ export {};