@sema-agent/core 7.32.0 → 7.33.0

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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.33.0 — 2026-09-26
4
+
5
+ ### Model-call timeouts re-cut by segment: the header wait gets its own `firstByteTimeoutMs`, and every request carries the engine's dispatcher with the transport's header/body clocks off (#1121 #1122; behaviour: a pre-headers stall is a `stall` with its own one-retry lane, not a `transport` failure re-sent ten times; every watchdog defaults ON; +1 optional key `BrainTimeoutConfig.firstByteTimeoutMs`; **`BrainTimeoutConfig.connectTimeoutMs` REMOVED**; `StallTimeouts.connectMs` → `firstByteMs` (type-face breaking, see below); `WORKFLOW_AGENT_STALL_MS` 630 s → 1 500 s (derived); +1 dependency `undici@7.30.0`; `engines.node` `>=20.18.1`) (@server @cli @client-core)
6
+
7
+ - **Before.** Two shapes, both reported against a self-hosted gateway. (1) The knob named `connectTimeoutMs` (advisory 30 s) actually bounded the `fetch()` call → response headers. A gateway that runs its prefill BEFORE writing headers was cut at 30 s, the cut was announced as `errClass: "transport"` / "connection lost, reconnecting" (nothing about the link was observed — this process cancelled its own request), and the identical body was re-sent through the transient ladder up to `maxRetries` (10) times: ~8.5 minutes of burned prefill ending in "connect timeout after 30000ms". (2) `config.fetchImpl ?? fetch` handed the request to Node's global `fetch`, whose undici transport keeps a 300 s headers clock and a 300 s body-silence clock that nothing here set or named; they sat UNDER `firstTokenTimeoutMs` (600 s) and `idleTimeoutMs`, so a value above 300 s was never granted, the cut arrived as a read rejection reported `transport`, and raising the knob changed nothing, silently.
8
+ - **Three engine clocks, one per segment the engine can see, one definition (`src/brain/timeout.ts`).** The TCP + TLS connect is the transport's own clock (undici's 10 s by default, tuned on the process dispatcher the host installs); it is not a knob here.
9
+
10
+ | segment | key (`BrainTimeoutConfig`, all optional) | default | enforced by | on expiry |
11
+ |---|---|---|---|---|
12
+ | `fetch()` call → response headers | `firstByteTimeoutMs` — **new** | `STALL_FIRST_BYTE_MS` 300 s, plus 1 s per 32 KiB of request body (rounded up); the one retry runs at 2× the knob | the engine (per-attempt abort controller) | `stall`: retry frame `first-byte watchdog fired after <window>ms (firstByteTimeoutMs[ <knob> + <n> upload allowance]), retrying`, `maxRetries: 1`; terminal `[network] first-byte timeout: no response headers within <w1>ms, then <w2>ms on the retry (firstByteTimeoutMs) — if a proxy or gateway holds responses until they complete, raise firstByteTimeoutMs to wait longer` |
13
+ | headers → first content delta | `firstTokenTimeoutMs` | `STALL_FIRST_TOKEN_MS` 600 s | the engine | unchanged (`first-token watchdog fired …`) |
14
+ | delta → delta | `idleTimeoutMs` | `STALL_IDLE_MS` **300 s** (was 90 s; the reference implementation's idle floor — ruled to follow) | the engine | unchanged (`stream idle watchdog fired …`) |
15
+
16
+ - **One resolution chain for every knob** (`resolveWatchdogMs`): construction config (an explicit `0` is OFF) > per-call advisory `StreamOptions.stallTimeouts` (the Runner sends the same defaults on every call; a BYOM brain reads them as its hint) > the engine default constant. The old third level "both absent ⇒ off" is retired: a brain built with no timeout config and used outside the Runner used to have NO engine watchdog at all and rode on the transport's accidental 300 s. **Who is hurt.** A deployment that constructed a first-party brain with no timeout fields and relied on it having none: it now arms 300 / 600 / 300 s (first byte / first token / idle). The idle default is the reference implementation's 300 s floor (ruled to follow), so a call that carried no advisory keeps the same 300 s of tolerated mid-stream silence the transport's body clock used to give it — now as the engine's own watchdog with a `stall` announcement instead of a transport failure; an Anthropic `ping` is still not a content delta, so a server-side tool that keeps the stream silent past 300 s is cut and, with text already streamed, committed by tier A (unchanged bound, new announcer). The number is not changed here; it is in #1125's candidate material. **Compensation.** `0` on the field it wants off (deployment sovereignty, unchanged), or a larger `idleTimeoutMs`.
17
+ - **The first-byte lane** has its OWN budget (`FIRST_BYTE_RETRY_BUDGET` = 1, independent of `maxRetries`, like the thinking-only tier's), widens the knob ×2 on the retry (`FIRST_BYTE_RETRY_WINDOW_FACTOR`), re-sends the same body exactly once, and never claims the link dropped. The retry frame and the operator telemetry read one mapping (`retryAnnouncement`), the same one the post-headers watchdogs already used — a phase has no dialect of its own. The **wait disclosure** (`waiting_first_token`, first frame at 30 s then every 30 s) now runs during the header wait too, reporting the first-byte window as `timeoutMs`; before, a 5-minute header hold showed nothing until the wrong "reconnecting".
18
+ - **The transport seat** (`src/brain/transport.ts`, one `resolveFetch` for all three brains): every request goes to the caller's `fetchImpl` (else Node's global `fetch`) with ONE addition to its init — `ENGINE_DISPATCHER`, a transparent stand-in for the PROCESS's dispatcher that differs from it in one place: each request it dispatches carries `headersTimeout: 0, bodyTimeout: 0`. The sending members undici's Dispatcher base builds on `dispatch` (`request`, `compose`, …, read off the base class, not listed) stay the engine's, so a `fetchImpl` that composes interceptors over `init.dispatcher` still sends with the clocks off, keeps every interceptor and reaches its own fetch's slot; every other member (`isMockActive`, `close`, events, …) is read off the host's dispatcher when accessed — nothing is listed, so a host `MockAgent` sees the request body and intercepts as it does for the host's own requests. Which host dispatcher: the one the CALLING fetch would resolve on its own — undici keeps one process slot per Dispatcher-API version (`undici.globalDispatcher.1` for every undici ≤ 7 including Node's built-in fetch, `.2` for undici 8), and the handler the fetch passes says which (undici's own test: `onRequestStart` ⇒ v2); resolved per request. Proxies, CA trust, mTLS, mocks and the connect clock stay whatever the host installed with `setGlobalDispatcher`. Consequence: `firstTokenTimeoutMs` / `idleTimeoutMs` above 300 s are now actually granted, on Node's fetch and on any `fetchImpl` that forwards `init` to a fetch. On an undici 8 host a v1 caller (Node's built-in fetch) goes through undici 8's slot-1 translation, which forces HTTP/1.1 and leaves a `MockAgent`'s interceptors — for the host's own plain requests too; a `fetchImpl` that is undici 8's `fetch` reaches the real dispatcher (KL-1121h). **The slot is the API**: slot 1 (`…globalDispatcher.1`) is always handed a v1 handler and slot 2 a v2 one, through undici's own converters (`lib/handler/unwrap-handler.js` / `wrap-handler.js`), so a composed chain whose interceptor rewraps the handler to v2 (undici 7's `interceptors.retry()`) still reaches an undici 6 host in slot 1 (before this rule: `UND_ERR_INVALID_ARG invalid onError method` and a retry loop).
19
+ - **`fetchImpl` contract.** It receives `init.dispatcher = ENGINE_DISPATCHER` beside the method / headers / body / signal it always received. A wrapper that forwards `init` to Node's `fetch` gets the seat for free; a fetch that ignores `init.dispatcher` (another HTTP stack, a fetch that rebuilds its init) keeps its own transport's clocks under the engine's — the engine cannot see or lift them (KL-1121a): with undici's defaults its 300 s header clock fires before the engine's first-byte window (≥ 301 s: 300 s + at least 1 s of upload allowance), so the one-retry first-byte lane is never reached — the cut is reported `transport`, re-sent through the shared ladder, and a dead gateway reaches the terminal after about 11 × 300 s. The engine's three watchdogs arm regardless; a first-byte cut still requires the fetch to honour `init.signal`.
20
+ - **`connectTimeoutMs` removed — who is hurt / compensation.** The knob bounded the header wait, which is now `firstByteTimeoutMs`; the TCP + TLS connect it was named for is the transport's own clock, tuned on the process dispatcher (`setGlobalDispatcher(new Agent({ connect: { timeout } }))`) — the engine has no honest home for a connect number, so the key is gone rather than kept with a changed meaning or a silent no-op. A host passing it fails loudly at upgrade — a literal does not compile, and a spread (`{ ...timeouts }`) or a JS caller, which the compiler cannot see, is refused by the brain factory (`config.stall_timeout_retired`, naming `firstByteTimeoutMs`) rather than ignored — and moves the number to `firstByteTimeoutMs` (a host that wants the old 30 s header cut spells it `firstByteTimeoutMs: 30_000`). **@server** `MODEL_CONNECT_TIMEOUT_MS` fed this key through a spread (`src/brain.ts` `timeouts`) — that shape compiles, and `createBrain` throws at construction until the key is dropped; it maps to `firstByteTimeoutMs` now (the env's own doc, "连接超时 … 0=关", described the header wait), and `0` there still means off. **@cli / @client-core** the retry frame for a pre-headers cut now carries `errClass: "stall"` with a detail that names `firstByteTimeoutMs`; a renderer that special-cased "connection lost" for that case sees a stall.
21
+ - **Hard breaking (type face).** ① `BrainTimeoutConfig.connectTimeoutMs` is removed: a literal passing it fails to compile; a spread or a JS caller passing it is refused at run time by all three brain factories (`config.stall_timeout_retired`, the message names `firstByteTimeoutMs`) — the key's presence is refused even with an `undefined` value; the header wait it bounded is `firstByteTimeoutMs`. ② `StallTimeouts.connectMs` is removed and `firstByteMs` added: a literal with `connectMs` fails to compile; a spread or a JS caller that still sends it gets the CALL refused (stop reason `error`, the message names `firstByteMs`, nothing sent); a reader sees `firstByteMs`. ③ `WORKFLOW_AGENT_STALL_MS` is now `firstByteLaneKnobMs(STALL_FIRST_BYTE_MS) + STALL_FIRST_TOKEN_MS` = 1 500 000 (was 630 000): the floor is what one call may legitimately spend producing nothing — the first-byte probe and every widened retry the lane's budget allows (`FIRST_BYTE_RETRY_BUDGET`, `FIRST_BYTE_RETRY_WINDOW_FACTOR`), then the first-token wait — so a workflow's per-agent progress watchdog waits longer before restarting a silent agent (the derivation's whole point: an outer, coarser clock must not fire before the engine's own; the body-dependent upload allowance and the backoff between attempts are not counted, so it is a lower bound). ④ `engines.node` rises from `>=20.3.0` to `>=20.18.1`, the floor `undici@7.30.0` states; Node 20.18.1 is on the same LTS line.
22
+ - **Dependency decision — `undici@7.30.0` (exact) enters `dependencies`.** Node exposes neither its dispatcher classes nor `getGlobalDispatcher`; the npm package does, and it shares the process-wide dispatcher slot with Node's own `fetch` (the documented `setGlobalDispatcher` contract), which is what lets the engine forward to whatever the host installed. undici 6 would have kept `>=18.17` but is the previous major. Stated side effect of the import (KL-1121c): if slot 1 is still empty, undici writes its default `Agent` (the same defaults as Node's own) into both process slots — measured on Node 20.18.1 at import of the package entry (the host's plain `fetch` then uses it too); on Node 24 it depends on load order. Anchored on the reference implementation's shape: an independent first-byte window with its own knob and a one-retry cap, a request-body upload allowance, a widened retry, and the gateway hint — its default for a non-first-party route (300 s) is the default here.
23
+ - **Unchanged here, stated.** A model call whose watchdog is `0` has no bound on that segment at all now (the transport's clocks are 0 too); under the Runner the hour-scale guardrail remains, and the calls outside it (compaction, side-query, memory consolidation, the classifier, prompt suggestions) are listed with their own bounds in `docs/TIMEOUTS.md`. The memory distiller's model call is not on the seat yet (#1127).
24
+ - Pins: `test/backlog1121-model-timeouts.test.ts` (window arithmetic; the chain; the engine dispatcher forwarding to the process dispatcher with the clocks off — pure and on the real transport with a spied host `Agent`; the slot chosen by the handler's Dispatcher-API version — Node's built-in fetch and npm undici 7's fetch reach slot 1 past a v2-only dispatcher in slot 2, a v2 handler reaches slot 2; every other member read off the host while `compose` / `request` keep the clocks off; a host `MockAgent` with a string body matcher in either slot, net connect disabled and enabled, and end to end through a brain — matched, 0 real requests; `isMockActive` true against a real dispatcher delivers the body intact; the retired keys refused by all three factories and per call; a deadline between the transport's headers and `doFetch` returning taken as a stall; the `fetchImpl` init carrying the seat; three fake-gateway arms against a local HTTP server — headers held, headers never, first data held — plus a contrast arm with an undici body clock; the first-byte and idle defaults pinned at the call site; the header-wait disclosure and the truthful closing frame under fake timers). Re-pinned: `test/walltime-stall-watchdog.test.ts`, `test/resilience.test.ts`, `test/backlog682-stall-retry-announcement.test.ts`, `test/stream-options-forwarding.test.ts`, `test/brain-resilience-coverage.test.ts`. Wire sheet: `docs/wire-consumption/unreleased-1121.md`. The timeout table: `docs/TIMEOUTS.md`.
25
+
26
+ ### Type surface — `TaskRegistry.retainedShellIds` removed (#1120 follow-up; +0 wire keys)
27
+
28
+ - **Removed (type surface).** `TaskRegistry.retainedShellIds(env)` is gone. Since 7.32.1 nothing in `src` calls it (the park-only parks share the run-boundary dispose, whose keep-list is `runningShellIds(env)`). No caller was found in the server / cli / client-core trees. Read `runningShellIds(env)` with `list(access)` rows' `retained` instead.
29
+
30
+ ## 7.32.1 — 2026-09-25
31
+
32
+ ### Fix — a park that does not pause the env no longer stops the session's background shells (#1120; behavior; +0 exports, +0 wire keys) (@cli @server)
33
+
34
+ - **Before.** A durable park on a park-only target (a remote env with `capabilities.suspendable: false`, whose workspace persists on the target: a tool ask under `durableApproval`, a plan-review park, a resource-slice park) swept the env keeping only rows already retained. The run's teardown keeps every registered shell row still running on the env. One question, two rules: a session's `run_in_background` loop outlived every turn, yet was stopped the moment the run parked — for example while a plan-approval card was on screen — and its row read `running` until the next watcher poll, then `failed` (measured: the process was already gone at the checkpoint commit). A parked run also stopped the non-retained loops of other sessions sharing the same env.
35
+ - **Now.** One function answers both boundaries: a declared retain lane (the env's or the task's) sweeps nothing; otherwise every registered shell row still running on the env is spared, any owner, and unregistered env shells are disposed. The run's teardown that follows the park still collects the run's own rows that end with it (a session-less direct mount's rows: stop source `parent` where the park's sweep used to mark `env_sweep`). A park that pauses the env (`suspendVM`) still disposes every background process first. The teardown's gate now reads the env's retain declaration too: for an env that honours its own declaration nothing changes (its dispose was already a no-op); an adapter that declares retain on its capability face but disposes anyway now keeps its unregistered shells at a run's end as well.
36
+ - **Who needs it.** A host that declared `TaskSpec.retainBackgroundProcesses` on every turn only to keep a session's loop across an approval card, and any session waiting on a park-only approval.
37
+ - **Who is hurt / compensation.** A deployment that relied on a park to stop a session's non-retained shells — none expected. The session terminal (`reapSessionBackground`) and the exit hard face (`killAllBackgroundShellsNow`) end them as before; a teardown on an isolated env destroyed with the run still spares nothing.
38
+ - **Known limit.** A park-only park resumed in ANOTHER process: the session's loop still runs on the target, but the resuming process does not track it, and the resume's orphan notice lists it as stopped (KL-1120a, #1126; retained rows had this before).
39
+ - Pins: `test/backlog1120-park-sweep-rule.test.ts` (three park lanes × task lane absent / declared on real processes, an unregistered shell as the negative control, a second session sharing the env, a failed park commit, a persistent Monitor, a same-process resume, the parking run's own run-scoped row, the env lane read by the engine's gate, five teardown controls). Contract: `docs/background-process-lifetime.md`. Wire sheet: `docs/wire-consumption/unreleased-1120.md`.
40
+
3
41
  ## 7.32.0 — 2026-09-25
4
42
 
5
43
  ### The ask names WHICH mandate it stands on — `AskRequest.mandate` + `PendingAction.tool_approval.mandate` + `CheckpointSummary.mandate` (#1092; additive; +3 exports `PERSISTED_RULE_MANDATES` / `PersistedRuleMandate` / `isPersistedRuleMandate`; +4 optional type keys (`AskRequest`, the `PermissionResult` ask arm, the park row, the summary); no checkpoint-version bump) (@cli @client-core @server)
@@ -9,7 +9,7 @@ export interface AnthropicBrainConfig extends StreamEngineConfig {
9
9
  version?: string;
10
10
  /** Extra headers merged into every request (e.g. `anthropic-beta`). */
11
11
  headers?: Record<string, string>;
12
- /** Custom fetch (tests / proxies). Defaults to global fetch. */
12
+ /** Custom fetch (tests / proxies); absent ⇒ the engine's default transport — see `resolveFetch`. */
13
13
  fetchImpl?: typeof fetch;
14
14
  /** `max_tokens` when the call/model doesn't specify one (Anthropic requires it). Default 4096. */
15
15
  defaultMaxTokens?: number;
@@ -10,6 +10,8 @@ import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap
10
10
  import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, joinRequestUrl, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
11
  import { MIN_THINKING_TOKENS, budgetCapSkipsThinking, declaredEffortLevels, reasoningBudgetShare, reasoningRequestCarried, resolveEffort } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
+ import { refuseRetiredTimeoutKnobs } from "./timeout.js";
14
+ import { resolveFetch } from "./transport.js";
13
15
  function thinkingBudget(maxTokens, share, fixed, hardCap = false) {
14
16
  const max = hardCap ? maxTokens : Math.max(maxTokens, MIN_THINKING_TOKENS * 2);
15
17
  const want = fixed ?? Math.floor(max * share);
@@ -225,7 +227,8 @@ function closeToolUseBlock(acc) {
225
227
  }
226
228
  export function createAnthropicBrain(config = {}) {
227
229
  assertServableRequestRoot(config.baseUrl, "config.baseUrl");
228
- const doFetch = config.fetchImpl ?? fetch;
230
+ refuseRetiredTimeoutKnobs(config, "construction", "createAnthropicBrain config");
231
+ const doFetch = resolveFetch(config);
229
232
  const cacheBreakpoints = config.cacheBreakpoints !== false;
230
233
  const stream = (model, context, options) => {
231
234
  let thinkingRequested = false;
@@ -7,7 +7,8 @@ export interface OpenResponsesBrainConfig extends StreamEngineConfig {
7
7
  apiKey?: string;
8
8
  /** Extra headers merged into every request (auth + content-type are hard-locked after the merge). */
9
9
  headers?: Record<string, string>;
10
- /** Inject a custom fetch (tests / proxies). Defaults to global fetch. */
10
+ /** Inject a custom fetch (tests / proxies); the default transport and the `fetchImpl` seat are
11
+ * `resolveFetch` (transport.ts). */
11
12
  fetchImpl?: typeof fetch;
12
13
  /**
13
14
  * Replay prior assistant reasoning items back to the endpoint on the turns that carry tool calls
@@ -9,6 +9,8 @@ import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap
9
9
  import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, joinRequestUrl, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
10
  import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
11
11
  import { runStreamingBrain } from "./stream-engine.js";
12
+ import { refuseRetiredTimeoutKnobs } from "./timeout.js";
13
+ import { resolveFetch } from "./transport.js";
12
14
  const DEGENERATE_POLL_CHARS = 64;
13
15
  const MALFORMED_SAMPLE_CHARS = 160;
14
16
  const MALFORMED_ARGS_CHARS = 200;
@@ -218,7 +220,8 @@ function mapTerminalStopReason(incompleteReason, hadToolCalls) {
218
220
  }
219
221
  export function createOpenResponsesBrain(config = {}) {
220
222
  assertServableRequestRoot(config.baseUrl, "config.baseUrl");
221
- const doFetch = config.fetchImpl ?? fetch;
223
+ refuseRetiredTimeoutKnobs(config, "construction", "createOpenResponsesBrain config");
224
+ const doFetch = resolveFetch(config);
222
225
  const stream = (model, context, options) => {
223
226
  let sentMaxTokens;
224
227
  let sentMaxTokensLane = "unset";
@@ -7,7 +7,8 @@ export interface OpenAIBrainConfig extends StreamEngineConfig {
7
7
  apiKey?: string;
8
8
  /** Extra headers merged into every request. */
9
9
  headers?: Record<string, string>;
10
- /** Inject a custom fetch (tests / proxies). Defaults to global fetch. */
10
+ /** Inject a custom fetch (tests / proxies). Absent ⇒ the engine's default transport; the contract for
11
+ * both cases is `resolveFetch` (transport.ts). */
11
12
  fetchImpl?: typeof fetch;
12
13
  /**
13
14
  * For weaker models that emit tool calls as TEXT (`<tool_call>{...}</tool_call>` / fenced json)
@@ -10,6 +10,8 @@ import { BrainError } from "./errors.js";
10
10
  import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, joinRequestUrl, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
11
  import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
+ import { refuseRetiredTimeoutKnobs } from "./timeout.js";
14
+ import { resolveFetch } from "./transport.js";
13
15
  function closeToolCallAccum(acc) {
14
16
  if (acc.closedTc)
15
17
  return acc.closedTc;
@@ -282,7 +284,8 @@ export function inferMaxTokensField(modelId) {
282
284
  }
283
285
  export function createOpenAIBrain(config = {}) {
284
286
  assertServableRequestRoot(config.baseUrl, "config.baseUrl");
285
- const doFetch = config.fetchImpl ?? fetch;
287
+ refuseRetiredTimeoutKnobs(config, "construction", "createOpenAIBrain config");
288
+ const doFetch = resolveFetch(config);
286
289
  const stream = (model, context, options) => {
287
290
  let sentMaxTokens;
288
291
  let sentMaxTokensLane = "unset";
@@ -4,7 +4,7 @@ import { type BrainTimeoutConfig } from "./timeout.js";
4
4
  * Shared streaming engine for the OpenAI- and Anthropic-shaped brains (design/32, consolidation).
5
5
  *
6
6
  * It owns the **bug-prone machinery that was duplicated verbatim** across `openai.ts` and
7
- * `anthropic.ts` — the connect-timeout + retry loop, the three-tier timer logic (first-token + idle
7
+ * `anthropic.ts` — the first-byte window + retry loop, the three-tier timer logic (first-token + idle
8
8
  * watchdog) with their flag resets, the SSE read loop + line framing, the one-shot cleanup, and the
9
9
  * `.catch/.finally` error wrapping. Centralizing the timer logic is the point: the same first-token /
10
10
  * idle reset bug was fixed twice (1.38.2 / 1.40.2) precisely because this lived in two copies.
@@ -231,14 +231,15 @@ export declare function runStreamingBrain(args: {
231
231
  makeParser: (ctrl: StreamControls) => StreamParser;
232
232
  /**
233
233
  * 1.296 件A — per-call ADVISORY stall watchdogs (`StreamOptions.stallTimeouts`). Priority chain:
234
- * brain construction-time config (`BrainTimeoutConfig.connectTimeoutMs`/`firstTokenTimeoutMs`/
235
- * `idleTimeoutMs`) wins whenever PRESENT — including an explicit 0/negative, which means
236
- * "deliberately disabled" — and only an ABSENT (undefined) field adopts the advisory value; both
237
- * absent ⇒ off. `connectMs` bounds the fetch→response-headers wait: the engine's post-headers
238
- * timers can't see a gateway that never returns headers.
234
+ * brain construction-time config (`BrainTimeoutConfig.firstByteTimeoutMs`/`firstTokenTimeoutMs`/
235
+ * `idleTimeoutMs`) wins whenever PRESENT — including an explicit 0, which means "deliberately
236
+ * disabled" — and only an ABSENT (undefined) field adopts the advisory value; both absent ⇒ the
237
+ * engine default constant for that segment (`timeout.ts`). `firstByteMs` bounds the
238
+ * fetch→response-headers wait: the engine's post-headers timers can't see a gateway that never
239
+ * returns headers. The connect clock is not per-call: it is the process dispatcher's (`transport.ts`).
239
240
  */
240
241
  stallTimeouts?: {
241
- connectMs?: number;
242
+ firstByteMs?: number;
242
243
  firstTokenMs?: number;
243
244
  idleMs?: number;
244
245
  };
@@ -6,7 +6,7 @@ import { stripUrlCredentials } from "./route-adjudicator.js";
6
6
  import { classifyInputTooLong } from "./input-too-long.js";
7
7
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
8
8
  import { bindBrainStatusEmitter, emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
9
- import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
9
+ import { createAttemptController, FIRST_BYTE_RETRY_BUDGET, firstByteWindow, refuseRetiredTimeoutKnobs, resolveWatchdogMs, STALL_FIRST_BYTE_MS, STALL_FIRST_TOKEN_MS, STALL_IDLE_MS, } from "./timeout.js";
10
10
  import { readMember, thrownText } from "../core/thrown-value.js";
11
11
  import { redactSecrets } from "../core/untrusted-egress.js";
12
12
  const DEFAULT_MAX_RETRIES = 10;
@@ -250,10 +250,18 @@ async function sleepAnnouncingRetry(totalMs, signal, frame) {
250
250
  return;
251
251
  }
252
252
  }
253
- function midstreamRetryAnnouncement(failure) {
254
- return failure.kind === "stalled"
255
- ? { errClass: "stall", detail: `${failure.watchdog} watchdog fired after ${failure.timeoutMs}ms (${failure.knob}), retrying` }
256
- : { errClass: "transport", detail: "connection lost, reconnecting" };
253
+ function retryAnnouncement(failure) {
254
+ if (failure.kind !== "stalled")
255
+ return { errClass: "transport", detail: "connection lost, reconnecting" };
256
+ const knob = failure.allowanceMs !== undefined && failure.allowanceMs > 0
257
+ ? `${failure.knob} ${failure.timeoutMs - failure.allowanceMs} + ${failure.allowanceMs} upload allowance`
258
+ : failure.knob;
259
+ return { errClass: "stall", detail: `${failure.watchdog} watchdog fired after ${failure.timeoutMs}ms (${knob}), retrying` };
260
+ }
261
+ function firstByteTimeoutMessage(windowsMs) {
262
+ const [first, ...retries] = windowsMs;
263
+ const waited = retries.length === 0 ? `within ${first ?? 0}ms` : `within ${first ?? 0}ms, then ${retries.map((w) => `${w}ms`).join(", then ")} on the retry`;
264
+ return `first-byte timeout: no response headers ${waited} (firstByteTimeoutMs) — if a proxy or gateway holds responses until they complete, raise firstByteTimeoutMs to wait longer`;
257
265
  }
258
266
  export function runStreamingBrain(args) {
259
267
  const { model, doFetch, signal, config, httpLabel, buildRequest, makeParser, stallTimeouts } = args;
@@ -262,6 +270,7 @@ export function runStreamingBrain(args) {
262
270
  let announcedRetry = false;
263
271
  let announcedWait = false;
264
272
  let terminalWaitDetail = "first token arrived";
273
+ let firstTokenArrived = false;
265
274
  let terminalRetryPhase = "recovered";
266
275
  let terminalRetryDetail = "recovered after retrying";
267
276
  let requestIdSeen;
@@ -271,7 +280,13 @@ export function runStreamingBrain(args) {
271
280
  const aborted = signal?.aborted === true || isAbortError(err);
272
281
  terminalRetryPhase = "gave_up";
273
282
  terminalRetryDetail = aborted ? "cancelled while retrying" : "retries exhausted";
274
- terminalWaitDetail = aborted ? "cancelled while waiting for the first token" : "the first-token wait ended in failure";
283
+ terminalWaitDetail = firstTokenArrived
284
+ ? aborted
285
+ ? "cancelled after the first token arrived"
286
+ : "the call failed after the first token arrived"
287
+ : aborted
288
+ ? "cancelled while waiting for the first token"
289
+ : "the first-token wait ended in failure";
275
290
  const errorMsg = emptyAssistant(model);
276
291
  errorMsg.stopReason = aborted ? "aborted" : "error";
277
292
  errorMsg.errorMessage = thrownText(err);
@@ -315,33 +330,83 @@ export function runStreamingBrain(args) {
315
330
  const baseDelay = config.retryDelayMs ?? 500;
316
331
  const deterministicConnectBudget = Math.min(resolveDeterministicConnectRetries(config.deterministicConnectRetries), maxRetries);
317
332
  const deterministicLaneActive = deterministicConnectBudget < maxRetries;
318
- const firstTokenTimeoutMs = resolveStallTimeoutMs(config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs, "firstTokenTimeoutMs");
319
- const idleTimeoutMs = resolveStallTimeoutMs(config.idleTimeoutMs ?? stallTimeouts?.idleMs, "idleTimeoutMs");
320
- const connectTimeoutMs = resolveStallTimeoutMs(config.connectTimeoutMs ?? stallTimeouts?.connectMs, "connectTimeoutMs");
333
+ refuseRetiredTimeoutKnobs(stallTimeouts, "advisory", "StreamOptions.stallTimeouts");
334
+ const firstByteTimeoutMs = resolveWatchdogMs(config.firstByteTimeoutMs, stallTimeouts?.firstByteMs, STALL_FIRST_BYTE_MS, "firstByteTimeoutMs");
335
+ const firstTokenTimeoutMs = resolveWatchdogMs(config.firstTokenTimeoutMs, stallTimeouts?.firstTokenMs, STALL_FIRST_TOKEN_MS, "firstTokenTimeoutMs");
336
+ const idleTimeoutMs = resolveWatchdogMs(config.idleTimeoutMs, stallTimeouts?.idleMs, STALL_IDLE_MS, "idleTimeoutMs");
337
+ const armWaitDisclosure = (ceilingMs) => {
338
+ if (!(ceilingMs > WAITING_FIRST_TOKEN_AFTER_MS))
339
+ return { stop: () => undefined };
340
+ const startedAt = Date.now();
341
+ const emitWaitFrame = bindBrainStatusEmitter();
342
+ let timer;
343
+ let stopped = false;
344
+ const schedule = (delayMs) => {
345
+ timer = setTimeout(() => {
346
+ timer = undefined;
347
+ if (stopped || Date.now() - startedAt >= ceilingMs)
348
+ return;
349
+ announcedWait = true;
350
+ emitWaitFrame({ phase: "waiting_first_token", detail: "waiting for the first token", elapsedMs: Date.now() - startedAt, timeoutMs: ceilingMs });
351
+ schedule(WAITING_FIRST_TOKEN_EVERY_MS);
352
+ }, delayMs);
353
+ };
354
+ schedule(WAITING_FIRST_TOKEN_AFTER_MS);
355
+ return {
356
+ stop: () => {
357
+ stopped = true;
358
+ if (timer)
359
+ clearTimeout(timer);
360
+ timer = undefined;
361
+ },
362
+ };
363
+ };
321
364
  let attempt = 0;
322
365
  let thinkingRetries = 0;
366
+ let firstByteStalls = 0;
367
+ const firstByteWindowsWaited = [];
323
368
  let startEmitted = false;
324
369
  for (;;) {
325
370
  let res;
326
371
  let connect;
327
- for (;; attempt++) {
372
+ for (;;) {
328
373
  if (signal?.aborted) {
329
374
  const e = new Error("aborted");
330
375
  e.name = "AbortError";
331
376
  throw e;
332
377
  }
333
- const cc = createConnectController(connectTimeoutMs, signal);
378
+ const window = firstByteWindow(firstByteTimeoutMs, Buffer.byteLength(req.body), firstByteStalls);
379
+ const cc = createAttemptController(window.windowMs, signal);
334
380
  let r;
335
381
  let netErr;
382
+ let firstByteStall;
336
383
  requestIdSeen = undefined;
337
384
  attemptPartial = undefined;
385
+ const headerWait = armWaitDisclosure(window.windowMs);
386
+ const stalledAtFirstByte = () => {
387
+ firstByteWindowsWaited.push(window.windowMs);
388
+ return {
389
+ kind: "stalled",
390
+ watchdog: "first-byte",
391
+ knob: "firstByteTimeoutMs",
392
+ timeoutMs: window.windowMs,
393
+ allowanceMs: window.allowanceMs,
394
+ err: new BrainError("network", firstByteTimeoutMessage(firstByteWindowsWaited), undefined, { apiError: true }),
395
+ };
396
+ };
338
397
  try {
339
398
  r = await doFetch(req.url, { method: "POST", headers: req.headers, body: req.body, signal: cc.signal });
340
399
  requestIdSeen = readProviderRequestId(r);
400
+ if (cc.timedOut()) {
401
+ discardResponseBody(r);
402
+ r = undefined;
403
+ requestIdSeen = undefined;
404
+ firstByteStall = stalledAtFirstByte();
405
+ }
341
406
  }
342
407
  catch (e) {
343
408
  if (cc.timedOut()) {
344
- netErr = new Error(`connect timeout after ${connectTimeoutMs}ms`);
409
+ firstByteStall = stalledAtFirstByte();
345
410
  }
346
411
  else if (isAbortError(e) || signal?.aborted) {
347
412
  cc.dispose();
@@ -355,6 +420,9 @@ export function runStreamingBrain(args) {
355
420
  netErr = e;
356
421
  }
357
422
  }
423
+ finally {
424
+ headerWait.stop();
425
+ }
358
426
  if (r && r.ok && r.body) {
359
427
  cc.clearTimer();
360
428
  connect = cc;
@@ -364,6 +432,27 @@ export function runStreamingBrain(args) {
364
432
  }
365
433
  cc.clearTimer();
366
434
  try {
435
+ if (firstByteStall !== undefined) {
436
+ if (firstByteStalls >= FIRST_BYTE_RETRY_BUDGET)
437
+ throw firstByteStall.err;
438
+ firstByteStalls++;
439
+ const announcement = retryAnnouncement(firstByteStall);
440
+ const delayMs = retryBackoffMs(baseDelay, firstByteStalls - 1);
441
+ emitBrainTelemetry({ kind: "retry", attempt: firstByteStalls, phase: "connect", errClass: announcement.errClass, nextDelayMs: delayMs });
442
+ announcedRetry = true;
443
+ cc.abort();
444
+ cc.dispose();
445
+ await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
446
+ phase: "reconnecting",
447
+ detail: announcement.detail,
448
+ retryInSec: Math.ceil(remainingMs / 1000),
449
+ retryInMs: remainingMs,
450
+ attempt: firstByteStalls,
451
+ maxRetries: FIRST_BYTE_RETRY_BUDGET,
452
+ errClass: announcement.errClass,
453
+ }));
454
+ continue;
455
+ }
367
456
  let overflowBody;
368
457
  let overflowDeclineNote = "";
369
458
  if (r !== undefined && r.status === 400) {
@@ -402,6 +491,7 @@ export function runStreamingBrain(args) {
402
491
  errClass: "output_cap",
403
492
  ...(namesTheFailure(r?.status) ? { errorStatus: r.status } : {}),
404
493
  });
494
+ attempt++;
405
495
  continue;
406
496
  }
407
497
  overflowDeclineNote =
@@ -468,6 +558,7 @@ export function runStreamingBrain(args) {
468
558
  errClass,
469
559
  ...(retryStatus !== undefined ? { errorStatus: retryStatus } : {}),
470
560
  }));
561
+ attempt++;
471
562
  continue;
472
563
  }
473
564
  if (netErr)
@@ -494,39 +585,14 @@ export function runStreamingBrain(args) {
494
585
  let firstTokenSeen = false;
495
586
  let firedFirstTokenDeadlineMs;
496
587
  let ftTimer;
497
- let waitTimer;
498
- const clearWaitTimer = () => {
499
- if (waitTimer) {
500
- clearTimeout(waitTimer);
501
- waitTimer = undefined;
502
- }
503
- };
588
+ let firstTokenWait = { stop: () => undefined };
504
589
  if (firstTokenTimeoutMs && firstTokenTimeoutMs > 0) {
505
590
  ftTimer = setTimeout(() => {
506
591
  firedFirstTokenDeadlineMs = firstTokenTimeoutMs;
507
- clearWaitTimer();
592
+ firstTokenWait.stop();
508
593
  void reader.cancel().catch(() => undefined);
509
594
  }, firstTokenTimeoutMs);
510
- if (firstTokenTimeoutMs > WAITING_FIRST_TOKEN_AFTER_MS) {
511
- const waitStartedAt = Date.now();
512
- const emitWaitFrame = bindBrainStatusEmitter();
513
- const scheduleWaitFrame = (delayMs) => {
514
- waitTimer = setTimeout(() => {
515
- waitTimer = undefined;
516
- if (firstTokenSeen || firedFirstTokenDeadlineMs !== undefined || Date.now() - waitStartedAt >= firstTokenTimeoutMs)
517
- return;
518
- announcedWait = true;
519
- emitWaitFrame({
520
- phase: "waiting_first_token",
521
- detail: "waiting for the first token",
522
- elapsedMs: Date.now() - waitStartedAt,
523
- timeoutMs: firstTokenTimeoutMs,
524
- });
525
- scheduleWaitFrame(WAITING_FIRST_TOKEN_EVERY_MS);
526
- }, delayMs);
527
- };
528
- scheduleWaitFrame(WAITING_FIRST_TOKEN_AFTER_MS);
529
- }
595
+ firstTokenWait = armWaitDisclosure(firstTokenTimeoutMs);
530
596
  }
531
597
  let firedIdleDeadlineMs;
532
598
  let idleTimer;
@@ -546,19 +612,20 @@ export function runStreamingBrain(args) {
546
612
  if (firstTokenSeen)
547
613
  return;
548
614
  firstTokenSeen = true;
615
+ firstTokenArrived = true;
549
616
  firedFirstTokenDeadlineMs = undefined;
550
617
  if (ftTimer) {
551
618
  clearTimeout(ftTimer);
552
619
  ftTimer = undefined;
553
620
  }
554
- clearWaitTimer();
621
+ firstTokenWait.stop();
555
622
  };
556
623
  cleanup = () => {
557
624
  if (ftTimer) {
558
625
  clearTimeout(ftTimer);
559
626
  ftTimer = undefined;
560
627
  }
561
- clearWaitTimer();
628
+ firstTokenWait.stop();
562
629
  if (idleTimer) {
563
630
  clearTimeout(idleTimer);
564
631
  idleTimer = undefined;
@@ -631,7 +698,7 @@ export function runStreamingBrain(args) {
631
698
  clearTimeout(ftTimer);
632
699
  ftTimer = undefined;
633
700
  }
634
- clearWaitTimer();
701
+ firstTokenWait.stop();
635
702
  if (idleTimer) {
636
703
  clearTimeout(idleTimer);
637
704
  idleTimer = undefined;
@@ -678,7 +745,7 @@ export function runStreamingBrain(args) {
678
745
  throw failure.err;
679
746
  cleanup?.();
680
747
  cleanup = undefined;
681
- const announcement = midstreamRetryAnnouncement(failure);
748
+ const announcement = retryAnnouncement(failure);
682
749
  if (snap.hasOnlyThinking) {
683
750
  if (thinkingRetries >= THINKING_RETRY_BUDGET)
684
751
  throw failure.err;