@sema-agent/core 5.52.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 +43 -0
- package/dist/brain/anthropic.js +17 -4
- package/dist/core/a2a.js +12 -1
- package/dist/core/cache-break-detector.js +9 -3
- package/dist/core/mcp.js +21 -1
- package/dist/core/protocol-naming.d.ts +25 -2
- package/dist/core/protocol-naming.js +11 -0
- package/dist/core/runner/prepare-safety-scan.js +7 -0
- package/dist/core/runner/prepare-task.js +24 -3
- package/dist/core/runner/runtask.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
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
|
+
|
|
3
46
|
## 5.52.0 — 2026-08-21
|
|
4
47
|
|
|
5
48
|
### Added
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -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
|
|
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 });
|
|
@@ -377,9 +378,19 @@ export function createAnthropicBrain(config = {}) {
|
|
|
377
378
|
acc.redacted = true;
|
|
378
379
|
acc.signature = cb.data ?? "";
|
|
379
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
|
+
}
|
|
380
387
|
acc.pb = { type: "thinking", thinking: "" };
|
|
381
388
|
partial.content = [acc.pb];
|
|
382
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
|
+
}
|
|
383
394
|
}
|
|
384
395
|
else if (cb.type === "tool_use") {
|
|
385
396
|
acc.type = "tool_use";
|
|
@@ -418,6 +429,7 @@ export function createAnthropicBrain(config = {}) {
|
|
|
418
429
|
}
|
|
419
430
|
}
|
|
420
431
|
else if (d.type === "thinking_delta" && d.thinking) {
|
|
432
|
+
acc.startSignature = undefined;
|
|
421
433
|
acc.text += d.thinking;
|
|
422
434
|
if (acc.pb && acc.pb.type === "thinking")
|
|
423
435
|
acc.pb.thinking = acc.text;
|
|
@@ -430,7 +442,7 @@ export function createAnthropicBrain(config = {}) {
|
|
|
430
442
|
}
|
|
431
443
|
}
|
|
432
444
|
}
|
|
433
|
-
else if (d.type === "signature_delta" && d.signature) {
|
|
445
|
+
else if (d.type === "signature_delta" && typeof d.signature === "string" && d.signature.length > 0) {
|
|
434
446
|
acc.signature += d.signature;
|
|
435
447
|
}
|
|
436
448
|
else if (d.type === "input_json_delta" && d.partial_json) {
|
|
@@ -536,8 +548,9 @@ export function createAnthropicBrain(config = {}) {
|
|
|
536
548
|
if (acc.text.length > 0 || acc.redacted === true)
|
|
537
549
|
reasoningSeen = true;
|
|
538
550
|
const block = { type: "thinking", thinking: acc.text };
|
|
539
|
-
|
|
540
|
-
|
|
551
|
+
const sig = acc.signature || acc.startSignature;
|
|
552
|
+
if (sig)
|
|
553
|
+
block.thinkingSignature = sig;
|
|
541
554
|
if (acc.redacted)
|
|
542
555
|
block.redacted = true;
|
|
543
556
|
finalContent.push(block);
|
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
|
|
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
|
-
?
|
|
74
|
-
:
|
|
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 };
|
package/dist/core/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MCP_NAMESPACE } from "./protocol-table.js";
|
|
2
|
-
import { mintNamespacePrefix, mintNamespacedToolName, normalizeNameSegment } from "./protocol-naming.js";
|
|
2
|
+
import { findNamespacePrefixCollision, mintNamespacePrefix, mintNamespacedToolName, normalizeNameSegment } from "./protocol-naming.js";
|
|
3
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
4
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
5
|
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
@@ -663,6 +663,17 @@ export function mcpToolSchemaProblem(schema) {
|
|
|
663
663
|
return undefined;
|
|
664
664
|
}
|
|
665
665
|
export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure, mcpRevocations) {
|
|
666
|
+
{
|
|
667
|
+
const collision = findNamespacePrefixCollision(MCP_NAMESPACE, specs.map((s) => s.name));
|
|
668
|
+
if (collision) {
|
|
669
|
+
const [a, b] = collision.peers;
|
|
670
|
+
const e = new Error(a === b
|
|
671
|
+
? `MCP server "${inlineUntrusted(a, 120)}" is declared twice — each server needs its own name (both mount under "${collision.prefix}", so their tools would shadow each other and a refresh of one would unmount the other's).`
|
|
672
|
+
: `MCP server names "${inlineUntrusted(a, 120)}" and "${inlineUntrusted(b, 120)}" both mount under "${collision.prefix}" — the namespaced tool name keeps only [a-zA-Z0-9_-], so they are the same domain to this engine (their tools would shadow each other and a refresh of one would unmount the other's). Rename one.`);
|
|
673
|
+
e.code = "config.mcp_server_name_collision";
|
|
674
|
+
throw e;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
666
677
|
let revocationProbeFailed = false;
|
|
667
678
|
const isServerRevoked = (serverName) => {
|
|
668
679
|
if (mcpRevocations === undefined)
|
|
@@ -1256,6 +1267,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
|
|
|
1256
1267
|
const serverTools = [];
|
|
1257
1268
|
const serverAxes = [];
|
|
1258
1269
|
const dropped = [];
|
|
1270
|
+
const mintedNames = new Set();
|
|
1259
1271
|
for (const t of listed.tools) {
|
|
1260
1272
|
if (spec.allowTools && !spec.allowTools.includes(t.name)) {
|
|
1261
1273
|
continue;
|
|
@@ -1278,6 +1290,14 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
|
|
|
1278
1290
|
}
|
|
1279
1291
|
const remoteName = t.name;
|
|
1280
1292
|
const namespacedName = mintNamespacedToolName(MCP_NAMESPACE, spec.name, remoteName);
|
|
1293
|
+
if (mintedNames.has(namespacedName)) {
|
|
1294
|
+
dropped.push({
|
|
1295
|
+
tool: inlineUntrusted(t.name),
|
|
1296
|
+
reason: `name collides with an earlier tool of this server: both mount as "${namespacedName}" after namespacing (only [a-zA-Z0-9_-] survives), and one name cannot denote two tools`,
|
|
1297
|
+
});
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
mintedNames.add(namespacedName);
|
|
1281
1301
|
const hintAxis = mcpAxisFor(namespacedName, t.annotations);
|
|
1282
1302
|
const axis = applyCallerAxisOverride(namespacedName, hintAxis, spec.toolAxes?.[remoteName]);
|
|
1283
1303
|
if (axis)
|
|
@@ -12,8 +12,11 @@ export declare const MINTED_TOOL_SEGMENT_MIN_CHARS = 16;
|
|
|
12
12
|
* `mcp__<server>__<tool>` (services/mcp/normalization.ts `normalizeNameForMCP` + mcpStringUtils.ts
|
|
13
13
|
* `buildMcpToolName`), so a peer advertising a dotted/spaced/unicode name can't mint a name the provider
|
|
14
14
|
* rejects. NOTE: only the MODEL-FACING namespaced name is normalized — the raw remote name still goes on
|
|
15
|
-
* the wire and still keys the caller-facing maps (`allowTools`/`toolAxes`).
|
|
16
|
-
*
|
|
15
|
+
* the wire and still keys the caller-facing maps (`allowTools`/`toolAxes`). The collision property is
|
|
16
|
+
* inherent (two remote names that normalize to the same string mint the same string; CC accepts this at
|
|
17
|
+
* the same layer) — what this engine does NOT accept is two PEERS colliding, because a peer prefix is a
|
|
18
|
+
* live domain key here (the refresh splice). That case is decided by
|
|
19
|
+
* {@link findNamespacePrefixCollision} and refused at the mount, loudly; the mint stays total.
|
|
17
20
|
*
|
|
18
21
|
* RB-83 (2026-07-25, red probe): the pattern this enforces is `{1,64}`, and only the charset half was
|
|
19
22
|
* enforced. Both other halves matter for the same reason the charset does — an over-long or empty segment
|
|
@@ -37,6 +40,26 @@ export declare function clampNameSegment(seg: string, max?: number): string;
|
|
|
37
40
|
* (ticket #10), and the property test holds the two legs to the identical law.
|
|
38
41
|
*/
|
|
39
42
|
export declare function mintNamespacePrefix(ns: ProtocolNamespace, peer: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* The first pair of peers in `peers` that mint the SAME {@link mintNamespacePrefix} — i.e. that would
|
|
45
|
+
* register into one indistinguishable namespace domain — or `undefined` when every peer owns its own.
|
|
46
|
+
*
|
|
47
|
+
* Two spellings collide whenever charset normalization, the length clamp or the separator fold maps
|
|
48
|
+
* them together: `"prod.db"` and `"prod_db"` both mint `mcp__prod_db__`, and the same name listed twice
|
|
49
|
+
* collides trivially. The mint itself cannot refuse (its input is deployment/remote data and a dotted
|
|
50
|
+
* name must not become a materialization failure — see {@link normalizeNameSegment}), so the refusal
|
|
51
|
+
* belongs to the MOUNT, which is the layer that knows the whole peer list. This function is that
|
|
52
|
+
* layer's decision procedure: pure name arithmetic, decidable before any I/O.
|
|
53
|
+
*
|
|
54
|
+
* Why a collision cannot be tolerated downstream: the prefix IS the domain key. A refresh splices
|
|
55
|
+
* `name.startsWith(prefix)` out and re-inserts only the refreshed peer's listing, so refreshing one of
|
|
56
|
+
* two colliding peers silently unmounts the other's tools; equal tool names additionally shadow each
|
|
57
|
+
* other last-write-wins in the harness map. Both failures are invisible at the moment they happen.
|
|
58
|
+
*/
|
|
59
|
+
export declare function findNamespacePrefixCollision(ns: ProtocolNamespace, peers: readonly string[]): {
|
|
60
|
+
prefix: string;
|
|
61
|
+
peers: [string, string];
|
|
62
|
+
} | undefined;
|
|
40
63
|
/**
|
|
41
64
|
* The full registered name for `(peer, tool)` in `ns`. Always starts with {@link mintNamespacePrefix}'s
|
|
42
65
|
* answer for the same peer (the invariant ticket #9's pins hold), and never exceeds
|
|
@@ -26,6 +26,17 @@ export function mintNamespacePrefix(ns, peer) {
|
|
|
26
26
|
const peerBudget = Math.max(1, TOOL_NAME_MAX_CHARS - ns.prefix.length - NAME_SEP.length - MINTED_TOOL_SEGMENT_MIN_CHARS);
|
|
27
27
|
return `${ns.prefix}${settlePeerSegment(clampNameSegment(normalizeNameSegment(peer), peerBudget))}${NAME_SEP}`;
|
|
28
28
|
}
|
|
29
|
+
export function findNamespacePrefixCollision(ns, peers) {
|
|
30
|
+
const seen = new Map();
|
|
31
|
+
for (const peer of peers) {
|
|
32
|
+
const prefix = mintNamespacePrefix(ns, peer);
|
|
33
|
+
const first = seen.get(prefix);
|
|
34
|
+
if (first !== undefined)
|
|
35
|
+
return { prefix, peers: [first, peer] };
|
|
36
|
+
seen.set(prefix, peer);
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
29
40
|
export function mintNamespacedToolName(ns, peer, tool) {
|
|
30
41
|
const prefix = mintNamespacePrefix(ns, peer);
|
|
31
42
|
return `${prefix}${clampNameSegment(normalizeNameSegment(tool), Math.max(1, TOOL_NAME_MAX_CHARS - prefix.length))}`;
|
|
@@ -19,6 +19,13 @@ export function prepareSafetyScan(input) {
|
|
|
19
19
|
e.code = "config.tool_name_invalid";
|
|
20
20
|
throw e;
|
|
21
21
|
}
|
|
22
|
+
for (const alias of t.aliases ?? []) {
|
|
23
|
+
if (alias.includes("__")) {
|
|
24
|
+
const e = new Error(`Tool alias "${alias}" (of "${t.name}") is invalid: "__" is reserved for the engine's protocol tool namespaces (${NAMESPACED_NAME_SHAPES}) and must not appear in a caller tool alias.`);
|
|
25
|
+
e.code = "config.tool_name_invalid";
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
22
29
|
if (t.effect) {
|
|
23
30
|
toolEffects.set(t.name, t.effect);
|
|
24
31
|
}
|
|
@@ -1221,22 +1221,43 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1221
1221
|
const excludedSet = new Set(toolFaceSnapshot.exclude ?? []);
|
|
1222
1222
|
const pushable = r.tools.filter((t) => !excludedSet.has(t.name));
|
|
1223
1223
|
const excludedNow = r.tools.filter((t) => excludedSet.has(t.name)).map((t) => t.name);
|
|
1224
|
+
const domainSnapshot = (m) => new Map([...m].filter(([name]) => name.startsWith(r.prefix)));
|
|
1225
|
+
const restoreDomain = (m, snap) => {
|
|
1226
|
+
for (const name of [...m.keys()])
|
|
1227
|
+
if (name.startsWith(r.prefix))
|
|
1228
|
+
m.delete(name);
|
|
1229
|
+
for (const [name, v] of snap)
|
|
1230
|
+
m.set(name, v);
|
|
1231
|
+
};
|
|
1232
|
+
const priorDomainEffects = domainSnapshot(toolEffects);
|
|
1233
|
+
const priorDomainNegatives = domainSnapshot(axisExplicitNegatives);
|
|
1234
|
+
for (const name of priorDomainEffects.keys())
|
|
1235
|
+
toolEffects.delete(name);
|
|
1236
|
+
for (const name of priorDomainNegatives.keys())
|
|
1237
|
+
axisExplicitNegatives.delete(name);
|
|
1224
1238
|
try {
|
|
1225
1239
|
foldProtocolAxes((r.axes ?? []).filter((a) => !excludedSet.has(a.name)), "MCP");
|
|
1226
1240
|
}
|
|
1227
1241
|
catch (foldErr) {
|
|
1242
|
+
restoreDomain(toolEffects, priorDomainEffects);
|
|
1243
|
+
restoreDomain(axisExplicitNegatives, priorDomainNegatives);
|
|
1228
1244
|
lines.push(`${r.server}: failed (${foldErr instanceof Error ? foldErr.message : String(foldErr)})`);
|
|
1229
1245
|
anyActiveFailure = true;
|
|
1230
1246
|
continue;
|
|
1231
1247
|
}
|
|
1248
|
+
let domainAnchor = -1;
|
|
1232
1249
|
for (let i = tools.length - 1; i >= 0; i--) {
|
|
1233
1250
|
const t = tools[i];
|
|
1234
1251
|
if (t.name.startsWith(r.prefix)) {
|
|
1235
|
-
|
|
1252
|
+
domainAnchor = i;
|
|
1236
1253
|
tools.splice(i, 1);
|
|
1237
1254
|
}
|
|
1238
1255
|
}
|
|
1239
|
-
|
|
1256
|
+
const refreshedMounts = pushable.map((t) => remoteToolOffload(t));
|
|
1257
|
+
if (domainAnchor >= 0)
|
|
1258
|
+
tools.splice(domainAnchor, 0, ...refreshedMounts);
|
|
1259
|
+
else
|
|
1260
|
+
tools.push(...refreshedMounts);
|
|
1240
1261
|
changed = true;
|
|
1241
1262
|
const detail = [];
|
|
1242
1263
|
const shownAdded = r.added.filter((n) => !excludedSet.has(n));
|
|
@@ -1247,7 +1268,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
1247
1268
|
if (excludedNow.length > 0)
|
|
1248
1269
|
detail.push(`excluded by deployment config (not mounted): ${excludedNow.join(", ")}`);
|
|
1249
1270
|
if ((r.dropped?.length ?? 0) > 0)
|
|
1250
|
-
detail.push(`dropped
|
|
1271
|
+
detail.push(`dropped: ${r.dropped.map((d) => `${d.tool} (${d.reason.length > 90 ? `${d.reason.slice(0, 90)}…` : d.reason})`).join("; ")}`);
|
|
1251
1272
|
lines.push(`${r.server}: refreshed — ${pushable.length} tool${pushable.length === 1 ? "" : "s"}${detail.length > 0 ? ` (${detail.join("; ")})` : ""}`);
|
|
1252
1273
|
}
|
|
1253
1274
|
if (changed)
|
|
@@ -3473,9 +3473,12 @@ export class Runner {
|
|
|
3473
3473
|
}
|
|
3474
3474
|
stats.cacheHitRate = Math.min(1, rawHit);
|
|
3475
3475
|
if (!rs.telemetry.cacheBreakReported && stats.turns >= 2 && stats.totalInputTokens >= 8000 && stats.cacheHitRate < 0.15) {
|
|
3476
|
+
const cacheWritten = stats.cacheWriteTokens + stats.cacheWriteTokensLong;
|
|
3476
3477
|
const cause = rs.degrade.degraded
|
|
3477
3478
|
? `a mid-task model switch (${rs.degrade.degraded.from} → ${rs.degrade.degraded.to}, degradation) reset the prefix cache — this is the likely cause`
|
|
3478
|
-
:
|
|
3479
|
+
: cacheWritten > 0
|
|
3480
|
+
? `this task reported ${cacheWritten} cache-write tokens across its calls (compaction included) but served almost none back as reads — consistent with a prompt prefix that CHANGES between turns (client-side: volatile content up front, or per-turn tool churn/reorder) and, less often, with server-side eviction. Keep volatile content (memory/timestamps/ids) out of the prefix and the tool list stable in membership AND order`
|
|
3481
|
+
: `no call in this task reported any cache-write tokens — and this API family may not report them at all (an openai-shaped usage row carries cached READS only), so the write side is no evidence here; check that the prompt prefix (system prompt + tool list, membership AND order) is byte-stable across turns and that this route caches this model`;
|
|
3479
3482
|
this.deps.onError?.(new Error(`prompt-cache: low prefix-cache hit rate ${(stats.cacheHitRate * 100).toFixed(0)}% over ${stats.turns} turns (${stats.totalInputTokens} prompt tokens) — ${cause}. See design/09.`), { phase: "prompt-cache", sessionId: prepared.sessionId });
|
|
3480
3483
|
}
|
|
3481
3484
|
}
|