@sema-agent/core 7.6.3 → 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.
- package/CHANGELOG.md +33 -10
- package/dist/agents/peer-admission.d.ts +1 -1
- package/dist/brain/anthropic.js +8 -2
- package/dist/brain/open-responses.js +5 -3
- package/dist/brain/openai.js +31 -8
- package/dist/brain/reasoning.d.ts +32 -0
- package/dist/brain/reasoning.js +18 -0
- package/dist/core/auto-mode-defaults.d.ts +16 -0
- package/dist/core/auto-mode-defaults.js +1 -0
- package/dist/core/auto-mode.d.ts +19 -0
- package/dist/core/auto-mode.js +74 -56
- package/dist/core/checkpoint-execution-record.d.ts +110 -0
- package/dist/core/checkpoint-execution-record.js +49 -0
- package/dist/core/checkpoint-store.d.ts +88 -10
- package/dist/core/checkpoint-store.js +35 -2
- package/dist/core/engine-notice.d.ts +11 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/runner/clock-and-limits.d.ts +117 -0
- package/dist/core/runner/clock-and-limits.js +118 -0
- package/dist/core/runner/contracts.d.ts +10 -0
- package/dist/core/runner/decide-continuation.d.ts +98 -0
- package/dist/core/runner/decide-continuation.js +133 -0
- package/dist/core/runner/execution-record.d.ts +26 -0
- package/dist/core/runner/execution-record.js +19 -0
- package/dist/core/runner/git-leg-delivery.d.ts +28 -0
- package/dist/core/runner/git-leg-delivery.js +94 -0
- package/dist/core/runner/initial-run-state.d.ts +14 -0
- package/dist/core/runner/initial-run-state.js +11 -0
- package/dist/core/runner/prepare-caps-and-workflow.js +17 -0
- package/dist/core/runner/prepare-run-refs.d.ts +0 -16
- package/dist/core/runner/prepare-run-refs.js +0 -6
- package/dist/core/runner/runtask.d.ts +0 -68
- package/dist/core/runner/runtask.js +28 -452
- package/dist/core/runner/steer-admission.d.ts +17 -0
- package/dist/core/runner/steer-admission.js +17 -0
- package/dist/core/runner/tool-end-body.d.ts +71 -0
- package/dist/core/runner/tool-end-body.js +74 -0
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +4 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +85 -0
- package/dist/core/trace.d.ts +24 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/stores/file/checkpoint-store.d.ts +7 -0
- package/dist/stores/file/checkpoint-store.js +20 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +37 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 7.
|
|
3
|
+
## 7.7.0 — 2026-09-07
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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).
|
|
9
14
|
|
|
10
|
-
|
|
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).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
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).
|
|
11
24
|
|
|
12
|
-
|
|
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).
|
|
13
30
|
|
|
14
31
|
### Fixed
|
|
15
|
-
-
|
|
16
|
-
- **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`.
|
|
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`.
|
|
17
33
|
|
|
18
|
-
### Changed
|
|
19
|
-
- `
|
|
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.
|
|
20
43
|
|
|
21
44
|
## 7.6.1 — 2026-09-07
|
|
22
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/
|
|
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
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -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 (
|
|
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 &&
|
|
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 (
|
|
156
|
-
return
|
|
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) {
|
package/dist/brain/openai.js
CHANGED
|
@@ -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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
package/dist/brain/reasoning.js
CHANGED
|
@@ -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;
|
package/dist/core/auto-mode.d.ts
CHANGED
|
@@ -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). */
|
package/dist/core/auto-mode.js
CHANGED
|
@@ -32,67 +32,85 @@ export function createAutoModeDecider(opts) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
+
}
|