UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

251 lines (250 loc) • 16.1 kB
import { type AssistantMessage, type Model, createAssistantMessageEventStream } from "../internal/llm.js"; import { type BrainTimeoutConfig } from "./timeout.js"; /** * Shared streaming engine for the OpenAI- and Anthropic-shaped brains (design/32, consolidation). * * It owns the **bug-prone machinery that was duplicated verbatim** across `openai.ts` and * `anthropic.ts` — the first-byte window + retry loop, the three-tier timer logic (first-token + idle * watchdog) with their flag resets, the SSE read loop + line framing, the one-shot cleanup, and the * `.catch/.finally` error wrapping. Centralizing the timer logic is the point: the same first-token / * idle reset bug was fixed twice (1.38.2 / 1.40.2) precisely because this lived in two copies. * * Each provider keeps its OWN request-shaping (`buildRequest`) and SSE parsing + finalization * (`makeParser`) — those genuinely differ (chat-completions deltas vs typed content-block events, * thinking signatures, per-provider usage/finish-reason) and stay verbatim in the adapters, so this * refactor is behavior-preserving (safety net: resilience/streaming/brain tests). */ export interface StreamEngineConfig extends BrainTimeoutConfig { /** * Retries for transient failures (network / 5xx / 429) BEFORE the body streams, and for the * mid-stream tier-C re-send that shares this budget. Absent ⇒ {@link resolveMaxRetries} (the * `SEMA_MAX_RETRIES` env, else 10). An explicit value here is authoritative and NOT clamped — * deployment sovereignty, same posture as the timeout fields. Sovereignty covers a chosen NUMBER: a * non-finite value (CLS-A-8) falls back to the env/default instead of disabling the budget. */ maxRetries?: number; /** First-retry backoff ms; each further attempt doubles it (capped, +≤25% jitter). Default 500. */ retryDelayMs?: number; /** * Retries allowed for a DETERMINISTIC connect failure — one whose error codes say the target itself * is not there (`ECONNREFUSED`, or a name with no address; see `errors.ts` `classifyConnectFailure`). * Absent ⇒ {@link DEFAULT_DETERMINISTIC_CONNECT_RETRIES}. * * These retries exist for one case only: a peer that is mid-restart and about to accept. Waiting * longer cannot change any other outcome — re-sending at an address where nothing listens gets the * same refusal in milliseconds — so spending the full transient budget on it buys nothing and costs * an unattended caller minutes of silence. * * The value is capped by {@link maxRetries} (this lane can only ever be the SHORTER of the two). * Setting it AT or ABOVE `maxRetries` turns the lane off entirely: budget and backoff both revert to * the shared transient ladder, which is the opt-out for a deployment that wants the old behavior. */ deterministicConnectRetries?: number; } /** * Resolve the transient-failure retry budget — explicit config > `SEMA_MAX_RETRIES` env > default 10 * (the same three-level shape as the shell's timeout caps). A non-numeric, negative, or empty env * value is ignored rather than silently meaning "zero retries"; an oversized one is clamped. */ export declare function resolveMaxRetries(configured: number | undefined): number; /** * Resolve the deterministic-connect retry budget. Same posture as {@link resolveMaxRetries}: a chosen * NUMBER is honored, a non-finite or negative one falls back to the default rather than silently * meaning something (a NaN here would compare false against every budget gate and disable the lane by * accident, which is the sentinel-inversion shape CLS-A-8 pinned for the shared budget). */ export declare function resolveDeterministicConnectRetries(configured: number | undefined): number; /** * Is `rebuilt` still THE SAME REQUEST as `original`, differing only in the output cap and the values * that follow from it (ruled 2026-08-05)? * * `buildRequest` re-reads the adapter's model, config, options and context, so a re-shaped re-send is * only trustworthy to the extent that those did not move underneath it. Rather than deep-copying all * of them, the two built requests are compared directly: same destination, same headers, and a body * identical outside {@link SSERequest.capDerivedPaths}. That covers a changed model id, prompt, tool * set, temperature, reasoning posture, or passthrough parameter with one rule instead of a list of * things to remember. * * Conservative on anything it cannot read: a body that is not a JSON object compares by exact string, * so an unparseable pair declines rather than being waved through. */ export declare function sameRequestModuloCap(original: SSERequest, rebuilt: SSERequest, imposedCap: number): boolean; /** * CC parity (`Q1b`, ruled 2026-08-05) — read the provider's EXPLICIT retry verdict off the response. * * The status code is an inference about whether a re-send can help; this header is the provider * SAYING so, and it outranks the inference in BOTH directions: * - `x-should-retry: true` — retry even a status the predicate calls terminal (the upstream knows * the condition is transient, e.g. a fenced-off shard behind a 4xx). * - `x-should-retry: false` — do NOT retry even a 5xx/429/408/409. Without this, a provider that * has already decided the request is unservable is re-sent the same request up to the whole * budget (10 by default), which costs the caller and the upstream alike for a known-refused call. * * Returns `undefined` for an absent header AND for any value other than the two exact tokens — "no * opinion", so the status-code predicate decides and the pre-existing behavior is byte-identical. * Exact-match (no case folding / trimming) is deliberate: it mirrors CC's comparison, and `Headers` * already normalizes surrounding whitespace, so anything else reaching here is a value we did not * define and must not guess at. * * Tolerates a `doFetch` (BYOM injection point) whose Response-shaped return has no usable `headers`. */ export declare function shouldRetryHeaderVerdict(res: Response | undefined): boolean | undefined; /** * #530 — the first-token WAIT disclosure. While a first-token watchdog is armed * (`firstTokenTimeoutMs > 0`) and no content token has arrived, the engine says so on the status * channel: the FIRST `waiting_first_token` frame goes out once the wait has lasted * {@link WAITING_FIRST_TOKEN_AFTER_MS}, and one more every {@link WAITING_FIRST_TOKEN_EVERY_MS} after * that, until the first token lands, the watchdog fires, or the attempt ends. A short wait (under the * threshold) announces nothing — the frame exists for the unattended "no answer for a minute" case, * not for every request. A wait whose watchdog fires AT the threshold is disclosed once, at that * instant, before the cut is announced. The same 30s slice the retry countdown re-announces on. */ export declare const WAITING_FIRST_TOKEN_AFTER_MS = 30000; export declare const WAITING_FIRST_TOKEN_EVERY_MS = 30000; export interface SSERequest { url: string; headers: Record<string, string>; body: string; /** * The per-request output cap this request ACTUALLY carries on the wire, when the adapter sets one * (the adapters disagree on the body key, so the engine cannot read it off `body`). Absent means * "no cap sent" or "this adapter does not report it". * * The context-overflow recovery needs it because the number the PROVIDER echoes back in its error * is not evidence of what was sent: a stale or malformed 400 reporting a larger cap than the * request carried would otherwise let the recovery "lower" the cap to a value ABOVE the caller's * own explicit bound. It doubles as the check that an adapter honored an imposed cap at all. */ outputCapTokens?: number; /** * The body PATHS this adapter may legitimately re-derive when the engine imposes a different output * cap — the cap's own key(s), plus anything computed from it. Dotted for nested leaves * (`"thinking.budget_tokens"`), deliberately: naming the whole `thinking` object would excuse a * change of reasoning MODE as if it followed from the cap, when only the budget does. * * The engine uses this to check that a re-shaped re-send is still THE SAME REQUEST. `buildRequest` * re-reads the adapter's model/config/options/context, so a deployment that changes any of them * while a call is in flight could otherwise turn "lower the cap and try again" into a request with * a different model id, prompt, tool set, or reasoning posture. Everything outside these paths must * come back identical; an adapter that declares nothing therefore permits no body change at all. */ capDerivedPaths?: readonly string[]; /** * Called by the engine once, if and only if THIS request becomes the one that is sent. * * `buildRequest` is also called SPECULATIVELY — the context-overflow recovery builds a candidate * and may then decline it. Any bookkeeping the adapter keeps for its own later diagnostics (which * output cap actually went out, which knob supplied it, whether thinking was requested) therefore * belongs here rather than in the build: assigned during a build, a rejected candidate's numbers * would outlive it and be reported against the request that really was sent. */ onCommitted?: () => void; } /** * Per-attempt request overrides the ENGINE imposes on a re-send (ruled 2026-08-05). Absent — which is * every ordinary build — the adapter shapes its request exactly as before, byte for byte. * * An adapter MUST honor every field it can express. Ignoring one is not silent: the only producer is * the context-overflow recovery, whose no-progress guard sees the unchanged cap come back in the next * provider error and fails loud with the original failure instead of looping. */ export interface BuildRequestOverrides { /** * The per-request output cap to send, outranking every caller/model knob for THIS attempt. Set when * the provider reported that `input + max_tokens` exceeds the context limit; whichever body key the * adapter uses for the cap (`max_tokens` / `max_completion_tokens` / `max_output_tokens`) must carry * this value, and any field derived from the cap must be re-derived from it — the point of routing * this through the adapter rather than patching the serialized body. */ maxOutputTokens?: number; } /** Controls that the engine passes to the per-stream parser. */ export interface StreamControls { out: ReturnType<typeof createAssistantMessageEventStream>; /** Shared mutable partial-snapshot message; the parser mutates `content` and emits `{ ...partial }`. */ partial: AssistantMessage; /** Call on every CONTENT delta (text/thinking/tool input) — resets the first-token + idle watchdogs. */ sawContentToken(): void; /** Cancel the underlying stream (e.g. degenerate-repetition cutoff). The read loop then ends. */ cancel(): void; } export interface StreamParser { /** Handle one raw SSE line (`data: {...}`); parse, emit events, call `ctrl.sawContentToken()` on content. */ onLine(line: string): void; /** * Build + emit the final `done`/`error` message after the stream ends. * * `truncated: true` = this finalize is a design/124 tier-A PROMOTION of a mid-stream failure * (stall / connection cut / in-band overload downgrade): the provider never terminated the * stream, so any still-open prose segment was CUT, not closed. A parser that mints segment-end * frames (`text_end`) must withhold them on this arm — the TaskEvent contract promises the frame * means "the model closed the block" (HONEST ABSENCE otherwise) — while still building the same * final message (the facade stamps `partialFinalized` + the warning on the terminal event). */ finalize(opts?: { truncated?: boolean; }): void; /** * design/124 §0.5-2: introspection for the mid-stream failure tiering. Read directly off the * parser's accumulated state (cheap, no side effects): * - `hasSubstantiveText` — non-blank answer text (or a streamed refusal) has been emitted → tier A * (partial finalize) territory. * - `hasCompletedToolCall` — at least one tool call CLOSED successfully (a `toolcall_end` was * emitted, so an in-stream executor may already be running it) → tier A; also the retry-safety * assertion (§0.5-4): a re-send is FORBIDDEN once this is true (double execution). * - `hasOnlyThinking` — thinking streamed but nothing substantive (no non-blank text, no completed * tool call) → tier B (seal + whole-turn retry). A dangling tool-call accumulation alongside the * thinking does not veto this (nothing was admitted; the retry's snapshot replacement drops it). * - `terminalFrameSeen` — the provider TERMINATED this stream (openai `finish_reason`, anthropic * `message_delta.stop_reason`, responses `response.completed`/`response.incomplete`). A FACT, not a tier: * the parser reports it off the same state its own finalize reads, and the tiering owns what it means. */ snapshot(): { hasSubstantiveText: boolean; hasCompletedToolCall: boolean; hasOnlyThinking: boolean; terminalFrameSeen: boolean; }; /** * design/124 §0.5-2 tier B: seal the UI stream before a whole-turn retry — emit `thinking_end` for * any OPEN thinking block (the block state is parser-private; the engine cannot emit a correct * close). The retry attempt's parser may then legally REOPEN a thinking block at the SAME * contentIndex — consumers render by partial snapshot (replacement semantics), so index reuse is * part of the event contract (see AssistantMessageEvent thinking_start note). Idempotent. */ sealForRetry(): void; } /** * Run one streaming request end-to-end and drive the provider `parser`. Returns a fresh event stream; * never throws (errors are pushed as `error` events, mirroring the Brain contract). */ export declare function runStreamingBrain(args: { model: Model; doFetch: typeof fetch; signal?: AbortSignal; config: StreamEngineConfig; /** HTTP error message prefix (e.g. "gateway" / "anthropic"). */ httpLabel: string; /** Build the one request. May throw (→ surfaced as an error event). Called again with * {@link BuildRequestOverrides} when the engine re-shapes a re-send (context-overflow recovery). */ buildRequest: (overrides?: BuildRequestOverrides) => SSERequest; /** Create the per-stream parser once the partial + controls exist. */ makeParser: (ctrl: StreamControls) => StreamParser; /** * 1.296 件A — per-call ADVISORY stall watchdogs (`StreamOptions.stallTimeouts`). Priority chain: * brain construction-time config (`BrainTimeoutConfig.firstByteTimeoutMs`/`firstTokenTimeoutMs`/ * `idleTimeoutMs`) wins whenever PRESENT — including an explicit 0, which means "deliberately * disabled" — and only an ABSENT (undefined) field adopts the advisory value; both absent ⇒ the * engine default constant for that segment (`timeout.ts`). `firstByteMs` bounds the * fetch→response-headers wait: the engine's post-headers timers can't see a gateway that never * returns headers. The connect clock is not per-call: it is the process dispatcher's (`transport.ts`). */ stallTimeouts?: { firstByteMs?: number; firstTokenMs?: number; idleMs?: number; }; /** Per-call CAP on the transient retry budget (`StreamOptions.maxRetries`): the call retries at most this many times * and never more than the brain's own budget ({@link resolveMaxRetries}); a non-finite or negative value caps nothing. */ maxRetries?: number; }): ReturnType<typeof createAssistantMessageEventStream>;