@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.
@@ -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
- gatewaySpendBefore = await readGatewaySpend(session.agent.model.gatewayUrl, lease.modelToken);
764
- if (gatewaySpendBefore === null)
765
- this.logger.warn(`[coding-agent] Gateway spend start unavailable for turn ${session.turnId}; this turn cannot be debited from gateway usage`);
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
- if (gatewaySpendBefore !== null &&
1414
- session.agent.model.gatewayUrl &&
1415
- lease.modelToken) {
1416
- const spendAfter = await readGatewaySpend(session.agent.model.gatewayUrl, lease.modelToken);
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, POLL_INTERVAL_MS);
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.13",
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",