@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 +38 -0
- package/dist/brain/anthropic.d.ts +1 -1
- package/dist/brain/anthropic.js +4 -1
- package/dist/brain/open-responses.d.ts +2 -1
- package/dist/brain/open-responses.js +4 -1
- package/dist/brain/openai.d.ts +2 -1
- package/dist/brain/openai.js +4 -1
- package/dist/brain/stream-engine.d.ts +8 -7
- package/dist/brain/stream-engine.js +111 -44
- package/dist/brain/timeout.d.ts +112 -53
- package/dist/brain/timeout.js +39 -5
- package/dist/brain/transport.d.ts +13 -0
- package/dist/brain/transport.js +62 -0
- package/dist/core/background-shell.d.ts +24 -8
- package/dist/core/background-shell.js +4 -3
- package/dist/core/runner/prepare-boundary-parks.js +3 -3
- package/dist/core/runner/prepare-park-ask.js +2 -2
- package/dist/core/runner/prepare-task.js +2 -2
- package/dist/core/runner/run-settle-and-teardown.js +3 -5
- package/dist/core/task-event.d.ts +7 -5
- package/dist/core/task-registry-shared.d.ts +1 -1
- package/dist/core/task-registry.d.ts +3 -6
- package/dist/core/task-registry.js +0 -9
- package/dist/engine/llm/types.d.ts +5 -5
- package/dist/orchestration/workflow.d.ts +4 -4
- package/dist/orchestration/workflow.js +2 -2
- package/package.json +6 -3
- package/wire-changes/core@7.32.1.json +78 -0
- package/wire-changes/core@7.33.0.json +351 -0
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)
|
|
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;
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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";
|
package/dist/brain/openai.d.ts
CHANGED
|
@@ -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).
|
|
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)
|
package/dist/brain/openai.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
235
|
-
* `idleTimeoutMs`) wins whenever PRESENT — including an explicit 0
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* timers can't see a gateway that never
|
|
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
|
-
|
|
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 {
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 =
|
|
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
|
-
|
|
319
|
-
const
|
|
320
|
-
const
|
|
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 (;;
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
592
|
+
firstTokenWait.stop();
|
|
508
593
|
void reader.cancel().catch(() => undefined);
|
|
509
594
|
}, firstTokenTimeoutMs);
|
|
510
|
-
|
|
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
|
-
|
|
621
|
+
firstTokenWait.stop();
|
|
555
622
|
};
|
|
556
623
|
cleanup = () => {
|
|
557
624
|
if (ftTimer) {
|
|
558
625
|
clearTimeout(ftTimer);
|
|
559
626
|
ftTimer = undefined;
|
|
560
627
|
}
|
|
561
|
-
|
|
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
|
-
|
|
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 =
|
|
748
|
+
const announcement = retryAnnouncement(failure);
|
|
682
749
|
if (snap.hasOnlyThinking) {
|
|
683
750
|
if (thinkingRetries >= THINKING_RETRY_BUDGET)
|
|
684
751
|
throw failure.err;
|