@peakflo/agent-harness 0.2.13 → 0.2.14-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/coding-agent-adapter.d.ts +19 -0
- package/dist/adapters/pi-adapter.d.ts +27 -1
- package/dist/adapters/pi-adapter.js +170 -6
- package/dist/adapters/pi-mcp-cache-seed.d.ts +66 -0
- package/dist/adapters/pi-mcp-cache-seed.js +188 -0
- package/dist/contract.d.ts +1 -1
- package/dist/contract.js +1 -1
- package/dist/control-plane.d.ts +9 -2
- package/dist/control-plane.js +11 -3
- package/dist/harness.d.ts +3 -0
- package/dist/harness.js +28 -7
- package/dist/runtime/cold-start-batch.d.ts +36 -0
- package/dist/runtime/cold-start-batch.js +96 -0
- package/dist/runtime/mcp-preflight.d.ts +15 -1
- package/dist/runtime/mcp-preflight.js +54 -2
- package/dist/runtime/mcp-tool-store.d.ts +79 -0
- package/dist/runtime/mcp-tool-store.js +151 -0
- package/dist/runtime/mcp-toolset.d.ts +51 -0
- package/dist/runtime/mcp-toolset.js +40 -0
- package/dist/runtime/session-runner.d.ts +25 -0
- package/dist/runtime/session-runner.js +133 -9
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
package/dist/control-plane.js
CHANGED
|
@@ -114,15 +114,23 @@ class ControlPlaneClient {
|
|
|
114
114
|
getRunnerId() {
|
|
115
115
|
return this.runnerId;
|
|
116
116
|
}
|
|
117
|
-
/**
|
|
118
|
-
|
|
117
|
+
/**
|
|
118
|
+
* All cold-start timing events of one turn in ONE request. Diagnostics
|
|
119
|
+
* must never delay a turn; the caller handles any failure.
|
|
120
|
+
*
|
|
121
|
+
* This replaced one POST per step. The control plane accepts both shapes,
|
|
122
|
+
* so an older harness on an existing sandbox keeps working.
|
|
123
|
+
*/
|
|
124
|
+
async reportColdStartBatch(body) {
|
|
119
125
|
if (!this.runnerId || !this.runnerCredential)
|
|
120
126
|
return;
|
|
127
|
+
if (body.events.length === 0)
|
|
128
|
+
return;
|
|
121
129
|
await this.send({
|
|
122
130
|
method: 'POST',
|
|
123
131
|
path: contract_1.ROUTES.coldStart(this.runnerId),
|
|
124
132
|
token: this.runnerCredential,
|
|
125
|
-
body
|
|
133
|
+
body,
|
|
126
134
|
timeoutMs: 2_000
|
|
127
135
|
});
|
|
128
136
|
}
|
package/dist/harness.d.ts
CHANGED
|
@@ -249,6 +249,8 @@ export declare class Harness {
|
|
|
249
249
|
private readonly activeLeaseIds;
|
|
250
250
|
/** Renewal workers that must receive a credential rotation in flight. */
|
|
251
251
|
private readonly activeRenewals;
|
|
252
|
+
/** Cold-start timing buffered per running turn; sent once at turn end. */
|
|
253
|
+
private readonly coldStartBatches;
|
|
252
254
|
/** commandIds already applied. `SessionCommand` is idempotent by contract. */
|
|
253
255
|
private readonly appliedCommands;
|
|
254
256
|
/** Prune lease-bound command IDs when their session turn ends. */
|
|
@@ -473,6 +475,7 @@ export declare class Harness {
|
|
|
473
475
|
private observeSandboxToolsPin;
|
|
474
476
|
/** Stop once. A failed install is then the supervisor's terminal decision. */
|
|
475
477
|
private requestUpgrade;
|
|
478
|
+
private newColdStartBatch;
|
|
476
479
|
/**
|
|
477
480
|
* Run one leased turn.
|
|
478
481
|
*
|
package/dist/harness.js
CHANGED
|
@@ -18,6 +18,7 @@ const health_1 = require("./health");
|
|
|
18
18
|
const model_probe_1 = require("./model-probe");
|
|
19
19
|
const ports_default_1 = require("./ports-default");
|
|
20
20
|
const browser_takeover_1 = require("./runtime/browser-takeover");
|
|
21
|
+
const cold_start_batch_1 = require("./runtime/cold-start-batch");
|
|
21
22
|
const knowledge_tools_1 = require("./runtime/knowledge-tools");
|
|
22
23
|
const lease_renewal_1 = require("./runtime/lease-renewal");
|
|
23
24
|
const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
|
|
@@ -200,7 +201,9 @@ class Harness {
|
|
|
200
201
|
mcpCheckState = {
|
|
201
202
|
checked: new Map(),
|
|
202
203
|
failed: new Map(),
|
|
203
|
-
shownNotices: new Set()
|
|
204
|
+
shownNotices: new Set(),
|
|
205
|
+
// Item 1(b): per-session MCP tool listings, reused on warm turns.
|
|
206
|
+
toolsets: new Map()
|
|
204
207
|
};
|
|
205
208
|
startedAt = Date.now();
|
|
206
209
|
status = 'starting';
|
|
@@ -265,6 +268,8 @@ class Harness {
|
|
|
265
268
|
activeLeaseIds = new Map();
|
|
266
269
|
/** Renewal workers that must receive a credential rotation in flight. */
|
|
267
270
|
activeRenewals = new Set();
|
|
271
|
+
/** Cold-start timing buffered per running turn; sent once at turn end. */
|
|
272
|
+
coldStartBatches = new Set();
|
|
268
273
|
/** commandIds already applied. `SessionCommand` is idempotent by contract. */
|
|
269
274
|
appliedCommands = new Set();
|
|
270
275
|
/** Prune lease-bound command IDs when their session turn ends. */
|
|
@@ -515,6 +520,11 @@ class Harness {
|
|
|
515
520
|
for (const controller of this.active.values()) {
|
|
516
521
|
controller.abort();
|
|
517
522
|
}
|
|
523
|
+
// Send buffered turn timing while the credential is still valid.
|
|
524
|
+
// Deregistration below revokes it. Each send has a 2 s timeout.
|
|
525
|
+
const coldStarts = Array.from(this.coldStartBatches);
|
|
526
|
+
this.coldStartBatches.clear();
|
|
527
|
+
await Promise.all(coldStarts.map((batch) => batch.flush()));
|
|
518
528
|
/*
|
|
519
529
|
* A DURABLE RUNNER DOES NOT DEREGISTER.
|
|
520
530
|
*
|
|
@@ -1565,7 +1575,9 @@ class Harness {
|
|
|
1565
1575
|
durationMs: Date.now() - pollStartedAt
|
|
1566
1576
|
});
|
|
1567
1577
|
log(leaseReceived);
|
|
1568
|
-
|
|
1578
|
+
// Buffered, not sent: the whole turn's timing goes out as ONE request.
|
|
1579
|
+
const coldStart = this.newColdStartBatch(lease);
|
|
1580
|
+
coldStart.add(leaseReceived);
|
|
1569
1581
|
this.observeHarnessPin(lease.harnessVersion);
|
|
1570
1582
|
this.observeSandboxToolsPin(lease.sandboxToolsVersion);
|
|
1571
1583
|
// Protocol 3 uses a short visibility window until the runner confirms
|
|
@@ -1593,10 +1605,12 @@ class Harness {
|
|
|
1593
1605
|
}
|
|
1594
1606
|
}
|
|
1595
1607
|
if (!leaseAcknowledged) {
|
|
1608
|
+
this.coldStartBatches.delete(coldStart);
|
|
1609
|
+
void coldStart.flush();
|
|
1596
1610
|
await sleep(LEASE_ERROR_BACKOFF_MS);
|
|
1597
1611
|
continue;
|
|
1598
1612
|
}
|
|
1599
|
-
void this.runLeasedTurn(lease);
|
|
1613
|
+
void this.runLeasedTurn(lease, coldStart);
|
|
1600
1614
|
}
|
|
1601
1615
|
await this.stop();
|
|
1602
1616
|
}
|
|
@@ -1680,6 +1694,11 @@ class Harness {
|
|
|
1680
1694
|
this.fatal = new UpgradeRequestedError(harnessVersion);
|
|
1681
1695
|
await this.stop({ preserveRegistration: true });
|
|
1682
1696
|
}
|
|
1697
|
+
newColdStartBatch(lease) {
|
|
1698
|
+
const batch = new cold_start_batch_1.ColdStartBatch(lease.session.sessionId, lease.session.turnId, (body) => this.client.reportColdStartBatch(body));
|
|
1699
|
+
this.coldStartBatches.add(batch);
|
|
1700
|
+
return batch;
|
|
1701
|
+
}
|
|
1683
1702
|
/**
|
|
1684
1703
|
* Run one leased turn.
|
|
1685
1704
|
*
|
|
@@ -1688,7 +1707,7 @@ class Harness {
|
|
|
1688
1707
|
* throws must not end the lease loop, or one bad session would take the whole
|
|
1689
1708
|
* runner offline.
|
|
1690
1709
|
*/
|
|
1691
|
-
async runLeasedTurn(lease) {
|
|
1710
|
+
async runLeasedTurn(lease, coldStart = this.newColdStartBatch(lease)) {
|
|
1692
1711
|
const { sessionId } = lease.session;
|
|
1693
1712
|
// The exact loopback-proxy targets this session will reach. Keyed on these
|
|
1694
1713
|
// urls, this session's MCP token cannot be overwritten or revoked by a
|
|
@@ -1852,9 +1871,7 @@ class Harness {
|
|
|
1852
1871
|
secrets: this.secrets,
|
|
1853
1872
|
events: this.events,
|
|
1854
1873
|
telemetry: this.telemetry,
|
|
1855
|
-
coldStartLog: (line) =>
|
|
1856
|
-
void this.client.reportColdStart(line).catch(() => undefined);
|
|
1857
|
-
},
|
|
1874
|
+
coldStartLog: (line) => coldStart.add(line),
|
|
1858
1875
|
notifier: this.notifier,
|
|
1859
1876
|
artifacts: this.artifacts,
|
|
1860
1877
|
conversation: this.conversation,
|
|
@@ -1938,6 +1955,10 @@ class Harness {
|
|
|
1938
1955
|
finally {
|
|
1939
1956
|
renewal.stop();
|
|
1940
1957
|
this.activeRenewals.delete(renewal);
|
|
1958
|
+
// ONE telemetry request for the whole turn, sent before the git
|
|
1959
|
+
// preservation below so slow pushes do not delay it. Fire-and-forget.
|
|
1960
|
+
this.coldStartBatches.delete(coldStart);
|
|
1961
|
+
void coldStart.flush();
|
|
1941
1962
|
let workProtected = true;
|
|
1942
1963
|
if ((this.options.autoCommitDirtyWorktree ??
|
|
1943
1964
|
this.options.runtime === 'sandbox') &&
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One cold-start telemetry POST per turn instead of one per step.
|
|
3
|
+
*
|
|
4
|
+
* `ColdStartSteps` and the Pi adapter emit 13–15 timing lines per turn. Each
|
|
5
|
+
* used to become its own `POST /runners/:id/cold-start` (auth + turn lookup on
|
|
6
|
+
* the server every time). This buffer collects the lines for ONE turn and the
|
|
7
|
+
* harness sends them as a single batch when the turn ends, or when the
|
|
8
|
+
* harness stops. It is diagnostics only: it never throws, never blocks a turn
|
|
9
|
+
* and never retries.
|
|
10
|
+
*/
|
|
11
|
+
/** Events the control plane records. Anything else stays in the local log. */
|
|
12
|
+
export declare const COLD_START_EVENTS: ReadonlySet<string>;
|
|
13
|
+
/** Mirrors the server limit in `services/coding-agents/cold-start-telemetry.ts`. */
|
|
14
|
+
export declare const COLD_START_BATCH_MAX_EVENTS = 64;
|
|
15
|
+
export interface ColdStartBatchBody {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
turnId: string;
|
|
18
|
+
events: Array<Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
export declare class ColdStartBatch {
|
|
21
|
+
readonly sessionId: string;
|
|
22
|
+
readonly turnId: string;
|
|
23
|
+
private readonly send;
|
|
24
|
+
/** Lines that arrive after the turn-end flush are coalesced this long. */
|
|
25
|
+
private readonly lateFlushMs;
|
|
26
|
+
private events;
|
|
27
|
+
private flushed;
|
|
28
|
+
private lateTimer;
|
|
29
|
+
constructor(sessionId: string, turnId: string, send: (body: ColdStartBatchBody) => Promise<void>,
|
|
30
|
+
/** Lines that arrive after the turn-end flush are coalesced this long. */
|
|
31
|
+
lateFlushMs?: number);
|
|
32
|
+
/** Buffer one JSON line from `ColdStartSteps`. Never throws. */
|
|
33
|
+
add(line: string): void;
|
|
34
|
+
/** Send everything buffered as ONE request. Resolves; never rejects. */
|
|
35
|
+
flush(): Promise<void>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* One cold-start telemetry POST per turn instead of one per step.
|
|
4
|
+
*
|
|
5
|
+
* `ColdStartSteps` and the Pi adapter emit 13–15 timing lines per turn. Each
|
|
6
|
+
* used to become its own `POST /runners/:id/cold-start` (auth + turn lookup on
|
|
7
|
+
* the server every time). This buffer collects the lines for ONE turn and the
|
|
8
|
+
* harness sends them as a single batch when the turn ends, or when the
|
|
9
|
+
* harness stops. It is diagnostics only: it never throws, never blocks a turn
|
|
10
|
+
* and never retries.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.ColdStartBatch = exports.COLD_START_BATCH_MAX_EVENTS = exports.COLD_START_EVENTS = void 0;
|
|
14
|
+
/** Events the control plane records. Anything else stays in the local log. */
|
|
15
|
+
exports.COLD_START_EVENTS = new Set([
|
|
16
|
+
'cold_start_step',
|
|
17
|
+
'cold_start_summary',
|
|
18
|
+
'model_catalogue_deferred'
|
|
19
|
+
]);
|
|
20
|
+
/** Mirrors the server limit in `services/coding-agents/cold-start-telemetry.ts`. */
|
|
21
|
+
exports.COLD_START_BATCH_MAX_EVENTS = 64;
|
|
22
|
+
class ColdStartBatch {
|
|
23
|
+
sessionId;
|
|
24
|
+
turnId;
|
|
25
|
+
send;
|
|
26
|
+
lateFlushMs;
|
|
27
|
+
events = [];
|
|
28
|
+
flushed = false;
|
|
29
|
+
lateTimer = null;
|
|
30
|
+
constructor(sessionId, turnId, send,
|
|
31
|
+
/** Lines that arrive after the turn-end flush are coalesced this long. */
|
|
32
|
+
lateFlushMs = 1_000) {
|
|
33
|
+
this.sessionId = sessionId;
|
|
34
|
+
this.turnId = turnId;
|
|
35
|
+
this.send = send;
|
|
36
|
+
this.lateFlushMs = lateFlushMs;
|
|
37
|
+
}
|
|
38
|
+
/** Buffer one JSON line from `ColdStartSteps`. Never throws. */
|
|
39
|
+
add(line) {
|
|
40
|
+
try {
|
|
41
|
+
const event = JSON.parse(line);
|
|
42
|
+
if (!event || typeof event !== 'object' || Array.isArray(event))
|
|
43
|
+
return;
|
|
44
|
+
const record = event;
|
|
45
|
+
if (typeof record.event !== 'string' ||
|
|
46
|
+
!exports.COLD_START_EVENTS.has(record.event) ||
|
|
47
|
+
record.sessionId !== this.sessionId ||
|
|
48
|
+
record.turnId !== this.turnId) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// The batch carries the ids once; per-event copies are redundant bytes.
|
|
52
|
+
const rest = { ...record };
|
|
53
|
+
delete rest.sessionId;
|
|
54
|
+
delete rest.turnId;
|
|
55
|
+
this.events.push(rest);
|
|
56
|
+
if (this.events.length >= exports.COLD_START_BATCH_MAX_EVENTS) {
|
|
57
|
+
void this.flush();
|
|
58
|
+
}
|
|
59
|
+
else if (this.flushed && !this.lateTimer) {
|
|
60
|
+
// A step that ends after the turn-end flush (rare) is still sent,
|
|
61
|
+
// coalesced with any others that follow it.
|
|
62
|
+
this.lateTimer = setTimeout(() => {
|
|
63
|
+
this.lateTimer = null;
|
|
64
|
+
void this.flush();
|
|
65
|
+
}, this.lateFlushMs);
|
|
66
|
+
this.lateTimer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// A diagnostic sink must never stop an agent turn.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Send everything buffered as ONE request. Resolves; never rejects. */
|
|
74
|
+
flush() {
|
|
75
|
+
this.flushed = true;
|
|
76
|
+
if (this.lateTimer) {
|
|
77
|
+
clearTimeout(this.lateTimer);
|
|
78
|
+
this.lateTimer = null;
|
|
79
|
+
}
|
|
80
|
+
if (this.events.length === 0)
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
const events = this.events;
|
|
83
|
+
this.events = [];
|
|
84
|
+
try {
|
|
85
|
+
return this.send({
|
|
86
|
+
sessionId: this.sessionId,
|
|
87
|
+
turnId: this.turnId,
|
|
88
|
+
events
|
|
89
|
+
}).catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.ColdStartBatch = ColdStartBatch;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { McpServerToolListing } from './mcp-toolset';
|
|
1
2
|
export interface UnavailableMcpServer {
|
|
2
3
|
name: string;
|
|
3
4
|
reason: string;
|
|
@@ -14,8 +15,21 @@ export interface FailedMcpCheck {
|
|
|
14
15
|
* Check leased HTTP servers and use the caller's bounded, shared cache.
|
|
15
16
|
* A successful check is only a startup hint. The gateway checks live access
|
|
16
17
|
* on each tool call, even when a cached success skips this probe.
|
|
18
|
+
*
|
|
19
|
+
* When `listings` is given, a server that was probed keeps its `tools/list`
|
|
20
|
+
* result there (all pages: the client aggregates them when no cursor is
|
|
21
|
+
* passed), plus `resources/list` when the server advertises resources. The
|
|
22
|
+
* Pi backend seeds its MCP metadata cache from it, so Pi does not repeat the
|
|
23
|
+
* handshake on this turn. A server skipped by the success cache, or whose
|
|
24
|
+
* resource listing failed, has no listing.
|
|
17
25
|
*/
|
|
18
26
|
export declare function verifyMcpServers(servers: Record<string, {
|
|
19
27
|
type: string;
|
|
20
28
|
url?: string;
|
|
21
|
-
}>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck
|
|
29
|
+
}>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck>, listings?: Map<string, McpServerToolListing>,
|
|
30
|
+
/**
|
|
31
|
+
* Servers to probe and list even inside the success-cache window: their
|
|
32
|
+
* stored listing is no longer valid (item 1(b)), so without a fresh one Pi
|
|
33
|
+
* would fall back to its own eager handshake.
|
|
34
|
+
*/
|
|
35
|
+
relist?: ReadonlySet<string>): Promise<UnavailableMcpServer[]>;
|
|
@@ -16,12 +16,45 @@ function safeHttpStatus(cause) {
|
|
|
16
16
|
const match = String(cause).match(/^(?:Error:\s*)?HTTP\s+([45]\d\d)\b/i);
|
|
17
17
|
return match ? Number(match[1]) : null;
|
|
18
18
|
}
|
|
19
|
+
function toolListing(url, client, result, resources, resourcesAdvertised) {
|
|
20
|
+
const hints = result;
|
|
21
|
+
const instructions = client.getInstructions?.();
|
|
22
|
+
return {
|
|
23
|
+
url,
|
|
24
|
+
tools: (result.tools ?? []).filter((tool) => typeof tool?.name === 'string' && tool.name.length > 0),
|
|
25
|
+
resources,
|
|
26
|
+
resourcesAdvertised,
|
|
27
|
+
...(typeof instructions === 'string' ? { instructions } : {}),
|
|
28
|
+
...(typeof hints.ttlMs === 'number' &&
|
|
29
|
+
Number.isSafeInteger(hints.ttlMs) &&
|
|
30
|
+
hints.ttlMs >= 0
|
|
31
|
+
? { ttlMs: hints.ttlMs }
|
|
32
|
+
: {}),
|
|
33
|
+
...(hints.cacheScope === 'public' || hints.cacheScope === 'private'
|
|
34
|
+
? { cacheScope: hints.cacheScope }
|
|
35
|
+
: {}),
|
|
36
|
+
listedAt: Date.now()
|
|
37
|
+
};
|
|
38
|
+
}
|
|
19
39
|
/**
|
|
20
40
|
* Check leased HTTP servers and use the caller's bounded, shared cache.
|
|
21
41
|
* A successful check is only a startup hint. The gateway checks live access
|
|
22
42
|
* on each tool call, even when a cached success skips this probe.
|
|
43
|
+
*
|
|
44
|
+
* When `listings` is given, a server that was probed keeps its `tools/list`
|
|
45
|
+
* result there (all pages: the client aggregates them when no cursor is
|
|
46
|
+
* passed), plus `resources/list` when the server advertises resources. The
|
|
47
|
+
* Pi backend seeds its MCP metadata cache from it, so Pi does not repeat the
|
|
48
|
+
* handshake on this turn. A server skipped by the success cache, or whose
|
|
49
|
+
* resource listing failed, has no listing.
|
|
50
|
+
*/
|
|
51
|
+
async function verifyMcpServers(servers, signal, successful, failed, listings,
|
|
52
|
+
/**
|
|
53
|
+
* Servers to probe and list even inside the success-cache window: their
|
|
54
|
+
* stored listing is no longer valid (item 1(b)), so without a fresh one Pi
|
|
55
|
+
* would fall back to its own eager handshake.
|
|
23
56
|
*/
|
|
24
|
-
|
|
57
|
+
relist) {
|
|
25
58
|
const results = await Promise.all(Object.entries(servers).map(async ([name, server]) => {
|
|
26
59
|
if (server.type !== 'http' || !server.url)
|
|
27
60
|
return null;
|
|
@@ -30,6 +63,7 @@ async function verifyMcpServers(servers, signal, successful, failed) {
|
|
|
30
63
|
const key = `${name}\n${server.url}`;
|
|
31
64
|
const checkedAt = successful?.get(key);
|
|
32
65
|
if (checkedAt !== undefined &&
|
|
66
|
+
!relist?.has(name) &&
|
|
33
67
|
Date.now() - checkedAt < SUCCESS_CACHE_TTL_MS)
|
|
34
68
|
return null;
|
|
35
69
|
const priorFailure = failed?.get(key);
|
|
@@ -55,7 +89,25 @@ async function verifyMcpServers(servers, signal, successful, failed) {
|
|
|
55
89
|
: deadline;
|
|
56
90
|
try {
|
|
57
91
|
await client.connect(new client_1.StreamableHTTPClientTransport(new URL(server.url)), { timeout: 10_000, signal: requestSignal });
|
|
58
|
-
await client.listTools({}, { timeout: 10_000, signal: requestSignal });
|
|
92
|
+
const listed = await client.listTools({}, { timeout: 10_000, signal: requestSignal });
|
|
93
|
+
if (listings) {
|
|
94
|
+
const resourcesAdvertised = Boolean(client.getServerCapabilities?.()?.resources);
|
|
95
|
+
let resources = [];
|
|
96
|
+
if (resourcesAdvertised) {
|
|
97
|
+
// Pi lists resources on connect. A seeded entry without them
|
|
98
|
+
// would hide the server's `read_*` tools, so a failed resource
|
|
99
|
+
// listing leaves the server unseeded (Pi then connects as before).
|
|
100
|
+
resources = await client
|
|
101
|
+
.listResources({}, { timeout: 10_000, signal: requestSignal })
|
|
102
|
+
.then((result) => (result.resources ?? []))
|
|
103
|
+
.catch(() => null);
|
|
104
|
+
if (signal?.aborted)
|
|
105
|
+
throw new Error('MCP check was cancelled.');
|
|
106
|
+
}
|
|
107
|
+
if (resources) {
|
|
108
|
+
listings.set(name, toolListing(server.url, client, listed, resources, resourcesAdvertised));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
59
111
|
successful?.set(key, Date.now());
|
|
60
112
|
failed?.delete(key);
|
|
61
113
|
if (successful && successful.size > MAX_CACHE_ENTRIES) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type McpServerToolListing, type McpToolsetMember } from './mcp-toolset';
|
|
2
|
+
/**
|
|
3
|
+
* ITEM 1(b): THE PER-SESSION MCP TOOL STORE.
|
|
4
|
+
*
|
|
5
|
+
* Pi is started again on every turn. Without this store a warm turn cannot
|
|
6
|
+
* seed Pi's metadata cache: the 5-minute preflight success cache skips the
|
|
7
|
+
* probe, so nothing is listed, every server stays eager and Pi repeats the
|
|
8
|
+
* whole MCP handshake before the first prompt (~19.6 s on the baseline warm
|
|
9
|
+
* turn). The store keeps the last listing of each server per session and
|
|
10
|
+
* gives it back while the toolset is unchanged, so a warm turn sends no MCP
|
|
11
|
+
* request before the prompt.
|
|
12
|
+
*
|
|
13
|
+
* WHEN A LISTING IS REUSED. Only while all of these still hold:
|
|
14
|
+
* - the lease carries `mcpToolScope` (the speaker and the Space whose live
|
|
15
|
+
* permissions the gateway applies). An older control plane does not send
|
|
16
|
+
* it, and then nothing is reused, so a list never crosses speakers;
|
|
17
|
+
* - the scope is the one the listing was taken under. A different speaker in
|
|
18
|
+
* a shared chat drops every stored listing of the session;
|
|
19
|
+
* - the server's own fingerprint (name, upstream URL, allowlist) is the same;
|
|
20
|
+
* - the server is still configured at the URL the listing was taken from;
|
|
21
|
+
* - the listing is younger than `MCP_TOOL_STORE_MAX_AGE_MS` and than the
|
|
22
|
+
* server's own `ttlMs` hint. A `ttlMs: 0` listing (the workspace server
|
|
23
|
+
* sends one) is never reused: that server is listed again by the preflight
|
|
24
|
+
* on every turn, two requests in the 2026-07-28 era instead of Pi's four,
|
|
25
|
+
* until the gateway declares a non-zero private ttl (PR follow-up).
|
|
26
|
+
*
|
|
27
|
+
* Any other server is checked by the preflight as before. A server that had a
|
|
28
|
+
* stored listing which is no longer valid is re-listed even inside the success
|
|
29
|
+
* cache window, so the changed server is seeded again rather than left eager.
|
|
30
|
+
*
|
|
31
|
+
* The store holds only tool metadata (names, descriptions, schemas) and never
|
|
32
|
+
* a token. It is advisory: the gateway still checks every `tools/call`.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* How long a stored listing may be reused. Longer than the 5-minute preflight
|
|
36
|
+
* success cache, so a turn a few minutes after the last one skips the
|
|
37
|
+
* preflight too (the baseline turn `91c442db` came 6 minutes after the one
|
|
38
|
+
* before it and spent 9.7 s in it). Short, because a catalogue change that is
|
|
39
|
+
* not in the lease (an app connected, or a grant revoked, for the same speaker)
|
|
40
|
+
* shows only after this; Pi also replaces the cached tools as soon as it
|
|
41
|
+
* connects to a server. A revoked tool can stay listed for up to this long,
|
|
42
|
+
* but the gateway refuses the call: a stale name, never extra access.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MCP_TOOL_STORE_MAX_AGE_MS: number;
|
|
45
|
+
interface StoredServer {
|
|
46
|
+
memberKey: string;
|
|
47
|
+
listing: McpServerToolListing;
|
|
48
|
+
}
|
|
49
|
+
interface StoredToolset {
|
|
50
|
+
scopeKey: string;
|
|
51
|
+
servers: Map<string, StoredServer>;
|
|
52
|
+
}
|
|
53
|
+
/** Shared by the harness across the per-turn runners, keyed by session id. */
|
|
54
|
+
export type McpToolStore = Map<string, StoredToolset>;
|
|
55
|
+
export type McpToolScope = Record<string, string>;
|
|
56
|
+
export interface McpToolStorePlan {
|
|
57
|
+
/** Stored listings that this turn uses instead of a preflight. */
|
|
58
|
+
reused: Record<string, McpServerToolListing>;
|
|
59
|
+
/** Servers whose stored listing was dropped; list them again. */
|
|
60
|
+
relist: Set<string>;
|
|
61
|
+
/** Why each dropped listing was dropped (server names only). */
|
|
62
|
+
dropped: Record<string, string>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Decide which servers this turn can take from the store. Drops every entry
|
|
66
|
+
* that is no longer valid, so a later turn cannot reuse it either.
|
|
67
|
+
*/
|
|
68
|
+
export declare function planMcpToolReuse(store: McpToolStore, sessionId: string, members: readonly McpToolsetMember[], scope: McpToolScope | null, configured: Readonly<Record<string, {
|
|
69
|
+
type: string;
|
|
70
|
+
url?: string;
|
|
71
|
+
}>>, now?: number): McpToolStorePlan;
|
|
72
|
+
/**
|
|
73
|
+
* Keep this turn's fresh listings for the next turn of the session. Nothing is
|
|
74
|
+
* kept without a scope. A `ttlMs: 0` listing is kept only as a marker: it is
|
|
75
|
+
* never reused, but the next turn knows to list that server again rather than
|
|
76
|
+
* leave it to Pi's eager handshake.
|
|
77
|
+
*/
|
|
78
|
+
export declare function rememberMcpToolListings(store: McpToolStore, sessionId: string, members: readonly McpToolsetMember[], scope: McpToolScope | null, listings: ReadonlyMap<string, McpServerToolListing>): string[];
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MCP_TOOL_STORE_MAX_AGE_MS = void 0;
|
|
4
|
+
exports.planMcpToolReuse = planMcpToolReuse;
|
|
5
|
+
exports.rememberMcpToolListings = rememberMcpToolListings;
|
|
6
|
+
const mcp_toolset_1 = require("./mcp-toolset");
|
|
7
|
+
/**
|
|
8
|
+
* ITEM 1(b): THE PER-SESSION MCP TOOL STORE.
|
|
9
|
+
*
|
|
10
|
+
* Pi is started again on every turn. Without this store a warm turn cannot
|
|
11
|
+
* seed Pi's metadata cache: the 5-minute preflight success cache skips the
|
|
12
|
+
* probe, so nothing is listed, every server stays eager and Pi repeats the
|
|
13
|
+
* whole MCP handshake before the first prompt (~19.6 s on the baseline warm
|
|
14
|
+
* turn). The store keeps the last listing of each server per session and
|
|
15
|
+
* gives it back while the toolset is unchanged, so a warm turn sends no MCP
|
|
16
|
+
* request before the prompt.
|
|
17
|
+
*
|
|
18
|
+
* WHEN A LISTING IS REUSED. Only while all of these still hold:
|
|
19
|
+
* - the lease carries `mcpToolScope` (the speaker and the Space whose live
|
|
20
|
+
* permissions the gateway applies). An older control plane does not send
|
|
21
|
+
* it, and then nothing is reused, so a list never crosses speakers;
|
|
22
|
+
* - the scope is the one the listing was taken under. A different speaker in
|
|
23
|
+
* a shared chat drops every stored listing of the session;
|
|
24
|
+
* - the server's own fingerprint (name, upstream URL, allowlist) is the same;
|
|
25
|
+
* - the server is still configured at the URL the listing was taken from;
|
|
26
|
+
* - the listing is younger than `MCP_TOOL_STORE_MAX_AGE_MS` and than the
|
|
27
|
+
* server's own `ttlMs` hint. A `ttlMs: 0` listing (the workspace server
|
|
28
|
+
* sends one) is never reused: that server is listed again by the preflight
|
|
29
|
+
* on every turn, two requests in the 2026-07-28 era instead of Pi's four,
|
|
30
|
+
* until the gateway declares a non-zero private ttl (PR follow-up).
|
|
31
|
+
*
|
|
32
|
+
* Any other server is checked by the preflight as before. A server that had a
|
|
33
|
+
* stored listing which is no longer valid is re-listed even inside the success
|
|
34
|
+
* cache window, so the changed server is seeded again rather than left eager.
|
|
35
|
+
*
|
|
36
|
+
* The store holds only tool metadata (names, descriptions, schemas) and never
|
|
37
|
+
* a token. It is advisory: the gateway still checks every `tools/call`.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* How long a stored listing may be reused. Longer than the 5-minute preflight
|
|
41
|
+
* success cache, so a turn a few minutes after the last one skips the
|
|
42
|
+
* preflight too (the baseline turn `91c442db` came 6 minutes after the one
|
|
43
|
+
* before it and spent 9.7 s in it). Short, because a catalogue change that is
|
|
44
|
+
* not in the lease (an app connected, or a grant revoked, for the same speaker)
|
|
45
|
+
* shows only after this; Pi also replaces the cached tools as soon as it
|
|
46
|
+
* connects to a server. A revoked tool can stay listed for up to this long,
|
|
47
|
+
* but the gateway refuses the call: a stale name, never extra access.
|
|
48
|
+
*/
|
|
49
|
+
exports.MCP_TOOL_STORE_MAX_AGE_MS = 15 * 60_000;
|
|
50
|
+
const MAX_STORED_SESSIONS = 256;
|
|
51
|
+
function memberKey(member) {
|
|
52
|
+
return (0, mcp_toolset_1.mcpToolsetFingerprint)([member]);
|
|
53
|
+
}
|
|
54
|
+
function scopeKeyOf(scope) {
|
|
55
|
+
return (0, mcp_toolset_1.mcpToolsetFingerprint)([], scope);
|
|
56
|
+
}
|
|
57
|
+
function reusableUntil(listing) {
|
|
58
|
+
const ttl = listing.ttlMs === undefined
|
|
59
|
+
? exports.MCP_TOOL_STORE_MAX_AGE_MS
|
|
60
|
+
: Math.min(listing.ttlMs, exports.MCP_TOOL_STORE_MAX_AGE_MS);
|
|
61
|
+
return listing.listedAt + ttl;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Decide which servers this turn can take from the store. Drops every entry
|
|
65
|
+
* that is no longer valid, so a later turn cannot reuse it either.
|
|
66
|
+
*/
|
|
67
|
+
function planMcpToolReuse(store, sessionId, members, scope, configured, now = Date.now()) {
|
|
68
|
+
const plan = { reused: {}, relist: new Set(), dropped: {} };
|
|
69
|
+
const stored = store.get(sessionId);
|
|
70
|
+
if (!stored)
|
|
71
|
+
return plan;
|
|
72
|
+
const dropAll = (reason) => {
|
|
73
|
+
for (const name of stored.servers.keys()) {
|
|
74
|
+
plan.relist.add(name);
|
|
75
|
+
plan.dropped[name] = reason;
|
|
76
|
+
}
|
|
77
|
+
store.delete(sessionId);
|
|
78
|
+
};
|
|
79
|
+
if (!scope) {
|
|
80
|
+
dropAll('lease has no tool scope');
|
|
81
|
+
return plan;
|
|
82
|
+
}
|
|
83
|
+
if (stored.scopeKey !== scopeKeyOf(scope)) {
|
|
84
|
+
dropAll('speaker or space changed');
|
|
85
|
+
return plan;
|
|
86
|
+
}
|
|
87
|
+
const current = new Map(members.map((member) => [member.name, member]));
|
|
88
|
+
for (const [name, entry] of [...stored.servers]) {
|
|
89
|
+
const member = current.get(name);
|
|
90
|
+
let reason = null;
|
|
91
|
+
if (!member)
|
|
92
|
+
reason = 'server removed';
|
|
93
|
+
else if (entry.memberKey !== memberKey(member))
|
|
94
|
+
reason = 'server changed';
|
|
95
|
+
else if (configured[name]?.type !== 'http')
|
|
96
|
+
reason = 'server not configured';
|
|
97
|
+
else if (configured[name]?.url !== entry.listing.url)
|
|
98
|
+
reason = 'url changed';
|
|
99
|
+
else if (now >= reusableUntil(entry.listing))
|
|
100
|
+
reason =
|
|
101
|
+
entry.listing.ttlMs !== undefined &&
|
|
102
|
+
entry.listing.ttlMs < exports.MCP_TOOL_STORE_MAX_AGE_MS
|
|
103
|
+
? 'server ttl'
|
|
104
|
+
: 'expired';
|
|
105
|
+
if (reason) {
|
|
106
|
+
stored.servers.delete(name);
|
|
107
|
+
plan.dropped[name] = reason;
|
|
108
|
+
if (member)
|
|
109
|
+
plan.relist.add(name);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
plan.reused[name] = entry.listing;
|
|
113
|
+
}
|
|
114
|
+
// A reuse counts as use: move the session to the newest end.
|
|
115
|
+
store.delete(sessionId);
|
|
116
|
+
if (stored.servers.size > 0)
|
|
117
|
+
store.set(sessionId, stored);
|
|
118
|
+
return plan;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Keep this turn's fresh listings for the next turn of the session. Nothing is
|
|
122
|
+
* kept without a scope. A `ttlMs: 0` listing is kept only as a marker: it is
|
|
123
|
+
* never reused, but the next turn knows to list that server again rather than
|
|
124
|
+
* leave it to Pi's eager handshake.
|
|
125
|
+
*/
|
|
126
|
+
function rememberMcpToolListings(store, sessionId, members, scope, listings) {
|
|
127
|
+
if (!scope)
|
|
128
|
+
return [];
|
|
129
|
+
const scopeKey = scopeKeyOf(scope);
|
|
130
|
+
let stored = store.get(sessionId);
|
|
131
|
+
if (!stored || stored.scopeKey !== scopeKey) {
|
|
132
|
+
stored = { scopeKey, servers: new Map() };
|
|
133
|
+
}
|
|
134
|
+
const current = new Map(members.map((member) => [member.name, member]));
|
|
135
|
+
const kept = [];
|
|
136
|
+
for (const [name, listing] of listings) {
|
|
137
|
+
const member = current.get(name);
|
|
138
|
+
if (!member)
|
|
139
|
+
continue;
|
|
140
|
+
stored.servers.set(name, { memberKey: memberKey(member), listing });
|
|
141
|
+
kept.push(name);
|
|
142
|
+
}
|
|
143
|
+
// Most recently used last, so the oldest session is evicted first.
|
|
144
|
+
store.delete(sessionId);
|
|
145
|
+
if (stored.servers.size > 0)
|
|
146
|
+
store.set(sessionId, stored);
|
|
147
|
+
while (store.size > MAX_STORED_SESSIONS) {
|
|
148
|
+
store.delete(store.keys().next().value);
|
|
149
|
+
}
|
|
150
|
+
return kept;
|
|
151
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One MCP server's tool listing, taken by the harness preflight.
|
|
3
|
+
*
|
|
4
|
+
* The values are what the server returned for `tools/list` (all pages) and,
|
|
5
|
+
* when the server advertises resources, `resources/list`. They contain no
|
|
6
|
+
* credential: the per-turn MCP token is added by the loopback proxy and never
|
|
7
|
+
* appears in a URL, a header or a listing here.
|
|
8
|
+
*/
|
|
9
|
+
export interface McpServerToolListing {
|
|
10
|
+
/** The exact URL the listing was taken from (the loopback proxy URL). */
|
|
11
|
+
url: string;
|
|
12
|
+
tools: Array<Record<string, unknown> & {
|
|
13
|
+
name: string;
|
|
14
|
+
}>;
|
|
15
|
+
resources: Array<Record<string, unknown>>;
|
|
16
|
+
/** True when the server advertises the resources capability. */
|
|
17
|
+
resourcesAdvertised: boolean;
|
|
18
|
+
instructions?: string;
|
|
19
|
+
/** Server cache hints from the first `tools/list` page, when present. */
|
|
20
|
+
ttlMs?: number;
|
|
21
|
+
cacheScope?: 'public' | 'private';
|
|
22
|
+
listedAt: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The tool listings for one toolset, keyed by the harness server name.
|
|
26
|
+
*
|
|
27
|
+
* Item 4 uses this only within the turn that took it. Item 1(b) can keep one
|
|
28
|
+
* per session and reuse it on a later turn while `fingerprint` still matches.
|
|
29
|
+
*/
|
|
30
|
+
export interface McpToolsetSnapshot {
|
|
31
|
+
fingerprint: string;
|
|
32
|
+
servers: Record<string, McpServerToolListing>;
|
|
33
|
+
}
|
|
34
|
+
/** One leased server, as it enters the toolset fingerprint. */
|
|
35
|
+
export interface McpToolsetMember {
|
|
36
|
+
name: string;
|
|
37
|
+
/** The upstream URL from the lease, not the loopback proxy URL. */
|
|
38
|
+
url: string;
|
|
39
|
+
/** `null` means every tool the server exposes. */
|
|
40
|
+
allowedTools: readonly string[] | null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A stable hash of what decides a toolset: the server set, each server's URL
|
|
44
|
+
* and its allowlist, plus any caller scope (for example the speaker, the space
|
|
45
|
+
* and a permission version, once the lease carries them).
|
|
46
|
+
*
|
|
47
|
+
* A change to any input gives a different fingerprint, so a cached listing for
|
|
48
|
+
* the old toolset is not reused. Order does not matter. `allowedTools: null`
|
|
49
|
+
* (every tool) and `[]` (no tool) are different toolsets.
|
|
50
|
+
*/
|
|
51
|
+
export declare function mcpToolsetFingerprint(members: readonly McpToolsetMember[], scope?: Record<string, string | number | null | undefined>): string;
|