@mono-agent/agent-runtime 0.1.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.
Files changed (60) hide show
  1. package/ARCHITECTURE.md +219 -0
  2. package/LICENSE +674 -0
  3. package/README.md +430 -0
  4. package/package.json +46 -0
  5. package/src/agent/allowlists.js +49 -0
  6. package/src/agent/approval.js +211 -0
  7. package/src/agent/compaction.js +752 -0
  8. package/src/agent/index.js +40 -0
  9. package/src/agent/prompt/skill-index.js +66 -0
  10. package/src/agent/tool-bloat.js +164 -0
  11. package/src/agent/tools/bash.js +156 -0
  12. package/src/agent/tools/edit.js +15 -0
  13. package/src/agent/tools/glob.js +71 -0
  14. package/src/agent/tools/grep.js +84 -0
  15. package/src/agent/tools/index.js +17 -0
  16. package/src/agent/tools/pi-bridge.js +638 -0
  17. package/src/agent/tools/read.js +39 -0
  18. package/src/agent/tools/shared/constants.js +21 -0
  19. package/src/agent/tools/shared/dedup.js +31 -0
  20. package/src/agent/tools/shared/output-truncation.js +54 -0
  21. package/src/agent/tools/shared/path-resolver.js +156 -0
  22. package/src/agent/tools/shared/ripgrep.js +130 -0
  23. package/src/agent/tools/shared/runtime-context.js +69 -0
  24. package/src/agent/tools/web-fetch.js +59 -0
  25. package/src/agent/tools/web-search.js +21 -0
  26. package/src/agent/tools/write.js +14 -0
  27. package/src/agent/transcript.js +227 -0
  28. package/src/ai/backend.js +17 -0
  29. package/src/ai/cost.js +164 -0
  30. package/src/ai/failure.js +165 -0
  31. package/src/ai/file-change-stats.js +234 -0
  32. package/src/ai/index.js +16 -0
  33. package/src/ai/live-input-prompt.js +15 -0
  34. package/src/ai/observer.js +233 -0
  35. package/src/ai/providers/claude-cli.js +694 -0
  36. package/src/ai/providers/claude-sdk.js +864 -0
  37. package/src/ai/providers/claude-subagents.js +67 -0
  38. package/src/ai/providers/codex-app.js +1045 -0
  39. package/src/ai/providers/opencode-app.js +356 -0
  40. package/src/ai/providers/opencode-discovery.js +39 -0
  41. package/src/ai/providers/pi-events.js +62 -0
  42. package/src/ai/providers/pi-messages.js +68 -0
  43. package/src/ai/providers/pi-models.js +111 -0
  44. package/src/ai/providers/pi-sdk.js +1310 -0
  45. package/src/ai/registry.js +5 -0
  46. package/src/ai/runtime/capabilities-used.js +56 -0
  47. package/src/ai/runtime/capabilities.js +44 -0
  48. package/src/ai/runtime/context-windows.js +38 -0
  49. package/src/ai/runtime/fast-mode.js +8 -0
  50. package/src/ai/runtime/model-refs.js +144 -0
  51. package/src/ai/runtime/registry.js +57 -0
  52. package/src/ai/runtime/router.js +214 -0
  53. package/src/ai/runtime/sessions.js +126 -0
  54. package/src/ai/streaming/codex-events.js +139 -0
  55. package/src/ai/streaming/opencode-events.js +54 -0
  56. package/src/ai/types.js +70 -0
  57. package/src/index.js +23 -0
  58. package/src/pi-auth.js +80 -0
  59. package/src/runtime-brand.js +32 -0
  60. package/src/runtime.js +104 -0
@@ -0,0 +1,638 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ import { prepareSandboxedCommand } from "@mono-agent/sandbox";
7
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
9
+ import {
10
+ bashToolImpl,
11
+ editToolImpl,
12
+ globToolImpl,
13
+ grepToolImpl,
14
+ normalizeBashTimeoutMs,
15
+ readToolImpl,
16
+ webFetchToolImpl,
17
+ webSearchToolImpl,
18
+ writeToolImpl,
19
+ } from "./index.js";
20
+ import {
21
+ createFileEditToolResultEvent,
22
+ createFileEditToolUseEvent,
23
+ fileChangeSummary,
24
+ readFileChangeSnapshot,
25
+ statsForCompletedChange,
26
+ } from "../../ai/file-change-stats.js";
27
+ import { formatSkillBodyWithPathNote } from "../prompt/skill-index.js";
28
+ import { MAX_TOOL_RESULT_BYTES, summarisePayload, wrapToolsWithBloatGuard } from "../tool-bloat.js";
29
+ import { wrapToolsWithApprovalGate } from "../approval.js";
30
+ import { isInsidePath } from "./shared/path-resolver.js";
31
+ import { readRuntimeBrand, readToolRuntime, resolveSandboxPolicy } from "./shared/runtime-context.js";
32
+
33
+ function textResult(text, details = {}) {
34
+ return {
35
+ content: [{ type: "text", text: String(text ?? "") }],
36
+ details,
37
+ };
38
+ }
39
+
40
+ const MCP_TEXT_RESULT_LIMIT = 12_000;
41
+ const MCP_RAW_DETAIL_LIMIT = 4_000;
42
+ const MCP_IMAGE_INLINE_MAX_BYTES = 250_000;
43
+ const DEFAULT_BASH_TIMEOUT_MS = 120_000;
44
+
45
+ function objectSchema(properties, required = []) {
46
+ return { type: "object", properties, required, additionalProperties: false };
47
+ }
48
+
49
+ function stripFrontmatter(content) {
50
+ const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
51
+ return m ? content.slice(m[0].length).trim() : content.trim();
52
+ }
53
+
54
+ function isErrorText(value) {
55
+ return /^Error:|^Exit code \d+:/i.test(String(value || "").trim());
56
+ }
57
+
58
+ function absolutizePath(value, cwd) {
59
+ if (!value || typeof value !== "string" || isAbsolute(value) || !cwd) return value;
60
+ return resolve(cwd, value);
61
+ }
62
+
63
+ const PLAYWRIGHT_FILENAME_TOOLS = new Set([
64
+ "browser_console_messages",
65
+ "browser_snapshot",
66
+ "browser_take_screenshot",
67
+ ]);
68
+
69
+ function artifactFilename(filename, outputDir) {
70
+ if (!filename || typeof filename !== "string" || isAbsolute(filename) || !outputDir) return filename;
71
+ const base = resolve(outputDir);
72
+ const requested = resolve(base, filename);
73
+ const target = isInsidePath(base, requested) ? requested : resolve(base, basename(filename));
74
+ mkdirSync(dirname(target), { recursive: true });
75
+ return target;
76
+ }
77
+
78
+ export function normalizeMcpToolParams(_serverName, toolName, params, { qaOutputDir } = {}) {
79
+ if (!params || typeof params !== "object" || Array.isArray(params)) return params;
80
+ if (!PLAYWRIGHT_FILENAME_TOOLS.has(toolName) || !params.filename || isAbsolute(String(params.filename))) return params;
81
+ const dir = qaOutputDir ?? readToolRuntime().qaOutputDir;
82
+ return {
83
+ ...params,
84
+ filename: artifactFilename(params.filename, dir),
85
+ };
86
+ }
87
+
88
+ function normalizeWorkdir(value, cwd) {
89
+ const base = resolve(cwd || readToolRuntime().workspace || process.cwd());
90
+ const resolved = value ? resolve(absolutizePath(value, base)) : base;
91
+ return isInsidePath(base, resolved) ? resolved : base;
92
+ }
93
+
94
+ function withAbsolutePaths(name, params, cwd) {
95
+ const next = { ...(params || {}) };
96
+ if (["Read", "Write", "Edit"].includes(name)) next.file_path = absolutizePath(next.file_path, cwd);
97
+ if (["Glob", "Grep"].includes(name)) next.path = absolutizePath(next.path, cwd);
98
+ if (["Read", "Write", "Edit", "Glob", "Grep", "Bash"].includes(name)) {
99
+ next.workdir = normalizeWorkdir(next.workdir, cwd);
100
+ }
101
+ return next;
102
+ }
103
+
104
+ function toolText(result) {
105
+ if (typeof result === "string") return result;
106
+ if (result == null) return "";
107
+ try { return JSON.stringify(result); } catch { return String(result); }
108
+ }
109
+
110
+ function base64Bytes(data) {
111
+ const text = String(data || "");
112
+ if (!text) return 0;
113
+ const clean = text.includes(",") ? text.slice(text.indexOf(",") + 1) : text;
114
+ return Math.floor(clean.length * 0.75);
115
+ }
116
+
117
+ function truncateMcpText(text, limit = MCP_TEXT_RESULT_LIMIT) {
118
+ const value = String(text || "");
119
+ if (value.length <= limit) return { text: value, truncated: false, originalLength: value.length };
120
+ const marker = [
121
+ "",
122
+ `[truncated MCP tool result from ${value.length} to ${limit} characters]`,
123
+ "Use a more specific MCP tool, filters, or a detail/get tool for the exact item you need.",
124
+ ].join("\n");
125
+ return {
126
+ text: `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`,
127
+ truncated: true,
128
+ originalLength: value.length,
129
+ };
130
+ }
131
+
132
+ function compactRawMcpResult(out) {
133
+ let text;
134
+ try {
135
+ text = JSON.stringify(out || {});
136
+ } catch {
137
+ text = String(out ?? "");
138
+ }
139
+ if (text.length <= MCP_RAW_DETAIL_LIMIT) return out;
140
+ return {
141
+ truncated: true,
142
+ original_length: text.length,
143
+ preview: `${text.slice(0, MCP_RAW_DETAIL_LIMIT)}\n[truncated raw MCP result]`,
144
+ };
145
+ }
146
+
147
+ function fileEditPayload(change, { status, before, after, error } = {}) {
148
+ const lineStats = statsForCompletedChange(change, before, after);
149
+ const completedChange = lineStats ? { ...change, line_stats: lineStats } : change;
150
+ const summary = fileChangeSummary([completedChange]);
151
+ return {
152
+ changes: [completedChange],
153
+ status,
154
+ ...(summary ? { summary } : {}),
155
+ ...(error ? { error } : {}),
156
+ };
157
+ }
158
+
159
+ function limitedNumber(value, fallback) {
160
+ const n = Number(value);
161
+ if (!Number.isFinite(n) || n <= 0) return fallback;
162
+ return Math.min(Math.floor(n), fallback);
163
+ }
164
+
165
+ function withToolLimits(name, params, limits = {}) {
166
+ const next = { ...(params || {}) };
167
+ if (["Read", "Glob", "Grep", "WebFetch"].includes(name)) {
168
+ next.max_output_chars = limitedNumber(next.max_output_chars, limits.toolTextLimitChars || 16000);
169
+ }
170
+ if (name === "Glob") {
171
+ next.limit = limitedNumber(next.limit ?? next.max_matches, limits.searchResultLimit || 100);
172
+ delete next.max_matches;
173
+ }
174
+ if (name === "Grep") {
175
+ next.head_limit = limitedNumber(next.head_limit ?? next.max_matches, limits.searchResultLimit || 100);
176
+ next.output_mode = next.output_mode || "files_with_matches";
177
+ delete next.max_matches;
178
+ }
179
+ if (name === "Bash") {
180
+ next.max_output_chars = limitedNumber(next.max_output_chars, limits.bashOutputLimitChars || limits.toolTextLimitChars || 20000);
181
+ next.timeout = normalizeBashTimeoutMs(next.timeout, limits.bashTimeoutMs || DEFAULT_BASH_TIMEOUT_MS);
182
+ }
183
+ return next;
184
+ }
185
+
186
+ export function normalizePiBuiltinToolParams(name, params, { cwd, toolLimits } = {}) {
187
+ return withToolLimits(name, withAbsolutePaths(name, params, cwd), toolLimits);
188
+ }
189
+
190
+ function integerSchema() {
191
+ return { type: "integer" };
192
+ }
193
+
194
+ function isReadOnlyShellCommand(command) {
195
+ const text = String(command || "").trim();
196
+ if (!text) return false;
197
+ if (/[;&|`<>]|\$\(/.test(text)) return false;
198
+ return [
199
+ /^pwd(\s|$)/,
200
+ /^ls(\s|$)/,
201
+ /^find(\s|$)/,
202
+ /^rg(\s|$)/,
203
+ /^grep(\s|$)/,
204
+ /^sed(\s|$)/,
205
+ /^awk(\s|$)/,
206
+ /^cat(\s|$)/,
207
+ /^head(\s|$)/,
208
+ /^tail(\s|$)/,
209
+ /^wc(\s|$)/,
210
+ /^git\s+(status|diff|log|show|branch|rev-parse|ls-files)(\s|$)/,
211
+ ].some((pattern) => pattern.test(text));
212
+ }
213
+
214
+ function createBuiltinTool(name, label, description, parameters, execute, { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine } = {}) {
215
+ return {
216
+ name,
217
+ label,
218
+ description,
219
+ parameters,
220
+ executionMode: name === "Write" || name === "Edit" || name === "Bash" ? "sequential" : undefined,
221
+ async execute(toolCallId, params, signal) {
222
+ if (signal?.aborted) throw new Error("tool execution aborted");
223
+ const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits });
224
+ if (name === "Bash" && toolPolicy?.bashReadOnly && !isReadOnlyShellCommand(normalized.command)) {
225
+ throw new Error("Error: Planning shell policy allows only read-only inspection commands.");
226
+ }
227
+ const isFileEdit = name === "Write" || name === "Edit";
228
+ let editState = null;
229
+ if (isFileEdit && normalized.file_path) {
230
+ const before = readFileChangeSnapshot(normalized.file_path);
231
+ editState = {
232
+ path: normalized.file_path,
233
+ before,
234
+ change: {
235
+ path: normalized.file_path,
236
+ kind: name === "Write" && before && !before.exists ? "add" : "update",
237
+ },
238
+ };
239
+ onEvent?.(createFileEditToolUseEvent(`file_edit:${toolCallId}`, {
240
+ changes: [editState.change],
241
+ status: "in_progress",
242
+ }));
243
+ }
244
+
245
+ const raw = await execute(normalized, { signal, sandboxPolicy, sandboxEngine });
246
+ const text = toolText(raw);
247
+ if (isFileEdit && editState) {
248
+ const failed = isErrorText(text);
249
+ const after = readFileChangeSnapshot(editState.path);
250
+ onEvent?.(createFileEditToolResultEvent(
251
+ `file_edit:${toolCallId}`,
252
+ fileEditPayload(editState.change, {
253
+ status: failed ? "failed" : "completed",
254
+ before: editState.before,
255
+ after,
256
+ error: failed ? text : null,
257
+ }),
258
+ { isError: failed },
259
+ ));
260
+ }
261
+ if (isErrorText(text)) throw new Error(text);
262
+ return textResult(text, { tool: name, params: normalized });
263
+ },
264
+ };
265
+ }
266
+
267
+ function readSkillTool(skillNames = [], dataDir) {
268
+ const safe = skillNames.filter((name) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name));
269
+ if (!safe.length || !dataDir) return null;
270
+ return {
271
+ name: "read_skill",
272
+ label: "Read Skill",
273
+ description: "Load the full instructions for a named skill.",
274
+ parameters: objectSchema({ name: { type: "string", enum: safe } }, ["name"]),
275
+ async execute(_toolCallId, { name }) {
276
+ const path = resolve(dataDir, "skills", name, "SKILL.md");
277
+ const root = resolve(dataDir, "skills");
278
+ if (!path.startsWith(root + "/")) throw new Error(`invalid skill path: ${name}`);
279
+ if (!existsSync(path)) throw new Error(`SKILL.md not found for ${name}`);
280
+ return textResult(formatSkillBodyWithPathNote({
281
+ body: stripFrontmatter(readFileSync(path, "utf8")),
282
+ assetsPath: resolve(root, name),
283
+ skillsRoot: root,
284
+ }), { skill: name, path });
285
+ },
286
+ };
287
+ }
288
+
289
+ export function createStructuredOutputTool(outputSchema, onStructuredOutput) {
290
+ if (!outputSchema) return null;
291
+ return {
292
+ name: "StructuredOutput",
293
+ label: "Structured Output",
294
+ description: "Submit the final structured result object. Call this once when the response is complete.",
295
+ parameters: Type.Unsafe(outputSchema),
296
+ executionMode: "sequential",
297
+ async execute(_toolCallId, params) {
298
+ onStructuredOutput?.(params);
299
+ return {
300
+ content: [{ type: "text", text: "Structured output received." }],
301
+ details: params,
302
+ terminate: true,
303
+ };
304
+ },
305
+ };
306
+ }
307
+
308
+ export function getPiBuiltinTools(allowedTools, {
309
+ skillNames = [],
310
+ dataDir,
311
+ cwd,
312
+ onEvent,
313
+ toolLimits,
314
+ persistArtifact = null,
315
+ onTruncate = null,
316
+ toolPayloadMaxBytes = MAX_TOOL_RESULT_BYTES,
317
+ toolPolicy = null,
318
+ sandboxPolicy = null,
319
+ sandboxEngine = null,
320
+ approvalManager = null,
321
+ approvalModel = null,
322
+ } = {}) {
323
+ const textLimitSchema = integerSchema();
324
+ const bashLimitSchema = integerSchema();
325
+ const bashTimeoutSchema = {
326
+ type: "integer",
327
+ description: "Timeout in milliseconds. Use 30000 for 30 seconds; small values like 30 are treated as seconds for compatibility.",
328
+ };
329
+ const toolContext = { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine };
330
+ const all = {
331
+ Read: createBuiltinTool("Read", "Read", "Read a local file with line numbers.", objectSchema({
332
+ file_path: { type: "string" },
333
+ offset: { type: "integer" },
334
+ start_line: { type: "integer" },
335
+ limit: { type: "integer" },
336
+ max_output_chars: textLimitSchema,
337
+ }, ["file_path"]), readToolImpl, toolContext),
338
+ Write: createBuiltinTool("Write", "Write", "Write content to a local file.", objectSchema({
339
+ file_path: { type: "string" },
340
+ content: { type: "string" },
341
+ }, ["file_path", "content"]), writeToolImpl, toolContext),
342
+ Edit: createBuiltinTool("Edit", "Edit", "Replace an exact string in a local file.", objectSchema({
343
+ file_path: { type: "string" },
344
+ old_string: { type: "string" },
345
+ new_string: { type: "string" },
346
+ replace_all: { type: "boolean" },
347
+ }, ["file_path", "old_string", "new_string"]), editToolImpl, toolContext),
348
+ Glob: createBuiltinTool("Glob", "Glob", "Find files matching a pattern.", objectSchema({
349
+ pattern: { type: "string" },
350
+ path: { type: "string" },
351
+ limit: { type: "integer" },
352
+ offset: { type: "integer" },
353
+ max_matches: { type: "integer" },
354
+ max_output_chars: textLimitSchema,
355
+ }, ["pattern"]), globToolImpl, toolContext),
356
+ Grep: createBuiltinTool("Grep", "Grep", "Search file contents with ripgrep. Defaults to returning matching file paths; use output_mode='content' only for exact snippets.", objectSchema({
357
+ pattern: { type: "string" },
358
+ path: { type: "string" },
359
+ glob: { type: "string" },
360
+ type: { type: "string" },
361
+ output_mode: { type: "string", enum: ["files_with_matches", "content", "count"] },
362
+ context: { type: "integer" },
363
+ case_insensitive: { type: "boolean" },
364
+ multiline: { type: "boolean" },
365
+ head_limit: { type: "integer" },
366
+ offset: { type: "integer" },
367
+ max_matches: { type: "integer" },
368
+ max_output_chars: textLimitSchema,
369
+ }, ["pattern"]), grepToolImpl, toolContext),
370
+ Bash: createBuiltinTool("Bash", "Bash", "Execute a shell command in the workspace.", objectSchema({
371
+ command: { type: "string" },
372
+ workdir: { type: "string" },
373
+ description: { type: "string" },
374
+ timeout: bashTimeoutSchema,
375
+ max_output_chars: bashLimitSchema,
376
+ }, ["command"]), bashToolImpl, toolContext),
377
+ WebFetch: createBuiltinTool("WebFetch", "Web Fetch", "Fetch a URL and return text.", objectSchema({
378
+ url: { type: "string" },
379
+ headers: { type: "object", additionalProperties: { type: "string" } },
380
+ max_output_chars: textLimitSchema,
381
+ }, ["url"]), webFetchToolImpl, toolContext),
382
+ WebSearch: createBuiltinTool("WebSearch", "Web Search", "Search the web and return result summaries.", objectSchema({
383
+ query: { type: "string" },
384
+ limit: { type: "integer" },
385
+ }, ["query"]), webSearchToolImpl, toolContext),
386
+ };
387
+ const names = Array.isArray(allowedTools) ? allowedTools : Object.keys(all);
388
+ const tools = names.map((name) => all[name]).filter(Boolean);
389
+ const skillTool = readSkillTool(skillNames, dataDir);
390
+ if (skillTool) tools.push(skillTool);
391
+ const gated = approvalManager
392
+ ? wrapToolsWithApprovalGate(tools, approvalManager, { model: approvalModel })
393
+ : tools;
394
+ return wrapToolsWithBloatGuard(gated, {
395
+ persistArtifact,
396
+ maxBytes: toolPayloadMaxBytes,
397
+ onTruncate,
398
+ });
399
+ }
400
+
401
+ export function resolveMcpStdioCwd(cfg = {}, cwd = null) {
402
+ const configured = cfg.cwd || null;
403
+ if (configured && isAbsolute(configured)) return configured;
404
+ if (configured) return resolve(cwd || process.cwd(), configured);
405
+ return cwd || process.cwd();
406
+ }
407
+
408
+ export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPolicy = null, sandboxEngine = null } = {}) {
409
+ return prepareSandboxedCommand({
410
+ policy: resolveSandboxPolicy(sandboxPolicy),
411
+ engine: sandboxEngine ?? undefined,
412
+ command: {
413
+ command: cfg.command,
414
+ args: cfg.args || [],
415
+ cwd: resolveMcpStdioCwd(cfg, cwd),
416
+ ...(cfg.env && typeof cfg.env === "object" ? { env: cfg.env } : {}),
417
+ },
418
+ });
419
+ }
420
+
421
+ async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine } = {}) {
422
+ const brand = readRuntimeBrand();
423
+ const client = new McpClient(
424
+ { name: `${brand.mcpClientName}/${name}`, version: brand.mcpClientVersion },
425
+ { capabilities: {} },
426
+ );
427
+ let transport;
428
+ if (cfg.type === "http") {
429
+ transport = new StreamableHTTPClientTransport(new URL(cfg.url), { requestInit: { headers: cfg.headers || {} } });
430
+ } else if (cfg.type === "sse") {
431
+ transport = new SSEClientTransport(new URL(cfg.url), {
432
+ eventSourceInit: { headers: cfg.headers || {} },
433
+ requestInit: { headers: cfg.headers || {} },
434
+ });
435
+ } else {
436
+ const prepared = await prepareMcpStdioCommand(cfg, { cwd, sandboxPolicy, sandboxEngine });
437
+ transport = new StdioClientTransport({
438
+ command: prepared.command,
439
+ args: prepared.args || [],
440
+ cwd: prepared.cwd,
441
+ env: { ...process.env, ...(prepared.env || {}) },
442
+ });
443
+ transport.__monoSandboxCleanup = prepared.cleanup;
444
+ }
445
+ try {
446
+ await client.connect(transport);
447
+ return { name, client, transport };
448
+ } catch (error) {
449
+ try { await transport?.close?.(); } catch { /* best-effort */ }
450
+ try { await transport?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
451
+ throw error;
452
+ }
453
+ }
454
+
455
+ export function coerceMcpContent(out, {
456
+ textLimit = MCP_TEXT_RESULT_LIMIT,
457
+ imageInlineMaxBytes = MCP_IMAGE_INLINE_MAX_BYTES,
458
+ persistArtifact = null,
459
+ toolName = "mcp",
460
+ toolUseId = null,
461
+ onTruncate = null,
462
+ } = {}) {
463
+ if (Array.isArray(out?.content) && out.content.length) {
464
+ return out.content.map((part) => {
465
+ if (part.type === "text") return { type: "text", text: truncateMcpText(part.text || "", textLimit).text };
466
+ if (part.type === "image") {
467
+ const bytes = base64Bytes(part.data);
468
+ if (bytes > imageInlineMaxBytes) {
469
+ const summary = summarisePayload(toolName, [{
470
+ type: "image",
471
+ data: part.data,
472
+ mimeType: part.mimeType || part.mime_type || "image/png",
473
+ }], persistArtifact, { maxBytes: imageInlineMaxBytes, toolUseId });
474
+ if (summary.truncated && typeof onTruncate === "function") {
475
+ try {
476
+ onTruncate({
477
+ tool: toolName,
478
+ tool_use_id: toolUseId,
479
+ original_bytes: summary.originalBytes,
480
+ max_bytes: imageInlineMaxBytes,
481
+ saved_paths: summary.savedPaths,
482
+ });
483
+ } catch { /* best-effort */ }
484
+ }
485
+ return summary.rewrittenBlocks[0] || {
486
+ type: "text",
487
+ text: `[omitted MCP image result: ${bytes} bytes exceeds ${imageInlineMaxBytes} byte context budget]`,
488
+ };
489
+ }
490
+ return {
491
+ type: "image",
492
+ data: part.data,
493
+ mimeType: part.mimeType || part.mime_type || "image/png",
494
+ };
495
+ }
496
+ return { type: "text", text: truncateMcpText(JSON.stringify(part), textLimit).text };
497
+ });
498
+ }
499
+ return [{ type: "text", text: truncateMcpText(JSON.stringify(out || {}), textLimit).text }];
500
+ }
501
+
502
+ function mcpContentWasTruncated(out, { textLimit = MCP_TEXT_RESULT_LIMIT, imageInlineMaxBytes = MCP_IMAGE_INLINE_MAX_BYTES } = {}) {
503
+ if (Array.isArray(out?.content) && out.content.length) {
504
+ return out.content.some((part) => {
505
+ if (part.type === "text") return truncateMcpText(part.text || "", textLimit).truncated;
506
+ if (part.type === "image") return base64Bytes(part.data) > imageInlineMaxBytes;
507
+ return truncateMcpText(JSON.stringify(part), textLimit).truncated;
508
+ });
509
+ }
510
+ return truncateMcpText(JSON.stringify(out || {}), textLimit).truncated;
511
+ }
512
+
513
+ function mcpToolName(serverName, toolName, reservedNames) {
514
+ if (!reservedNames.has(toolName)) return toolName;
515
+ return `mcp__${serverName}__${toolName}`;
516
+ }
517
+
518
+ function withTimeout(promise, timeoutMs, signal, label) {
519
+ if (signal?.aborted) return Promise.reject(new Error("tool execution aborted"));
520
+ const ms = Number(timeoutMs) || 120000;
521
+ let timeout;
522
+ const timer = new Promise((_, reject) => {
523
+ timeout = setTimeout(() => reject(new Error(`${label || "MCP tool"} timed out after ${ms}ms`)), ms);
524
+ });
525
+ return Promise.race([promise, timer]).finally(() => clearTimeout(timeout));
526
+ }
527
+
528
+ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
529
+ limits = {},
530
+ cwd = null,
531
+ persistArtifact = null,
532
+ qaOutputDir = null,
533
+ onTruncate = null,
534
+ toolPayloadMaxBytes = MAX_TOOL_RESULT_BYTES,
535
+ sandboxPolicy = null,
536
+ sandboxEngine = null,
537
+ } = {}) {
538
+ const clients = [];
539
+ const tools = [];
540
+ const entries = Object.entries(mcpConfig || {});
541
+ const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine })));
542
+ const warnings = [];
543
+ const seen = new Set(reservedNames);
544
+
545
+ for (const [index, result] of settled.entries()) {
546
+ const serverName = entries[index]?.[0];
547
+ if (result.status !== "fulfilled") {
548
+ warnings.push({
549
+ type: "runtime_warning",
550
+ warning_kind: "mcp_init_failed",
551
+ server: serverName,
552
+ message: result.reason?.message || String(result.reason),
553
+ });
554
+ continue;
555
+ }
556
+
557
+ const connected = result.value;
558
+ clients.push(connected);
559
+ let listed;
560
+ try {
561
+ listed = await connected.client.listTools();
562
+ } catch (err) {
563
+ warnings.push({
564
+ type: "runtime_warning",
565
+ warning_kind: "mcp_list_tools_failed",
566
+ server: serverName,
567
+ message: err?.message || String(err),
568
+ });
569
+ continue;
570
+ }
571
+
572
+ for (const sourceTool of listed.tools || []) {
573
+ const name = mcpToolName(serverName, sourceTool.name, seen);
574
+ if (seen.has(name)) continue;
575
+ seen.add(name);
576
+ tools.push({
577
+ name,
578
+ label: sourceTool.title || sourceTool.name,
579
+ description: sourceTool.description || `${serverName}:${sourceTool.name}`,
580
+ parameters: sourceTool.inputSchema || sourceTool.input_schema || objectSchema({}),
581
+ async execute(toolCallId, params, signal) {
582
+ if (signal?.aborted) throw new Error("tool execution aborted");
583
+ const textLimit = limits.mcpTextLimitChars || MCP_TEXT_RESULT_LIMIT;
584
+ const imageInlineMaxBytes = limits.imageInlineMaxBytes ?? MCP_IMAGE_INLINE_MAX_BYTES;
585
+ const normalizedParams = normalizeMcpToolParams(serverName, sourceTool.name, params || {}, { qaOutputDir });
586
+ const out = await withTimeout(
587
+ connected.client.callTool({ name: sourceTool.name, arguments: normalizedParams || {} }),
588
+ limits.mcpCallTimeoutMs || 120000,
589
+ signal,
590
+ `${serverName}:${sourceTool.name}`,
591
+ );
592
+ const imageTruncations = [];
593
+ return {
594
+ content: coerceMcpContent(out, {
595
+ textLimit,
596
+ imageInlineMaxBytes,
597
+ persistArtifact,
598
+ toolName: name,
599
+ toolUseId: toolCallId,
600
+ onTruncate: (event) => {
601
+ imageTruncations.push(event);
602
+ onTruncate?.(event);
603
+ },
604
+ }),
605
+ details: {
606
+ server: serverName,
607
+ tool: sourceTool.name,
608
+ result_truncated: mcpContentWasTruncated(out, { textLimit, imageInlineMaxBytes }),
609
+ raw: compactRawMcpResult(out),
610
+ ...(imageTruncations.length ? {
611
+ tool_payload_truncated: true,
612
+ tool_payload_original_bytes: imageTruncations.reduce((sum, event) => sum + (Number(event.original_bytes) || 0), 0),
613
+ tool_payload_saved_paths: imageTruncations.flatMap((event) => event.saved_paths || []),
614
+ } : {}),
615
+ },
616
+ };
617
+ },
618
+ });
619
+ }
620
+ }
621
+ return {
622
+ clients,
623
+ tools: wrapToolsWithBloatGuard(tools, {
624
+ persistArtifact,
625
+ maxBytes: toolPayloadMaxBytes,
626
+ onTruncate,
627
+ }),
628
+ warnings,
629
+ };
630
+ }
631
+
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 */ }
637
+ }
638
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import {
3
+ DEFAULT_MAX_READ_CHARS,
4
+ DEFAULT_READ_LINES,
5
+ MAX_READ_LINES,
6
+ } from "./shared/constants.js";
7
+ import { boundedInt, rememberRead, trimLine } from "./shared/dedup.js";
8
+ import { capChars } from "./shared/output-truncation.js";
9
+ import { isPathAllowed, resolveToolPath } from "./shared/path-resolver.js";
10
+
11
+ export async function readToolImpl({ file_path, offset = 0, start_line, limit, max_output_chars, workdir }, { sandboxPolicy } = {}) {
12
+ const target = resolveToolPath(file_path, workdir);
13
+ if (!isPathAllowed(target, workdir, { sandboxPolicy })) return `Error: Path not allowed: ${file_path}`;
14
+ if (!existsSync(target)) return `Error: File not found: ${file_path}`;
15
+ const content = readFileSync(target, "utf8");
16
+ let lines = content.split("\n");
17
+ const total = lines.length;
18
+ const explicitStartLine = Number(start_line);
19
+ const start = Number.isInteger(explicitStartLine) && explicitStartLine > 0
20
+ ? explicitStartLine - 1
21
+ : Math.max(0, Number(offset) || 0);
22
+ const requested = limit == null
23
+ ? DEFAULT_READ_LINES
24
+ : boundedInt(limit, DEFAULT_READ_LINES, { min: 1, max: MAX_READ_LINES });
25
+ const requestedExceeded = limit != null && Number(limit) > MAX_READ_LINES;
26
+ lines = lines.slice(start, start + requested);
27
+ const repeated = rememberRead(target, start, requested);
28
+ const numbered = lines.map((line, i) => `${start + i + 1}\t${trimLine(line)}`).join("\n");
29
+ const nextLine = start + lines.length + 1;
30
+ const notes = [];
31
+ if (requestedExceeded) notes.push(`Requested limit was capped at ${MAX_READ_LINES} lines.`);
32
+ if (nextLine <= total) notes.push(`Next unread line: ${nextLine}. Continue with offset=${nextLine - 1} or start_line=${nextLine}.`);
33
+ if (repeated) notes.push("This exact file range was already read in this process; use a narrower or later range if you need new context.");
34
+ return capChars(`${numbered}${notes.length ? `\n\n${notes.join("\n")}` : ""}`, {
35
+ label: "Read",
36
+ maxChars: Number(max_output_chars) || DEFAULT_MAX_READ_CHARS,
37
+ hint: "Use Read with offset/start_line and limit for the specific range you need.",
38
+ });
39
+ }
@@ -0,0 +1,21 @@
1
+ export const DEFAULT_READ_LINES = 240;
2
+ export const MAX_READ_LINES = 500;
3
+ export const MAX_READ_LINE_CHARS = 2_000;
4
+ export const DEFAULT_MAX_READ_CHARS = 16_000;
5
+ export const DEFAULT_MAX_TOOL_OUTPUT_CHARS = 16_000;
6
+ export const DEFAULT_MAX_BASH_OUTPUT_CHARS = 20_000;
7
+ export const MAX_WRITE_BYTES = 10 * 1024 * 1024;
8
+ export const DEFAULT_EXCLUDED_DIRS = [
9
+ ".git",
10
+ "node_modules",
11
+ "dist",
12
+ "coverage",
13
+ "playwright-report",
14
+ "test-results",
15
+ ".cache",
16
+ ];
17
+ export const DEFAULT_EXCLUDED_FILES = ["*.map"];
18
+ export const DEFAULT_MAX_SEARCH_LINES = 100;
19
+ export const DEFAULT_MAX_SEARCH_CHARS = 16_000;
20
+ export const SEARCH_MAX_BUFFER = 4 * 1024 * 1024;
21
+ export const READ_HISTORY_LIMIT = 200;