@sema-agent/core 7.36.0 → 7.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/brain/anthropic.js +7 -14
  3. package/dist/brain/open-responses.js +6 -13
  4. package/dist/brain/openai.js +7 -14
  5. package/dist/brain/stream-shared.d.ts +21 -0
  6. package/dist/brain/stream-shared.js +13 -0
  7. package/dist/core/ask-origin.d.ts +35 -5
  8. package/dist/core/ask-origin.js +23 -4
  9. package/dist/core/checkpoint-store.d.ts +13 -3
  10. package/dist/core/decision-receipt.d.ts +21 -0
  11. package/dist/core/decision-receipt.js +63 -0
  12. package/dist/core/engine-notice.d.ts +17 -5
  13. package/dist/core/gate-fold.js +2 -1
  14. package/dist/core/gate-lanes.js +8 -13
  15. package/dist/core/gate-outcome.d.ts +7 -5
  16. package/dist/core/gate-outcome.js +5 -1
  17. package/dist/core/governance-codes.d.ts +1 -1
  18. package/dist/core/governance-codes.js +2 -0
  19. package/dist/core/hooks.d.ts +11 -2
  20. package/dist/core/hooks.js +3 -1
  21. package/dist/core/park-selfcheck.js +2 -0
  22. package/dist/core/runner/denial-limit-arms.d.ts +28 -18
  23. package/dist/core/runner/denial-limit-arms.js +11 -7
  24. package/dist/core/runner/inherited-ask-grants.d.ts +32 -31
  25. package/dist/core/runner/inherited-ask-grants.js +5 -0
  26. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  27. package/dist/core/runner/prepare-park-ask.d.ts +2 -2
  28. package/dist/core/runner/prepare-park-ask.js +1 -5
  29. package/dist/core/runner/prepare-policy-chain.d.ts +3 -8
  30. package/dist/core/runner/prepare-policy-chain.js +43 -25
  31. package/dist/core/runner/run-identity-wiring.d.ts +1 -1
  32. package/dist/core/runner/run-identity-wiring.js +2 -0
  33. package/dist/core/runner/tool-turn-repair-seat.d.ts +8 -0
  34. package/dist/core/runner/tool-turn-repair-seat.js +28 -0
  35. package/dist/core/session-reconcile.d.ts +35 -0
  36. package/dist/core/session-reconcile.js +31 -20
  37. package/dist/core/side-query.js +4 -2
  38. package/dist/core/store-contracts/checkpoint-store-contract.js +18 -0
  39. package/dist/core/tool-name-readings.d.ts +4 -0
  40. package/dist/core/tool-name-readings.js +1 -0
  41. package/dist/core/tool-policy.d.ts +74 -84
  42. package/dist/core/tool-policy.js +71 -36
  43. package/dist/engine/harness/agent-harness.d.ts +7 -0
  44. package/dist/engine/harness/agent-harness.js +14 -2
  45. package/dist/engine/llm/index.d.ts +1 -0
  46. package/dist/engine/llm/index.js +1 -0
  47. package/dist/engine/llm/tool-turns.d.ts +20 -0
  48. package/dist/engine/llm/tool-turns.js +91 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/internal/llm.d.ts +2 -2
  52. package/dist/internal/llm.js +1 -1
  53. package/package.json +1 -1
  54. package/test/export-surface.snapshot.json +7 -1
  55. package/wire-changes/core@7.37.0.json +405 -0
  56. package/wire-changes/core@7.37.1.json +122 -0
@@ -1,41 +1,42 @@
1
+ import type { AskClass } from "../ask-origin.js";
1
2
  import type { HumanReviewGateRow } from "../task-result.js";
2
3
  import { type OnAsk, type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
3
- /** JSON.stringify throws on a cyclic value (a deployment
4
- * policy's rewrite object carries no acyclicity guarantee) — `undefined` here means "no comparable
5
- * shape", which every consumer treats as a mismatch (skip the record / skip the reuse ⇒ the child
6
- * gate just re-asks — the safe direction, never a gate failure). */
7
- export declare function askGrantShapeOf(args: unknown): string | undefined;
8
- /**
9
- * Duplicate-frame collapse (single-Runner embedded shape), the CONSUME half: an inherited
10
- * layer already resolved THIS call as a clean allow at the SAME approver function, on EXACTLY the
11
- * byte-shape this caller-slot ask now presents. The human's consent covers this consultation
12
- * verbatim — asking again is the 2-frame UX debt, not a second authority. Guards, all mismatches
13
- * re-ask (⇒ `undefined`):
14
- * · SOURCE: only a caller-slot POLICY ask may reuse — a safety tighten
15
- * (egress/irreversible/shellGate stamp `decisionReason:"safety"`), a hook-promoted ask
16
- * ("hook"), or a classifier ask carries a DIFFERENT authority/context and keeps its frame.
17
- * · IDENTITY: the approver FUNCTION reference (never a name) — compared through
18
- * `askApproverIdentity`, not by raw reference (ruled 2026-08-04): a delegated child's approver
19
- * is the ancestor's own, wrapped with delegation provenance — a DIFFERENT function object, the
20
- * SAME operator. Bare `===` therefore stopped collapsing the frame the moment the ask seam
21
- * started forwarding approvers, and one tool call presented the same person the same card
22
- * twice. The wrapper adds metadata and delegates every decision, so it grants no authority of
23
- * its own; identity here only answers "same consultation".
24
- * · SHAPE: byte-equality against the presented SNAPSHOT; reuse re-emits that snapshot
25
- * as the executed shape via `presentedInput` — the same schema-revalidated binding
26
- * as a main-gate approval (shown == executed, by construction).
27
- * · LEDGER: the ONE real consultation (timed on the wrapper arm) is recorded here —
28
- * one frame happened, one frame is booked.
29
- * The grant lives only within one gate evaluation (consumed on reuse, swept at the call site) — a
30
- * REOPENED checkpoint's re-adjudication never sees a stale grant.
31
- */
32
- export declare function consumeInheritedAskGrant(grants: Map<string, {
4
+ /** One inherited ask grant: a person's clean allow at an ancestor's live approver, on ONE presented shape, to ONE question
5
+ * (`classes`, the request's class set in `ASK_CLASSES` order) — it answers no stricter question. */
6
+ export interface InheritedAskGrant {
33
7
  approver: unknown;
34
8
  argsJson: string;
35
9
  presented: unknown;
36
10
  waitMs: number;
37
11
  settled?: object;
38
- }>, onAsk: OnAsk | undefined, humanReviewRef: {
12
+ classes: readonly AskClass[];
13
+ }
14
+ /** Does a grant for `granted` cover a question the gate classified as `asked`? `ordinary` — the plain hesitation every other
15
+ * class is a stricter form of — is covered by any card answered; every other class must have been on the card (the gate reads
16
+ * facts the station cannot hold, and a class those add was asked of nobody). Unclassified, or a card with no class ⇒ never. */
17
+ export declare function askGrantCovers(granted: readonly AskClass[], asked: readonly AskClass[] | undefined): boolean;
18
+ /** JSON.stringify throws on a cyclic value (a rewrite object carries no acyclicity guarantee) — `undefined` means "no
19
+ * comparable shape", which every consumer treats as a mismatch (skip the record / reuse ⇒ re-ask, the safe direction). */
20
+ export declare function askGrantShapeOf(args: unknown): string | undefined;
21
+ /**
22
+ * Duplicate-frame collapse, the CONSUME half: an inherited layer already resolved THIS call as a clean allow at the
23
+ * SAME approver function, on EXACTLY the byte-shape this caller-slot ask now presents, for a question that covers this
24
+ * one — asking again is the 2-frame UX debt, not a second authority. Guards, all mismatches re-ask (⇒ `undefined`):
25
+ * · SOURCE: only a caller-slot POLICY ask may reuse — a safety tighten (`decisionReason:"safety"`), a hook-promoted
26
+ * ask ("hook") or a classifier ask carries a DIFFERENT authority and keeps its frame.
27
+ * · IDENTITY: the approver FUNCTION reference (never a name), compared through `askApproverIdentity`: a delegated
28
+ * child's approver is the ancestor's own wrapped with provenance — a different function object, the same operator
29
+ * (the wrapper grants no authority of its own); identity answers only "same consultation".
30
+ * · SHAPE: byte-equality against the presented SNAPSHOT; reuse re-emits that snapshot as the executed shape via
31
+ * `presentedInput` — the same binding as a main-gate approval (shown == executed, by construction).
32
+ * · CLASS: the gate's stamped `classes` is covered by the set the person answered ({@link askGrantCovers}); a class
33
+ * the gate's own judges add (a protected target, an out-of-root read) was not on the card. Not covered ⇒ the gate
34
+ * asks its own question and the grant stays (it answers what it answered; the call-site sweep ends it).
35
+ * · LEDGER: the ONE real consultation (timed on the wrapper arm) is booked here — one frame happened, one is booked.
36
+ * The grant lives within one gate evaluation (consumed on reuse, swept at the call site): a REOPENED checkpoint's
37
+ * re-adjudication never sees a stale grant.
38
+ */
39
+ export declare function consumeInheritedAskGrant(grants: Map<string, InheritedAskGrant>, onAsk: OnAsk | undefined, humanReviewRef: {
39
40
  count: number;
40
41
  totalWaitMs: number;
41
42
  gates: HumanReviewGateRow[];
@@ -1,5 +1,8 @@
1
1
  import { primaryActivityArg } from "../arg-summary.js";
2
2
  import { askApproverIdentity, carryEngineAttestations } from "../tool-policy.js";
3
+ export function askGrantCovers(granted, asked) {
4
+ return asked !== undefined && asked.length > 0 && granted.length > 0 && asked.every((c) => c === "ordinary" || granted.includes(c));
5
+ }
3
6
  export function askGrantShapeOf(args) {
4
7
  try {
5
8
  return JSON.stringify(args ?? null);
@@ -14,6 +17,8 @@ export function consumeInheritedAskGrant(grants, onAsk, humanReviewRef, decision
14
17
  const grant = grants.get(req.toolCallId);
15
18
  if (grant === undefined || askApproverIdentity(grant.approver) !== askApproverIdentity(onAsk) || grant.argsJson !== askGrantShapeOf(req.args))
16
19
  return undefined;
20
+ if (!askGrantCovers(grant.classes, decision.action === "ask" ? decision.classes : undefined))
21
+ return undefined;
17
22
  grants.delete(req.toolCallId);
18
23
  humanReviewRef.count += 1;
19
24
  humanReviewRef.totalWaitMs += grant.waitMs;
@@ -36,7 +36,7 @@ export function prepareInheritedGate(input) {
36
36
  if (modeSeed.migrated !== undefined) {
37
37
  deliverEngineNotice(deps.onNotice, {
38
38
  code: "resume.legacy_mode_seed_migrated",
39
- message: `the resumed checkpoint row carried pre-mode keys (${modeSeed.migrated.droppedKeys.join(", ")}); read once into ${modeSeed.migrated.permissionMode !== undefined ? `permissionMode: "${modeSeed.migrated.permissionMode}"` : "no mode of its own (the chain's ceiling stands)"}${modeSeed.shellGate !== undefined ? ' with the shell floor "always" kept' : ""} — the next suspend writes the one key; from 7.35.0 a row still carrying these keys is refused`,
39
+ message: `the resumed checkpoint row carried pre-mode keys (${modeSeed.migrated.droppedKeys.join(", ")}); read once into ${modeSeed.migrated.permissionMode !== undefined ? `permissionMode: "${modeSeed.migrated.permissionMode}"` : "no mode of its own (the chain's ceiling stands)"}${modeSeed.shellGate !== undefined ? ' with the shell floor "always" kept' : ""} — the next suspend writes the one key; this migration is kept through 7.37.x and removed in 7.38.0, which refuses a row still carrying these keys (resume or abandon such rows before upgrading)`,
40
40
  detail: { migrated: { ...(modeSeed.migrated.permissionMode !== undefined ? { permissionMode: modeSeed.migrated.permissionMode } : {}), droppedKeys: [...modeSeed.migrated.droppedKeys] }, sessionId, runId },
41
41
  });
42
42
  }
@@ -9,8 +9,8 @@
9
9
  * decomposition exists to make explicit.
10
10
  *
11
11
  * The thirteen-argument park closure is unchanged: twelve positional seats then the structural {@link AskCarry}, the
12
- * shape the gate's one invocation site (`parkWith`) holds. The ask's origin word and the denial-limit fallback ride the
13
- * carry onto the row exactly as before — this phase mints no origin, changes no word, adds no arm to the closed set.
12
+ * shape the gate's one invocation site (`parkWith`) holds. The ask's origin word, class pair and denial-limit fallback
13
+ * ride the carry onto the row — this phase mints no origin or class, changes no word, adds no arm to the closed set.
14
14
  *
15
15
  * SYNCHRONOUS FUNCTION, SYNCHRONOUS CALL: the stretch has no await of its own (both closures run per tool call), so the
16
16
  * phase adds no yield between the boundary parks before it and the gate station after it.
@@ -173,11 +173,7 @@ export function prepareParkAsk(input) {
173
173
  if (reprojectedRaced.tag === "threw")
174
174
  throw reprojectedRaced.error;
175
175
  const reprojected = refuseOutOfContractDecision(reprojectedRaced.value);
176
- const demanded = reprojected.updatedInput !== undefined ? tryCloneArgs(reprojected.updatedInput) : undefined;
177
- const rewroteTheFiledValue = reprojected.updatedInput !== undefined &&
178
- !(demanded?.ok === true &&
179
- samePlainValue(demanded.value, parkedArgs) &&
180
- samePlainValue(reprojected.updatedInput, parkedArgs));
176
+ const rewroteTheFiledValue = reprojected.updatedInput !== undefined && !samePlainValue(reprojected.updatedInput, parkedArgs);
181
177
  if (reprojected.action === "deny" || rewroteTheFiledValue) {
182
178
  throw new ParkRefusal(`the arguments of "${req.toolName}" change shape when stored by this checkpoint backend, and the deployment's tool policy does not accept the stored shape` +
183
179
  `${reprojected.action === "deny" && reprojected.message ? `: ${reprojected.message}` : ""} — ` +
@@ -34,6 +34,7 @@ import { type ActiveSkillScope } from "./active-skill-scope.js";
34
34
  import type { InheritedGate, IrreversibilityTier, PrepareResume, RunInternals, StopForDenialLimit } from "./contracts.js";
35
35
  import { type Arbitration } from "../ask-origin.js";
36
36
  import { type ModeGateSeats, type PermissionModeController } from "../mode-policy.js";
37
+ import { type InheritedAskGrant } from "./inherited-ask-grants.js";
37
38
  import { createRuleOffersOf, type PermissionRuleLanes } from "./permission-rule-lanes.js";
38
39
  import type { ToolCallLike } from "../tool-registry.js";
39
40
  export interface PreparePolicyChainInput {
@@ -45,7 +46,7 @@ export interface PreparePolicyChainInput {
45
46
  * `permissionRuleStore` / `localOwnerRules` (the lanes and the offer factory), `onError` / `onNotice` (the advisories,
46
47
  * the refusal disclosures, the late-ask observer), `tracer` (the admission and rule-store frames), `checkpointStore` (the park facility). Receiver preserved;
47
48
  * the advisory dedupe is KEYED on this object's identity. */
48
- deps: Pick<RunnerDeps, "sessionPolicyStore" | "hooks" | "permissionRuleStore" | "localOwnerRules" | "onError" | "onNotice" | "tracer" | "checkpointStore">;
49
+ deps: Pick<RunnerDeps, "sessionPolicyStore" | "hooks" | "permissionRuleStore" | "localOwnerRules" | "onError" | "onNotice" | "tracer" | "checkpointStore" | "a2a">;
49
50
  /** borrowed-readonly — the trusted spawn-side channel: `parentTaskId` (the from-subagent bit), `explicitAgentName` /
50
51
  * `agentName` (the source agent name) — the ask source identity's local, trusted facts. */
51
52
  internals: Pick<RunInternals, "parentTaskId" | "explicitAgentName" | "agentName"> | undefined;
@@ -186,13 +187,7 @@ export interface PreparePolicyChainResult {
186
187
  }>>;
187
188
  /** borrowed-mutable — the grant record. Writers: the inherited arms here (record a clean allow at a live approver);
188
189
  * the gate station (consume on reuse, sweep at settlement). */
189
- inheritedAskGrants: Map<string, {
190
- approver: unknown;
191
- argsJson: string;
192
- presented: unknown;
193
- waitMs: number;
194
- settled?: object;
195
- }>;
190
+ inheritedAskGrants: Map<string, InheritedAskGrant>;
196
191
  /** owned — the SINGLE AskRequest source-identity factory every resolveAsk construction site spreads. */
197
192
  askSourceIdentity: () => {
198
193
  principal?: string;
@@ -6,12 +6,12 @@ import { cloneObserverInput, resolveHookTimeoutMs } from "../hooks.js";
6
6
  import { assertHookSeatsCallable } from "../hook-seats.js";
7
7
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "../permission-rules.js";
8
8
  import { isIsolated } from "../remote-env.js";
9
- import { askApproverIdentity, carryEngineAttestations, carryPrecedingSettlement, checkToolPolicyProjection, combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, durableParkOptInDeclared, isLiveApproverSeat, resolveAsk, toolPolicyNameSets, wrapToolPolicy, attestAskUnresolvableCause } from "../tool-policy.js";
9
+ import { askApproverIdentity, carryEngineAttestations, carryPrecedingSettlement, checkToolPolicyProjection, combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, durableParkOptInDeclared, isLiveApproverSeat, refuseOutOfContractDecision, resolveAsk, toolPolicyNameSets, wrapToolPolicy, attestAskUnresolvableCause } from "../tool-policy.js";
10
10
  import { RETIRED_TOOL_NAMES } from "../tool-name-aliases.js";
11
11
  import { NAMESPACED_NAME_SHAPES, protocolOf } from "../protocol-table.js";
12
12
  import { emitTrace } from "../trace.js";
13
13
  import { createActiveSkillScopePolicy } from "./active-skill-scope.js";
14
- import { arbitrateByMode, askMandateMember, classifyAsk } from "../ask-origin.js";
14
+ import { arbitrateByMode, askClassMembersOf, askMandateMember } from "../ask-origin.js";
15
15
  import { createModeGatePolicy, mountedToolDeclaresReadOnly } from "../mode-policy.js";
16
16
  const ancestorFacts = (ask) => ({ ...(ask ?? {}), ancestorResolved: true });
17
17
  import { headlessDenyAtFold, headlessDenyAtRecheck, INHERITED_STATION_FACTS, inheritedAskCarry, judgeInheritedClassifier, lateAskSettlementObserver, settleDenialLimitFallback } from "./denial-limit-arms.js";
@@ -20,12 +20,12 @@ import { askGrantShapeOf } from "./inherited-ask-grants.js";
20
20
  import { createPermissionRuleLanes, inheritedAskRuleEvidence, createRuleOffersOf } from "./permission-rule-lanes.js";
21
21
  import { createLiveSessionRules, createSessionRulePolicy } from "./session-rule-policy.js";
22
22
  import { thrownText } from "../thrown-value.js";
23
- import { heldBy, seatOf, wireOf } from "../tool-name-readings.js";
23
+ import { heldBy, heldValues, seatOf, wireOf } from "../tool-name-readings.js";
24
24
  export async function preparePolicyChain(input) {
25
25
  const { spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, additionalRootsCanonical, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, shellGatedTools, toolMarkSources, envHandToolNames, handsCwdRef, readDenyMatcher, announceRuleStoreSkipped, questionToolMounted, runtimeCaps, inheritedAncestorRules, ancestorSessionRulesRef, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, permissionMode, scratchpadDir, toolRosterDeltas, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef } = input;
26
26
  const ancestorArbitrations = new Map();
27
- const arbitrateInherited = (ask, toolCallId) => {
28
- const verdict = arbitrateByMode(permissionMode.current, classifyAsk(ask, INHERITED_STATION_FACTS), { ...(ask.origin !== undefined ? { origin: ask.origin } : {}), ...(ask.mandate !== undefined ? { mandate: ask.mandate } : {}) });
27
+ const arbitrateInherited = (ask, toolCallId, classification) => {
28
+ const verdict = arbitrateByMode(permissionMode.current, classification, { ...(ask.origin !== undefined ? { origin: ask.origin } : {}), ...(ask.mandate !== undefined ? { mandate: ask.mandate } : {}) });
29
29
  if (verdict.kind === "ask" || verdict.kind === "accepted")
30
30
  return undefined;
31
31
  ancestorArbitrations.set(toolCallId, verdict);
@@ -188,7 +188,7 @@ export async function preparePolicyChain(input) {
188
188
  }, "mandate_unreconstructible", creq);
189
189
  const markerChannelFullDeny = (creq) => attestAskUnresolvableCause({ action: "deny", message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed` }, "approver_unavailable", creq);
190
190
  const inheritedAskGrants = new Map();
191
- const recordInheritedAskGrant = (toolCallId, approver, presented, waitMs, settled) => {
191
+ const recordInheritedAskGrant = (toolCallId, approver, presented, waitMs, settled, classes) => {
192
192
  if (typeof approver !== "function")
193
193
  return;
194
194
  if (presented === undefined)
@@ -198,7 +198,7 @@ export async function preparePolicyChain(input) {
198
198
  const argsJson = askGrantShapeOf(presented);
199
199
  if (argsJson === undefined)
200
200
  return;
201
- inheritedAskGrants.set(toolCallId, { approver, argsJson, presented, waitMs, settled });
201
+ inheritedAskGrants.set(toolCallId, { approver, argsJson, presented, waitMs, settled, classes });
202
202
  };
203
203
  const sandboxDeclared = isIsolated(executionEnv);
204
204
  const sandboxAdmissionArmed = (() => {
@@ -259,7 +259,7 @@ export async function preparePolicyChain(input) {
259
259
  };
260
260
  const callerPolicyLayer = policy !== undefined && sameInstanceAncestorCount > 0
261
261
  ? wrapToolPolicy(policy, async (creq, csignal) => {
262
- const decision = await policy.check(creq, csignal ?? abortController.signal);
262
+ const decision = refuseOutOfContractDecision(await policy.check(creq, csignal ?? abortController.signal));
263
263
  sharedFirstDecision.set(creq.toolCallId, decision);
264
264
  if (sharedFirstDecision.size > SHARED_FIRST_DECISION_CAP) {
265
265
  const oldest = sharedFirstDecision.keys().next().value;
@@ -278,15 +278,33 @@ export async function preparePolicyChain(input) {
278
278
  : {}),
279
279
  ...(delegation.isDelegatedChild ? { isDelegatedChild: true } : {}),
280
280
  });
281
+ const seatMarksOf = (call) => {
282
+ const tierSeat = seatOf("report.riskAxes", irreversibilityTier, call);
283
+ return {
284
+ tier: tierSeat?.value,
285
+ egress: heldBy("report.riskAxes.egress", egressTools, call),
286
+ shellGated: tierSeat?.shellGated(shellGatedTools, toolMarkSources) === true,
287
+ sources: new Set(heldValues("report.markSources", toolMarkSources, call).flatMap((s) => [...s])),
288
+ };
289
+ };
281
290
  const riskAxesOf = (call) => {
282
- const tier = seatOf("report.riskAxes", irreversibilityTier, call)?.value;
291
+ const seat = seatMarksOf(call);
283
292
  const negatives = seatOf("report.riskAxes.negatives", axisExplicitNegatives, call)?.value;
284
- const irreversible = tier === "always" || tier === "maybe" ? true : tier === "never" ? false : negatives?.irreversible;
285
- const egress = heldBy("report.riskAxes.egress", egressTools, call) ? true : negatives?.egress;
293
+ const irreversible = seat.tier === "always" || seat.tier === "maybe" ? true : seat.tier === "never" ? false : negatives?.irreversible;
294
+ const egress = seat.egress ? true : negatives?.egress;
286
295
  if (irreversible === undefined && egress === undefined)
287
296
  return {};
288
297
  return { riskAxes: { ...(irreversible !== undefined ? { irreversible } : {}), ...(egress !== undefined ? { egress } : {}) } };
289
298
  };
299
+ const inheritedStationFacts = (call) => {
300
+ const seat = seatMarksOf(call);
301
+ return {
302
+ ...INHERITED_STATION_FACTS,
303
+ mandateMarks: { egress: seat.egress, shellGated: seat.shellGated, ...(seat.tier !== undefined ? { irreversibility: seat.tier } : {}) },
304
+ ...(seat.sources.size > 0 ? { toolMarkSources: seat.sources } : {}),
305
+ ...(deps.a2a?.isolatePeers === true ? { a2aIsolatePeers: true } : {}),
306
+ };
307
+ };
290
308
  const inheritedAskEvidence = inheritedAskRuleEvidence(deps.permissionRuleStore);
291
309
  const { personal: permissionRuleLane, org: permissionRuleOrgLane } = createPermissionRuleLanes({
292
310
  provider: deps.permissionRuleStore,
@@ -335,7 +353,7 @@ export async function preparePolicyChain(input) {
335
353
  }
336
354
  let re;
337
355
  try {
338
- re = await pol.check({ ...creq, args: editArgs }, csignal ?? abortController.signal);
356
+ re = refuseOutOfContractDecision(await pol.check({ ...creq, args: editArgs }, csignal ?? abortController.signal));
339
357
  }
340
358
  catch (err) {
341
359
  return vetoed({
@@ -353,7 +371,7 @@ export async function preparePolicyChain(input) {
353
371
  }
354
372
  if (re.updatedInput !== undefined)
355
373
  editArgs = re.updatedInput;
356
- const judged = await judgeInheritedClassifier({ liveMode: () => permissionMode.current, autoMode: ancestorAutoMode, ask: re, req: { ...creq, args: editArgs }, signal: csignal ?? abortController.signal, subject: "the approved edit", allowRewrite: editArgs, unavailableSeat: classifierUnavailableSeat({ station: "ancestor", ...(ancestorDecider?.timeoutMs !== undefined ? { budgetMs: ancestorDecider.timeoutMs } : {}), approvalSeat: isLiveApproverSeat(onAskOf), onNotice: deps.onNotice, sessionId, runId }) });
374
+ const judged = await judgeInheritedClassifier({ liveMode: () => permissionMode.current, autoMode: ancestorAutoMode, ask: re, req: { ...creq, args: editArgs }, signal: csignal ?? abortController.signal, subject: "the approved edit", allowRewrite: editArgs, facts: inheritedStationFacts(creq), unavailableSeat: classifierUnavailableSeat({ station: "ancestor", ...(ancestorDecider?.timeoutMs !== undefined ? { budgetMs: ancestorDecider.timeoutMs } : {}), approvalSeat: isLiveApproverSeat(onAskOf), onNotice: deps.onNotice, sessionId, runId }) });
357
375
  if (judged.kind === "allow") {
358
376
  const cleared = { ...judged.result };
359
377
  carryEngineAttestations(judged.result, cleared);
@@ -364,7 +382,7 @@ export async function preparePolicyChain(input) {
364
382
  return vetoed(judged.result);
365
383
  const { ask: editAsk, fallback: editFallback, mintedHere: editMintedHere } = judged;
366
384
  re = editAsk;
367
- const editArbitrated = arbitrateInherited(re, creq.toolCallId);
385
+ const editArbitrated = arbitrateInherited(re, creq.toolCallId, judged.classification);
368
386
  if (editArbitrated !== undefined) {
369
387
  const out = editArbitrated.action === "allow" ? { action: "allow", updatedInput: editArgs } : editArbitrated;
370
388
  carryPrecedingSettlement(settledBy, out);
@@ -435,17 +453,17 @@ export async function preparePolicyChain(input) {
435
453
  return first;
436
454
  return mandateUnreconstructibleDeny(creq);
437
455
  }
438
- const judged = await judgeInheritedClassifier({ liveMode: () => permissionMode.current, autoMode: pc.autoMode, ask: first, req: creq, signal: csignal ?? abortController.signal, subject: "this call", unavailableSeat: classifierUnavailableSeat({ station: "ancestor", ...(pc.autoMode?.decider.timeoutMs !== undefined ? { budgetMs: pc.autoMode.decider.timeoutMs } : {}), approvalSeat: isLiveApproverSeat(pc.onAsk), onNotice: deps.onNotice, sessionId, runId }) });
456
+ const judged = await judgeInheritedClassifier({ liveMode: () => permissionMode.current, autoMode: pc.autoMode, ask: first, req: creq, signal: csignal ?? abortController.signal, subject: "this call", facts: inheritedStationFacts(creq), unavailableSeat: classifierUnavailableSeat({ station: "ancestor", ...(pc.autoMode?.decider.timeoutMs !== undefined ? { budgetMs: pc.autoMode.decider.timeoutMs } : {}), approvalSeat: isLiveApproverSeat(pc.onAsk), onNotice: deps.onNotice, sessionId, runId }) });
439
457
  if (judged.kind === "allow")
440
458
  return judged.result;
441
459
  if (judged.kind === "deny")
442
460
  return judged.result;
443
461
  const { ask: inheritedAsk, fallback, mintedHere } = judged;
444
- if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq) && classifyAsk(inheritedAsk, INHERITED_STATION_FACTS).row.sandboxAdmission) {
462
+ if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq) && judged.classification.row.sandboxAdmission) {
445
463
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
446
464
  return { action: "allow" };
447
465
  }
448
- const arbitrated = arbitrateInherited(inheritedAsk, creq.toolCallId);
466
+ const arbitrated = arbitrateInherited(inheritedAsk, creq.toolCallId, judged.classification);
449
467
  if (arbitrated !== undefined)
450
468
  return arbitrated;
451
469
  const presentedArgs = creq.args;
@@ -477,14 +495,14 @@ export async function preparePolicyChain(input) {
477
495
  if (resolved.updatedInput !== undefined) {
478
496
  return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, resolved, csignal, pc.autoMode);
479
497
  }
480
- recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs, resolved);
498
+ recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs, resolved, askClassMembersOf(judged.classification).classes);
481
499
  return { action: "allow" };
482
500
  },
483
501
  }
484
502
  : wrapToolPolicy(pc.policy, async (creq, csignal) => {
485
503
  let decision;
486
504
  try {
487
- decision = await pc.policy.check(creq, csignal ?? abortController.signal);
505
+ decision = refuseOutOfContractDecision(await pc.policy.check(creq, csignal ?? abortController.signal));
488
506
  }
489
507
  catch (err) {
490
508
  return {
@@ -509,17 +527,17 @@ export async function preparePolicyChain(input) {
509
527
  return mandateUnreconstructibleDeny(creq);
510
528
  }
511
529
  const presentedArgs = decision.updatedInput !== undefined ? decision.updatedInput : creq.args;
512
- const judged = await judgeInheritedClassifier({ liveMode: () => permissionMode.current, autoMode: pc.autoMode, ask: decision, req: { ...creq, args: presentedArgs }, signal: csignal ?? abortController.signal, subject: "this call", allowRewrite: decision.updatedInput, unavailableSeat: classifierUnavailableSeat({ station: "ancestor", ...(pc.autoMode?.decider.timeoutMs !== undefined ? { budgetMs: pc.autoMode.decider.timeoutMs } : {}), approvalSeat: isLiveApproverSeat(pc.onAsk), onNotice: deps.onNotice, sessionId, runId }) });
530
+ const judged = await judgeInheritedClassifier({ liveMode: () => permissionMode.current, autoMode: pc.autoMode, ask: decision, req: { ...creq, args: presentedArgs }, signal: csignal ?? abortController.signal, subject: "this call", allowRewrite: decision.updatedInput, facts: inheritedStationFacts(creq), unavailableSeat: classifierUnavailableSeat({ station: "ancestor", ...(pc.autoMode?.decider.timeoutMs !== undefined ? { budgetMs: pc.autoMode.decider.timeoutMs } : {}), approvalSeat: isLiveApproverSeat(pc.onAsk), onNotice: deps.onNotice, sessionId, runId }) });
513
531
  if (judged.kind === "allow")
514
532
  return judged.result;
515
533
  if (judged.kind === "deny")
516
534
  return judged.result;
517
535
  const { ask: inheritedAsk, fallback, mintedHere } = judged;
518
- if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq) && classifyAsk(inheritedAsk, INHERITED_STATION_FACTS).row.sandboxAdmission) {
536
+ if (fallback === undefined && sandboxAdmissionArmed && policyAskClassOf(pc.policy) === "sandbox_local" && !sandboxBoundaryCapable(creq) && judged.classification.row.sandboxAdmission) {
519
537
  recordAncestorSandboxAdmission(creq.toolCallId, creq.toolName);
520
538
  return decision.updatedInput !== undefined ? { action: "allow", updatedInput: decision.updatedInput } : { action: "allow" };
521
539
  }
522
- const arbitrated = arbitrateInherited(inheritedAsk.action === "ask" ? { ...inheritedAsk, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) } : inheritedAsk, creq.toolCallId);
540
+ const arbitrated = arbitrateInherited(inheritedAsk.action === "ask" ? { ...inheritedAsk, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) } : inheritedAsk, creq.toolCallId, judged.classification);
523
541
  if (arbitrated !== undefined)
524
542
  return arbitrated;
525
543
  const askT0 = now();
@@ -551,7 +569,7 @@ export async function preparePolicyChain(input) {
551
569
  return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, resolved, csignal, pc.autoMode);
552
570
  }
553
571
  if (pc.preToolUse === undefined) {
554
- recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs, resolved);
572
+ recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs, resolved, askClassMembersOf(judged.classification).classes);
555
573
  }
556
574
  if (decision.updatedInput === undefined)
557
575
  return resolved;
@@ -572,7 +590,7 @@ export async function preparePolicyChain(input) {
572
590
  sameInstanceAdapterIndices.add(j + callerSlotOffset);
573
591
  });
574
592
  const instrumentedChain = rewriteCapableChain.map((layer, idx) => wrapToolPolicy(layer, async (creq, csignal) => {
575
- const d = await layer.check(creq, csignal);
593
+ const d = refuseOutOfContractDecision(await layer.check(creq, csignal));
576
594
  if (d.updatedInput !== undefined)
577
595
  rewriteEmitterIndex.set(creq.toolCallId, idx);
578
596
  if (d.action === "ask") {
@@ -619,7 +637,7 @@ export async function preparePolicyChain(input) {
619
637
  const revalidated = {};
620
638
  try {
621
639
  for (const layer of recheckLayers) {
622
- const d = await layer.check(finalReq, signal);
640
+ const d = refuseOutOfContractDecision(await layer.check(finalReq, signal));
623
641
  if (d.action === "deny") {
624
642
  const carried = { ...d, updatedInput: decision.updatedInput };
625
643
  carryEngineAttestations(d, carried);
@@ -12,7 +12,7 @@ export interface RunIdentityWiringInput {
12
12
  * task_notification frames. */
13
13
  queue: PushQueue<TaskEvent>;
14
14
  /** borrowed-mutable — the leg's prepared seat: the harness sinks (`onUndrainedEngineNotes`, `engineInjectionsHeld`,
15
- * `onUndrainedUserInputs`, `onEngineNoteConsumed`) are installed, `nextTurn` is appended to; the ids, the identity
15
+ * `onUndrainedUserInputs`, `onEngineNoteConsumed`, `onToolTurnRepaired`) are installed, `nextTurn` is appended to; the ids, the identity
16
16
  * envelope, the hold refs, the memory session and the listing / roster faces are read. */
17
17
  prepared: Prepared;
18
18
  /** borrowed-readonly — the trusted run-scoped channel: `parentToolCallId`, `onNotifyInjectorReady`. */
@@ -3,6 +3,7 @@ import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js"
3
3
  import { recordFrameReceipt, toolResultReceiptKey } from "../frame-settlement.js";
4
4
  import { discloseDroppedPending, renderTaskNotificationXml, taskNotificationDedupKey } from "../task-notification.js";
5
5
  import { deliverDelegationLifecycle, deliverEngineNotice, undrainedUserInputNotices } from "../types.js";
6
+ import { createToolTurnRepairSeat } from "./tool-turn-repair-seat.js";
6
7
  export function runIdentityWiring(input) {
7
8
  const { spec, queue, prepared, internals, taskIdRef, onReady, manualCompactRef, notifyRef, captureOptOutRef, mcpRedialRef, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef, notificationLane, pendingSessionNotifications, runner } = input;
8
9
  notificationLane.harness = prepared.harness;
@@ -84,6 +85,7 @@ export function runIdentityWiring(input) {
84
85
  if (key !== undefined)
85
86
  recordFrameReceipt(seam, key, carrier.kind === "tool_result" ? carrier.content : undefined, entryId);
86
87
  };
88
+ prepared.harness.onToolTurnRepaired = createToolTurnRepairSeat((notice) => runner.deps.onNotice?.(notice), { sessionId: prepared.sessionId, runId: prepared.runId });
87
89
  if (notifyRef)
88
90
  notifyRef.inject = injectTaskNotification;
89
91
  if (captureOptOutRef && prepared.memoryEngineSession?.captureOptOut !== undefined) {
@@ -0,0 +1,8 @@
1
+ import type { EngineNotice } from "../engine-notice.js";
2
+ import type { ToolTurnRepair } from "../../internal/llm.js";
3
+ /** The notice's attribution ids — the same pair, for the same reason, as the fold seat's. */
4
+ export interface ToolTurnRepairSeatIds {
5
+ sessionId: string;
6
+ runId: string;
7
+ }
8
+ export declare function createToolTurnRepairSeat(onNotice: ((notice: EngineNotice) => void) | undefined, ids: ToolTurnRepairSeatIds): (repairs: readonly ToolTurnRepair[]) => void;
@@ -0,0 +1,28 @@
1
+ import { deliverEngineNotice } from "../engine-notice.js";
2
+ const FORM_SENTENCE = {
3
+ trailing_text: "a block after an assistant's tool calls was moved ahead of them",
4
+ late_result: "a result recorded later was moved up behind its call",
5
+ missing_result: "a call with no result was closed as interrupted (outcome unknown)",
6
+ duplicate_result: "a second result recorded for one call was folded into the first (an interrupted close beside a recorded result is left out)",
7
+ };
8
+ export function createToolTurnRepairSeat(onNotice, ids) {
9
+ const announced = new Set();
10
+ return (repairs) => {
11
+ const fresh = repairs.filter((repair) => {
12
+ const key = `${repair.form}\u0000${[...repair.toolCallIds].sort().join("\u0000")}`;
13
+ if (announced.has(key))
14
+ return false;
15
+ announced.add(key);
16
+ return true;
17
+ });
18
+ if (fresh.length === 0)
19
+ return;
20
+ deliverEngineNotice(onNotice, {
21
+ code: "context.tool_turn_repaired",
22
+ message: `The session transcript carries ${fresh.length === 1 ? "a tool turn" : `${fresh.length} tool turns`} in a shape a provider lane ` +
23
+ `rejects, so the request was repaired on its way out: ${[...new Set(fresh.map((r) => FORM_SENTENCE[r.form]))].join("; ")}. ` +
24
+ `The transcript is unchanged and every later request is repaired the same way; announced once per run.`,
25
+ detail: { repairs: fresh.map((r) => ({ form: r.form, toolCallIds: [...r.toolCallIds] })), sessionId: ids.sessionId, runId: ids.runId },
26
+ });
27
+ };
28
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "../internal/harness-types.js";
2
+ import type { ToolResultMessage } from "../internal/llm.js";
2
3
  import type { Session } from "./session.js";
3
4
  import type { ToolEffect } from "./types.js";
4
5
  /**
@@ -116,6 +117,40 @@ export declare const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interru
116
117
  export declare function appendInterruptionMarker(session: Session, opts: {
117
118
  toolUseInFlight: boolean;
118
119
  }): Promise<string>;
120
+ /** One synthetic interrupted result: what a call with no recorded result is closed with. */
121
+ export interface InterruptedResult {
122
+ toolCallId: string;
123
+ toolName: string;
124
+ /** The model-facing text. */
125
+ text: string;
126
+ /** Which of the two honest states the call is closed in. */
127
+ errorKind: ReconciledErrorKind;
128
+ }
129
+ /**
130
+ * The ONE mint of interrupted-result content for a batch of calls that have no recorded result. Two readers: the
131
+ * persisted reconcile ({@link reconcileInterruptedSession}) and the request view's tool-turn law
132
+ * (`engine/llm/tool-turns.ts`, which closes a call on the wire only and never writes the transcript) — so a call
133
+ * closed either way reads the same to the model.
134
+ *
135
+ * `toolEffects` / `startedToolCallIds` as on {@link reconcileInterruptedSession}; absent ⇒ every call gets the
136
+ * conservative unknown-outcome wording. The first call of each class in the batch states it in full and the rest
137
+ * point back at it (scoped to this call, so a later batch always starts from the full wording again).
138
+ */
139
+ export declare function interruptedResults(calls: readonly {
140
+ toolCallId: string;
141
+ toolName: string;
142
+ }[], toolEffects?: Map<string, ToolEffect>, startedToolCallIds?: ReadonlySet<string>): InterruptedResult[];
143
+ /** Is `message` an INTERRUPTED CLOSE — a result minted to stand in for one that was never recorded (this family's
144
+ * two-valued `details.errorKind`, written by the reconcile here and by the loop's turn-scoped interrupt)? A close
145
+ * says only "no result was recorded", so it is void beside a recorded result for the same call. Total: a tool's
146
+ * `details` is the tool's value, so an unreadable one reads as "not a close" (one guarded read per member). */
147
+ export declare function isInterruptedClose(message: {
148
+ details?: unknown;
149
+ }): boolean;
150
+ /** The `toolResult` message for one {@link InterruptedResult} — the persisted and the request-view closes are this one shape. */
151
+ export declare function interruptedResultMessage(result: InterruptedResult, timestamp: number): ToolResultMessage<{
152
+ errorKind: ReconciledErrorKind;
153
+ }>;
119
154
  /**
120
155
  * Reconcile a resumed session's active branch: close any orphan tool calls with a synthetic
121
156
  * interrupted `toolResult` (never re-running the tool). Returns what was recovered.
@@ -1,3 +1,4 @@
1
+ import { readMember } from "./thrown-value.js";
1
2
  const INTERRUPTED_UNKNOWN = "[INTERRUPTED] The previous run ended before this tool call's result was recorded. Its outcome " +
2
3
  "is UNKNOWN — the action may have completed, partially completed, or never run. Do not assume " +
3
4
  "success or failure. Before relying on it: if it is safe to read, query the current state to " +
@@ -96,30 +97,40 @@ export async function appendInterruptionMarker(session, opts) {
96
97
  timestamp: Date.now(),
97
98
  });
98
99
  }
99
- export async function reconcileInterruptedSession(session, toolEffects, suspendedBatch, startedToolCallIds) {
100
- const { messages } = await session.buildContext();
101
- const orphans = findOrphanToolCalls(messages, suspendedBatch).filter((o) => o.kind !== "result");
102
- const recovered = [];
100
+ export function interruptedResults(calls, toolEffects, startedToolCallIds) {
103
101
  const statedInFull = new Set();
104
- for (const orphan of orphans) {
105
- const effect = toolEffects?.get(orphan.toolName) ?? "write";
102
+ return calls.map(({ toolCallId, toolName }) => {
103
+ const effect = toolEffects?.get(toolName) ?? "write";
106
104
  const effectText = effect === "read" ? INTERRUPTED_SAFE : effect === "idempotent" ? INTERRUPTED_IDEMPOTENT : INTERRUPTED_UNKNOWN;
107
- const neverStarted = startedToolCallIds !== undefined && !startedToolCallIds.has(orphan.toolCallId);
108
- const fullText = neverStarted ? INTERRUPTED_NEVER_STARTED : effectText;
105
+ const neverStarted = startedToolCallIds !== undefined && !startedToolCallIds.has(toolCallId);
109
106
  const cls = neverStarted ? "never_started" : effect === "read" ? "read" : effect === "idempotent" ? "idempotent" : "unknown";
110
- const text = statedInFull.has(cls) ? REPEAT_TEXT[cls] : fullText;
107
+ const text = statedInFull.has(cls) ? REPEAT_TEXT[cls] : neverStarted ? INTERRUPTED_NEVER_STARTED : effectText;
111
108
  statedInFull.add(cls);
112
- const errorKind = neverStarted ? "interrupted_never_started" : "interrupted_outcome_unknown";
113
- const entryId = await session.appendMessage({
114
- role: "toolResult",
115
- toolCallId: orphan.toolCallId,
116
- toolName: orphan.toolName,
117
- content: [{ type: "text", text }],
118
- details: { errorKind },
119
- isError: true,
120
- timestamp: Date.now(),
121
- });
122
- recovered.push({ ...orphan, entryId, text, errorKind });
109
+ return { toolCallId, toolName, text, errorKind: neverStarted ? "interrupted_never_started" : "interrupted_outcome_unknown" };
110
+ });
111
+ }
112
+ export function isInterruptedClose(message) {
113
+ const kind = readMember(readMember(message, "details"), "errorKind");
114
+ return kind === "interrupted_never_started" || kind === "interrupted_outcome_unknown";
115
+ }
116
+ export function interruptedResultMessage(result, timestamp) {
117
+ return {
118
+ role: "toolResult",
119
+ toolCallId: result.toolCallId,
120
+ toolName: result.toolName,
121
+ content: [{ type: "text", text: result.text }],
122
+ details: { errorKind: result.errorKind },
123
+ isError: true,
124
+ timestamp,
125
+ };
126
+ }
127
+ export async function reconcileInterruptedSession(session, toolEffects, suspendedBatch, startedToolCallIds) {
128
+ const { messages } = await session.buildContext();
129
+ const orphans = findOrphanToolCalls(messages, suspendedBatch).filter((o) => o.kind !== "result");
130
+ const recovered = [];
131
+ for (const result of interruptedResults(orphans, toolEffects, startedToolCallIds)) {
132
+ const entryId = await session.appendMessage(interruptedResultMessage(result, Date.now()));
133
+ recovered.push({ toolCallId: result.toolCallId, toolName: result.toolName, entryId, text: result.text, errorKind: result.errorKind });
123
134
  }
124
135
  return { recovered };
125
136
  }
@@ -1,3 +1,4 @@
1
+ import { ensureToolTurnInvariants } from "../internal/llm.js";
1
2
  import { resolveTaskModel } from "./roles.js";
2
3
  import { readDegradation } from "../brain/degrading.js";
3
4
  import { cacheFamilyOf, uncachedInputTokensOf } from "./runner/usage-accounting.js";
@@ -33,14 +34,15 @@ export async function runSideQuery(spec, deps) {
33
34
  };
34
35
  }
35
36
  if (m.role === "toolResult") {
36
- return { role: "toolResult", toolCallId: m.toolCallId, toolName: m.toolName, content: m.content, isError: m.isError ?? false, timestamp };
37
+ const details = m.details;
38
+ return { role: "toolResult", toolCallId: m.toolCallId, toolName: m.toolName, content: m.content, isError: m.isError ?? false, timestamp, ...(details !== undefined ? { details } : {}) };
37
39
  }
38
40
  return { role: "user", content: m.content, timestamp };
39
41
  };
40
42
  const effectiveSystemPrompt = spec.systemPrompt ?? resolved.systemPrompt;
41
43
  const context = {
42
44
  ...(effectiveSystemPrompt !== undefined ? { systemPrompt: effectiveSystemPrompt } : {}),
43
- messages: spec.messages.map(normalize),
45
+ messages: ensureToolTurnInvariants(spec.messages.map(normalize)).messages,
44
46
  ...(spec.tools !== undefined && spec.tools.length > 0
45
47
  ? { tools: spec.tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })) }
46
48
  : {}),
@@ -381,6 +381,24 @@ export async function checkpointStoreContract(make, runAssertion) {
381
381
  assert.equal("mandate" in byId.get("mw-absent"), false, "absent must project the key OMITTED");
382
382
  assert.equal("mandate" in byId.get("mw-bogus"), false, "a word outside the closed set must read as absent, never project verbatim");
383
383
  });
384
+ run("#1214 ask class pair: a tool_approval row's `askClass` / `classes` survive the round-trip verbatim (the array whole, in its order); an absent pair stays absent", async () => {
385
+ const store = make();
386
+ const single = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "ac-single" });
387
+ Object.assign(single.pendingAction, { askClass: "denial_limit_fallback", classes: ["denial_limit_fallback"] });
388
+ const several = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "ac-several" });
389
+ Object.assign(several.pendingAction, { askClass: "transcript_write", classes: ["transcript_write", "org_rule"] });
390
+ const absent = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "ac-absent" });
391
+ for (const cp of [single, several, absent])
392
+ await store.put(cp.token, cp);
393
+ for (const [cp, askClass, classes] of [[single, "denial_limit_fallback", ["denial_limit_fallback"]], [several, "transcript_write", ["transcript_write", "org_rule"]]]) {
394
+ const back = (await store.get(cp.token)).pendingAction;
395
+ assert.equal(back.askClass, askClass, "the row's askClass must survive the round-trip verbatim");
396
+ assert.deepEqual(back.classes, classes, "the row's classes must survive the round-trip as the same array, in the same order");
397
+ }
398
+ const bare = (await store.get(absent.token)).pendingAction;
399
+ assert.equal("askClass" in bare, false, "an absent askClass must stay OMITTED on the row");
400
+ assert.equal("classes" in bare, false, "an absent classes must stay OMITTED on the row");
401
+ });
384
402
  await settle();
385
403
  }
386
404
  const sortByToken = (a, b) => a.token.localeCompare(b.token);
@@ -203,6 +203,10 @@ export declare const NAME_READINGS: {
203
203
  readonly rank: "member";
204
204
  readonly file: string;
205
205
  };
206
+ readonly "report.markSources": {
207
+ readonly kind: "any";
208
+ readonly file: string;
209
+ };
206
210
  readonly "verify.wroteThisRun": {
207
211
  readonly kind: "wire";
208
212
  readonly file: string;