@sema-agent/core 7.35.1 → 7.36.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 +85 -0
- package/dist/brain/anthropic.js +6 -6
- package/dist/brain/errors.d.ts +1 -14
- package/dist/brain/errors.js +2 -6
- package/dist/brain/open-responses.js +7 -7
- package/dist/brain/openai.js +7 -7
- package/dist/brain/provider-echo-shape.d.ts +26 -0
- package/dist/brain/provider-echo-shape.js +12 -0
- package/dist/brain/stream-engine.d.ts +2 -1
- package/dist/brain/stream-engine.js +28 -14
- package/dist/brain/tool-call-id.d.ts +27 -15
- package/dist/brain/tool-call-id.js +43 -0
- package/dist/core/ask-question.d.ts +0 -30
- package/dist/core/ask-question.js +3 -2
- package/dist/core/checkpoint-store.d.ts +5 -3
- package/dist/core/checkpoint-store.js +2 -1
- package/dist/core/context-edit.d.ts +4 -0
- package/dist/core/context-edit.js +5 -2
- package/dist/core/engine-notice.d.ts +7 -0
- package/dist/core/gate-lanes.js +12 -10
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hooks.d.ts +3 -0
- package/dist/core/hooks.js +4 -2
- package/dist/core/mode-policy.js +2 -1
- package/dist/core/permission-rule-model.d.ts +2 -2
- package/dist/core/permission-rule-model.js +2 -2
- package/dist/core/permission-rules.js +2 -5
- package/dist/core/runner/active-skill-scope.js +5 -5
- package/dist/core/runner/decide-continuation.js +2 -1
- package/dist/core/runner/permission-rule-lanes.d.ts +3 -0
- package/dist/core/runner/permission-rule-lanes.js +7 -6
- package/dist/core/runner/prepare-context-lane.d.ts +4 -0
- package/dist/core/runner/prepare-context-lane.js +7 -5
- package/dist/core/runner/prepare-gate-stations.js +17 -12
- package/dist/core/runner/prepare-park-ask.d.ts +2 -0
- package/dist/core/runner/prepare-park-ask.js +7 -6
- package/dist/core/runner/prepare-policy-chain.d.ts +2 -0
- package/dist/core/runner/prepare-policy-chain.js +10 -9
- package/dist/core/runner/prepare-safety-scan.js +9 -2
- package/dist/core/runner/prepare-task.js +3 -3
- package/dist/core/runner/resume-apply.js +2 -1
- package/dist/core/runner/run-attachment-seats.js +3 -1
- package/dist/core/runner/run-harness-handlers.js +6 -1
- package/dist/core/runner/run-leg.js +11 -9
- package/dist/core/runner/run-recovery-lanes.js +2 -0
- package/dist/core/runner/session-rule-policy.js +5 -4
- package/dist/core/runner/tool-call-id-fold-seat.d.ts +13 -0
- package/dist/core/runner/tool-call-id-fold-seat.js +31 -0
- package/dist/core/sensitive-path-policy.js +2 -2
- package/dist/core/side-query.d.ts +5 -1
- package/dist/core/side-query.js +5 -1
- package/dist/core/task-event.d.ts +2 -1
- package/dist/core/tool-name-readings.d.ts +366 -0
- package/dist/core/tool-name-readings.js +136 -0
- package/dist/core/tool-policy.d.ts +5 -5
- package/dist/core/tool-policy.js +15 -13
- package/dist/core/tool-registry.d.ts +1 -31
- package/dist/core/tool-registry.js +2 -22
- package/dist/core/tool-result-budget.d.ts +3 -0
- package/dist/core/tool-result-budget.js +5 -3
- package/dist/core/tool-roster.d.ts +30 -9
- package/dist/core/tool-roster.js +45 -5
- package/dist/engine/llm/types.d.ts +4 -0
- package/dist/tools/fs/file-read-check.d.ts +1 -1
- package/dist/tools/fs/file-read-check.js +4 -3
- package/dist/tools/fs/shell-read-check.d.ts +15 -1
- package/dist/tools/fs/shell-read-check.js +5 -2
- package/package.json +2 -1
- package/wire-changes/core@7.35.2.json +61 -0
- package/wire-changes/core@7.36.0.json +288 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
+
## 7.36.0 — 2026-09-30
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes — the whole release in three sentences.**
|
|
4
|
+
|
|
5
|
+
① *Who is affected.* (design/1209) A host whose PostToolUse / PostToolUseFailure hook (or hook matcher) keys on an alias spelling (PostToolBatch is untouched: its entries keep the spelling, as the reference's do); a deployment that declares one tool under the names of two engine tools (any two engine tool names count, seat-bearing or not: `Custom{aliases:["NotebookEdit","Write"]}`, `NotebookEdit{aliases:["Write"]}`, `Read{aliases:["Bash"]}` — a tool's own `pathTarget` does not exempt it); a caller tool that declares a low irreversibility tier while wearing `Bash` / `Monitor` as an alias; a caller tool that claims an engine hand tool's name as an alias (`Charge{effect:"write", aliases:["Read"]}`); a reader of a park row's `names` order or of a resumed call's `tool_start` / `tool_end` name. (#1207) A host, tool or store that keys on a tool-call id and is served by a gateway whose ids fall outside visible ASCII 1–128 or contain `~` (it now receives the engine's fold, not the provider's bytes, on every frame, card, park row and transcript line); a tool that reads the transcript's `ToolCall.id` as the provider's own id; a host replaying a side query's returned calls to a provider; a consumer with a closed table of notice codes (server / cli / client-core: +1 `route.tool_call_id_folded`).
|
|
6
|
+
|
|
7
|
+
② *How to change.* (design/1209) Match PostToolUse / PostToolUseFailure on the tool's wire name (during a mixed-version rollout, list both the wire name and the alias); leave a PostToolBatch matcher on the spelling (its entries do not move); keep ONE engine tool's names on a tool (drop the second alias); drop the `Bash` / `Monitor` alias from a tool that must not sit behind the engine's shell seat, or accept the seat; do not claim an engine hand tool's name as an alias; read a park row's `names` as "the spelling first", and pair a resumed call's frames by `toolCallId`. (#1207) Key everything on the engine id (`ToolCall.id`, `toolCallId` on every frame, card and row); read the provider's spelling from `ToolCall.wireId` / `SideQueryResult.toolCalls[].wireId` (present only when the two differ) and hand the provider `wireId ?? id` (replaying through another side query: carry both on the `toolCall` block, key the result by `id`); add the notice row (`operator`).
|
|
8
|
+
|
|
9
|
+
③ *If you do not.* (design/1209) An alias matcher on PostToolUse / PostToolUseFailure no longer fires (post-execution face only — no decision is widened); a declaration answering to two engine tools refuses the run at prepare with `config.tool_face_invalid` (no turn runs; a call parked under such a tool stays parked until the declaration changes); the shell seat's question now reaches a low-tier tool that wears `Bash` (`default` 0 → 1 card, `dontAsk` ran 1 → 0); an interrupted call spelled a claimed hand tool's name reads the conservative "confirm with the user before repeating it" wording, and the faces that admit a call by its declared effect under that name treat it as the less repeat-safe effect. (#1207) Nothing breaks for an id in shape: every `toolu_…` / `call_…` / uuid id is admitted verbatim (no fold, no `wireId`, no notice), so every face and every request is byte-identical to v7.35.1's; for a folded id the provider is still handed its own spelling for the call, its result, an image carrier's label and a dropped-call note (only engine handles minted from the call id — an offloaded result's `ref` — are minted from the fold); a host that replays a side query's folded call to a provider under `id` alone hands it the fold, not its own id; a notice table missing the row reads the code as an unknown one.
|
|
10
|
+
|
|
11
|
+
**Closed sets, one by one.**
|
|
12
|
+
- `ENGINE_NOTICE_CODES` / `EngineNoticeCode` (#1207): +`route.tool_call_id_folded` (audience `operator` in `NOTICE_AUDIENCE`).
|
|
13
|
+
- design/1209: no closed set moves — its prepare refusal is `config.tool_face_invalid`, already in `TOOL_DECLARATION_CODES`.
|
|
14
|
+
|
|
15
|
+
**Package exports** (`npm run handoff:diff v7.35.1 HEAD`): +0 / −0. Four public types gain one optional member each: `ToolCall.wireId?: string`, `SideQueryResult.toolCalls[].wireId?: string` (#1207, one meaning: the provider's spelling, present only on a fold); `AggregateBudgetOptions.toolNameOf?`, `ContextEditOptions.toolNameOf?` (design/1209; absent ⇒ the recorded name, as before). The wire list carries three of the four (`ToolCall` +`wireId`, `AggregateBudgetOptions` / `ContextEditOptions` +`toolNameOf`, each optional); the fourth sits on an inline array element the list does not read — stated here by hand. design/1209's read ports and table (`src/core/tool-name-readings.ts`) are not exported; `ruleNamesOf` stays exported (now declared beside the ports); the retired call-site helpers (`callAnswersTo`, `toolNameSetHas`, `anyNamed`, `firstNamed`) were never package exports. #1207's `admitToolCallId` and the fold are internal (`src/brain/tool-call-id.ts`).
|
|
16
|
+
|
|
17
|
+
**The wire list's BREAKING items** (`node scripts/wire-changes.mjs v7.35.1 HEAD --version 7.36.0`), one per line:
|
|
18
|
+
- none — 0 BREAKING items. The non-breaking moves it carries: closed set gained a member `ENGINE_NOTICE_CODES` +`route.tool_call_id_folded` (#1207); a new module-level closed set `ToolCallIdFoldReason` (`charset` / `length` — the notice detail's `reason`, #1207; not a package export); type gained optional keys `ToolCall` +`wireId` (#1207), `AggregateBudgetOptions` +`toolNameOf`, `ContextEditOptions` +`toolNameOf` (design/1209).
|
|
19
|
+
- not on the wire list (an inline array element): `SideQueryResult.toolCalls[]` +`wireId?` (#1207) — stated here by hand.
|
|
20
|
+
- value moves on existing faces the list cannot read, from the criteria: F1 (the tier seat), F2 (the post hooks' name), F3 (the declaration refusal), F4 (a park row's `names` order, a resumed call's frame name), F5 / F6 (the effect and result-name readings), F7 (the declaration-time effect table), F8 (a folded tool-call id on every engine face).
|
|
21
|
+
|
|
22
|
+
### Every per-call site that resolves a call's names reads them one declared way — one table, four readings, typed ports (display and hint sites that only echo the spelling stay outside it: KL-1209-h) (design/1209; behavior (tighten, and the post-execution hooks' name); +0 exports, +2 optional option members (`AggregateBudgetOptions.toolNameOf`, `ContextEditOptions.toolNameOf`), +0 codes, +0 wire keys) (@server @cli @test)
|
|
23
|
+
|
|
24
|
+
- **Before (v7.35.0).** The gate resolved a call's name to the wire name, and each of 41 sites keyed on an engine tool name decided on its own whether to read that name or every name the call answers to — in a comment, and in which of five helpers it called; five sites compared the call's name with an engine name literally. The same review round found three defects of that family.
|
|
25
|
+
- **Now.** `src/core/tool-name-readings.ts` holds ONE table (`NAME_READINGS`, 56 rows: the design's 41, 12 its program scan found, 1 the release review found (the batch hook entry, a spelling row), 2 the review round found) giving each site one of four readings — `any` (a tighten held under every name the call answers to; a set-valued table read as the union), `seat` (a multi-key seat read under ONE name: `tier` = the name whose irreversibility tier is strictest, `member` = the first name the table holds), `wire` (a grant or a routing: the wire name only), `spelling` (a record of what the model said; no port) — and the ports `heldBy` / `heldValues` / `seatOf` / `wireOf`, which take a site id typed by the table's kind: a site read through another kind's port does not compile. `gate:name-readings` (in `gate:all`) reads the program for the ways around the ports: a name-keyed table read outside them, a call's name compared with an engine name without `wireOf`, a site id spelled twice.
|
|
26
|
+
- **The tier seat is the strictest name, with the source band.** *Before:* the gate, the rule-offer door, the park row's risk descriptor and an ask's `riskAxes` read the seat under the FIRST of the call's names holding a tier — `MyShell{irreversibility:"never", aliases:["Bash"]}` called as `MyShell` read `never` and never met the engine's `Bash` seat. *Now:* the seat is the name whose tier is strictest (`never < maybe < always`, absent lowest; ties to the earlier name); its probe and its shell-seat provenance are read under that name; the four sites choose the same name. `shellGated` holds only when no name the call answers to carries a deployment mark (the gate's mark sources are now the union over the call's names). *Who is hurt:* a caller tool that declares a low tier and wears `Bash` / `Monitor`: `rm -rf <root>/y` under `default` asks (0 → 1), under `dontAsk` is refused (ran 1 → 0).
|
|
27
|
+
- **PostToolUse / PostToolUseFailure receive the wire name** (the reference: the post hook is passed the resolved tool's name). *Before:* they received the model's spelling while PreToolUse received the wire name. *Now:* both receive the wire name. **PostToolBatch keeps the spelling** — deliberately, as the reference does (its PostToolBatch payload maps each tool_use block's own name, while its PostToolUse is passed the resolved tool): the batch entry is now a table row (`hook.postToolBatch`, spelling), so the gate sees it — the release review found the projection outside the table; `tool_start` / `tool_end`, the transcript and the activity stream keep the spelling. *Who is hurt:* a post hook or matcher written against an alias spelling (see ①–③).
|
|
28
|
+
- **One order of a call's names; the park row carries the gate's.** *Before:* the gate's face listed the spelling first, the park row's `names` the wire name first. *Now:* the park row copies the names of the face the gate judged the call under (the spelling first); a resume re-mints its face under that recorded spelling only while it still resolves to the row's own tool — a spelling that disappeared, or that another tool now owns, gives the row's wire name (never another tool's face). A resumed call's `tool_start` / `tool_end` carry the recorded spelling (`names[0]`, else the row's name). A row parked by 7.34.0 or earlier (no `names`) reads as before (KL-1194-a); a row parked by 7.35.0 carries the wire name first, which re-mints as it did.
|
|
29
|
+
- **The write verification reads the tool's effect under the wire name** (live and on the resume seed); the orphan reconcile keeps reading the recorded name.
|
|
30
|
+
- **A result's budget exemption and its clearability are judged on the tool the call ran.** *Before:* the per-turn result budget exempted a result whose RECORDED name was in the catalog's exempt set (`Read`), and the micro-compaction pass cleared a result whose recorded name was compactable — so a caller tool wearing `Read` called as `Read` escaped the budget (a 300 000-character result stayed whole) while the same tool called under its own name was capped. *Now:* the Runner hands both passes the roster's resolution (`toolNameOf`), and the set is read under the wire name — the engine's own `Read` stays exempt, a tool wearing its name is capped under either spelling; clearing likewise reaches only the engine's compactable tools. A host calling `capAggregateToolResults` / `clearStaleToolResults` without `toolNameOf` reads the recorded name as before.
|
|
31
|
+
- Pins: `test/name-readings-oracle.test.ts` (the table deep-equals the frozen oracle `test/fixtures/name-readings.oracle.json`; every row has a cell judged by the ORACLE's kind — site / real-lane / port cells; the four compile-time bind pins); `test/name-readings-behavior.test.ts` (the four seat sites agree, the tier counterexample on a real lane, the source band, the three fates of a parked alias, the post hooks' name); `test/name-readings-gate.test.ts` (the gate on the tree, and every way around the ports planted in memory — red at its line). Mutations D-a…D-j (D-g is the declaration refusal of the BREAKING section below; D-k is the effect table's): `.gate-logs/criteria-7.36.0-mutations.md`, its design/1209 part.
|
|
32
|
+
|
|
33
|
+
### An interrupted call is never reconciled as safe to repeat on a hand tool's word when a caller tool claims that name (pre-existing, fixed in this release; design/1209 review round 2; behavior (tighten); +0 exports) (@test)
|
|
34
|
+
|
|
35
|
+
- **Before (≤ v7.35.0).** The declaration-time effect table first recorded a caller tool's effect under each alias it claims, then registered the hand band's effects unconditionally over them: `Charge{effect:"write", aliases:["Read"]}` with the engine's `Read` excluded filed `Read` as `read`, so a `Read`-spelled call to `Charge` interrupted mid-run was reconciled as "read-only/idempotent … simply call it again" — a write the model was told to repeat.
|
|
36
|
+
- **Now.** A name a caller tool claims as an alias AND a hand tool owns records the LESS repeat-safe of the two effects (`write` over `idempotent` over `read`; a claimer that declares none is a write), whichever tool the roster later dispatches it to: the interrupted call reads the UNKNOWN wording ("confirm with the user before repeating it"). A name no caller claims keeps the hand's own effect. *Who is hurt:* where a caller tool also claims an engine hand tool's name as an alias — an interrupted call spelled that name reads the conservative wording, and the faces that admit a call by its declared effect under that name (a session's `allowDirs`, a skill's `allowPaths`, the final-verification seat) treat it as the less repeat-safe effect (the same over-conservative direction the effect default already takes) — only where the name's mount does not re-register its effect: with the engine's own hand tool mounted under that name, its mount re-registers the hand's effect and those three faces read it (the conservative wording then reaches only the interrupted-call note). Compensation: do not claim an engine hand tool's name as an alias.
|
|
37
|
+
- Pin: `test/name-readings-behavior.test.ts` (the declaration-time effect table block); mutation D-k.
|
|
38
|
+
|
|
39
|
+
### BREAKING (declaration) — a tool whose names belong to two engine tools is refused at prepare (design/1209 §3, #1208; `config.tool_face_invalid`, an existing code; +0 exports) (@server @test)
|
|
40
|
+
|
|
41
|
+
- **Before (v7.35.0).** `Custom{aliases:["NotebookEdit","Write"]}` declaring no path target was judged on the slot of the name it was called by — called as `NotebookEdit` with the denied path in `file_path`, it ran (KL-1194-d); `Read{aliases:["Bash"]}` joined only the first read station its names met.
|
|
42
|
+
- **Now.** The roster counts, per row, the engine tools its RESOLVED names belong to (its wire name and the aliases that resolve to it: an alias another row owns is not counted; several names of one engine tool — `ListAgents` / `ListPeers` — count once). Two or more ⇒ prepare fails with `config.tool_face_invalid`, before any turn and before a resume's identity comparison. A tool that declares its own `pathTarget` under two engine names is refused too (the literal form). `Custom{aliases:["Read","Bash"]}` beside the engine's mounted `Read` is not refused: `Read` resolves to the engine's row.
|
|
43
|
+
- *Who is hurt / compensation:* see ①–③ above — keep one engine tool's names.
|
|
44
|
+
|
|
45
|
+
### A provider's tool-call id is admitted once, where the brain builds the call: in shape it is the key, out of shape it is folded — the provider always gets its own id back (#1207; behavior (a folded id on every engine face) + closed set +1 notice code; +0 exports, +2 optional type members, one meaning (`ToolCall.wireId`, `SideQueryResult.toolCalls[].wireId`)) (@server @cli @client-core @test)
|
|
46
|
+
|
|
47
|
+
- **Before (v7.35.1).** The three built-in brains copied the provider's id into `ToolCall.id` as sent. An id outside the provider echo shape — a control sequence (`call_\u001b]0;…\u0007`), a non-ASCII unit, a space, 129+ characters — became the key of every engine face (tool frames, approval cards and askIds, the tool-result join, the park row, the transcript) and reached the wire as upstream bytes. Only a missing id had a rule (the lane's fallback).
|
|
48
|
+
- **Now.** One admission, `admitToolCallId(raw, prefix)` (`src/brain/tool-call-id.ts`), at the one place each brain builds a call (anthropic `closeToolUseBlock`, openai `closeToolCallAccum`, open-responses `closeFunctionCall`), returning two ids:
|
|
49
|
+
- absent (a non-string or `""`) ⇒ the lane's fallback (`toolu_…` / `call_…`), both ids — unchanged;
|
|
50
|
+
- admitted — visible ASCII 0x21–0x7E, 1–128 (`isProviderEchoShape`), and no `~` ⇒ both ids are the raw string — unchanged;
|
|
51
|
+
- otherwise FOLDED ⇒ the engine id is a readable base (the raw's first 32 code units, each one outside the admitted alphabet written `-`) + `~` + 32 hex of sha256 over the raw's UTF-16 code units — always in shape, always carrying one `~`, which the admitted arm refuses, so a fold never equals an admitted id; two different raws fold apart (lone surrogates included); one raw folds to one id in every lane and every run. The raw rides beside it as `ToolCall.wireId`.
|
|
52
|
+
- **The provider always gets its own id.** Each brain hands a call back under `wireId ?? id` and a result under the `wireId` of the call it answers, read off the same request's messages (no side table): anthropic `tool_use.id` / `tool_result.tool_use_id`, openai `tool_calls[].id` / `tool_call_id`, open-responses `function_call.call_id` / `function_call_output.call_id`. `wireId` is persisted with the assistant message, so a resumed run — in another process, after a compaction — hands back the same spelling and never folds again; a row parked by v7.35.x (the raw id as the key, no `wireId`) resumes under that raw id.
|
|
53
|
+
- **A side query's tool calls carry the provider's spelling too.** `SideQueryResult.toolCalls[]` gains an optional `wireId` — present only when it differs from `id`, read from the same `ToolCall.wireId`. The replay rule: hand the provider `wireId ?? id`; replaying through another side query, carry both on the `toolCall` block and key the result by `id` (the brain then hands the provider `wireId`). Absent ⇒ `id` is the provider's own. (@server: the S-889 side-query consumer.)
|
|
54
|
+
- **One notice per run**, `route.tool_call_id_folded` (operator), on the first folded call: `detail: { toolCallId, rawLength, reason: "charset" | "length", sessionId, runId }` — the engine id, never the raw bytes. Read off `ToolCall.wireId` by a runner seat, so it is the same reading for every brain.
|
|
55
|
+
- **Model-facing text names the provider's spelling too.** The label of an image carried beside a tool result (openai / open-responses: `[Image from tool result <id>]`) names the result's provider id; the note a brain adds for a dropped call (a call with no name: `id="…"`, replayed to the provider as assistant text) keeps naming the id as the provider sent it (unchanged).
|
|
56
|
+
- `PROVIDER_ECHO_SHAPE` / `isProviderEchoShape` move to a leaf module (`src/brain/provider-echo-shape.ts`, re-exported from `errors.ts` as before) so the admission stays a leaf; the constant, the predicate and the package exports are unchanged.
|
|
57
|
+
- **Who is hurt.** A consumer that relied on seeing a gateway's out-of-shape bytes as the tool-call id on a frame, card, row or transcript line now sees the fold; the bytes are on `ToolCall.wireId` in the transcript (upstream bytes, never sanitized — render `id`). A host `Brain` is not admitted: its ids enter as it wrote them (KL-1207-a). Compensation: the notice, the deterministic fold (one raw, one id — joinable across runs), and the provider face: every provider-minted id the request carries is the provider's own spelling.
|
|
58
|
+
- Pins: `test/backlog1207-tool-call-id-admission.test.ts` — a real Runner over each of the three brains, the provider a fetch stub, reading frames, notices, the persisted transcript and the request bodies: G1 admitted ids (four shapes) identical on every face, no notice; G2 five out-of-shape shapes folded in shape on every engine face, handed back as sent, one notice per run (two folds, one notice), deterministic across runs; G3 the fallback unchanged; G4 two 129-character ids differing in their last two characters fold apart and both join; G5 a folded and an admitted call side by side — the request never names the fold; a folded call beside a nameless one — the replayed drop note names the provider's spelling; a result carrying an image — the carrier's label names the provider's id; G6 a folded call parked, the process gone, a fresh Runner over the same files resumes it: the call executes under the parked engine id and the provider gets its own id back for the call and its result, no notice again; G6b a row parked with the raw id as its key resumes under it; G6c a compaction between the resumed call and the next request keeps the parked call verbatim (a tool result is never a cut point, so a call and its result are kept or summarized together) and the next request still hands the provider its own id. `test/backlog1207-tool-call-id-unit.test.ts` — U1 the three arms and both fold reasons; U2 injectivity (past the readable base; lone surrogates vs U+FFFD), disjoint arms, determinism, lane independence; U3 the outbound reading; U4 the notice seat (once per run; a host brain's in-shape `wireId` is not a fold); U5 the drop note on all three lanes names the provider's spelling; U6 a side query over the openai brain returns `{ id: <fold>, wireId: <raw> }`, and replaying it through a second side query hands the provider the raw for the call and its result (an admitted id: no `wireId` key). v7.35.1 (2c39696fe) on the first file: G2, G4, G6 red (24), G1, G3, G5 green (21).
|
|
59
|
+
- Mutations (`.gate-logs/criteria-7.36.0-mutations.md`, its #1207 part, per fixture): D970 no fold; D971 / D972 a call / a result handed back under the engine id; D973 the fold without its digest; D974 a fold salted per call; D975 `wireId` kept only in memory; D976 a notice per call; D977 no notice; D978 `~` admitted verbatim; D979 the drop note names the fold; D980 the image carrier's label names the engine id; D981 the admitted arm removed; D982 the empty string folds; D983 the side-query projection drops `wireId`.
|
|
60
|
+
- Known limits: KL-1207-a (a host brain's ids are not admitted). KL-1207-b (a side query returned only the fold) closed in this entry by `SideQueryResult.toolCalls[].wireId`.
|
|
61
|
+
|
|
62
|
+
## 7.35.2 — 2026-09-29
|
|
63
|
+
|
|
64
|
+
### A wait the watchdog cuts at the 30 s disclosure threshold is disclosed before the cut (#746; behavior (status frames: +1 `waiting_first_token` frame per attempt when a first-token or first-byte window is exactly the 30 s threshold); +0 exports, +0 wire keys, +0 codes, closed sets unchanged) (@cli @client-core @server @test)
|
|
65
|
+
|
|
66
|
+
- **Before (v7.35.1).** Measured by cli against a running engine (L-778, five scenarios: a gateway that sends headers at once and its first token 45 s later, or holds its headers 45 s). With the first-token watchdog at 30 s (`firstTokenTimeoutMs`; server env `MODEL_FIRST_TOKEN_TIMEOUT_MS`), a request waited 30 s with no `status` frame at all and was then cut. The wait disclosure was armed only for a ceiling strictly above its 30 s threshold, so the one tick that fell on the deadline was never sent. At 40 s, or at the 600 s default, the 30 s frame was there. A first-byte window of exactly 30 s (knob plus upload allowance) had the same gap.
|
|
67
|
+
- **Now.** The wait disclosure is armed whenever its ceiling reaches the threshold (`>=`, the watchdog's own comparison). When the watchdog fires, a wait not yet disclosed is disclosed at that instant — `waiting_first_token` with `elapsedMs` equal to `timeoutMs` — and the retry (or terminal) frame follows. The deadline owns that frame, so which timer runs first no longer decides whether it is sent. This holds for the first-token wait and the header wait alike. Re-announcements are unchanged: they stay strictly before the ceiling, so a 120 s watchdog still shows 30 / 60 / 90 s and no frame at 120 s.
|
|
68
|
+
- **Not changed.** The retry after a watchdog cut keeps `phase: "reconnecting"` with `errClass: "stall"`. The shells already tell the two apart by `errClass`: cli L-264 renders it as "No output before the stream timeout · slow model or gateway", through client-core's `stalled` arm. The engine does not rename the phase. The header wait and the first-token wait still share `waiting_first_token`; a separate phase word would be a wire change.
|
|
69
|
+
- **Readings (engine fixtures of cli's scenarios, fake clock).**
|
|
70
|
+
- A (token 45 s, watchdog 30 s, every request slow): 0 → 11 wait frames, one per attempt, each 30000 of 30000, each before its attempt's `reconnecting` / `stall` frame. The call still ends `gave_up` / `first-token timeout after 30000ms`.
|
|
71
|
+
- A2 (only the first request slow): `reconnecting, recovered` → `waiting_first_token, reconnecting, recovered`.
|
|
72
|
+
- B′ (header window exactly 30 s): the same.
|
|
73
|
+
- B (headers 45 s, first-byte 30 s + 3 s upload allowance), C (the 600 s default) and D (40 s): unchanged.
|
|
74
|
+
- **Who is hurt.** A deployment whose first-token (or first-byte) window is exactly 30 s sees one more frame per attempt, at the deadline. The frame is closed by `recovered` / `gave_up` as before. client-core maps it to `kind: 'waiting'` and cli renders its waiting byline. A probe that counted zero wait frames under a 30 s watchdog goes red. The engine defaults are unchanged.
|
|
75
|
+
- Pins: `test/backlog746-first-token-watchdog-phase.test.ts`:
|
|
76
|
+
- A ① every attempt's wait is disclosed at its deadline; A ② control: the retries and the end; A ③ an event loop stalled across the deadline still gets exactly one wait frame before the retry.
|
|
77
|
+
- A2, B′ (the full trails), B, C, D (controls).
|
|
78
|
+
- Baseline v7.35.1: A ①, A ③, A2 and B′ red; A ②, B, C and D green.
|
|
79
|
+
- Unchanged and green: `backlog530-waiting-first-token`, including the 120 s cell and the stalled-event-loop cell.
|
|
80
|
+
- Mutations:
|
|
81
|
+
- D960, the disclosure arms only above the threshold (v7.35.1's `>`): A ①, A ③, A2 and B′ go red.
|
|
82
|
+
- D960b, the deadline does not disclose (`expire` does what `stop` does): the same four go red. Per site: the first-token watchdog calling `stop` turns A ①, A ③ and A2 red; the header `finally` always stopping turns B′ red.
|
|
83
|
+
- D960c, the deadline discloses a wait that was already disclosed: the controls go red (D, B, and `backlog530` ④ / ⑥ / ④c).
|
|
84
|
+
- Per-fixture table: `.gate-logs/criteria-7.35.2-mutations.md`.
|
|
85
|
+
|
|
1
86
|
## 7.35.1 — 2026-09-29
|
|
2
87
|
|
|
3
88
|
### A run that named no session holds the session its store minted — a second run on that id waits for it (#1206; behavior (tighten: a second run of a new session queues); +0 exports, +0 wire keys; +1 engine-invariant code `internal.session_lock_second_id` that no engine path reaches) (@server @test)
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -3,7 +3,7 @@ import { BrainError } from "./errors.js";
|
|
|
3
3
|
import { startsWithInputTooLongAnchor } from "./input-too-long.js";
|
|
4
4
|
import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
|
|
5
5
|
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
|
|
6
|
-
import {
|
|
6
|
+
import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
|
|
7
7
|
import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
|
|
8
8
|
import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
|
|
9
9
|
import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, takeHeaderCasefold } from "./request-params.js";
|
|
@@ -58,6 +58,7 @@ function toAnthropicMessages(ctx, model) {
|
|
|
58
58
|
raw.push({ role: "user", content: pendingDocs });
|
|
59
59
|
pendingDocs = [];
|
|
60
60
|
};
|
|
61
|
+
const resultWireId = providerResultIds(ctx.messages);
|
|
61
62
|
for (const m of ctx.messages) {
|
|
62
63
|
if (m.role !== "toolResult")
|
|
63
64
|
flushDocs();
|
|
@@ -84,7 +85,7 @@ function toAnthropicMessages(ctx, model) {
|
|
|
84
85
|
blocks.push({ type: "text", text: c.text });
|
|
85
86
|
}
|
|
86
87
|
else if (c.type === "toolCall") {
|
|
87
|
-
blocks.push({ type: "tool_use", id: c
|
|
88
|
+
blocks.push({ type: "tool_use", id: providerToolCallId(c), name: c.name, input: c.arguments ?? {} });
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
raw.push({ role: "assistant", content: blocks.length ? blocks : [{ type: "text", text: NO_CONTENT }] });
|
|
@@ -132,7 +133,7 @@ function toAnthropicMessages(ctx, model) {
|
|
|
132
133
|
content: [
|
|
133
134
|
{
|
|
134
135
|
type: "tool_result",
|
|
135
|
-
tool_use_id: m.toolCallId,
|
|
136
|
+
tool_use_id: resultWireId(m.toolCallId),
|
|
136
137
|
content: inner,
|
|
137
138
|
...(m.isError ? { is_error: true } : {}),
|
|
138
139
|
},
|
|
@@ -220,9 +221,8 @@ function closeToolUseBlock(acc) {
|
|
|
220
221
|
const args = parseStreamedToolArgs(acc.toolJson);
|
|
221
222
|
if (args === undefined)
|
|
222
223
|
return undefined;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
acc.closedTc = { type: "toolCall", id: acc.toolId, name: acc.toolName, arguments: args };
|
|
224
|
+
const { engineId, wireId } = admitToolCallId(acc.toolId, "toolu_");
|
|
225
|
+
acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.toolName, arguments: args };
|
|
226
226
|
return acc.closedTc;
|
|
227
227
|
}
|
|
228
228
|
export function createAnthropicBrain(config = {}) {
|
package/dist/brain/errors.d.ts
CHANGED
|
@@ -70,19 +70,7 @@ export declare class BrainError extends Error {
|
|
|
70
70
|
apiError?: boolean;
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
* The shape a provider ECHO field must have to be carried: the request id a provider states on a response
|
|
75
|
-
* header (`AssistantMessage.requestId`, projected as `TaskResult.apiFailure.requestId`) and the model name it
|
|
76
|
-
* reports answering (`AssistantMessage.responseModel`). Both are UPSTREAM BYTES that end up persisted in a
|
|
77
|
-
* transcript and rendered by a UI, so one alphabet governs both: visible ASCII (0x21–0x7E — no space, no
|
|
78
|
-
* control byte, no non-ASCII), 1–128 characters. A value outside it is dropped (the field is absent), never
|
|
79
|
-
* truncated or rewritten; the empty string is outside it. No flags, so `test` is stateless. This object is the
|
|
80
|
-
* consumer's copy: the engine judges with its own instance built from the same source, so recompiling or
|
|
81
|
-
* otherwise mutating this one cannot change what the engine admits.
|
|
82
|
-
*/
|
|
83
|
-
export declare const PROVIDER_ECHO_SHAPE: RegExp;
|
|
84
|
-
/** Does `value` fit {@link PROVIDER_ECHO_SHAPE}? A non-string answers `false` (a JS caller's number is not an echo). */
|
|
85
|
-
export declare function isProviderEchoShape(value: string): boolean;
|
|
73
|
+
export { isProviderEchoShape, PROVIDER_ECHO_SHAPE } from "./provider-echo-shape.js";
|
|
86
74
|
/** How the brains read one provider echo value: a non-string or the empty string states nothing (`absent` — no field,
|
|
87
75
|
* nothing counted); a string in {@link isProviderEchoShape} is `carried`; any other string is `refused` (dropped and
|
|
88
76
|
* counted as `echo_dropped`). */
|
|
@@ -180,4 +168,3 @@ export declare function brainErrorCodeOf(e: unknown): BrainErrorCode | undefined
|
|
|
180
168
|
* each layer's `code:`-prefixed message and skipping layers the parent's message already contains;
|
|
181
169
|
* a causeless error renders as its plain message. */
|
|
182
170
|
export declare function describeNetworkError(e: unknown): string;
|
|
183
|
-
export {};
|
package/dist/brain/errors.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isErrorLike, readMember, thrownText } from "../core/thrown-value.js";
|
|
2
2
|
import { emitBrainTelemetry } from "./status-sink.js";
|
|
3
|
+
import { isProviderEchoShape } from "./provider-echo-shape.js";
|
|
3
4
|
export const ZERO_USAGE = {
|
|
4
5
|
input: 0,
|
|
5
6
|
output: 0,
|
|
@@ -31,12 +32,7 @@ export class BrainError extends Error {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
const REQUEST_ID_HEADERS = ["request-id", "x-request-id"];
|
|
34
|
-
|
|
35
|
-
const providerEchoShape = new RegExp(PROVIDER_ECHO_SOURCE);
|
|
36
|
-
export const PROVIDER_ECHO_SHAPE = new RegExp(PROVIDER_ECHO_SOURCE);
|
|
37
|
-
export function isProviderEchoShape(value) {
|
|
38
|
-
return typeof value === "string" && providerEchoShape.test(value);
|
|
39
|
-
}
|
|
35
|
+
export { isProviderEchoShape, PROVIDER_ECHO_SHAPE } from "./provider-echo-shape.js";
|
|
40
36
|
export function providerEchoReading(value) {
|
|
41
37
|
if (typeof value !== "string" || value.length === 0)
|
|
42
38
|
return "absent";
|
|
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
|
|
|
2
2
|
import { BrainError } from "./errors.js";
|
|
3
3
|
import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
|
|
4
4
|
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
|
|
5
|
-
import {
|
|
5
|
+
import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
|
|
6
6
|
import { emitBrainTelemetry } from "./status-sink.js";
|
|
7
7
|
import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
|
|
8
8
|
import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
|
|
@@ -80,6 +80,7 @@ function toResponsesInput(ctx, model, replayReasoning) {
|
|
|
80
80
|
});
|
|
81
81
|
pendingImages = [];
|
|
82
82
|
};
|
|
83
|
+
const resultWireId = providerResultIds(ctx.messages);
|
|
83
84
|
for (const m of ctx.messages) {
|
|
84
85
|
if (m.role !== "toolResult")
|
|
85
86
|
flushImages();
|
|
@@ -113,7 +114,7 @@ function toResponsesInput(ctx, model, replayReasoning) {
|
|
|
113
114
|
if (text !== "")
|
|
114
115
|
out.push({ type: "message", role: "assistant", content: [{ type: "output_text", text }] });
|
|
115
116
|
for (const tc of toolCalls) {
|
|
116
|
-
out.push({ type: "function_call", call_id: tc
|
|
117
|
+
out.push({ type: "function_call", call_id: providerToolCallId(tc), name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) });
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
else if (m.role === "toolResult") {
|
|
@@ -132,11 +133,11 @@ function toResponsesInput(ctx, model, replayReasoning) {
|
|
|
132
133
|
const finalText = errorNote === undefined ? withVisionNote : withVisionNote ? `${withVisionNote}\n${errorNote}` : errorNote;
|
|
133
134
|
out.push({
|
|
134
135
|
type: "function_call_output",
|
|
135
|
-
call_id: m.toolCallId,
|
|
136
|
+
call_id: resultWireId(m.toolCallId),
|
|
136
137
|
output: functionCallOutputText(finalText, images.length > 0, m.content),
|
|
137
138
|
});
|
|
138
139
|
for (const img of images)
|
|
139
|
-
pendingImages.push({ toolCallId: m.toolCallId, data: img.data, mimeType: img.mimeType });
|
|
140
|
+
pendingImages.push({ toolCallId: resultWireId(m.toolCallId), data: img.data, mimeType: img.mimeType });
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
flushImages();
|
|
@@ -206,9 +207,8 @@ function closeFunctionCall(acc) {
|
|
|
206
207
|
const args = parseStreamedToolArgs(acc.args);
|
|
207
208
|
if (args === undefined)
|
|
208
209
|
return undefined;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
acc.closedTc = { type: "toolCall", id: acc.callId, name: acc.name, arguments: args };
|
|
210
|
+
const { engineId, wireId } = admitToolCallId(acc.callId, "call_");
|
|
211
|
+
acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.name, arguments: args };
|
|
212
212
|
return acc.closedTc;
|
|
213
213
|
}
|
|
214
214
|
function mapTerminalStopReason(incompleteReason, hadToolCalls) {
|
package/dist/brain/openai.js
CHANGED
|
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
|
|
|
2
2
|
import { repairTextToolCalls } from "./tool-call-repair.js";
|
|
3
3
|
import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
|
|
4
4
|
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
|
|
5
|
-
import {
|
|
5
|
+
import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
|
|
6
6
|
import { emitBrainTelemetry } from "./status-sink.js";
|
|
7
7
|
import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
|
|
8
8
|
import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
|
|
@@ -20,9 +20,8 @@ function closeToolCallAccum(acc) {
|
|
|
20
20
|
const args = parseStreamedToolArgs(acc.args);
|
|
21
21
|
if (args === undefined)
|
|
22
22
|
return undefined;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
acc.closedTc = { type: "toolCall", id: acc.id, name: acc.name, arguments: args };
|
|
23
|
+
const { engineId, wireId } = admitToolCallId(acc.id, "call_");
|
|
24
|
+
acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.name, arguments: args };
|
|
26
25
|
return acc.closedTc;
|
|
27
26
|
}
|
|
28
27
|
function textOf(content) {
|
|
@@ -174,6 +173,7 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
|
|
|
174
173
|
});
|
|
175
174
|
pendingImages = [];
|
|
176
175
|
};
|
|
176
|
+
const resultWireId = providerResultIds(ctx.messages);
|
|
177
177
|
for (const m of ctx.messages) {
|
|
178
178
|
if (m.role !== "toolResult")
|
|
179
179
|
flushImages();
|
|
@@ -201,7 +201,7 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
|
|
|
201
201
|
}
|
|
202
202
|
if (toolCalls.length > 0) {
|
|
203
203
|
msg.tool_calls = toolCalls.map((tc) => ({
|
|
204
|
-
id: tc
|
|
204
|
+
id: providerToolCallId(tc),
|
|
205
205
|
type: "function",
|
|
206
206
|
function: { name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) },
|
|
207
207
|
}));
|
|
@@ -224,11 +224,11 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
|
|
|
224
224
|
const finalText = errorNote === undefined ? text : text ? `${text}\n${errorNote}` : errorNote;
|
|
225
225
|
out.push({
|
|
226
226
|
role: "tool",
|
|
227
|
-
tool_call_id: m.toolCallId,
|
|
227
|
+
tool_call_id: resultWireId(m.toolCallId),
|
|
228
228
|
content: toolResultText(finalText, images.length > 0, m.content),
|
|
229
229
|
});
|
|
230
230
|
for (const img of images)
|
|
231
|
-
pendingImages.push({ toolCallId: m.toolCallId, data: img.data, mimeType: img.mimeType });
|
|
231
|
+
pendingImages.push({ toolCallId: resultWireId(m.toolCallId), data: img.data, mimeType: img.mimeType });
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
flushImages();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider echo shape — the ONE alphabet for upstream bytes the engine carries verbatim (#1204): visible ASCII
|
|
3
|
+
* (0x21–0x7E — no space, no control byte, no non-ASCII), 1–128 characters. The echo fields (`requestId`,
|
|
4
|
+
* `responseModel`) are carried or dropped by it, and a provider-minted tool-call id is admitted verbatim or folded
|
|
5
|
+
* by it (`tool-call-id.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Zero-dependency leaf: `tool-call-id.ts` (itself a leaf, see its header) reads the shape from here. `errors.ts`
|
|
8
|
+
* re-exports the two public names, which is where the package root takes them from.
|
|
9
|
+
*/
|
|
10
|
+
/** The longest value the shape admits. */
|
|
11
|
+
export declare const PROVIDER_ECHO_MAX_CHARS = 128;
|
|
12
|
+
/**
|
|
13
|
+
* The shape a provider ECHO field must have to be carried: the request id a provider states on a response
|
|
14
|
+
* header (`AssistantMessage.requestId`, projected as `TaskResult.apiFailure.requestId`) and the model name it
|
|
15
|
+
* reports answering (`AssistantMessage.responseModel`). Both are UPSTREAM BYTES that end up persisted in a
|
|
16
|
+
* transcript and rendered by a UI, so one alphabet governs both: visible ASCII (0x21–0x7E — no space, no
|
|
17
|
+
* control byte, no non-ASCII), 1–128 characters. A value outside it is dropped (the field is absent), never
|
|
18
|
+
* truncated or rewritten; the empty string is outside it. No flags, so `test` is stateless. This object is the
|
|
19
|
+
* consumer's copy: the engine judges with its own instance built from the same source, so recompiling or
|
|
20
|
+
* otherwise mutating this one cannot change what the engine admits.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PROVIDER_ECHO_SHAPE: RegExp;
|
|
23
|
+
/** Does `value` fit {@link PROVIDER_ECHO_SHAPE}? A non-string answers `false` (a JS caller's number is not an echo). */
|
|
24
|
+
export declare function isProviderEchoShape(value: string): boolean;
|
|
25
|
+
/** Is every character of `value` in the shape's alphabet, whatever its length? (`""` answers `true`.) */
|
|
26
|
+
export declare function fitsProviderEchoCharset(value: string): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const PROVIDER_ECHO_CHARSET = "\\x21-\\x7E";
|
|
2
|
+
export const PROVIDER_ECHO_MAX_CHARS = 128;
|
|
3
|
+
const PROVIDER_ECHO_SOURCE = `^[${PROVIDER_ECHO_CHARSET}]{1,${PROVIDER_ECHO_MAX_CHARS}}$`;
|
|
4
|
+
const providerEchoShape = new RegExp(PROVIDER_ECHO_SOURCE);
|
|
5
|
+
const providerEchoChars = new RegExp(`^[${PROVIDER_ECHO_CHARSET}]*$`);
|
|
6
|
+
export const PROVIDER_ECHO_SHAPE = new RegExp(PROVIDER_ECHO_SOURCE);
|
|
7
|
+
export function isProviderEchoShape(value) {
|
|
8
|
+
return typeof value === "string" && providerEchoShape.test(value);
|
|
9
|
+
}
|
|
10
|
+
export function fitsProviderEchoCharset(value) {
|
|
11
|
+
return providerEchoChars.test(value);
|
|
12
|
+
}
|
|
@@ -96,7 +96,8 @@ export declare function shouldRetryHeaderVerdict(res: Response | undefined): boo
|
|
|
96
96
|
* {@link WAITING_FIRST_TOKEN_AFTER_MS}, and one more every {@link WAITING_FIRST_TOKEN_EVERY_MS} after
|
|
97
97
|
* that, until the first token lands, the watchdog fires, or the attempt ends. A short wait (under the
|
|
98
98
|
* threshold) announces nothing — the frame exists for the unattended "no answer for a minute" case,
|
|
99
|
-
* not for every request.
|
|
99
|
+
* not for every request. A wait whose watchdog fires AT the threshold is disclosed once, at that
|
|
100
|
+
* instant, before the cut is announced. The same 30s slice the retry countdown re-announces on.
|
|
100
101
|
*/
|
|
101
102
|
export declare const WAITING_FIRST_TOKEN_AFTER_MS = 30000;
|
|
102
103
|
export declare const WAITING_FIRST_TOKEN_EVERY_MS = 30000;
|
|
@@ -337,29 +337,40 @@ export function runStreamingBrain(args) {
|
|
|
337
337
|
const idleTimeoutMs = resolveWatchdogMs(config.idleTimeoutMs, stallTimeouts?.idleMs, STALL_IDLE_MS, "idleTimeoutMs");
|
|
338
338
|
const firstByteRetryBudget = resolveFirstByteRetries(config.firstByteRetries);
|
|
339
339
|
const armWaitDisclosure = (ceilingMs) => {
|
|
340
|
-
if (!(ceilingMs
|
|
341
|
-
return { stop: () => undefined };
|
|
340
|
+
if (!(ceilingMs >= WAITING_FIRST_TOKEN_AFTER_MS))
|
|
341
|
+
return { stop: () => undefined, expire: () => undefined };
|
|
342
342
|
const startedAt = Date.now();
|
|
343
343
|
const emitWaitFrame = bindBrainStatusEmitter();
|
|
344
344
|
let timer;
|
|
345
345
|
let stopped = false;
|
|
346
|
+
let disclosed = false;
|
|
347
|
+
const disclose = () => {
|
|
348
|
+
disclosed = true;
|
|
349
|
+
announcedWait = true;
|
|
350
|
+
emitWaitFrame({ phase: "waiting_first_token", detail: "waiting for the first token", elapsedMs: Date.now() - startedAt, timeoutMs: ceilingMs });
|
|
351
|
+
};
|
|
346
352
|
const schedule = (delayMs) => {
|
|
347
353
|
timer = setTimeout(() => {
|
|
348
354
|
timer = undefined;
|
|
349
355
|
if (stopped || Date.now() - startedAt >= ceilingMs)
|
|
350
356
|
return;
|
|
351
|
-
|
|
352
|
-
emitWaitFrame({ phase: "waiting_first_token", detail: "waiting for the first token", elapsedMs: Date.now() - startedAt, timeoutMs: ceilingMs });
|
|
357
|
+
disclose();
|
|
353
358
|
schedule(WAITING_FIRST_TOKEN_EVERY_MS);
|
|
354
359
|
}, delayMs);
|
|
355
360
|
};
|
|
356
361
|
schedule(WAITING_FIRST_TOKEN_AFTER_MS);
|
|
362
|
+
const stop = () => {
|
|
363
|
+
stopped = true;
|
|
364
|
+
if (timer)
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
timer = undefined;
|
|
367
|
+
};
|
|
357
368
|
return {
|
|
358
|
-
stop
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
|
|
369
|
+
stop,
|
|
370
|
+
expire: () => {
|
|
371
|
+
if (!stopped && !disclosed)
|
|
372
|
+
disclose();
|
|
373
|
+
stop();
|
|
363
374
|
},
|
|
364
375
|
};
|
|
365
376
|
};
|
|
@@ -378,13 +389,13 @@ export function runStreamingBrain(args) {
|
|
|
378
389
|
throw e;
|
|
379
390
|
}
|
|
380
391
|
const window = firstByteWindow(firstByteTimeoutMs, Buffer.byteLength(req.body), firstByteStalls);
|
|
392
|
+
const headerWait = armWaitDisclosure(window.windowMs);
|
|
381
393
|
const cc = createAttemptController(window.windowMs, signal);
|
|
382
394
|
let r;
|
|
383
395
|
let netErr;
|
|
384
396
|
let firstByteStall;
|
|
385
397
|
requestIdSeen = undefined;
|
|
386
398
|
attemptPartial = undefined;
|
|
387
|
-
const headerWait = armWaitDisclosure(window.windowMs);
|
|
388
399
|
const stalledAtFirstByte = () => {
|
|
389
400
|
firstByteWindowsWaited.push(window.windowMs);
|
|
390
401
|
return {
|
|
@@ -423,7 +434,10 @@ export function runStreamingBrain(args) {
|
|
|
423
434
|
}
|
|
424
435
|
}
|
|
425
436
|
finally {
|
|
426
|
-
|
|
437
|
+
if (firstByteStall !== undefined)
|
|
438
|
+
headerWait.expire();
|
|
439
|
+
else
|
|
440
|
+
headerWait.stop();
|
|
427
441
|
}
|
|
428
442
|
if (r && r.ok && r.body) {
|
|
429
443
|
cc.clearTimer();
|
|
@@ -587,14 +601,14 @@ export function runStreamingBrain(args) {
|
|
|
587
601
|
let firstTokenSeen = false;
|
|
588
602
|
let firedFirstTokenDeadlineMs;
|
|
589
603
|
let ftTimer;
|
|
590
|
-
let firstTokenWait = { stop: () => undefined };
|
|
604
|
+
let firstTokenWait = { stop: () => undefined, expire: () => undefined };
|
|
591
605
|
if (firstTokenTimeoutMs && firstTokenTimeoutMs > 0) {
|
|
606
|
+
firstTokenWait = armWaitDisclosure(firstTokenTimeoutMs);
|
|
592
607
|
ftTimer = setTimeout(() => {
|
|
593
608
|
firedFirstTokenDeadlineMs = firstTokenTimeoutMs;
|
|
594
|
-
firstTokenWait.
|
|
609
|
+
firstTokenWait.expire();
|
|
595
610
|
void reader.cancel().catch(() => undefined);
|
|
596
611
|
}, firstTokenTimeoutMs);
|
|
597
|
-
firstTokenWait = armWaitDisclosure(firstTokenTimeoutMs);
|
|
598
612
|
}
|
|
599
613
|
let firedIdleDeadlineMs;
|
|
600
614
|
let idleTimer;
|
|
@@ -1,21 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* When a provider's wire frame carries no `id` for a tool call, the adapter must still hand downstream a
|
|
5
|
-
* non-`undefined`, process-unique handle: the whole execution/pairing chain keys on it (a tool_use must pair
|
|
6
|
-
* with its tool_result, and an in-stream executor's result must pair with the final content). Three adapters
|
|
7
|
-
* used to hand-roll the same `Math.random().toString(36)` short id with only their protocol prefix differing.
|
|
8
|
-
*
|
|
9
|
-
* The prefix is the caller's protocol identity (`toolu_` / `call_` / `repair_<name>_`) and is the ONLY
|
|
10
|
-
* parameter — the id BODY is deliberately opaque and uniform: no length/charset options, so there is exactly
|
|
11
|
-
* one collision profile to reason about for every lane.
|
|
12
|
-
*
|
|
13
|
-
* Zero-dependency leaf on purpose: `tool-call-repair.ts` is a pure parser with no runtime imports, so this
|
|
14
|
-
* must not drag a stream/telemetry dependency in. Internal module: not re-exported from `src/index.ts`.
|
|
15
|
-
*/
|
|
1
|
+
import type { Message } from "../internal/llm.js";
|
|
2
|
+
/** The character every folded id carries and no admitted id may: what keeps the two arms' outputs apart. */
|
|
3
|
+
export declare const TOOL_CALL_ID_FOLD_SEPARATOR = "~";
|
|
16
4
|
/**
|
|
17
5
|
* Mint a placeholder tool-call id under `prefix`. Uniqueness is per-process and probabilistic
|
|
18
6
|
* (`Math.random`), which is what every call site already relied on — the id never crosses a trust boundary
|
|
19
7
|
* and is never a capability; it only has to not collide inside one response.
|
|
20
8
|
*/
|
|
21
9
|
export declare function mintFallbackToolCallId(prefix: string): string;
|
|
10
|
+
/** The two ids of one admitted tool call (see the module header). Equal unless the admission folded. */
|
|
11
|
+
export interface AdmittedToolCallId {
|
|
12
|
+
readonly engineId: string;
|
|
13
|
+
readonly wireId: string;
|
|
14
|
+
}
|
|
15
|
+
/** Why a raw id is folded: `"charset"` — it holds a character outside the admitted alphabet (a control byte, a space,
|
|
16
|
+
* a non-ASCII unit, or the fold separator); `"length"` — every character is admitted and it is longer than 128. */
|
|
17
|
+
export type ToolCallIdFoldReason = "charset" | "length";
|
|
18
|
+
/** The fold reading of a non-empty raw id: `undefined` ⇒ admitted verbatim; otherwise why it is folded. */
|
|
19
|
+
export declare function toolCallIdFoldReason(raw: string): ToolCallIdFoldReason | undefined;
|
|
20
|
+
/** Admit a provider-minted tool-call id (see the module header for the three arms). Pure: announcing a fold is the
|
|
21
|
+
* run's job, read off `ToolCall.wireId`. */
|
|
22
|
+
export declare function admitToolCallId(raw: unknown, prefix: string): AdmittedToolCallId;
|
|
23
|
+
/** The id a provider is handed for this call: its `wireId` when it carries one (a non-empty string), else its `id`. */
|
|
24
|
+
export declare function providerToolCallId(call: {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly wireId?: unknown;
|
|
27
|
+
}): string;
|
|
28
|
+
/**
|
|
29
|
+
* The id a provider is handed for the tool RESULT of `engineId`, read off the calls in the same request's messages:
|
|
30
|
+
* a result carries only the engine id, and the call it answers carries the provider's. A result whose call is not
|
|
31
|
+
* in the messages is handed on under its engine id.
|
|
32
|
+
*/
|
|
33
|
+
export declare function providerResultIds(messages: readonly Message[]): (engineId: string) => string;
|
|
@@ -1,3 +1,46 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fitsProviderEchoCharset, isProviderEchoShape } from "./provider-echo-shape.js";
|
|
3
|
+
export const TOOL_CALL_ID_FOLD_SEPARATOR = "~";
|
|
4
|
+
const FOLD_BASE_CHARS = 32;
|
|
5
|
+
const FOLD_DIGEST_HEX = 32;
|
|
1
6
|
export function mintFallbackToolCallId(prefix) {
|
|
2
7
|
return `${prefix}${Math.random().toString(36).slice(2)}`;
|
|
3
8
|
}
|
|
9
|
+
export function toolCallIdFoldReason(raw) {
|
|
10
|
+
const separated = raw.includes(TOOL_CALL_ID_FOLD_SEPARATOR);
|
|
11
|
+
if (!separated && isProviderEchoShape(raw))
|
|
12
|
+
return undefined;
|
|
13
|
+
return !separated && fitsProviderEchoCharset(raw) ? "length" : "charset";
|
|
14
|
+
}
|
|
15
|
+
export function admitToolCallId(raw, prefix) {
|
|
16
|
+
if (typeof raw !== "string" || raw === "") {
|
|
17
|
+
const id = mintFallbackToolCallId(prefix);
|
|
18
|
+
return { engineId: id, wireId: id };
|
|
19
|
+
}
|
|
20
|
+
return { engineId: engineIdOf(raw), wireId: raw };
|
|
21
|
+
}
|
|
22
|
+
function engineIdOf(raw) {
|
|
23
|
+
if (toolCallIdFoldReason(raw) === undefined)
|
|
24
|
+
return raw;
|
|
25
|
+
const base = raw.slice(0, FOLD_BASE_CHARS).replace(/[^\x21-\x7D]/g, "-");
|
|
26
|
+
const digest = createHash("sha256").update(Buffer.from(raw, "utf16le")).digest("hex").slice(0, FOLD_DIGEST_HEX);
|
|
27
|
+
return `${base}${TOOL_CALL_ID_FOLD_SEPARATOR}${digest}`;
|
|
28
|
+
}
|
|
29
|
+
export function providerToolCallId(call) {
|
|
30
|
+
return typeof call.wireId === "string" && call.wireId !== "" ? call.wireId : call.id;
|
|
31
|
+
}
|
|
32
|
+
export function providerResultIds(messages) {
|
|
33
|
+
const byEngineId = new Map();
|
|
34
|
+
for (const m of messages) {
|
|
35
|
+
if (m.role !== "assistant" || !Array.isArray(m.content))
|
|
36
|
+
continue;
|
|
37
|
+
for (const c of m.content) {
|
|
38
|
+
if (c?.type !== "toolCall" || typeof c.id !== "string")
|
|
39
|
+
continue;
|
|
40
|
+
const wire = providerToolCallId(c);
|
|
41
|
+
if (wire !== c.id)
|
|
42
|
+
byEngineId.set(c.id, wire);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return (engineId) => byEngineId.get(engineId) ?? engineId;
|
|
46
|
+
}
|
|
@@ -279,36 +279,6 @@ export interface AskUserQuestionToolOptions {
|
|
|
279
279
|
* would let a LATER question inherit the claim that an operator approved it. */
|
|
280
280
|
redeemedApprovalQuestionsHash?: string;
|
|
281
281
|
}
|
|
282
|
-
/**
|
|
283
|
-
* Durable ask (design/64 TC-5.4 — ABOVE CC, the disconnected-human case): instead of the headless
|
|
284
|
-
* "proceed with your best judgment" default, an AskUserQuestion **suspends the task durably** and the
|
|
285
|
-
* answer resumes it — built ENTIRELY from existing primitives, no new gate kind.
|
|
286
|
-
*
|
|
287
|
-
* **The park is the fallback, not the mandate.** An adjudicated question parks durably UNLESS this run
|
|
288
|
-
* has a live question face (see {@link isLiveQuestionFace}), in which case the engine routes the question
|
|
289
|
-
* to that face and the answer arrives in-stream — same turn, no checkpoint, no rebuild. The park is what
|
|
290
|
-
* happens when nobody is there: no live face, the face affirming nobody is reachable, or a control-plane
|
|
291
|
-
* `forceDurableGate` that mandates the durable breakpoint regardless. A deployment that wired a live face
|
|
292
|
-
* AND wants every question parked anyway states that with `forceDurableGate` (or by not wiring the face).
|
|
293
|
-
*
|
|
294
|
-
* 1. Run the task with `durableApproval` + a durable `CheckpointStore` (+ durable session/offload
|
|
295
|
-
* stores) and `combinePolicies(createDurableQuestionPolicy(), yourPolicy)`. The policy adjudicates
|
|
296
|
-
* the AskUserQuestion call `ask`; in durable mode that suspends BEFORE the tool executes —
|
|
297
|
-
* `onQuestion` is never called on the suspend side, so mount the tool with
|
|
298
|
-
* {@link QUESTION_AWAITS_RESUME} when no live human is connected.
|
|
299
|
-
* 2. The HITL surface reads the questions from `checkpoint.pendingAction.args.questions` and collects
|
|
300
|
-
* the human's {@link QuestionAnswer} out-of-band (hours/days later, any replica).
|
|
301
|
-
* 3. `runner.resume(token, { gate: "policy_ask", decision: "allow", boundCallId:
|
|
302
|
-
* checkpoint.pendingAction.toolCallId }, { ...taskConfig, onQuestion: async () => answer })` — the resume
|
|
303
|
-
* re-mounts the tool with the answer closure; the pending call executes against it and the model
|
|
304
|
-
* continues with "The user answered: …". `boundCallId` (design/80 D-1) must name the pending call the
|
|
305
|
-
* human answered, or the resume is rejected (`checkpoint.invalid_outcome`).
|
|
306
|
-
*
|
|
307
|
-
* A deployment that wants the CC-style threshold form ("wait N minutes for a live human, then go
|
|
308
|
-
* durable") implements it inside its own policy: return `allow` while a human is connected (the live
|
|
309
|
-
* `onQuestion` answers synchronously) and `ask` when none is — the sync-vs-durable choice is exactly
|
|
310
|
-
* one policy decision.
|
|
311
|
-
*/
|
|
312
282
|
export declare function createDurableQuestionPolicy(): ToolPolicy;
|
|
313
283
|
/**
|
|
314
284
|
* Placeholder {@link OnQuestion} that mounts the AskUserQuestion tool on a worker with NO live human
|