@mono-agent/agent-runtime 0.2.2 → 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.
- package/ARCHITECTURE.md +48 -3
- package/MIGRATION.md +115 -0
- package/README.md +24 -8
- package/package.json +3 -2
- package/src/agent/compaction.js +11 -677
- package/src/agent/index.js +1 -1
- package/src/agent/tools/pi-bridge.js +46 -10
- package/src/agent/tools/web-fetch.js +48 -12
- package/src/ai/failure.js +1 -1
- package/src/ai/index.js +1 -0
- package/src/ai/providers/claude-cli.js +5 -1
- package/src/ai/providers/pi-errors.js +65 -0
- package/src/ai/providers/pi-native.js +1445 -0
- package/src/ai/providers/pi-sdk.js +26 -1301
- package/src/ai/runtime/capabilities.js +3 -0
- package/src/ai/runtime/registry.js +4 -2
- package/src/ai/runtime/router.js +23 -7
- package/src/ai/runtime/sessions.js +3 -2
- package/src/ai/types.js +0 -1
- package/src/runtime.js +3 -2
package/src/agent/index.js
CHANGED
|
@@ -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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
+
}
|