@sema-agent/core 7.37.1 → 7.38.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/dist/agents/observer.js +1 -1
  3. package/dist/agents/peer-notices.js +4 -4
  4. package/dist/agents/subagent.js +1 -1
  5. package/dist/agents/team-role.js +2 -2
  6. package/dist/brain/anthropic.js +10 -9
  7. package/dist/brain/errors.d.ts +26 -0
  8. package/dist/brain/errors.js +34 -0
  9. package/dist/brain/input-too-long.d.ts +0 -31
  10. package/dist/brain/input-too-long.js +1 -18
  11. package/dist/brain/open-responses.js +10 -9
  12. package/dist/brain/openai.js +10 -9
  13. package/dist/brain/status-sink.d.ts +1 -1
  14. package/dist/brain/stream-engine.js +10 -2
  15. package/dist/brain/stream-shared.d.ts +11 -3
  16. package/dist/brain/stream-shared.js +4 -2
  17. package/dist/brain/tool-call-id.d.ts +29 -8
  18. package/dist/brain/tool-call-id.js +36 -6
  19. package/dist/core/a2a-task-state.js +2 -2
  20. package/dist/core/artifact-host.js +6 -6
  21. package/dist/core/ask-origin.js +6 -6
  22. package/dist/core/ask-unresolvable-notice.js +1 -1
  23. package/dist/core/auto-mode.js +4 -4
  24. package/dist/core/background-agent-store.js +2 -2
  25. package/dist/core/checkpoint-execution-record.js +1 -1
  26. package/dist/core/checkpoint-store.d.ts +2 -2
  27. package/dist/core/checkpoint-store.js +2 -2
  28. package/dist/core/child-clamp-seats.js +3 -3
  29. package/dist/core/compliance.js +1 -1
  30. package/dist/core/context-edit.js +1 -1
  31. package/dist/core/context-guard.d.ts +0 -19
  32. package/dist/core/context-guard.js +0 -38
  33. package/dist/core/decision-receipt.d.ts +27 -1
  34. package/dist/core/decision-receipt.js +176 -1
  35. package/dist/core/engine-notice.d.ts +59 -8
  36. package/dist/core/engine-notice.js +2 -1
  37. package/dist/core/gate-fold.js +3 -2
  38. package/dist/core/gate-outcome.js +4 -4
  39. package/dist/core/governance-codes.d.ts +221 -13
  40. package/dist/core/governance-codes.js +104 -4
  41. package/dist/core/hook-seats.js +2 -2
  42. package/dist/core/hooks.d.ts +21 -2
  43. package/dist/core/hooks.js +4 -1
  44. package/dist/core/mailbox-store.js +3 -3
  45. package/dist/core/mcp-failure.js +4 -4
  46. package/dist/core/mcp-injection-drop.js +1 -1
  47. package/dist/core/memory-engine/consolidation.js +1 -1
  48. package/dist/core/memory-engine/delegation-settlement.d.ts +8 -0
  49. package/dist/core/memory-engine/delegation-settlement.js +7 -0
  50. package/dist/core/memory-engine/engine.d.ts +12 -2
  51. package/dist/core/memory-engine/engine.js +10 -6
  52. package/dist/core/memory-engine/export-bundle.js +1 -1
  53. package/dist/core/memory-engine/layout.d.ts +6 -6
  54. package/dist/core/memory-engine/layout.js +6 -8
  55. package/dist/core/memory-engine/session-write-home.d.ts +30 -0
  56. package/dist/core/memory-engine/session-write-home.js +34 -0
  57. package/dist/core/memory-engine/tools.js +1 -1
  58. package/dist/core/memory-engine/types.js +1 -1
  59. package/dist/core/memory.js +1 -1
  60. package/dist/core/mode-policy.d.ts +10 -1
  61. package/dist/core/mode-policy.js +15 -7
  62. package/dist/core/model-seat.js +1 -1
  63. package/dist/core/permission-rule-model.js +3 -3
  64. package/dist/core/permission-rule-store.js +2 -2
  65. package/dist/core/read-only-shell-table.js +8 -8
  66. package/dist/core/retention.js +2 -2
  67. package/dist/core/runner/assemble-result.js +15 -5
  68. package/dist/core/runner/classifier-ladder.js +1 -1
  69. package/dist/core/runner/denial-limit-arms.d.ts +2 -0
  70. package/dist/core/runner/denial-limit-arms.js +6 -2
  71. package/dist/core/runner/inherited-ask-grants.d.ts +6 -5
  72. package/dist/core/runner/inherited-ask-grants.js +3 -2
  73. package/dist/core/runner/prepare-context-lane.d.ts +19 -15
  74. package/dist/core/runner/prepare-context-lane.js +11 -15
  75. package/dist/core/runner/prepare-gate-stations.js +5 -5
  76. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  77. package/dist/core/runner/prepare-policy-chain.js +10 -10
  78. package/dist/core/runner/run-harness-handlers.js +3 -0
  79. package/dist/core/runner/run-identity-wiring.d.ts +1 -1
  80. package/dist/core/runner/run-identity-wiring.js +0 -2
  81. package/dist/core/runner/run-leg.js +2 -2
  82. package/dist/core/runner/run-stop-and-final-verify.js +1 -0
  83. package/dist/core/runner/run-turn-boundary.js +1 -1
  84. package/dist/core/runner/session-mode-seats.d.ts +11 -3
  85. package/dist/core/runner/session-mode-seats.js +6 -0
  86. package/dist/core/runner/session-rule-policy.js +2 -3
  87. package/dist/core/runner/stream-steer-verb.js +1 -1
  88. package/dist/core/runner/tool-call-id-fold-seat.js +16 -9
  89. package/dist/core/runner/tool-turn-repair-seat.d.ts +11 -1
  90. package/dist/core/runner/tool-turn-repair-seat.js +35 -0
  91. package/dist/core/runner/turn-attachments.js +1 -1
  92. package/dist/core/runtime-caps.js +1 -1
  93. package/dist/core/safety-axis-vocab.d.ts +5 -0
  94. package/dist/core/safety-axis-vocab.js +5 -0
  95. package/dist/core/session-reconcile.d.ts +9 -7
  96. package/dist/core/session-reconcile.js +21 -1
  97. package/dist/core/shared-memory/types.js +1 -1
  98. package/dist/core/side-query.d.ts +6 -1
  99. package/dist/core/store-contracts/read-back-shape-contract.js +1 -1
  100. package/dist/core/swappable-deps.js +1 -1
  101. package/dist/core/task-notification.js +4 -4
  102. package/dist/core/task-registry-shared.js +1 -1
  103. package/dist/core/terminal-cause.d.ts +19 -0
  104. package/dist/core/tool-face.js +6 -6
  105. package/dist/core/tool-policy.d.ts +17 -11
  106. package/dist/core/tool-policy.js +38 -72
  107. package/dist/core/tool-roster.js +1 -1
  108. package/dist/core/tool-search-setting.js +1 -1
  109. package/dist/core/trace.d.ts +5 -4
  110. package/dist/core/types.d.ts +2 -2
  111. package/dist/core/types.js +1 -1
  112. package/dist/core/wiring-manifest.js +2 -2
  113. package/dist/core/write-protect.js +1 -1
  114. package/dist/engine/harness/agent-harness.js +10 -6
  115. package/dist/engine/llm/tool-turns.d.ts +93 -4
  116. package/dist/engine/llm/tool-turns.js +63 -9
  117. package/dist/engine/llm/types.d.ts +11 -0
  118. package/dist/index.d.ts +5 -3
  119. package/dist/index.js +4 -2
  120. package/dist/internal/llm.d.ts +2 -2
  121. package/dist/internal/llm.js +1 -1
  122. package/dist/orchestration/workflow-governance.js +2 -2
  123. package/dist/orchestration/workflow-types.js +2 -2
  124. package/dist/shell/lex.js +7 -7
  125. package/dist/tools/fs/bash-program-position.js +2 -2
  126. package/dist/tools/fs/read-deny.js +2 -2
  127. package/dist/tools/fs/safety.js +1 -1
  128. package/dist/tools/fs/shell-read-judge.js +2 -2
  129. package/dist/tools/fs/shell-read-plan.js +2 -2
  130. package/dist/tools/fs/shell-read-write-targets.js +1 -1
  131. package/package.json +1 -1
  132. package/test/export-surface.snapshot.json +17 -1
  133. package/wire-changes/core@7.38.0.json +664 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,138 @@
1
+ ## 7.38.0 — 2026-10-01
2
+
3
+ **Hard-breaking changes:** none.
4
+
5
+ **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.
6
+
7
+ **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.
8
+
9
+ **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.
10
+
11
+ ### `screenAskClassMembers` is exported (#1249; +1 export) (@server)
12
+
13
+ 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.
14
+
15
+ ### 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)
16
+
17
+ - **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`).
18
+ - **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).
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+ - **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).
23
+ - 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.
24
+
25
+ ### 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)
26
+
27
+ - **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.
28
+ - **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.
29
+ - **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).
30
+ - **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 ②).
31
+ - **Compensation.** None needed.
32
+ - **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.
33
+ - **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).
34
+ - **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.
35
+ - 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).
36
+
37
+ ### 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)
38
+
39
+ - **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.
40
+ - **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.
41
+ - **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).
42
+ - **Compensation.** None needed — the exact entries already read this way.
43
+ - 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).
44
+
45
+ ### 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)
46
+
47
+ - **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.
48
+ - **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.
49
+ - **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.
50
+ - **Compensation.** Copy the list (`[...ASK_CLASSES]`) before editing a copy.
51
+ - 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.
52
+
53
+
54
+ ### 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)
55
+
56
+ - **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.
57
+ - **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.
58
+ - **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.
59
+ - **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.
60
+ - **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.
61
+ - 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).
62
+
63
+ ### 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)
64
+
65
+ - **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.
66
+ - **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.
67
+ - **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.
68
+ - **Compensation.** Read the notice instead of the `onError` line; add the form row.
69
+ - 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.
70
+
71
+ ### A reused tool-call id no longer makes every acquire append another interrupted close (#1235; behavior (the persisted reconcile); +0 exports) (@server @cli @test)
72
+
73
+ - **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).
74
+ - **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.
75
+ - **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.
76
+ - **Compensation.** None needed — the request is the same as before for the earlier use (closed as interrupted on the wire).
77
+ - 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.
78
+
79
+ ### The request view's interrupted close states what it does not know (#1232, narrow; docs; +0 exports)
80
+
81
+ - **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.
82
+
83
+ ### Server-minted notice codes are catalogued, with their audience and a minter table (#1240; closed set +4, +2 exports) (@server @client-core @cli @test)
84
+
85
+ - **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.
86
+ - **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).
87
+ - **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.
88
+ - **Compensation.** Regenerate the table; read audience and minter from the package.
89
+ - 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).
90
+
91
+ ### `task.user_steer_undrained` says why: `detail.cause` closed { `leg_end`, `crashed` } (#1233; +2 exports) (@server @cli @client-core @test)
92
+
93
+ - **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.
94
+ - **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.
95
+ - **Who is hurt.** A consumer that pinned the steer row's detail keys exactly: one more key.
96
+ - **Compensation.** Read `cause`; render `crashed` as the dead-process arm.
97
+ - 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).
98
+
99
+ ### 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)
100
+
101
+ - **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.
102
+ - **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.
103
+ - **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.
104
+ - **Compensation.** Add the row; render the line by `cause` (wording and default visibility are the shell's).
105
+ - 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).
106
+
107
+ ### 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)
108
+
109
+ - **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.
110
+ - **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.
111
+ - **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.
112
+ - **Compensation.** Read `detail` by `kind`; branch on `status` to tell a request the person can change and resend from one they cannot.
113
+ - **Known limits.** The engine's persisted API-failure record (the synthetic transcript entry) carries `apiErrorStatus` but not `apiErrorCode`.
114
+ - 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).
115
+ ### `sessionMemoryStatus` answers where the session's memory is written — `writeHome` (#1222; additive: one optional key; +0 exports) (@server @cli)
116
+
117
+ - **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.
118
+ - **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.
119
+ - **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.
120
+ - **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.
121
+ - **Compensation.** None needed.
122
+ - **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`.
123
+ - **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.)
124
+ - **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).
125
+ - 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.
126
+
127
+ ### Hook contexts carry the leg's live permission mode — `permissionMode` (#1223; additive: one optional member on five context types; +0 exports) (@server @cli)
128
+
129
+ - **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.
130
+ - **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.
131
+ - **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.
132
+ - **Who is hurt.** Nobody: the member is optional and read-only; a hook that ignores it is unchanged.
133
+ - **Compensation.** None needed. A serving layer composing its own `permission_mode` can read this member instead.
134
+ - 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.
135
+
1
136
  ## 7.37.1 — 2026-10-01
2
137
 
3
138
  **Hard-breaking changes:** none.
@@ -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
  }
@@ -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",
@@ -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();
@@ -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;
@@ -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
@@ -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";
@@ -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, placeTurnNotes } from "./stream-shared.js";
5
- import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
5
+ import { admitToolCallId, providerResultIds, providerToolCallId, responseToolCallIds } 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";
@@ -199,7 +199,7 @@ function responsesErrorCode(code) {
199
199
  const INCOMPLETE_MAX_OUTPUT_TOKENS = "max_output_tokens";
200
200
  const INCOMPLETE_CONTENT_FILTER = "content_filter";
201
201
  const RESPONSE_INCOMPLETE_WARNING = 'The response above may be incomplete: the endpoint reported status "incomplete" with no reason this client recognises.';
202
- function closeFunctionCall(acc) {
202
+ function closeFunctionCall(acc, responseIds) {
203
203
  if (acc.closedTc)
204
204
  return acc.closedTc;
205
205
  if (!acc.name)
@@ -207,7 +207,7 @@ function closeFunctionCall(acc) {
207
207
  const args = parseStreamedToolArgs(acc.args);
208
208
  if (args === undefined)
209
209
  return undefined;
210
- const { engineId, wireId } = admitToolCallId(acc.callId, "call_");
210
+ const { engineId, wireId } = admitToolCallId(acc.callId, "call_", responseIds);
211
211
  acc.closedTc = { type: "toolCall", id: engineId, ...(wireId !== engineId ? { wireId } : {}), name: acc.name, arguments: args };
212
212
  return acc.closedTc;
213
213
  }
@@ -305,6 +305,7 @@ export function createOpenResponsesBrain(config = {}) {
305
305
  makeParser: (ctrl) => {
306
306
  const { out, partial } = ctrl;
307
307
  const items = new Map();
308
+ const responseIds = responseToolCallIds();
308
309
  let usageRaw;
309
310
  let responseId;
310
311
  let terminalStatus;
@@ -412,7 +413,7 @@ export function createOpenResponsesBrain(config = {}) {
412
413
  out.push({ type: "text_end", contentIndex: index, content: messageFace(acc), partial: { ...partial } });
413
414
  }
414
415
  else {
415
- const tc = closeFunctionCall(acc);
416
+ const tc = closeFunctionCall(acc, responseIds);
416
417
  if (tc)
417
418
  out.push({ type: "toolcall_end", contentIndex: index, toolCall: tc, partial: { ...partial } });
418
419
  }
@@ -650,7 +651,7 @@ export function createOpenResponsesBrain(config = {}) {
650
651
  }
651
652
  }
652
653
  else {
653
- const tc = closeFunctionCall(acc);
654
+ const tc = closeFunctionCall(acc, responseIds);
654
655
  if (tc) {
655
656
  toolCalls.push(tc);
656
657
  finalContent.push(tc);
@@ -675,15 +676,15 @@ export function createOpenResponsesBrain(config = {}) {
675
676
  const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
676
677
  const notes = [];
677
678
  if (malformed.length > 0) {
678
- notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (status="${terminalLabel}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
679
+ notes.push({ cause: "truncated", count: malformed.length, text: `\n[note: ${malformed.length} tool call(s) were truncated (status="${terminalLabel}") and dropped — re-issue them next turn: ${malformed.join("; ")}]` });
679
680
  }
680
681
  if (unnamed.length > 0) {
681
- 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("; ")}]`);
682
+ 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("; ")}]` });
682
683
  }
683
684
  if (malformedFrames > 0) {
684
- 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)})]`);
685
+ 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)})]` });
685
686
  }
686
- placeTurnNotes(finalContent, notes);
687
+ placeTurnNotes(partial, finalContent, notes);
687
688
  const truncated = incompleteReason === INCOMPLETE_MAX_OUTPUT_TOKENS;
688
689
  const truncatedEmpty = truncated && noUsableContent && !toolError;
689
690
  const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;