@aragon-agent/core 0.2.8 → 0.2.10

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/CHANGELOG.md CHANGED
@@ -9,6 +9,49 @@ were written.
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`llm/retry.ts` — retry with exponential backoff, ON BY DEFAULT.**
13
+ `RETRY_LIMITS`, `DEFAULT_RETRY_POLICY`, `isRetryableError`,
14
+ `computeBackoffDelay`, `parseRetryAfterMs` and `withRetry` are exported from the
15
+ root barrel (75 → 81 runtime exports). `ProviderRegistry.stream()` wraps every
16
+ provider stream, so the agent loop, every subagent and `ProviderRegistry`
17
+ itself inherit retry with no call-site changes.
18
+
19
+ A transient failure — 429, 529, 5xx, DNS/TLS/ECONNRESET, 408 — is now retried
20
+ up to **10 times** with an increasing, equal-jittered interval
21
+ (1 s → 2 → 4 → 8 → 16 → 30 s, capped at 30 s per wait) under a **240 s**
22
+ wall-clock budget measured from the first failure. `auth_error`,
23
+ `invalid_request`, `context_overflow` and `unknown` are never retried: ten
24
+ retries of a bad API key is three minutes of looking broken.
25
+
26
+ A user abort is never retried, and this is the load-bearing detail:
27
+ `wrapFetchError` marks an `AbortError` `retryable: true`, so
28
+ `isRetryableError` reads `signal.aborted` **before** `err.retryable`. An
29
+ aborted request yields no further event of any kind — in particular no `error`
30
+ — so nothing fabricates a network failure out of Esc.
31
+
32
+ `Retry-After` (and `x-ratelimit-reset-after` /
33
+ `anthropic-ratelimit-requests-reset` / `anthropic-ratelimit-input-tokens-reset`)
34
+ is honoured as a **floor**, never a replacement, and a value above 60 s is
35
+ surfaced immediately rather than waited out.
36
+
37
+ - **Three new `StreamEvent` variants** — `RetryScheduledEvent`,
38
+ `RetryAttemptEvent`, `StreamRestartEvent`. Additive: `consumeStream` ignores
39
+ anything that is not `done`/`error`, and a consumer with a `default` branch is
40
+ unaffected. `retry_scheduled` carries `resumeAt` (an absolute instant) rather
41
+ than a tick stream, so a UI owns its own clock.
42
+
43
+ - **`ProviderRegistry` holds a policy.** New `ProviderRegistryOptions`,
44
+ `setRetryPolicy(policy | null)` and `getRetryPolicy()`;
45
+ `initProviders(options?)` forwards them. `undefined` means the product default,
46
+ an explicit `null` means opt out.
47
+
48
+ - **`LLMError` gains a 7th optional field `retryAfterMs`**, and
49
+ `classifyHttpError` an optional 4th parameter `headers?: Headers`. Both
50
+ additive; every existing call site compiles unchanged.
51
+
52
+ - **`Agent` pauses its idle watchdog across a backoff.** A 30 s wait would
53
+ otherwise be read as a wedged call and abort the run at `idleTimeout`.
54
+
12
55
  - **`llm/output-limits.ts` — one authority for the output-token cap.**
13
56
  `DEFAULT_MAX_OUTPUT_TOKENS` (64000), `MIN_MAX_OUTPUT_TOKENS`,
14
57
  `SAFE_FALLBACK_MAX_OUTPUT_TOKENS`, `ABSOLUTE_MAX_OUTPUT_TOKENS`,
@@ -41,6 +84,40 @@ were written.
41
84
 
42
85
  ### Changed
43
86
 
87
+ - **BEHAVIOUR CHANGE — retry is on by default for embedders.** A failing call
88
+ that used to end immediately can now take up to ~3 minutes to fail. The budget
89
+ bounds it; `initProviders({ retryPolicy: null })` or `setRetryPolicy(null)`
90
+ restores the old behaviour exactly, and `maxRetries: 0` short-circuits the
91
+ wrapper so the opted-out path is byte-identical.
92
+
93
+ - **BEHAVIOUR CHANGE — a truncated stream is now a loud error instead of a
94
+ silent success.** All three adapters used to end their SSE loop by
95
+ *unconditionally* yielding `done` with whatever had accumulated. A stream
96
+ closed gracefully mid-answer — a proxy idle cut, a load-balancer timeout, an
97
+ HTTP/2 GOAWAY, `[DONE]` never arriving — therefore surfaced as **success
98
+ carrying a truncated answer**, which no layer above the adapter could tell from
99
+ a real one. Each adapter now tracks its own terminal sentinel
100
+ (`message_stop` / `[DONE]` **or** a `finish_reason` / a chunk carrying
101
+ `finishReason`) and yields a retryable `network_error` when the stream ends
102
+ without it. Consequences:
103
+
104
+ - the mid-answer disconnection case is now retried and recovered;
105
+ - `ProviderRegistry.complete()` / `provider.complete()` change from returning a
106
+ partial `AssistantMessage` to **throwing** for a truncated stream;
107
+ - an **abort** is explicitly excluded and still yields `done`, and an empty but
108
+ complete turn is still a success.
109
+
110
+ This is deliberately **not** behind the retry switch: an adapter that reports a
111
+ truncated stream honestly should do so whether or not retry is enabled.
112
+
113
+ - **HTTP 408 is now `errorType: 'timeout'`, `retryable: true`** (it used to fall
114
+ into `unknown` / non-retryable).
115
+
116
+ - **`ProviderRegistry.complete()` now routes through `this.stream()`** instead of
117
+ `provider.complete()`. Behaviour-preserving — all three adapters implement
118
+ `complete` as exactly `consumeStream(this.stream(request))` — except that the
119
+ registry's version is now the retrying one.
120
+
44
121
  - `ModelRegistry.buildRuntimeModel` defaults `maxOutputTokens` to
45
122
  `DEFAULT_MAX_OUTPUT_TOKENS` rather than a private `16384`, the built-in
46
123
  Anthropic catalog entries carry their real ceilings, and `registerDefaults` /
@@ -49,6 +126,21 @@ were written.
49
126
  product default for every model — neither API reports an output ceiling, and
50
127
  claiming otherwise outranked the correct value for every small model.
51
128
 
129
+ ### Notes
130
+
131
+ - **`streamLLM` / `completeLLM` diverge from a registry you build yourself.**
132
+ They resolve through `getProviderRegistry()`, which lazily calls
133
+ `initProviders()` with no arguments, so they always carry
134
+ `DEFAULT_RETRY_POLICY` and do not observe a policy installed elsewhere. For one
135
+ policy everywhere, call `setProviderRegistry(initProviders({ retryPolicy }))`
136
+ once at startup.
137
+
138
+ - **A half-open connection is still not covered** (no FIN, no RST). It produces
139
+ no event, so `withRetry` never wakes and the idle watchdog aborts the run. A
140
+ per-attempt stall timer is not safe at this layer: Anthropic's `ping` frames
141
+ are swallowed by the adapter, so a long extended-thinking pause and a dead
142
+ socket look identical at the `StreamEvent` layer.
143
+
52
144
  ## 0.3.0
53
145
 
54
146
  ### Added
@@ -131,6 +131,23 @@ export declare class Agent {
131
131
  * Listener errors are caught and logged to prevent crashing the loop.
132
132
  */
133
133
  private emit;
134
+ /**
135
+ * Suspend the idle watchdog across a retry backoff, and resume it when the
136
+ * next attempt goes out (llm-api-retry-backoff §4.6).
137
+ *
138
+ * `withRetry` waits up to `maxDelayMs` between attempts and emits nothing while
139
+ * it does. That is indistinguishable from a wedged network call to the
140
+ * watchdog, which is right for a wedged call and wrong for a wait the engine
141
+ * itself scheduled. `pause()` swallows the subsequent `kick()` by design and
142
+ * `resume()` restarts the window from now; both are idempotent.
143
+ *
144
+ * A `retry_scheduled` never followed by a `retry_attempt` — an abort during the
145
+ * wait — leaves the watchdog deaf, and that is bounded rather than leaked:
146
+ * `runLoopWithLifecycle`'s `finally` calls `watchdog.stop()`, and `stop()`
147
+ * clears `paused` for exactly this reason. DO NOT "simplify" that line out of
148
+ * `stop()`.
149
+ */
150
+ private applyRetryWatchdogPolicy;
134
151
  /**
135
152
  * Synchronize the ToolRegistry with the provided tool array.
136
153
  * Removes tools no longer present and adds new ones.
@@ -218,6 +218,11 @@ export class Agent {
218
218
  * Listener errors are caught and logged to prevent crashing the loop.
219
219
  */
220
220
  emit(event) {
221
+ // BEFORE THE LISTENERS. A listener that throws is caught below, but the
222
+ // policy still has to have been applied — otherwise a bad subscriber leaves
223
+ // the watchdog armed across a 30-second backoff and the run is aborted for
224
+ // being "idle" while it is provably waiting on purpose.
225
+ this.applyRetryWatchdogPolicy(event);
221
226
  for (const listener of this.listeners) {
222
227
  try {
223
228
  listener(event);
@@ -228,6 +233,31 @@ export class Agent {
228
233
  }
229
234
  this.watchdog.kick();
230
235
  }
236
+ /**
237
+ * Suspend the idle watchdog across a retry backoff, and resume it when the
238
+ * next attempt goes out (llm-api-retry-backoff §4.6).
239
+ *
240
+ * `withRetry` waits up to `maxDelayMs` between attempts and emits nothing while
241
+ * it does. That is indistinguishable from a wedged network call to the
242
+ * watchdog, which is right for a wedged call and wrong for a wait the engine
243
+ * itself scheduled. `pause()` swallows the subsequent `kick()` by design and
244
+ * `resume()` restarts the window from now; both are idempotent.
245
+ *
246
+ * A `retry_scheduled` never followed by a `retry_attempt` — an abort during the
247
+ * wait — leaves the watchdog deaf, and that is bounded rather than leaked:
248
+ * `runLoopWithLifecycle`'s `finally` calls `watchdog.stop()`, and `stop()`
249
+ * clears `paused` for exactly this reason. DO NOT "simplify" that line out of
250
+ * `stop()`.
251
+ */
252
+ applyRetryWatchdogPolicy(event) {
253
+ if (event.type !== 'message_update')
254
+ return;
255
+ const t = event.streamEvent.type;
256
+ if (t === 'retry_scheduled')
257
+ this.watchdog.pause();
258
+ else if (t === 'retry_attempt')
259
+ this.watchdog.resume();
260
+ }
231
261
  /**
232
262
  * Synchronize the ToolRegistry with the provided tool array.
233
263
  * Removes tools no longer present and adds new ones.
package/dist/index.d.ts CHANGED
@@ -5,17 +5,20 @@
5
5
  * Other tasks will expand this file as more modules are added (engine, tools,
6
6
  * sandbox).
7
7
  */
8
- export type { ContentPart, TextContentPart, ImageContentPart, ContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, Message, UserMessage, AssistantMessage, ToolResultMessage, StopReason, TokenUsage, ThinkingLevel, StreamEvent, TextDeltaEvent, ThinkingStartEvent, ThinkingDeltaEvent, ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent, DoneEvent, ErrorEvent, ToolDefinition, JSONSchema, ModelInfo, ModelCost, } from './llm/types.js';
8
+ export type { ContentPart, TextContentPart, ImageContentPart, ContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, Message, UserMessage, AssistantMessage, ToolResultMessage, StopReason, TokenUsage, ThinkingLevel, StreamEvent, TextDeltaEvent, ThinkingStartEvent, ThinkingDeltaEvent, ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent, DoneEvent, ErrorEvent, RetryScheduledEvent, RetryAttemptEvent, StreamRestartEvent, ToolDefinition, JSONSchema, ModelInfo, ModelCost, } from './llm/types.js';
9
9
  export { THINKING_BUDGET } from './llm/types.js';
10
10
  export type { LLMProvider, LLMRequest, LLMErrorType, } from './llm/provider.js';
11
11
  export { LLMError, classifyHttpError, wrapFetchError, } from './llm/provider.js';
12
12
  export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens, } from './llm/output-limits.js';
13
13
  export type { OutputLimitInput, OutputLimitResolution, CeilingSource, } from './llm/output-limits.js';
14
14
  export { classifyOutputLimitFailure, sendWithOutputLimitRecovery, learnTokenField, getLearnedTokenField, clearLearnedTokenFields, } from './llm/output-limit-recovery.js';
15
+ export { RETRY_LIMITS, DEFAULT_RETRY_POLICY, isRetryableError, computeBackoffDelay, parseRetryAfterMs, withRetry, } from './llm/retry.js';
16
+ export type { RetryPolicy, RetryDecision, WithRetryOptions, } from './llm/retry.js';
15
17
  export type { OutputLimitFailure, RecoveryAttempt, RecoveryOptions, TokenField, } from './llm/output-limit-recovery.js';
16
18
  export type { SSEEvent } from './llm/stream-utils.js';
17
19
  export { parseSSEStream, consumeStream, } from './llm/stream-utils.js';
18
20
  export { ProviderRegistry, initProviders, getProviderRegistry, setProviderRegistry, streamLLM, completeLLM, } from './llm/providers/index.js';
21
+ export type { ProviderRegistryOptions } from './llm/providers/index.js';
19
22
  export { AnthropicProvider } from './llm/providers/anthropic.js';
20
23
  export { OpenAIProvider } from './llm/providers/openai.js';
21
24
  export { GoogleProvider } from './llm/providers/google.js';
package/dist/index.js CHANGED
@@ -13,6 +13,17 @@ export { LLMError, classifyHttpError, wrapFetchError, } from './llm/provider.js'
13
13
  // ---------------------------------------------------------------------------
14
14
  export { DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, SAFE_FALLBACK_MAX_OUTPUT_TOKENS, ABSOLUTE_MAX_OUTPUT_TOKENS, THINKING_HEADROOM_TOKENS, CONTEXT_SAFETY_MARGIN_TOKENS, resolveOutputTokens, staticCeilingFor, learnModelCeiling, getLearnedCeiling, clearLearnedCeilings, estimatePromptTokens, } from './llm/output-limits.js';
15
15
  export { classifyOutputLimitFailure, sendWithOutputLimitRecovery, learnTokenField, getLearnedTokenField, clearLearnedTokenFields, } from './llm/output-limit-recovery.js';
16
+ // ---------------------------------------------------------------------------
17
+ // Retry & exponential backoff — the authority for "a provider call failed, now
18
+ // what". Installed by default on `ProviderRegistry.stream()`; opt out with
19
+ // `initProviders({ retryPolicy: null })` or `setRetryPolicy(null)`.
20
+ //
21
+ // `normalizePolicy` and `decide` are deliberately NOT here: they are the
22
+ // implementation of the two entry points below, nobody outside the module has a
23
+ // reason to call them, and promoting them would add two symbols to the frozen
24
+ // public surface for nothing.
25
+ // ---------------------------------------------------------------------------
26
+ export { RETRY_LIMITS, DEFAULT_RETRY_POLICY, isRetryableError, computeBackoffDelay, parseRetryAfterMs, withRetry, } from './llm/retry.js';
16
27
  export { parseSSEStream, consumeStream, } from './llm/stream-utils.js';
17
28
  // ---------------------------------------------------------------------------
18
29
  // Provider registry
@@ -79,13 +79,36 @@ export declare class LLMError extends Error {
79
79
  readonly retryable: boolean;
80
80
  readonly statusCode?: number | undefined;
81
81
  readonly raw?: unknown | undefined;
82
- constructor(message: string, provider: string, errorType: LLMErrorType, retryable: boolean, statusCode?: number | undefined, raw?: unknown | undefined);
82
+ /**
83
+ * The wait the SERVER asked for, in ms (llm-api-retry-backoff §4.4).
84
+ *
85
+ * A 7th OPTIONAL POSITIONAL FIELD, so every existing construction site
86
+ * compiles unchanged. Read structurally by `withRetry` — never through
87
+ * `instanceof LLMError`, for the reason `isRetryableError` records.
88
+ */
89
+ readonly retryAfterMs?: number | undefined;
90
+ constructor(message: string, provider: string, errorType: LLMErrorType, retryable: boolean, statusCode?: number | undefined, raw?: unknown | undefined,
91
+ /**
92
+ * The wait the SERVER asked for, in ms (llm-api-retry-backoff §4.4).
93
+ *
94
+ * A 7th OPTIONAL POSITIONAL FIELD, so every existing construction site
95
+ * compiles unchanged. Read structurally by `withRetry` — never through
96
+ * `instanceof LLMError`, for the reason `isRetryableError` records.
97
+ */
98
+ retryAfterMs?: number | undefined);
83
99
  }
84
100
  /**
85
101
  * Classify an HTTP status code into an {@link LLMErrorType} and determine
86
102
  * whether the request is retryable.
103
+ *
104
+ * `headers` is OPTIONAL and additive: when supplied, a server-stated
105
+ * `Retry-After` (or a provider rate-limit reset header) rides out on
106
+ * `LLMError.retryAfterMs`, which is the only channel a retry policy has for
107
+ * honouring it. Adapters that cannot reach the headers — Anthropic's IN-STREAM
108
+ * `error` event, which arrives after the connection is already open — simply
109
+ * omit it and fall back to the plain backoff ladder.
87
110
  */
88
- export declare function classifyHttpError(statusCode: number, body: string, provider: string): LLMError;
111
+ export declare function classifyHttpError(statusCode: number, body: string, provider: string, headers?: Headers): LLMError;
89
112
  /**
90
113
  * Wrap a network-level fetch error (DNS, TLS, abort, etc.) into an LLMError.
91
114
  */
@@ -1,27 +1,56 @@
1
1
  /**
2
2
  * LLM Provider interface, request types, and error classification.
3
3
  */
4
+ /**
5
+ * A DELIBERATE MODULE CYCLE, and it is safe for one specific reason:
6
+ * `retry.ts` imports `LLMError` from here and uses it only INSIDE a function
7
+ * body, while this module uses `parseRetryAfterMs` only inside
8
+ * `classifyHttpError`. Neither side touches the other's binding during module
9
+ * evaluation, so whichever loads first the other's top level completes cleanly.
10
+ *
11
+ * A future top-level `LLMError` reference in `retry.ts`, or a top-level
12
+ * `parseRetryAfterMs` call here, WOULD hit the TDZ and throw at import time.
13
+ * Keep both usages inside functions.
14
+ */
15
+ import { parseRetryAfterMs } from './retry.js';
4
16
  export class LLMError extends Error {
5
17
  provider;
6
18
  errorType;
7
19
  retryable;
8
20
  statusCode;
9
21
  raw;
10
- constructor(message, provider, errorType, retryable, statusCode, raw) {
22
+ retryAfterMs;
23
+ constructor(message, provider, errorType, retryable, statusCode, raw,
24
+ /**
25
+ * The wait the SERVER asked for, in ms (llm-api-retry-backoff §4.4).
26
+ *
27
+ * A 7th OPTIONAL POSITIONAL FIELD, so every existing construction site
28
+ * compiles unchanged. Read structurally by `withRetry` — never through
29
+ * `instanceof LLMError`, for the reason `isRetryableError` records.
30
+ */
31
+ retryAfterMs) {
11
32
  super(message);
12
33
  this.provider = provider;
13
34
  this.errorType = errorType;
14
35
  this.retryable = retryable;
15
36
  this.statusCode = statusCode;
16
37
  this.raw = raw;
38
+ this.retryAfterMs = retryAfterMs;
17
39
  this.name = 'LLMError';
18
40
  }
19
41
  }
20
42
  /**
21
43
  * Classify an HTTP status code into an {@link LLMErrorType} and determine
22
44
  * whether the request is retryable.
45
+ *
46
+ * `headers` is OPTIONAL and additive: when supplied, a server-stated
47
+ * `Retry-After` (or a provider rate-limit reset header) rides out on
48
+ * `LLMError.retryAfterMs`, which is the only channel a retry policy has for
49
+ * honouring it. Adapters that cannot reach the headers — Anthropic's IN-STREAM
50
+ * `error` event, which arrives after the connection is already open — simply
51
+ * omit it and fall back to the plain backoff ladder.
23
52
  */
24
- export function classifyHttpError(statusCode, body, provider) {
53
+ export function classifyHttpError(statusCode, body, provider, headers) {
25
54
  let errorType;
26
55
  let retryable;
27
56
  switch (statusCode) {
@@ -41,6 +70,13 @@ export function classifyHttpError(statusCode, body, provider) {
41
70
  errorType = 'auth_error';
42
71
  retryable = false;
43
72
  break;
73
+ // A REQUEST TIMEOUT IS TRANSIENT, and it used to fall into `default` ->
74
+ // `unknown` / non-retryable. The abort exclusion that makes `timeout` safe to
75
+ // retry lives in `isRetryableError` (check 1), not here.
76
+ case 408:
77
+ errorType = 'timeout';
78
+ retryable = true;
79
+ break;
44
80
  case 429:
45
81
  errorType = 'rate_limit';
46
82
  retryable = true;
@@ -61,7 +97,8 @@ export function classifyHttpError(statusCode, body, provider) {
61
97
  }
62
98
  // Truncate body for the error message to avoid huge payloads
63
99
  const truncated = body.length > 300 ? body.slice(0, 300) + '...' : body;
64
- return new LLMError(`${provider} API error ${statusCode}: ${truncated}`, provider, errorType, retryable, statusCode, body);
100
+ const retryAfterMs = parseRetryAfterMs(headers);
101
+ return new LLMError(`${provider} API error ${statusCode}: ${truncated}`, provider, errorType, retryable, statusCode, body, retryAfterMs);
65
102
  }
66
103
  /**
67
104
  * Wrap a network-level fetch error (DNS, TLS, abort, etc.) into an LLMError.
@@ -64,7 +64,13 @@ export class AnthropicProvider {
64
64
  }
65
65
  if (!response.ok) {
66
66
  // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
67
- yield { type: 'error', error: classifyHttpError(response.status, errorBody, this.id) };
67
+ // `response.headers` carries `Retry-After` / `anthropic-ratelimit-*-reset`
68
+ // out on `LLMError.retryAfterMs`; without it a 429 that stated a wait is
69
+ // retried on the plain ladder instead of when the server said to.
70
+ yield {
71
+ type: 'error',
72
+ error: classifyHttpError(response.status, errorBody, this.id, response.headers),
73
+ };
68
74
  return;
69
75
  }
70
76
  if (!response.body) {
@@ -243,14 +249,42 @@ export class AnthropicProvider {
243
249
  break;
244
250
  }
245
251
  }
246
- // Stream ended without message_stop — emit done with what we have
247
- const message = {
248
- role: 'assistant',
249
- content: contentBlocks,
250
- usage,
251
- stopReason,
252
+ // ---- The terminal-sentinel contract (llm-api-retry-backoff §4.5a) ----
253
+ //
254
+ // Reaching here means the SSE ended WITHOUT `message_stop`: that case
255
+ // `return`s from inside the loop above, and so does the in-stream `error`
256
+ // case. No `sawTerminal` flag is needed for this adapter precisely because
257
+ // of those two returns — the absence is the signal. (`openai.ts` and
258
+ // `google.ts` DO need a flag, because their terminal frames only `break`.)
259
+ //
260
+ // TWO VERY DIFFERENT SITUATIONS SHARE THIS LINE, and conflating them is how
261
+ // every user abort would become a fake network error (constraint 1 / R-17):
262
+ //
263
+ // - the request was ABORTED. `parseSSEStream` `break`s on
264
+ // `signal.aborted` rather than throwing, so Esc lands here. An abort is
265
+ // not a provider failure, and `agent-loop` discards the `done` anyway.
266
+ // - the stream was TRUNCATED — a proxy idle cut, a load-balancer timeout,
267
+ // an HTTP/2 GOAWAY, `message_stop` never arriving. This USED to yield
268
+ // `done` with partial content, which is indistinguishable from success
269
+ // at every layer above and is the single failure the retry machinery
270
+ // exists to recover. It is now a retryable `network_error`.
271
+ //
272
+ // An empty-but-complete turn is unaffected: Anthropic always sends
273
+ // `message_stop`, so a model that emits zero content still returns above.
274
+ if (request.signal?.aborted) {
275
+ const message = {
276
+ role: 'assistant',
277
+ content: contentBlocks,
278
+ usage,
279
+ stopReason,
280
+ };
281
+ yield { type: 'done', message, usage };
282
+ return;
283
+ }
284
+ yield {
285
+ type: 'error',
286
+ error: new LLMError(`${this.id} stream truncated before message_stop`, this.id, 'network_error', true),
252
287
  };
253
- yield { type: 'done', message, usage };
254
288
  }
255
289
  catch (err) {
256
290
  yield { type: 'error', error: wrapFetchError(err, this.id) };
@@ -58,7 +58,12 @@ export class GoogleProvider {
58
58
  }
59
59
  if (!response.ok) {
60
60
  // `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
61
- yield { type: 'error', error: classifyHttpError(response.status, errorBody, this.id) };
61
+ // `response.headers` carries `Retry-After` out on `LLMError.retryAfterMs`
62
+ // so a 429 waits as long as the server asked rather than only the ladder.
63
+ yield {
64
+ type: 'error',
65
+ error: classifyHttpError(response.status, errorBody, this.id, response.headers),
66
+ };
62
67
  return;
63
68
  }
64
69
  if (!response.body) {
@@ -70,6 +75,15 @@ export class GoogleProvider {
70
75
  const contentBlocks = [];
71
76
  let textAccumulator = '';
72
77
  let stopReason;
78
+ /**
79
+ * Whether a terminal chunk arrived (§4.5a). Google's terminator is a
80
+ * candidate carrying `finishReason`; unlike Anthropic's `message_stop` it
81
+ * does not `return`, so the flag is what tells a completed stream from a
82
+ * truncated one. Set AT THE FRAME, never inferred from
83
+ * `contentBlocks.length` — a model that legitimately produces nothing and
84
+ * stops is a success, not a truncation (constraint 2).
85
+ */
86
+ let sawTerminal = false;
73
87
  try {
74
88
  for await (const sse of parseSSEStream(response.body, request.signal)) {
75
89
  const raw = sse.data.trim();
@@ -96,6 +110,7 @@ export class GoogleProvider {
96
110
  // Finish reason
97
111
  const finishReason = candidate.finishReason;
98
112
  if (finishReason) {
113
+ sawTerminal = true;
99
114
  stopReason = mapFinishReason(finishReason);
100
115
  }
101
116
  const content = candidate.content;
@@ -127,6 +142,19 @@ export class GoogleProvider {
127
142
  }
128
143
  }
129
144
  }
145
+ // ---- The terminal-sentinel contract (§4.5a) -------------------------
146
+ //
147
+ // An abort is NOT a truncation: `parseSSEStream` `break`s on
148
+ // `signal.aborted` and lands here, and reporting that as a retryable
149
+ // `network_error` would re-create the trap `isRetryableError` check 1
150
+ // closes (constraint 1 / R-17). Signal first; that path yields `done`.
151
+ if (!sawTerminal && !request.signal?.aborted) {
152
+ yield {
153
+ type: 'error',
154
+ error: new LLMError(`${this.id} stream truncated before a terminal chunk`, this.id, 'network_error', true),
155
+ };
156
+ return;
157
+ }
130
158
  // Flush text
131
159
  if (textAccumulator) {
132
160
  contentBlocks.push({ type: 'text', text: textAccumulator });
@@ -4,10 +4,33 @@
4
4
  */
5
5
  import type { AssistantMessage, ModelInfo, StreamEvent } from '../types.js';
6
6
  import type { LLMProvider, LLMRequest } from '../provider.js';
7
+ import { type RetryPolicy } from '../retry.js';
8
+ export interface ProviderRegistryOptions {
9
+ /**
10
+ * Retry policy for `stream()` and (through it) `complete()`.
11
+ *
12
+ * `undefined` and `null` MEAN DIFFERENT THINGS, exactly as they do for
13
+ * `PersistedConfig.maxTokens`: absent means "the product default"
14
+ * (`DEFAULT_RETRY_POLICY`), explicit `null` means "the user said no".
15
+ */
16
+ retryPolicy?: RetryPolicy | null;
17
+ }
7
18
  export declare class ProviderRegistry {
8
19
  private readonly providers;
20
+ private retryPolicy;
21
+ constructor(options?: ProviderRegistryOptions);
9
22
  /** Register a provider. Overwrites any existing registration with the same id. */
10
23
  register(provider: LLMProvider): void;
24
+ /**
25
+ * Install a retry policy live, or `null` to opt out entirely.
26
+ *
27
+ * A METHOD RATHER THAN A CONSTRUCTOR-ONLY CONCERN because the CLI's `/retry
28
+ * off` has to take effect in the running session: persisting the setting and
29
+ * changing nothing until the next launch is the failure users report as "the
30
+ * setting does nothing".
31
+ */
32
+ setRetryPolicy(policy: RetryPolicy | null): void;
33
+ getRetryPolicy(): RetryPolicy | null;
11
34
  /** Retrieve a provider by id. */
12
35
  get(id: string): LLMProvider | undefined;
13
36
  /** Return all registered providers. */
@@ -15,7 +38,22 @@ export declare class ProviderRegistry {
15
38
  /** Check whether a provider is registered. */
16
39
  has(id: string): boolean;
17
40
  /**
18
- * Convenience: stream a completion through the named provider.
41
+ * Convenience: stream a completion through the named provider, WITH RETRY.
42
+ *
43
+ * THIS IS THE SEAM THE WHOLE RETRY FEATURE HANGS ON (llm-api-retry-backoff
44
+ * §5.2). Wrapping here rather than inside each adapter is what lets the retry
45
+ * see the commit point, the abort signal and the whole event stream at once,
46
+ * and it is why the lead agent, every subagent and `completeLLM` all inherit
47
+ * retry with zero call-site changes.
48
+ *
49
+ * The thunk captures `request` WHOLE, so every attempt sends the same body —
50
+ * including any `max_tokens` that `sendWithOutputLimitRecovery` repaired on the
51
+ * way through, because that repair lives inside `provider.stream()` and re-runs
52
+ * per attempt.
53
+ *
54
+ * `maxRetries <= 0` returns the adapter's iterator DIRECTLY rather than a
55
+ * wrapper that happens to never retry: an opted-out caller is then byte- and
56
+ * allocation-identical to a pre-feature build.
19
57
  *
20
58
  * @throws {LLMError} if the provider is not registered.
21
59
  */
@@ -23,6 +61,14 @@ export declare class ProviderRegistry {
23
61
  /**
24
62
  * Convenience: non-streaming completion through the named provider.
25
63
  *
64
+ * ROUTED THROUGH `this.stream()`, NOT `provider.complete()`, and that is not
65
+ * optional (§5.4). All three adapters implement `complete` as exactly
66
+ * `consumeStream(this.stream(request))`, so this is behaviour-preserving — the
67
+ * only difference is that the registry's version is now the RETRYING one.
68
+ * Calling `provider.complete()` here would leave `completeLLM` as the one
69
+ * public entry point with no retry at all, which is exactly the kind of hole
70
+ * that gets discovered in production.
71
+ *
26
72
  * @throws {LLMError} if the provider is not registered.
27
73
  */
28
74
  complete(providerId: string, request: LLMRequest): Promise<AssistantMessage>;
@@ -39,14 +85,27 @@ export declare class ProviderRegistry {
39
85
  /**
40
86
  * Create and populate a {@link ProviderRegistry} with all built-in providers.
41
87
  * Call this once at server startup.
88
+ *
89
+ * `options` is additive and source-compatible: every existing `initProviders()`
90
+ * call site compiles unchanged and gets `DEFAULT_RETRY_POLICY`.
42
91
  */
43
- export declare function initProviders(): ProviderRegistry;
92
+ export declare function initProviders(options?: ProviderRegistryOptions): ProviderRegistry;
44
93
  /**
45
94
  * Get (or lazily create) the global singleton provider registry.
46
95
  *
47
96
  * Prefer calling `initProviders()` explicitly at startup and passing the
48
97
  * registry around. This singleton exists as a convenience for modules that
49
98
  * cannot easily receive the registry through dependency injection.
99
+ *
100
+ * IT IS A SEPARATE REGISTRY AND IT KEEPS THE PRODUCT DEFAULT RETRY POLICY
101
+ * (llm-api-retry-backoff §5.3). It is built here with `initProviders()` and no
102
+ * arguments, and a host that constructs its own registry (the CLI does) never
103
+ * installs it — so `streamLLM` / `completeLLM` retry with
104
+ * `DEFAULT_RETRY_POLICY` and do NOT observe that host's `/retry off` equivalent.
105
+ * Harmless while nothing calls those two helpers, and deliberately not "fixed"
106
+ * by having a UI mutate process-wide state. An embedder that wants one policy
107
+ * everywhere calls `setProviderRegistry(initProviders({ retryPolicy }))` once at
108
+ * startup.
50
109
  */
51
110
  export declare function getProviderRegistry(): ProviderRegistry;
52
111
  /**