@peakflo/agent-harness 0.2.6 → 0.2.7

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.
@@ -157,6 +157,11 @@ function buildPiMcpConfigDocument(servers, onRename) {
157
157
  {
158
158
  url: server.url,
159
159
  headers: server.headers ?? {},
160
+ // Pi's default lazy mode registers a namespace tool only when it
161
+ // already has cached tool metadata. A new session has no cache for
162
+ // this URL, so the model can see fewer servers than the lease gave
163
+ // it. Eager mode lists tools before the first model prompt.
164
+ lifecycle: 'eager',
160
165
  // The managed integrations gateway can wait for a cold pfMCP
161
166
  // instance. Its own upstream deadline is 270 seconds, so Pi must
162
167
  // wait long enough to receive the gateway's result or clear error.
package/dist/harness.d.ts CHANGED
@@ -135,6 +135,8 @@ export declare class Harness {
135
135
  private readonly notifier;
136
136
  private readonly artifacts;
137
137
  private readonly tokens;
138
+ /** One state for all leased turns in this process; each turn gets a new runner. */
139
+ private readonly mcpCheckState;
138
140
  private readonly startedAt;
139
141
  private status;
140
142
  private tenantId;
package/dist/harness.js CHANGED
@@ -112,6 +112,12 @@ class Harness {
112
112
  notifier;
113
113
  artifacts;
114
114
  tokens = new ports_default_1.SessionTokenInjector(null);
115
+ /** One state for all leased turns in this process; each turn gets a new runner. */
116
+ mcpCheckState = {
117
+ checked: new Map(),
118
+ failed: new Map(),
119
+ shownNotices: new Set()
120
+ };
115
121
  startedAt = Date.now();
116
122
  status = 'starting';
117
123
  tenantId = null;
@@ -1403,6 +1409,7 @@ class Harness {
1403
1409
  */
1404
1410
  const modelGateway = new model_gateway_1.SessionModelGateway();
1405
1411
  const runner = new session_runner_1.SessionRunner({
1412
+ mcpCheckState: this.mcpCheckState,
1406
1413
  getAdapter: (backend) => {
1407
1414
  const build = this.deps.getAdapter;
1408
1415
  if (!build) {
@@ -0,0 +1,21 @@
1
+ export interface UnavailableMcpServer {
2
+ name: string;
3
+ reason: string;
4
+ /** A cold integrations server may still connect through Pi after this check. */
5
+ keepConfigured?: boolean;
6
+ }
7
+ export interface FailedMcpCheck {
8
+ reason: string;
9
+ keepConfigured: boolean;
10
+ nextRetryAt: number;
11
+ backoffMs: number;
12
+ }
13
+ /**
14
+ * Check leased HTTP servers and use the caller's bounded, shared cache.
15
+ * A successful check is only a startup hint. The gateway checks live access
16
+ * on each tool call, even when a cached success skips this probe.
17
+ */
18
+ export declare function verifyMcpServers(servers: Record<string, {
19
+ type: string;
20
+ url?: string;
21
+ }>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck>): Promise<UnavailableMcpServer[]>;
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyMcpServers = verifyMcpServers;
4
+ const client_1 = require("@modelcontextprotocol/client");
5
+ const SUCCESS_CACHE_TTL_MS = 5 * 60_000;
6
+ const INITIAL_FAILURE_BACKOFF_MS = 15_000;
7
+ const MAX_FAILURE_BACKOFF_MS = 2 * 60_000;
8
+ const MAX_CACHE_ENTRIES = 1024;
9
+ function safeHttpStatus(cause) {
10
+ let current = cause;
11
+ for (let depth = 0; depth < 4 && current instanceof Error; depth++) {
12
+ if (current instanceof client_1.SdkHttpError)
13
+ return current.status;
14
+ current = current.cause;
15
+ }
16
+ const match = String(cause).match(/^(?:Error:\s*)?HTTP\s+([45]\d\d)\b/i);
17
+ return match ? Number(match[1]) : null;
18
+ }
19
+ /**
20
+ * Check leased HTTP servers and use the caller's bounded, shared cache.
21
+ * A successful check is only a startup hint. The gateway checks live access
22
+ * on each tool call, even when a cached success skips this probe.
23
+ */
24
+ async function verifyMcpServers(servers, signal, successful, failed) {
25
+ const results = await Promise.all(Object.entries(servers).map(async ([name, server]) => {
26
+ if (server.type !== 'http' || !server.url)
27
+ return null;
28
+ if (signal?.aborted)
29
+ throw new Error('MCP check was cancelled.');
30
+ const key = `${name}\n${server.url}`;
31
+ const checkedAt = successful?.get(key);
32
+ if (checkedAt !== undefined &&
33
+ Date.now() - checkedAt < SUCCESS_CACHE_TTL_MS)
34
+ return null;
35
+ const priorFailure = failed?.get(key);
36
+ if (priorFailure && Date.now() < priorFailure.nextRetryAt) {
37
+ return {
38
+ name,
39
+ reason: priorFailure.reason,
40
+ ...(priorFailure.keepConfigured ? { keepConfigured: true } : {})
41
+ };
42
+ }
43
+ let reason = 'connection or tool listing failed';
44
+ let status = null;
45
+ let timedOut = false;
46
+ // Each attempt has one shared connect+list deadline. A cold integrations
47
+ // server stays configured after a timeout; Pi has its own longer limit.
48
+ for (let attempt = 0; attempt < 2; attempt++) {
49
+ if (signal?.aborted)
50
+ throw new Error('MCP check was cancelled.');
51
+ const client = new client_1.Client({ name: 'peakflo-agent-harness-check', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
52
+ const deadline = AbortSignal.timeout(10_000);
53
+ const requestSignal = signal
54
+ ? AbortSignal.any([signal, deadline])
55
+ : deadline;
56
+ try {
57
+ 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 });
59
+ successful?.set(key, Date.now());
60
+ failed?.delete(key);
61
+ if (successful && successful.size > MAX_CACHE_ENTRIES) {
62
+ successful.delete(successful.keys().next().value);
63
+ }
64
+ return null;
65
+ }
66
+ catch (cause) {
67
+ if (signal?.aborted)
68
+ throw new Error('MCP check was cancelled.');
69
+ // SDK errors can include a URL or response body. Expose only an
70
+ // explicit HTTP status, never the raw error.
71
+ status = safeHttpStatus(cause);
72
+ timedOut = deadline.aborted;
73
+ reason = timedOut
74
+ ? 'timed out'
75
+ : status
76
+ ? `HTTP ${status}`
77
+ : 'connection or tool listing failed';
78
+ if (status &&
79
+ status >= 400 &&
80
+ status < 500 &&
81
+ status !== 408 &&
82
+ status !== 429)
83
+ break;
84
+ // Do not spend another ten seconds probing a cold integrations
85
+ // server. Pi can still connect to it with its own 300-second limit.
86
+ if (name === 'integrations' && timedOut)
87
+ break;
88
+ }
89
+ finally {
90
+ await client.close().catch(() => undefined);
91
+ }
92
+ }
93
+ const keepConfigured = name === 'integrations' &&
94
+ (timedOut ||
95
+ status === 408 ||
96
+ status === 429 ||
97
+ (status !== null && status >= 500));
98
+ const backoffMs = Math.min(priorFailure ? priorFailure.backoffMs * 2 : INITIAL_FAILURE_BACKOFF_MS, MAX_FAILURE_BACKOFF_MS);
99
+ // Access can change between turns. Recheck 4xx refusals immediately;
100
+ // only temporary failures use backoff.
101
+ if (timedOut ||
102
+ status === null ||
103
+ status === 408 ||
104
+ status === 429 ||
105
+ status >= 500) {
106
+ failed?.set(key, {
107
+ reason,
108
+ keepConfigured,
109
+ nextRetryAt: Date.now() + backoffMs,
110
+ backoffMs
111
+ });
112
+ }
113
+ else {
114
+ failed?.delete(key);
115
+ }
116
+ if (failed && failed.size > MAX_CACHE_ENTRIES) {
117
+ failed.delete(failed.keys().next().value);
118
+ }
119
+ return {
120
+ name,
121
+ reason,
122
+ ...(keepConfigured ? { keepConfigured: true } : {})
123
+ };
124
+ }));
125
+ return results.filter((result) => result !== null);
126
+ }
@@ -24,6 +24,7 @@ import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, No
24
24
  import { LeaseResponse, LeasedTurnInput, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
25
25
  import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
26
26
  import { McpAuthProxy } from './mcp-auth-proxy';
27
+ import { type FailedMcpCheck } from './mcp-preflight';
27
28
  export interface SessionRunnerDeps {
28
29
  /** Resolves a backend name from the lease to a working adapter. */
29
30
  getAdapter(backend: string): CodingAgentAdapter;
@@ -34,6 +35,8 @@ export interface SessionRunnerDeps {
34
35
  * consults a gateway, keep constructing a runner without one.
35
36
  */
36
37
  modelGateway?: SessionModelGateway;
38
+ /** Shared by the harness across disposable runners for leased turns. */
39
+ mcpCheckState?: McpCheckState;
37
40
  store: TranscriptStore;
38
41
  workspace: WorkspaceProvider;
39
42
  proxy: McpAuthProxy;
@@ -248,9 +251,15 @@ export declare function decodeQuestionAnswers(answer: string | undefined): Recor
248
251
  * Codex's "Allow" read as a denial.
249
252
  */
250
253
  export declare function isApprovingOptionId(optionId: string): boolean;
254
+ export interface McpCheckState {
255
+ checked: Map<string, number>;
256
+ failed: Map<string, FailedMcpCheck>;
257
+ shownNotices: Set<string>;
258
+ }
251
259
  export declare class SessionRunner {
252
260
  private readonly deps;
253
261
  private readonly logger;
262
+ private readonly mcpCheckState;
254
263
  /**
255
264
  * What the running turn needs in order to ANSWER a permission from outside
256
265
  * the turn's own call stack.
@@ -34,6 +34,7 @@ const attachments_1 = require("./attachments");
34
34
  const cold_start_steps_1 = require("./cold-start-steps");
35
35
  const conversation_1 = require("./conversation");
36
36
  const knowledge_tools_1 = require("./knowledge-tools");
37
+ const mcp_preflight_1 = require("./mcp-preflight");
37
38
  const permission_mode_1 = require("./permission-mode");
38
39
  const projection_1 = require("./projection");
39
40
  const space_files_1 = require("./space-files");
@@ -375,6 +376,7 @@ function resolveApproval(command, open) {
375
376
  class SessionRunner {
376
377
  deps;
377
378
  logger;
379
+ mcpCheckState;
378
380
  /**
379
381
  * What the running turn needs in order to ANSWER a permission from outside
380
382
  * the turn's own call stack.
@@ -565,6 +567,11 @@ class SessionRunner {
565
567
  constructor(deps) {
566
568
  this.deps = deps;
567
569
  this.logger = deps.logger ?? console;
570
+ this.mcpCheckState = deps.mcpCheckState ?? {
571
+ checked: new Map(),
572
+ failed: new Map(),
573
+ shownNotices: new Set()
574
+ };
568
575
  }
569
576
  /**
570
577
  * True while a permission prompt is outstanding. Read by the harness to
@@ -615,6 +622,7 @@ class SessionRunner {
615
622
  * files exist.
616
623
  */
617
624
  let turnContent = session.input.content;
625
+ let turnMcpNotice = null;
618
626
  this.turnParts = [];
619
627
  this.steerTurnGeneration++;
620
628
  this.steerAcknowledgementsClosed = false;
@@ -845,8 +853,46 @@ class SessionRunner {
845
853
  });
846
854
  knowledgeUrl = await this.deps.knowledge.host.endpointFor(session.sessionId);
847
855
  }
848
- const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl);
856
+ const unavailableMcpServers = [];
857
+ const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl, unavailableMcpServers);
849
858
  config.coldStartLog = this.deps.coldStartLog;
859
+ const endMcpPreflight = coldStart.begin('mcp_preflight');
860
+ try {
861
+ unavailableMcpServers.push(...(await (0, mcp_preflight_1.verifyMcpServers)(config.mcpServers ?? {}, signal, this.mcpCheckState.checked, this.mcpCheckState.failed)));
862
+ }
863
+ finally {
864
+ endMcpPreflight();
865
+ }
866
+ for (const unavailable of unavailableMcpServers) {
867
+ if (!unavailable.keepConfigured) {
868
+ delete config.mcpServers?.[unavailable.name];
869
+ }
870
+ }
871
+ const modelNotices = unavailableMcpServers.map(({ name, reason, keepConfigured }) => keepConfigured
872
+ ? `MCP server "${name}" did not answer the check (${reason}). Its tools may become available later in this turn.`
873
+ : `MCP server "${name}" is unavailable (${reason}). Its tools cannot be used in this turn.`);
874
+ turnMcpNotice = modelNotices.length > 0 ? modelNotices.join('\n') : null;
875
+ const userNotices = unavailableMcpServers.flatMap(({ name }, index) => {
876
+ const key = `${session.sessionId}\n${name}`;
877
+ if (this.mcpCheckState.shownNotices.has(key))
878
+ return [];
879
+ this.mcpCheckState.shownNotices.add(key);
880
+ if (this.mcpCheckState.shownNotices.size > 1024) {
881
+ this.mcpCheckState.shownNotices.delete(this.mcpCheckState.shownNotices.values().next().value);
882
+ }
883
+ return [modelNotices[index]];
884
+ });
885
+ if (userNotices.length > 0) {
886
+ partsProduced += this.record(session.sessionId, [
887
+ {
888
+ id: `mcp-unavailable-${session.turnId}`,
889
+ role: 'system',
890
+ partType: 'text',
891
+ content: userNotices.join('\n')
892
+ }
893
+ ]);
894
+ await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
895
+ }
850
896
  /*
851
897
  * REMEMBER THE CONVERSATION. This block is the fix for the defect that
852
898
  * made this a chat product that could not hold a conversation: the
@@ -1002,11 +1048,29 @@ class SessionRunner {
1002
1048
  const promptWithHostNote = turnNote
1003
1049
  ? `${turnNote}\n\n${promptText}`
1004
1050
  : promptText;
1051
+ const promptParts = [
1052
+ ...(turnMcpNotice
1053
+ ? [
1054
+ {
1055
+ type: 'text',
1056
+ text: `Tool access notice:\n${turnMcpNotice}`,
1057
+ role: 'system'
1058
+ }
1059
+ ]
1060
+ : []),
1061
+ { type: 'text', text: promptWithHostNote, role: 'user' }
1062
+ ];
1063
+ // Pi, ACP, Claude Code, and Codex join parts with a newline. OpenCode
1064
+ // keeps them separate. Accept echoes in either representation.
1065
+ const sentPromptParts = promptParts
1066
+ .filter((part) => part.type === 'text' && Boolean(part.text))
1067
+ .map((part) => part.text);
1068
+ const sentPromptTexts = [sentPromptParts.join('\n'), ...sentPromptParts];
1005
1069
  replayed = !resumed && memory.messages.length > 0;
1006
1070
  priorPartIdsForLog = priorPartIds;
1007
1071
  const endPrompt = coldStart.begin('model_prompt_send');
1008
1072
  try {
1009
- await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
1073
+ await adapter.sendPrompt(backendSessionId, promptParts, config);
1010
1074
  }
1011
1075
  finally {
1012
1076
  endPrompt();
@@ -1044,7 +1108,7 @@ class SessionRunner {
1044
1108
  resumed ? priorPartIds : new Set(),
1045
1109
  // THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
1046
1110
  // backend's own echo of it — see `drain`.
1047
- promptWithHostNote, () => endFirstOutput(), () => endFirstIngest());
1111
+ sentPromptTexts, () => endFirstOutput(), () => endFirstIngest());
1048
1112
  partsProduced = drained.parts;
1049
1113
  if (signal.aborted) {
1050
1114
  stopReason = 'aborted';
@@ -1300,7 +1364,7 @@ class SessionRunner {
1300
1364
  * Both sides are trimmed first: a backend that strips a trailing newline
1301
1365
  * from the echoed text must not slip past the prefix check on that alone.
1302
1366
  */
1303
- isEchoOfTurnPrompt(part, sentPromptText) {
1367
+ isEchoOfTurnPrompt(part, sentPromptTexts) {
1304
1368
  if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user') {
1305
1369
  return false;
1306
1370
  }
@@ -1310,7 +1374,7 @@ class SessionRunner {
1310
1374
  const echoed = text.trim();
1311
1375
  if (echoed.length === 0)
1312
1376
  return false;
1313
- return sentPromptText.trim().startsWith(echoed);
1377
+ return sentPromptTexts.some((text) => text.trim().startsWith(echoed));
1314
1378
  }
1315
1379
  isExactSteerEcho(part, steeredPrompt) {
1316
1380
  if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user')
@@ -1345,7 +1409,7 @@ class SessionRunner {
1345
1409
  */
1346
1410
  async drain(adapter, backendSessionId, config, session, signal, alreadySeenPartIds = new Set(),
1347
1411
  /** The exact text `sendPrompt` was given this turn, for echo filtering. */
1348
- sentPromptText = null, onFirstOutput, onFirstIngest) {
1412
+ sentPromptTexts = null, onFirstOutput, onFirstIngest) {
1349
1413
  let providerError;
1350
1414
  let timedOut = false;
1351
1415
  let idleTimedOut = false;
@@ -1468,9 +1532,9 @@ class SessionRunner {
1468
1532
  * be. Answers to questions travel a different wire (`respondTo*`) and
1469
1533
  * are recorded by the answer path itself, with the `answer-…` part id.
1470
1534
  */
1471
- const freshParts = sentPromptText && sentPromptText.length > 0
1535
+ const freshParts = sentPromptTexts && sentPromptTexts.length > 0
1472
1536
  ? parts.filter((part) => {
1473
- if (this.isEchoOfTurnPrompt(part, sentPromptText))
1537
+ if (this.isEchoOfTurnPrompt(part, sentPromptTexts))
1474
1538
  return false;
1475
1539
  return !this.consumesSteerEcho(part);
1476
1540
  })
@@ -2357,23 +2421,22 @@ class SessionRunner {
2357
2421
  * security perimeter and trusting it to filter its own tools is exactly
2358
2422
  * defect G2.
2359
2423
  */
2360
- buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl = null) {
2424
+ buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl = null, unavailable = []) {
2361
2425
  const mcpServers = {};
2362
2426
  for (const server of session.agent.mcpServers) {
2363
2427
  const proxied = this.deps.proxy.registerTarget(server.url, server.name);
2428
+ if (!proxied) {
2429
+ unavailable.push({
2430
+ name: server.name,
2431
+ reason: 'local MCP proxy is unavailable'
2432
+ });
2433
+ continue;
2434
+ }
2364
2435
  mcpServers[server.name] = {
2365
2436
  name: server.name,
2366
2437
  type: 'http',
2367
- // A null proxy URL means the proxy is not running. Falling back to the
2368
- // direct URL would put the session token in the backend's config, so
2369
- // the server is simply omitted instead — fewer tools is recoverable,
2370
- // a leaked token is not.
2371
2438
  url: proxied
2372
2439
  };
2373
- if (!proxied) {
2374
- delete mcpServers[server.name];
2375
- this.logger.warn(`[SessionRunner] MCP server "${server.name}" omitted: proxy not running`);
2376
- }
2377
2440
  }
2378
2441
  /*
2379
2442
  * The knowledge server is on loopback with a per-session path secret, so it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",
@@ -37,6 +37,7 @@
37
37
  "undici": "^7.21.0"
38
38
  },
39
39
  "dependencies": {
40
+ "@modelcontextprotocol/client": "2.0.0",
40
41
  "@peculiar/x509": "^2.1.0",
41
42
  "reflect-metadata": "^0.2.2"
42
43
  },