@mono-agent/agent-runtime 0.3.0 → 0.4.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.
@@ -5,8 +5,8 @@
5
5
  // from edge layers.
6
6
 
7
7
  export {
8
- createAgentCompactionManager,
9
8
  isLikelyContextTermination,
9
+ resolveAgentCompactionPolicy,
10
10
  } from "./compaction.js";
11
11
 
12
12
  export {
@@ -542,6 +542,9 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
542
542
  const warnings = [];
543
543
  const seen = new Set(reservedNames);
544
544
 
545
+ // Phase 1: collect connected clients in entry order (so tool registration is
546
+ // deterministic) and record connect failures.
547
+ const connectedList = [];
545
548
  for (const [index, result] of settled.entries()) {
546
549
  const serverName = entries[index]?.[0];
547
550
  if (result.status !== "fulfilled") {
@@ -553,18 +556,28 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
553
556
  });
554
557
  continue;
555
558
  }
559
+ clients.push(result.value);
560
+ connectedList.push({ serverName, connected: result.value });
561
+ }
556
562
 
557
- const connected = result.value;
558
- clients.push(connected);
559
- let listed;
563
+ // Phase 2: list tools from every connected server CONCURRENTLY (previously
564
+ // serial, adding ~Nx the slowest listTools to turn startup, painful over a
565
+ // SOCKS proxy). Results are awaited in entry order to keep registration stable.
566
+ const listings = await Promise.all(connectedList.map(async ({ serverName, connected }) => {
560
567
  try {
561
- listed = await connected.client.listTools();
568
+ return { serverName, connected, listed: await connected.client.listTools() };
562
569
  } catch (err) {
570
+ return { serverName, connected, error: err };
571
+ }
572
+ }));
573
+
574
+ for (const { serverName, connected, listed, error } of listings) {
575
+ if (error) {
563
576
  warnings.push({
564
577
  type: "runtime_warning",
565
578
  warning_kind: "mcp_list_tools_failed",
566
579
  server: serverName,
567
- message: err?.message || String(err),
580
+ message: error?.message || String(error),
568
581
  });
569
582
  continue;
570
583
  }
@@ -583,12 +596,16 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
583
596
  const textLimit = limits.mcpTextLimitChars || MCP_TEXT_RESULT_LIMIT;
584
597
  const imageInlineMaxBytes = limits.imageInlineMaxBytes ?? MCP_IMAGE_INLINE_MAX_BYTES;
585
598
  const normalizedParams = normalizeMcpToolParams(serverName, sourceTool.name, params || {}, { qaOutputDir });
599
+ // Measure the MCP round-trip so observability can separate slow MCP
600
+ // servers (e.g. context-a8c over a SOCKS proxy) from model latency.
601
+ const mcpCallStartMs = Date.now();
586
602
  const out = await withTimeout(
587
603
  connected.client.callTool({ name: sourceTool.name, arguments: normalizedParams || {} }),
588
604
  limits.mcpCallTimeoutMs || 120000,
589
605
  signal,
590
606
  `${serverName}:${sourceTool.name}`,
591
607
  );
608
+ const mcpCallDurationMs = Date.now() - mcpCallStartMs;
592
609
  const imageTruncations = [];
593
610
  return {
594
611
  content: coerceMcpContent(out, {
@@ -605,6 +622,7 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
605
622
  details: {
606
623
  server: serverName,
607
624
  tool: sourceTool.name,
625
+ mcp_call_duration_ms: mcpCallDurationMs,
608
626
  result_truncated: mcpContentWasTruncated(out, { textLimit, imageInlineMaxBytes }),
609
627
  raw: compactRawMcpResult(out),
610
628
  ...(imageTruncations.length ? {
@@ -629,10 +647,28 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
629
647
  };
630
648
  }
631
649
 
632
- export async function closePiMcpClients(clients) {
633
- for (const { client, transport } of clients || []) {
634
- try { await client.close?.(); } catch { /* best-effort */ }
635
- try { await transport.close?.(); } catch { /* best-effort */ }
636
- try { await transport.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
650
+ async function closeWithTimeout(close, timeoutMs) {
651
+ if (typeof close !== "function") return;
652
+ const result = close();
653
+ if (!(result && typeof result.then === "function")) return;
654
+ let timer;
655
+ try {
656
+ await Promise.race([
657
+ result,
658
+ new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
659
+ ]);
660
+ } finally {
661
+ if (timer) clearTimeout(timer);
637
662
  }
638
663
  }
664
+
665
+ export async function closePiMcpClients(clients, { timeoutMs = 5000 } = {}) {
666
+ // Close the client first (stop accepting messages) then the transport (tear
667
+ // down I/O), each bounded by a timeout so a hung stdio pipe cannot stall
668
+ // shutdown — a common source of "Connection closed" churn on reconnect.
669
+ await Promise.all((clients || []).map(async ({ client, transport }) => {
670
+ try { await closeWithTimeout(client?.close?.bind(client), timeoutMs); } catch { /* best-effort */ }
671
+ try { await closeWithTimeout(transport?.close?.bind(transport), timeoutMs); } catch { /* best-effort */ }
672
+ try { await transport?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
673
+ }));
674
+ }
@@ -5,8 +5,26 @@ import { resolveSandboxPolicy } from "./shared/runtime-context.js";
5
5
 
6
6
  const FETCH_TIMEOUT_MS = 15000;
7
7
  const MAX_REDIRECTS = 5;
8
+ // Backoff delays between retry attempts (length = number of retries). Retrying
9
+ // transient failures in-tool stops the model from burning whole reasoning rounds
10
+ // re-issuing the fetch (or falling back to Bash curl) on a momentary network blip.
11
+ const DEFAULT_FETCH_RETRY_DELAYS_MS = [1000, 2000];
8
12
 
9
- export async function webFetchToolImpl({ url, headers = {}, max_output_chars }, { sandboxPolicy } = {}) {
13
+ function isTransientFetchError(err) {
14
+ if (err === undefined || err === null) return false;
15
+ if (err.name === "AbortError" || err.name === "TimeoutError") return true; // timeout
16
+ const code = err.code ?? err.cause?.code;
17
+ return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "EAI_AGAIN";
18
+ }
19
+
20
+ function fetchRetryDelay(ms) {
21
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
22
+ }
23
+
24
+ export async function webFetchToolImpl(
25
+ { url, headers = {}, max_output_chars },
26
+ { sandboxPolicy, retryDelaysMs = DEFAULT_FETCH_RETRY_DELAYS_MS } = {},
27
+ ) {
10
28
  const maxChars = Number(max_output_chars) || DEFAULT_MAX_TOOL_OUTPUT_CHARS;
11
29
  let parsed;
12
30
  try { parsed = new URL(url); } catch { return "Error: Invalid URL"; }
@@ -16,18 +34,36 @@ export async function webFetchToolImpl({ url, headers = {}, max_output_chars },
16
34
  const policy = resolveSandboxPolicy(sandboxPolicy);
17
35
  if (!networkPolicyAllowsUrl(policy, parsed.href)) return "Error: Network access denied by sandbox policy.";
18
36
  const requestHeaders = { "User-Agent": "AgentRuntime/0.1", ...headers };
19
- try {
20
- const restricted = policy !== undefined && policy.network.mode !== "all";
21
- const resp = restricted
22
- ? await fetchCheckingRedirects(parsed, requestHeaders, policy)
23
- : await fetch(url, { headers: requestHeaders, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
24
- if (typeof resp === "string") return resp;
25
- const text = await resp.text();
26
- if (!resp.ok) return `HTTP ${resp.status}: ${text.slice(0, 500)}`;
27
- return capChars(text, { label: "WebFetch", maxChars });
28
- } catch (err) {
29
- return `Error fetching URL: ${err.message}`;
37
+ const restricted = policy !== undefined && policy.network.mode !== "all";
38
+ const maxRetries = Array.isArray(retryDelaysMs) ? retryDelaysMs.length : 0;
39
+
40
+ let lastErrorMessage = "request failed";
41
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
42
+ try {
43
+ const resp = restricted
44
+ ? await fetchCheckingRedirects(parsed, requestHeaders, policy)
45
+ : await fetch(url, { headers: requestHeaders, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
46
+ if (typeof resp === "string") return resp; // policy/redirect error — not retryable
47
+ // Transient server errors (5xx) are worth one more attempt.
48
+ if (resp.status >= 500 && resp.status < 600 && attempt < maxRetries) {
49
+ try { await resp.body?.cancel(); } catch { /* best-effort */ }
50
+ lastErrorMessage = `HTTP ${resp.status}`;
51
+ await fetchRetryDelay(retryDelaysMs[attempt]);
52
+ continue;
53
+ }
54
+ const text = await resp.text();
55
+ if (!resp.ok) return `HTTP ${resp.status}: ${text.slice(0, 500)}`;
56
+ return capChars(text, { label: "WebFetch", maxChars });
57
+ } catch (err) {
58
+ lastErrorMessage = err.message;
59
+ if (isTransientFetchError(err) && attempt < maxRetries) {
60
+ await fetchRetryDelay(retryDelaysMs[attempt]);
61
+ continue;
62
+ }
63
+ return `Error fetching URL: ${err.message}`;
64
+ }
30
65
  }
66
+ return `Error fetching URL: ${lastErrorMessage}`;
31
67
  }
32
68
 
33
69
  // fetch() follows redirects transparently, which would let an allowed host
package/src/ai/failure.js CHANGED
@@ -29,7 +29,7 @@ export const FAILURE_KINDS = [
29
29
  ];
30
30
 
31
31
  const USAGE_LIMIT_RE = /(rate limit|usage limit|max tokens|max turns|context length|too many tokens)/i;
32
- const PROVIDER_UNAVAILABLE_RE = /(econn|enotfound|etimedout|service unavailable|503|502|gateway|fetch failed|network|websocket)/i;
32
+ const PROVIDER_UNAVAILABLE_RE = /(econn|enotfound|etimedout|timed? ?out|service unavailable|503|502|gateway|fetch failed|network|websocket)/i;
33
33
  const TOOL_FAILURE_RE = /(tool .* failed|mcp tool|permission denied|EACCES|read-only file system)/i;
34
34
  const NON_RETRYABLE_PROVIDER_RE = /(invalid[_ ]request|unknown parameter|invalid api key|incorrect api key|authentication|authorization|not authorized|forbidden|billing|insufficient[_ ]quota|quota exceeded|model[_ ]not[_ ]found|unsupported model|permission denied|bad request|401|403|404)/i;
35
35
  const RETRYABLE_PROVIDER_RE = /(currently overloaded|server(?:s)? (?:is |are )?overloaded|try again later|retry your request|request id|service unavailable|temporar(?:y|ily)|timed? ?out|stream disconnected|fetch failed|econnreset|econnrefused|eai_again|enotfound|etimedout|network|429|too many requests|500|502|503|504|gateway|internal server error)/i;
package/src/ai/index.js CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  disposeProviderSession,
10
10
  } from "./runtime/sessions.js";
11
11
  export { createMetricsObserver, createObserverHub } from "./observer.js";
12
+ export { generatePiNativeResponse, piNativeRuntimeBridge } from "./providers/pi-native.js";
12
13
  export {
13
14
  buildCapabilitiesUsed,
14
15
  toolCompactionAppliedFromWarnings,
@@ -450,7 +450,11 @@ export async function generateCliResponse(systemPrompt, options = {}) {
450
450
  const mcpConfigPath = hasEntries(mcpServers) && resolved.sdk === "claude-code"
451
451
  ? join(dir, "mcp.json")
452
452
  : null;
453
- if (mcpConfigPath) writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2));
453
+ // Owner-only mode: this temp config carries full mcpServers env (including a
454
+ // resolved embedding apiKey) and lives under shared /tmp. The 0700 parent dir
455
+ // (mkdtempSync) already blocks cross-user traversal; 0o600 is defense-in-depth
456
+ // matching the idiom in packages/settings/src/json-source.ts and pi-auth.js.
457
+ if (mcpConfigPath) writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), { mode: 0o600 });
454
458
  const reusableSessionId = (typeof options.sessionId === "string" && options.sessionId.trim())
455
459
  || (typeof options.providerSessionId === "string" && options.providerSessionId.trim())
456
460
  || null;
@@ -0,0 +1,65 @@
1
+ // Shared pi error-normalization helpers.
2
+ //
3
+ // Extracted so the pi-native bridge does not have to import from a sibling
4
+ // provider module. Both the message-normalizer (unwrap nested provider error
5
+ // envelopes) and the context-limit classifier are pure string helpers with no
6
+ // runtime dependencies.
7
+
8
+ function tryParseJson(text) {
9
+ try { return JSON.parse(text); } catch { return null; }
10
+ }
11
+
12
+ export function normalizePiErrorMessage(message) {
13
+ const text = String(message || "").trim();
14
+ if (!text) return null;
15
+ const codexMatch = /^Codex error:\s*(\{[\s\S]*\})$/i.exec(text);
16
+ const parsed = tryParseJson(codexMatch ? codexMatch[1] : text);
17
+ const nested = parsed?.error || parsed;
18
+ if (typeof nested?.message === "string" && nested.message.trim()) return nested.message.trim();
19
+ if (typeof nested?.error?.message === "string" && nested.error.message.trim()) return nested.error.message.trim();
20
+ return text;
21
+ }
22
+
23
+ export function isContextLimitError(message) {
24
+ const text = String(message || "");
25
+ // Rate-limit wording takes precedence: it is a throttle, not a context overflow.
26
+ if (/rate limit|too many requests/i.test(text)) return false;
27
+ // Broadened to catch the many ways providers phrase a context/token overflow:
28
+ // "context length/window/budget", "max(imum) tokens", "token limit",
29
+ // "too many tokens", "prompt (is) too long", "exceeds the context/maximum/max",
30
+ // "input tokens/exceeds", "output token(s)", "token(s) exceed".
31
+ return /context[_ ](?:length|window|budget)|max(?:imum)?[_ ]?tokens?|token[_ ]limit|too[_ ]many[_ ]tokens?|prompt[_ ](?:is[_ ])?too[_ ]long|exceeds?[_ ](?:the context|maximum|max)|input[_ ](?:tokens?|exceeds)|output[_ ]tokens?|tokens?[_ ]exceed/i.test(text);
32
+ }
33
+
34
+ // Best-effort extraction of the model's real context-window ceiling from an
35
+ // overflow error. Providers usually state the limit ("maximum context length is
36
+ // 200000 tokens", "context window of 128000", "this model supports at most
37
+ // 32768 tokens"). We use the discovered value to lower the proactive compaction
38
+ // trigger on the running model so a wrong/default contextWindow self-corrects.
39
+ // Returns the smallest plausible token count found, or null.
40
+ export function parseContextLimitFromError(message) {
41
+ const text = String(message || "");
42
+ if (!text) return null;
43
+ // Capture a number that sits next to context/window/token wording, tolerating
44
+ // separators in large numbers (e.g. "128,000" or "128 000"). Phrases like
45
+ // "however you requested 210000 tokens" would over-count the limit, so we
46
+ // anchor on max/limit/context/window wording and take the smallest match.
47
+ const patterns = [
48
+ /(?:maximum|max(?:imum)?)\s+context\s+(?:length|window)\s*(?:is|of|=|:)?\s*([\d][\d,_ ]*)/ig,
49
+ /context\s+(?:length|window|budget)\s*(?:is|of|=|:)?\s*([\d][\d,_ ]*)/ig,
50
+ /(?:maximum|max(?:imum)?|at most|supports?(?:\s+up\s+to)?)\s+([\d][\d,_ ]*)\s*(?:input\s+)?tokens?/ig,
51
+ /(?:token|context)\s+limit\s*(?:is|of|=|:)?\s*([\d][\d,_ ]*)/ig,
52
+ ];
53
+ const found = [];
54
+ for (const re of patterns) {
55
+ let m;
56
+ while ((m = re.exec(text)) !== null) {
57
+ const n = Number(String(m[1]).replace(/[,_ ]/g, ""));
58
+ // Guard against matching small token counts (e.g. "8 tokens") that are not
59
+ // a real window; a context window is at least a few thousand tokens.
60
+ if (Number.isFinite(n) && n >= 1000) found.push(n);
61
+ }
62
+ }
63
+ if (found.length === 0) return null;
64
+ return Math.min(...found);
65
+ }