@sema-agent/core 5.50.0 → 5.52.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 (42) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/agents/send-message-tool.d.ts +13 -2
  3. package/dist/agents/send-message-tool.js +13 -7
  4. package/dist/agents/subagent.js +16 -4
  5. package/dist/brain/anthropic.js +6 -2
  6. package/dist/brain/reasoning.d.ts +10 -2
  7. package/dist/brain/request-params.d.ts +20 -4
  8. package/dist/brain/status-sink.d.ts +56 -0
  9. package/dist/brain/status-sink.js +16 -0
  10. package/dist/core/auto-mode-prompt.js +9 -1
  11. package/dist/core/hooks.d.ts +24 -1
  12. package/dist/core/hooks.js +26 -4
  13. package/dist/core/mcp.js +37 -12
  14. package/dist/core/memory-engine/delegation-settlement.d.ts +15 -5
  15. package/dist/core/memory-engine/delegation-settlement.js +3 -3
  16. package/dist/core/memory-engine/engine.js +10 -2
  17. package/dist/core/reminder-disclosure.d.ts +41 -0
  18. package/dist/core/reminder-disclosure.js +11 -1
  19. package/dist/core/runner/assemble-result.d.ts +6 -0
  20. package/dist/core/runner/assemble-result.js +1 -1
  21. package/dist/core/runner/prepare-task.d.ts +15 -0
  22. package/dist/core/runner/prepare-task.js +89 -43
  23. package/dist/core/runner/runtask.d.ts +5 -1
  24. package/dist/core/runner/runtask.js +57 -26
  25. package/dist/core/task-registry-agent.js +3 -3
  26. package/dist/core/task-registry-shared.d.ts +6 -0
  27. package/dist/core/task-registry.js +4 -2
  28. package/dist/core/tool-policy.d.ts +54 -0
  29. package/dist/core/tool-policy.js +72 -12
  30. package/dist/core/tools.js +7 -0
  31. package/dist/core/trace.d.ts +13 -1
  32. package/dist/core/types.d.ts +51 -6
  33. package/dist/engine/harness/agent-harness.d.ts +30 -0
  34. package/dist/engine/harness/agent-harness.js +41 -7
  35. package/dist/engine/loop/agent-loop.js +95 -30
  36. package/dist/engine/loop/types.d.ts +32 -0
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/tools/web.d.ts +10 -1
  40. package/dist/tools/web.js +5 -4
  41. package/package.json +1 -1
  42. package/test/export-surface.snapshot.json +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,120 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.52.0 — 2026-08-21
4
+
5
+ ### Added
6
+ - **The rejection boundary holds against pre-accepted engine injections** (#370 terminal form):
7
+ the harness grows ONE provenance gate every engine-authored injection judges against — new
8
+ engine steers/followUps refuse at entry during a halt, and frames accepted BEFORE the rejection
9
+ landed are held at drain (delayed, never destroyed: queued user input drains past them, and once
10
+ the user's turn spends the halt they deliver at the next boundary; a run that ends first parks
11
+ payload frames per session losslessly). Caller-relayed steers state
12
+ `UserMessageProvenance.callerAuthored` (additive) at their one mint seam, so the gate's failure
13
+ direction can never mis-gate user input. `task.user_steer_undrained` no longer counts
14
+ payload-less engine advisories as lost user steers.
15
+ - **The nine-code resolution vocabulary reaches every deny lane**: `AskDenyResolution` +
16
+ `isAskDenyResolution` + `ASK_DENY_RESOLUTION_VALUES` exported from the package root (the
17
+ settledBy precedent); the `createApprovalPolicy` legacy lane and the inherited-ancestor
18
+ delegation fold now classify on `tool_end.resolution` via a module-private attestation carrier
19
+ (WeakMap sidecar, call-bound, fold-transferred) — a foreign policy still cannot forge or replay
20
+ a code, and the public deny wording is byte-unchanged.
21
+ - **The memory session-account close discloses standing deferrals** (#369): a harvest with
22
+ `deferred` rejection rows that actually closes the account emits one warnings line naming the
23
+ deferred seats; a refused close reports its outcome UNKNOWN honestly. The two-ledger canon is
24
+ written at four seats: the projection-debt ledger lives on the HARVEST timeline, orthogonal to
25
+ the session open/close ledger (writes never cross; reads cross one way).
26
+ - **The bare-mark echo observation seat** (`<outlet>.mark_echo`): failure arms that fence but do
27
+ not defuse (MCP tool errors, WebFetch non-2xx, WebSearch backend errors) now count a naked mark
28
+ value riding to the model — count-only, zero model-facing byte changes; the count is an upper
29
+ bound by contract.
30
+ - **Reasoning reports consume the wire's own request facts** (#367): the anthropic budget arm
31
+ reports the cap-wins facts of the request the engine SELECTED (minter-reports form, via a
32
+ brain→runner sink); a leg whose hard output cap deleted the thinking block now reports the drop
33
+ on both faces instead of a full graded gradient. `reasoning.resolved` may re-emit once per leg
34
+ when the verdict changes (deduplicated; single-frame runs unchanged).
35
+
36
+ ### Fixed
37
+ - The operator-continuation verb's `access` argument is the WHOLE row-reach identity: unspecified
38
+ axes no longer backfill from the mount (which WIDENED reach); a mount-only parent pair is
39
+ unreachable by construction. Disclosed behavior difference: a parent-paired direct continuation
40
+ without `senderName` now attributes to "main" instead of an internal task id.
41
+ - `createWebFetchTool`'s product-form ctx limitation documented at the factory (defuse/counts
42
+ need the enrichCtx/spec mount forms).
43
+ - Pre-release scan disposition (fixed in-tree before publish): the continuation verb's identity
44
+ strip also dropped the mount sessionId as the SESSION RETAIN LEDGER key — an owner-declared
45
+ access could reach a session-scoped retained child yet get a false `resume.retain_off`. The
46
+ key now rides a ledger-only options seat (`sessionRetainLedgerKey`, never a predicate axis):
47
+ reach is still exactly the declared access; the store route is restored.
48
+
49
+ ### Notes
50
+ - Standing residuals ticketed: #372 (withdrawn-seat provenance whitewash, reproduced, design
51
+ adjudication), #373 (twelve long-lived-map leak risks, store-domain batch), #374 (unsigned
52
+ thinking replay vs strict gateways), #375 (in-session tool-schema drift vs prompt cache,
53
+ awaiting break-cadence data).
54
+
55
+ ## 5.51.0 — 2026-08-21
56
+
57
+ ### Added
58
+ - **A parent-thread BARE human rejection is a control-flow boundary** (#210; CC 223 anchor
59
+ `$Vo`/`cancelAndAbort`): after the person rejects a tool call with no note, the same assistant
60
+ batch's later side-effecting siblings no longer execute — never-started calls settle as coded
61
+ results (`details.{error,code}: "gate.batch_halted"`, known-not-executed wording,
62
+ `rejectedToolCallId`/`rejectedToolName` attached), already-executing calls finish and settle
63
+ honestly, and the run ends awaiting user input. Sequential, partitioned-preflight and in-stream
64
+ pipelines all consume ONE judgment seat; delegated children (forks included), reject-WITH-note,
65
+ and every non-human deny keep today's posture byte-for-byte. Engine continuation lanes
66
+ (stop-gate pushback, final-verify, attachment/batch-context steer, limit-approach, LSP
67
+ diagnostics, task notifications — the last parks losslessly) cannot revive a halted run; user
68
+ steer/followUp can — they ARE the awaited input. Additive faces: `TaskResult.haltedOnUserRejection`,
69
+ `tool_execution_end.notExecuted`, `BeforeToolCallResult.haltRemaining`. Containment form (no
70
+ abort teardown) is a registered cc-parity divergence.
71
+ - **Every ask-resolution deny names its arm** (#127): `AskDenyResolution` nine-code closed set
72
+ (human_refused/window_expired/no_approver/blanket_allow_refused/approver_unavailable/
73
+ task_aborted/presentation_failed/approver_error/approver_contract) minted where each arm
74
+ states its own fact, carried as `PermissionDeniedPayload.resolution` (distinct from `source`)
75
+ onto the `tool_end` frame. Existing deny wording byte-unchanged; the gate captures only at its
76
+ own ask-resolution seats, so a policy cannot forge a code.
77
+ - **`defineTool` carries the declared safety axes onto its product** (#126):
78
+ egress/irreversibility/reversibilityProbe/offload/offloadThresholdChars now survive into a
79
+ product-form `TaskSpec.tools` entry — a declared egress/irreversible tool fed as a finished
80
+ product previously auto-allowed with no door sounding. Declaration-takes-effect (behavior
81
+ narrowing): such declarations now really gate, and a product-form `egress:true+effect:"read"`
82
+ contradiction refuses prepare loudly. A ToolSpec-key completeness table makes the next
83
+ silently-dropped key a compile error.
84
+ - **Review sampling is tail-inclusive** (#287): the spawn review reads objective+systemPrompt
85
+ through the layered head/interior/tail sample (12k budget, gap markers), and the auto-mode
86
+ classifier's 48k action block reads head-half + declared-middle + tail-half — a two-stage
87
+ payload (benign head, real instructions past the cut) no longer evades review. Bounds unchanged.
88
+ - **The reversibilityProbe wait is always finite** (#128): absent `approvalTimeoutMs` falls to a
89
+ 30s default (elapse takes the existing fail-closed tighten-to-ask arm), garbage values refuse
90
+ loudly to the same default, explicit 0 stays honored; a throwing/timed-out probe now reaches
91
+ `onHookError`/`onError(phase:"hook")` instead of being swallowed.
92
+ - **`details.code` twins for the SendMessage admission and stop/poll families**: the five
93
+ admission codes (rate_limited/duplicate/hop_loop/hop_runaway/queue_full) and the stop family
94
+ (not_local/parked_pending_approval/park_resume_won/park_arbiter_unreachable) carry the machine
95
+ twin beside `error` at all nine mint sites; `UnifiedTaskOutput` grows the additive `code` seat.
96
+ - **`mcp.revocation_probe_failed` forwarding guidance**: the seat contract and notice directory
97
+ now state the dedup unit precisely (once per MATERIALIZATION — a resume re-materializes and may
98
+ re-announce), no session attribution, operator audience by the `NOTICE_AUDIENCE` default; plus
99
+ an in-flight-not-chased pin (a revocation racing an already-dispatched call never retracts it).
100
+
101
+ ### Changed
102
+ - Behavior narrowing (named): post-rejection siblings from "executed as usual" to "settle
103
+ un-executed"; a bare human rejection no longer re-invokes the model to narrate it
104
+ (`TaskResult.result` may be empty text); product-form tool declarations now really gate (#126).
105
+
106
+ ### Notes
107
+ - Residuals ticketed, not shipped silently: a pre-rejection harness-accepted engine steer still
108
+ drains past the boundary (#370, single-choke harness steer entry proposed); delegated-child
109
+ denies fold through policy without a resolution code (observed on #370).
110
+ - Pre-release merged-code scan dispositions (three confirmed, fixed in-tree before publish):
111
+ a human-halted boundary no longer DRAINS the LSP diagnostics registry (drain is a consuming
112
+ read — pending now survives for the continuation, the frame defers with it); `defineTool` also
113
+ carries `defer`/`alwaysLoad` (the defer classification reads them off spec.tools entries — a
114
+ product's declared deferral silently inlined its schema, and a declared inline pin lost to
115
+ `TaskSpec.deferTools`); the probe-deadline guard refuses values above setTimeout's 2^31-1
116
+ ceiling loudly (the silent ~1ms clamp it claimed to close).
117
+
3
118
  ## 5.50.0 — 2026-08-21
4
119
 
5
120
  ### Added
@@ -20,6 +20,12 @@ export interface SendMessageToolOptions {
20
20
  owner?: string;
21
21
  scope?: string;
22
22
  sessionId?: string;
23
+ /** SESSION RETAIN LEDGER key only — never an access axis. `sessionId` above wears two hats
24
+ * (row-reach predicate axis AND the process-global retain-ledger map key); a caller that must
25
+ * strip the predicate axes (the continuation verb: access IS the identity) still needs the
26
+ * ledger route, because the ledger is a STORE seat consulted for an already-gated row by its
27
+ * own toolUseId — routing through it cannot widen reach. Consulted after ctx/options sessionId. */
28
+ sessionRetainLedgerKey?: string;
23
29
  /** Completion-notify sink for the resumed run. The Runner mount wires the SENDING run's own
24
30
  * notification injector here (its runtask-wrapped `injectTaskNotification`), so the completion
25
31
  * notice lands in the sender's live injection queue at a turn boundary; a direct mount may wire a
@@ -185,13 +191,18 @@ export interface AgentContinuationReceipt {
185
191
  * clear-terminal-payload erases the durable mark; the model-facing tool NEVER softens).
186
192
  *
187
193
  * Authorization is three-layered and this verb owns only the first: ① the same
188
- * `canAccessAgentRecord` row predicate the tool runs (an out-of-scope handle reads not_found
189
- * non-leaking); ② "this really is an explicit human instruction" is the DEPLOYMENT's obligation —
194
+ * `canAccessAgentRecord` row predicate the tool runs, against the caller's `access` argument and
195
+ * NOTHING ELSE (an out-of-scope handle reads not_found — non-leaking); ② "this really is an explicit
196
+ * human instruction" is the DEPLOYMENT's obligation —
190
197
  * the same trust seat that stamps `stopSource:"user"` on the stop side (a server exposes this only
191
198
  * through a verified interactive-user principal endpoint, never to unattended machine clients); ③ the model
192
199
  * tool face has no such parameter (the bit travels on a module-private symbol — zero forgeable
193
200
  * surface from arguments).
194
201
  *
202
+ * ACCESS TOTALITY: the `access` argument is the WHOLE row-reach identity of the call — a partially
203
+ * specified one leaves the axes it omits UNDECLARED (default-deny), never backfilled from the mount.
204
+ * See the identity-strip note in the body for why that has to be spelled at the options seat too.
205
+ *
195
206
  * Store-conditional like every continuation face: with no durable stores wired the live-gate waiver
196
207
  * still works for same-process handles, and the durable leg refuses honestly.
197
208
  */
@@ -132,7 +132,7 @@ export function createSendMessageTool(opts) {
132
132
  : reason === "hop_loop"
133
133
  ? `this message has already passed through ${whoLabel} too many times (a forwarding loop) — stop relaying it; act on it or drop it.`
134
134
  : `this message's forwarding chain is too long (runaway relay) — stop relaying it; act on it or drop it.`;
135
- return { content: `Message not sent: ${text}`, details: { error: reason, to }, isError: true };
135
+ return { content: `Message not sent: ${text}`, details: { error: reason, code: reason, to }, isError: true };
136
136
  };
137
137
  if (normalizeAgentName(to) === "main") {
138
138
  if (opts.uplink && senderId !== undefined) {
@@ -217,7 +217,7 @@ export function createSendMessageTool(opts) {
217
217
  if (row.status === "parked") {
218
218
  return {
219
219
  content: `Message not sent: ${whoT3} is parked on a pending approval — it resumes when the approval is decided (durable approval inbox), not by message delivery. Send again after it resumes.`,
220
- details: { error: "parked_pending_approval", to },
220
+ details: { error: "parked_pending_approval", code: "parked_pending_approval", to },
221
221
  isError: true,
222
222
  };
223
223
  }
@@ -257,7 +257,7 @@ export function createSendMessageTool(opts) {
257
257
  if (boxFull) {
258
258
  const queueFullReceipt = (guidance) => ({
259
259
  content: `Message not sent: ${whoT3}'s mailbox is at its queued-message limit (${admissionConfig.maxQueuedPeerMessages}) — the message was NOT queued. ${guidance} ${DEDUP_RETRY_NOTE}`,
260
- details: { error: "queue_full", to },
260
+ details: { error: "queue_full", code: "queue_full", to },
261
261
  isError: true,
262
262
  });
263
263
  if (!opts.registry.beginDurableClaim(handle)) {
@@ -601,7 +601,7 @@ export function createSendMessageTool(opts) {
601
601
  if (delivered.reason === "queue_full") {
602
602
  return {
603
603
  content: `Message not sent: ${who} is still starting up and its startup message buffer is full — resend in a moment. ${DEDUP_RETRY_NOTE}`,
604
- details: { error: "queue_full", to },
604
+ details: { error: "queue_full", code: "queue_full", to },
605
605
  isError: true,
606
606
  };
607
607
  }
@@ -609,7 +609,7 @@ export function createSendMessageTool(opts) {
609
609
  if (nowRow?.status === "parked") {
610
610
  return {
611
611
  content: `Message not sent: ${who} is parked on a pending approval — it resumes when the approval is decided, not by message delivery. Send again after it resumes. ${DEDUP_RETRY_NOTE}`,
612
- details: { error: "parked_pending_approval", to },
612
+ details: { error: "parked_pending_approval", code: "parked_pending_approval", to },
613
613
  isError: true,
614
614
  };
615
615
  }
@@ -638,7 +638,7 @@ export function createSendMessageTool(opts) {
638
638
  }
639
639
  }
640
640
  const runLedger = ctx.subagentRetain ?? opts.retain;
641
- const smLedgerSessionId = ctx.sessionId ?? opts.sessionId;
641
+ const smLedgerSessionId = ctx.sessionId ?? opts.sessionId ?? opts.sessionRetainLedgerKey;
642
642
  const sessionLedger = smLedgerSessionId !== undefined ? getSessionRetainLedger(smLedgerSessionId) : undefined;
643
643
  const knows = (l) => l !== undefined && row.toolUseId !== undefined && (l.get(row.toolUseId) !== undefined || l.wasEvicted(row.toolUseId));
644
644
  const siblingLedger = opts.siblingRetain;
@@ -746,9 +746,15 @@ export function createSendMessageTool(opts) {
746
746
  }, opts.enrichCtx !== undefined ? { enrichCtx: opts.enrichCtx } : {});
747
747
  }
748
748
  export function createAgentContinuationVerb(opts) {
749
+ const { owner: _mo, scope: _ms, sessionId: _msid, parentTaskId: _mpt, parentSessionId: _mps, ...mountWithoutIdentity } = opts;
750
+ const mountLedgerKey = opts.sessionId;
749
751
  return async (handle, content, access, o) => {
750
752
  const tool = createSendMessageTool({
751
- ...opts,
753
+ ...mountWithoutIdentity,
754
+ ...(mountLedgerKey !== undefined ? { sessionRetainLedgerKey: mountLedgerKey } : {}),
755
+ ...(access.owner !== undefined ? { owner: access.owner } : {}),
756
+ ...(access.scope !== undefined ? { scope: access.scope } : {}),
757
+ ...(access.sessionId !== undefined ? { sessionId: access.sessionId } : {}),
752
758
  enrichCtx: (base) => {
753
759
  const enriched = {
754
760
  ...base,
@@ -110,14 +110,14 @@ export function layeredReviewSample(text, budget = HANDBACK_REVIEW_SAMPLE_BUDGET
110
110
  return { text, readChars: total, totalChars: total, sampled: false, gaps: 0 };
111
111
  return { text: out, readChars, totalChars: total, sampled: true, gaps };
112
112
  }
113
- function reviewCoverageNote(fields) {
113
+ function reviewCoverageNote(fields, author = "the child's own writing") {
114
114
  const sampled = fields.filter((f) => f.sample.sampled);
115
115
  if (sampled.length === 0)
116
116
  return undefined;
117
117
  return (sampled
118
118
  .map((f) => `${f.field}: the classifier was shown ${f.sample.readChars} of ${f.sample.totalChars} characters (UTF-16 code units) ` +
119
119
  `(layered head/middle/tail sample; the ${f.sample.gaps} skipped span(s) are marked in place as ` +
120
- `"[… N chars not shown …]" — any further such marker in the text is the child's own writing, not this sampler's)`)
120
+ `"[… N chars not shown …]" — any further such marker in the text is ${author}, not this sampler's)`)
121
121
  .join("; ") + " — the unshown spans were NOT reviewed; treat them as unknown, not as benign.");
122
122
  }
123
123
  const HANDBACK_ASK_MESSAGE = "Subagent has finished and is handing back control to the main agent. Review the subagent's work and flag if any action may violate security policy.";
@@ -2028,14 +2028,26 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2028
2028
  const spawnToolsNote = spawnToolNames.length > 0
2029
2029
  ? spawnToolNames.join(", ")
2030
2030
  : "(none explicitly listed — but if this deployment gave the child a real execution environment, it likely also has the standard file/shell tools: Read/Edit/Write/Bash/Grep/Glob)";
2031
+ const objectiveSample = layeredReviewSample(prompt);
2032
+ const systemPromptSample = childSystemPrompt != null ? layeredReviewSample(childSystemPrompt) : undefined;
2033
+ const spawnCoverage = reviewCoverageNote([
2034
+ { field: "objective", sample: objectiveSample },
2035
+ ...(systemPromptSample ? [{ field: "systemPrompt", sample: systemPromptSample }] : []),
2036
+ ], "the delegating agent's own writing");
2037
+ const objectiveDisplay = layeredReviewSample(prompt, 2_000, 2);
2031
2038
  const spawnVerdict = await ctx.autoModeReview.decider
2032
2039
  .decide({
2033
2040
  req: {
2034
2041
  toolName: wantsFork ? "Agent(fork)" : "Agent",
2035
- args: { objective: prompt.slice(0, 2_000), tools: spawnToolNames, systemPrompt: childSystemPrompt?.slice(0, 2_000) },
2042
+ args: {
2043
+ objective: objectiveSample.text,
2044
+ tools: spawnToolNames,
2045
+ systemPrompt: systemPromptSample?.text,
2046
+ ...(spawnCoverage ? { reviewCoverage: spawnCoverage } : {}),
2047
+ },
2036
2048
  toolCallId: ctx.toolCallId,
2037
2049
  },
2038
- askMessage: `Reviewing a sub-agent about to be spawned. Objective: ${prompt.slice(0, 2000)}${prompt.length > 2000 ? "…" : ""}\nTools available to it: ${spawnToolsNote}`,
2050
+ askMessage: `Reviewing a sub-agent about to be spawned. Objective: ${objectiveDisplay.text}\nTools available to it: ${spawnToolsNote}`,
2039
2051
  }, ctx.signal)
2040
2052
  .catch(() => ({ kind: "unavailable", cause: "error" }));
2041
2053
  if (ctx.signal?.aborted) {
@@ -3,7 +3,7 @@ import { BrainError } from "./errors.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
4
  import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
- import { emitBrainTelemetry } from "./status-sink.js";
6
+ import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
8
  import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, stripAuthHeaders, takeHeaderCasefold } from "./request-params.js";
9
9
  import { MIN_THINKING_TOKENS, budgetCapSkipsThinking, declaredEffortLevels, reasoningBudgetShare, reasoningRequestCarried, resolveEffort } from "./reasoning.js";
@@ -260,13 +260,15 @@ export function createAnthropicBrain(config = {}) {
260
260
  if (options?.temperature !== undefined && anthCompat.supportsTemperature !== false) {
261
261
  body.temperature = options.temperature;
262
262
  }
263
+ let builtReasoningFacts;
263
264
  if (reasoningRequestCarried(model, options?.reasoning)) {
264
265
  if (anthCompat.thinkingMode === "adaptive") {
265
266
  body.thinking = { type: "adaptive" };
266
267
  }
267
268
  else {
268
269
  const hardCap = overrides?.maxOutputTokens !== undefined || options?.maxTokens !== undefined;
269
- if (budgetCapSkipsThinking(body.max_tokens, hardCap)) {
270
+ builtReasoningFacts = { outputCapTokens: body.max_tokens, hardOutputCap: hardCap };
271
+ if (budgetCapSkipsThinking(builtReasoningFacts.outputCapTokens, builtReasoningFacts.hardOutputCap)) {
270
272
  body.thinking = undefined;
271
273
  delete body.thinking;
272
274
  }
@@ -333,6 +335,8 @@ export function createAnthropicBrain(config = {}) {
333
335
  thinkingRequested = builtThinkingRequested;
334
336
  sentMaxTokens = builtMaxTokens;
335
337
  sentMaxTokensLane = builtMaxTokensLane;
338
+ if (builtReasoningFacts !== undefined)
339
+ reportReasoningWireFacts(builtReasoningFacts);
336
340
  },
337
341
  };
338
342
  },
@@ -109,8 +109,16 @@ export declare function budgetCapSkipsThinking(outputCapTokens: number, hardCap:
109
109
  * request build that a per-leg eager resolution cannot: the resolved output cap and whether it is a
110
110
  * HARD bound. Supplied ⇒ the anthropic budget arm mirrors the wire's cap-wins skip
111
111
  * ({@link budgetCapSkipsThinking}); absent ⇒ the budget arm reports the cap-blind gradient it always
112
- * did (the eager per-leg trace/result mint has no request facts a capped request's per-attempt skip
113
- * is visible only to a caller that passes them).
112
+ * did (an eager mint that has not yet seen a request has no facts).
113
+ *
114
+ * Who supplies them, and why it is never the reporting caller's own arithmetic: the adapter
115
+ * that BUILT the request reports the pair it judged, through the runner's reasoning-wire-facts sink
116
+ * (`status-sink.ts`), from its `onCommitted` hook so a speculatively-built request the overflow
117
+ * recovery declined can never be reported as sent. A consumer re-deriving the cap would have to
118
+ * re-implement the adapter's precedence chain (engine override > caller `options.maxTokens` >
119
+ * `model.maxTokens` > brain-construction default) — a second source of truth for a number only the
120
+ * minter can state. The runner then re-resolves with them: the skip and its report stay ONE predicate
121
+ * reading ONE set of facts.
114
122
  */
115
123
  export interface ReasoningWireFacts {
116
124
  /** The request's resolved output cap (the wire `max_tokens` at the moment the thinking arm judges). */
@@ -45,10 +45,26 @@ export declare function stripAuthHeaders(headers: Record<string, string>): void;
45
45
  * deployment — is byte-identical on the wire).
46
46
  *
47
47
  * EXEMPT: the auth carriers (`authorization` / `x-api-key`, any case) pass through with the exact
48
- * legacy spread semantics (same-spelling override only, no case-fold dedup) their case handling is
49
- * {@link stripAuthHeaders}' pinned jurisdiction (the per-call-replaces flow and the
50
- * header-only ANTHROPIC_AUTH_TOKEN shape, which must survive under its own capital-A spelling), and
51
- * this layer must not become a second, subtly different auth authority.
48
+ * legacy spread semantics (same-spelling override only, no case-fold dedup), so that
49
+ * {@link stripAuthHeaders} stays the ONE authority over auth spelling and this layer never becomes a
50
+ * second, subtly different one.
51
+ *
52
+ * RE-RULED, because the exemption used to be justified by a reason that does not hold: the
53
+ * note claimed it protected "the header-only ANTHROPIC_AUTH_TOKEN shape, which must survive under its
54
+ * own capital-A spelling". Dedup would not endanger that shape — it keeps the WINNER'S spelling, and a
55
+ * lone `Authorization` has nothing to be deduped against, so it survives either way; nor does the
56
+ * per-call-replaces flow depend on the exemption, since {@link stripAuthHeaders} already deletes every
57
+ * spelling present. Measured, not reasoned: `mergeHeaders({Authorization:A},{authorization:B})` keeps
58
+ * BOTH, and the platform `Headers` fold sends `authorization: A, B`.
59
+ *
60
+ * STATED RESIDUAL (deliberately not fixed here): a deployment that spells the SAME auth carrier two
61
+ * ways across two layers therefore ships both, comma-folded — the very disease this function fixed
62
+ * for every other header. It is held, not denied, on severity: no server accepts a comma-joined
63
+ * credential, so the failure is a LOUD 401 attributable to the misconfiguration, whereas the
64
+ * non-auth case this function exists for produced a silently WRONG value (`X-Tenant: a, b` — neither
65
+ * writer's, the later layer's documented override defeated). Tightening it changes which credential
66
+ * reaches the wire, so it belongs in a window that discloses an auth-face behavior change, not in one
67
+ * whose subject is the reasoning knob.
52
68
  */
53
69
  /**
54
70
  * #343 (review r4) — assign a STRUCTURAL locked header under its canonical lowercase name, deleting
@@ -1,4 +1,5 @@
1
1
  import type { BrainRetryErrClass, BrainStatus } from "../core/types.js";
2
+ import type { ReasoningWireFacts } from "./reasoning.js";
2
3
  /** Run `fn` with a per-task brain-status sink in scope. ALS propagates it through the async brain calls
3
4
  * inside `fn` (the harness's prompt → brain.stream → connect/retry loop), so {@link emitBrainStatus}
4
5
  * reaches THIS task's sink and nothing else. */
@@ -60,3 +61,58 @@ export type BrainTelemetry = {
60
61
  export declare function runWithBrainTelemetry<T>(emit: (t: BrainTelemetry) => void, fn: () => Promise<T>): Promise<T>;
61
62
  /** Report a brain-layer fallback/telemetry event to the active per-task sink, if any. */
62
63
  export declare function emitBrainTelemetry(t: BrainTelemetry): void;
64
+ /**
65
+ * Run `fn` with a per-task REASONING-WIRE-FACTS sink in scope — the THIRD brain→runner ALS channel,
66
+ * same decoupling contract as its two siblings (no brain-interface field, no per-call option, no-op
67
+ * outside the scope, an emit must never change a call's outcome).
68
+ *
69
+ * What it carries and WHY it is a channel rather than a runner-side computation: the reporting
70
+ * resolver's cap-wins arm ({@link import("./reasoning.js").budgetCapSkipsThinking}) needs facts that
71
+ * belong to ONE outgoing request — the output cap that actually reached the wire and whether it was a
72
+ * HARD bound. The runner's per-leg eager mint cannot know them without re-deriving the adapter's own
73
+ * cap precedence chain (engine override > caller `options.maxTokens` > `model.maxTokens` >
74
+ * brain-construction default), i.e. without standing up a SECOND source of truth that a deployment's
75
+ * own brain, a construction-time default, or a per-attempt engine override would silently desync. So
76
+ * the MINTER reports the facts it judged — the same law `SSERequest.outputCapTokens` already states
77
+ * for the overflow recovery ("what the adapter actually put on the wire", never what a consumer
78
+ * infers) — and the resolver stays the single predicate both faces read.
79
+ *
80
+ * REVOKED ON SETTLEMENT, unlike its two siblings, because this sink WRITES to a seat that is later
81
+ * read into a returned value instead of pushing an advisory frame. `AsyncLocalStorage.run` does not
82
+ * revoke the store from async resources created inside `fn`: a brain call the engine ABANDONED (the
83
+ * brain-call guardrail's whole purpose) keeps the scope alive in its own continuation and can call
84
+ * `observe` long after the task settled — mutating a resolution whose `TaskResult` has already been
85
+ * assembled and handed back, and emitting a trace correction after the task's terminal. The scope
86
+ * therefore carries a liveness flag cleared when `fn`'s promise settles, which is also the "freeze
87
+ * before result assembly" boundary: assembly runs after the brain-driving call resolves, so a report
88
+ * that could still land is exactly one that arrives before the freeze.
89
+ *
90
+ * Passing a NO-OP `observe` is the documented way to SHIELD a nested internal brain call (compaction
91
+ * summary, side query): it installs a fresh innermost scope for the duration, so the inner call's
92
+ * reports are swallowed instead of restating the outer leg's posture — the same shape the status sink
93
+ * uses for the same reason ("background/internal brain calls are not surfaced").
94
+ */
95
+ export declare function runWithReasoningWireFacts<T>(observe: (f: ReasoningWireFacts) => void, fn: () => Promise<T>): Promise<T>;
96
+ /**
97
+ * Report the reasoning wire facts of the request the engine has SELECTED to send, to the active
98
+ * per-task sink, if any.
99
+ *
100
+ * Call it from the adapter's `onCommitted` hook, never from `buildRequest`: the context-overflow
101
+ * recovery builds candidate requests speculatively and may decline them, and a declined candidate's
102
+ * facts would otherwise be reported against a request that was never sent (the exact hazard
103
+ * `onCommitted` exists for).
104
+ *
105
+ * Precisely what that buys, since the hook's own summary ("becomes the one that is sent") is a shade
106
+ * stronger than its position: the engine calls it immediately after choosing a request and BEFORE the
107
+ * attempt loop's abort check and `doFetch`, so a call aborted before its first connect can still have
108
+ * reported. The distinction that matters here is nonetheless the one the hook does guarantee — a
109
+ * DECLINED candidate never reports, so the facts always belong to the request the adapter's decision
110
+ * was made about. The residual (a selected-but-never-connected request on an aborting leg) describes
111
+ * the posture that leg was about to run under, and is not worth moving a hook three adapters share
112
+ * their own diagnostics attribution on.
113
+ *
114
+ * Fire-and-forget and swallow-guarded like its siblings — an observability fact must never turn a
115
+ * settled brain call into a failure — and inert once its scope has settled (see
116
+ * {@link runWithReasoningWireFacts}).
117
+ */
118
+ export declare function reportReasoningWireFacts(facts: ReasoningWireFacts): void;
@@ -21,3 +21,19 @@ export function emitBrainTelemetry(t) {
21
21
  catch {
22
22
  }
23
23
  }
24
+ const reasoningFactsSinkStore = new AsyncLocalStorage();
25
+ export function runWithReasoningWireFacts(observe, fn) {
26
+ const scope = { observe, live: true };
27
+ return reasoningFactsSinkStore.run(scope, fn).finally(() => {
28
+ scope.live = false;
29
+ });
30
+ }
31
+ export function reportReasoningWireFacts(facts) {
32
+ try {
33
+ const sink = reasoningFactsSinkStore.getStore();
34
+ if (sink?.live === true)
35
+ sink.observe(facts);
36
+ }
37
+ catch {
38
+ }
39
+ }
@@ -49,6 +49,14 @@ function excerpt(text, cap) {
49
49
  return text;
50
50
  return `${text.slice(0, cap)} [… ${text.length - cap} chars truncated]`;
51
51
  }
52
+ function excerptTailInclusive(text, cap) {
53
+ if (text.length <= cap)
54
+ return text;
55
+ const half = Math.floor(cap / 2);
56
+ const marker = ` [… ${text.length - 2 * half} chars not shown …] `;
57
+ const out = `${text.slice(0, half)}${marker}${text.slice(text.length - half)}`;
58
+ return out.length >= text.length ? text : out;
59
+ }
52
60
  function renderEntry(m, cap) {
53
61
  if (m.role === "user") {
54
62
  const raw = typeof m.content === "string" ? m.content : m.content.map((c) => (c.type === "text" ? c.text : `[${c.type}]`)).join("\n");
@@ -102,5 +110,5 @@ export function renderAutoModeWindow(messages, options) {
102
110
  export function renderAutoModeAction(input) {
103
111
  const ask = input.askMessage ? `\npermission gate: ${input.askMessage}` : "";
104
112
  return (`\n## New action to classify (the agent's most recent action — evaluate THIS)\n\n` +
105
- `[tool_call] ${input.req.toolName} ${excerpt(JSON.stringify(input.req.args ?? {}), 48_000)}${ask}\n`);
113
+ `[tool_call] ${input.req.toolName} ${excerptTailInclusive(JSON.stringify(input.req.args ?? {}), 48_000)}${ask}\n`);
106
114
  }
@@ -198,6 +198,13 @@ export interface PermissionDeniedPayload {
198
198
  reason: string;
199
199
  /** Which gate source produced the deny (our `decision_reason_type` analog). */
200
200
  source: PermissionDeniedSource;
201
+ /** The ask resolver's own deny-arm classification, carried BESIDE `source` (two different
202
+ * questions: `source` names which LAYER raised the gate; this names HOW the ask resolution
203
+ * refused — a person's no vs a timeout vs headless vs an approver contract violation …). Present
204
+ * only on a deny that came through an ask resolution AND whose word passed the closed-vocabulary
205
+ * screen; a policy's direct deny, a hook deny, and the crash/plan-mode/compliance emissions carry
206
+ * none. See {@link import("./tool-policy.js").AskDenyResolution}. */
207
+ resolution?: import("./tool-policy.js").AskDenyResolution;
201
208
  }
202
209
  /**
203
210
  * 1.256 复审 MED-1 — observe-only payload isolation for {@link Hooks.permissionDenied}: clone the tool
@@ -606,6 +613,14 @@ export interface ToolGateResult {
606
613
  * and no post-tool hook can write.
607
614
  */
608
615
  settledBy?: import("./tool-policy.js").ApprovalSettledBy;
616
+ /**
617
+ * The ask resolver's deny-arm classification (see {@link import("./tool-policy.js").AskDenyResolution}),
618
+ * present only on a BLOCK whose deny came through an ask resolution and passed the closed-vocabulary
619
+ * screen at the deny exit (a self-declared word on a policy's own deny is dropped there, never
620
+ * forwarded). Rides beside {@link settledBy} to the caller's per-call sideband and the call's
621
+ * `tool_end` frame — the machine-readable "why was this refused" a consumer classifies on.
622
+ */
623
+ resolution?: import("./tool-policy.js").AskDenyResolution;
609
624
  /**
610
625
  * design/252 G-7 — WHOSE settlement that was: the identifier the approval channel reported, carried
611
626
  * out verbatim beside {@link settledBy}. This layer authenticates nothing and compares nothing; the
@@ -915,7 +930,10 @@ export interface ToolGateInput {
915
930
  * probe cannot widen anything through this member.
916
931
  */
917
932
  reversibilityProbe?: (args: unknown) => import("./types.js").ReversibilityVerdict | Promise<import("./types.js").ReversibilityVerdict>;
918
- /** design/77 §4: deadline (ms) for {@link reversibilityProbe}; on timeout the gate fails closed to `ask`. */
933
+ /** design/77 §4: deadline (ms) for {@link reversibilityProbe}; on timeout the gate fails closed to `ask`.
934
+ * ABSENT ⇒ a bounded default applies (30s — the probe wait is never unbounded, even with no
935
+ * {@link abortSignal}); a non-finite/negative value is refused loudly (via {@link onHookError}) to
936
+ * that same default, never silently reinterpreted. `0` is honored as written (immediate deadline). */
919
937
  approvalTimeoutMs?: number;
920
938
  /** design/77 §4: the task abort signal — bounds {@link reversibilityProbe} by the task's real deadline
921
939
  * (timeout/cancel) in addition to {@link approvalTimeoutMs}; an abort while probing fails closed to `ask`. */
@@ -937,6 +955,11 @@ export interface ToolGateInput {
937
955
  * this one carries the exception object itself to whoever runs the deployment, because a crashing hook is
938
956
  * a bug someone has to fix and the model-facing summary is bounded/sanitized. Never affects the outcome
939
957
  * (a throwing sink is swallowed).
958
+ *
959
+ * ALSO fired for a {@link reversibilityProbe} that threw or timed out (same species — a
960
+ * deployment-supplied callback failing while the gate holds the fail-closed line) and for a
961
+ * malformed {@link approvalTimeoutMs} refused to the bounded default. A task-abort rejection
962
+ * mid-probe is NOT reported (normal cancellation, not a defect).
940
963
  */
941
964
  onHookError?: (err: unknown) => void;
942
965
  /**
@@ -1,4 +1,4 @@
1
- import { decisionText, describeThrown, refuseOutOfContractDecision } from "./tool-policy.js";
1
+ import { coreMintedResolutionOf, decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { brandPolicyAskClass } from "./ask-class.js";
3
3
  import { inlineUntrusted } from "./untrusted-text.js";
4
4
  import { mintSystemReminder } from "./reminder-mint.js";
@@ -269,6 +269,7 @@ function withProbeTimeout(p, ms, signal) {
269
269
  p.then((v) => done(resolve, v), (err) => done(reject, err));
270
270
  });
271
271
  }
272
+ const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
272
273
  export function persistedRuleMandateOf(marks) {
273
274
  return marks.egress === true
274
275
  ? "tool_marks"
@@ -293,6 +294,7 @@ export async function runToolGate(input) {
293
294
  const preToolContext = [];
294
295
  let hookAsk;
295
296
  let parkFailed;
297
+ let askDenyResolution;
296
298
  const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
297
299
  if (preToolUse) {
298
300
  let r;
@@ -373,8 +375,20 @@ export async function runToolGate(input) {
373
375
  if (input.irreversibility === "maybe" && input.reversibilityProbe) {
374
376
  let reversible = false;
375
377
  const probeArgs = policyRewrite !== undefined ? policyRewrite : currentInput;
378
+ const suppliedProbeMs = input.approvalTimeoutMs;
379
+ let probeTimeoutMs;
380
+ if (suppliedProbeMs === undefined) {
381
+ probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
382
+ }
383
+ else if (Number.isFinite(suppliedProbeMs) && suppliedProbeMs >= 0 && suppliedProbeMs <= 2_147_483_647) {
384
+ probeTimeoutMs = suppliedProbeMs;
385
+ }
386
+ else {
387
+ probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
388
+ traceHookCrash(input, new Error(`approvalTimeoutMs must be a non-negative finite number no greater than 2147483647 (got ${String(suppliedProbeMs)}) — the reversibilityProbe deadline falls back to the ${DEFAULT_PROBE_TIMEOUT_MS}ms default`), notifier);
389
+ }
376
390
  try {
377
- const verdict = await withProbeTimeout(Promise.resolve(input.reversibilityProbe(probeArgs)), input.approvalTimeoutMs, input.abortSignal);
391
+ const verdict = await withProbeTimeout(Promise.resolve(input.reversibilityProbe(probeArgs)), probeTimeoutMs, input.abortSignal);
378
392
  reversible = verdict?.reversible === true;
379
393
  if (!reversible) {
380
394
  const raw = verdict?.reason;
@@ -383,8 +397,10 @@ export async function runToolGate(input) {
383
397
  probeCause = normalizeProbeCause(verdict?.cause);
384
398
  }
385
399
  }
386
- catch {
400
+ catch (err) {
387
401
  reversible = false;
402
+ if (input.abortSignal?.aborted !== true)
403
+ traceHookCrash(input, err, notifier);
388
404
  }
389
405
  tighten = !reversible;
390
406
  }
@@ -671,6 +687,8 @@ export async function runToolGate(input) {
671
687
  const resolved = await resolveAsk(decision, req);
672
688
  if (resolved.action !== "ask" && resolved.approver !== undefined)
673
689
  resolvedApprover = resolved.approver;
690
+ if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
691
+ askDenyResolution = resolved.resolution;
674
692
  decision = resolved;
675
693
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
676
694
  const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined);
@@ -771,6 +789,8 @@ export async function runToolGate(input) {
771
789
  const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
772
790
  resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
773
791
  if (rr.action !== "allow") {
792
+ if (rr.action === "deny" && isAskDenyResolution(rr.resolution))
793
+ askDenyResolution = rr.resolution;
774
794
  editDenied = rr;
775
795
  if (!orgRaisedThisRound)
776
796
  denySource = "policy";
@@ -807,8 +827,9 @@ export async function runToolGate(input) {
807
827
  if (decision.updatedInput !== undefined) {
808
828
  currentInput = decision.updatedInput;
809
829
  }
830
+ const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
810
831
  if (input.permissionDenied) {
811
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
832
+ await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
812
833
  }
813
834
  const denySettledBy = decision.settledBy;
814
835
  const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
@@ -816,6 +837,7 @@ export async function runToolGate(input) {
816
837
  block: true,
817
838
  reason: formatHookFeedback(denyReason, input.reminderMark),
818
839
  ...(denySettledBy !== undefined ? { settledBy: denySettledBy } : {}),
840
+ ...(denyResolution !== undefined ? { resolution: denyResolution } : {}),
819
841
  ...(denyApprover !== undefined ? { approver: denyApprover } : {}),
820
842
  preToolContext,
821
843
  };