@sema-agent/core 5.51.0 → 5.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.53.0 — 2026-08-21
4
+
5
+ ### Fixed
6
+ - **An MCP refresh no longer rewrites the cacheable tool prefix** (#375 root fix): `RefreshMcpTools`
7
+ re-inserted the server's tool domain at the TAIL of the mount array — the wire order — so a
8
+ byte-identical listing still displaced the whole domain past every later tool, a full provider
9
+ prefix-cache re-bill the model itself was told to trigger ("refresh when a listing looks stale").
10
+ The domain now re-enters at its anchor (the lowest index it occupied): an unchanged listing
11
+ produces a byte-identical array. First-time mounts still append.
12
+ - **Thinking replay survives strict gateways and non-streaming rewrites** (#374 adjacent):
13
+ the replay judge is shape-based (only a non-empty string counts as a signature — a JSON-roundtrip
14
+ `null`/number can no longer ride onto the wire), `signature_delta` refuses non-string coercion,
15
+ and an inlined `content_block_start` thinking block (the compat-gateway SSE-rewrite form) is now
16
+ fully captured — text and signature — instead of being dropped from every subsequent request.
17
+ - **The cache-break detector tells the truth about reorders** (#376): an order-preserving hash
18
+ now attributes a pure reorder to `tool-set` with its own wording (previously it fell through to
19
+ "server-or-ttl … not a client-side prefix bug" — actively misleading, and the exact shape #375
20
+ just fixed); the low-hit-rate summary discriminates "prefix keeps changing" from "this route
21
+ reports no caching" using cacheWrite facts instead of guessing.
22
+ - **The MCP refresh region is transactional and collision-gated** (#377): tool effect records are
23
+ reset before the fold (a refreshed read-only tool no longer silently degrades to write), a
24
+ rejected listing restores both non-monotone tables, two servers whose names normalize to the
25
+ same prefix refuse at connect (`config.mcp_server_name_collision`, both original spellings
26
+ named), and a single listing minting two identical tool names keeps the first with a disclosed
27
+ drop.
28
+ - Behavior narrowings (named): colliding server configs from "silent shared domain" to a loud
29
+ connect refusal; same-listing name collisions from "both mounted under one name" to
30
+ "first kept, drop disclosed".
31
+
32
+ - **Pre-release scan dispositions (three, fixed in-tree before publish)**: the protocol-name
33
+ reservation now covers ALIASES (a `__`-shaped caller alias refuses at prepare with the same loud
34
+ code — it was a dispatchable name sitting inside a server's prefix domain, which the refresh
35
+ region would classify and clear as remote); the refresh receipt states each drop's OWN reason
36
+ (a fixed "invalid schema" label lied about the collision lane); the A2A mount gains the same
37
+ normalized-prefix collision gate as MCP (`config.a2a_peer_name_collision`), making the
38
+ protocol-naming contract's engine-wide claim true.
39
+ - Behavior narrowing (named): a caller tool alias containing `__` from "accepted" to a loud
40
+ prepare refusal; colliding A2A peer names from "silent shared domain" to a connect refusal.
41
+
42
+ ### Notes
43
+ - Residuals ticketed: #379 (open-responses presence judge, inline text-block sibling, two-phase
44
+ refresh observation). #378 resolved by the alias-reservation fix above.
45
+
46
+ ## 5.52.0 — 2026-08-21
47
+
48
+ ### Added
49
+ - **The rejection boundary holds against pre-accepted engine injections** (#370 terminal form):
50
+ the harness grows ONE provenance gate every engine-authored injection judges against — new
51
+ engine steers/followUps refuse at entry during a halt, and frames accepted BEFORE the rejection
52
+ landed are held at drain (delayed, never destroyed: queued user input drains past them, and once
53
+ the user's turn spends the halt they deliver at the next boundary; a run that ends first parks
54
+ payload frames per session losslessly). Caller-relayed steers state
55
+ `UserMessageProvenance.callerAuthored` (additive) at their one mint seam, so the gate's failure
56
+ direction can never mis-gate user input. `task.user_steer_undrained` no longer counts
57
+ payload-less engine advisories as lost user steers.
58
+ - **The nine-code resolution vocabulary reaches every deny lane**: `AskDenyResolution` +
59
+ `isAskDenyResolution` + `ASK_DENY_RESOLUTION_VALUES` exported from the package root (the
60
+ settledBy precedent); the `createApprovalPolicy` legacy lane and the inherited-ancestor
61
+ delegation fold now classify on `tool_end.resolution` via a module-private attestation carrier
62
+ (WeakMap sidecar, call-bound, fold-transferred) — a foreign policy still cannot forge or replay
63
+ a code, and the public deny wording is byte-unchanged.
64
+ - **The memory session-account close discloses standing deferrals** (#369): a harvest with
65
+ `deferred` rejection rows that actually closes the account emits one warnings line naming the
66
+ deferred seats; a refused close reports its outcome UNKNOWN honestly. The two-ledger canon is
67
+ written at four seats: the projection-debt ledger lives on the HARVEST timeline, orthogonal to
68
+ the session open/close ledger (writes never cross; reads cross one way).
69
+ - **The bare-mark echo observation seat** (`<outlet>.mark_echo`): failure arms that fence but do
70
+ not defuse (MCP tool errors, WebFetch non-2xx, WebSearch backend errors) now count a naked mark
71
+ value riding to the model — count-only, zero model-facing byte changes; the count is an upper
72
+ bound by contract.
73
+ - **Reasoning reports consume the wire's own request facts** (#367): the anthropic budget arm
74
+ reports the cap-wins facts of the request the engine SELECTED (minter-reports form, via a
75
+ brain→runner sink); a leg whose hard output cap deleted the thinking block now reports the drop
76
+ on both faces instead of a full graded gradient. `reasoning.resolved` may re-emit once per leg
77
+ when the verdict changes (deduplicated; single-frame runs unchanged).
78
+
79
+ ### Fixed
80
+ - The operator-continuation verb's `access` argument is the WHOLE row-reach identity: unspecified
81
+ axes no longer backfill from the mount (which WIDENED reach); a mount-only parent pair is
82
+ unreachable by construction. Disclosed behavior difference: a parent-paired direct continuation
83
+ without `senderName` now attributes to "main" instead of an internal task id.
84
+ - `createWebFetchTool`'s product-form ctx limitation documented at the factory (defuse/counts
85
+ need the enrichCtx/spec mount forms).
86
+ - Pre-release scan disposition (fixed in-tree before publish): the continuation verb's identity
87
+ strip also dropped the mount sessionId as the SESSION RETAIN LEDGER key — an owner-declared
88
+ access could reach a session-scoped retained child yet get a false `resume.retain_off`. The
89
+ key now rides a ledger-only options seat (`sessionRetainLedgerKey`, never a predicate axis):
90
+ reach is still exactly the declared access; the store route is restored.
91
+
92
+ ### Notes
93
+ - Standing residuals ticketed: #372 (withdrawn-seat provenance whitewash, reproduced, design
94
+ adjudication), #373 (twelve long-lived-map leak risks, store-domain batch), #374 (unsigned
95
+ thinking replay vs strict gateways), #375 (in-session tool-schema drift vs prompt cache,
96
+ awaiting break-cadence data).
97
+
3
98
  ## 5.51.0 — 2026-08-21
4
99
 
5
100
  ### 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
  */
@@ -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,
@@ -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";
@@ -66,7 +66,8 @@ function toAnthropicMessages(ctx, model) {
66
66
  for (const c of m.content) {
67
67
  if (c.type === "thinking") {
68
68
  const tc = c;
69
- const sig = tc.thinkingSignature;
69
+ const sigRaw = tc.thinkingSignature;
70
+ const sig = typeof sigRaw === "string" && sigRaw.length > 0 ? sigRaw : undefined;
70
71
  if (tc.redacted) {
71
72
  if (sig)
72
73
  blocks.push({ type: "redacted_thinking", data: sig });
@@ -260,13 +261,15 @@ export function createAnthropicBrain(config = {}) {
260
261
  if (options?.temperature !== undefined && anthCompat.supportsTemperature !== false) {
261
262
  body.temperature = options.temperature;
262
263
  }
264
+ let builtReasoningFacts;
263
265
  if (reasoningRequestCarried(model, options?.reasoning)) {
264
266
  if (anthCompat.thinkingMode === "adaptive") {
265
267
  body.thinking = { type: "adaptive" };
266
268
  }
267
269
  else {
268
270
  const hardCap = overrides?.maxOutputTokens !== undefined || options?.maxTokens !== undefined;
269
- if (budgetCapSkipsThinking(body.max_tokens, hardCap)) {
271
+ builtReasoningFacts = { outputCapTokens: body.max_tokens, hardOutputCap: hardCap };
272
+ if (budgetCapSkipsThinking(builtReasoningFacts.outputCapTokens, builtReasoningFacts.hardOutputCap)) {
270
273
  body.thinking = undefined;
271
274
  delete body.thinking;
272
275
  }
@@ -333,6 +336,8 @@ export function createAnthropicBrain(config = {}) {
333
336
  thinkingRequested = builtThinkingRequested;
334
337
  sentMaxTokens = builtMaxTokens;
335
338
  sentMaxTokensLane = builtMaxTokensLane;
339
+ if (builtReasoningFacts !== undefined)
340
+ reportReasoningWireFacts(builtReasoningFacts);
336
341
  },
337
342
  };
338
343
  },
@@ -373,9 +378,19 @@ export function createAnthropicBrain(config = {}) {
373
378
  acc.redacted = true;
374
379
  acc.signature = cb.data ?? "";
375
380
  }
381
+ else {
382
+ if (typeof cb.thinking === "string" && cb.thinking !== "")
383
+ acc.text = cb.thinking;
384
+ if (typeof cb.signature === "string" && cb.signature !== "")
385
+ acc.startSignature = cb.signature;
386
+ }
376
387
  acc.pb = { type: "thinking", thinking: "" };
377
388
  partial.content = [acc.pb];
378
389
  out.push({ type: "thinking_start", contentIndex: idx, partial: { ...partial } });
390
+ if (acc.text !== "") {
391
+ acc.pb.thinking = acc.text;
392
+ out.push({ type: "thinking_delta", contentIndex: idx, delta: acc.text, partial: { ...partial } });
393
+ }
379
394
  }
380
395
  else if (cb.type === "tool_use") {
381
396
  acc.type = "tool_use";
@@ -414,6 +429,7 @@ export function createAnthropicBrain(config = {}) {
414
429
  }
415
430
  }
416
431
  else if (d.type === "thinking_delta" && d.thinking) {
432
+ acc.startSignature = undefined;
417
433
  acc.text += d.thinking;
418
434
  if (acc.pb && acc.pb.type === "thinking")
419
435
  acc.pb.thinking = acc.text;
@@ -426,7 +442,7 @@ export function createAnthropicBrain(config = {}) {
426
442
  }
427
443
  }
428
444
  }
429
- else if (d.type === "signature_delta" && d.signature) {
445
+ else if (d.type === "signature_delta" && typeof d.signature === "string" && d.signature.length > 0) {
430
446
  acc.signature += d.signature;
431
447
  }
432
448
  else if (d.type === "input_json_delta" && d.partial_json) {
@@ -532,8 +548,9 @@ export function createAnthropicBrain(config = {}) {
532
548
  if (acc.text.length > 0 || acc.redacted === true)
533
549
  reasoningSeen = true;
534
550
  const block = { type: "thinking", thinking: acc.text };
535
- if (acc.signature)
536
- block.thinkingSignature = acc.signature;
551
+ const sig = acc.signature || acc.startSignature;
552
+ if (sig)
553
+ block.thinkingSignature = sig;
537
554
  if (acc.redacted)
538
555
  block.redacted = true;
539
556
  finalContent.push(block);
@@ -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
+ }
package/dist/core/a2a.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { A2A_TASK_STATES } from "./a2a-task-state.js";
4
4
  import { describeHttpTransportFailure, resolveProtocolHttpHeaders } from "./mcp.js";
5
- import { mintNamespacePrefix, mintNamespacedToolName } from "./protocol-naming.js";
5
+ import { findNamespacePrefixCollision, mintNamespacePrefix, mintNamespacedToolName } from "./protocol-naming.js";
6
6
  import { A2A_NAMESPACE } from "./protocol-table.js";
7
7
  import { delimitUntrusted, inlineUntrusted } from "./untrusted-text.js";
8
8
  const A2A_REQUEST_TIMEOUT_MS = 30_000;
@@ -575,6 +575,17 @@ function asPeerWarning(spec, err) {
575
575
  return warning;
576
576
  }
577
577
  export async function materializeA2aTools(specs, principal, signal) {
578
+ if (specs.length > 0) {
579
+ const collision = findNamespacePrefixCollision(A2A_NAMESPACE, specs.map((s) => s.name));
580
+ if (collision) {
581
+ const [a, b] = collision.peers;
582
+ const e = new Error(a === b
583
+ ? `A2A peer "${a}" is declared twice — each peer needs its own name (both mount under "${collision.prefix}").`
584
+ : `A2A peer names "${a}" and "${b}" both mount under "${collision.prefix}" — the namespaced tool name keeps only [a-zA-Z0-9_-], so they are the same domain to this engine. Rename one.`);
585
+ e.code = "config.a2a_peer_name_collision";
586
+ throw e;
587
+ }
588
+ }
578
589
  const lifecycle = { disposed: false };
579
590
  const tools = [];
580
591
  const toolAxes = [];
@@ -27,6 +27,7 @@ export class CacheBreakDetector {
27
27
  systemHash: fnv1a(input.systemPrompt),
28
28
  perToolHash: new Map(input.tools.map((t) => [t.name, fnv1a(`${t.name}\0${t.description}\0${stableStringify(t.parameters)}`)])),
29
29
  toolSetHash: fnv1a([...input.tools.map((t) => t.name)].sort().join(",")),
30
+ toolOrderHash: fnv1a(input.tools.map((t) => t.name).join(",")),
30
31
  modelKey: input.modelKey,
31
32
  cacheRead: input.cacheRead,
32
33
  at: Date.now(),
@@ -59,7 +60,11 @@ export class CacheBreakDetector {
59
60
  }
60
61
  else if (snap.toolSetHash !== prev.toolSetHash) {
61
62
  cause = "tool-set";
62
- detail = "the tool set changed (a tool was added/removed/reordered)";
63
+ detail = "the tool set changed (a tool was added or removed)";
64
+ }
65
+ else if (snap.toolOrderHash !== prev.toolOrderHash) {
66
+ cause = "tool-set";
67
+ detail = "the tool ORDER changed (same tools, different positions) — a reordered tool list is a byte-different prefix from the first moved tool onward, so the provider re-bills the whole tail";
63
68
  }
64
69
  else if (snap.systemHash !== prev.systemHash) {
65
70
  cause = "system-prefix";
@@ -68,10 +73,11 @@ export class CacheBreakDetector {
68
73
  else {
69
74
  const gapMs = snap.at - prev.at;
70
75
  cause = "server-or-ttl";
76
+ const checked = "nothing this detector fingerprints (system prefix, tool set + order, model)";
71
77
  detail =
72
78
  gapMs < SERVER_GAP_MS
73
- ? `nothing in the prefix changed and the gap was ${Math.round(gapMs / 1000)}s — likely a server-side miss, not a client-side prefix bug`
74
- : `nothing changed but the gap was ${Math.round(gapMs / 60_000)}min — likely a normal TTL expiry (cold cache), not a bug`;
79
+ ? `${checked} changed and the gap was ${Math.round(gapMs / 1000)}s — most likely a server-side miss; the message history is not fingerprinted, so volatile content early in the conversation would also land here`
80
+ : `${checked} changed and the gap was ${Math.round(gapMs / 60_000)}min — most likely a normal TTL expiry (cold cache)`;
75
81
  }
76
82
  }
77
83
  return { turn: input.turn, cacheReadBefore: before, cacheReadAfter: after, cause, detail };
@@ -1,4 +1,4 @@
1
- import { decisionText, describeThrown, isAskDenyResolution, 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";
@@ -827,7 +827,7 @@ export async function runToolGate(input) {
827
827
  if (decision.updatedInput !== undefined) {
828
828
  currentInput = decision.updatedInput;
829
829
  }
830
- const denyResolution = askDenyResolution;
830
+ const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
831
831
  if (input.permissionDenied) {
832
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");
833
833
  }