@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mcpToolsetFingerprint = mcpToolsetFingerprint;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
function stableJson(value) {
|
|
6
|
+
if (value === null || typeof value !== 'object') {
|
|
7
|
+
return JSON.stringify(value) ?? 'null';
|
|
8
|
+
}
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
11
|
+
const record = value;
|
|
12
|
+
return `{${Object.keys(record)
|
|
13
|
+
.filter((key) => record[key] !== undefined)
|
|
14
|
+
.sort()
|
|
15
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
|
16
|
+
.join(',')}}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A stable hash of what decides a toolset: the server set, each server's URL
|
|
20
|
+
* and its allowlist, plus any caller scope (for example the speaker, the space
|
|
21
|
+
* and a permission version, once the lease carries them).
|
|
22
|
+
*
|
|
23
|
+
* A change to any input gives a different fingerprint, so a cached listing for
|
|
24
|
+
* the old toolset is not reused. Order does not matter. `allowedTools: null`
|
|
25
|
+
* (every tool) and `[]` (no tool) are different toolsets.
|
|
26
|
+
*/
|
|
27
|
+
function mcpToolsetFingerprint(members, scope = {}) {
|
|
28
|
+
const servers = [...members]
|
|
29
|
+
.map((member) => ({
|
|
30
|
+
name: member.name,
|
|
31
|
+
url: member.url,
|
|
32
|
+
allowedTools: member.allowedTools === null
|
|
33
|
+
? null
|
|
34
|
+
: [...new Set(member.allowedTools)].sort()
|
|
35
|
+
}))
|
|
36
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
37
|
+
return (0, crypto_1.createHash)('sha256')
|
|
38
|
+
.update(stableJson({ version: 1, servers, scope }))
|
|
39
|
+
.digest('hex');
|
|
40
|
+
}
|
|
@@ -25,6 +25,7 @@ import { LeaseResponse, LeasedTurnInput, ModelMessageInput, TranscriptPartInput,
|
|
|
25
25
|
import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
|
|
26
26
|
import { McpAuthProxy } from './mcp-auth-proxy';
|
|
27
27
|
import { type FailedMcpCheck } from './mcp-preflight';
|
|
28
|
+
import { type McpToolStore } from './mcp-tool-store';
|
|
28
29
|
export declare function approvalTimeoutMs(value: string | undefined): number;
|
|
29
30
|
export interface SessionRunnerDeps {
|
|
30
31
|
/** Resolves a backend name from the lease to a working adapter. */
|
|
@@ -319,6 +320,12 @@ export interface McpCheckState {
|
|
|
319
320
|
checked: Map<string, number>;
|
|
320
321
|
failed: Map<string, FailedMcpCheck>;
|
|
321
322
|
shownNotices: Set<string>;
|
|
323
|
+
/**
|
|
324
|
+
* Item 1(b): each session's last MCP tool listings, reused on a warm turn
|
|
325
|
+
* while the toolset fingerprint is unchanged. See `mcp-tool-store.ts`.
|
|
326
|
+
* Created on first use when a caller's state predates it.
|
|
327
|
+
*/
|
|
328
|
+
toolsets?: McpToolStore;
|
|
322
329
|
}
|
|
323
330
|
export declare class SessionRunner {
|
|
324
331
|
private readonly deps;
|
|
@@ -652,5 +659,23 @@ export declare class SessionRunner {
|
|
|
652
659
|
* security perimeter and trusting it to filter its own tools is exactly
|
|
653
660
|
* defect G2.
|
|
654
661
|
*/
|
|
662
|
+
/**
|
|
663
|
+
* The leased toolset as it enters the fingerprint: every server's name,
|
|
664
|
+
* upstream URL and allowlist, plus the knowledge server when bound.
|
|
665
|
+
*/
|
|
666
|
+
private mcpToolsetMembers;
|
|
667
|
+
/**
|
|
668
|
+
* The listings the backend may seed from (item 4, and item 1(b) for stored
|
|
669
|
+
* ones). Only a server that is still configured and was listed at its
|
|
670
|
+
* current url is kept.
|
|
671
|
+
*/
|
|
672
|
+
private mcpToolSnapshot;
|
|
673
|
+
/**
|
|
674
|
+
* One `mcp_tool_store` line per turn on the harness log. Server names only,
|
|
675
|
+
* no tool data. Not sent to the control plane: its cold-start endpoint
|
|
676
|
+
* accepts only `cold_start_step`/`cold_start_summary`, so it would be one
|
|
677
|
+
* refused request per turn.
|
|
678
|
+
*/
|
|
679
|
+
private logMcpToolStore;
|
|
655
680
|
private buildSessionConfig;
|
|
656
681
|
}
|
|
@@ -40,6 +40,8 @@ const cold_start_steps_1 = require("./cold-start-steps");
|
|
|
40
40
|
const conversation_1 = require("./conversation");
|
|
41
41
|
const knowledge_tools_1 = require("./knowledge-tools");
|
|
42
42
|
const mcp_preflight_1 = require("./mcp-preflight");
|
|
43
|
+
const mcp_tool_store_1 = require("./mcp-tool-store");
|
|
44
|
+
const mcp_toolset_1 = require("./mcp-toolset");
|
|
43
45
|
const permission_mode_1 = require("./permission-mode");
|
|
44
46
|
const projection_1 = require("./projection");
|
|
45
47
|
const space_files_1 = require("./space-files");
|
|
@@ -83,6 +85,15 @@ function approvalTimeoutMs(value) {
|
|
|
83
85
|
* transcripts are complete.
|
|
84
86
|
*/
|
|
85
87
|
const IDLE_CONFIRMATIONS = 2;
|
|
88
|
+
/**
|
|
89
|
+
* The pause before the CONFIRMING poll, for a backend whose IDLE is terminal
|
|
90
|
+
* (`CodingAgentAdapter.idleIsTerminal`). The confirmation is kept: a final
|
|
91
|
+
* poll after the backend reported IDLE is what picks up a part that landed
|
|
92
|
+
* between the previous poll and the status read. It just no longer waits a
|
|
93
|
+
* full `POLL_INTERVAL_MS`, which used to add ~2 s to the end of every turn.
|
|
94
|
+
* Any new part in that poll resets the count, exactly as before.
|
|
95
|
+
*/
|
|
96
|
+
const TERMINAL_IDLE_CONFIRM_MS = 250;
|
|
86
97
|
/**
|
|
87
98
|
* THE PRIMARY GUARD, per the LONG TURNS fix.
|
|
88
99
|
*
|
|
@@ -728,6 +739,15 @@ class SessionRunner {
|
|
|
728
739
|
let stopReason = 'completed';
|
|
729
740
|
let error;
|
|
730
741
|
let gatewaySpendBefore = null;
|
|
742
|
+
/**
|
|
743
|
+
* The start-of-turn `/key/info` read, in flight. Started next to the model
|
|
744
|
+
* catalogue and the workspace prepare instead of before them, and awaited
|
|
745
|
+
* just before `sendPrompt` — the first point this turn can spend — so the
|
|
746
|
+
* baseline is still taken before any model call.
|
|
747
|
+
*/
|
|
748
|
+
let gatewaySpendBeforeRead = null;
|
|
749
|
+
/** The end-of-turn read, started when the drain ends. See `finally`. */
|
|
750
|
+
let gatewaySpendAfterRead = null;
|
|
731
751
|
let repositoryStatuses = [];
|
|
732
752
|
/** True when the backend session was resumed rather than created. */
|
|
733
753
|
let resumed = false;
|
|
@@ -760,9 +780,12 @@ class SessionRunner {
|
|
|
760
780
|
if (refusal)
|
|
761
781
|
throw new Error(refusal);
|
|
762
782
|
if (session.agent.model.gatewayUrl && lease.modelToken) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
783
|
+
// `readGatewaySpend` never rejects: a failure resolves to null.
|
|
784
|
+
gatewaySpendBeforeRead = readGatewaySpend(session.agent.model.gatewayUrl, lease.modelToken).then((spend) => {
|
|
785
|
+
if (spend === null)
|
|
786
|
+
this.logger.warn(`[coding-agent] Gateway spend start unavailable for turn ${session.turnId}; this turn cannot be debited from gateway usage`);
|
|
787
|
+
return spend;
|
|
788
|
+
});
|
|
766
789
|
}
|
|
767
790
|
// The catalogue is advisory. Fetch it while the independent workspace
|
|
768
791
|
// is prepared. Pi can start with the selected lease model while this
|
|
@@ -989,18 +1012,45 @@ class SessionRunner {
|
|
|
989
1012
|
const unavailableMcpServers = [];
|
|
990
1013
|
const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl, unavailableMcpServers);
|
|
991
1014
|
config.coldStartLog = this.deps.coldStartLog;
|
|
1015
|
+
/*
|
|
1016
|
+
* ITEM 1(b). A server whose stored listing is still valid for this
|
|
1017
|
+
* toolset and this speaker is not checked again: its tools are seeded
|
|
1018
|
+
* into Pi from the store, so this turn sends it no MCP request before
|
|
1019
|
+
* the prompt. The gateway still checks every tool call.
|
|
1020
|
+
*/
|
|
1021
|
+
const toolStore = (this.mcpCheckState.toolsets ??= new Map());
|
|
1022
|
+
const toolsetMembers = this.mcpToolsetMembers(session, knowledgeUrl);
|
|
1023
|
+
const toolScope = session.mcpToolScope
|
|
1024
|
+
? { tenantId: session.tenantId, mcpToolScope: session.mcpToolScope }
|
|
1025
|
+
: null;
|
|
1026
|
+
const reusePlan = (0, mcp_tool_store_1.planMcpToolReuse)(toolStore, session.sessionId, toolsetMembers, toolScope, config.mcpServers ?? {});
|
|
992
1027
|
const endMcpPreflight = coldStart.begin('mcp_preflight');
|
|
1028
|
+
const mcpListings = new Map();
|
|
993
1029
|
try {
|
|
994
|
-
unavailableMcpServers.push(...(await (0, mcp_preflight_1.verifyMcpServers)(config.mcpServers ?? {}, signal, this.mcpCheckState.checked, this.mcpCheckState.failed)));
|
|
1030
|
+
unavailableMcpServers.push(...(await (0, mcp_preflight_1.verifyMcpServers)(Object.fromEntries(Object.entries(config.mcpServers ?? {}).filter(([name]) => !(name in reusePlan.reused))), signal, this.mcpCheckState.checked, this.mcpCheckState.failed, mcpListings, reusePlan.relist)));
|
|
995
1031
|
}
|
|
996
1032
|
finally {
|
|
997
1033
|
endMcpPreflight();
|
|
998
1034
|
}
|
|
1035
|
+
const storedNow = (0, mcp_tool_store_1.rememberMcpToolListings)(toolStore, session.sessionId, toolsetMembers, toolScope, mcpListings);
|
|
1036
|
+
for (const [name, listing] of Object.entries(reusePlan.reused)) {
|
|
1037
|
+
mcpListings.set(name, listing);
|
|
1038
|
+
}
|
|
999
1039
|
for (const unavailable of unavailableMcpServers) {
|
|
1000
1040
|
if (!unavailable.keepConfigured) {
|
|
1001
1041
|
delete config.mcpServers?.[unavailable.name];
|
|
1002
1042
|
}
|
|
1003
1043
|
}
|
|
1044
|
+
const toolsetFingerprint = (0, mcp_toolset_1.mcpToolsetFingerprint)(toolsetMembers, toolScope ?? { tenantId: session.tenantId });
|
|
1045
|
+
config.mcpToolSnapshot = this.mcpToolSnapshot(toolsetFingerprint, config, mcpListings);
|
|
1046
|
+
this.logMcpToolStore(session, {
|
|
1047
|
+
fingerprint: toolsetFingerprint.slice(0, 12),
|
|
1048
|
+
scoped: toolScope !== null,
|
|
1049
|
+
reused: Object.keys(reusePlan.reused),
|
|
1050
|
+
listed: [...mcpListings.keys()].filter((name) => !(name in reusePlan.reused)),
|
|
1051
|
+
stored: storedNow,
|
|
1052
|
+
dropped: reusePlan.dropped
|
|
1053
|
+
});
|
|
1004
1054
|
const modelNotices = unavailableMcpServers.map(({ name, reason, keepConfigured }) => keepConfigured
|
|
1005
1055
|
? `MCP server "${name}" did not answer the check (${reason}). Its tools may become available later in this turn.`
|
|
1006
1056
|
: `MCP server "${name}" is unavailable (${reason}). Its tools cannot be used in this turn.`);
|
|
@@ -1201,6 +1251,9 @@ class SessionRunner {
|
|
|
1201
1251
|
const sentPromptTexts = [sentPromptParts.join('\n'), ...sentPromptParts];
|
|
1202
1252
|
replayed = !resumed && memory.messages.length > 0;
|
|
1203
1253
|
priorPartIdsForLog = priorPartIds;
|
|
1254
|
+
// The spend baseline must be read before the first model call.
|
|
1255
|
+
if (gatewaySpendBeforeRead)
|
|
1256
|
+
gatewaySpendBefore = await gatewaySpendBeforeRead;
|
|
1204
1257
|
const endPrompt = coldStart.begin('model_prompt_send');
|
|
1205
1258
|
try {
|
|
1206
1259
|
await adapter.sendPrompt(backendSessionId, promptParts, config);
|
|
@@ -1339,6 +1392,21 @@ class SessionRunner {
|
|
|
1339
1392
|
]);
|
|
1340
1393
|
}
|
|
1341
1394
|
finally {
|
|
1395
|
+
/*
|
|
1396
|
+
* THE END-OF-TURN SPEND READ STARTS NOW, NOT AFTER TEARDOWN.
|
|
1397
|
+
*
|
|
1398
|
+
* The drain is over, so the backend has made its last model call. The
|
|
1399
|
+
* read used to be awaited serially after `stopServer`, adding its whole
|
|
1400
|
+
* round trip (up to its 5 s timeout) to every turn before the final
|
|
1401
|
+
* ingest. It now overlaps the rest of teardown and is awaited only where
|
|
1402
|
+
* `costUsd` is needed, on the final ingest that meters the turn.
|
|
1403
|
+
*/
|
|
1404
|
+
gatewaySpendAfterRead =
|
|
1405
|
+
gatewaySpendBeforeRead &&
|
|
1406
|
+
session.agent.model.gatewayUrl &&
|
|
1407
|
+
lease.modelToken
|
|
1408
|
+
? readGatewaySpend(session.agent.model.gatewayUrl, lease.modelToken)
|
|
1409
|
+
: null;
|
|
1342
1410
|
release();
|
|
1343
1411
|
this.deps.modelGateway?.revoke();
|
|
1344
1412
|
/*
|
|
@@ -1410,10 +1478,11 @@ class SessionRunner {
|
|
|
1410
1478
|
*/
|
|
1411
1479
|
}
|
|
1412
1480
|
let costUsd;
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1481
|
+
// A turn that failed before `sendPrompt` never awaited the baseline.
|
|
1482
|
+
if (gatewaySpendBeforeRead)
|
|
1483
|
+
gatewaySpendBefore = await gatewaySpendBeforeRead;
|
|
1484
|
+
if (gatewaySpendBefore !== null && gatewaySpendAfterRead) {
|
|
1485
|
+
const spendAfter = await gatewaySpendAfterRead;
|
|
1417
1486
|
if (spendAfter === null)
|
|
1418
1487
|
this.logger.warn(`[coding-agent] Gateway spend end unavailable for turn ${session.turnId}; this turn cannot be debited from gateway usage`);
|
|
1419
1488
|
else
|
|
@@ -1886,7 +1955,9 @@ class SessionRunner {
|
|
|
1886
1955
|
wake = null;
|
|
1887
1956
|
resolveWait();
|
|
1888
1957
|
};
|
|
1889
|
-
timer.handle = setTimeout(finish,
|
|
1958
|
+
timer.handle = setTimeout(finish, idleSeen > 0 && adapter.idleIsTerminal === true
|
|
1959
|
+
? TERMINAL_IDLE_CONFIRM_MS
|
|
1960
|
+
: POLL_INTERVAL_MS);
|
|
1890
1961
|
wake = finish;
|
|
1891
1962
|
signal.addEventListener('abort', finish, { once: true });
|
|
1892
1963
|
});
|
|
@@ -2682,6 +2753,59 @@ class SessionRunner {
|
|
|
2682
2753
|
* security perimeter and trusting it to filter its own tools is exactly
|
|
2683
2754
|
* defect G2.
|
|
2684
2755
|
*/
|
|
2756
|
+
/**
|
|
2757
|
+
* The leased toolset as it enters the fingerprint: every server's name,
|
|
2758
|
+
* upstream URL and allowlist, plus the knowledge server when bound.
|
|
2759
|
+
*/
|
|
2760
|
+
mcpToolsetMembers(session, knowledgeUrl) {
|
|
2761
|
+
return [
|
|
2762
|
+
...session.agent.mcpServers.map((server) => ({
|
|
2763
|
+
name: server.name,
|
|
2764
|
+
url: server.url,
|
|
2765
|
+
allowedTools: server.allowedTools
|
|
2766
|
+
})),
|
|
2767
|
+
...(knowledgeUrl
|
|
2768
|
+
? [
|
|
2769
|
+
{
|
|
2770
|
+
name: knowledge_tools_1.KNOWLEDGE_SERVER_NAME,
|
|
2771
|
+
url: knowledgeUrl,
|
|
2772
|
+
allowedTools: null
|
|
2773
|
+
}
|
|
2774
|
+
]
|
|
2775
|
+
: [])
|
|
2776
|
+
];
|
|
2777
|
+
}
|
|
2778
|
+
/**
|
|
2779
|
+
* The listings the backend may seed from (item 4, and item 1(b) for stored
|
|
2780
|
+
* ones). Only a server that is still configured and was listed at its
|
|
2781
|
+
* current url is kept.
|
|
2782
|
+
*/
|
|
2783
|
+
mcpToolSnapshot(fingerprint, config, listings) {
|
|
2784
|
+
const servers = {};
|
|
2785
|
+
for (const [name, listing] of listings) {
|
|
2786
|
+
const configured = config.mcpServers?.[name];
|
|
2787
|
+
if (configured?.type === 'http' && configured.url === listing.url) {
|
|
2788
|
+
servers[name] = listing;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
if (Object.keys(servers).length === 0)
|
|
2792
|
+
return undefined;
|
|
2793
|
+
return { fingerprint, servers };
|
|
2794
|
+
}
|
|
2795
|
+
/**
|
|
2796
|
+
* One `mcp_tool_store` line per turn on the harness log. Server names only,
|
|
2797
|
+
* no tool data. Not sent to the control plane: its cold-start endpoint
|
|
2798
|
+
* accepts only `cold_start_step`/`cold_start_summary`, so it would be one
|
|
2799
|
+
* refused request per turn.
|
|
2800
|
+
*/
|
|
2801
|
+
logMcpToolStore(session, detail) {
|
|
2802
|
+
this.logger.log(JSON.stringify({
|
|
2803
|
+
event: 'mcp_tool_store',
|
|
2804
|
+
sessionId: session.sessionId,
|
|
2805
|
+
turnId: session.turnId,
|
|
2806
|
+
...detail
|
|
2807
|
+
}));
|
|
2808
|
+
}
|
|
2685
2809
|
buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl = null, unavailable = []) {
|
|
2686
2810
|
const mcpServers = {};
|
|
2687
2811
|
for (const server of session.agent.mcpServers) {
|
package/dist/types.d.ts
CHANGED
|
@@ -523,6 +523,14 @@ export interface LeasedSession {
|
|
|
523
523
|
/** One lease is one TURN. A new turn on the same session is a new lease. */
|
|
524
524
|
turnId: string;
|
|
525
525
|
runtime: RunnerRuntime;
|
|
526
|
+
/**
|
|
527
|
+
* Opaque. Changes when the speaker or the Space whose live permissions the
|
|
528
|
+
* MCP gateway applies changes, so the tool catalogue may differ. The harness
|
|
529
|
+
* reuses a session's stored MCP tool listings on a later turn only while
|
|
530
|
+
* this is the same. Optional and additive: absent (an older control plane)
|
|
531
|
+
* means nothing is reused across turns.
|
|
532
|
+
*/
|
|
533
|
+
mcpToolScope?: string;
|
|
526
534
|
agent: LeasedAgent;
|
|
527
535
|
input: LeasedTurnInput;
|
|
528
536
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14-rc.1",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|