@aragon-agent/core 0.2.7 → 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|