@sema-agent/core 7.37.1 → 7.38.1
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 +168 -0
- package/dist/agents/observer.js +1 -1
- package/dist/agents/peer-notices.js +4 -4
- package/dist/agents/subagent.js +1 -1
- package/dist/agents/team-role.js +2 -2
- package/dist/brain/anthropic.js +10 -9
- package/dist/brain/errors.d.ts +26 -0
- package/dist/brain/errors.js +34 -0
- package/dist/brain/input-too-long.d.ts +0 -31
- package/dist/brain/input-too-long.js +1 -18
- package/dist/brain/open-responses.js +10 -9
- package/dist/brain/openai.js +10 -9
- package/dist/brain/status-sink.d.ts +1 -1
- package/dist/brain/stream-engine.js +10 -2
- package/dist/brain/stream-shared.d.ts +11 -3
- package/dist/brain/stream-shared.js +4 -2
- package/dist/brain/tool-call-id.d.ts +29 -8
- package/dist/brain/tool-call-id.js +36 -6
- package/dist/brain/tool-call-repair.js +1 -1
- package/dist/core/a2a-task-state.js +2 -2
- package/dist/core/artifact-host.js +6 -6
- package/dist/core/ask-origin.js +6 -6
- package/dist/core/ask-unresolvable-notice.js +1 -1
- package/dist/core/auto-mode.js +4 -4
- package/dist/core/background-agent-store.js +2 -2
- package/dist/core/checkpoint-execution-record.js +1 -1
- package/dist/core/checkpoint-store.d.ts +2 -2
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/child-clamp-seats.js +3 -3
- package/dist/core/compliance.js +1 -1
- package/dist/core/context-edit.js +1 -1
- package/dist/core/context-guard.d.ts +0 -19
- package/dist/core/context-guard.js +0 -38
- package/dist/core/decision-receipt.d.ts +27 -1
- package/dist/core/decision-receipt.js +176 -1
- package/dist/core/engine-notice.d.ts +60 -8
- package/dist/core/engine-notice.js +2 -1
- package/dist/core/gate-fold.js +3 -2
- package/dist/core/gate-outcome.js +4 -4
- package/dist/core/governance-codes.d.ts +221 -13
- package/dist/core/governance-codes.js +104 -4
- package/dist/core/hook-seats.js +2 -2
- package/dist/core/hooks.d.ts +21 -2
- package/dist/core/hooks.js +4 -1
- package/dist/core/mailbox-store.js +3 -3
- package/dist/core/mcp-failure.js +4 -4
- package/dist/core/mcp-injection-drop.js +1 -1
- package/dist/core/memory-engine/consolidation.js +1 -1
- package/dist/core/memory-engine/delegation-settlement.d.ts +8 -0
- package/dist/core/memory-engine/delegation-settlement.js +7 -0
- package/dist/core/memory-engine/engine.d.ts +12 -2
- package/dist/core/memory-engine/engine.js +10 -6
- package/dist/core/memory-engine/export-bundle.js +1 -1
- package/dist/core/memory-engine/layout.d.ts +6 -6
- package/dist/core/memory-engine/layout.js +6 -8
- package/dist/core/memory-engine/session-write-home.d.ts +30 -0
- package/dist/core/memory-engine/session-write-home.js +34 -0
- package/dist/core/memory-engine/tools.js +1 -1
- package/dist/core/memory-engine/types.js +1 -1
- package/dist/core/memory.js +1 -1
- package/dist/core/mode-policy.d.ts +10 -1
- package/dist/core/mode-policy.js +15 -7
- package/dist/core/model-seat.js +1 -1
- package/dist/core/permission-rule-model.js +3 -3
- package/dist/core/permission-rule-store.js +2 -2
- package/dist/core/read-only-shell-table.js +8 -8
- package/dist/core/retention.js +2 -2
- package/dist/core/runner/assemble-result.js +15 -5
- package/dist/core/runner/classifier-ladder.js +1 -1
- package/dist/core/runner/denial-limit-arms.d.ts +2 -0
- package/dist/core/runner/denial-limit-arms.js +6 -2
- package/dist/core/runner/inherited-ask-grants.d.ts +6 -5
- package/dist/core/runner/inherited-ask-grants.js +3 -2
- package/dist/core/runner/prepare-artifact.d.ts +3 -2
- package/dist/core/runner/prepare-context-lane.d.ts +19 -15
- package/dist/core/runner/prepare-context-lane.js +11 -15
- package/dist/core/runner/prepare-gate-stations.js +5 -5
- package/dist/core/runner/prepare-hands-readface.d.ts +4 -3
- package/dist/core/runner/prepare-hands-readface.js +1 -0
- package/dist/core/runner/prepare-inherited-gate.js +1 -1
- package/dist/core/runner/prepare-policy-chain.js +10 -10
- package/dist/core/runner/prepare-turn-wiring.d.ts +2 -2
- package/dist/core/runner/run-harness-handlers.js +3 -0
- package/dist/core/runner/run-identity-wiring.d.ts +1 -1
- package/dist/core/runner/run-identity-wiring.js +0 -2
- package/dist/core/runner/run-leg.js +2 -2
- package/dist/core/runner/run-stop-and-final-verify.js +1 -0
- package/dist/core/runner/run-turn-boundary.js +1 -1
- package/dist/core/runner/session-mode-seats.d.ts +11 -3
- package/dist/core/runner/session-mode-seats.js +6 -0
- package/dist/core/runner/session-rule-policy.js +2 -3
- package/dist/core/runner/stream-steer-verb.js +1 -1
- package/dist/core/runner/tool-call-id-fold-seat.js +16 -9
- package/dist/core/runner/tool-turn-repair-seat.d.ts +11 -1
- package/dist/core/runner/tool-turn-repair-seat.js +35 -0
- package/dist/core/runner/turn-attachments.js +1 -1
- package/dist/core/runtime-caps.js +1 -1
- package/dist/core/safety-axis-vocab.d.ts +5 -0
- package/dist/core/safety-axis-vocab.js +5 -0
- package/dist/core/session-reconcile.d.ts +9 -7
- package/dist/core/session-reconcile.js +21 -1
- package/dist/core/shared-memory/types.js +1 -1
- package/dist/core/side-query.d.ts +6 -1
- package/dist/core/store-contracts/read-back-shape-contract.js +1 -1
- package/dist/core/swappable-deps.js +1 -1
- package/dist/core/task-notification.js +4 -4
- package/dist/core/task-registry-shared.js +1 -1
- package/dist/core/terminal-cause.d.ts +19 -0
- package/dist/core/tool-face.js +6 -6
- package/dist/core/tool-policy.d.ts +17 -11
- package/dist/core/tool-policy.js +38 -72
- package/dist/core/tool-roster.js +1 -1
- package/dist/core/tool-search-setting.js +1 -1
- package/dist/core/trace.d.ts +5 -4
- package/dist/core/types.d.ts +2 -2
- package/dist/core/types.js +1 -1
- package/dist/core/wiring-manifest.js +2 -2
- package/dist/core/write-protect.js +1 -1
- package/dist/engine/harness/agent-harness.js +10 -6
- package/dist/engine/llm/tool-turns.d.ts +93 -4
- package/dist/engine/llm/tool-turns.js +63 -9
- package/dist/engine/llm/types.d.ts +11 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/internal/llm.d.ts +2 -2
- package/dist/internal/llm.js +1 -1
- package/dist/orchestration/workflow-governance.js +2 -2
- package/dist/orchestration/workflow-types.js +2 -2
- package/dist/shell/lex.js +7 -7
- package/dist/tools/fs/bash-program-position.js +2 -2
- package/dist/tools/fs/fs-shared.d.ts +2 -1
- package/dist/tools/fs/fs-shared.js +1 -1
- package/dist/tools/fs/index.d.ts +13 -6
- package/dist/tools/fs/index.js +12 -4
- package/dist/tools/fs/read-deny-home.d.ts +98 -45
- package/dist/tools/fs/read-deny-home.js +294 -117
- package/dist/tools/fs/read-deny.d.ts +2 -2
- package/dist/tools/fs/read-deny.js +2 -2
- package/dist/tools/fs/safety.d.ts +2 -0
- package/dist/tools/fs/safety.js +2 -2
- package/dist/tools/fs/search.d.ts +4 -3
- package/dist/tools/fs/search.js +3 -3
- package/dist/tools/fs/shell-read-check.d.ts +9 -0
- package/dist/tools/fs/shell-read-check.js +3 -2
- package/dist/tools/fs/shell-read-establish.d.ts +3 -0
- package/dist/tools/fs/shell-read-establish.js +1 -1
- package/dist/tools/fs/shell-read-identity.d.ts +4 -3
- package/dist/tools/fs/shell-read-identity.js +2 -2
- package/dist/tools/fs/shell-read-judge.js +2 -2
- package/dist/tools/fs/shell-read-plan.js +2 -2
- package/dist/tools/fs/shell-read-write-targets.js +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +17 -1
- package/wire-changes/core@7.38.0.json +664 -0
- package/wire-changes/core@7.38.1.json +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
+
## 7.38.1 — 2026-10-02
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes:** none.
|
|
4
|
+
|
|
5
|
+
**Closed sets:** none. **Package exports:** +0 / −0.
|
|
6
|
+
|
|
7
|
+
### Home-anchored read-deny rows are placed under the account home as well as the child's `HOME` (#1244; behavior (tighten: the read deny table); +0 exports) (@server @cli @client-core @test)
|
|
8
|
+
|
|
9
|
+
- **Before (v7.38.0).** The home-anchored rows of the read deny table (`~/.sema/settings.json`, `~/.ssh`, `~/.aws`, …) were placed under one home: the `HOME` the shell child receives (`ExecutionEnv.homeDir`; `NodeExecutionEnv` reads it off `shellEnv` over the inherited environment). A host that handed the child a temporary `HOME` moved the whole fence with it — the user's real home directory (the account database's, `os.userInfo().homedir`) held no row, and the structured read tools and the shell read station served `~/.sema/settings.json` there.
|
|
10
|
+
- **Now.** `declaredReadDenyHomes` returns the declared home and, when its lexically normalized spelling differs, the account home. The home set is fixed ONCE per placement — the Runner's (per run), the library-direct toolkit's own, or a host's explicit `readDenyHomes` taken as the home set (the account database is then not asked and no home is added) — and every home of the set, looked up or listed, is anchored at its spelling and at its canonical spelling where the environment answered one — and every judgment reads that set: a judge carries the home views of the placement it was compiled at, so a path spelled through a firmlink or a bind mount of a home is respelled at the home without asking the account again. The Runner hands its tool band that placement itself — the very home list it returned carries its views — so the band never reads the homes a second time; a band handed any other list with the placement's links (a copy) holds its home-anchored rows at every location and says so on `console.warn` (an engine defect, never a silent narrower view). The DECLARED home keeps the v7.38.0 law: its probes wait the gate bound, a placement whose declared home did not answer holds the rows at every location and is asked again, and its directory chain is asked again by every judgment until it answers (a home created after the run began is read once it exists); its views stay under every answer of the account lookup, also under no answer, so a user's deny rule and the write face meet its aliases as in v7.38.0. The ACCOUNT home (and every entry of an explicit list that is not the declared home) is best effort and bounded: its canonical spelling, its identity chain and where its rows' heads lead are read once per placement under one 2000 ms bound; what answered is kept and only what did not is lost, that home's alone — a canonical spelling that answered anchors the rows there at once; a chain that did not answer leaves a path through an alias of the home judged as spelled, a head that did not answer adds no linked location, until the next placement; a canonical spelling that did not answer leaves the rows at the home's spelling alone — the placement stays whole, and one `console.warn` line says so. In the chain a directory that is a home (read off the home's own canonical directory, where its identity answered) keeps the home's own spelling over an ancestor's. A head under a spelling that is no directory — a file, a symlink to a file, a device node, a socket: the environment's path resolution answers `not_directory` — has nothing below it (an answer; before, only a plain file counted: a `HOME` that is a symlink to a file left the placement not whole, replayed on every call). The `~` expansion base is unchanged: `ExecutionEnv.homeDir` stays the directory the child's own `~` names. An environment that declares no absolute home still places nothing (a home-anchored row then holds its run anywhere); the account home is no stand-in for it.
|
|
11
|
+
- **Who is hurt.** A read of a file under the account home that matches a home-anchored row (`~/.sema/settings.json`, `~/.ssh/…`, `~/.aws/…`) while the child's `HOME` is elsewhere: refused (`read_path_denied`) where it was served. A host that lists `readDenyHomes` itself sees the same placement, and its views now cover every listed home (v7.38.0 respelled aliases of the declared home only: an alias spelling of another listed home was served); its explicit list stays a legal channel and can be withdrawn once it carries nothing the default does not. A remote or sandboxed leg gets the engine host's account home as one more anchor: a row over a path that may not exist there. A deployment whose account lookup fails for a reason other than "no passwd row" gets the over-strict placement above (rows held at every location) until the lookup answers. A hung account home makes a placement wait up to 2 s more, once per placement (the Runner's tool band judges with the run's placement and reads no home again). A host that passes BOTH `readDenyHomes` and `readDenyHomeLinks` (a hand-built pair) is now taken for the engine's own handoff of a placement it cannot find: the home-anchored rows are held at every location and `console.warn` names it (v7.38.0 placed the rows at the listed homes); no sibling host makes that call — pass `readDenyHomes` alone and let the toolkit resolve where the heads lead. No sibling host lists one today: server's only home seat is the execution environment's `homeDir` (`boot/execution-env.ts`), so its deployments get the account home from this default with no change on its side.
|
|
12
|
+
- **Compensation.** None needed for the common case (`HOME` equals the account home: one home, nothing changes). A deployment that really reads a file under its own account home that a row names lists the row in `readDenyBuiltinExclude`.
|
|
13
|
+
- **Known limit (KL-1244-a).** The account lookup has three answers, the split the environment's `canonicalPath` already meets, and NOTHING is kept for the process: every placement asks afresh, and a placement's judgments never ask. A home places. **No account home** — `os.userInfo()` throws the libuv "no passwd row" error (`info.code === "ENOENT"`, a container that runs an arbitrary uid; also a directory service that is down and says so) or answers a non-absolute path — places the rows under the declared home alone, a whole placement; the missing row is not a fault and does not warn (a non-absolute answer warns once). A library-direct toolkit does not place again after a whole placement, so an account home that answers later is not placed for that toolkit's lifetime; the Runner's next run places it. **No answer** on the first placement — any other failure, whatever the error object looks like — says so once on `console.warn` and yields an EMPTY home list (`readDenyHomesOf`, `declaredReadDenyHomes`), the engine's rule for "no home known": the home-anchored rows hold their run at every location until the lookup answers (the project's own `.sema/settings.json` is refused too — over-strict, recoverable) and the placement is asked again at the next placement; the home views keep the declared home meanwhile. `readDenyHomesOf` returns `[]` both when the environment declares no home and when the lookup has no answer, so a host cannot tell them apart and should not cache the list: take it at every mount, or omit `readDenyHomes` and let the toolkit place itself. A toolkit whose table has no home-anchored row places nothing and its views read the declared home (or the list it is handed) without asking the account. A structured notice would be a closed-set row; a minor release carries it.
|
|
14
|
+
- **Known limit (KL-1244-b).** An auxiliary home loses, until the next placement, exactly the parts that did not answer within its 2000 ms bound: a chain that did not answer — paths through an alias of it are judged as spelled; a head that did not answer — no linked location for it; a canonical spelling that did not answer — the rows sit at its spelling alone (neither its canonical spelling nor its linked targets are guarded). The next placement is the Runner's next run; a library-direct toolkit does not place again for that. A directory the probes may not enter (EACCES) is no answer: as the declared home it leaves the placement not whole (replayed per call; the rows stay at its spellings). **Known limit (KL-1244-d).** The public `judgeShellRead` has no placement to read — the boundary's deny seats are the host's own compile and the input carries no home list — so its views read the declared home and the account home looked up for that call; rows the host placed at another home get no alias views there (as in v7.38.0, whose public judgment read the declared home only); an optional input seat comes with a minor. **Known limit (KL-1244-c).** A user's own `~/` persisted permission rule (`Read(~/.aws/**)` …) still resolves against the environment's declared home only (`prepare-task.ts`, `prepare-policy-chain.ts`, `permission-rule-lanes.ts`); under a temporary `HOME` such a rule does not cover the real home directory. The built-in read deny table does.
|
|
15
|
+
- Pins: `test/backlog1244-passwd-home.test.ts` — ① Read / `cat` / Grep / the library-direct toolkit over the account home with a temporary child `HOME` (baseline: served); ② the child's own `HOME` still guarded; ③ one home when both are the same; ④ the `~` expansion base is the child's `HOME`; ⑤ the lookup's answers — (i) ENOENT: declared home alone, whole, silent, the project's settings read; (ii) no answer on a direct toolkit: rows held anywhere, said once, placed again on the next read; (iii) the same on the Runner lane, per run; (iv) the views read the placement's homes — the declared home's chain under every answer, read once per placement, the account home's only when it answered; (v) every reader of the home list is empty under no answer, and a toolkit built from `readDenyHomesOf` holds the rows anywhere (the account home's file, the declared home's file and the project's settings refused, a plain file read), then carries both homes once the lookup answers; (vi) the `~` base is still the child's `HOME`; (vii) the failure is classified totally (an `info` / `message` getter or `toString` that throws, an unprintable value, a console that throws) and the tool call still ends with a frame; a non-absolute answer is a bad value, said once; ⑥ no declared home ⇒ no placement; ⑦ the account home reached through a mount that the declared home lies below (Read / `cat` / Grep refused; the view of a path through the mount is the account home's spelling), also when the declared home's own identity probe fails; ⑧ the placement read the account home, then the lookup fails (EMFILE): the alias spellings of both homes are still refused (toolkit and Runner), nothing is kept for the process and a placement's judgments ask the account 0 times, an account home that does not exist or is a plain file is an answer (the chain read once, the placement whole). `test/backlog1244-home-view-snapshot.test.ts` (baseline car-7381: 10 of 11 red) — T1–T3 a placement that answered (ENOENT / a bad value) then a failing lookup: the declared home's alias refused (toolkit, Runner: Read / `cat` / Grep); R3a an explicit `[H, A]` under a lookup that never answers: both aliases refused, the account asked 0 times; R3b an account home whose path probe hangs: the first judgment ends under 3 s, the declared home's alias and the account home's own file refused, the project's settings read, the Runner's run not failed; R3c an account home whose probe fails: probed by the placement only, the project's settings read; Q1 under no answer a user's Read deny rule and an Edit deny rule (the write face) meet the declared home's alias; Q2 a declared home created after the placement is read once it exists; N a declared home that is a symlink to a file, a device node or a socket: a whole placement, not replayed; a directory the probe may not enter is no answer; R2 an account home that answers after a whole placement is not placed by the toolkit (the account not asked), the Runner's next run places it; R4 the account home's probe answers the run's placement and fails afterwards: Read / Grep / Glob through its alias refused on the Runner lane, its chain read once per run; a carried copy of the list holds the rows anywhere and is named; a host's own list is read once by the toolkit itself; R6 a host's list `[H, A-link]` whose A-link answers its canonical spelling while its chain and heads are slow: Read / Grep of A-real refused, the anchors carry A-real; a symlinked declared home in a list is anchored at its canonical spelling too; a list of real directories is placed at exactly the list; R5 a symlinked account home whose canonical spelling answers at once and whose chain is slow: anchored at the real path (a Grep over the link, a Read and a `cat` of the real path refused), a canonical probe that hangs leaves the spelling alone, `readDenyHomesOf` lists the placement's anchors. `test/backlog-r2-default-tiers.test.ts` asserts the whole homes list.
|
|
16
|
+
|
|
17
|
+
### The text-repair lane's fallback tool-call id never carries the tool's name (#1219; behavior (the id's shape); +0 exports) (@server @test)
|
|
18
|
+
|
|
19
|
+
- **Before (v7.38.0).** `repairTextToolCalls` minted `repair_<tool name>_<body>`. A roster name is only length-bounded (1–128), so a non-ASCII name or a 120-character name made an id outside the admitted shape that every native lane's id is held to, with no fold and no notice.
|
|
20
|
+
- **Now.** The prefix is the constant `repair_` (as `toolu_` / `call_` are): `repair_<opaque body>`, in the admitted shape, kept verbatim by `admitToolCallId`; the id no longer names the tool. Off by default (`createOpenAIBrain({ repairTextToolCalls: true })`).
|
|
21
|
+
- **Who is hurt.** A consumer that parsed a repaired call's tool name out of its id, or matched `^repair_<name>_`. Nothing in the engine does.
|
|
22
|
+
- **Compensation.** Read the call's `name`.
|
|
23
|
+
- Pins: `test/backlog1219-repair-fallback-id.test.ts` (non-ASCII, 120-character, fold-separator and space names; the openai lane end to end); `test/brain-protocol-coverage.test.ts` re-pinned.
|
|
24
|
+
|
|
25
|
+
### Docs: which of a call's names a host's own by-name policy reads (#1220; docs only; +0 exports)
|
|
26
|
+
|
|
27
|
+
`docs/INTEGRATION-CORE.md` §4.6f: a tightening policy (deny, ask, require approval) reads the whole of `ruleNamesOf(req)`; a loosening one (allow, auto-approve) reads `toolName`, the wire name. No code change; `ruleNamesOf` was already exported.
|
|
28
|
+
|
|
29
|
+
### Contract sentence: the `crashed` cause of `task.user_steer_undrained` (docs only) (@server @cli @client-core)
|
|
30
|
+
|
|
31
|
+
- `USER_STEER_UNDRAINED_CAUSES` member `crashed` now reads "accepted by the serving layer and not confirmed consumed before the process died" (was "never consumed"). A live leg gives the serving layer no evidence that a steer reached the model, so the arm cannot claim the steer was never consumed; a consumer must not tell the person to re-send without checking the run's transcript. No code, word or shape change. The consumption evidence itself is a separate change (#1273).
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## 7.38.0 — 2026-10-01
|
|
35
|
+
|
|
36
|
+
**Hard-breaking changes:** none.
|
|
37
|
+
|
|
38
|
+
**Closed sets.** `TOOL_TURN_REPAIR_FORMS` / `ToolTurnRepairForm` (internal, not a package export): +`duplicate_call`, +`orphan_result` (#1241 / #1242); the tool-call-id fold notice's reason set: +`duplicate` (#1234). `ENGINE_NOTICE_CODES` +5: `instructions.source_changed`, `session.records_skipped`, `memory.layer_locked_legacy`, `server.model_unreachable` (server-minted, #1240), `task.tool_turn_incomplete` (#1230) — a downstream table generated from it or pinned to its count gains five rows. New: `NOTICE_MINTER` (#1240), `USER_STEER_UNDRAINED_CAUSES` (#1233), `TOOL_TURN_INCOMPLETE_CAUSES` (#1230), `TerminalFailureDetail["kind"]` (#1229); the `brain.echo_dropped` trace frame's `field`: +`apiErrorCode` (#1229). Otherwise none move. The receipt now holds every closed-set-typed decision member to its set at run time (`action`, `decisionReason`, `origin`, `askClass`, `classes`, `foldedAskClasses`, `mandate`, `guard`, `ruleUnreadable`, `authoredBy`); the sets themselves are the exported lists already published.
|
|
39
|
+
|
|
40
|
+
**Package exports:** +8 / −0 — `screenAskClassMembers` (#1249); `NOTICE_MINTER`, `NoticeMinter` (#1240); `USER_STEER_UNDRAINED_CAUSES`, `UserSteerUndrainedCause` (#1233); `TOOL_TURN_INCOMPLETE_CAUSES`, `ToolTurnIncompleteCause` (#1230); `TerminalFailureDetail` (#1229). New optional type keys: `TerminalCause` (`failed`) `.detail`, `AssistantMessage.apiErrorCode` (#1229). Every exported `as const` closed-set array (112 declarations, 57 files) is now `Object.freeze`d where it is declared; the declaration types are unchanged.
|
|
41
|
+
|
|
42
|
+
**Deferred (self-promise from 7.37.0, #1213).** The legacy mode-seed migration (`migrateLegacyModeSeed`: checkpoint rows still carrying `autoModeRequested` / `approverPosture` / a doctrine `shellGate`) was promised removed in 7.38.0. It stays through 7.38.x; the removal — and the refusal of a row still carrying those keys, a hard-breaking change for a serving layer resuming pre-7.35 rows — moves to **7.39.0**, where it gets its own criterion. The `resume.legacy_mode_seed_migrated` notice and the three code comments now say 7.39.0 (the four spellings stay one sentence). Reason: 7.38.0 already carries four cars; a hard-breaking removal rides a version whose judge post can name it.
|
|
43
|
+
|
|
44
|
+
### `screenAskClassMembers` is exported (#1249; +1 export) (@server)
|
|
45
|
+
|
|
46
|
+
The engine's own screen for an ask-class member list — the one `isAskClass`-and-pair check the gate uses — is a package export, so a serving layer that reads an ask-class pair off a card (server `readAskClassPair`) reads it through the engine's screen instead of a second copy of the rule. Additive; no behavior change inside the engine.
|
|
47
|
+
|
|
48
|
+
### The receipt holds every closed-set member to its set, and a nested record that is present must be a record (#1231 / #1239; behavior (tighten: the contract screen); +0 exports) (@server @cli @client-core @test)
|
|
49
|
+
|
|
50
|
+
- **Before (v7.37.1).** The contract screen held a decision's `action` and `decisionReason` to their sets and nothing else. An ask carrying `foldedAskClasses: ["not_a_class"]` reached the class derivation and threw: on the gate lane the call ended with no `tool_end.gate` record, on the inherited lane as `policy check aborted`; `origin: "junk"` on an ancestor's ask threw at the record screen; `askClass` / `classes` / `mandate` / `guard` / `ruleUnreadable` / `authoredBy` outside their sets rode through. A nested contract member that was present but not a record — `denialLimitFallback: null`, `new Date()`, `[]`, a `Map`, `{}` — was skipped (`null`) or received as `{}`: `Cannot read properties of null` in the tool result, a request carrying a count-less fallback. The inherited station's mode release disclosed the ANCESTOR decision's own `origin` / `mandate` words (`origin: "org_rule"`, a bare `mandate: "operator_always"` appeared on `tool_end.gate.disposition.released`).
|
|
51
|
+
- **Now.** One table in the receipt (`src/core/decision-receipt.ts`): a closed-set member holds a member of its set or is absent (`action` required); a LIST member (`classes`, `foldedAskClasses`) is read under the receipt's own law — an ordinary array, its `length` and every index an own data property (a hole, an `undefined` element, an accessor index or a Proxy array is refused unread), each element in the set, and the engine's own copy is what rides; anything else is refused at the receipt, before any approver, `{ kind: "denied", deniedBy: "policy" }` on the wire, the model-facing text naming the member and the set — the one code every out-of-contract arm already carried. A nested record that is present (`denialLimitFallback`, `probeCause`, its `roots` / `further`) is an ordinary object holding its required members (`consecutive` / `total` / `limit`; `code` / `roots`; `shown` / `total`); `null`, a Date, an array, a Map, a partial record are refused by path. `null` is a value outside the contract, not absence (the reference implementation spells optional record members `.optional()` — undefined only — and lists a present member of the wrong type as an invalid value); `undefined` is absence, as before. The inherited station's release discloses the station's own words: the judge's `origin` and the mandate word the card's derivation yields (a bare self-declared `mandate` yields none; a self-declared `mandated: true` is `operator_forced` and is asked, not released).
|
|
52
|
+
- **Who is hurt.** A policy (own or an ancestor's) that spells one of these members outside its set: it threw or rode through before, it is refused now with the screen's code. A policy whose nested record is `null` or partial: refused by name, where it threw or was received as `{}`. A host reading the inherited release's `origin` / `mandate` off the ancestor policy's spelling: it reads the engine's derivation now.
|
|
53
|
+
- **Compensation.** Spell the member from its exported list (`ASK_ORIGINS`, `ASK_CLASSES`, `PERSISTED_RULE_MANDATES`, …); leave an optional record OUT rather than `null`; give a record its required members.
|
|
54
|
+
- **Release-round additions (r2).** The boolean-typed members are closed sets too: `requiresRealApproval` admits `true` / `false`, `probeMandated` and `mandated` admit `true` — a string `"true"`, a `1`, a `null`, an object is refused by name (a station reads them `=== true`, so a host's text spelling of a mandate would otherwise read as the other word and release a real-approval demand). An operand record's list (`probeCause.roots.shown`, `probeCause.further.shown`) is received like the decision's own lists — an ordinary array, own data elements, each a string, into the engine's copy — before any fence or station reads it (a fence running `every` on the caller's array read it a second time and ran its code; a revoked Proxy there threw instead of denying). Pins: `test/backlog1231-receipt-closed-sets.test.ts` ⑦.5 / ⑦.6.
|
|
55
|
+
- **Release-round addition (r6 / r8).** The PreToolUse hook seam is the SAME receipt: a hook's result is received, screened and detached like a policy's (before: the seam returned the hook's object unread, so `requiresRealApproval: "true"` from a hook read as the other word and a blanket `onAsk: "allow"` cleared it), with `decisionReason` not held to its set (the fold re-stamps `"hook"`) and `additionalContext` admitted as a string. A hook result with no `action`, or a result of `undefined` / `null` / `false` (`cond && {...}`), is no opinion (the reference implementation's `permissionDecision` is optional); any other non-object (`0`, `""`, `true`) is refused. The receipt's refusal at this seam is the ENGINE's decision: it exits through the fail-closed arm (the deny observer fires with the receipt's sentence; nothing is raised on `onError` — no code crashed), never as the hook's own deny; a hook rewrite that cannot be detached is refused here, before any durable park (the park's own clone check no longer sees it). **Who is hurt:** a hook returning a non-boolean mandate, an out-of-set `action` (before: read as no opinion), members held by accessors or on a prototype, a `null` where the type says a string or a record, or a non-string `additionalContext` — refused now, fail-closed; the model reads the receipt's sentence. Pins: ⑦.7; `test/backlog1226-decision-receipt.test.ts` ⑨.6 and `test/backlog1221-decision-action-closed-set.test.ts` ④.5 (re-pinned); `test/park-mint-unclonable-args.test.ts` (the hook arm re-pinned to the receipt's refusal); `test/core-security-coverage.test.ts` (a hook rewrite rides as the engine's copy).
|
|
56
|
+
- Pins: `test/backlog1231-receipt-closed-sets.test.ts` — ①/② each member's out-of-set values on both lanes (ran 0, asked 0, the member named); ③ the nested shapes on both lanes and the screen's `null` / `undefined` / missing-member sentences; ④ in-set values and well-formed records pass value-equal, an in-set `foldedAskClasses` still seeds the class set; ⑤ the inherited release carries `origin: "policy"` and no bare mandate (the gate lane already did). Baseline (main 1911a6246): 23 of 30 cells red.
|
|
57
|
+
|
|
58
|
+
### One call, one card: a person's yes at an ancestor's station answers the child's gate's plain question (#1216; behavior (fewer approval cards); +0 exports) (@server @cli @test)
|
|
59
|
+
|
|
60
|
+
- **Before (v7.37.1).** A delegated shell call met two cards for one `toolCallId`: the ancestor station's (`["ordinary"]` or `["ask_rule"]`) and, on byte-identical arguments, the child's own gate's shell-seat tighten (`origin: "shell_gate_tighten"`, `["ordinary"]`). The duplicate-frame collapse refused to reuse the grant for any ask whose `decisionReason` was not `rule` (a safety tighten, a hook's ask, a classifier's fallback) — a guard the class rule of 7.37.0 (#1218) had made redundant for every mandated question and wrong for the plain one.
|
|
61
|
+
- **Now.** Coverage alone decides: the grant records the class set the person answered and the gate reuses it iff its own stamped classes are covered. A hook's ask (`hook`), a fallback (`denial_limit_fallback`), a marked tool (`operator_forced`), a protected write (`write_protection`), an out-of-root read (`probe`) are not covered by a plain card and are asked as before; the shell seat's classify-tier hesitation (`ordinary`) is covered — one card, the call runs, the gate's settlement is the person's yes.
|
|
62
|
+
- **A marked tool's one card carries the mark's frame — and only those marks are covered.** The inherited lane's card for a tool marked `egress` or `irreversibility: "always"` (the station classes it `operator_forced` from the tool's seat) appends the gate's tighten sentence (`markTightenMessage`, one spelling in `safety-axis-vocab.ts`), so the one card a person answers says what the mark means; the grant it records then covers the gate's tighten honestly. A `"maybe"` tier (the probe's question, with the probe's reason) and the shell gate's own question are NOT classed at the station and keep their own card after the ancestor's — the person is never asked less than before for those. Before 7.38.0 every mark minted a second frame (RB-40 F-2 / F-2b / F-2c / F-2d pin the four shapes).
|
|
63
|
+
- **Who is hurt.** A host counting two cards on a delegated shell call sees one. Nothing else moves: every protection class still reaches its own card (pins ②).
|
|
64
|
+
- **Compensation.** None needed.
|
|
65
|
+
- **Release-round additions (r2 / r3).** A shell-seat tier (the operator's `shellGate:"always"` on Bash / Monitor) is not a mark the inherited station classes, and a grant never answers the shell floor's own question (`mandate: "operator_always"`) whatever classes the card it came from carried — so a delegated Bash under the floor gets the ancestor's card AND the shell gate's own card (a deployment-authored ancestor ask shares the `operator_forced` class and used to collapse the two). An ancestor ask without words keeps the provenance sentence ahead of a mark frame. Pins: `test/inherited-gate-rb40-review-gaps.test.ts` F-2e / F-2f.
|
|
66
|
+
- **Behavior change under `dontAsk` / `bypassPermissions` (release round r2, disclosed).** Because the inherited station no longer classes a shell-seat tier as a forced mark, an ancestor's PLAIN ask about a delegated shell call is now an ORDINARY question at the station, and the leg's mode arbitrates it as one: under `dontAsk` it is refused by the mode (`deniedBy: mode`; v7.37.1: the station mis-classed it forced, asked TWO cards — the ancestor's and the shell gate's — and ran the call); under `bypassPermissions` the mode releases it and only the shell gate's own card comes (v7.37.1: two cards). **Who is hurt:** a deployment running delegation under `dontAsk` with an ancestor policy asking about shell calls and an approver seat that answered it. **How to change:** answer the shell gate's own card (it still comes under every mode but `dontAsk`); under `dontAsk` nothing asks — the direction C-R201 rules for every ask in 7.39.0 (#1262). **If not:** the delegated shell call is refused under `dontAsk`. Pins: the station cells in `test/inherited-gate-rb40-review-gaps.test.ts`; the mode matrix is the reviewer's reading (Bash / Monitor × 5 modes × 9 ancestor asks).
|
|
67
|
+
- **Known limit (KL-1216-a, ruled C-R200).** When the host hands the SAME policy object to `toolPolicy` and to `parentConstraints[].policy` under the operator's `shellGate:"always"`, the deployment-authored ancestor ask stays in the fold and the shell floor's own card is still covered by the ancestor's yes (the same person approved the same bytes; the disclosure of the operator floor is what is missing). The obligation rides an exclusive mandate word; the design fix (#1260, 7.38.1) gives it its own fact.
|
|
68
|
+
- Pins: `test/backlog1216-one-card-per-call.test.ts` — ① the two-seat form over a real shell, a plain ask and an explicit ask rule (one card; baseline two); ② `write_protection`, `hook`, `operator_forced`, `probe` on the same form (unchanged readings); ③ the consume half as a unit. The Agent-tool form does not reproduce the second card on this tree (the child leg there has no shell seat of its own).
|
|
69
|
+
|
|
70
|
+
### A covering rule name reaches the call under any name it answers to (#1237; behavior (tighten / widen by the rule's own reading); +0 exports) (@server @cli @test)
|
|
71
|
+
|
|
72
|
+
- **Before (v7.37.1).** The exact entries of every name list read `ruleNamesOf` (the wire name and every alias the roster resolves to it); the covering entries (`mcp__peer__*`, `a2a__peer__*`) read `req.toolName` alone. A caller tool wearing the alias `mcp__peer__x` under the wire name `peer_x` was reached by `deny: ["mcp__peer__x"]` and not by `deny: ["mcp__peer__*"]`; in an allowlist the covering entry admitted the wire spelling and refused the alias; the frozen projection replay and the session-rule lane read the same way.
|
|
73
|
+
- **Now.** `namespacedCoveringHit` judges the covering entries over every name of the call through the port the exact lists read — the allow/deny policy, the approval policy's `deny` / `requireApproval` / `autoAllow`, the projection replay, the session-rule lane. Server's reading (S-919) is the engine's.
|
|
74
|
+
- **Who is hurt.** A deployment whose covering deny did not reach an aliased call (reached now); one whose covering allowlist refused an aliased call (admitted now).
|
|
75
|
+
- **Compensation.** None needed — the exact entries already read this way.
|
|
76
|
+
- Pins: `test/backlog1237-covering-rule-names.test.ts` (6 cells red on the baseline; the wire spelling and a face that does not list the call's name as controls).
|
|
77
|
+
|
|
78
|
+
### Every exported closed-set array is frozen where it is declared (#1215; behavior (a write into an exported list throws); +0 exports) (@server @cli @client-core @test)
|
|
79
|
+
|
|
80
|
+
- **Before (v7.37.1).** 112 exported `as const` arrays (`ASK_CLASSES`, `ASK_ORIGINS`, `ENGINE_NOTICE_CODES`, …) were writable at run time and the engine reads several of them directly (`classifyAsk`, `arbitrateByMode`): a caller's `ASK_CLASSES.pop()` changed the class derivation for the process. Six were frozen.
|
|
81
|
+
- **Now.** The whole class is `Object.freeze([...] as const)` at the declaration (112 declarations, 57 files); the declaration types are unchanged. `gate:closed-sets` gains a frozen-exports leg (an unfrozen member reds by file:line; census floor 100); the registry reader and `verify-child-clamp-seats` unwrap the same one wrapper the wire-change manifest already did, so nothing moves in any manifest.
|
|
82
|
+
- **Who is hurt.** A caller that wrote into an exported list: in strict code the write throws a `TypeError`, in sloppy code it is a no-op.
|
|
83
|
+
- **Compensation.** Copy the list (`[...ASK_CLASSES]`) before editing a copy.
|
|
84
|
+
- Pins: `test/closed-sets-frozen-exports.test.ts`; `test/backlog1212-mode-release-reasons.test.ts` R1 re-pinned (the write throws). KL-closed-set-arrays closed.
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
### One call per id per assistant turn: a repeated id in one response is renamed at admission, and a stored pair is cut to the first call in the request view (#1234 write side, #1241 read side; behavior + closed sets +1 / +1; +0 exports) (@server @cli @client-core @test)
|
|
88
|
+
|
|
89
|
+
- **Before (v7.37.1).** Every provider lane requires a request's call ids to be unique per turn. A provider answering one response with two calls under one id (`call_0` twice — gateway numbering) had both admitted verbatim; the engine keys a call by its id, so the two executions ran under one id, and the next request carried two `tool_use` / `tool_calls` / `function_call` with that id and one folded result — rejected on all three lanes. A stored message already carrying the pair (written before this release, a host brain's message, a side-query caller's history) went out the same way: the request view folded results by id but sent every call.
|
|
90
|
+
- **Now.** Write side: the admission reads the whole response (`admitToolCallId(raw, prefix, responseIds)`, one record per response attempt in each brain). A call whose raw id the response already handed out — the second and later calls with one raw id, or a raw equal to an id an earlier rename minted — is renamed on both faces: provider id `<lane prefix><digest>` in the admitted shape, deterministic, never an id the response already handed out; engine id `<base>~<arrival>~<digest of the provider id>` (never equal to an admitted id or a charset / length fold, and naming one provider id across the session, so a result replayed from an earlier response is handed back under its own call's id). Every call runs under its own id, every result reaches the model, and the provider gets distinct ids back. Announced once per run as `route.tool_call_id_folded`, `reason: "duplicate"` (`toolCallFoldReason` reads the reason off the call; an in-shape provider id beside an engine id the admission did not mint is a host brain's own spelling and no fold). Read side: the tool-turn law keeps the FIRST call per id in each assistant message and leaves the rest out (form `duplicate_call`); every result recorded for that id answers the kept call (folded as before, `duplicate_result`). The transcript is never rewritten.
|
|
91
|
+
- **Who is hurt.** A consumer with a closed table of the fold reasons or the repair forms (+1 row each). A consumer reading `detail.rawLength` of `route.tool_call_id_folded` unconditionally (absent on `duplicate`). A consumer that keyed a duplicate pair's second call by the provider's raw id: its engine id and provider id are now the rename.
|
|
92
|
+
- **Compensation.** Add the two rows; read `rawLength` only for `charset` / `length`. Nothing else moves: a response whose ids are distinct is admitted exactly as before.
|
|
93
|
+
- **Known limits.** A request whose view left a duplicate call out is a lossy projection: no fork summary seeds from it (`TOOL_TURN_REPAIR_DROPS`). The view dedupes on the engine id; two calls a host brain gave distinct engine ids but one `wireId` still reach the wire as a pair.
|
|
94
|
+
- Pins: `test/backlog1241-duplicate-call-ids.test.ts` — the law driven directly (first call kept, results folded, with a trailing block, a later-turn reuse untouched), the read side through a real Runner on each lane, the write side through a real Runner on each lane (two and three calls under one id; a raw equal to a rename's id, before and after the pair; a rename in one response and a raw equal to its provider id in the next), the admission and the seat's reading, the side query on each lane. Baseline v7.37.1: 20 of the first 23 red (the wire on all three lanes: two calls with one id, one result).
|
|
95
|
+
|
|
96
|
+
### A result that answers no call is left out by the one request-view law, on every request builder (#1242; behavior + closed set +1; +0 exports) (@server @cli @test)
|
|
97
|
+
|
|
98
|
+
- **Before (v7.37.1).** The harness lane dropped a tool result with no earlier call carrying its id (its call gone, or recorded ahead of its call) with a separate request-time belt (`dropOrphanToolResults`), reported only as an `onError` line (`context belt: dropped N orphan toolResult(s)…`). `runSideQuery` had no such belt: the same caller-supplied history reached the provider as stored, and every lane rejects a result that answers no call right before it.
|
|
99
|
+
- **Now.** The tool-turn law leaves such a result out and says so: form `orphan_result`, one repair for the view (its ids once each, in transcript order). The belt is deleted; the harness and the side query read the one view. The harness's lossy-request flag keeps its orphan arm without a second pass: the run's repair sink (installed by the context lane, which now owns the `context.tool_turn_repaired` seat) marks the request lossy when the boundary's repairs include a form that leaves content out (`duplicate_call`, `orphan_result`), so no fork summary seeds from it. The law runs once per request.
|
|
100
|
+
- **Who is hurt.** A deployment that watched `onError` for the `context belt:` line: it no longer fires; the same fact arrives as `context.tool_turn_repaired` with `orphan_result`. A consumer with a closed table of repair forms (+1 row). `runSideQuery` callers whose history carried an orphan: the request now leaves it out instead of failing.
|
|
101
|
+
- **Compensation.** Read the notice instead of the `onError` line; add the form row.
|
|
102
|
+
- Pins: `test/backlog1242-orphan-results.test.ts` — the law driven directly (a result ahead of its call, a result whose call is gone, several orphans, a legal list), the harness lane through a real Runner on each lane (no wire trace, one notice, no `onError` line, the transcript unchanged), the side query sending what the harness lane sends, the fork-seam lossy guard with its control. Baseline v7.37.1: 13 of 16 red. Re-pinned: the belt's unit and e2e cells (`test/token-estimate-calibration.test.ts`), the context lane's orphan cell (`test/prepare-context-lane-transcript.test.ts`), the #1224 "left where it is" cell.
|
|
103
|
+
|
|
104
|
+
### A reused tool-call id no longer makes every acquire append another interrupted close (#1235; behavior (the persisted reconcile); +0 exports) (@server @cli @test)
|
|
105
|
+
|
|
106
|
+
- **Before (v7.37.1).** With an id used again by a later turn before the earlier use had a result ([A1: call X] … [A2: call X] [result X] — per-request id numbering), the reconcile treated A1 as an orphan and appended an interrupted close at the transcript's tail. A result at the tail answers the id's nearest earlier call — A2, already answered — so A1 stayed an orphan and every later acquire appended one more close (measured: 1 → 2 → 3 over three acquires, through the reconcile alone and through the real Runner).
|
|
107
|
+
- **Now.** The reconcile closes only what an append can close: each id's last call site with no result after it, once (the positional reading the oracle `findOrphanToolCalls` and the request view share). An earlier unanswered site stays open in the transcript; the request view closes it on the wire (`missing_result`). Two calls under one id in one message, unanswered, get one close, not two. `findOrphanToolCalls` is unchanged.
|
|
108
|
+
- **Who is hurt.** A reader of the transcript that expected a persisted close for the earlier use (the close it got answered the later use, never the earlier one). A run on such a session announces `missing_result` for the earlier use once per run.
|
|
109
|
+
- **Compensation.** None needed — the request is the same as before for the earlier use (closed as interrupted on the wire).
|
|
110
|
+
- Pins: `test/backlog1235-reconcile-reused-id.test.ts` (the reconcile alone three times; three runs on one session per lane; a last site closed once; a pair in one message; an earlier and a later use both unanswered). Baseline v7.37.1: 6 of 7 red (the control green). Re-pinned #1224 ①c: no stored close lands on the later use.
|
|
111
|
+
|
|
112
|
+
### The request view's interrupted close states what it does not know (#1232, narrow; docs; +0 exports)
|
|
113
|
+
|
|
114
|
+
- **Now.** The close the view synthesizes for a call with no result carries no tool-effect refinement and no started set — every such close reads the reconcile family's "outcome unknown — verify" wording, whatever the tool; stated where the view mints it (`src/engine/llm/tool-turns.ts`). Unchanged: the persisted reconcile refines with the run's tool effects, and — on a live abort only — with the calls it saw start. After a crash, the acquire reconcile still cannot tell a call that never started from one whose outcome is unknown (both read `interrupted_outcome_unknown`): that needs a durable "started" record before each execution and is a design item, not in this release.
|
|
115
|
+
|
|
116
|
+
### Server-minted notice codes are catalogued, with their audience and a minter table (#1240; closed set +4, +2 exports) (@server @client-core @cli @test)
|
|
117
|
+
|
|
118
|
+
- **Before (v7.37.1).** A server forwards four notices of its own through the engine's sink — `instructions.source_changed`, `session.records_skipped`, `memory.layer_locked_legacy`, `server.model_unreachable` (7.108.0). None was in `ENGINE_NOTICE_CODES` / `NOTICE_AUDIENCE`, so a consumer generating its catalog and audience table from this package (client-core) had no row for them: `noticeAudienceOf` read the conservative `"operator"` and a user-audience projection never showed them; the server kept its own audience table beside this one.
|
|
119
|
+
- **Now.** The four are catalogued with their audience (`user` / `user` / `operator` / `user`), and a new lockstep table `NOTICE_MINTER: Record<EngineNoticeCode, "engine" | "server">` (frozen, exported with `NoticeMinter`) says who mints each code — `"server"` for these four, `"engine"` for every other. This engine never mints a server row: the catalog gate's "still minted" arm judges engine rows only, and a new arm reds a mint site for a server row under `src/`. Each row's `detail` shape is on `EngineNotice.code`; `server.model_unreachable` states the server's closed detail (`code` one of six connect failures, `targetHost`, the constant `remedy`, `sessionId`, optional `proxyInUse` ∈ `HTTP_PROXY` / `HTTPS_PROXY`; no `runId`; once per leg per target).
|
|
120
|
+
- **Who is hurt.** A consumer whose table is generated from `ENGINE_NOTICE_CODES` / `NOTICE_AUDIENCE`, or pinned to their counts: four more rows. A consumer that hand-mirrored the server's audience table: two sources now agree, and the server's table can retire into this one.
|
|
121
|
+
- **Compensation.** Regenerate the table; read audience and minter from the package.
|
|
122
|
+
- Pins: `test/backlog1240-server-minted-notices.test.ts` (rows, audience, minter, frozen, the gate's two arms as this ticket reads them, the model-unreachable detail sentence); `test/governance-codes.test.ts` (the still-minted arm over engine rows, the never-minted arm, the user list).
|
|
123
|
+
|
|
124
|
+
### `task.user_steer_undrained` says why: `detail.cause` closed { `leg_end`, `crashed` } (#1233; +2 exports) (@server @cli @client-core @test)
|
|
125
|
+
|
|
126
|
+
- **Before (v7.37.1).** The code meant one thing — the leg ended with an accepted steer still in its queue. A steer the serving layer had accepted when the engine process died was lost with no notice the code could carry; a separate code would have added a row to every de-duplication table.
|
|
127
|
+
- **Now.** The steer row's `detail.cause` (closed `USER_STEER_UNDRAINED_CAUSES`): this engine mints `leg_end` on every steer row it announces; `crashed` is minted by the serving layer before the session's next run, for the steers it accepted that the dead process never consumed — the one member-level exception to the code's `NOTICE_MINTER` row (`"engine"`), stated beside the table, on the catalog row and in INTEGRATION-CORE. The follow-up row is unchanged. One code for both arms, so de-duplication by code holds.
|
|
128
|
+
- **Who is hurt.** A consumer that pinned the steer row's detail keys exactly: one more key.
|
|
129
|
+
- **Compensation.** Read `cause`; render `crashed` as the dead-process arm.
|
|
130
|
+
- Pins: `test/backlog1233-steer-undrained-cause.test.ts` (the closed set; the factory with every id combination; the real interrupt lane; no `"crashed"` literal in engine code but the set's own member).
|
|
131
|
+
|
|
132
|
+
### A tool turn the model was handed incomplete gets a line the person sees: `task.tool_turn_incomplete` (#1230; closed set +1 notice code, +2 exports) (@cli @client-core @server @test)
|
|
133
|
+
|
|
134
|
+
- **Before (v7.37.1).** When a brain dropped tool calls (arguments cut before they parsed, no tool name) or unreadable stream frames, the model read a `[note: …]` in the turn and the person saw nothing; when the request view closed a call with no recorded result as interrupted (#1224), only the operator's `context.tool_turn_repaired` said so.
|
|
135
|
+
- **Now.** One user-audience code for the family: `task.tool_turn_incomplete`, `detail: { cause, count, toolCallIds?, sessionId, runId }`, `cause` closed `TOOL_TURN_INCOMPLETE_CAUSES` = `truncated` / `unnamed` / `bad_frame` / `missing_result_synthesized` (`toolCallIds` on the last only, each id in the provider echo shape — a stored id outside it is not shown, `count` still counts it). Closed words, a count and call ids — never a tool name, an argument or the note's text. ONCE PER RUN PER CAUSE. The three brain causes come from the same note list the model reads: `placeTurnNotes` takes typed notes and stamps them on the turn's message (`diagnostics`, entry `stream_drops`), and the run reads them at `message_end` in all three lanes; the missing-result cause is announced by the repair seat beside its operator line, which is unchanged.
|
|
136
|
+
- **Who is hurt.** A consumer pinned to the catalog's count or the user-row list: one more row. A consumer reading an assistant message's `diagnostics` exhaustively: one more entry type on a turn that dropped something.
|
|
137
|
+
- **Compensation.** Add the row; render the line by `cause` (wording and default visibility are the shell's).
|
|
138
|
+
- Pins: `test/backlog1230-tool-turn-incomplete.test.ts` (the vocabulary; every lane × stream cause stamps exactly that drop; a real Runner per lane × cause — one notice, detail exactly `{ cause, count, sessionId, runId }`, the same cause again silent, two causes two lines; the view's missing result once per run with its ids, a stored id outside the echo shape not shown; a malformed or throwing diagnostic reads as no drop; no free text). Re-pinned: `test/backlog1224-tool-turn-invariants.test.ts` reads the operator code by code (the seat now also mints the user line).
|
|
139
|
+
|
|
140
|
+
### A provider's 4xx refusal leaves a machine-readable account on the failed terminal (#1229; optional keys +2, +1 export) (@cli @client-core @server @test)
|
|
141
|
+
|
|
142
|
+
- **Before (v7.37.1).** A run the provider refused (a structured 4xx — a thinking-block echo, a bad parameter) ended `terminal: { kind: "failed", code, message }`: the message carried the provider's body as text and `apiFailure.status` the status; nothing on the terminal itself said "the provider refused this request as sent", and the provider's own error word was only in the prose.
|
|
143
|
+
- **Now.** `terminal.detail = { kind: "provider_rejected", status, code? }` (`TerminalFailureDetail`, exported; `kind` closed), minted in the provider-error arm beside `apiFailure` and present only when a provider failure with a 400–499 status named the result — not when a lost session lock (`conflict`) or the request-build guard names the code (one predicate at the read, the lift and the assembly; each member of the message read once). `code` is the provider's own word from its JSON error envelope (`error.code`, else `error.type`) — read structurally at the HTTP throw, carried on the new optional `AssistantMessage.apiErrorCode`, only in the provider echo shape (visible ASCII, 1–128); a word outside it is dropped whole, never truncated, and counted as one `brain.echo_dropped` frame (`field: "apiErrorCode"`). Absent on a 5xx, a connect / stream failure, an in-band error inside a 200 and every non-provider failure. A 401 / 403 / 429 is a refusal too: the status says which.
|
|
144
|
+
- **Who is hurt.** A consumer that compared `terminal` for a failed run exactly: one more key on 4xx terminals. A consumer with a closed table of the echo-drop fields: one more row.
|
|
145
|
+
- **Compensation.** Read `detail` by `kind`; branch on `status` to tell a request the person can change and resend from one they cannot.
|
|
146
|
+
- **Known limits.** The engine's persisted API-failure record (the synthetic transcript entry) carries `apiErrorStatus` but not `apiErrorCode`.
|
|
147
|
+
- Pins: `test/backlog1229-provider-rejected-detail.test.ts` (the message seat for an OpenAI and an Anthropic envelope, plain text, a 5xx, a word outside the shape with its one drop event; the run's terminal for 400 / 401 / 503 / a connect failure; the assembly arm for a host message, an unmarked 4xx, a higher-ranked terminal, a `conflict` / request-build-guard code, a member that answers differently per read or throws).
|
|
148
|
+
### `sessionMemoryStatus` answers where the session's memory is written — `writeHome` (#1222; additive: one optional key; +0 exports) (@server @cli)
|
|
149
|
+
|
|
150
|
+
- **Before (v7.37.1).** `MemoryEngine.sessionMemoryStatus(sessionId)` carried five keys and no path. A serving layer that wanted to show where a session's memory lands had no engine read for it: `materialize()` writes (registers the scope, opens the account), and `TaskResult.effectiveMemoryScopes` names the scope, not its directory.
|
|
151
|
+
- **Now.** `SessionMemoryStatus.writeHome?: string` — the canonical absolute directory of the write scope the session wrote under most recently (`canonicalize(scopeDirFor(...))`, the derivation behind a leg's `MemorySessionHandle.writableRoot`). Two dated sources name a scope and the NEWER answers first: the session's account row (every sessioned, unrestricted mount with a write face records it, dated by the account's (re-)open; readable from any engine on the control plane, live leg or ended) and the entry carrying the session's newest committed lineage contribution (dated by that commit; that one entry, chosen from the ledger before any read) — so a commit after an older account leg ended, under another scope through an engine that opens no account (provenance `"off"`), names the home while its entry stands. A SILENT source yields to the other (the newest contribution's entry is gone — deleted, a tombstone — or there is no account row); a FAULTED read (the account ledger or the lineage throws) answers absent and never falls through. Neither → absent. Known limit KL-1222-b: the File backend's committed read reports an unreadable entry as absent, so an I/O failure on the newest entry reads as a deletion there. A registry that cannot derive a home (v1, an unfinished layout move) answers absent before either source is read, so no `writeHome` read finishes a migration and then names a post-migration home. `scopeDirFor` stays internal.
|
|
152
|
+
- **In-presence condition.** Present ⇔ a source names a scope and the scope registry derives paths. Absent ⇔ the session never opened an account and never committed, the account ledger or the lineage faulted, the newest contribution's entry could not be read, or the registry is in its v1 / mid-migration form. Absence is not an engine-version signal.
|
|
153
|
+
- **Who is hurt.** Nobody: a reader that ignores the key is unchanged; a consumer comparing the whole status object with deep equality sees one more key on a session that wrote.
|
|
154
|
+
- **Compensation.** None needed.
|
|
155
|
+
- **Release-round addition (r3).** A lineage contribution is dated by its CREDENTIAL (the commit's durable time) on both promotion paths — the direct promotion and the crash recovery that replays a credentialed orphan — where the recovery used to date it at replay time; `writeHome`'s ordering reads that date, so a recovered old commit no longer outranks a later account. Pin: `test/backlog1222-lineage-credential-date.test.ts`.
|
|
156
|
+
- **Release-round addition (r6).** A SILENT source yields, a FAULTED source answers absent: when the newest contribution's entry is gone (deleted, a tombstone) the account row answers — a deleted note is an ordinary state; an unreadable ledger still answers absent. A tie between the two dates goes to the account. Pins: the write-home pin's deleted-note and tie cells. (A credentialed orphan promoted after a later transaction of the same pair still re-dates the row — KL-1222-b, #1261.)
|
|
157
|
+
- **Known limit (KL-1222-a).** The two dates are wall-clock times as the host clock read them (the account's open, the credential's record); a clock that runs backwards between them can misorder the two sources either way, and the engine does not clamp (a clamp inverts the opposite case) or keep a monotonic ordering key yet (#1261, 7.39.0).
|
|
158
|
+
- Pins: `test/backlog1222-memory-status-write-home.test.ts` — each source (the account row from another engine before any commit, after the leg ended, a later leg's scope over an older commit's; the lineage without an account row, under provenance `"off"`), the absences (never mounted, a corrupt account ledger with a readable lineage, a corrupt lineage, the newest contribution's entry removed beside a readable older one, a registry carrying an unfinished layout move), the physical path through a symlinked memory dir. Baseline (the branch base `7a17f399d`): 8 of 11 red — the key is absent everywhere, so only the three pure-absence cells pass.
|
|
159
|
+
|
|
160
|
+
### Hook contexts carry the leg's live permission mode — `permissionMode` (#1223; additive: one optional member on five context types; +0 exports) (@server @cli)
|
|
161
|
+
|
|
162
|
+
- **Before (v7.37.1).** No hook context carried the mode. A serving layer forwarding a `permission_mode` in a hook payload composed its own word (the request's, or the checkpoint's), which missed a `setPermissionMode` steer, the model's `EnterPlanMode` / `ExitPlanMode`, and a resumed leg's restored word.
|
|
163
|
+
- **Now.** `HookToolContext` (preToolUse / postToolUse / postToolUseFailure), `PermissionDeniedPayload`, `UserPromptSubmitContext`, `PostToolBatchContext` and `StopHookContext` carry `permissionMode?: PermissionMode` — the leg's EFFECTIVE mode read off its live seat when the callback is invoked (the seat's `observed`: the word `current` answers, reached through every ceiling's own `observed`, starting no classifier arming on the leg or an ancestor — `current` stays the arbitration read that arms, and an observation through it would have moved who answers the leg's next ask; the mode seat and the engine's leg ceiling are internal, no export moves). The Runner adds the member to its own legs' contexts, the gate-built preToolUse screenings and deny observations included; `runToolGate` itself stamps none and never calls `ToolGateInput.permissionMode` for an observation.
|
|
164
|
+
- **In-presence condition.** Present on every engine emission from a prepared leg. Absent on a host-driven `runToolGate` (with or without `ToolGateInput.permissionMode`), on the delegation-fold twin (`createPreToolUseConstraintPolicy`), and on the `resume_message` screen (it runs before the resumed leg exists). `PreCompactContext` / `PostCompactContext` / `StopFailureContext` carry no mode — the reference implementation's hook input omits it on those three events. Absence is not an engine-version signal.
|
|
165
|
+
- **Who is hurt.** Nobody: the member is optional and read-only; a hook that ignores it is unchanged.
|
|
166
|
+
- **Compensation.** None needed. A serving layer composing its own `permission_mode` can read this member instead.
|
|
167
|
+
- Pins: `test/backlog1223-hook-context-permission-mode.test.ts` — each mode-carrying seat under a declared mode, the deny observer under a plan question and under a policy deny, a mid-call `setPermissionMode` read by that call's postToolUse / the steer screen / the next preToolUse / stop, the model's own plan entry and a `plan_review` resume (approve ⇒ `default`, reject ⇒ `plan`), the absences (a host-driven gate with and without a reader — the reader called as often with hook seats wired as without — the fold twin, the wake screen, the compaction pair, stopFailure), the parked steer that wake redelivers (screened inside the resumed leg: present), a child whose ceiling flips into `auto` mid-batch (the second call's ask reaches the same answerer with the hook seats wired as without them), and the observation read over a grandparent → parent → child chain (arms nobody; the decision read still arms the child and the parent it reads through). Baseline (the branch base `7a17f399d`): 8 of 13 red — every cell holding a presence assertion, and the chain cell (no `observed` read there); the five pure-absence cells pass.
|
|
168
|
+
|
|
1
169
|
## 7.37.1 — 2026-10-01
|
|
2
170
|
|
|
3
171
|
**Hard-breaking changes:** none.
|
package/dist/agents/observer.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createSafeNotifier } from "../core/safe-notify.js";
|
|
|
4
4
|
import { escapeEnvelopeTag } from "../core/untrusted-text.js";
|
|
5
5
|
import { toolFace } from "../core/tool-catalog-entries.js";
|
|
6
6
|
import { isErrorLike, thrownName, thrownText } from "../core/thrown-value.js";
|
|
7
|
-
export const OBSERVER_EVENT_TAGS = ["tool-call", "user-message", "tool-result", "turn-ended"];
|
|
7
|
+
export const OBSERVER_EVENT_TAGS = Object.freeze(["tool-call", "user-message", "tool-result", "turn-ended"]);
|
|
8
8
|
const TAG_ESCAPE_RE = new RegExp(`<(?=/?(?:${OBSERVER_EVENT_TAGS.join("|")})(?:[>\\s/]|$))`, "gi");
|
|
9
9
|
export function escapeObserverTags(text) {
|
|
10
10
|
return text.replace(TAG_ESCAPE_RE, "<\\");
|
|
@@ -7,7 +7,7 @@ export const PEER_IDLE_PRIORS_KEPT = 3;
|
|
|
7
7
|
export const PEER_IDLE_FIRE_DEBOUNCE_MS = 750;
|
|
8
8
|
export const PEER_IDLE_HELD_BACKOFF_MS = 30_000;
|
|
9
9
|
export const PEER_IDLE_LABEL_MAX = 100;
|
|
10
|
-
export const CROSS_SESSION_DIALOG_EXPIRY_VALUES = ["60s", "5m", "10m", "never"];
|
|
10
|
+
export const CROSS_SESSION_DIALOG_EXPIRY_VALUES = Object.freeze(["60s", "5m", "10m", "never"]);
|
|
11
11
|
export const CROSS_SESSION_DIALOG_EXPIRY_DEFAULT = "5m";
|
|
12
12
|
export function resolveCrossSessionDialogExpiry(raw) {
|
|
13
13
|
const toMs = (v) => (v === "never" ? null : v === "60s" ? 60_000 : v === "5m" ? 300_000 : 600_000);
|
|
@@ -20,11 +20,11 @@ export function resolveCrossSessionDialogExpiry(raw) {
|
|
|
20
20
|
return { ms: toMs(CROSS_SESSION_DIALOG_EXPIRY_DEFAULT), value: CROSS_SESSION_DIALOG_EXPIRY_DEFAULT, invalid: true };
|
|
21
21
|
}
|
|
22
22
|
export const PEER_HELD_REVIEW_CAUSES = Object.freeze(["mode-mismatch", "no-mode-asserted", "invalid-mode-attestation"]);
|
|
23
|
-
export const PEER_DELIVERY_RECEIPT_STATES = ["held", "denied", "expired", "delivered", "refused", "dropped"];
|
|
23
|
+
export const PEER_DELIVERY_RECEIPT_STATES = Object.freeze(["held", "denied", "expired", "delivered", "refused", "dropped"]);
|
|
24
24
|
export function isPeerDeliveryReceiptState(v) {
|
|
25
25
|
return typeof v === "string" && PEER_DELIVERY_RECEIPT_STATES.includes(v);
|
|
26
26
|
}
|
|
27
|
-
export const PEER_DROP_REASONS = ["rate-limited", "duplicate", "hop-loop", "hop-runaway", "queue-full"];
|
|
27
|
+
export const PEER_DROP_REASONS = Object.freeze(["rate-limited", "duplicate", "hop-loop", "hop-runaway", "queue-full"]);
|
|
28
28
|
export function peerRecipientSuffix(recipients) {
|
|
29
29
|
const uniq = [...new Set(recipients.map((r) => inlineUntrusted(r, 64)).filter((r) => r !== ""))];
|
|
30
30
|
if (uniq.length === 0)
|
|
@@ -87,7 +87,7 @@ export function renderCrossSessionDroppedNotice(count, recipients = [], reasons
|
|
|
87
87
|
const R = describePeerDropReasons(reasons);
|
|
88
88
|
return `[Cross-session delivery notice] Do not resend now: ${n === 1 ? "one of your messages to another session was" : `${n} of your messages to another session were`} dropped at that session's inbox${k} and NOT delivered${R !== "" ? ` (${R})` : ""}. Treat them as unsent. If the content still matters, fold it into ONE later message after you have finished other work; never retry in a loop.`;
|
|
89
89
|
}
|
|
90
|
-
export const PEER_IDLE_NOTICE_KINDS = ["idle", "exited", "unavailable", "expired"];
|
|
90
|
+
export const PEER_IDLE_NOTICE_KINDS = Object.freeze(["idle", "exited", "unavailable", "expired"]);
|
|
91
91
|
export function isPeerIdleNoticeKind(v) {
|
|
92
92
|
return typeof v === "string" && PEER_IDLE_NOTICE_KINDS.includes(v);
|
|
93
93
|
}
|
package/dist/agents/subagent.js
CHANGED
|
@@ -358,7 +358,7 @@ function configError(message, code) {
|
|
|
358
358
|
e.code = code;
|
|
359
359
|
return e;
|
|
360
360
|
}
|
|
361
|
-
export const SUBAGENT_ERROR_KINDS = ["rate_limit", "timeout", "network", "logic", "governance", "unknown"];
|
|
361
|
+
export const SUBAGENT_ERROR_KINDS = Object.freeze(["rate_limit", "timeout", "network", "logic", "governance", "unknown"]);
|
|
362
362
|
const SUBAGENT_ERROR_KIND_BY_CODE = {
|
|
363
363
|
rate_limit: "rate_limit",
|
|
364
364
|
server: "network",
|
package/dist/agents/team-role.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export const TEAM_ROLES = ["lead", "teammate", "none"];
|
|
1
|
+
export const TEAM_ROLES = Object.freeze(["lead", "teammate", "none"]);
|
|
2
2
|
export function teamRoleOf(facts) {
|
|
3
3
|
if (!facts.isDelegatedChild)
|
|
4
4
|
return "lead";
|
|
5
5
|
return facts.spawnName !== undefined ? "teammate" : "none";
|
|
6
6
|
}
|
|
7
|
-
export const DIRECTION_RELATIONS = ["lead", "peer", "stranger"];
|
|
7
|
+
export const DIRECTION_RELATIONS = Object.freeze(["lead", "peer", "stranger"]);
|
|
8
8
|
export function mayDirect(sender, self) {
|
|
9
9
|
const root = (self.rootSessionId ?? self.sessionId).toLowerCase();
|
|
10
10
|
const fromSession = sender.fromSession?.toLowerCase();
|
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, placeTurnNotes, truncationCauseClause } from "./stream-shared.js";
|
|
6
|
-
import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
|
|
6
|
+
import { admitToolCallId, providerResultIds, providerToolCallId, responseToolCallIds } 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";
|
|
@@ -213,7 +213,7 @@ function computeUsage(model, u) {
|
|
|
213
213
|
},
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
|
-
function closeToolUseBlock(acc) {
|
|
216
|
+
function closeToolUseBlock(acc, responseIds) {
|
|
217
217
|
if (acc.closedTc)
|
|
218
218
|
return acc.closedTc;
|
|
219
219
|
if (!acc.toolName)
|
|
@@ -221,7 +221,7 @@ function closeToolUseBlock(acc) {
|
|
|
221
221
|
const args = parseStreamedToolArgs(acc.toolJson);
|
|
222
222
|
if (args === undefined)
|
|
223
223
|
return undefined;
|
|
224
|
-
const { engineId, wireId } = admitToolCallId(acc.toolId, "toolu_");
|
|
224
|
+
const { engineId, wireId } = admitToolCallId(acc.toolId, "toolu_", responseIds);
|
|
225
225
|
acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.toolName, arguments: args };
|
|
226
226
|
return acc.closedTc;
|
|
227
227
|
}
|
|
@@ -368,6 +368,7 @@ export function createAnthropicBrain(config = {}) {
|
|
|
368
368
|
makeParser: (ctrl) => {
|
|
369
369
|
const { out, partial } = ctrl;
|
|
370
370
|
const blocks = new Map();
|
|
371
|
+
const responseIds = responseToolCallIds();
|
|
371
372
|
const usageRaw = {};
|
|
372
373
|
let stopReason = null;
|
|
373
374
|
const terminalFrameSeen = () => stopReason != null;
|
|
@@ -489,7 +490,7 @@ export function createAnthropicBrain(config = {}) {
|
|
|
489
490
|
out.push({ type: "text_end", contentIndex: idx, content: acc.text, partial: { ...partial } });
|
|
490
491
|
}
|
|
491
492
|
else if (acc?.type === "tool_use") {
|
|
492
|
-
const tc = closeToolUseBlock(acc);
|
|
493
|
+
const tc = closeToolUseBlock(acc, responseIds);
|
|
493
494
|
if (tc) {
|
|
494
495
|
out.push({ type: "toolcall_end", contentIndex: idx, toolCall: tc, partial: { ...partial } });
|
|
495
496
|
}
|
|
@@ -601,7 +602,7 @@ export function createAnthropicBrain(config = {}) {
|
|
|
601
602
|
unnamed.push(`id="${acc.toolId || "?"}"(${acc.toolJson.slice(0, 200)})`);
|
|
602
603
|
}
|
|
603
604
|
else {
|
|
604
|
-
const tc = closeToolUseBlock(acc);
|
|
605
|
+
const tc = closeToolUseBlock(acc, responseIds);
|
|
605
606
|
if (tc) {
|
|
606
607
|
toolCalls.push(tc);
|
|
607
608
|
finalContent.push(tc);
|
|
@@ -624,15 +625,15 @@ export function createAnthropicBrain(config = {}) {
|
|
|
624
625
|
const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
|
|
625
626
|
const notes = [];
|
|
626
627
|
if (malformed.length > 0) {
|
|
627
|
-
notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
|
|
628
|
+
notes.push({ cause: "truncated", count: malformed.length, text: `\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]` });
|
|
628
629
|
}
|
|
629
630
|
if (unnamed.length > 0) {
|
|
630
|
-
notes.push(`\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`);
|
|
631
|
+
notes.push({ cause: "unnamed", count: unnamed.length, text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]` });
|
|
631
632
|
}
|
|
632
633
|
if (malformedFrames > 0) {
|
|
633
|
-
notes.push(`\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`);
|
|
634
|
+
notes.push({ cause: "bad_frame", count: malformedFrames, text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]` });
|
|
634
635
|
}
|
|
635
|
-
placeTurnNotes(finalContent, notes);
|
|
636
|
+
placeTurnNotes(partial, finalContent, notes);
|
|
636
637
|
const truncatedEmpty = stopReason === "max_tokens" && noUsableContent && !toolError;
|
|
637
638
|
const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
|
|
638
639
|
const staticReasoningCut = truncatedEmpty && !dynamicCut && reasoningSeen && options?.staticReasoningCutDowngrade === true;
|
package/dist/brain/errors.d.ts
CHANGED
|
@@ -64,12 +64,33 @@ export declare class BrainError extends Error {
|
|
|
64
64
|
* conservative reading for every pre-existing and third-party construction site.
|
|
65
65
|
*/
|
|
66
66
|
readonly apiError?: true;
|
|
67
|
+
/** The provider's own word for a REFUSED request (a 4xx answer), read from its JSON error envelope by
|
|
68
|
+
* {@link readProviderErrorCode} at the HTTP throw. Lifted onto `AssistantMessage.apiErrorCode` at the terminal-shell
|
|
69
|
+
* mint. Absent when the answer was not a 4xx or the envelope stated no word in the echo shape. */
|
|
70
|
+
readonly providerCode?: string;
|
|
67
71
|
constructor(code: BrainErrorCode, detail: string, status?: number, opts?: {
|
|
68
72
|
inputTooLong?: boolean;
|
|
69
73
|
inputTooLongRuledOut?: boolean;
|
|
70
74
|
apiError?: boolean;
|
|
75
|
+
providerCode?: string;
|
|
71
76
|
});
|
|
72
77
|
}
|
|
78
|
+
/** The provider's diagnostic object out of a JSON error body, or undefined when the body establishes no provenance
|
|
79
|
+
* for one: both the Anthropic `{type:"error",error:{…}}` and the OpenAI `{error:{…}}` envelopes reduce to an object
|
|
80
|
+
* `error` member; a plain-text body, a string `error`, a torn read are refused. */
|
|
81
|
+
export declare function providerErrorObject(body: string): {
|
|
82
|
+
message?: unknown;
|
|
83
|
+
code?: unknown;
|
|
84
|
+
type?: unknown;
|
|
85
|
+
} | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* The provider's own word for a refused request, from its JSON error envelope: `error.code` when it is a string (the
|
|
88
|
+
* OpenAI-family code, e.g. `context_length_exceeded`), else `error.type` (the Anthropic-family word, e.g.
|
|
89
|
+
* `invalid_request_error`). Read structurally, never from prose. Carried only in the provider echo shape
|
|
90
|
+
* ({@link isProviderEchoShape}); a word outside it is dropped whole — absent, never truncated — and counted as one
|
|
91
|
+
* `echo_dropped` event (`field: "apiErrorCode"`). An envelope that states neither yields nothing.
|
|
92
|
+
*/
|
|
93
|
+
export declare function readProviderErrorCode(body: string): string | undefined;
|
|
73
94
|
export { isProviderEchoShape, PROVIDER_ECHO_SHAPE } from "./provider-echo-shape.js";
|
|
74
95
|
/** How the brains read one provider echo value: a non-string or the empty string states nothing (`absent` — no field,
|
|
75
96
|
* nothing counted); a string in {@link isProviderEchoShape} is `carried`; any other string is `refused` (dropped and
|
|
@@ -112,6 +133,11 @@ export declare function readProviderRequestId(res: {
|
|
|
112
133
|
* Stalls, connect failures and in-band error frames inside a 200 leave the seat absent by the same rule.
|
|
113
134
|
*/
|
|
114
135
|
export declare function namesTheFailure(status: number | undefined): status is number;
|
|
136
|
+
/** Did the provider REFUSE the request as sent — a 4xx answer (400–499)? The one predicate for the refusal family: the
|
|
137
|
+
* envelope word's read ({@link readProviderErrorCode}), its lift (`AssistantMessage.apiErrorCode`) and the run's
|
|
138
|
+
* `terminal.detail` (`provider_rejected`). An auth refusal (401 / 403) and a rate limit (429) are refusals too; which
|
|
139
|
+
* one is the status's own statement. */
|
|
140
|
+
export declare function refusedTheRequest(status: number | undefined): status is number;
|
|
115
141
|
/** Map an HTTP status to an error class. 401/403 = auth (don't retry); 429 = rate limit; 5xx = server. */
|
|
116
142
|
export declare function classifyHttp(status: number): BrainErrorCode;
|
|
117
143
|
/** Lift the machine-readable code back out of a `[code] …` prefixed `errorMessage` (the single shared
|
package/dist/brain/errors.js
CHANGED
|
@@ -18,6 +18,7 @@ export class BrainError extends Error {
|
|
|
18
18
|
inputTooLong;
|
|
19
19
|
inputTooLongRuledOut;
|
|
20
20
|
apiError;
|
|
21
|
+
providerCode;
|
|
21
22
|
constructor(code, detail, status, opts) {
|
|
22
23
|
super(`[${code}] ${detail}`);
|
|
23
24
|
this.name = "BrainError";
|
|
@@ -29,8 +30,38 @@ export class BrainError extends Error {
|
|
|
29
30
|
this.inputTooLongRuledOut = true;
|
|
30
31
|
if (opts?.apiError === true)
|
|
31
32
|
this.apiError = true;
|
|
33
|
+
if (opts?.providerCode !== undefined)
|
|
34
|
+
this.providerCode = opts.providerCode;
|
|
32
35
|
}
|
|
33
36
|
}
|
|
37
|
+
export function providerErrorObject(body) {
|
|
38
|
+
const trimmed = body.trim();
|
|
39
|
+
if (!trimmed.startsWith("{"))
|
|
40
|
+
return undefined;
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(trimmed);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
49
|
+
return undefined;
|
|
50
|
+
const err = parsed.error;
|
|
51
|
+
if (typeof err !== "object" || err === null || Array.isArray(err))
|
|
52
|
+
return undefined;
|
|
53
|
+
return err;
|
|
54
|
+
}
|
|
55
|
+
export function readProviderErrorCode(body) {
|
|
56
|
+
const err = providerErrorObject(body);
|
|
57
|
+
const word = typeof err?.code === "string" ? err.code : typeof err?.type === "string" ? err.type : undefined;
|
|
58
|
+
const reading = providerEchoReading(word);
|
|
59
|
+
if (reading === "carried")
|
|
60
|
+
return word;
|
|
61
|
+
if (reading === "refused")
|
|
62
|
+
emitBrainTelemetry({ kind: "echo_dropped", field: "apiErrorCode" });
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
34
65
|
const REQUEST_ID_HEADERS = ["request-id", "x-request-id"];
|
|
35
66
|
export { isProviderEchoShape, PROVIDER_ECHO_SHAPE } from "./provider-echo-shape.js";
|
|
36
67
|
export function providerEchoReading(value) {
|
|
@@ -62,6 +93,9 @@ export function readProviderRequestId(res) {
|
|
|
62
93
|
export function namesTheFailure(status) {
|
|
63
94
|
return status !== undefined && status >= 100 && !(status >= 200 && status < 300);
|
|
64
95
|
}
|
|
96
|
+
export function refusedTheRequest(status) {
|
|
97
|
+
return status !== undefined && status >= 400 && status < 500;
|
|
98
|
+
}
|
|
65
99
|
export function classifyHttp(status) {
|
|
66
100
|
if (status === 401 || status === 403)
|
|
67
101
|
return "auth";
|
|
@@ -1,34 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* design/374 slice 2 — TYPED input-too-long classification for provider refusals.
|
|
3
|
-
*
|
|
4
|
-
* "The request INPUT alone exceeds the model's context limit" is recognizable from prose over the
|
|
5
|
-
* human-facing `errorMessage`, but such a classifier cannot tell a provider's own diagnostic from
|
|
6
|
-
* caller text a gateway reflected, and breaks the moment a provider rewords. This module classifies
|
|
7
|
-
* at the BRAIN boundary, where provenance is decidable, and the verdict rides
|
|
8
|
-
* `BrainError.inputTooLong` → `AssistantMessage.errorKind: "input_too_long"` (the same typed lane
|
|
9
|
-
* `degenerate`/`length_empty` already use).
|
|
10
|
-
*
|
|
11
|
-
* PROVENANCE DISCIPLINE (mirrors `context-overflow.ts`, the sibling classifier for the
|
|
12
|
-
* input+max_tokens overflow — a DIFFERENT error: there the input fits and lowering the output cap
|
|
13
|
-
* self-heals; here the input alone does not fit and only shrinking the input helps):
|
|
14
|
-
* - The only text source is the provider's diagnostic FIELD: `error.message` of a JSON object
|
|
15
|
-
* body whose `error` is itself an object (both the Anthropic `{type:"error",error:{…}}` and
|
|
16
|
-
* the OpenAI `{error:{…}}` envelopes reduce to this). A plain-text body, a string `error`, a
|
|
17
|
-
* top-level `message` — refused: each is a guess about gateway formatting that would trade a
|
|
18
|
-
* hypothetical classification for a field a gateway may fill with caller content.
|
|
19
|
-
* - Prose is matched ANCHORED at the start of that field only, against the conservative
|
|
20
|
-
* context-limit FAMILY below (case-INsensitive since the r2 family form — the family note owns
|
|
21
|
-
* the reasoning and the member list). A gateway that inlines the offending request
|
|
22
|
-
* ("validation failed; offending prompt: prompt is too long…") therefore cannot classify:
|
|
23
|
-
* caller text must never steer a retry.
|
|
24
|
-
* - Structural signals need no prose at all: OpenAI-family `error.code ===
|
|
25
|
-
* "context_length_exceeded"`, and HTTP 413 (the transport's own "request entity too large").
|
|
26
|
-
*
|
|
27
|
-
* Misclassification cost: inside a PARSED provider envelope a miss is an AUTHORITATIVE negative
|
|
28
|
-
* that SUPPRESSES the loop's prose fallback — which is why the family must carry every observed
|
|
29
|
-
* genuine opening; prose-only bodies keep the fallback reach via the "unknown" arm. A FALSE HIT
|
|
30
|
-
* costs one bounded recovery attempt, and only on a 400/413/422 the request already failed on.
|
|
31
|
-
*/
|
|
32
1
|
/** True when the (provider-authored) diagnostic OPENS with the context-limit refusal family. */
|
|
33
2
|
export declare function startsWithInputTooLongAnchor(diagnostic: string): boolean;
|
|
34
3
|
/**
|
|
@@ -1,26 +1,9 @@
|
|
|
1
|
+
import { providerErrorObject } from "./errors.js";
|
|
1
2
|
const INPUT_TOO_LONG_ANCHORED_RE = /^(?:prompt is too long|input is too long|this model's maximum context length is |context.{0,3}length|context window (?:limit|exceeded)|reached its context window|too many tokens|maximum context|input length and `max_tokens` exceed context limit|(?:the )?request(?: \([^)]{0,40}\))? exceeds|(?:the )?input token count(?: \([^)]{0,40}\))? exceeds|requested tokens(?: \([^)]{0,40}\))? exceed|your input exceeds|(?:the )?number of tokens in the request exceeds)/i;
|
|
2
3
|
const INPUT_TOO_LONG_STATUSES = new Set([400, 413, 422]);
|
|
3
4
|
export function startsWithInputTooLongAnchor(diagnostic) {
|
|
4
5
|
return INPUT_TOO_LONG_ANCHORED_RE.test(diagnostic.trimStart());
|
|
5
6
|
}
|
|
6
|
-
function providerErrorObject(body) {
|
|
7
|
-
const trimmed = body.trim();
|
|
8
|
-
if (!trimmed.startsWith("{"))
|
|
9
|
-
return undefined;
|
|
10
|
-
let parsed;
|
|
11
|
-
try {
|
|
12
|
-
parsed = JSON.parse(trimmed);
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
return undefined;
|
|
16
|
-
}
|
|
17
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
18
|
-
return undefined;
|
|
19
|
-
const err = parsed.error;
|
|
20
|
-
if (typeof err !== "object" || err === null || Array.isArray(err))
|
|
21
|
-
return undefined;
|
|
22
|
-
return err;
|
|
23
|
-
}
|
|
24
7
|
export function classifyInputTooLong(status, body) {
|
|
25
8
|
if (!INPUT_TOO_LONG_STATUSES.has(status))
|
|
26
9
|
return "no";
|