@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
@@ -50,6 +50,7 @@ export const NAME_READINGS = {
50
50
  "report.riskAxes": seat("tier", CHAIN),
51
51
  "report.riskAxes.egress": any(CHAIN),
52
52
  "report.riskAxes.negatives": seat("member", CHAIN),
53
+ "report.markSources": any(CHAIN),
53
54
  "verify.wroteThisRun": wire(HANDLERS),
54
55
  "verify.wroteThisRunSeed": wire("src/core/runner/run-attachment-seats.ts"),
55
56
  "event.toolStartEnd": spelling(HANDLERS),
@@ -262,18 +262,9 @@ export declare function screenApproverAttribution(v: unknown): {
262
262
  * or `approver` member on its verdict is putting an unread property on it.
263
263
  * RB-479-B① (ruled 2026-08-02): the legacy `reason` field is REMOVED — one name for one thing.
264
264
  *
265
- * How far the TYPE carries that retirement (measured 2026-08-03, correcting this note's earlier claim
266
- * that a policy returning `{reason}` "compiles red for TS consumers"): TypeScript's excess-property
267
- * check fires only where the decision is a LITERAL in an ANNOTATED position — `const d: PermissionResult
268
- * = {…}`, or a `check` written with an explicit `: PermissionResult` return annotation. The ordinary
269
- * policy shape, `{ check: () => ({…}) }` with an INFERRED return type (also the method-syntax and
270
- * assign-then-return forms), compiles GREEN with a stray `reason`. That is not a hole to plug in the
271
- * type system — it is how contextual typing works — so the enforcement is at RUNTIME instead: every
272
- * point that CONSUMES a decision REFUSES one carrying an own `reason` property, fail-closed (deny, with
273
- * a migration message) — the {@link combinePolicies} fold, the PreToolUse hook face, and the durable
274
- * resume-edit rechecks (see {@link refuseOutOfContractDecision}). Same doctrine as the strict `approve`
275
- * / `onAsk` folds in this file: an out-of-contract value on the permission face is a loud rejection,
276
- * never a best-effort read.
265
+ * The TYPE carries that retirement only where a decision is a literal in an annotated position; an inferred `check`
266
+ * return compiles green with a stray `reason`, so the enforcement is at RUNTIME — every point that consumes a decision
267
+ * refuses one carrying an own `reason` property, fail-closed ({@link refuseOutOfContractDecision}).
277
268
  */
278
269
  export type PermissionResult = {
279
270
  action: "allow";
@@ -387,6 +378,13 @@ export type PermissionResult = {
387
378
  * render the card by it. Optional on the TYPE only because a decision is caller-composable; every ask
388
379
  * the gate hands to its resolver carries it. */
389
380
  origin?: import("./ask-origin.js").AskOrigin;
381
+ /** The ask's CLASS — the first member of {@link classes} (see {@link AskRequest.askClass}). ENGINE-STAMPED at the
382
+ * same single stamp as {@link origin}, from the class set the gate's lanes read; a policy that self-declares it is
383
+ * overwritten there, and no lane reads it back (they read the classification itself). */
384
+ askClass?: import("./ask-origin.js").AskClass;
385
+ /** The ask's whole class set, in {@link import("./ask-origin.js").ASK_CLASSES} order (see
386
+ * {@link AskRequest.classes}). Stamped beside {@link askClass}, under the same rule. */
387
+ classes?: readonly import("./ask-origin.js").AskClass[];
390
388
  /** WHICH integrity guard minted this ask — the unverifiable recursive delete or the session-transcript
391
389
  * write. ENGINE-STAMPED by the two guard policies; a fold-internal fact the class derivation reads
392
390
  * ({@link import("./ask-origin.js").classifyAsk}) so the two guards, which mint the same
@@ -737,63 +735,53 @@ export declare function constraintChainEntryOfSuppliedLayer(layer: ConstraintCha
737
735
  * different frozen deny data now digest differently.
738
736
  */
739
737
  export declare function constraintChainDigest(chain: readonly ConstraintChainEntry[]): string;
738
+ /** Every member of the decision union — a member the union gains and this list lacks is a type error at `satisfies`.
739
+ * The receipt walks it: a contract member not held as an own enumerable data property is refused if a descriptor on the
740
+ * prototype chain holds one (an `undefined` data value spells absence). Not re-exported from `src/index.ts`. */
741
+ export type DecisionMember = PermissionResult extends infer U ? (U extends unknown ? keyof U : never) : never;
742
+ export declare const DECISION_MEMBERS: readonly DecisionMember[];
740
743
  /**
741
- * Refuse a decision that speaks the retired `reason` dialect (RB-479-B①, tripwire ruled 2026-08-03).
742
- *
743
- * Applied at every point a decision is CONSUMED, because no single fold sees them all (ruled
744
- * 2026-08-03): {@link combinePolicies} (the runner's gate path), the PreToolUse hook's result seam
745
- * (`hooks.ts` — a hook result reaches the gate decision without ever passing through a fold), and the
746
- * durable resume-edit rechecks (`runtask.ts` — direct `check()` calls on the caller's own policy). The
747
- * wording is face-neutral for that reason: one construction point, three consumers. The export exists
748
- * for those two engine modules only and is deliberately NOT re-exported from `src/index.ts`: this is an
749
- * internal screen on inputs the engine receives, not a facility deployments call.
750
- *
751
- * Why this is a DENY and not a best-effort read of the old field: the retirement is settled, so the
752
- * value is out-of-contract input to the permission face — the same class as an untyped `approve`
753
- * returning a truthy non-`true`, which this file already refuses fail-closed. Reading the old name
754
- * anyway would re-open the two-names-for-one-thing surface; ignoring it (the pre-tripwire behavior)
755
- * silently dropped the policy's own text and returned the per-site generic default, so a deployment
756
- * whose deny messages had quietly degraded looked healthy. The type system cannot carry this alone —
757
- * excess-property checking does not reach an inferred `check` return (see {@link PermissionResult}).
758
- *
759
- * OWN property only (`hasOwnProperty`, not `in`): a decision whose PROTOTYPE happens to expose
760
- * `reason` never wrote the retired field. A non-object decision is passed through untouched — reading
761
- * `.action` off it is the caller's existing failure, and inventing a second failure mode here would
762
- * change which error a broken policy raises.
763
- *
764
- * Any accumulated `updatedInput` is deliberately NOT carried onto this deny: it came from the same
765
- * out-of-contract decision object, so it is not a rewrite this layer is willing to report as audited.
766
- * (A rewrite from an EARLIER, contract-shaped policy still rides out — {@link combinePolicies} adds it
767
- * on the deny path per the observer contract.)
768
- *
769
- * The second arm screens `decisionReason` against the closed set ({@link DecisionReason}): a
770
- * deployment-authored `ToolPolicy.check` returning a value outside it would otherwise ride silently
771
- * into every consumer that BRANCHES on the word — provenance-keyed exclusions, audit rows, checkpoint
772
- * discriminants — each of which would treat the unclassifiable value as "none of the reasons I know",
773
- * a meaning the producing policy never chose. Same doctrine as the retired-field arm: out-of-contract
774
- * input to the permission face is refused loudly, not passed through as an accidental tenth reason.
775
- * An ABSENT `decisionReason` stays legal (it is optional; `undefined` is the typed spelling of
776
- * absence), and hook results never reach here with a foreign word — the hook seam stamps its own
777
- * (`hooks.ts`) — so this arm's live producers are exactly the deployment policies the tripwire exists
778
- * to screen. Unlike the retired-field arm this one reads the property PLAINLY (prototype chain
779
- * included), because it answers a different question: the retired arm asks whether the AUTHOR wrote
780
- * the old field (own property = authorship), this arm asks what a CONSUMER would read — and every
781
- * consumer branches on a plain `d.decisionReason` read, which the prototype can satisfy.
782
- *
783
- * SCOPE of the carrier check (ruled after three review rounds converged on the same root): it screens
784
- * STRUCTURALLY unstable carriers — getters, prototype-supplied values — i.e. shapes an ordinary
785
- * deployment can write by accident. It does NOT try to defeat a Proxy whose descriptor trap reports a
786
- * data property while its get trap stays stateful: same-process JavaScript has no trust boundary a
787
- * function can enforce (an untrusted party who ships such a Proxy can as easily patch this module), so
788
- * chasing that shape adds complexity without adding a guarantee. The screen's promise is against
789
- * drift and accident, not against a untrusted co-resident.
790
- *
791
- * `reasonIsNonInput` (the HOOK seam's spelling — merged-code scan, 5.28 window): at that seam the
792
- * field is documented as DISCARDED — every mint point downstream unconditionally re-stamps
793
- * `decisionReason:"hook"` (fold, deny arm, delegated twin), and the allow path never reads it — so
794
- * both reason arms are skipped there: refusing a value that cannot travel would fail-close a call
795
- * over a field with no consumer, which inverted the discard contract the stamp exists to enforce.
796
- * The retired-`reason` arm still applies (that one is about the MESSAGE channel, which does travel).
744
+ * The RECEIPT of a permission decision — the one point at which the engine takes a decision in from whoever composed it,
745
+ * on every lane: each layer of {@link combinePolicies}' fold, the gate's adjudication entry and its approved-edit re-check,
746
+ * the inherited lane's receipts of an ancestor policy's decision, the final-form re-check, the direct `check()` rechecks
747
+ * (the resume edit, the park re-projection, the changed-files probe), and the PreToolUse hook's result seam (its own
748
+ * reach, below). NOT re-exported from `src/index.ts`: an internal screen on inputs the engine receives.
749
+ *
750
+ * WHAT COMES BACK is the engine's own frozen copy, never the caller's object. A decision is caller-supplied data: a
751
+ * stateful accessor, a Proxy, or a policy that keeps and mutates an object it returned could otherwise answer two
752
+ * consumers with two values, and which consumer read which value would decide the call. So the caller's object is read
753
+ * ONCE, here, and every station after the receipt (the fold's ranking, the origin stamp, the classifier's eligibility, the
754
+ * mode arbitration, the approval request, the rewrite that executes) reads the copy; a copy handed to the screen again
755
+ * comes back as it is, unread. In order: (1) the CARRIER's type — a decision that is not an object (a string, a function
756
+ * holding the members) has no `action`; (2) the ONE READ — ONE function ({@link receiveContract}) for the decision and for the
757
+ * records it nests ({@link NESTED_CONTRACTS}), on an ORDINARY object (a Proxy, as the carrier or anywhere on its prototype
758
+ * chain, is refused before a trap of its could run): each contract member absent or an own enumerable DATA property, its value
759
+ * taken off the carrier's own descriptor (a member outside the contract is neither read nor carried — a policy's self-declared
760
+ * record on another name finds no reader); any other spelling refused by name — an accessor (never invoked), a member the
761
+ * prototype or a non-enumerable property supplies unless that descriptor spells absence (an `undefined` data value) — judged by
762
+ * descriptor, never read; (3) the retired `reason` field as an OWN property, judged without a read (authorship of the old
763
+ * spelling: a prototype exposing it never wrote it; ignoring it silently dropped the policy's text); (4) the VALUES, off the engine's
764
+ * one-read record, never a second read of the caller's object — the retired window absent, `action` in
765
+ * {@link DECISION_ACTIONS} (a consumer ranks a decision by testing two of its words, so any other value used to rank as an
766
+ * allow; a `canUseTool` result outside its set is a failure in CC) and `decisionReason` in {@link DECISION_REASONS} when
767
+ * present (absence stays legal); (5) the DETACH —
768
+ * the contract members' values structured-cloned ({@link tryCloneArgs}; one that cannot be, a function inside a rewrite,
769
+ * is refused), the engine's identity-keyed records carried across ({@link carryEngineAttestations}), the copy frozen and
770
+ * marked as this module's. A refusal is a deny in the screen's words, one code for every arm (`decisionReason: "rule"`),
771
+ * carrying no `updatedInput` from the refused object (a rewrite from an EARLIER, contract-shaped layer still rides out of
772
+ * {@link combinePolicies}' deny path). `null` / `undefined` carry nothing and take the carrier's refusal (the caller's own
773
+ * read of `.action` would throw instead, and that throw was being reported as an aborted check).
774
+ *
775
+ * SCOPE: the receipt runs NONE of the carrier's code — a Proxy (its traps are the caller's code) is refused unread, and on an
776
+ * ordinary object descriptor looks and `Object.getPrototypeOf` invoke nothing. What the receipt does not reach: code inside a
777
+ * member's VALUE, run by the detach itself (an accessor inside a rewrite is invoked by the structured clone, and what it answers
778
+ * there is what is cloned and what runs — one read, consistent) and by a refusal's rendering of a foreign word (`String(v)` on an
779
+ * out-of-set `action`, after the deny is decided). Same-process JavaScript has no trust boundary a function can enforce.
780
+ *
781
+ * `reasonIsNonInput` (the HOOK seam): a hook's `decisionReason` is DISCARDED there — every mint point downstream re-stamps
782
+ * `"hook"`, and the seam maps a hook's deny and ask and reads anything else as no objection, the policy still adjudicating
783
+ * the call — so that seam takes the two retired-field arms alone and its object back unchanged (`undefined` = no
784
+ * opinion): refusing a value that cannot travel would fail-close a call over a field with no consumer.
797
785
  */
798
786
  export declare function refuseOutOfContractDecision(d: PermissionResult, opts?: {
799
787
  reasonIsNonInput?: boolean;
@@ -911,22 +899,11 @@ export declare function createApprovalPolicy(opts: {
911
899
  */
912
900
  export declare function combinePolicies(...policies: ToolPolicy[]): ToolPolicy;
913
901
  /**
914
- * The shell tools whose `{ command }` arg a coarse command-name policy parses. Other tools are out of scope.
915
- *
916
- * `Monitor` belongs here. It takes a `command` and runs it through the
917
- * SAME `env.spawnBackground` seam as Bash, and prepare-task's own note says so in as many words — "Monitor
918
- * runs an arbitrary shell `command` through the SAME env seam — it must sit behind the SAME shellGate
919
- * tighten (else it becomes a policy side-door around a gated Bash)". The shellGate half was implemented;
920
- * this half — the two ALWAYS-ON, core-owned parity gates that a caller's policy cannot remove — was keyed
921
- * on a list containing only "Bash". The probe drove the whole thing end to end: the identical
922
- * `rm -rf $(cat targets)` was denied as Bash and executed as Monitor, deleting a real directory with no
923
- * gate at all; the same substitution makes `rm -rf ~/.ai-agent/sessions` a way to erase the audit trail.
924
- */
925
- /**
926
- * EXPORTED. A downstream governance layer keeps its own copy of this list so its
927
- * per-command ask-list gates the same tools core does, and it had no way to read the real one — its guard
928
- * had to probe a hand-written candidate list of names, which stays green for any name nobody thought to
929
- * put in it. The list itself is the fact; publishing it removes the guess.
902
+ * The shell tools whose `{ command }` arg a coarse command-name policy parses; other tools are out of scope. `Monitor`
903
+ * belongs here: it runs an arbitrary shell `command` through the same env seam as Bash, so the always-on parity gates
904
+ * keyed on this list reach it too (a list of `Bash` alone let the identical command run ungated as `Monitor`). EXPORTED:
905
+ * a downstream governance layer keeps its own copy of this list so its per-command ask-list gates the same tools core
906
+ * does — the list itself is the fact, and publishing it removes the guess.
930
907
  */
931
908
  export declare const COARSE_SHELL_TOOLS: string[];
932
909
  /**
@@ -1389,6 +1366,19 @@ export interface AskRequest {
1389
1366
  * therefore reading the deployment's own claim about its own ask, which is the direction that cannot
1390
1367
  * loosen anything. */
1391
1368
  readonly origin?: import("./ask-origin.js").AskOrigin;
1369
+ /** The ask's CLASS: the first member of {@link classes} in {@link import("./ask-origin.js").ASK_CLASSES} order — the
1370
+ * row key of {@link import("./ask-origin.js").MODE_ARBITRATION} a permission mode reads. Filled by every station that
1371
+ * fills {@link origin}, from the same classification the gate's lanes arbitrated this ask by; never written by a
1372
+ * caller or worker (a policy's self-declared value is replaced at the stamp), and a policy can move it only through
1373
+ * the tighten-only inputs the classification reads. ABSENT = unclassified: a request from a station that fills no
1374
+ * {@link origin} (the shared-artifact read confirmation), or a request built by a deployment itself. An approver
1375
+ * that answers by class treats absence as a class it will not answer. */
1376
+ readonly askClass?: import("./ask-origin.js").AskClass;
1377
+ /** Every class this ask belongs to, in {@link import("./ask-origin.js").ASK_CLASSES} order, `classes[0] ===
1378
+ * askClass`. One ask can carry several (an org verdict beside a hook's question); an approver that answers by class
1379
+ * reads the whole set — a class it will not answer anywhere in it is a question it does not answer. Present exactly
1380
+ * when {@link askClass} is. */
1381
+ readonly classes?: readonly import("./ask-origin.js").AskClass[];
1392
1382
  /** Engine-judged risk axes of the action awaiting approval, for the human decision surface: a
1393
1383
  * coarse `requiresRealApproval` cannot tell "cannot be undone" from "data leaves the machine",
1394
1384
  * and those call for different scrutiny. Filled by the gate from the SAME resolved axes the
@@ -13,6 +13,7 @@ import { boundInputHashOf } from "./canonical-json.js";
13
13
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./untrusted-text.js";
14
14
  import { describeRejectedValue } from "./tool-search-setting.js";
15
15
  import { thrownText } from "./thrown-value.js";
16
+ import { receiveContract, refusedOutOfContract } from "./decision-receipt.js";
16
17
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
17
18
  import { protocolOf } from "./protocol-table.js";
18
19
  import { isAbsoluteForFamily, isAbsolutePathForm, joinForFamily, normalizeAbsPathLexically, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
@@ -244,56 +245,90 @@ function stableJson(v) {
244
245
  export function constraintChainDigest(chain) {
245
246
  return `cpv1:${createHash("sha256").update(stableJson(chain)).digest("hex")}`;
246
247
  }
247
- const ALLOW = { action: "allow" };
248
+ const ALLOW = Object.freeze({ action: "allow" });
248
249
  const RETIRED_TEXT_FIELD = "reason";
249
250
  const RETIRED_WINDOW_MEMBER = "autoDenyAfterMs";
250
251
  const RETIRED_WINDOW_MEMBER_DENY_MESSAGE = `a permission decision's "denialLimitFallback" carries "${RETIRED_WINDOW_MEMBER}" — the decision names the counts only; ` +
251
252
  `the auto-deny window is decided by the ask's route (a live approver arms it, every other route runs none) and cannot be ` +
252
253
  `declared by a policy; denied fail-closed rather than silently replacing the declared number`;
254
+ const carriesRetiredWindow = (fallback) => typeof fallback === "object" && fallback !== null && fallback[RETIRED_WINDOW_MEMBER] !== undefined;
255
+ const DECISION_ACTIONS = Object.keys({ allow: 0, deny: 0, ask: 0 });
256
+ const DECISION_ACTION_SET = new Set(DECISION_ACTIONS);
257
+ export const DECISION_MEMBERS = Object.keys({
258
+ action: 0, updatedInput: 0, message: 0, decisionReason: 0, requiresRealApproval: 0, denialLimitFallback: 0, persistedRuleShadowed: 0, probeReason: 0,
259
+ probeCause: 0, probeMandated: 0, mandated: 0, mandate: 0, probeReadRootCandidate: 0, matchedAskRule: 0, ruleEvidence: 0, segmentCoverage: 0,
260
+ origin: 0, askClass: 0, classes: 0, guard: 0, ruleUnreadable: 0, authoredBy: 0, foldedAskClasses: 0,
261
+ });
262
+ const OPERAND_MEMBERS = Object.keys({ shown: 0, total: 0 });
263
+ const NESTED_CONTRACTS = [
264
+ ["", "denialLimitFallback", Object.keys({ consecutive: 0, total: 0, limit: 0, autoDenyAfterMs: 0 })],
265
+ ["", "probeCause", Object.keys({ code: 0, roots: 0, further: 0 })],
266
+ ["probeCause.", "roots", OPERAND_MEMBERS],
267
+ ["probeCause.", "further", OPERAND_MEMBERS],
268
+ ];
269
+ const refusedAction = (what) => refusedOutOfContract(`a permission decision's "action" is ${what} — the closed set is ${DECISION_ACTIONS.join("/")}; denied fail-closed rather than reading a word outside it as an allow`);
270
+ function renderForeignValue(v) {
271
+ try {
272
+ return JSON.stringify(String(v).slice(0, 64));
273
+ }
274
+ catch {
275
+ return `[unprintable ${typeof v}]`;
276
+ }
277
+ }
253
278
  const RETIRED_TEXT_FIELD_DENY_MESSAGE = `a permission decision carries the retired "${RETIRED_TEXT_FIELD}" field — rename it to "message" (the one text field ` +
254
279
  `a decision carries); denied fail-closed rather than executing a decision whose text this layer cannot read`;
280
+ const ENGINE_RECEIPTS = new WeakSet();
255
281
  export function refuseOutOfContractDecision(d, opts) {
282
+ if (opts?.reasonIsNonInput === true) {
283
+ if (d === null || d === undefined)
284
+ return d;
285
+ if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD))
286
+ return refusedOutOfContract(RETIRED_TEXT_FIELD_DENY_MESSAGE);
287
+ return carriesRetiredWindow(d.denialLimitFallback) ? refusedOutOfContract(RETIRED_WINDOW_MEMBER_DENY_MESSAGE) : d;
288
+ }
256
289
  if (typeof d !== "object" || d === null)
290
+ return refusedAction(`absent (the decision is ${d === null || d === undefined ? String(d) : `a ${typeof d}`}, not an object)`);
291
+ if (ENGINE_RECEIPTS.has(d))
257
292
  return d;
258
- if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD)) {
259
- return { action: "deny", message: RETIRED_TEXT_FIELD_DENY_MESSAGE, decisionReason: "rule" };
293
+ const decision = receiveContract(d, DECISION_MEMBERS, "");
294
+ if (decision.kind === "refused")
295
+ return decision.refused;
296
+ if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD))
297
+ return refusedOutOfContract(RETIRED_TEXT_FIELD_DENY_MESSAGE);
298
+ const contract = decision.held;
299
+ const records = Object.assign(Object.create(null), { "": contract });
300
+ for (const [holder, member, members] of NESTED_CONTRACTS) {
301
+ const held = records[holder];
302
+ if (held === undefined || typeof held[member] !== "object" || held[member] === null)
303
+ continue;
304
+ const nested = receiveContract(held[member], members, `${holder}${member}.`);
305
+ if (nested.kind === "refused")
306
+ return nested.refused;
307
+ held[member] = records[`${holder}${member}.`] = nested.held;
260
308
  }
261
- const fb = d.denialLimitFallback;
262
- if (typeof fb === "object" && fb !== null && fb.autoDenyAfterMs !== undefined) {
263
- return { action: "deny", message: RETIRED_WINDOW_MEMBER_DENY_MESSAGE, decisionReason: "rule" };
309
+ if (carriesRetiredWindow(contract.denialLimitFallback))
310
+ return refusedOutOfContract(RETIRED_WINDOW_MEMBER_DENY_MESSAGE);
311
+ if (!DECISION_ACTION_SET.has(contract.action))
312
+ return refusedAction(contract.action === undefined ? "absent" : renderForeignValue(contract.action));
313
+ if (contract.decisionReason !== undefined && !DECISION_REASON_SET.has(contract.decisionReason)) {
314
+ return refusedOutOfContract(`a permission decision carries an unrecognized "decisionReason" value (${renderForeignValue(contract.decisionReason)}) — ` +
315
+ `the closed set is ${DECISION_REASONS.join("/")}; denied fail-closed rather than letting consumers branch on a value this layer cannot classify`);
264
316
  }
265
- if (opts?.reasonIsNonInput === true)
266
- return d;
267
- const dr = d.decisionReason;
268
- if (dr === undefined)
269
- return d;
270
- if (!DECISION_REASON_SET.has(dr)) {
271
- let rendered;
272
- try {
273
- rendered = JSON.stringify(String(dr).slice(0, 64));
274
- }
275
- catch {
276
- rendered = `[unprintable ${typeof dr}]`;
277
- }
278
- return {
279
- action: "deny",
280
- message: `a permission decision carries an unrecognized "decisionReason" value (${rendered}) — ` +
281
- `the closed set is ${DECISION_REASONS.join("/")}; denied fail-closed rather than letting consumers ` +
282
- `branch on a value this layer cannot classify`,
283
- decisionReason: "rule",
284
- };
317
+ const detached = tryCloneArgs(contract);
318
+ if (!detached.ok) {
319
+ return refusedOutOfContract(`a permission decision's members could not be detached from the policy that returned them (${detached.reason}) — ` +
320
+ `the engine keeps its own copy of what it read; denied fail-closed`);
285
321
  }
286
- const carrier = Object.getOwnPropertyDescriptor(d, "decisionReason");
287
- if (carrier === undefined || !("value" in carrier)) {
288
- return {
289
- action: "deny",
290
- message: `a permission decision supplies "decisionReason" through ${carrier === undefined ? "its prototype" : "an accessor"} — ` +
291
- `contract fields must be own data properties, so consumers re-reading the field cannot be shown a ` +
292
- `different value than this screen validated; denied fail-closed`,
293
- decisionReason: "rule",
294
- };
322
+ const receipt = Object.freeze(detached.value);
323
+ const ownRecord = (o, k) => (typeof o === "object" && o !== null && Object.hasOwn(o, k) ? o[k] : undefined);
324
+ for (const [holder, member] of NESTED_CONTRACTS) {
325
+ const record = ownRecord(holder.split(".").filter(Boolean).reduce(ownRecord, receipt), member);
326
+ if (typeof record === "object" && record !== null)
327
+ Object.freeze(record);
295
328
  }
296
- return d;
329
+ carryEngineAttestations(d, receipt);
330
+ ENGINE_RECEIPTS.add(receipt);
331
+ return receipt;
297
332
  }
298
333
  const DEADLINE_ELAPSED = Symbol("approval.deadline_elapsed");
299
334
  function withTimeout(p, ms, onTimeout) {
@@ -1,4 +1,5 @@
1
1
  import type { ActorAssertion, AssistantMessage, ImageContent, Model, UserMessage } from "../llm/index.js";
2
+ import { type ToolTurnRepair } from "../llm/index.js";
2
3
  import type { HaltRemainingDirective } from "../loop/types.js";
3
4
  import type { AgentMessage, AgentTool, LoopMalformedToolUseRecovery, LoopThinkingOnlyRecovery, LoopTruncatedOutputRecovery, QueueMode, ThinkingLevel } from "../loop/types.js";
4
5
  import { type EngineSegment } from "../../core/untrusted-text.js";
@@ -189,6 +190,10 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
189
190
  private thinkingLevel;
190
191
  /** RB-30 terminal fix — runner-set sink for engine-note payloads left undrained at agent_end. */
191
192
  onUndrainedEngineNotes?: (payloads: unknown[]) => void;
193
+ /** Runner-set sink for the provider boundary's tool-turn repairs ({@link ensureToolTurnInvariants}) — called
194
+ * with every request view that needed one (the transcript is never rewritten, so a broken turn is repaired, and
195
+ * reported, on each request; the sink owns its dedup). Advisory: a throwing sink never breaks the request. */
196
+ onToolTurnRepaired?: (repairs: readonly ToolTurnRepair[]) => void;
192
197
  /** backlog #259 — USER-authored queue remnants at agent_end: steers/follow-ups whose receipts said
193
198
  * "queued" but that no turn will ever drain (the run ended first). The engine-note sweep above
194
199
  * hands ENGINE payloads back for redelivery; user inputs have no redelivery semantics (a steer
@@ -226,6 +231,8 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
226
231
  * gated: its splice opens a NEW run (idle-park redelivery), and a halt belongs to the run that
227
232
  * minted it. */
228
233
  engineInjectionsHeld?: () => boolean;
234
+ /** The provider boundary's tool-turn law: the repaired view goes to the brain, the repairs to the runner's sink. */
235
+ private holdToolTurnLaw;
229
236
  /** RB-30 R2 belt-and-braces: runner-callable idempotent recovery for exit paths that reach
230
237
  * neither agent_end nor abort() (a pre-prompt throw after prepare). Swept entries are removed,
231
238
  * so a double call is a no-op. */
@@ -1,4 +1,4 @@
1
- import { createAssistantMessageEventStream, snapshotActorAssertion, stripEngineMetadata } from "../llm/index.js";
1
+ import { createAssistantMessageEventStream, ensureToolTurnInvariants, snapshotActorAssertion, stripEngineMetadata } from "../llm/index.js";
2
2
  import { runAgentLoop } from "../loop/agent-loop.js";
3
3
  import { resolveAgentCoreStreamFn } from "../loop/runtime-deps.js";
4
4
  import { normalizeEngineSegments } from "../../core/untrusted-text.js";
@@ -211,10 +211,22 @@ export class AgentHarness {
211
211
  modelRef;
212
212
  thinkingLevel;
213
213
  onUndrainedEngineNotes;
214
+ onToolTurnRepaired;
214
215
  onUndrainedUserInputs;
215
216
  onEngineNoteConsumed;
216
217
  onFrameCommitted;
217
218
  engineInjectionsHeld;
219
+ holdToolTurnLaw(messages) {
220
+ const view = ensureToolTurnInvariants(messages);
221
+ if (view.repairs.length > 0) {
222
+ try {
223
+ this.onToolTurnRepaired?.(view.repairs);
224
+ }
225
+ catch {
226
+ }
227
+ }
228
+ return view.messages;
229
+ }
218
230
  recoverUndrainedEngineNotes() {
219
231
  this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
220
232
  }
@@ -620,7 +632,7 @@ export class AgentHarness {
620
632
  ...(this.streamingToolExecution === true && (this.getHandlers("tool_call")?.size ?? 0) === 0
621
633
  ? { streamingToolExecution: true }
622
634
  : {}),
623
- convertToLlm: (messages) => stripEngineMetadata(convertToLlm(messages)),
635
+ convertToLlm: (messages) => this.holdToolTurnLaw(stripEngineMetadata(convertToLlm(messages))),
624
636
  shouldStopAfterTurn: () => this.consultStopLatch("post_turn"),
625
637
  shouldHaltBeforeTurn: () => this.consultStopLatch("pre_turn"),
626
638
  transformContext: async (messages, signal) => {
@@ -9,3 +9,4 @@ export * from "./types.js";
9
9
  export * from "./diagnostics.js";
10
10
  export * from "./event-stream.js";
11
11
  export * from "./validation.js";
12
+ export * from "./tool-turns.js";
@@ -2,3 +2,4 @@ export * from "./types.js";
2
2
  export * from "./diagnostics.js";
3
3
  export * from "./event-stream.js";
4
4
  export * from "./validation.js";
5
+ export * from "./tool-turns.js";
@@ -0,0 +1,20 @@
1
+ import type { Message } from "./types.js";
2
+ /** What the law restored — the `forms` of a `context.tool_turn_repaired` notice. */
3
+ export declare const TOOL_TURN_REPAIR_FORMS: readonly ["trailing_text", "late_result", "missing_result", "duplicate_result"];
4
+ export type ToolTurnRepairForm = (typeof TOOL_TURN_REPAIR_FORMS)[number];
5
+ /** One restoration on one assistant message. */
6
+ export interface ToolTurnRepair {
7
+ readonly form: ToolTurnRepairForm;
8
+ /** The calls concerned, in call order — for `trailing_text` every call of the message; otherwise the calls whose
9
+ * result was moved up, synthesized, or folded with a second one. */
10
+ readonly toolCallIds: readonly string[];
11
+ }
12
+ export interface ToolTurnView {
13
+ /** The request view — the input array itself when {@link repairs} is empty. */
14
+ readonly messages: Message[];
15
+ readonly repairs: readonly ToolTurnRepair[];
16
+ }
17
+ /** The text standing between two results recorded for one call when the view folds them into one. */
18
+ export declare const SECOND_RESULT_SEPARATOR = "[another result was recorded for this same call:]";
19
+ /** Restore the tool-turn law on a provider-bound message list (see the module header). */
20
+ export declare function ensureToolTurnInvariants(messages: Message[]): ToolTurnView;
@@ -0,0 +1,91 @@
1
+ import { interruptedResultMessage, interruptedResults, isInterruptedClose } from "../../core/session-reconcile.js";
2
+ export const TOOL_TURN_REPAIR_FORMS = ["trailing_text", "late_result", "missing_result", "duplicate_result"];
3
+ function callsOf(m) {
4
+ return m.role === "assistant" && Array.isArray(m.content) ? m.content.filter((c) => c?.type === "toolCall") : [];
5
+ }
6
+ export const SECOND_RESULT_SEPARATOR = "[another result was recorded for this same call:]";
7
+ export function ensureToolTurnInvariants(messages) {
8
+ const anchor = [];
9
+ messages.forEach((m, i) => anchor.push(m.role === "toolResult" && i > 0 ? anchor[i - 1] : i));
10
+ const sites = new Map();
11
+ const ownerOf = new Map();
12
+ const resultsOf = new Map();
13
+ messages.forEach((m, i) => {
14
+ if (m.role === "assistant") {
15
+ for (const call of callsOf(m)) {
16
+ const list = sites.get(call.id) ?? [];
17
+ if (list.at(-1) !== i)
18
+ list.push(i);
19
+ sites.set(call.id, list);
20
+ }
21
+ return;
22
+ }
23
+ if (m.role !== "toolResult")
24
+ return;
25
+ const all = sites.get(m.toolCallId) ?? [];
26
+ if (all.length === 0)
27
+ return;
28
+ const owner = all.at(-1);
29
+ ownerOf.set(i, owner);
30
+ const batch = resultsOf.get(owner);
31
+ if (batch === undefined)
32
+ resultsOf.set(owner, [i]);
33
+ else
34
+ batch.push(i);
35
+ });
36
+ const repairs = [];
37
+ const out = [];
38
+ messages.forEach((m, i) => {
39
+ if (ownerOf.has(i))
40
+ return;
41
+ const calls = callsOf(m);
42
+ if (calls.length === 0) {
43
+ out.push(m);
44
+ return;
45
+ }
46
+ const assistant = m;
47
+ const firstCall = assistant.content.findIndex((c) => c?.type === "toolCall");
48
+ const trails = assistant.content.slice(firstCall).some((c) => c?.type !== "toolCall");
49
+ out.push(trails ? { ...assistant, content: [...assistant.content.filter((c) => c?.type !== "toolCall"), ...calls] } : assistant);
50
+ if (trails)
51
+ repairs.push({ form: "trailing_text", toolCallIds: calls.map((c) => c.id) });
52
+ const byId = new Map();
53
+ for (const call of calls)
54
+ if (!byId.has(call.id))
55
+ byId.set(call.id, call);
56
+ const ids = [...byId.keys()];
57
+ const recorded = new Map();
58
+ for (const r of resultsOf.get(i) ?? []) {
59
+ const id = messages[r].toolCallId;
60
+ recorded.set(id, [...(recorded.get(id) ?? []), r]);
61
+ }
62
+ const sent = new Map();
63
+ const late = [];
64
+ const duplicate = [];
65
+ for (const [id, at] of recorded) {
66
+ const real = at.filter((r) => !isInterruptedClose(messages[r]));
67
+ const chosen = real.length > 0 ? real : at.slice(0, 1);
68
+ sent.set(id, chosen.map((r) => messages[r]).reduce(foldResults));
69
+ if (anchor[chosen[0]] !== i)
70
+ late.push(id);
71
+ if (at.length > 1)
72
+ duplicate.push(id);
73
+ }
74
+ out.push(...sent.values());
75
+ const missing = [...byId.values()].filter((c) => !sent.has(c.id));
76
+ for (const result of interruptedResults(missing.map((c) => ({ toolCallId: c.id, toolName: c.name })))) {
77
+ out.push(interruptedResultMessage(result, assistant.timestamp));
78
+ }
79
+ const inCallOrder = (set) => ids.filter((id) => set.includes(id));
80
+ if (late.length > 0)
81
+ repairs.push({ form: "late_result", toolCallIds: inCallOrder(late) });
82
+ if (missing.length > 0)
83
+ repairs.push({ form: "missing_result", toolCallIds: missing.map((c) => c.id) });
84
+ if (duplicate.length > 0)
85
+ repairs.push({ form: "duplicate_result", toolCallIds: inCallOrder(duplicate) });
86
+ });
87
+ return repairs.length === 0 ? { messages, repairs } : { messages: out, repairs };
88
+ }
89
+ function foldResults(first, second) {
90
+ return { ...first, content: [...first.content, { type: "text", text: SECOND_RESULT_SEPARATOR }, ...second.content], isError: first.isError && second.isError };
91
+ }
package/dist/index.d.ts CHANGED
@@ -192,7 +192,7 @@ export { createAllowDenyPolicy, createApprovalPolicy,
192
192
  type ApprovalSeatOutcome, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, wrapToolPolicy, decisionText, resolveAsk, toolPolicyNameSets, toolPolicyPathDenyRules, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicyProjection, type ToolPolicyProjectionComponent, type ConstraintChainEntry, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, type AskRuleEvidence, type AskEvidenceAbsence, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
193
193
  export { type ReadRootGrantScreen } from "./core/read-root-grant-screen.js";
194
194
  export { type AskOrigin, ASK_ORIGINS, isAskOrigin, ORIGIN_IMPLIES_REAL_APPROVAL, PERSISTED_RULE_MANDATES, type PersistedRuleMandate, isPersistedRuleMandate } from "./core/ask-origin.js";
195
- export { PERMISSION_MODES, type PermissionMode, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, type AskClass, isAskClass, MODE_ARBITRATION, type ModeArbitrationRow, RELEASABLE_ASK_CLASSES, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, type AskClassification, type AskClassFacts, type ToolMarkSource, arbitrateByMode, type Arbitration, fileCommandOptionsSentence } from "./core/ask-origin.js";
195
+ export { PERMISSION_MODES, type PermissionMode, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, type AskClass, isAskClass, MODE_ARBITRATION, type ModeArbitrationRow, RELEASABLE_ASK_CLASSES, MODE_RELEASE_REASONS, type ModeReleaseReason, isModeReleaseReason, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, type AskClassification, type AskClassFacts, type ToolMarkSource, arbitrateByMode, type Arbitration, fileCommandOptionsSentence } from "./core/ask-origin.js";
196
196
  export { modePolicyOf, type ModePolicy, PERMISSION_MODE_FOLD_REASONS, type PermissionModeFoldReason, type PermissionModeReceipt, ACCEPT_EDITS_FILE_COMMANDS, modeAcceptsFileCommand } from "./core/mode-policy.js";
197
197
  export { readDenyClassifierRules, renderClassifierDenyRules, type DerivedDenyRule } from "./core/auto-mode-prompt.js";
198
198
  export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource, type AutoModeDenySource } from "./core/runtime-caps.js";
package/dist/index.js CHANGED
@@ -157,7 +157,7 @@ export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.
157
157
  export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, wrapToolPolicy, decisionText, resolveAsk, toolPolicyNameSets, toolPolicyPathDenyRules, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
158
158
  export {} from "./core/read-root-grant-screen.js";
159
159
  export { ASK_ORIGINS, isAskOrigin, ORIGIN_IMPLIES_REAL_APPROVAL, PERSISTED_RULE_MANDATES, isPersistedRuleMandate } from "./core/ask-origin.js";
160
- export { PERMISSION_MODES, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, isAskClass, MODE_ARBITRATION, RELEASABLE_ASK_CLASSES, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, arbitrateByMode, fileCommandOptionsSentence } from "./core/ask-origin.js";
160
+ export { PERMISSION_MODES, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, isAskClass, MODE_ARBITRATION, RELEASABLE_ASK_CLASSES, MODE_RELEASE_REASONS, isModeReleaseReason, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, arbitrateByMode, fileCommandOptionsSentence } from "./core/ask-origin.js";
161
161
  export { modePolicyOf, PERMISSION_MODE_FOLD_REASONS, ACCEPT_EDITS_FILE_COMMANDS, modeAcceptsFileCommand } from "./core/mode-policy.js";
162
162
  export { readDenyClassifierRules, renderClassifierDenyRules } from "./core/auto-mode-prompt.js";
163
163
  export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource } from "./core/runtime-caps.js";
@@ -4,5 +4,5 @@
4
4
  * design/118 ①②: the layer is now first-party (`src/engine/llm/`, naturalized out of the vendored
5
5
  * llm-core); the facade stays so consumers never notice which side a symbol lives on.
6
6
  */
7
- export { createAssistantMessageEventStream, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
8
- export type { ActorAssertion, AnthropicMessagesCompat, OpenAICompletionsCompat, OpenAIResponsesCompat, AssistantMessage, AssistantMessageDiagnostic, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, Model, ResilienceOptions, RouteAdjudication, RouteCredential, RouteCredentialSource, RoutePairingConfig, RoutePairingPosture, RouteRefusalCode, RouteRefusalDetail, SimpleStreamOptions, StallTimeouts, StopReason, StreamFn, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage, Usage, UserMessage, } from "../engine/llm/index.js";
7
+ export { createAssistantMessageEventStream, ensureToolTurnInvariants, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
8
+ export type { ActorAssertion, AnthropicMessagesCompat, OpenAICompletionsCompat, OpenAIResponsesCompat, AssistantMessage, AssistantMessageDiagnostic, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, Model, ResilienceOptions, RouteAdjudication, RouteCredential, RouteCredentialSource, RoutePairingConfig, RoutePairingPosture, RouteRefusalCode, RouteRefusalDetail, SimpleStreamOptions, StallTimeouts, StopReason, StreamFn, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage, ToolTurnRepair, Usage, UserMessage, } from "../engine/llm/index.js";
@@ -1 +1 @@
1
- export { createAssistantMessageEventStream, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
1
+ export { createAssistantMessageEventStream, ensureToolTurnInvariants, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.36.0",
3
+ "version": "7.37.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",