@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.
- package/CHANGELOG.md +92 -0
- package/dist/engine/agent.d.ts +17 -0
- package/dist/engine/agent.js +30 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +11 -0
- package/dist/llm/provider.d.ts +25 -2
- package/dist/llm/provider.js +40 -3
- package/dist/llm/providers/anthropic.js +42 -8
- package/dist/llm/providers/google.js +29 -1
- package/dist/llm/providers/index.d.ts +61 -2
- package/dist/llm/providers/index.js +70 -9
- package/dist/llm/providers/openai.js +40 -2
- package/dist/llm/retry.d.ts +211 -0
- package/dist/llm/retry.js +458 -0
- package/dist/llm/types.d.ts +40 -1
- package/package.json +1 -1
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
|
package/dist/engine/agent.d.ts
CHANGED
|
@@ -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.
|
package/dist/engine/agent.js
CHANGED
|
@@ -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
|
package/dist/llm/provider.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
*/
|
package/dist/llm/provider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
/**
|