@sema-agent/core 7.6.2 → 7.7.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 (47) hide show
  1. package/CHANGELOG.md +36 -6
  2. package/dist/agents/peer-admission.d.ts +1 -1
  3. package/dist/brain/anthropic.js +8 -2
  4. package/dist/brain/open-responses.js +5 -3
  5. package/dist/brain/openai.js +31 -8
  6. package/dist/brain/reasoning.d.ts +32 -0
  7. package/dist/brain/reasoning.js +18 -0
  8. package/dist/core/auto-mode-defaults.d.ts +16 -0
  9. package/dist/core/auto-mode-defaults.js +1 -0
  10. package/dist/core/auto-mode.d.ts +19 -0
  11. package/dist/core/auto-mode.js +74 -56
  12. package/dist/core/checkpoint-execution-record.d.ts +110 -0
  13. package/dist/core/checkpoint-execution-record.js +49 -0
  14. package/dist/core/checkpoint-store.d.ts +88 -10
  15. package/dist/core/checkpoint-store.js +35 -2
  16. package/dist/core/engine-notice.d.ts +11 -0
  17. package/dist/core/governance-codes.d.ts +1 -1
  18. package/dist/core/governance-codes.js +2 -0
  19. package/dist/core/runner/clock-and-limits.d.ts +117 -0
  20. package/dist/core/runner/clock-and-limits.js +118 -0
  21. package/dist/core/runner/contracts.d.ts +10 -0
  22. package/dist/core/runner/decide-continuation.d.ts +98 -0
  23. package/dist/core/runner/decide-continuation.js +133 -0
  24. package/dist/core/runner/execution-record.d.ts +26 -0
  25. package/dist/core/runner/execution-record.js +19 -0
  26. package/dist/core/runner/git-leg-delivery.d.ts +28 -0
  27. package/dist/core/runner/git-leg-delivery.js +94 -0
  28. package/dist/core/runner/initial-run-state.d.ts +14 -0
  29. package/dist/core/runner/initial-run-state.js +11 -0
  30. package/dist/core/runner/prepare-caps-and-workflow.js +17 -0
  31. package/dist/core/runner/prepare-run-refs.d.ts +0 -12
  32. package/dist/core/runner/prepare-run-refs.js +0 -5
  33. package/dist/core/runner/runtask.d.ts +0 -68
  34. package/dist/core/runner/runtask.js +28 -450
  35. package/dist/core/runner/steer-admission.d.ts +17 -0
  36. package/dist/core/runner/steer-admission.js +17 -0
  37. package/dist/core/runner/tool-end-body.d.ts +71 -0
  38. package/dist/core/runner/tool-end-body.js +74 -0
  39. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +4 -0
  40. package/dist/core/store-contracts/checkpoint-store-contract.js +85 -0
  41. package/dist/core/trace.d.ts +24 -0
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.js +4 -3
  44. package/dist/stores/file/checkpoint-store.d.ts +7 -0
  45. package/dist/stores/file/checkpoint-store.js +20 -1
  46. package/package.json +1 -1
  47. package/test/export-surface.snapshot.json +37 -1
package/CHANGELOG.md CHANGED
@@ -1,15 +1,45 @@
1
1
  # Changelog
2
2
 
3
- ## 7.6.2 — 2026-09-08
3
+ ## 7.7.0 — 2026-09-07
4
4
 
5
- Backport patch on the 7.6.x line (two field bugs the server 7.64.0 chain found; no store-contract change that is 7.7.0).
5
+ One version for the first refactor wave and its bug fixes (the 7.6.2 patch folded in: main already carries the store-contract change, so the fix ships with it). #618 / B-040: The classifier request shape changes on the wire; the explicit "off" tier changes on both brain lanes.
6
6
 
7
- ### Fixed
8
- - **R12 (`forwardSubagentEvents`)** — a run that opted into forwarding did not wait for the frames its BACKGROUND children had already handed to the forward channel before saying `done`; the gap is one microtask ladder deep and moved when #594 split the tool gate into three async stations (a child's gated call went from 24 to 27 microtask hops between `tool_start` and `tool_end`), so a consumer that read the parent to `done` and then inspected the sink lost the child's `tool_end`. The parent now takes one macrotask turn right before its terminal push, only on opted-in runs, on both the success and the failure path. Pin: `test/forward-subagent-events-drain.test.ts`.
9
- - **R13 (checkpoint)** the pre-CAS refusal of a pending approval row that carries no `origin` word (a row minted before the ask's origin was persisted on the park) now carries `reason: "origin_missing"` like its three `unsupported_version` siblings, so a host upgrading with parked cards can classify the orphaned row. Pin: `test/backlog-r13-origin-missing-reason.test.ts`.
7
+ **BREAKING (store contract).** #602 — the durable half of a post-CAS veto. A person approved a parked call, the deployment's own re-check refused the approver's edit on the resumed leg, `tool_end.gate` said `denied/policy` beside the `human_allowed` settlement — and the row still said `resolvedOutcome.gateOutcome.disposition = allowed`, because the store contract had no verb for "how was the decided action finally disposed". A consumer rebuilding the run from the row after the stream broke read the pre-execution allow and nothing else.
8
+
9
+ ### BREAKING — `CheckpointStore` gains two REQUIRED members
10
+ - `readonly execution: { readonly outcome: true }` — a declaration, read at the first read of the store seat (`resolveCheckpointStore`): a store without it is refused with `config.invalid_checkpoint_store` (the same door as the retired `null` spelling), naming the seat (`TaskSpec.checkpointStore` / `RunnerDeps.checkpointStore`) and the missing declaration. A store that satisfies the TypeScript type carries it; a JavaScript caller is told at prepare what the type would have told it.
11
+ - `recordExecutionOutcome(token, scope, gate: GateOutcome): Promise<"recorded" | "already_recorded" | "not_resolved" | "absent">` — the CAS `status='resolved' AND execution_outcome IS NULL`; the same record again is idempotent (`already_recorded`, structural equality over the record's JSON); a DIFFERENT record throws `checkpoint.execution_outcome_conflict` (`ExecutionOutcomeConflictError`, carrying `recorded` + `attempted`); pending/expired rows answer `not_resolved`, missing and wrong-scope rows `absent`. Never touches `status` / `rev` / `resolvedOutcome` / `reopenReason`.
12
+ - `reopen` gains the predicate `AND execution_outcome IS NULL` — a row whose decided action is already disposed is settled, never reopenable (the reopen answers `false`; the engine's compensation reads it as `checkpoint.reopen_failed`, definitive). Behaviour narrowing: an `env_failed` retry is offered only for a row whose action never reached its disposition.
13
+ - Deployment stores (server's two) need two columns, `execution_outcome` (json) + `execution_at_ms`, the record CAS, and the reopen predicate; the store-contract kit (`checkpointStoreContract`) carries the six cases. The in-tree file store writes a new ledger event kind (`execution`); a binary older than 7.7.0 refuses to replay a directory whose live ledger carries one (loud) — but once compaction has folded the event into a snapshot row, that older binary reads the row with a field it does not know and its `reopen` ignores the record (the same rollback shape the parked-steer queue event disclosed in its release; a rollback across a recorded row is not supported).
14
+
15
+ ### Added
16
+ - `CheckpointRow.executionOutcome?: GateOutcome` + `executionAtMs?: number` (additive row keys, present together) — the record the resumed call's `tool_end.gate` carried, written by the engine on EVERY resolution (an executed allow too); absence has one meaning: unknown, never "allowed". `resolvedOutcome.gateOutcome` stays the immutable record of the decision, so a vetoed row reads `allowed` there and `denied/policy` here.
17
+ - `executionVerdict(cp)` → `{ kind: "executed", gate } | { kind: "unknown" }` — the one read face of the column (`type ExecutionVerdict`).
18
+ - `EXECUTION_OUTCOME_RECORD_WORDS` / `type ExecutionOutcomeRecordWord` / `isExecutionOutcomeRecordWord`, the disposition table `EXECUTION_RECORD_LEAVES_ROW_UNRECORDED` (+ fence `ExecutionRecordTableCoversEveryWord`; registered in `docs/CLOSED-SETS.md`), `ExecutionOutcomeConflictError`, and the shared backend halves `executionRecordDisposition` / `checkpointExecutionRecorded` / `sameExecutionRecord` (every in-tree backend routes through them — `gate:single-mint` rows); `checkpointStoreExecutionUndeclared` (the refusal's one mint).
19
+ - `engine_notice` code `checkpoint.execution_outcome_unrecorded` (audience operator) — the resolver disposed the decided action and the store answered `not_resolved` / `absent`: the execution result stands as delivered, the row reads unknown, and this names it; `detail: { sessionId, runId, scope, checkpointId?, word }`. A conflict is not a notice: the throw propagates and the resumed leg fails with `checkpoint.execution_outcome_conflict` (the action, if it ran, ran; the defect is reported, not hidden).
20
+ - `@contract checkpoint.execution_outcome` (the row field's JSDoc is the one home).
10
21
 
11
22
  ### Changed
12
- - `CheckpointError.detail.reason` closed set gains `"origin_missing"` (terminal for the row's bytes; the host decides the orphaned card).
23
+ - The pending-call resolver (`resolvePendingCall`) files the frame's record on every arm through one `settleEnd` helper, between the `tool_end` frame and the transcript append; a policy_ask decide reaching the resolver without its settlement record is refused (`checkpoint.invalid_outcome`) rather than settled unrecorded. `ResumeRun.recordExecutionOutcome` is the bound verb (required seat). The notice half lives in `runner/execution-record.ts` (layer 1).
24
+
25
+ ### Changed (refactor — byte-invariant on the runtime)
26
+ - **design/393 S1** — six module-level helpers leave runtask.ts for layer-1 modules (initial-run-state / steer-admission / tool-end-body / decide-continuation / clock-and-limits / git-leg-delivery; runtask 11 963 → 11 059); dist runtask.js changes by import lines and moved bodies only, the await-boundary table is all-zero, the export snapshot is unchanged by this slice.
27
+
28
+ ### Pins
29
+ - `test/backlog602-execution-outcome.test.ts` (17): store-level ③④⑤⑥ over both in-process backends + file-ledger replay; engine-level ① (person's edit vetoed post-CAS ⇒ frame and row both `denied/policy`, decision cell still `allowed`, `executionVerdict` executed, reopen refused), ② (executed allow recorded), a delivered refusal, the unrecorded notice, the conflict propagation, ⑦ (undeclared store refused on both seats with the declared control). Store-contract kit +6 cases (InMemory / File / Pg via pg-mem).
30
+
31
+ ### Fixed
32
+ - **#618 (P1, B-040)** — the auto-mode classifier's request said nothing about thinking and carried no output cap, so a default-on Messages-compatible endpoint (deepseek's anthropic port in the field) spent the whole cap on reasoning and answered with zero text: every classification was a contract failure, three of them opened the session breaker, and every later ask in the session became a card for a person (3–7 s per classification on the way). The classify call now carries `reasoning:"off"` and `maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS` (256, derived from the engine's own `<block>…` output contract — the longest legal block verdict is ≈150 tokens; not upstream's 64 nor its 4096 critique cap), on every model whose wire can SAY off (`thinkingOffExpressible`); a model that thinks regardless (anthropic adaptive form; stock openai / Responses with no declared off spelling) keeps its own output budget — a cap sized for the answer alone would only cut its thinking. Explicit "off" is now spelled on every wire that has an off form, one rule with no absence-means-off exception: anthropic budget form `thinking:{type:"disabled"}` (adaptive form: field omitted — those models reject `disabled`); openai lane — deepseek `thinking:{type:"disabled"}`, qwen/zai `enable_thinking:false`, qwen-chat-template `chat_template_kwargs.enable_thinking:false`, openrouter/together `reasoning:{enabled:false}`; stock openai completions and the Responses lane the DEPLOYMENT-declared off spelling (`thinkingLevelMap.off`, e.g. a provider's `"none"` tier — `declaredOffSpelling`), else absence (an enabled tier is never inferred as off: it turns reasoning on). "Unset" still writes nothing on every format. A new trace frame `auto_mode.classified {toolCallId, model, ms, verdict, cause?}` reads one line per decision (breaker-open short-circuits included); `AutoModeDeciderOptions.onClassified` is the hook it rides. Repro `scripts/repro/B-040.mjs` (RED on 7.6.1, GREEN here) runs under `gate:repro`.
33
+
34
+ ### Changed (wire — consumers named, #618)
35
+ - Classifier request body on the wire: `thinking:{type:"disabled"}` + `max_tokens: 256` (anthropic budget form); `reasoning:"off"` reaches every brain lane. A deployment that reads the classifier's request through a proxy sees the two new keys.
36
+ - Explicit `"off"` on the main model's finalize turn (and any caller passing `reasoning:"off"`): anthropic budget models now receive `thinking:{type:"disabled"}` (was: no key); deepseek-format models receive `thinking:{type:"disabled"}` (was: no key); openrouter/together-format models receive `reasoning:{enabled:false}` (was: no key); stock-openai-format and Responses-lane models with a declared `thinkingLevelMap.off` receive that spelling as `reasoning_effort` / `reasoning.effort` (was: no key; without the mapping still no key). Unset requests are byte-identical to 7.6.1.
37
+
38
+ ### Added (#618)
39
+ - `AUTO_MODE_CLASSIFIER_MAX_TOKENS`, `thinkingOffExpressible(model)`, `declaredOffSpelling(model)`, `type OffCapabilityModel`, `type AutoModeClassified`, `AutoModeDeciderOptions.onClassified`, trace frame kind `auto_mode.classified`.
40
+
41
+ ### Known limit (ticketed, not fixed here)
42
+ - The off-capability table reads the WIRE's ability to say off; a thinking-only model served through a format that has a disable key (a Qwen thinking-only build, a mandatory-reasoning model behind openrouter/together) ignores that key, so its classifier still gets the 256-token cap and fails the verdict contract. The per-model fact the compat declaration would need (`mandatoryReasoning` / a classifier-specific budget knob) is a follow-up.
13
43
 
14
44
  ## 7.6.1 — 2026-09-07
15
45
 
@@ -78,7 +78,7 @@ export interface PeerIdentity {
78
78
  ownTokens: string[];
79
79
  }
80
80
  /**
81
- * design/176 §4.1 — the LATE-BOUND self-identity carrier ({@link import("../core/runner/prepare-task.js").RunInternals}`.peerSelfRef`).
81
+ * design/176 §4.1 — the LATE-BOUND self-identity carrier ({@link import("../core/runner/contracts.js").RunInternals}`.peerSelfRef`).
82
82
  * A ref (same family as `ownOrgAdmissionRef`): revival replays a spread COPY of spawn-time
83
83
  * internals, so a plain field would freeze at its spawn value; and a root run's session axis only
84
84
  * exists once `prepareTask` acquires the session — no single assembly point can synthesize the full
@@ -276,9 +276,11 @@ export function createAnthropicBrain(config = {}) {
276
276
  body.temperature = options.temperature;
277
277
  }
278
278
  let builtReasoningFacts;
279
+ let thinkingOn = false;
279
280
  if (reasoningRequestCarried(model, options?.reasoning)) {
280
281
  if (anthCompat.thinkingMode === "adaptive") {
281
282
  body.thinking = { type: "adaptive" };
283
+ thinkingOn = true;
282
284
  }
283
285
  else {
284
286
  const hardCap = overrides?.maxOutputTokens !== undefined || options?.maxTokens !== undefined;
@@ -292,11 +294,15 @@ export function createAnthropicBrain(config = {}) {
292
294
  const { max, budget } = thinkingBudget(body.max_tokens, share, config.thinkingBudgetTokens, hardCap);
293
295
  body.max_tokens = max;
294
296
  body.thinking = { type: "enabled", budget_tokens: budget };
297
+ thinkingOn = true;
295
298
  }
296
299
  }
297
- if (body.thinking !== undefined)
300
+ if (thinkingOn)
298
301
  delete body.temperature;
299
302
  }
303
+ else if (model.reasoning && options?.reasoning === "off" && anthCompat.thinkingMode !== "adaptive") {
304
+ body.thinking = { type: "disabled" };
305
+ }
300
306
  const betas = [];
301
307
  let sendEffortBeta = false;
302
308
  const declaredEffort = declaredEffortLevels(anthCompat.effortLevels);
@@ -308,7 +314,7 @@ export function createAnthropicBrain(config = {}) {
308
314
  }
309
315
  if (sendEffortBeta)
310
316
  betas.push("effort-2025-11-24");
311
- if (anthCompat.contextManagement && body.thinking !== undefined) {
317
+ if (anthCompat.contextManagement && thinkingOn) {
312
318
  body.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }] };
313
319
  betas.push("context-management-2025-06-27");
314
320
  }
@@ -7,7 +7,7 @@ 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";
9
9
  import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
- import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
10
+ import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
11
11
  import { runStreamingBrain } from "./stream-engine.js";
12
12
  const DEGENERATE_POLL_CHARS = 64;
13
13
  const MALFORMED_SAMPLE_CHARS = 160;
@@ -152,11 +152,13 @@ function toResponsesTools(ctx) {
152
152
  }));
153
153
  }
154
154
  function resolveWireEffort(model, reasoning) {
155
- if (!reasoningRequestCarried(model, reasoning))
156
- return undefined;
155
+ if (model.reasoning && reasoning === "off")
156
+ return declaredOffSpelling(model);
157
157
  const compat = responsesCompat(model);
158
158
  if (compat.supportsReasoningEffort === false)
159
159
  return undefined;
160
+ if (!reasoningRequestCarried(model, reasoning))
161
+ return undefined;
160
162
  return mintEffortWireValue(reasoning, model, compat.reasoningEffortLevels).wireValue;
161
163
  }
162
164
  function computeUsage(model, raw) {
@@ -8,7 +8,7 @@ import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNot
8
8
  import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
9
  import { BrainError } from "./errors.js";
10
10
  import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
- import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
11
+ import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
13
  function closeToolCallAccum(acc) {
14
14
  if (acc.closedTc)
@@ -78,13 +78,36 @@ function thinkingCompat(model) {
78
78
  }
79
79
  function applyThinking(body, model, reasoning) {
80
80
  if (model.reasoning && reasoning === "off") {
81
- const offFormat = thinkingCompat(model).thinkingFormat ?? "openai";
82
- if (offFormat === "qwen" || offFormat === "zai") {
83
- body.enable_thinking = false;
84
- }
85
- else if (offFormat === "qwen-chat-template") {
86
- const k = (typeof body.chat_template_kwargs === "object" && body.chat_template_kwargs) || {};
87
- body.chat_template_kwargs = { ...k, enable_thinking: false };
81
+ const offCompat = thinkingCompat(model);
82
+ const offFormat = offCompat.thinkingFormat ?? "openai";
83
+ switch (offFormat) {
84
+ case "qwen":
85
+ case "zai":
86
+ body.enable_thinking = false;
87
+ break;
88
+ case "qwen-chat-template": {
89
+ const k = (typeof body.chat_template_kwargs === "object" && body.chat_template_kwargs) || {};
90
+ body.chat_template_kwargs = { ...k, enable_thinking: false };
91
+ break;
92
+ }
93
+ case "deepseek":
94
+ body.thinking = { type: "disabled" };
95
+ break;
96
+ case "openrouter":
97
+ case "together":
98
+ body.reasoning = { enabled: false };
99
+ break;
100
+ case "openai": {
101
+ const offSpelling = declaredOffSpelling(model);
102
+ if (offSpelling !== undefined)
103
+ body.reasoning_effort = offSpelling;
104
+ break;
105
+ }
106
+ default: {
107
+ const _exhaustive = offFormat;
108
+ void _exhaustive;
109
+ break;
110
+ }
88
111
  }
89
112
  return;
90
113
  }
@@ -104,6 +104,38 @@ export declare const MIN_THINKING_TOKENS = 1024;
104
104
  * host the budget instead (`hardCap === false`).
105
105
  */
106
106
  export declare function budgetCapSkipsThinking(outputCapTokens: number, hardCap: boolean): boolean;
107
+ /** The model slice the off-capability reads: the API family, the reasoning bit, the compat declaration and the levelmap. */
108
+ export interface OffCapabilityModel {
109
+ api?: string;
110
+ reasoning?: boolean;
111
+ compat?: unknown;
112
+ thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
113
+ }
114
+ /**
115
+ * The DEPLOYMENT-declared spelling of the explicit "off" tier on an effort-only wire (stock openai
116
+ * completions, the Responses lane): `thinkingLevelMap.off` as a non-empty string (a provider's own
117
+ * "none" tier), on an endpoint that takes an effort value at all (`supportsReasoningEffort` not
118
+ * false) — else `undefined`: there is nothing to send. The effort formats have no disable key, and an
119
+ * effort VALUE is never off (it enables reasoning at that tier), so off on those wires is spelled only
120
+ * when the deployment says how. The two appliers and {@link thinkingOffExpressible} read this ONE
121
+ * function, so what the wire carries and what the capability table claims cannot disagree.
122
+ */
123
+ export declare function declaredOffSpelling(model: OffCapabilityModel): string | undefined;
124
+ /**
125
+ * Whether this model's wire can SAY "off". A model that does not reason at all (`reasoning` falsy)
126
+ * never thinks, so off is trivially expressible. Otherwise, per wire: the anthropic budget form spells
127
+ * `{type:"disabled"}` (the ADAPTIVE form rejects it — those models think on every request); deepseek
128
+ * `{type:"disabled"}`, the binary enable keys at `false`, openrouter/together `reasoning:{enabled:false}`
129
+ * all say it; the effort-only wires (stock openai completions, the Responses lane) say it only through
130
+ * a deployment-declared off spelling ({@link declaredOffSpelling}).
131
+ *
132
+ * A caller that sizes an output cap for an ANSWER alone (the auto-mode classifier's block-verdict
133
+ * contract) applies the cap only when this is true: a model that will think regardless cannot have its
134
+ * cap derived from the answer's contract, and keeps its own budget. What this table cannot see is a
135
+ * model that IGNORES its wire's disable key (a thinking-only build served through a format that has
136
+ * one) — that is a per-model fact the compat declaration does not yet carry.
137
+ */
138
+ export declare function thinkingOffExpressible(model: OffCapabilityModel): boolean;
107
139
  /**
108
140
  * OPTIONAL per-request facts for {@link resolveReasoning} — what the wire's budget arm knows at
109
141
  * request build that a per-leg eager resolution cannot: the resolved output cap and whether it is a
@@ -23,6 +23,24 @@ export const MIN_THINKING_TOKENS = 1024;
23
23
  export function budgetCapSkipsThinking(outputCapTokens, hardCap) {
24
24
  return hardCap && outputCapTokens < MIN_THINKING_TOKENS * 2;
25
25
  }
26
+ export function declaredOffSpelling(model) {
27
+ const compat = (model.compat ?? {});
28
+ if (compat.supportsReasoningEffort === false)
29
+ return undefined;
30
+ const mapped = model.thinkingLevelMap?.off;
31
+ return typeof mapped === "string" && mapped !== "" ? mapped : undefined;
32
+ }
33
+ export function thinkingOffExpressible(model) {
34
+ if (!model.reasoning)
35
+ return true;
36
+ const compat = (model.compat ?? {});
37
+ if (model.api === "anthropic-messages")
38
+ return compat.thinkingMode !== "adaptive";
39
+ if (model.api !== undefined && RESPONSES_APIS.has(model.api))
40
+ return declaredOffSpelling(model) !== undefined;
41
+ const format = compat.thinkingFormat ?? "openai";
42
+ return format === "openai" ? declaredOffSpelling(model) !== undefined : true;
43
+ }
26
44
  export function declaredEffortLevels(v) {
27
45
  return Array.isArray(v) && v.length > 0 ? v : undefined;
28
46
  }
@@ -6,6 +6,22 @@ export declare const AUTO_MODE_DEFAULT_FAILURE_THRESHOLD = 3;
6
6
  export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
7
7
  /** Per-entry excerpt cap when `AutoModeWindowOptions.maxCharsPerEntry` is omitted. */
8
8
  export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2000;
9
+ /**
10
+ * The classifier call's output cap (`maxTokens`), sized from the engine's OWN output contract rather
11
+ * than copied from upstream: the parser (`parseAutoModeResponse`) accepts exactly two shapes,
12
+ * `<block>no</block>` and `<block>yes</block><category>NAME</category><reason>[NAME] one short
13
+ * sentence</reason>`. The longest rule name in the shipped rule sets is 46 characters and the reason
14
+ * is one short sentence naming that rule (≈200 characters at the outside, more when several rules
15
+ * match and the others are named in the reason), so the longest LEGAL reply is ≈350 characters —
16
+ * ≈120 tokens at the conservative 3 chars/token that tag-heavy text tokenizes at, ≈150 with a
17
+ * second and third rule name in the reason. Doubled for margin: a truncated block verdict still
18
+ * parses as a block (the parser tolerates a missing tail), a truncated allow does not exist (it is
19
+ * seven tokens), and the cap's job is to make a model that ignores the "no preamble" instruction —
20
+ * or that reasons in-text — cost a bounded, quickly-detected contract failure instead of a slow one.
21
+ * Paired with `reasoning:"off"` at the call: the cap hosts the ANSWER, so it applies only where the
22
+ * wire can say off (`thinkingOffExpressible`); a model that thinks regardless keeps its own budget.
23
+ */
24
+ export declare const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
9
25
  /** The sentinel a deployment puts INSIDE a paired rule list to splice the CC default rules back in at
10
26
  * that position (CC `XYt = "$defaults"`). It lives here, beside the other defaults, because the #503
11
27
  * recipe canonicalizer needs its VALUE and must not load the assembly face (and its SHA-locked assets)
@@ -2,6 +2,7 @@ export const AUTO_MODE_DEFAULT_TIMEOUT_MS = 15_000;
2
2
  export const AUTO_MODE_DEFAULT_FAILURE_THRESHOLD = 3;
3
3
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
4
4
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2_000;
5
+ export const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
5
6
  export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
6
7
  export const AUTO_MODE_DENIAL_LIMIT_DEFAULTS = Object.freeze({ maxConsecutive: 3, maxTotal: 20 });
7
8
  export const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120_000;
@@ -70,6 +70,25 @@ export interface AutoModeDeciderOptions {
70
70
  consecutiveFailures: number;
71
71
  lastCause: string;
72
72
  }) => void;
73
+ /**
74
+ * Fired once per `decide` call with the verdict the gate is about to act on and the wall time it
75
+ * waited (`ms`, integer ≥ 0; a timed-out round reads the deadline; a breaker-open short-circuit
76
+ * reads ~0 with `cause:"breaker_open"`). `cause` is present iff the verdict is `unavailable`. The
77
+ * engine's own wiring turns this into the `auto_mode.classified` trace frame; a hand-built decider
78
+ * need not implement it. Like the breaker alarm, a throwing hook never breaks the gate.
79
+ */
80
+ onClassified?: (info: AutoModeClassified) => void;
81
+ }
82
+ /** What {@link AutoModeDeciderOptions.onClassified} receives — one record per decision. */
83
+ export interface AutoModeClassified {
84
+ /** The gated call the decision was about (`AutoModeClassifyInput.req.toolCallId`). */
85
+ toolCallId: string;
86
+ /** Wall-clock milliseconds between the decide call and its verdict. */
87
+ ms: number;
88
+ verdict: AutoModeVerdict["kind"];
89
+ cause?: Extract<AutoModeVerdict, {
90
+ kind: "unavailable";
91
+ }>["cause"];
73
92
  }
74
93
  export interface AutoModeDecider {
75
94
  /** Never rejects. Any internal failure surfaces as `unavailable`/`parse_error` (fail-closed). */
@@ -32,67 +32,85 @@ export function createAutoModeDecider(opts) {
32
32
  }
33
33
  }
34
34
  };
35
- return {
36
- breakerOpen: () => open,
37
- consecutiveFailures: () => consecutiveFailures,
38
- async decide(input, signal) {
39
- if (open)
40
- return { kind: "unavailable", cause: "breaker_open" };
41
- let timer;
42
- const inner = new AbortController();
43
- const onOuterAbort = () => inner.abort();
44
- if (signal !== undefined) {
45
- if (signal.aborted)
46
- inner.abort();
47
- else
48
- signal.addEventListener("abort", onOuterAbort, { once: true });
49
- }
35
+ const decide = async (input, signal) => {
36
+ const startedAt = performance.now();
37
+ const verdict = await decideUnreported(input, signal);
38
+ if (opts.onClassified !== undefined) {
50
39
  try {
51
- const raw = await new Promise((resolve, reject) => {
52
- let settled = false;
53
- timer = setTimeout(() => {
54
- if (!settled) {
55
- settled = true;
56
- inner.abort();
57
- reject(new AutoModeTimeout());
58
- }
59
- }, timeoutMs);
60
- opts.classify(input, inner.signal).then((v) => {
61
- if (!settled) {
62
- settled = true;
63
- resolve(v);
64
- }
65
- }, (e) => {
66
- if (!settled) {
67
- settled = true;
68
- reject(e);
69
- }
70
- });
40
+ opts.onClassified({
41
+ toolCallId: input.req.toolCallId,
42
+ ms: Math.max(0, Math.round(performance.now() - startedAt)),
43
+ verdict: verdict.kind,
44
+ ...(verdict.kind === "unavailable" ? { cause: verdict.cause } : {}),
71
45
  });
72
- if (open)
73
- return { kind: "unavailable", cause: "breaker_open" };
74
- const verdict = parseAutoModeResponse(raw);
75
- if (verdict.kind === "parse_error")
76
- recordFailure("parse_error");
77
- else
78
- consecutiveFailures = 0;
79
- return verdict;
80
46
  }
81
- catch (e) {
82
- if (signal?.aborted)
83
- return { kind: "unavailable", cause: "error" };
84
- const timedOut = e instanceof AutoModeTimeout;
85
- recordFailure(timedOut ? "timeout" : "error");
86
- return { kind: "unavailable", cause: timedOut ? "timeout" : "error" };
87
- }
88
- finally {
89
- if (timer !== undefined)
90
- clearTimeout(timer);
91
- if (signal !== undefined)
92
- signal.removeEventListener("abort", onOuterAbort);
47
+ catch {
93
48
  }
94
- },
49
+ }
50
+ return verdict;
51
+ };
52
+ return {
53
+ breakerOpen: () => open,
54
+ consecutiveFailures: () => consecutiveFailures,
55
+ decide,
95
56
  };
57
+ async function decideUnreported(input, signal) {
58
+ if (open)
59
+ return { kind: "unavailable", cause: "breaker_open" };
60
+ let timer;
61
+ const inner = new AbortController();
62
+ const onOuterAbort = () => inner.abort();
63
+ if (signal !== undefined) {
64
+ if (signal.aborted)
65
+ inner.abort();
66
+ else
67
+ signal.addEventListener("abort", onOuterAbort, { once: true });
68
+ }
69
+ try {
70
+ const raw = await new Promise((resolve, reject) => {
71
+ let settled = false;
72
+ timer = setTimeout(() => {
73
+ if (!settled) {
74
+ settled = true;
75
+ inner.abort();
76
+ reject(new AutoModeTimeout());
77
+ }
78
+ }, timeoutMs);
79
+ opts.classify(input, inner.signal).then((v) => {
80
+ if (!settled) {
81
+ settled = true;
82
+ resolve(v);
83
+ }
84
+ }, (e) => {
85
+ if (!settled) {
86
+ settled = true;
87
+ reject(e);
88
+ }
89
+ });
90
+ });
91
+ if (open)
92
+ return { kind: "unavailable", cause: "breaker_open" };
93
+ const verdict = parseAutoModeResponse(raw);
94
+ if (verdict.kind === "parse_error")
95
+ recordFailure("parse_error");
96
+ else
97
+ consecutiveFailures = 0;
98
+ return verdict;
99
+ }
100
+ catch (e) {
101
+ if (signal?.aborted)
102
+ return { kind: "unavailable", cause: "error" };
103
+ const timedOut = e instanceof AutoModeTimeout;
104
+ recordFailure(timedOut ? "timeout" : "error");
105
+ return { kind: "unavailable", cause: timedOut ? "timeout" : "error" };
106
+ }
107
+ finally {
108
+ if (timer !== undefined)
109
+ clearTimeout(timer);
110
+ if (signal !== undefined)
111
+ signal.removeEventListener("abort", onOuterAbort);
112
+ }
113
+ }
96
114
  }
97
115
  class AutoModeTimeout extends Error {
98
116
  constructor() {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The execution-outcome record's vocabulary (layer 0, beside the checkpoint store it serves): the closed
3
+ * answer set of `CheckpointStore.recordExecutionOutcome`, its one disposition table, the conflict error,
4
+ * the shared write disposition and reopen predicate every backend routes through, the structural
5
+ * equality the idempotency arm uses, and the one read face of the column. The column itself
6
+ * (`CheckpointRow.executionOutcome` / `executionAtMs`) and the verb's contract are declared on the store
7
+ * (`checkpoint-store.ts`); this module names no store type — the row facts it reads are spelled
8
+ * structurally — so the store reaches DOWN for it and nothing here reaches back.
9
+ */
10
+ import { type AssertAllKeysHandled } from "./ask-origin.js";
11
+ import type { GateOutcome } from "./gate-outcome.js";
12
+ /**
13
+ * The closed answer set of `CheckpointStore.recordExecutionOutcome` — what the store did with the
14
+ * execution record it was handed. One word per way the write can end; a caller keys on the word, never
15
+ * on an absence:
16
+ * - `recorded` — the CAS won: the row was `resolved` with no record yet, and now carries this one.
17
+ * - `already_recorded` — the row already carries a record EQUAL to the one supplied (a retry of the
18
+ * same write; idempotent, nothing changed). A record that DIFFERS is not a word — it is the
19
+ * {@link ExecutionOutcomeConflictError} throw.
20
+ * - `not_resolved` — the row exists in this scope but is not `resolved` (still pending, or expired):
21
+ * there is no decision an execution could be the outcome of, so nothing was written.
22
+ * - `absent` — no row under this token in this scope (a missing row and a wrong-scope row alike — the
23
+ * isolation rule every verb of the store keeps).
24
+ */
25
+ export declare const EXECUTION_OUTCOME_RECORD_WORDS: readonly ["recorded", "already_recorded", "not_resolved", "absent"];
26
+ export type ExecutionOutcomeRecordWord = (typeof EXECUTION_OUTCOME_RECORD_WORDS)[number];
27
+ /** Membership test for {@link ExecutionOutcomeRecordWord} — a deployment store answers across a process
28
+ * boundary, so the consumer validating its word must not hand-roll the set. */
29
+ export declare function isExecutionOutcomeRecordWord(v: unknown): v is ExecutionOutcomeRecordWord;
30
+ /**
31
+ * The one disposition table over {@link ExecutionOutcomeRecordWord}: does this answer mean the row now
32
+ * carries NO execution record — i.e. the leg's disposition of the decided action is not on file? The
33
+ * engine's belt reads it to decide whether to announce `checkpoint.execution_outcome_unrecorded` (the
34
+ * execution result itself is never changed by the answer: the record is an account of what ran, not a
35
+ * gate on it). A new word must take a row here (the fence below reds `tsc` otherwise).
36
+ */
37
+ export declare const EXECUTION_RECORD_LEAVES_ROW_UNRECORDED: {
38
+ readonly recorded: false;
39
+ readonly already_recorded: false;
40
+ readonly not_resolved: true;
41
+ readonly absent: true;
42
+ };
43
+ export type ExecutionRecordTableCoversEveryWord = AssertAllKeysHandled<Exclude<ExecutionOutcomeRecordWord, keyof typeof EXECUTION_RECORD_LEAVES_ROW_UNRECORDED>>;
44
+ /**
45
+ * The read face of `CheckpointRow.executionOutcome`: what a reader of a resolved row can say about how
46
+ * the decided action was disposed. `executed` carries the record the resumed leg filed; `unknown` is the
47
+ * ONE meaning of the column's absence (the leg has not reached its resolution yet, the row was consumed
48
+ * by a worker that could not record, or the store answered `not_resolved`/`absent` and the engine
49
+ * announced it). Never `allowed` by default — the pre-execution decision lives on `resolvedOutcome`.
50
+ */
51
+ export type ExecutionVerdict = {
52
+ kind: "executed";
53
+ gate: GateOutcome;
54
+ } | {
55
+ kind: "unknown";
56
+ };
57
+ /**
58
+ * `CheckpointStore.recordExecutionOutcome` was handed a record for a row that already carries a DIFFERENT
59
+ * one. The record is physically immutable (see `CheckpointRow.executionOutcome`), so this is never
60
+ * resolved by overwriting: two different accounts of one execution mean the engine ran, or believes it
61
+ * ran, the same decided action twice — an engine defect the belt must surface, not a store condition it
62
+ * may absorb. Carries both records so the operator can read what was on file and what tried to replace
63
+ * it. Code `checkpoint.execution_outcome_conflict`; a deployment store may throw any error carrying the
64
+ * same `code` — the code is the contract, this class is its in-tree form.
65
+ */
66
+ export declare class ExecutionOutcomeConflictError extends Error {
67
+ readonly code: "checkpoint.execution_outcome_conflict";
68
+ /** The record on file — a COPY: a backend hands this constructor its live row value, and a caller that
69
+ * catches the error must not be able to rewrite the row through it (the record is immutable by CAS,
70
+ * not by convention). */
71
+ readonly recorded: GateOutcome;
72
+ /** The record that tried to replace it — copied for the same reason. */
73
+ readonly attempted: GateOutcome;
74
+ constructor(recorded: GateOutcome, attempted: GateOutcome);
75
+ }
76
+ /** The row facts the record's disposition reads — spelled structurally so a backend can hand a partial
77
+ * read-back (a Pg row) as easily as a full `Checkpoint`. */
78
+ export interface ExecutionRecordRowFacts {
79
+ scope: string;
80
+ status: "pending" | "resolved" | "expired";
81
+ executionOutcome?: GateOutcome;
82
+ }
83
+ /** `true` iff the row carries an execution record — the reopen verbs' extra predicate (a settled action
84
+ * is not re-openable) and the record verb's own idempotency read. Pure; shared by every backend. */
85
+ export declare function checkpointExecutionRecorded(cp: Pick<ExecutionRecordRowFacts, "executionOutcome">): boolean;
86
+ /** Structural equality of two execution records as the JSON they round-trip as — key-order insensitive
87
+ * (a jsonb column reorders keys) and indifferent to freezing (the engine's record is minted frozen; a
88
+ * store's copy is not). A record is a plain data graph by construction, so nothing here needs the
89
+ * accessor/topology defences a caller-supplied value would. */
90
+ export declare function sameExecutionRecord(a: GateOutcome, b: GateOutcome): boolean;
91
+ /**
92
+ * THE one disposition of an execution-record write, shared by every backend so the CAS predicate
93
+ * `status = 'resolved' AND execution_outcome IS NULL` and its two idempotency/conflict arms cannot drift
94
+ * between stores (the same posture as the shared row predicate `checkpointRowMatches`). Pure: it decides
95
+ * what the write means for THIS row and this record; the backend keeps its own commit half (an in-place
96
+ * assignment, an appended ledger event, an `UPDATE … WHERE` that folds the same predicate into SQL).
97
+ * - `commit` — the row is `resolved` in this scope with no record: write it (the answer is `recorded`).
98
+ * - `already_recorded` / `not_resolved` / `absent` — the {@link ExecutionOutcomeRecordWord} to answer.
99
+ * - `conflict` — the row carries a different record: throw {@link ExecutionOutcomeConflictError}.
100
+ * Order is load-bearing: a record on file is judged BEFORE the status arm, so a retry against a row whose
101
+ * status a concurrent verb has since moved still answers by the record it carries.
102
+ */
103
+ export declare function executionRecordDisposition(cp: ExecutionRecordRowFacts | undefined, scope: string, gate: GateOutcome): "commit" | "conflict" | Exclude<ExecutionOutcomeRecordWord, "recorded">;
104
+ /**
105
+ * The ONE read of `CheckpointRow.executionOutcome`: `executed` with the record when the row carries one,
106
+ * `unknown` otherwise. A consumer rebuilding a run from its rows (an audit face, a supervisor inbox after
107
+ * a stream break) reads this rather than the column, so "no column" and "column not yet written" have
108
+ * exactly one spelling between them and no reader invents a default disposition.
109
+ */
110
+ export declare function executionVerdict(cp: Pick<ExecutionRecordRowFacts, "executionOutcome">): ExecutionVerdict;
@@ -0,0 +1,49 @@
1
+ import {} from "./ask-origin.js";
2
+ export const EXECUTION_OUTCOME_RECORD_WORDS = ["recorded", "already_recorded", "not_resolved", "absent"];
3
+ const EXECUTION_OUTCOME_RECORD_WORD_SET = new Set(EXECUTION_OUTCOME_RECORD_WORDS);
4
+ export function isExecutionOutcomeRecordWord(v) {
5
+ return EXECUTION_OUTCOME_RECORD_WORD_SET.has(v);
6
+ }
7
+ export const EXECUTION_RECORD_LEAVES_ROW_UNRECORDED = {
8
+ recorded: false,
9
+ already_recorded: false,
10
+ not_resolved: true,
11
+ absent: true,
12
+ };
13
+ export class ExecutionOutcomeConflictError extends Error {
14
+ code = "checkpoint.execution_outcome_conflict";
15
+ recorded;
16
+ attempted;
17
+ constructor(recorded, attempted) {
18
+ super("the checkpoint already carries an execution outcome that differs from the one being recorded — the record is immutable, so this is an engine defect (one decided action settled twice), not a retry");
19
+ this.name = "ExecutionOutcomeConflictError";
20
+ this.recorded = structuredClone(recorded);
21
+ this.attempted = structuredClone(attempted);
22
+ }
23
+ }
24
+ export function checkpointExecutionRecorded(cp) {
25
+ return cp.executionOutcome !== undefined;
26
+ }
27
+ export function sameExecutionRecord(a, b) {
28
+ return canonicalJson(a) === canonicalJson(b);
29
+ }
30
+ function canonicalJson(v) {
31
+ if (v === null || typeof v !== "object")
32
+ return JSON.stringify(v) ?? "undefined";
33
+ if (Array.isArray(v))
34
+ return `[${v.map(canonicalJson).join(",")}]`;
35
+ const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
36
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
37
+ }
38
+ export function executionRecordDisposition(cp, scope, gate) {
39
+ if (cp === undefined || cp.scope !== scope)
40
+ return "absent";
41
+ if (cp.executionOutcome !== undefined)
42
+ return sameExecutionRecord(cp.executionOutcome, gate) ? "already_recorded" : "conflict";
43
+ if (cp.status !== "resolved")
44
+ return "not_resolved";
45
+ return "commit";
46
+ }
47
+ export function executionVerdict(cp) {
48
+ return cp.executionOutcome !== undefined ? { kind: "executed", gate: cp.executionOutcome } : { kind: "unknown" };
49
+ }