@sema-agent/core 5.52.0 → 5.54.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 +99 -0
- package/dist/agents/subagent.d.ts +4 -0
- package/dist/agents/subagent.js +1 -1
- 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.d.ts +168 -5
- package/dist/core/mcp.js +200 -21
- package/dist/core/permission-rule-model.d.ts +140 -21
- package/dist/core/permission-rule-model.js +76 -17
- package/dist/core/permission-rule-org.d.ts +4 -3
- package/dist/core/permission-rule-org.js +12 -3
- 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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/workflow-types.d.ts +16 -6
- package/dist/orchestration/workflow-types.js +10 -4
- package/dist/orchestration/workflow.js +32 -6
- package/dist/stores/file/background-agent-store.js +1 -0
- package/dist/stores/file/checkpoint-store.d.ts +6 -2
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/fs-atomic.d.ts +151 -10
- package/dist/stores/file/fs-atomic.js +208 -32
- package/dist/stores/file/index.d.ts +26 -3
- package/dist/stores/file/index.js +25 -2
- package/dist/stores/file/shared-ledger.d.ts +40 -5
- package/dist/stores/file/shared-ledger.js +24 -8
- package/dist/stores/file/workflow-run-store.d.ts +8 -1
- package/dist/stores/file/workflow-run-store.js +1 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
- package/dist/tools/fs/bash-readonly-classifier.js +58 -47
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,104 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.54.0 — 2026-08-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Compound commands are adjudicated segment-wise** (#353 hemostat): the rule lane's whole-string
|
|
7
|
+
floor made every compound spelling invisible to it — an org/user **deny** on `curl` never fired on
|
|
8
|
+
`git status && curl evil.example` (fail-open past the deny into the classifier lane), suggestions
|
|
9
|
+
came back empty, and an exact compound rule could not be minted or matched. Now: any-segment deny
|
|
10
|
+
refuses the whole string before any allow path; whole-string exact allow admits; all-segments-allow
|
|
11
|
+
admits; anything else asks with a per-segment suggestion batch (capped at 5). A path-prefixed
|
|
12
|
+
argv0 (`/usr/bin/curl`) joins the rule lane as text. A build-tool allowlist was considered and
|
|
13
|
+
rejected (not a CC form).
|
|
14
|
+
- **MCP listings walk their pagination** (#381): tools/list and resources/list read only the FIRST
|
|
15
|
+
page — later tools silently never mounted and nothing disclosed it. All list sites now walk pages
|
|
16
|
+
through one bounded chokepoint (20-page cap; cursor-loop guard; a continuation needs a fundable
|
|
17
|
+
remainder — min(250ms, budget/10)); an unfinished walk is disclosed with a typed reason
|
|
18
|
+
(`cursor_invalid` | `page_error` | `cursor_loop` | `page_cap` | `budget_exhausted`) on the model
|
|
19
|
+
face, the operator warning lane (`mcp.listing_incomplete`), and `McpRefreshResult.listingIncomplete`;
|
|
20
|
+
a refresh that cannot prove an entry gone retains it. **Closed-set note**: `McpListingIncomplete.reason`
|
|
21
|
+
gains `budget_exhausted` (+ optional `budgetMs`) — a downstream exhaustive switch needs the case.
|
|
22
|
+
- **Ledger stores enforce single-writer-per-directory at construction** (#382): checkpoint /
|
|
23
|
+
background-agent / workflow-run each documented a boot lock they did not hold — two OS processes
|
|
24
|
+
could each win the SAME once-only approval, and one process's compaction could erase another's
|
|
25
|
+
fsync'd rows. The fence now lives with the authority (`shared-ledger.ts` bootstrap takes
|
|
26
|
+
`<dir>/LOCK`; refcount-joined in-process; crashed-owner locks pruned through a named claim gate);
|
|
27
|
+
a second OS process is refused at construction. New exports: `FileStoreLockError`,
|
|
28
|
+
`FileStoreLockErrorCode` (`store.dir_in_use` | `store.dir_claiming` | `store.lock_unreadable`).
|
|
29
|
+
- **The workflow budget gate re-judges after admission** (#383): the ceiling was checked only at
|
|
30
|
+
dispatch time against a pre-batch snapshot — a 40-item fan-out overshot 26x (serial dispatch
|
|
31
|
+
identically) and the refusal text blamed "a loop without a termination condition". The
|
|
32
|
+
authoritative check now runs after `sem.acquire()` against live spend; refused calls settle as
|
|
33
|
+
failed agent rows carrying `workflow.budget_exceeded`; a fully-cached resume is never gated;
|
|
34
|
+
bad budget values refuse loudly; budget and agent-cap refusals each name their own cause.
|
|
35
|
+
- **The workflow steer marker is unpredictable** (test P0-KPI #9): the workflow lane's steer
|
|
36
|
+
correlation marker was a monotone counter (`steer-1`…) — untrusted content could pre-claim the
|
|
37
|
+
tag and self-declare correlation, the hole the subagent steer seat had already closed
|
|
38
|
+
(design/122 r1-m3). Both seats now single-source the same 12-hex fragment; markers are
|
|
39
|
+
per-delivery distinct, ephemeral, never journaled.
|
|
40
|
+
- **Pre-release rescan dispositions (seven, fixed in-tree before publish)**: the compound-splitter
|
|
41
|
+
extraction was not byte-identical for the readonly classifier (`ls; 2>/dev/null` had flipped from
|
|
42
|
+
ask to auto-allow — restored); a near-exhausted MCP walk budget was spent as a real request and
|
|
43
|
+
its cut-off blamed on the server (now core-attributed, see #381 above); a stuck paginator's
|
|
44
|
+
re-served page was appended twice and the duplicate blamed on a namespacing collision (rewind of
|
|
45
|
+
provably byte-identical re-serves; drop reasons split); a failed `BootLock.release()` left a stale
|
|
46
|
+
holder able to delete its successor's live fence (release is one-shot again); a zero-ref fence
|
|
47
|
+
entry was joinable without consulting the disk (re-validated, foreign owners refused by name);
|
|
48
|
+
`FileWorkflowRunStore.close()`'s doc credited a backend factory that never constructs it;
|
|
49
|
+
`docs/ARCHITECTURE.md`'s lock coverage contradicted `docs/KNOWN-ISSUES.md`.
|
|
50
|
+
- Behavior narrowings (named): org/user denies now reach compound spellings (previously fail-open);
|
|
51
|
+
a second OS process over a ledger directory refuses at construction instead of corrupting;
|
|
52
|
+
`compactNow()` on a closed store throws; workflow budget bad values refuse at construction;
|
|
53
|
+
budget-refused calls appear as failed agent rows; a second `dispose()` after a failed fence
|
|
54
|
+
release removes nothing; an in-process re-open over an unreadable stranded lock refuses
|
|
55
|
+
(`store.lock_unreadable`); a stranded fence taken by another process refuses (`store.dir_in_use`);
|
|
56
|
+
a failed fence release warns (was silent); `ls; 2>/dev/null` asks again (restores the 5.53
|
|
57
|
+
contract).
|
|
58
|
+
|
|
59
|
+
## 5.53.0 — 2026-08-21
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
- **An MCP refresh no longer rewrites the cacheable tool prefix** (#375 root fix): `RefreshMcpTools`
|
|
63
|
+
re-inserted the server's tool domain at the TAIL of the mount array — the wire order — so a
|
|
64
|
+
byte-identical listing still displaced the whole domain past every later tool, a full provider
|
|
65
|
+
prefix-cache re-bill the model itself was told to trigger ("refresh when a listing looks stale").
|
|
66
|
+
The domain now re-enters at its anchor (the lowest index it occupied): an unchanged listing
|
|
67
|
+
produces a byte-identical array. First-time mounts still append.
|
|
68
|
+
- **Thinking replay survives strict gateways and non-streaming rewrites** (#374 adjacent):
|
|
69
|
+
the replay judge is shape-based (only a non-empty string counts as a signature — a JSON-roundtrip
|
|
70
|
+
`null`/number can no longer ride onto the wire), `signature_delta` refuses non-string coercion,
|
|
71
|
+
and an inlined `content_block_start` thinking block (the compat-gateway SSE-rewrite form) is now
|
|
72
|
+
fully captured — text and signature — instead of being dropped from every subsequent request.
|
|
73
|
+
- **The cache-break detector tells the truth about reorders** (#376): an order-preserving hash
|
|
74
|
+
now attributes a pure reorder to `tool-set` with its own wording (previously it fell through to
|
|
75
|
+
"server-or-ttl … not a client-side prefix bug" — actively misleading, and the exact shape #375
|
|
76
|
+
just fixed); the low-hit-rate summary discriminates "prefix keeps changing" from "this route
|
|
77
|
+
reports no caching" using cacheWrite facts instead of guessing.
|
|
78
|
+
- **The MCP refresh region is transactional and collision-gated** (#377): tool effect records are
|
|
79
|
+
reset before the fold (a refreshed read-only tool no longer silently degrades to write), a
|
|
80
|
+
rejected listing restores both non-monotone tables, two servers whose names normalize to the
|
|
81
|
+
same prefix refuse at connect (`config.mcp_server_name_collision`, both original spellings
|
|
82
|
+
named), and a single listing minting two identical tool names keeps the first with a disclosed
|
|
83
|
+
drop.
|
|
84
|
+
- Behavior narrowings (named): colliding server configs from "silent shared domain" to a loud
|
|
85
|
+
connect refusal; same-listing name collisions from "both mounted under one name" to
|
|
86
|
+
"first kept, drop disclosed".
|
|
87
|
+
|
|
88
|
+
- **Pre-release scan dispositions (three, fixed in-tree before publish)**: the protocol-name
|
|
89
|
+
reservation now covers ALIASES (a `__`-shaped caller alias refuses at prepare with the same loud
|
|
90
|
+
code — it was a dispatchable name sitting inside a server's prefix domain, which the refresh
|
|
91
|
+
region would classify and clear as remote); the refresh receipt states each drop's OWN reason
|
|
92
|
+
(a fixed "invalid schema" label lied about the collision lane); the A2A mount gains the same
|
|
93
|
+
normalized-prefix collision gate as MCP (`config.a2a_peer_name_collision`), making the
|
|
94
|
+
protocol-naming contract's engine-wide claim true.
|
|
95
|
+
- Behavior narrowing (named): a caller tool alias containing `__` from "accepted" to a loud
|
|
96
|
+
prepare refusal; colliding A2A peer names from "silent shared domain" to a connect refusal.
|
|
97
|
+
|
|
98
|
+
### Notes
|
|
99
|
+
- Residuals ticketed: #379 (open-responses presence judge, inline text-block sibling, two-phase
|
|
100
|
+
refresh observation). #378 resolved by the alias-reservation fix above.
|
|
101
|
+
|
|
3
102
|
## 5.52.0 — 2026-08-21
|
|
4
103
|
|
|
5
104
|
### Added
|
|
@@ -356,6 +356,10 @@ export interface SubagentSteerHandle {
|
|
|
356
356
|
*/
|
|
357
357
|
resume?: (content: string) => Promise<string>;
|
|
358
358
|
}
|
|
359
|
+
/** design/122 r1-m3 — UNPREDICTABLE correlation-marker fragment (uuid random tail, 12 hex chars). A marker
|
|
360
|
+
* must not be guessable by untrusted content — the old `steer-a<seq>` global counter let injected text
|
|
361
|
+
* pre-claim "[steer-aN]" and self-declare correlation; the same fix applies to the new resume markers. */
|
|
362
|
+
export declare function markerFragment(): string;
|
|
359
363
|
/**
|
|
360
364
|
* design/122 D2 — build the `resume` verb for one delegation (closure over the ledger + delegation runner —
|
|
361
365
|
* NEVER the parent tool ctx / spec builder, r1-m5). Validation order: TTL sweep (MAJOR-1) → disposed?
|
package/dist/agents/subagent.js
CHANGED
|
@@ -436,7 +436,7 @@ function createBgActivityBeat(parentToolCallId, emitTick, noteActivity) {
|
|
|
436
436
|
emitTick(starts);
|
|
437
437
|
};
|
|
438
438
|
}
|
|
439
|
-
function markerFragment() {
|
|
439
|
+
export function markerFragment() {
|
|
440
440
|
return uuidv7().replace(/-/g, "").slice(-12);
|
|
441
441
|
}
|
|
442
442
|
function createSteerHandle(stream, parentToolCallId, agentName, settled, retain) {
|
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.d.ts
CHANGED
|
@@ -85,6 +85,39 @@ export interface McpDroppedTool {
|
|
|
85
85
|
tool: string;
|
|
86
86
|
reason: string;
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The completeness verdict of a walked STANDARD MCP list method (`tools/list`, `resources/list` — see
|
|
90
|
+
* {@link walkMcpListPages}). ABSENT means the walk reached the end of pagination and the aggregated
|
|
91
|
+
* listing IS the server's full list. PRESENT means it is not, and the listing must not be presented —
|
|
92
|
+
* or DIFFED — as if it were: an entry missing from a truncated listing is unproven-absent, not
|
|
93
|
+
* withdrawn (that inference is exactly what made a paginating server's second-page tools read as
|
|
94
|
+
* `removed`).
|
|
95
|
+
* - `page_cap` — {@link MAX_MCP_LIST_PAGES} pages were walked with a cursor still pending;
|
|
96
|
+
* - `cursor_loop` — the server handed back a cursor already used in this walk (a paginator that does
|
|
97
|
+
* not advance); the walk stops rather than spinning to the cap re-appending the same page;
|
|
98
|
+
* - `cursor_invalid` — the server sent a `nextCursor` that is not a string (`null` excepted: that is
|
|
99
|
+
* JSON's absent optional). It cannot be followed and it is not proof of the end, so the walk stops
|
|
100
|
+
* and says so instead of certifying a short listing as complete;
|
|
101
|
+
* - `page_error` — a CONTINUATION page failed. The pages already retrieved are kept and the failure
|
|
102
|
+
* text carried. A FIRST-page failure is never this: it still throws (a caller's "no listing at all"
|
|
103
|
+
* must not degrade into "an empty listing");
|
|
104
|
+
* - `budget_exhausted` — CORE stopped the walk: the time this listing is allowed to spend
|
|
105
|
+
* ({@link walkMcpListPages}'s `budgetMs`) had too little left to fund another request
|
|
106
|
+
* ({@link MIN_MCP_PAGE_BUDGET_MS}), so none was sent. Distinct from `page_error` on purpose — the
|
|
107
|
+
* server neither failed nor was asked. Issuing that request anyway would hand the SDK a
|
|
108
|
+
* near-zero timeout, and its -32001 would arrive here wearing the server's failure clothes.
|
|
109
|
+
* `error` is SERVER-authored text — neutralize it before it rides any model/operator face.
|
|
110
|
+
*/
|
|
111
|
+
export interface McpListingIncomplete {
|
|
112
|
+
reason: "page_cap" | "cursor_loop" | "cursor_invalid" | "page_error" | "budget_exhausted";
|
|
113
|
+
/** Pages actually retrieved before the walk stopped (≥ 1 — a zero-page walk throws instead). */
|
|
114
|
+
pages: number;
|
|
115
|
+
error?: string;
|
|
116
|
+
/** The walk's total time budget in ms. Present ONLY on `budget_exhausted`, where it is the whole
|
|
117
|
+
* actionable content of the verdict (which bound to raise); every other reason leaves it absent
|
|
118
|
+
* rather than reporting a budget that had nothing to do with the stop. */
|
|
119
|
+
budgetMs?: number;
|
|
120
|
+
}
|
|
88
121
|
/** Tools materialized from one or more MCP servers, plus a disposer to disconnect them. */
|
|
89
122
|
export interface MaterializedMcp {
|
|
90
123
|
tools: AgentTool[];
|
|
@@ -125,10 +158,17 @@ export interface MaterializedMcp {
|
|
|
125
158
|
pendingRemovals: string[];
|
|
126
159
|
};
|
|
127
160
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
161
|
+
* The OPERATOR warning lane for this materialization (the Runner forwards each entry to
|
|
162
|
+
* `onError(phase:"mcp")`). Empty when every server connected and listed completely. Two populations,
|
|
163
|
+
* distinguishable by the `code` each Error carries — a consumer must NOT read mere presence as "this
|
|
164
|
+
* server was skipped":
|
|
165
|
+
* - `mcp.server_unavailable` — a server that failed to connect / list its tools and was SKIPPED
|
|
166
|
+
* (fail-open, design/29): the task proceeds with the healthy servers' tools, because a single bad
|
|
167
|
+
* server (missing stdio command, unreachable URL) must never brick every task in the scenario;
|
|
168
|
+
* - `mcp.listing_incomplete` — a server that DID connect but whose `tools/list` pagination walk did
|
|
169
|
+
* not reach the end ({@link McpListingIncomplete}): its tools past the stopping point are not
|
|
170
|
+
* mounted this task, which is a capability loss the operator must see rather than infer from a
|
|
171
|
+
* short tool list.
|
|
132
172
|
*/
|
|
133
173
|
warnings: Error[];
|
|
134
174
|
/**
|
|
@@ -186,7 +226,13 @@ export interface MaterializedMcp {
|
|
|
186
226
|
dispose: () => Promise<void>;
|
|
187
227
|
}
|
|
188
228
|
/** [1605] One per-server entry of {@link MaterializedMcp.refresh}. `added`/`removed` are namespaced
|
|
189
|
-
* (`mcp__<server>__<tool>`) names; `tools`/`axes`/`dropped` are present only on `"refreshed"`.
|
|
229
|
+
* (`mcp__<server>__<tool>`) names; `tools`/`axes`/`dropped` are present only on `"refreshed"`.
|
|
230
|
+
* The model-facing refresh receipt renders each `dropped` entry with ITS OWN `reason` (schema
|
|
231
|
+
* gate, same-listing name collision, …) — never a blanket label; a consumer matching that receipt
|
|
232
|
+
* text should key on the tool name, not on any fixed reason wording. Note the startup-time
|
|
233
|
+
* sibling: two DECLARED servers whose names normalize to one prefix refuse the whole mount at
|
|
234
|
+
* connect (`config.mcp_server_name_collision`, both original spellings named) — a deployment
|
|
235
|
+
* carrying such a pair learns at the first materialize, with the rename spelled out in the error. */
|
|
190
236
|
export interface McpRefreshResult {
|
|
191
237
|
server: string;
|
|
192
238
|
/** The server's namespaced-name prefix (`mcp__<normalized-server>__`) — the consumer's SPLICE
|
|
@@ -199,11 +245,37 @@ export interface McpRefreshResult {
|
|
|
199
245
|
status: "refreshed" | "not_connected" | "failed" | "revoked";
|
|
200
246
|
toolCount: number;
|
|
201
247
|
added: string[];
|
|
248
|
+
/**
|
|
249
|
+
* Names that were mounted before this refresh and are no longer in the roster it returns — i.e. an
|
|
250
|
+
* assertion that the server withdrew them. It is therefore EMPTY whenever {@link listingIncomplete}
|
|
251
|
+
* is present, and empty as a FACT rather than a policy: an incomplete walk cannot prove absence, so
|
|
252
|
+
* it retains what it could not prove gone (see `listingIncomplete`) and nothing leaves. `added` is
|
|
253
|
+
* unaffected — a name that IS in the partial listing was really listed, whether or not the walk
|
|
254
|
+
* finished (#381).
|
|
255
|
+
*/
|
|
202
256
|
removed: string[];
|
|
203
257
|
tools?: AgentTool[];
|
|
204
258
|
axes?: McpToolAxis[];
|
|
205
259
|
dropped?: McpDroppedTool[];
|
|
260
|
+
/**
|
|
261
|
+
* Set on `not_connected` / `failed` / `revoked`; ALSO set on a `"refreshed"` entry whose listing walk
|
|
262
|
+
* did not finish (see {@link listingIncomplete}) — a refresh that produced usable tools is not a
|
|
263
|
+
* failure, so the status stays `refreshed` and the incompleteness rides here.
|
|
264
|
+
*/
|
|
206
265
|
error?: string;
|
|
266
|
+
/**
|
|
267
|
+
* #381 — present when this entry's `tools/list` walk did NOT reach the end of pagination. `tools` is
|
|
268
|
+
* then a MERGE: everything the partial listing did return, plus the previously-mounted entries whose
|
|
269
|
+
* absence it cannot prove (so the consumer's replace-the-whole-prefix swap withdraws nothing, and
|
|
270
|
+
* `removed: []` describes a roster that really lost nothing). Absent on a complete walk and on every
|
|
271
|
+
* non-`refreshed` status.
|
|
272
|
+
*
|
|
273
|
+
* RESIDUAL, honest: the model-facing `RefreshMcpTools` receipt renders a refreshed entry's line from
|
|
274
|
+
* added/removed/excluded/dropped only, so this fact reaches the STRUCTURED face (`details.results`)
|
|
275
|
+
* and the operator, not the receipt text — rendering it there is a prepare-task change, filed rather
|
|
276
|
+
* than smuggled in here.
|
|
277
|
+
*/
|
|
278
|
+
listingIncomplete?: McpListingIncomplete;
|
|
207
279
|
}
|
|
208
280
|
/** design/99 §E9 — projected per-server MCP status (see {@link MaterializedMcp.statuses}). NOTE: `serverInfo`
|
|
209
281
|
* and `error` are SERVER-controlled strings (verbatim from the remote) — UNTRUSTED; a consumer rendering them
|
|
@@ -490,6 +562,97 @@ export declare function applyCallerAxisOverride(name: string, hint: McpToolAxis
|
|
|
490
562
|
* for the discrimination nails; the message is SERVER-authored and is only ever classified here, never
|
|
491
563
|
* trusted as instructions. */
|
|
492
564
|
export declare function classifyDirReadInvalidParams(message: string): "not_found" | "not_directory";
|
|
565
|
+
/**
|
|
566
|
+
* The content key {@link walkMcpListPages}'s cursor-loop arm uses to recognize an entry it already
|
|
567
|
+
* collected: the entry's own JSON, which is what the server sent and what a re-served page repeats
|
|
568
|
+
* verbatim. `undefined` for anything unserializable — an entry that cannot be keyed is never treated as
|
|
569
|
+
* a repeat, so the failure direction is "kept, and disclosed by the lane that judges it" rather than
|
|
570
|
+
* "silently gone".
|
|
571
|
+
*
|
|
572
|
+
* NOT a semantic identity: two spellings of the same entry (different key order, a description the
|
|
573
|
+
* server re-rendered) read as different entries here and both survive. That is the safe direction —
|
|
574
|
+
* this key only ever authorizes a DELETION, so it must be exact and never clever.
|
|
575
|
+
*
|
|
576
|
+
* Exported for the walker's own unit pins (same reason {@link walkMcpListPages} is): its bound is a
|
|
577
|
+
* behavior, and a pin that re-declared the bound locally would pass while the real one drifted.
|
|
578
|
+
*/
|
|
579
|
+
export declare function listEntryFingerprint(entry: unknown): string | undefined;
|
|
580
|
+
/**
|
|
581
|
+
* #381 — walk a paginated MCP list method to the END (`{cursor?}` → `{items, nextCursor?}`), bounded.
|
|
582
|
+
*
|
|
583
|
+
* Every spec-standard MCP list method is paginated the same way: absence of `nextCursor` means "that
|
|
584
|
+
* was the last page". A single request therefore returns the WHOLE list only for servers that choose
|
|
585
|
+
* not to paginate — and a server may start paginating at any time, with no protocol change and no
|
|
586
|
+
* malice, the moment its list outgrows its own page size. Treating page one as the complete list is
|
|
587
|
+
* both a capability loss (the rest is never mounted) and, at any call site that DIFFS two listings, an
|
|
588
|
+
* active falsehood about the server's inventory.
|
|
589
|
+
*
|
|
590
|
+
* Contract:
|
|
591
|
+
* - page ONE is fetched with the caller's own historic parameters (`cursor === undefined`), so a
|
|
592
|
+
* non-paginating server's wire traffic is byte-identical to before;
|
|
593
|
+
* - a FIRST-page failure propagates unchanged — every caller's failure shape (skipped server,
|
|
594
|
+
* `failed` refresh, per-server attributed aggregate error) is built on that throw;
|
|
595
|
+
* - a CONTINUATION failure keeps the pages already retrieved and reports {@link McpListingIncomplete}
|
|
596
|
+
* (`page_error`) — fail-open WITH disclosure, the same law {@link readDirViaExtension} follows —
|
|
597
|
+
* except an ABORT, which still propagates (a cancelled walk is not a short listing);
|
|
598
|
+
* - a repeated cursor stops the walk (`cursor_loop`) instead of spinning to the cap, AND undoes the
|
|
599
|
+
* entries that page re-served (a paginator that does not advance is typically one answering with a
|
|
600
|
+
* page already collected, and keeping it is how one entry gets listed twice) — judged by
|
|
601
|
+
* `identityOf` where the caller can key its entries, whole-page otherwise; an unreadable cursor
|
|
602
|
+
* stops the walk as `cursor_invalid` rather than passing for the end of the listing;
|
|
603
|
+
* - {@link MAX_MCP_LIST_PAGES} pages with a cursor still pending stops it too (`page_cap`);
|
|
604
|
+
* - a remainder too short to fund a real request stops it as `budget_exhausted` — core's own doing,
|
|
605
|
+
* named as such (see {@link MIN_MCP_PAGE_BUDGET_MS}).
|
|
606
|
+
* SIZE, stated rather than assumed: a single page has never been bounded here (a server could always
|
|
607
|
+
* answer with an arbitrarily long list), and walking multiplies that existing exposure by at most the
|
|
608
|
+
* page cap — a bounded multiplier, not a new unbounded surface. Per-entry admission stays where it
|
|
609
|
+
* already is (the intake schema gate, which also drops a name a later page repeats).
|
|
610
|
+
*
|
|
611
|
+
* TIME, the reason `budgetMs` exists (codex review, confirmed): the page cap bounds REQUESTS, not
|
|
612
|
+
* latency. Giving every page its own full request timeout would let a server that stalls each page hold
|
|
613
|
+
* a connect (or a refresh) for up to twenty timeouts — twenty minutes at the SDK's 60s default, where
|
|
614
|
+
* an unpaginated listing cost one. So the WALK gets the budget a single listing used to have: page one
|
|
615
|
+
* is issued with the full amount (byte-identical to the pre-walk request) and each continuation gets
|
|
616
|
+
* only what is left. ACCEPTED COST, stated: a genuinely slow server whose pages together outlast that
|
|
617
|
+
* bound is truncated where a single-request listing would not have been — but that truncation is
|
|
618
|
+
* DISCLOSED, while the alternative is an undisclosed twenty-fold latency ceiling on the task's startup
|
|
619
|
+
* path. The bound is the caller's own configured request timeout, not a new knob, so a deployment that
|
|
620
|
+
* needs more already has the dial. `fetchPage` receives the remaining budget and MUST spend it as that
|
|
621
|
+
* request's timeout — the deadline is cooperative, since only the caller can pass a timeout to its own
|
|
622
|
+
* transport — and must REPORT that same number if the request then times out: a page cut off at 3ms
|
|
623
|
+
* whose failure text names the whole listing budget describes a wait that never happened.
|
|
624
|
+
*
|
|
625
|
+
* NOT merged with {@link readDirViaExtension}, which walks the same way but owns CC-anchored
|
|
626
|
+
* first-page `InvalidParams` discrimination (not-a-directory vs not-found) and its own three-state
|
|
627
|
+
* flags; folding it in here would put that CC semantics into the standard-method path where it has no
|
|
628
|
+
* meaning.
|
|
629
|
+
*
|
|
630
|
+
* Exported for the walker's own unit pins — its arms are failure/deadline states that no fixture server
|
|
631
|
+
* can drive deterministically. Every production consumer goes through the three call sites in this file.
|
|
632
|
+
*/
|
|
633
|
+
export declare function walkMcpListPages<T>(fetchPage: (cursor: string | undefined, remainingMs: number) => Promise<{
|
|
634
|
+
items: T[];
|
|
635
|
+
nextCursor?: string;
|
|
636
|
+
cursorInvalid?: boolean;
|
|
637
|
+
}>, opts: {
|
|
638
|
+
budgetMs: number;
|
|
639
|
+
onPage?: () => void;
|
|
640
|
+
signal?: AbortSignal;
|
|
641
|
+
/** A key for one entry's CONTENT — equal keys mean the same listing entry, served again.
|
|
642
|
+
* `undefined` for an entry the caller cannot key. Used ONLY by the cursor-loop arm, to tell a
|
|
643
|
+
* re-served page from a page that merely arrived with a broken cursor; absent ⇒ that arm cannot
|
|
644
|
+
* tell them apart and falls back to dropping the whole page (see it for the trade).
|
|
645
|
+
*
|
|
646
|
+
* CONTENT, not name: an entry's name is what the CALLERS' admission gates key on, and they judge
|
|
647
|
+
* each entry on its own (a tool whose schema they refuse is dropped, and a later entry of the same
|
|
648
|
+
* name is then free to mount). Keying this on the name would let this walk delete the second
|
|
649
|
+
* entry before any of that ran — the only admissible definition of a tool, removed on the theory
|
|
650
|
+
* that a name cannot appear twice, which is a rule the layer below owns and applies differently. */
|
|
651
|
+
fingerprintOf?: (item: T) => string | undefined;
|
|
652
|
+
}): Promise<{
|
|
653
|
+
items: T[];
|
|
654
|
+
incomplete?: McpListingIncomplete;
|
|
655
|
+
}>;
|
|
493
656
|
/**
|
|
494
657
|
* RB-408 G12 — `tools/call` result parse that accepts the MCP `2026-07-28` widening of
|
|
495
658
|
* `structuredContent` from "a JSON object" to ANY JSON value (the same revision that relaxes
|