@sema-agent/core
Version:
Stateless, task-oriented AI agent core
303 lines (302 loc) • 19.3 kB
TypeScript
import { type AssistantMessage, type Model, type Usage, createAssistantMessageEventStream } from "../internal/llm.js";
import type { BrainRetryErrClass } from "../core/types.js";
import type { AssertAllKeysHandled } from "../core/ask-origin.js";
import { type RequestRoute } from "./request-route.js";
import { type ProviderDialect } from "./provider-dialect.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) or that the proxy on the way refused the tunnel
* (`PROXY_TUNNEL_REFUSED`); see `errors.ts` `readConnectFailure`.
* 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[];
/**
* The keys `Model.extraBody` carried that this adapter owns and therefore stripped from the body
* ({@link import("./request-params.js").applyExtraBody}'s `dropped`), names only; empty or absent when
* nothing was stripped. The engine reports them to the run's sink ({@link reportExtraBodyDropped}) when this
* request is committed to be sent, which is where the Runner announces them.
*/
extraBodyDropped?: readonly string[];
/**
* The route this request takes, as the adapter that built it states it ({@link RequestRoute}, minted by
* `requestRouteOf` from the same credential and root resolutions the build used). The engine lifts the committed
* request's route onto the terminal shell of a provider failure (`AssistantMessage.apiErrorRoute`, beside
* `isApiErrorMessage`) — every failure of the request states the same route, so it is declared once here rather
* than on each failure. Absent = the adapter states none.
*/
route?: RequestRoute;
/**
* 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;
/** The endpoint this call goes to, as the provider dialect table names it (`undefined` = none declares it):
* what an in-band error frame is judged by. */
dialect: ProviderDialect | undefined;
/** Hand the engine what this attempt produced — its final content and the provider-reported usage — before
* raising the provider's own in-band failure from `finalize`: the terminal message the engine mints for that
* failure keeps it (the text the provider streamed, the usage it reported), rather than an empty shell. */
keepOutput(output: KeptOutput): void;
}
/** The output a parser hands over with {@link StreamControls.keepOutput}. */
export interface KeptOutput {
content: AssistantMessage["content"];
usage: Usage;
/** The provider reported no usage frame (the zeros are not a measurement). */
usageMissing?: true;
}
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;
}
/**
* The phase a retry-wait frame of each {@link BrainRetryErrClass} carries — ONE table, so a frame's two
* classifications are never mapped separately: the phase is what the brain is doing about the wait (waiting
* out a rate limit, re-connecting, re-sending), read off why it waits. Every retry-wait frame this engine
* emits — the connect ladder, the first-byte lane, the output-cap re-send, the mid-stream tiers — reads it.
*/
declare const RETRY_PHASE_OF_ERR_CLASS: {
readonly connect_refused: "reconnecting";
readonly transport: "reconnecting";
readonly stall: "reconnecting";
readonly rate_limit: "rate_limited";
readonly server: "retrying";
readonly http: "retrying";
readonly output_cap: "retrying";
};
/** Compile-time fence: every retry class has a phase row. */
export type RetryPhaseTableCoversEveryErrClass = AssertAllKeysHandled<Exclude<BrainRetryErrClass, keyof typeof RETRY_PHASE_OF_ERR_CLASS>>;
/**
* 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>;
export {};