@sema-agent/core 7.35.2 → 7.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/dist/brain/anthropic.js +6 -6
  3. package/dist/brain/errors.d.ts +1 -14
  4. package/dist/brain/errors.js +2 -6
  5. package/dist/brain/open-responses.js +7 -7
  6. package/dist/brain/openai.js +7 -7
  7. package/dist/brain/provider-echo-shape.d.ts +26 -0
  8. package/dist/brain/provider-echo-shape.js +12 -0
  9. package/dist/brain/tool-call-id.d.ts +27 -15
  10. package/dist/brain/tool-call-id.js +43 -0
  11. package/dist/core/ask-origin.d.ts +35 -5
  12. package/dist/core/ask-origin.js +23 -4
  13. package/dist/core/ask-question.d.ts +0 -30
  14. package/dist/core/ask-question.js +3 -2
  15. package/dist/core/checkpoint-store.d.ts +18 -6
  16. package/dist/core/checkpoint-store.js +2 -1
  17. package/dist/core/context-edit.d.ts +4 -0
  18. package/dist/core/context-edit.js +5 -2
  19. package/dist/core/decision-receipt.d.ts +21 -0
  20. package/dist/core/decision-receipt.js +63 -0
  21. package/dist/core/engine-notice.d.ts +15 -5
  22. package/dist/core/gate-fold.js +2 -1
  23. package/dist/core/gate-lanes.js +20 -23
  24. package/dist/core/gate-outcome.d.ts +7 -5
  25. package/dist/core/gate-outcome.js +5 -1
  26. package/dist/core/governance-codes.d.ts +1 -1
  27. package/dist/core/governance-codes.js +2 -0
  28. package/dist/core/hooks.d.ts +14 -2
  29. package/dist/core/hooks.js +7 -3
  30. package/dist/core/mode-policy.js +2 -1
  31. package/dist/core/park-selfcheck.js +2 -0
  32. package/dist/core/permission-rule-model.d.ts +2 -2
  33. package/dist/core/permission-rule-model.js +2 -2
  34. package/dist/core/permission-rules.js +2 -5
  35. package/dist/core/runner/active-skill-scope.js +5 -5
  36. package/dist/core/runner/decide-continuation.js +2 -1
  37. package/dist/core/runner/denial-limit-arms.d.ts +28 -18
  38. package/dist/core/runner/denial-limit-arms.js +11 -7
  39. package/dist/core/runner/inherited-ask-grants.d.ts +32 -31
  40. package/dist/core/runner/inherited-ask-grants.js +5 -0
  41. package/dist/core/runner/permission-rule-lanes.d.ts +3 -0
  42. package/dist/core/runner/permission-rule-lanes.js +7 -6
  43. package/dist/core/runner/prepare-context-lane.d.ts +4 -0
  44. package/dist/core/runner/prepare-context-lane.js +7 -5
  45. package/dist/core/runner/prepare-gate-stations.js +17 -12
  46. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  47. package/dist/core/runner/prepare-park-ask.d.ts +4 -2
  48. package/dist/core/runner/prepare-park-ask.js +8 -11
  49. package/dist/core/runner/prepare-policy-chain.d.ts +5 -8
  50. package/dist/core/runner/prepare-policy-chain.js +50 -31
  51. package/dist/core/runner/prepare-safety-scan.js +9 -2
  52. package/dist/core/runner/prepare-task.js +3 -3
  53. package/dist/core/runner/resume-apply.js +2 -1
  54. package/dist/core/runner/run-attachment-seats.js +3 -1
  55. package/dist/core/runner/run-harness-handlers.js +6 -1
  56. package/dist/core/runner/run-leg.js +11 -9
  57. package/dist/core/runner/run-recovery-lanes.js +2 -0
  58. package/dist/core/runner/session-rule-policy.js +5 -4
  59. package/dist/core/runner/tool-call-id-fold-seat.d.ts +13 -0
  60. package/dist/core/runner/tool-call-id-fold-seat.js +31 -0
  61. package/dist/core/sensitive-path-policy.js +2 -2
  62. package/dist/core/side-query.d.ts +5 -1
  63. package/dist/core/side-query.js +5 -1
  64. package/dist/core/store-contracts/checkpoint-store-contract.js +18 -0
  65. package/dist/core/tool-name-readings.d.ts +370 -0
  66. package/dist/core/tool-name-readings.js +137 -0
  67. package/dist/core/tool-policy.d.ts +79 -89
  68. package/dist/core/tool-policy.js +86 -49
  69. package/dist/core/tool-registry.d.ts +1 -31
  70. package/dist/core/tool-registry.js +2 -22
  71. package/dist/core/tool-result-budget.d.ts +3 -0
  72. package/dist/core/tool-result-budget.js +5 -3
  73. package/dist/core/tool-roster.d.ts +30 -9
  74. package/dist/core/tool-roster.js +45 -5
  75. package/dist/engine/llm/types.d.ts +4 -0
  76. package/dist/index.d.ts +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/tools/fs/file-read-check.d.ts +1 -1
  79. package/dist/tools/fs/file-read-check.js +4 -3
  80. package/dist/tools/fs/shell-read-check.d.ts +15 -1
  81. package/dist/tools/fs/shell-read-check.js +5 -2
  82. package/package.json +2 -1
  83. package/test/export-surface.snapshot.json +7 -1
  84. package/wire-changes/core@7.36.0.json +288 -0
  85. package/wire-changes/core@7.37.0.json +405 -0
@@ -107,8 +107,9 @@ export interface AskOriginFacts {
107
107
  /** The MANDATE mark inputs of this call — the tool SEAT's marks plus the gate's own per-call records
108
108
  * ({@link MandateMarks}), read from the same two places the lane's own derivation reads them. The
109
109
  * per-call PROBE member is deliberately not here: it is engine-stamped on the DECISION, and
110
- * {@link ruleClearanceOf} reads it there, so one fact cannot arrive twice with two values. A station
111
- * holding no tool seat (the inherited lane) passes `{}` — an honest empty, since the decision's own
110
+ * {@link ruleClearanceOf} reads it there, so one fact cannot arrive twice with two values. An inherited
111
+ * station passes the tool SEAT's marks of the task it runs in (its per-call records are the gate's, absent
112
+ * there); a station holding no seat at all passes `{}` — an honest empty, since the decision's own
112
113
  * members still answer. */
113
114
  readonly mandateMarks: Omit<MandateMarks, "probeMandated">;
114
115
  }
@@ -133,12 +134,15 @@ export declare function askOriginOf(decision: Extract<PermissionResult, {
133
134
  * <out-of-root>` under a standing `Bash(cat:*)` ask rule offered the directory and kept asking after it).
134
135
  * It lives at the one derivation rather than the three stamp sites: the withdrawal's input IS the origin
135
136
  * word, so a site that stamped without it would be a second, quieter answer to one question.
137
+ * The same stamp writes the ask's CLASS members (`askClass` / `classes`, {@link askClassMembersOf} over the
138
+ * `classification` the caller derived from this decision under these facts): the word and the class leave the
139
+ * gate on one object, and a value a policy put on either is replaced.
136
140
  */
137
141
  export declare function stampAskOrigin<T extends Extract<PermissionResult, {
138
142
  action: "ask";
139
- }>>(decision: T, facts: AskOriginFacts): T & {
143
+ }>>(decision: T, facts: AskOriginFacts, classification: AskClassification): T & {
140
144
  origin: AskOrigin;
141
- };
145
+ } & AskClassMembers;
142
146
  /**
143
147
  * CAN a persisted allow rule clear this ask — and, if not, which door closed? ONE derivation with TWO
144
148
  * readers, which is the whole point of the shape: the approval card's `ruleOffersAbsence` word and the
@@ -610,6 +614,18 @@ export declare const RRA_EXPLAINERS: ReadonlySet<AskClass>;
610
614
  /** The classes a permission mode can RELEASE (the `bypassImmune: false` rows) — the only words that can appear on
611
615
  * `GateDisposition.released.askClass`; every other class reaches a person under every mode. */
612
616
  export declare const RELEASABLE_ASK_CLASSES: readonly AskClass[];
617
+ /**
618
+ * The closed set of `reason` words a mode release carries (`GateDisposition.released.reason`, the `released` arm of
619
+ * {@link Arbitration}): each is a releasable class the release record names on its own — `rule_unreadable`, a release
620
+ * over a person's rule the call could not be read against. A release carries the first member (array order) its class
621
+ * set holds, and none when it holds no member. {@link arbitrateByMode} and {@link isModeReleaseReason} read one copy
622
+ * of this list taken at load (a caller that writes into the exported array changes neither), so a new reason is one
623
+ * word here.
624
+ */
625
+ export declare const MODE_RELEASE_REASONS: readonly ["rule_unreadable"];
626
+ export type ModeReleaseReason = (typeof MODE_RELEASE_REASONS)[number];
627
+ /** Membership test for {@link ModeReleaseReason} — the word crosses the wire on `tool_end.gate.disposition.released.reason`. */
628
+ export declare function isModeReleaseReason(v: unknown): v is ModeReleaseReason;
613
629
  /**
614
630
  * The facts the class derivation reads beside the decision: the origin facts the gate holds, plus the tool
615
631
  * seat's mark PROVENANCE ({@link ToolMarkSource}; a mark with no source reads as the deployment's, fail-closed),
@@ -628,6 +644,20 @@ export interface AskClassification {
628
644
  readonly askClass: AskClass;
629
645
  readonly row: ModeArbitrationRow;
630
646
  }
647
+ /** The two members an ask's classification puts on the approval request and the park row: the disclosed word and
648
+ * the whole set, the set as an array in {@link ASK_CLASSES} order (so `classes[0] === askClass`). */
649
+ export interface AskClassMembers {
650
+ readonly askClass: AskClass;
651
+ readonly classes: readonly AskClass[];
652
+ }
653
+ /** The ONE projection of a classification onto {@link AskClassMembers} — every station that carries an ask's class
654
+ * spells it through this, so the word and the set cannot be read from two derivations. The array is a fresh frozen
655
+ * copy per call: an approver that holds it cannot reach the park row's copy or the next card's. */
656
+ export declare function askClassMembersOf(c: AskClassification): AskClassMembers;
657
+ /** The screen a boundary applies before it writes the pair where a host reads it back (the park row): both or
658
+ * neither, and only a well-formed pair — every member of `classes` in the closed set, no repeat, and `askClass` its
659
+ * first member in {@link ASK_CLASSES} order. The copy returned is fresh and frozen, never the caller's array. */
660
+ export declare function screenAskClassMembers(askClass: unknown, classes: unknown): AskClassMembers | undefined;
631
661
  /**
632
662
  * The DECISION half of the class derivation — what ONE ask says about itself through its own members, read
633
663
  * per contributing ask at the policy fold so a member the fold does not copy (a guard mark, the unreadable-rule
@@ -699,7 +729,7 @@ export type Arbitration = {
699
729
  classes: readonly AskClass[];
700
730
  origin?: AskOrigin;
701
731
  mandate?: PersistedRuleMandate;
702
- reason?: "rule_unreadable";
732
+ reason?: ModeReleaseReason;
703
733
  } | {
704
734
  kind: "denied_by_mode";
705
735
  mode: "bypassPermissions" | "dontAsk";
@@ -11,15 +11,16 @@ export function askOriginOf(decision, facts) {
11
11
  return origin;
12
12
  }
13
13
  const TIGHTEN_ONLY_ORIGINS = new Set(["shell_gate_tighten", "safety_tighten"]);
14
- export function stampAskOrigin(decision, facts) {
14
+ export function stampAskOrigin(decision, facts, classification) {
15
15
  const origin = askOriginOf(decision, facts);
16
+ const classed = askClassMembersOf(classification);
16
17
  const { mandate: _selfDeclared, ...unstamped } = decision;
17
18
  const clearance = ruleClearanceOf(unstamped, facts.mandateMarks);
18
19
  const mandated = { ...unstamped, ...(clearance.mandated ? { mandated: true } : {}), ...(clearance.mandate !== undefined ? { mandate: clearance.mandate } : {}) };
19
20
  if (mandated.probeReadRootCandidate === undefined || TIGHTEN_ONLY_ORIGINS.has(origin))
20
- return { ...mandated, origin };
21
+ return { ...mandated, origin, ...classed };
21
22
  const { probeReadRootCandidate: _withdrawn, ...kept } = mandated;
22
- return { ...kept, origin };
23
+ return { ...kept, origin, ...classed };
23
24
  }
24
25
  export function ruleClearanceOf(ask, marks) {
25
26
  const marksWord = persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true });
@@ -215,6 +216,23 @@ export const MODE_ARBITRATION = {
215
216
  };
216
217
  export const RRA_EXPLAINERS = new Set(["unverifiable_delete", "transcript_write", "org_rule", "governance_unavailable", "rule_unreadable", "denial_limit_fallback", "ancestor", "artifact_shared_read"]);
217
218
  export const RELEASABLE_ASK_CLASSES = ASK_CLASSES.filter((c) => !MODE_ARBITRATION[c].bypassImmune);
219
+ export const MODE_RELEASE_REASONS = ["rule_unreadable"];
220
+ const MODE_RELEASE_REASON_SET = new Set(MODE_RELEASE_REASONS);
221
+ export function isModeReleaseReason(v) {
222
+ return MODE_RELEASE_REASON_SET.has(v);
223
+ }
224
+ export function askClassMembersOf(c) {
225
+ return { askClass: c.askClass, classes: Object.freeze(ASK_CLASSES.filter((k) => c.classes.has(k))) };
226
+ }
227
+ export function screenAskClassMembers(askClass, classes) {
228
+ if (!isAskClass(askClass) || !Array.isArray(classes) || classes.length === 0 || !classes.every(isAskClass))
229
+ return undefined;
230
+ const held = new Set(classes);
231
+ const ordered = ASK_CLASSES.filter((k) => held.has(k));
232
+ if (ordered.length !== classes.length || ordered[0] !== askClass)
233
+ return undefined;
234
+ return { askClass, classes: Object.freeze(ordered) };
235
+ }
218
236
  export function decisionAskClassesOf(d) {
219
237
  const out = new Set();
220
238
  if (d.guard === "unverifiable_delete")
@@ -339,6 +357,7 @@ export function arbitrateByMode(mode, c, disclosure, call) {
339
357
  return { kind: "denied_by_mode", mode, askClass: c.askClass, classes };
340
358
  if (c.row.bypassImmune)
341
359
  return { kind: "ask" };
360
+ const reason = [...MODE_RELEASE_REASON_SET].find((r) => has(r));
342
361
  return {
343
362
  kind: "released",
344
363
  mode,
@@ -346,7 +365,7 @@ export function arbitrateByMode(mode, c, disclosure, call) {
346
365
  classes,
347
366
  ...(disclosure?.origin !== undefined ? { origin: disclosure.origin } : {}),
348
367
  ...(disclosure?.mandate !== undefined ? { mandate: disclosure.mandate } : {}),
349
- ...(has("rule_unreadable") ? { reason: "rule_unreadable" } : {}),
368
+ ...(reason !== undefined ? { reason } : {}),
350
369
  };
351
370
  case "acceptEdits":
352
371
  return call?.fileCommand === true && c.classes.size === 1 && c.classes.has("ordinary") ? { kind: "accepted", mode } : { kind: "ask" };
@@ -279,36 +279,6 @@ export interface AskUserQuestionToolOptions {
279
279
  * would let a LATER question inherit the claim that an operator approved it. */
280
280
  redeemedApprovalQuestionsHash?: string;
281
281
  }
282
- /**
283
- * Durable ask (design/64 TC-5.4 — ABOVE CC, the disconnected-human case): instead of the headless
284
- * "proceed with your best judgment" default, an AskUserQuestion **suspends the task durably** and the
285
- * answer resumes it — built ENTIRELY from existing primitives, no new gate kind.
286
- *
287
- * **The park is the fallback, not the mandate.** An adjudicated question parks durably UNLESS this run
288
- * has a live question face (see {@link isLiveQuestionFace}), in which case the engine routes the question
289
- * to that face and the answer arrives in-stream — same turn, no checkpoint, no rebuild. The park is what
290
- * happens when nobody is there: no live face, the face affirming nobody is reachable, or a control-plane
291
- * `forceDurableGate` that mandates the durable breakpoint regardless. A deployment that wired a live face
292
- * AND wants every question parked anyway states that with `forceDurableGate` (or by not wiring the face).
293
- *
294
- * 1. Run the task with `durableApproval` + a durable `CheckpointStore` (+ durable session/offload
295
- * stores) and `combinePolicies(createDurableQuestionPolicy(), yourPolicy)`. The policy adjudicates
296
- * the AskUserQuestion call `ask`; in durable mode that suspends BEFORE the tool executes —
297
- * `onQuestion` is never called on the suspend side, so mount the tool with
298
- * {@link QUESTION_AWAITS_RESUME} when no live human is connected.
299
- * 2. The HITL surface reads the questions from `checkpoint.pendingAction.args.questions` and collects
300
- * the human's {@link QuestionAnswer} out-of-band (hours/days later, any replica).
301
- * 3. `runner.resume(token, { gate: "policy_ask", decision: "allow", boundCallId:
302
- * checkpoint.pendingAction.toolCallId }, { ...taskConfig, onQuestion: async () => answer })` — the resume
303
- * re-mounts the tool with the answer closure; the pending call executes against it and the model
304
- * continues with "The user answered: …". `boundCallId` (design/80 D-1) must name the pending call the
305
- * human answered, or the resume is rejected (`checkpoint.invalid_outcome`).
306
- *
307
- * A deployment that wants the CC-style threshold form ("wait N minutes for a live human, then go
308
- * durable") implements it inside its own policy: return `allow` while a human is connected (the live
309
- * `onQuestion` answers synchronously) and `ask` when none is — the sync-vs-durable choice is exactly
310
- * one policy decision.
311
- */
312
282
  export declare function createDurableQuestionPolicy(): ToolPolicy;
313
283
  /**
314
284
  * Placeholder {@link OnQuestion} that mounts the AskUserQuestion tool on a worker with NO live human
@@ -5,7 +5,7 @@ import { defineTool, errorResult } from "./tools.js";
5
5
  import { delimitUntrusted, inlineUntrusted } from "./untrusted-text.js";
6
6
  import { toolFace } from "./tool-catalog-entries.js";
7
7
  import { thrownText } from "./thrown-value.js";
8
- import { callAnswersTo } from "./tool-roster.js";
8
+ import { heldBy } from "./tool-name-readings.js";
9
9
  export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion";
10
10
  export function classifyQuestionOutcome(outcome) {
11
11
  let unavailable;
@@ -132,10 +132,11 @@ export function validateAskQuestions(questions) {
132
132
  }
133
133
  return undefined;
134
134
  }
135
+ const QUESTION_TOOL_NAMES = new Set([ASK_USER_QUESTION_TOOL_NAME]);
135
136
  export function createDurableQuestionPolicy() {
136
137
  return {
137
138
  check(req) {
138
- if (!callAnswersTo(req, ASK_USER_QUESTION_TOOL_NAME)) {
139
+ if (!heldBy("policy.durableQuestion", QUESTION_TOOL_NAMES, req)) {
139
140
  return { action: "allow" };
140
141
  }
141
142
  return { action: "ask", message: "AskUserQuestion: awaiting a human answer (durable)" };
@@ -834,6 +834,16 @@ export type PendingAction = {
834
834
  * Written only when it is a member of the set; a pre-existing row reads absent (unreported).
835
835
  * Display metadata — the resume belts keep reading the gate's own bit. */
836
836
  origin?: import("./ask-origin.js").AskOrigin;
837
+ /** (additive, same precedent) — the PARK twin of `AskRequest.askClass`: the parked ask's class, the first member of
838
+ * {@link classes}, from the same classification the gate arbitrated it by — so a durable approval seat can answer by
839
+ * class exactly as a synchronous `onAsk` does. Written by the park mint only as a well-formed pair with
840
+ * {@link classes} (both or neither). A row parked by an engine before this member existed reads ABSENT
841
+ * (unclassified), and a resume does not fill it in: a seat answering by class treats absence as a class it will
842
+ * not answer. Display / seat metadata — no gate, CAS or resume path reads it. */
843
+ askClass?: import("./ask-origin.js").AskClass;
844
+ /** The PARK twin of `AskRequest.classes`: every class of the parked ask, in `ASK_CLASSES` order
845
+ * (`classes[0] === askClass`). Present exactly when {@link askClass} is. */
846
+ classes?: readonly import("./ask-origin.js").AskClass[];
837
847
  /**
838
848
  * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
839
849
  * SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at
@@ -868,9 +878,11 @@ export type PendingAction = {
868
878
  */
869
879
  family?: import("./tool-face.js").ToolFamily;
870
880
  pathTarget?: import("./tool-face.js").ToolRosterPathTarget;
871
- /** The names a rule may spell the parked tool by (`ToolCallFace.names`: the wire name, then the aliases that resolve
872
- * to it) off the same roster row at the same mint — the pre-CAS recheck judges an edit against rules written on
873
- * either name, as the live gate judged the original. Absent on a row minted before it ⇒ the row's `toolName` alone. */
881
+ /** The names the parked call answered to, copied from the face the gate judged it under (`ToolCallFace.names`: the
882
+ * model's spelling first, then the wire name and the other aliases that resolve to it; a row minted before the
883
+ * spelling led carries the wire name first) — the pre-CAS recheck judges an edit against rules written on any of
884
+ * them, as the live gate judged the original, and the resume re-mints its face under the spelling while it still
885
+ * resolves to the row's tool. Absent on a row minted before it ⇒ the row's `toolName` alone. */
874
886
  names?: readonly string[];
875
887
  /**
876
888
  * #438 (additive; no checkpoint-version bump — the `reminderMark` precedent: an optional field an
@@ -1123,7 +1135,7 @@ export interface CheckpointState {
1123
1135
  * revive the mode where the redeeming deployment denies it, and an `auto` row arms only where a face is
1124
1136
  * wired. Absent on a task that declared nothing and inherited nothing (`default`). Rows written before this
1125
1137
  * member carried `autoModeRequested` / `approverPosture` / a doctrine `shellGate`; the resume leg migrates
1126
- * them once, announced (`resume.legacy_mode_seed_migrated`), and the next suspend writes this member. */
1138
+ * them once, announced (`resume.legacy_mode_seed_migrated`), and the next suspend writes this member. The migration is kept through 7.37.x and removed in 7.38.0, which refuses a row still carrying these keys. */
1127
1139
  permissionMode?: PermissionMode;
1128
1140
  /** Beside a recorded `plan`: the declared word the leg had when a steer or the model's `EnterPlanMode` moved it into
1129
1141
  * plan — the mode an approved or edited `plan_review` resumes under ({@link import("./mode-policy.js").modeRequestAfterResume}). */
@@ -3042,7 +3054,7 @@ export interface LegacyModeSeedMigration {
3042
3054
  * bypass);
3043
3055
  * - `shellGate`: absent ⇒ none; `"always"` ⇒ kept (a live key, not a retired encoding); `"off"` / `"classify"` ⇒
3044
3056
  * dropped (no floor); anything else ⇒ refused (`resume.legacy_mode_seed`).
3045
- * Idempotent: a row the resume leg writes back carries `permissionMode` and the floor only, so a second read migrates
3046
- * nothing. Migration precedes any retired-key door, so a window row is read rather than refused.
3057
+ * Idempotent: a row the resume leg writes back carries `permissionMode` and the floor only, so a second read migrates nothing ("once" is per row).
3058
+ * Migration precedes any retired-key door, so a window row is read rather than refused. The window: 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 a row parked before 7.34.0 before upgrading to 7.38.0.
3047
3059
  */
3048
3060
  export declare function migrateLegacyModeSeed(gate: object | undefined): LegacyModeSeedMigration;
@@ -9,6 +9,7 @@ import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-
9
9
  import { redactSecrets } from "./untrusted-egress.js";
10
10
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
11
11
  import { isPermissionMode } from "./ask-origin.js";
12
+ import { wireOf } from "./tool-name-readings.js";
12
13
  export function mintCheckpointToken() {
13
14
  return randomBytes(16).toString("hex");
14
15
  }
@@ -290,7 +291,7 @@ export function summarizeCheckpoint(cp) {
290
291
  ...(cp.sourceTaskId !== undefined ? { sourceTaskId: cp.sourceTaskId } : {}),
291
292
  ...(cp.principal !== undefined ? { principal: cp.principal } : {}),
292
293
  ...(tool !== undefined ? { toolCallId: tool.toolCallId, toolName: tool.toolName } : {}),
293
- ...(tool?.toolName === ASK_USER_QUESTION_TOOL_NAME ? { contentKind: "content_ask" } : {}),
294
+ ...(tool !== undefined && wireOf("park.contentKindProjection", tool) === ASK_USER_QUESTION_TOOL_NAME ? { contentKind: "content_ask" } : {}),
294
295
  ...(toolInput !== undefined ? { toolInput } : {}),
295
296
  ...(cp.state.workspaceHandle !== undefined
296
297
  ? { restoreMode: cp.state.workspaceHandle.restoreMode === "park_only" ? "park_only" : "snapshot" }
@@ -171,6 +171,9 @@ export interface ContextEditOptions {
171
171
  * registry state are compaction's job, not a blanking marker's.
172
172
  */
173
173
  compactableTools?: ReadonlySet<string>;
174
+ /** The wire name a result's recorded tool name resolves to on the leg's roster (a result is clearable by the tool the
175
+ * call ran, not the spelling the model used). Absent ⇒ the recorded name. */
176
+ toolNameOf?: (recorded: string) => string;
174
177
  /**
175
178
  * design/374 (RB-212 durable half): the PRE-CAP counterpart array of `messages`, position-aligned
176
179
  * (the pipeline's aggregate caps replace in place, never drop). When present, the machine
@@ -316,6 +319,7 @@ export declare function planRejectionClears(messages: AgentMessage[], opts: {
316
319
  keyOf: OccurrenceIndex["keyOf"];
317
320
  offload?: ContextEditOptions["offload"];
318
321
  compactableTools?: ReadonlySet<string>;
322
+ toolNameOf?: (recorded: string) => string;
319
323
  keepRecentToolResults?: number;
320
324
  }): RejectionClearPlan;
321
325
  /**
@@ -5,6 +5,7 @@ import { physicalWindowOf } from "./context-settings.js";
5
5
  import { PERSISTED_OUTPUT_PREFIX, offloadPagebackHint } from "./tool-result-store.js";
6
6
  import { assertNoRetiredKeys } from "./retired-keys.js";
7
7
  import { compactableToolNames } from "./tool-registry.js";
8
+ import { wireOf } from "./tool-name-readings.js";
8
9
  const CC_CLEARED_MARKER = "[Old tool result content cleared]";
9
10
  const CC_CLEARED_EXTENDED_PREFIX = "[Old tool result content cleared —";
10
11
  const refNote = (ref) => offloadPagebackHint(ref, "cleared");
@@ -47,6 +48,7 @@ function resolveKeepRecentToolResults(value, defaultValue) {
47
48
  return Math.max(MIN_KEEP_RECENT_TOOL_RESULTS, value);
48
49
  }
49
50
  const COMPACTABLE_TOOLS = compactableToolNames();
51
+ const compactableResult = (compactable, recorded, toolNameOf) => compactable.has(wireOf("compaction.compactableResult", { toolName: toolNameOf !== undefined ? toolNameOf(recorded) : recorded }));
50
52
  function soleMarkerText(m) {
51
53
  const content = m.content;
52
54
  if (!Array.isArray(content) || content.length !== 1)
@@ -127,6 +129,7 @@ const CONTEXT_EDIT_OPTION_KEYS = new Set([
127
129
  "anchoredTotalTokens",
128
130
  "charsPerToken",
129
131
  "compactableTools",
132
+ "toolNameOf",
130
133
  "clearSource",
131
134
  ]);
132
135
  const RETIRED_CONTEXT_EDIT_OPTION_KEYS = {
@@ -159,7 +162,7 @@ export function clearStaleToolResults(messages, opts) {
159
162
  }
160
163
  const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults, CC_DEFAULT_KEEP_RECENT_TOOL_RESULTS);
161
164
  const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
162
- const candidates = messages.flatMap((m, i) => isToolResult(m) && compactable.has(m.toolName) ? [{ idx: i, target: m }] : []);
165
+ const candidates = messages.flatMap((m, i) => isToolResult(m) && compactableResult(compactable, m.toolName, opts.toolNameOf) ? [{ idx: i, target: m }] : []);
163
166
  const beyondKeep = candidates.slice(0, Math.max(0, candidates.length - keep));
164
167
  const clearable = beyondKeep.filter(({ target }) => !isCleared(target));
165
168
  if (clearable.length === 0) {
@@ -223,7 +226,7 @@ function occurrenceGroupId(m) {
223
226
  export function planRejectionClears(messages, opts) {
224
227
  const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults, CC_DEFAULT_KEEP_RECENT_TOOL_RESULTS);
225
228
  const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
226
- const candidates = messages.flatMap((m) => (isToolResult(m) && compactable.has(m.toolName) ? [m] : []));
229
+ const candidates = messages.flatMap((m) => (isToolResult(m) && compactableResult(compactable, m.toolName, opts.toolNameOf) ? [m] : []));
227
230
  const beyondKeep = candidates.slice(0, Math.max(0, candidates.length - keep));
228
231
  const clearable = beyondKeep.flatMap((m) => {
229
232
  if (isCleared(m))
@@ -0,0 +1,21 @@
1
+ import type { PermissionResult } from "./tool-policy.js";
2
+ /** The screen's one refusal — every arm's deny, so an out-of-contract decision is one code whichever arm caught it. */
3
+ export declare const refusedOutOfContract: (message: string) => PermissionResult;
4
+ /** One level of the receipt, the same for the decision and each record it nests. The carrier is an ORDINARY object — it and
5
+ * every object on its prototype chain are looked at for being a Proxy before any descriptor is asked for (a Proxy's traps are
6
+ * the caller's code; the look invokes none of them) — so the receipt runs none of the carrier's code: a contract member is
7
+ * absent or an own enumerable DATA property, its value taken off the descriptor (no read at all). Anything else refuses the
8
+ * carrier, the member named by `path`: an accessor (never invoked), and a member the prototype or a non-enumerable property
9
+ * supplies, unless the nearest descriptor holding it spells absence (an `undefined` data property). Descriptors are looked up
10
+ * as OWN entries of the descriptor map, so a polluted `Object.prototype` names no member. Members outside the contract are
11
+ * not looked at. */
12
+ /** The receipt's own answers carry a `kind` discriminant as an own property: narrowing never asks the prototype chain (`in`) for a
13
+ * word, so a polluted `Object.prototype` cannot answer for the receipt either. */
14
+ export type Received = {
15
+ kind: "held";
16
+ held: Record<string, unknown>;
17
+ } | {
18
+ kind: "refused";
19
+ refused: PermissionResult;
20
+ };
21
+ export declare function receiveContract(carrier: object, members: readonly string[], path: string): Received;
@@ -0,0 +1,63 @@
1
+ import { types } from "node:util";
2
+ import { thrownText } from "./thrown-value.js";
3
+ export const refusedOutOfContract = (message) => ({ action: "deny", message, decisionReason: "rule" });
4
+ const spellsAbsence = (d) => Object.hasOwn(d, "value") && d.value === undefined;
5
+ function ordinaryChain(carrier) {
6
+ const chain = [];
7
+ const seen = new Set();
8
+ try {
9
+ for (let o = carrier; o !== null; o = Object.getPrototypeOf(o)) {
10
+ if (types.isProxy(o))
11
+ return { kind: "not", why: o === carrier ? "is a Proxy" : "has a Proxy on its prototype chain" };
12
+ if (seen.has(o))
13
+ return { kind: "not", why: "has a cyclic prototype chain" };
14
+ seen.add(o);
15
+ chain.push(o);
16
+ }
17
+ }
18
+ catch (e) {
19
+ return { kind: "not", why: `threw while its prototype chain was looked at (${thrownText(e)})` };
20
+ }
21
+ return { kind: "chain", chain };
22
+ }
23
+ export function receiveContract(carrier, members, path) {
24
+ const what = path === "" ? "a permission decision" : `a permission decision's "${path.slice(0, -1)}" record`;
25
+ const ordinary = ordinaryChain(carrier);
26
+ if (ordinary.kind === "not") {
27
+ return { kind: "refused", refused: refusedOutOfContract(`${what} ${ordinary.why} — the engine reads a decision off an ordinary object's own property descriptors and runs none of the carrier's code; denied fail-closed`) };
28
+ }
29
+ let own;
30
+ try {
31
+ own = Object.getOwnPropertyDescriptors(carrier);
32
+ }
33
+ catch (e) {
34
+ return { kind: "refused", refused: refusedOutOfContract(`${what} threw while its property descriptors were looked at (${thrownText(e)}) — not an ordinary object; denied fail-closed`) };
35
+ }
36
+ const held = Object.create(null);
37
+ for (const m of members) {
38
+ const d = Object.hasOwn(own, m) ? own[m] : undefined;
39
+ if (d !== undefined && !Object.hasOwn(d, "value")) {
40
+ return { kind: "refused", refused: refusedOutOfContract(`${what}'s contract member "${path}${m}" is held by an accessor — contract members must be own data properties, not values supplied through an accessor; denied fail-closed`) };
41
+ }
42
+ if (d !== undefined && d.enumerable === true) {
43
+ held[m] = d.value;
44
+ continue;
45
+ }
46
+ let reached = d;
47
+ try {
48
+ for (let i = 1; reached === undefined && i < ordinary.chain.length; i++)
49
+ reached = Object.getOwnPropertyDescriptor(ordinary.chain[i], m);
50
+ }
51
+ catch (e) {
52
+ return { kind: "refused", refused: refusedOutOfContract(`${what} threw while "${path}${m}" was looked for on its prototype chain (${thrownText(e)}) — not an ordinary object; denied fail-closed`) };
53
+ }
54
+ if (reached !== undefined && !spellsAbsence(reached)) {
55
+ return {
56
+ kind: "refused",
57
+ refused: refusedOutOfContract(`${what} supplies "${path}${m}" other than as its own enumerable property (its prototype or a non-enumerable property) — ` +
58
+ `contract fields must be own properties, so every reader sees the one value the engine read; denied fail-closed`),
59
+ };
60
+ }
61
+ }
62
+ return { kind: "held", held };
63
+ }
@@ -251,6 +251,13 @@ export interface EngineNotice {
251
251
  * (requested, reported) PAIR — a catalog fact, not a turn fact. Audience `operator`;
252
252
  * `detail: { requested, reported, sessionId, runId }`. Absent ⇒ the provider echoed the requested id
253
253
  * or named nothing — one reading, not two.
254
+ * - `"route.tool_call_id_folded"` (#1207) — the provider named a tool call with an id outside the shape the
255
+ * engine keys calls under (visible ASCII, 1–128, no `~`), so the brain keyed it under a deterministic fold
256
+ * (`ToolCall.id`) and keeps the provider's spelling beside it (`ToolCall.wireId`), handed back only to the
257
+ * provider. Advisory: the run is unchanged. ONCE PER RUN, on the first folded call — a gateway fact, not a
258
+ * call fact. Audience `operator`; `detail: { toolCallId, rawLength, reason, sessionId, runId }` —
259
+ * `toolCallId` the engine id, `reason` `"charset"` (a control byte, a space, a non-ASCII unit or `~`) or
260
+ * `"length"` (admitted characters, over 128); never the raw bytes. Absent ⇒ no call of the run was folded.
254
261
  * - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
255
262
  * in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
256
263
  * (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
@@ -330,7 +337,8 @@ export interface EngineNotice {
330
337
  * `permissionMode` (`autoModeRequested: true` ⇒ `auto`; a posture is dropped — it never authorized anything;
331
338
  * `shellGate` `"off"` / `"classify"` are dropped, `"always"` kept) and the next suspend writes the new member.
332
339
  * `detail: { migrated: { permissionMode?, droppedKeys }, sessionId, runId }`. Audience `operator`. A row the
333
- * migration cannot read refuses the resume instead (`resume.legacy_mode_seed`, the error-code row above).
340
+ * migration cannot read refuses the resume instead (`resume.legacy_mode_seed`, the error-code row above). The
341
+ * migration is kept through 7.37.x and removed in 7.38.0, which refuses a row still carrying these keys.
334
342
  * - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
335
343
  * checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
336
344
  * `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
@@ -705,10 +713,12 @@ export interface EngineNotice {
705
713
  * `detail.settlement` names how: `"delivered"` (a re-judgment or an approval released it into
706
714
  * this session's model context), `"refused"` (the setting moved to refuse, or an approval met a
707
715
  * refusing policy), `"expired"` (the `dialogExpiry` deadline, an eviction when the 100-entry queue
708
- * was full, a cancelled review, or a graceful shutdown), `"denied"` (the review face), `"dropped"`
709
- * (the injection lane refused the released frame). `detail.reason` is the trigger
710
- * (`deadline | evicted | shutdown | rejudge | approved | denied | cancelled | inject_failed`); the
711
- * sender was sent the matching receipt whenever the record carried a reply address.
716
+ * was full, a cancelled review, a graceful shutdown, or a record another lease holder already settled),
717
+ * `"denied"` (the review face), `"dropped"` (the injection lane refused the released frame).
718
+ * `detail.reason` is the trigger (`PeerHeldSettleReason`: `deadline | evicted | shutdown | rejudge |
719
+ * approved | denied | cancelled | inject_failed | superseded`); the sender was sent the matching receipt
720
+ * whenever the record carried a reply address — except on `superseded`, where the lease holder that
721
+ * settled the record sent it.
712
722
  * `detail: { settlement, reason, seq, box, fromSession?, heldCount, sessionId, runId }`. Audience
713
723
  * `"user"`: it is this session's user who held (or was holding) the message.
714
724
  * - `"peer.idle_subscription"` (design/385 §5.2, slice 4) — a peer session asked to be told when
@@ -6,11 +6,12 @@ import { traceHookCrash } from "./gate-crash-trace.js";
6
6
  import { writeProtectionMandated, writeProtectionTightenDecision, writeProtectionUnresolved } from "./write-protect.js";
7
7
  import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
8
8
  import { thrownText } from "./thrown-value.js";
9
+ import { refuseOutOfContractDecision } from "./tool-policy.js";
9
10
  export async function runGateFold(pass) {
10
11
  const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
11
12
  const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
12
13
  pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
13
- pass.decision = adjudicate ? await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req)) : { action: "allow" };
14
+ pass.decision = adjudicate ? refuseOutOfContractDecision(await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req))) : { action: "allow" };
14
15
  {
15
16
  const refusedBy = ledger.fromDecision(pass.decision);
16
17
  if (refusedBy !== undefined)