/** * 错峰重试 (#49 feature 3) — the honest half of "back off when the provider * throttles us". * * THE HONEST BOUNDARY, stated first because everything below is shaped by it: * a plugin has NO seam that INTERCEPTS the model's own retry — we cannot delay a * request the model decides to send, and we must not pretend to. But the method * surface does let a plugin RE-DRIVE execution: `ctx.session.synthetic` is callable * on 2.1.2 (zero-token probe 2026-10-21, docs/v2-port-notes.md) and the host * documents it as "Durably admit synthetic session input and schedule execution * unless resume is false". So this layer does four things, and says so: * * 1. RECOGNISE a provider throttle from the event feed (`session.error`), by * matching explicit signatures only — never by guessing. A text that * not carry a known signature classifies as `unknown` and changes nothing. * 2. COMPUTE an exact wait (a per-request ladder: retries #1..#5 wait * 5s/10s/20s/40s/60s; the chain never accumulates across requests, and the * host's own `session.retry.scheduled` stands our directive down — the desktop * is already rendering that countdown; jitter applies only when retryJitter > 0) — but ONLY for the * kinds a wait can fix. A HARD quota (the BILLING family only: insufficient * balance / 余额不足 / 欠费 / insufficient_quota / billing) names no wait: the * directive says stop and raise-or-reset, and the breaker trips on the SECOND * consecutive hard-quota error instead of * `breakAfter`. The structured `retryAfterMs` host 2.1.2 (#57146) puts on session * errors is honored as the wait floor. Inject it into the * NEXT request's system prompt as an actionable sentence ("等 N 秒再发"), * not a vague "try later". The directive is consumed once, so a host that * replays the hook in-process cannot duplicate it. * 3. COOL DOWN: after `breakAfter` consecutive throttle errors the breaker * trips and new `subagent` dispatches are DENIED for `cooldownMs`, on the * same `permission.evaluate` seam the permission guards use. Both only ever * make a rule stricter, so they compose without fighting. * 4. RE-DRIVE: when a rung's wait is over and nobody moved — the host scheduled no * retry of its own and the session assembled no request — the layer admits a * synthetic continuation for that session (`session.synthetic`, resume:true). * One re-drive per rung, capped by `maxRetries` per chain, NEVER for a session * the scope has not seen, and cancelled the moment the session or the host * continues first. A hard quota is re-driven only when the provider stated a * retry time — otherwise waiting is the one thing that cannot help. * * Discipline (the same as every other v2 layer): Team-scoped, never throws (a * failure allows the call through and is counted), never rewrites args, and * idempotent under the host's in-process hook replay. */ import type { TeamScope } from "./v2-scope.js"; import type { DynamicSink } from "./v2-tail.js"; import type { V2Context, V2Registration } from "./v2-types.js"; /** The stable prefix a human (and a test) can recognise the injected line by. */ export declare const RETRY_MARKER = "## Team Retry"; export type ProviderErrorKind = "quota" | "rate" | "transient" | "unknown"; export interface ProviderErrorClass { kind: ProviderErrorKind; /** A `Retry-After`-style hint parsed out of the text, in ms; 0 when absent. */ retryAfterMs: number; /** true only inside the quota kind when the text says waiting cannot fix this * (the BILLING family: insufficient balance / 余额不足 / 欠费 / insufficient_quota / * billing — NOT alibaba's transient token-limit throttle, user correction 2026-10-21). */ hardQuota: boolean; } export declare function classifyProviderError(text: unknown): ProviderErrorClass; export interface RetryConfig { enabled: boolean; baseMs: number; maxMs: number; jitter: number; breakAfter: number; cooldownMs: number; /** How many retries ONE request earns (default 5 → rungs 5s/10s/20s/40s/60s). */ maxRetries: number; /** A session with no error for this long starts a NEW retry chain. */ chainGapMs: number; } /** * The default ladder is FIXED, deterministic AND PER REQUEST: retries #1..#5 of the * SAME failed request wait 5s / 10s / 20s / 40s / 60s (base 5000 doubling, jitter 0), * then that request's budget is spent (`maxRetries` 5) and the chain stops. A chain * never accumulates across requests (idle / gap / post-exhaustion all restart it), and * the host's own `session.retry.scheduled` stands our ladder directive down because the * desktop is already showing that countdown. Jitter stays a knob — a user who would * rather parallel sessions not re-fire in lockstep can set `retryJitter` back to a * fraction — but the shipped default promises the fixed ladder, and a promise with * jitter in it is not that promise. */ export declare const RETRY_DEFAULTS: RetryConfig; /** * Resolve the knobs. An ILLEGAL value falls back to its default — it never * silently disables the feature ("非法值回退默认,不静默关闸"). Only an explicit * `retry: "off"` turns it off. */ export declare function resolveRetryConfig(config?: { retry: string; retryBaseMs: number; retryMaxMs: number; retryJitter: number; retryBreakAfter: number; retryCooldownMs: number; retryMaxRetries: number; retryChainGapMs: number; }): RetryConfig; /** * `delay = min(base * 2^attempt, max) * (1 ± jitter)`, rounded to ms and never * negative. `attempt` is 0-based, so the first error waits `base`. */ export declare function backoffMs(attempt: number, baseMs: number, maxMs: number, jitter: number, random: () => number): number; export interface RetryReport { enabled: boolean; quotaSeen: number; rateSeen: number; transientSeen: number; unknownSeen: number; /** times the consecutive-error streak reached `breakAfter` and the breaker tripped */ breakTrips: number; /** cooldown windows entered (a trip enters one; an error during a cooldown extends it) */ cooldowns: number; lastDelayMs: number; directivesInjected: number; cooldownDenied: number; foreignSkipped: number; threw: number; /** per-request budgets that ran out — sub-agents and the main agent counted apart */ childExhausted: number; mainExhausted: number; /** retry chains restarted (idle event or idle gap) — proves the ladder is per-request */ chainsReset: number; /** directives armed FOR the lead because a child ran out of its budget */ leadNotices: number; /** host-native retries observed (`session.retry.scheduled`) — the UI's own countdown */ hostRetries: number; /** re-drives the plugin admitted itself (`session.synthetic` fired successfully) */ pluginRetries: number; } export interface RetryDirective { text: string; delayMs: number; /** What kind of guidance this is; a host retry may only stand down a "ladder". */ kind?: "ladder" | "hard" | "exhausted" | "notice"; } /** The turn-ending event spellings across host generations (2.0.28 renamed them). */ export declare function isTurnEndEvent(type: string): boolean; /** The session→role question, pure so a test can pin it without the host. */ export declare function roleOfSession(sid: string, children: ReadonlyArray<{ sessionID: string; parentSessionID?: string; agent?: string; }>, agentOf: (sessionID: string) => string, leadAgent?: string): { role: RetryRole; parentSessionID?: string; agent?: string; }; /** Who is making the failing request: the lead session or a dispatched child. */ export type RetryRole = "main" | "sub" | "unknown"; export interface RetryObserveOptions { sessionID?: unknown; structuredRetryAfterMs?: number; role?: RetryRole; parentSessionID?: unknown; agent?: unknown; } export interface RetryGovernor { observe(text: unknown, opts?: RetryObserveOptions | number): ProviderErrorClass & { delayMs: number; }; observeEvent(raw: unknown): void; /** The host scheduled its OWN retry (`session.retry.scheduled`): align the chain's * attempt with the host's count and stand the ladder directive down — the UI is * already showing the host's countdown, and two competing waits would fight. */ observeHostRetry(raw: unknown): void; /** The turn ended: this session's retry chain is over (a new request starts at #1). */ observeIdle(sessionID: unknown): void; /** Peek at the pending directive for a session without consuming it. */ directive(sessionID?: unknown): RetryDirective | null; /** Consume one session's pending directive (the injection hook's one-shot read). */ takeDirective(sessionID?: unknown): RetryDirective | null; inCooldown(sessionID?: unknown): boolean; /** ms left in that session's cooldown window (0 when not cooling down). */ cooldownRemainingMs(sessionID?: unknown): number; /** The configured consecutive-error threshold, for the refusal wording. */ readonly breakAfter: number; /** Retries one request earns (the ladder's length). */ readonly maxRetries: number; readonly report: RetryReport; reset(): void; } /** A plugin-driven re-drive request: wait `delayMs`, then admit synthetic input. */ export interface RedriveRequest { sessionID: string; delayMs: number; /** which re-drive of this chain this is (1-based) */ attempt: number; kind: ProviderErrorKind; hardQuota: boolean; } export interface RetryGovernorOptions extends Partial { now?: () => number; random?: () => number; /** Fired for each classification / breaker outcome (the trajectory sink). */ onEvent?: (row: Record) => void; /** The session→role facts the layer owns (scope + the child registry). */ roleOf?: (sessionID: string) => { role: RetryRole; parentSessionID?: string; agent?: string; }; /** * The layer wires this to the REAL re-drive (setTimeout + `ctx.session.synthetic`, * verified callable on 2.1.2: "Durably admit synthetic session input and schedule * execution unless resume is false"). The governor decides WHEN and with what * budget; the layer performs the I/O. */ redrive?: (req: RedriveRequest) => void; } /** * The state machine. Pure except for the injected `now`/`random`, so a test can * pin the backoff sequence, the jitter band and the breaker deterministically. */ export declare function createRetryGovernor(opts?: RetryGovernorOptions): RetryGovernor; /** * Pull the error text out of whatever the event feed handed us. Defensive by * design: the `session.error` payload shape is not a promised interface, so we * read the known keys and fall back to a bounded JSON stringify rather than * assuming one shape. */ export declare function errorTextOf(raw: unknown): string; /** * Host 2.1.2 (#57146) puts `retryAfterMs` on structured session errors. Read it * defensively — same walk style as `errorTextOf`: the payload shape is not a promised * interface, so an absent or non-numeric field is simply 0. */ export declare function structuredRetryAfterOf(raw: unknown): number; /** The session id an event refers to — defensive, the payload shape is not promised. */ export declare function sessionIDOf(raw: unknown): string; export interface RetryLayerInput { scope?: TeamScope; governor: RetryGovernor; /** * 易变注入的尾部通道 (`dynamicTail: "on"`). When handed in, the pending * directive goes to the request's TAIL (one message at the very end) instead * of `event.system`, so the prefix stays cacheable; absent = the old system * push, byte-exact. */ sink?: DynamicSink; /** Fired for each observable outcome so the personality can write the * `step_id:"v2-retry"` trajectory line — the layer owns no store. */ onEvent?: (row: Record) => void; /** The governor's re-drive decisions land here (wired by v2.ts through the sink). */ bindRedrive?: (fn: (req: RedriveRequest) => void) => void; /** Injectable timers so a test can fire the re-drive without sleeping. */ timers?: { set: (cb: () => void, ms: number) => unknown; clear: (h: unknown) => void; }; } export interface RetryLayer { registrations: V2Registration[]; report: RetryReport; /** Stand down a pending re-drive (the host scheduled its own retry for this session). */ cancelRedrive: (sessionID: unknown) => void; } /** * Register the two hooks the retry layer owns: * · `session.hook("context")` — inject the pending directive (exact seconds) * into the NEXT request's system prompt, once, Team-scoped. * · `permission.hook("evaluate")` — deny a `subagent` dispatch while the * breaker's cooldown is active, Team-scoped, only ever stricter. * * Nothing here throws into a hook: a failure is counted and the call is allowed * through (a throughput guard that fails OPEN is the honest failure mode). */ export declare function applyV2RetryGovernor(ctx: V2Context, input: RetryLayerInput): Promise; //# sourceMappingURL=v2-retry.d.ts.map