@mono-agent/agent-runtime 0.4.0 → 0.4.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.
- package/ARCHITECTURE.md +37 -16
- package/MIGRATION.md +185 -7
- package/README.md +24 -14
- package/package.json +103 -10
- package/src/agent/allowlists.js +3 -0
- package/src/agent/approval.js +3 -0
- package/src/agent/compaction.js +244 -1
- package/src/agent/prompt/skill-index.js +6 -0
- package/src/agent/sandbox-seam.js +227 -0
- package/src/agent/tool-bloat.js +8 -2
- package/src/agent/tools/bash.js +16 -8
- package/src/agent/tools/edit.js +7 -3
- package/src/agent/tools/glob.js +11 -5
- package/src/agent/tools/grep.js +11 -5
- package/src/agent/tools/pi-bridge.js +227 -45
- package/src/agent/tools/read.js +28 -3
- package/src/agent/tools/shared/output-truncation.js +8 -3
- package/src/agent/tools/shared/path-resolver.js +24 -18
- package/src/agent/tools/shared/ripgrep.js +33 -6
- package/src/agent/tools/shared/runtime-context.js +60 -50
- package/src/agent/tools/shared/tool-context.js +157 -0
- package/src/agent/tools/web-fetch.js +21 -10
- package/src/agent/tools/web-search.js +11 -4
- package/src/agent/tools/write.js +7 -3
- package/src/agent/transcript.js +16 -1
- package/src/ai/cost.js +128 -3
- package/src/ai/failure.js +96 -4
- package/src/ai/live-input-prompt.js +11 -1
- package/src/ai/providers/claude-cli.js +2 -2
- package/src/ai/providers/claude-sdk.js +55 -12
- package/src/ai/providers/codex-app.js +36 -23
- package/src/ai/providers/opencode-app.js +2 -2
- package/src/ai/providers/opencode-discovery.js +2 -2
- package/src/ai/providers/pi-events.js +5 -0
- package/src/ai/providers/pi-models.js +21 -4
- package/src/ai/providers/pi-native/compaction-driver.js +394 -0
- package/src/ai/providers/pi-native/result-builder.js +312 -0
- package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
- package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
- package/src/ai/providers/pi-native/structured-output.js +130 -0
- package/src/ai/providers/pi-native/turn-runner.js +278 -0
- package/src/ai/providers/pi-native.js +415 -1106
- package/src/ai/runtime/model-refs.js +30 -0
- package/src/ai/runtime/registry.js +29 -0
- package/src/ai/runtime/router.js +96 -12
- package/src/ai/runtime/session-liveness.js +110 -0
- package/src/ai/runtime/sessions.js +16 -0
- package/src/ai/types.js +252 -19
- package/src/index.js +1 -1
- package/src/pi-auth.js +147 -16
- package/src/runtime-brand.js +21 -1
- package/src/runtime.js +72 -8
- package/types/agent/allowlists.d.ts +25 -0
- package/types/agent/approval.d.ts +30 -0
- package/types/agent/compaction.d.ts +97 -0
- package/types/agent/index.d.ts +5 -0
- package/types/agent/prompt/skill-index.d.ts +19 -0
- package/types/agent/sandbox-seam.d.ts +148 -0
- package/types/agent/tool-bloat.d.ts +22 -0
- package/types/agent/tools/bash.d.ts +16 -0
- package/types/agent/tools/edit.d.ts +14 -0
- package/types/agent/tools/glob.d.ts +16 -0
- package/types/agent/tools/grep.d.ts +22 -0
- package/types/agent/tools/index.d.ts +10 -0
- package/types/agent/tools/pi-bridge.d.ts +144 -0
- package/types/agent/tools/read.d.ts +19 -0
- package/types/agent/tools/shared/constants.d.ts +13 -0
- package/types/agent/tools/shared/dedup.d.ts +8 -0
- package/types/agent/tools/shared/output-truncation.d.ts +22 -0
- package/types/agent/tools/shared/path-resolver.d.ts +6 -0
- package/types/agent/tools/shared/ripgrep.d.ts +38 -0
- package/types/agent/tools/shared/runtime-context.d.ts +27 -0
- package/types/agent/tools/shared/tool-context.d.ts +48 -0
- package/types/agent/tools/web-fetch.d.ts +13 -0
- package/types/agent/tools/web-search.d.ts +11 -0
- package/types/agent/tools/write.d.ts +12 -0
- package/types/agent/transcript.d.ts +45 -0
- package/types/ai/backend.d.ts +41 -0
- package/types/ai/cost.d.ts +96 -0
- package/types/ai/failure.d.ts +117 -0
- package/types/ai/file-change-stats.d.ts +75 -0
- package/types/ai/index.d.ts +7 -0
- package/types/ai/live-input-prompt.d.ts +6 -0
- package/types/ai/observer.d.ts +68 -0
- package/types/ai/providers/claude-cli.d.ts +211 -0
- package/types/ai/providers/claude-sdk.d.ts +66 -0
- package/types/ai/providers/claude-subagents.d.ts +2 -0
- package/types/ai/providers/codex-app.d.ts +151 -0
- package/types/ai/providers/opencode-app.d.ts +95 -0
- package/types/ai/providers/opencode-discovery.d.ts +4 -0
- package/types/ai/providers/pi-errors.d.ts +3 -0
- package/types/ai/providers/pi-events.d.ts +24 -0
- package/types/ai/providers/pi-messages.d.ts +5 -0
- package/types/ai/providers/pi-models.d.ts +57 -0
- package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
- package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
- package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
- package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
- package/types/ai/providers/pi-native.d.ts +18 -0
- package/types/ai/registry.d.ts +1 -0
- package/types/ai/runtime/capabilities-used.d.ts +21 -0
- package/types/ai/runtime/capabilities.d.ts +33 -0
- package/types/ai/runtime/context-windows.d.ts +8 -0
- package/types/ai/runtime/fast-mode.d.ts +2 -0
- package/types/ai/runtime/model-refs.d.ts +35 -0
- package/types/ai/runtime/registry.d.ts +38 -0
- package/types/ai/runtime/router.d.ts +56 -0
- package/types/ai/runtime/session-liveness.d.ts +55 -0
- package/types/ai/runtime/sessions.d.ts +38 -0
- package/types/ai/streaming/codex-events.d.ts +30 -0
- package/types/ai/streaming/opencode-events.d.ts +41 -0
- package/types/ai/types.d.ts +702 -0
- package/types/index.d.ts +7 -0
- package/types/pi-auth.d.ts +28 -0
- package/types/runtime-brand.d.ts +30 -0
- package/types/runtime.d.ts +10 -0
- package/src/ai/providers/pi-sdk.js +0 -35
package/src/agent/tools/glob.js
CHANGED
|
@@ -24,9 +24,13 @@ import {
|
|
|
24
24
|
|
|
25
25
|
const execFileAsync = promisify(execFile);
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* @param {{pattern: string, path?: string, limit?: number, offset?: number, max_matches?: number, max_output_chars?: number, workdir?: string}} params
|
|
29
|
+
* @param {{sandboxPolicy?: any, ctx?: any}} [options]
|
|
30
|
+
*/
|
|
31
|
+
export async function globToolImpl({ pattern, path, limit, offset = 0, max_matches, max_output_chars, workdir }, { sandboxPolicy, ctx } = {}) {
|
|
32
|
+
const cwd = resolveToolPath(path || workspaceRoot(workdir, ctx), workdir, ctx);
|
|
33
|
+
if (!isPathAllowed(cwd, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${cwd}`;
|
|
30
34
|
const stat = safeStat(cwd);
|
|
31
35
|
if (!stat?.isDirectory()) return `Error: Glob path is not a directory: ${cwd}`;
|
|
32
36
|
const resultLimit = boundedInt(limit ?? max_matches, DEFAULT_MAX_SEARCH_LINES, { min: 1, max: 1000 });
|
|
@@ -38,8 +42,8 @@ export async function globToolImpl({ pattern, path, limit, offset = 0, max_match
|
|
|
38
42
|
normalizeGlobPattern(pattern),
|
|
39
43
|
...excludedGlobArgs(),
|
|
40
44
|
];
|
|
41
|
-
const rgPath = resolveRgPath();
|
|
42
|
-
if (!rgPath) return ripgrepMissingMessage();
|
|
45
|
+
const rgPath = resolveRgPath({ ctx });
|
|
46
|
+
if (!rgPath) return ripgrepMissingMessage(ctx);
|
|
43
47
|
try {
|
|
44
48
|
const { stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER });
|
|
45
49
|
const lines = stdout.trim().split("\n").filter(Boolean).sort((a, b) => {
|
|
@@ -53,6 +57,7 @@ export async function globToolImpl({ pattern, path, limit, offset = 0, max_match
|
|
|
53
57
|
maxLines: resultLimit,
|
|
54
58
|
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
55
59
|
offset,
|
|
60
|
+
ctx,
|
|
56
61
|
});
|
|
57
62
|
return result === "No files found matching pattern." ? result : `${result}\n\n${excludedPathSummary()}`;
|
|
58
63
|
} catch (err) {
|
|
@@ -64,6 +69,7 @@ export async function globToolImpl({ pattern, path, limit, offset = 0, max_match
|
|
|
64
69
|
maxLines: resultLimit,
|
|
65
70
|
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
66
71
|
offset,
|
|
72
|
+
ctx,
|
|
67
73
|
})}\n\n${excludedPathSummary()}`;
|
|
68
74
|
}
|
|
69
75
|
return `Error: ${err.message}`;
|
package/src/agent/tools/grep.js
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const execFileAsync = promisify(execFile);
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @param {{pattern: string, path?: string, glob?: string, type?: string, output_mode?: string, context?: number, case_insensitive?: boolean, multiline?: boolean, head_limit?: number, offset?: number, max_matches?: number, max_output_chars?: number, workdir?: string}} params
|
|
27
|
+
* @param {{sandboxPolicy?: any, ctx?: any}} [options]
|
|
28
|
+
*/
|
|
25
29
|
export async function grepToolImpl({
|
|
26
30
|
pattern,
|
|
27
31
|
path,
|
|
@@ -36,9 +40,9 @@ export async function grepToolImpl({
|
|
|
36
40
|
max_matches,
|
|
37
41
|
max_output_chars,
|
|
38
42
|
workdir,
|
|
39
|
-
}, { sandboxPolicy } = {}) {
|
|
40
|
-
const target = resolveToolPath(path || workspaceRoot(workdir), workdir);
|
|
41
|
-
if (!isPathAllowed(target, workdir, { sandboxPolicy })) return `Error: Path not allowed: ${target}`;
|
|
43
|
+
}, { sandboxPolicy, ctx } = {}) {
|
|
44
|
+
const target = resolveToolPath(path || workspaceRoot(workdir, ctx), workdir, ctx);
|
|
45
|
+
if (!isPathAllowed(target, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${target}`;
|
|
42
46
|
const stat = safeStat(target);
|
|
43
47
|
if (!stat) return `Error: Path not found: ${target}`;
|
|
44
48
|
const cwd = stat.isDirectory() ? target : dirname(target);
|
|
@@ -55,8 +59,8 @@ export async function grepToolImpl({
|
|
|
55
59
|
if (type) args.push("--type", type);
|
|
56
60
|
args.push(...excludedGlobArgs(), "--", pattern, searchTarget);
|
|
57
61
|
const resultLimit = boundedInt(head_limit ?? max_matches, DEFAULT_MAX_SEARCH_LINES, { min: 1, max: 1000 });
|
|
58
|
-
const rgPath = resolveRgPath();
|
|
59
|
-
if (!rgPath) return ripgrepMissingMessage();
|
|
62
|
+
const rgPath = resolveRgPath({ ctx });
|
|
63
|
+
if (!rgPath) return ripgrepMissingMessage(ctx);
|
|
60
64
|
try {
|
|
61
65
|
const { stdout } = await execFileAsync(rgPath, args, { cwd, timeout: 15000, maxBuffer: SEARCH_MAX_BUFFER });
|
|
62
66
|
const normalized = stdout.trim().split("\n").filter(Boolean).map((line) => line.replace(/^\.\//, ""));
|
|
@@ -66,6 +70,7 @@ export async function grepToolImpl({
|
|
|
66
70
|
maxLines: resultLimit,
|
|
67
71
|
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
68
72
|
offset,
|
|
73
|
+
ctx,
|
|
69
74
|
});
|
|
70
75
|
return formatted === "No matches found." ? formatted : `${formatted}\n\n${excludedPathSummary()}`;
|
|
71
76
|
} catch (err) {
|
|
@@ -77,6 +82,7 @@ export async function grepToolImpl({
|
|
|
77
82
|
maxLines: resultLimit,
|
|
78
83
|
maxChars: Number(max_output_chars) || DEFAULT_MAX_SEARCH_CHARS,
|
|
79
84
|
offset,
|
|
85
|
+
ctx,
|
|
80
86
|
})}\n\n${excludedPathSummary()}`;
|
|
81
87
|
}
|
|
82
88
|
return `Error: ${err.message}`;
|
|
@@ -3,7 +3,7 @@ import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
3
3
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
4
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
5
5
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
-
import {
|
|
6
|
+
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
9
9
|
import {
|
|
@@ -28,7 +28,8 @@ import { formatSkillBodyWithPathNote } from "../prompt/skill-index.js";
|
|
|
28
28
|
import { MAX_TOOL_RESULT_BYTES, summarisePayload, wrapToolsWithBloatGuard } from "../tool-bloat.js";
|
|
29
29
|
import { wrapToolsWithApprovalGate } from "../approval.js";
|
|
30
30
|
import { isInsidePath } from "./shared/path-resolver.js";
|
|
31
|
-
import {
|
|
31
|
+
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
32
|
+
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
32
33
|
|
|
33
34
|
function textResult(text, details = {}) {
|
|
34
35
|
return {
|
|
@@ -37,7 +38,21 @@ function textResult(text, details = {}) {
|
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function imageResult(data, mimeType, details = {}) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "image", data: String(data ?? ""), mimeType: mimeType || "image/png" }],
|
|
44
|
+
details,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isImageToolResult(raw) {
|
|
49
|
+
return Boolean(raw) && typeof raw === "object" && raw.kind === "image" && typeof raw.data === "string";
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
const MCP_TEXT_RESULT_LIMIT = 12_000;
|
|
53
|
+
// Fallback hard cap per MCP call when limits.mcpCallMaxTotalTimeoutMs is not
|
|
54
|
+
// provided; mirrors DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS in agent/compaction.js.
|
|
55
|
+
const DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 2_700_000;
|
|
41
56
|
const MCP_RAW_DETAIL_LIMIT = 4_000;
|
|
42
57
|
const MCP_IMAGE_INLINE_MAX_BYTES = 250_000;
|
|
43
58
|
const DEFAULT_BASH_TIMEOUT_MS = 120_000;
|
|
@@ -75,28 +90,34 @@ function artifactFilename(filename, outputDir) {
|
|
|
75
90
|
return target;
|
|
76
91
|
}
|
|
77
92
|
|
|
78
|
-
|
|
93
|
+
/**
|
|
94
|
+
* @param {any} _serverName
|
|
95
|
+
* @param {any} toolName
|
|
96
|
+
* @param {any} params
|
|
97
|
+
* @param {{qaOutputDir?: any, ctx?: any}} [options]
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeMcpToolParams(_serverName, toolName, params, { qaOutputDir, ctx } = {}) {
|
|
79
100
|
if (!params || typeof params !== "object" || Array.isArray(params)) return params;
|
|
80
101
|
if (!PLAYWRIGHT_FILENAME_TOOLS.has(toolName) || !params.filename || isAbsolute(String(params.filename))) return params;
|
|
81
|
-
const dir = qaOutputDir ?? readToolRuntime().qaOutputDir;
|
|
102
|
+
const dir = qaOutputDir ?? (ctx ?? readToolRuntime()).qaOutputDir;
|
|
82
103
|
return {
|
|
83
104
|
...params,
|
|
84
105
|
filename: artifactFilename(params.filename, dir),
|
|
85
106
|
};
|
|
86
107
|
}
|
|
87
108
|
|
|
88
|
-
function normalizeWorkdir(value, cwd) {
|
|
89
|
-
const base = resolve(cwd || readToolRuntime().workspace || process.cwd());
|
|
109
|
+
function normalizeWorkdir(value, cwd, ctx) {
|
|
110
|
+
const base = resolve(cwd || (ctx ?? readToolRuntime()).workspace || process.cwd());
|
|
90
111
|
const resolved = value ? resolve(absolutizePath(value, base)) : base;
|
|
91
112
|
return isInsidePath(base, resolved) ? resolved : base;
|
|
92
113
|
}
|
|
93
114
|
|
|
94
|
-
function withAbsolutePaths(name, params, cwd) {
|
|
115
|
+
function withAbsolutePaths(name, params, cwd, ctx) {
|
|
95
116
|
const next = { ...(params || {}) };
|
|
96
117
|
if (["Read", "Write", "Edit"].includes(name)) next.file_path = absolutizePath(next.file_path, cwd);
|
|
97
118
|
if (["Glob", "Grep"].includes(name)) next.path = absolutizePath(next.path, cwd);
|
|
98
119
|
if (["Read", "Write", "Edit", "Glob", "Grep", "Bash"].includes(name)) {
|
|
99
|
-
next.workdir = normalizeWorkdir(next.workdir, cwd);
|
|
120
|
+
next.workdir = normalizeWorkdir(next.workdir, cwd, ctx);
|
|
100
121
|
}
|
|
101
122
|
return next;
|
|
102
123
|
}
|
|
@@ -144,6 +165,10 @@ function compactRawMcpResult(out) {
|
|
|
144
165
|
};
|
|
145
166
|
}
|
|
146
167
|
|
|
168
|
+
/**
|
|
169
|
+
* @param {any} change
|
|
170
|
+
* @param {{status?: any, before?: any, after?: any, error?: any}} [options]
|
|
171
|
+
*/
|
|
147
172
|
function fileEditPayload(change, { status, before, after, error } = {}) {
|
|
148
173
|
const lineStats = statsForCompletedChange(change, before, after);
|
|
149
174
|
const completedChange = lineStats ? { ...change, line_stats: lineStats } : change;
|
|
@@ -183,8 +208,13 @@ function withToolLimits(name, params, limits = {}) {
|
|
|
183
208
|
return next;
|
|
184
209
|
}
|
|
185
210
|
|
|
186
|
-
|
|
187
|
-
|
|
211
|
+
/**
|
|
212
|
+
* @param {any} name
|
|
213
|
+
* @param {any} params
|
|
214
|
+
* @param {{cwd?: any, toolLimits?: any, ctx?: any}} [options]
|
|
215
|
+
*/
|
|
216
|
+
export function normalizePiBuiltinToolParams(name, params, { cwd, toolLimits, ctx } = {}) {
|
|
217
|
+
return withToolLimits(name, withAbsolutePaths(name, params, cwd, ctx), toolLimits);
|
|
188
218
|
}
|
|
189
219
|
|
|
190
220
|
function integerSchema() {
|
|
@@ -211,7 +241,15 @@ function isReadOnlyShellCommand(command) {
|
|
|
211
241
|
].some((pattern) => pattern.test(text));
|
|
212
242
|
}
|
|
213
243
|
|
|
214
|
-
|
|
244
|
+
/**
|
|
245
|
+
* @param {any} name
|
|
246
|
+
* @param {any} label
|
|
247
|
+
* @param {any} description
|
|
248
|
+
* @param {any} parameters
|
|
249
|
+
* @param {any} execute
|
|
250
|
+
* @param {{cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
251
|
+
*/
|
|
252
|
+
function createBuiltinTool(name, label, description, parameters, execute, { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine, ctx } = {}) {
|
|
215
253
|
return {
|
|
216
254
|
name,
|
|
217
255
|
label,
|
|
@@ -220,7 +258,7 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
|
|
|
220
258
|
executionMode: name === "Write" || name === "Edit" || name === "Bash" ? "sequential" : undefined,
|
|
221
259
|
async execute(toolCallId, params, signal) {
|
|
222
260
|
if (signal?.aborted) throw new Error("tool execution aborted");
|
|
223
|
-
const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits });
|
|
261
|
+
const normalized = normalizePiBuiltinToolParams(name, params, { cwd, toolLimits, ctx });
|
|
224
262
|
if (name === "Bash" && toolPolicy?.bashReadOnly && !isReadOnlyShellCommand(normalized.command)) {
|
|
225
263
|
throw new Error("Error: Planning shell policy allows only read-only inspection commands.");
|
|
226
264
|
}
|
|
@@ -242,7 +280,13 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
|
|
|
242
280
|
}));
|
|
243
281
|
}
|
|
244
282
|
|
|
245
|
-
const raw = await execute(normalized, { signal, sandboxPolicy, sandboxEngine });
|
|
283
|
+
const raw = await execute(normalized, { signal, sandboxPolicy, sandboxEngine, ctx });
|
|
284
|
+
// Image reads (e.g. Read on a .png) come back as a structured image
|
|
285
|
+
// result so vision models see pixels; emit an image content block and let
|
|
286
|
+
// the shared bloat guard cap oversize payloads.
|
|
287
|
+
if (isImageToolResult(raw)) {
|
|
288
|
+
return imageResult(raw.data, raw.mimeType, { tool: name, params: normalized });
|
|
289
|
+
}
|
|
246
290
|
const text = toolText(raw);
|
|
247
291
|
if (isFileEdit && editState) {
|
|
248
292
|
const failed = isErrorText(text);
|
|
@@ -264,24 +308,81 @@ function createBuiltinTool(name, label, description, parameters, execute, { cwd,
|
|
|
264
308
|
};
|
|
265
309
|
}
|
|
266
310
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Progressive skill disclosure: exposes a `read_skill` tool so the agent can pull
|
|
313
|
+
* a named skill's FULL body on demand (skills are otherwise injected index-only).
|
|
314
|
+
*
|
|
315
|
+
* Two input shapes are accepted, matching what hosts pass:
|
|
316
|
+
* - Minimal / legacy (mono-agent's agent-harness): bare `skillNames` + a shared
|
|
317
|
+
* `skillsRoot` (the directory that directly contains `<name>/SKILL.md`), or the
|
|
318
|
+
* back-compat `dataDir` (skills under `<dataDir>/skills`). This path is UNCHANGED.
|
|
319
|
+
* - pi's neutral `Skill` shape (`{name, description, content, filePath, ...}`,
|
|
320
|
+
* what worklab passes): each skill carries an absolute `filePath`. When NO shared
|
|
321
|
+
* `skillsRoot`/`dataDir` is configured, read_skill derives each skill's root from
|
|
322
|
+
* its own `filePath` — pi has no lazy-body equivalent, so the body is still read
|
|
323
|
+
* from disk on demand rather than injected up front.
|
|
324
|
+
*
|
|
325
|
+
* The path-traversal guard (resolved file must stay under the shared root) is
|
|
326
|
+
* preserved on the shared-root path; on the filePath path the name→file binding is
|
|
327
|
+
* fixed at build time and the model can only pick an enum value, so there is no
|
|
328
|
+
* name-driven traversal surface.
|
|
329
|
+
*/
|
|
330
|
+
/**
|
|
331
|
+
* @param {any[]} [skillNames]
|
|
332
|
+
* @param {{skillsRoot?: any, dataDir?: any, skills?: any[]}} [options]
|
|
333
|
+
*/
|
|
334
|
+
function readSkillTool(skillNames = [], { skillsRoot, dataDir, skills = [] } = {}) {
|
|
335
|
+
const sharedRoot = skillsRoot
|
|
336
|
+
? resolve(skillsRoot)
|
|
337
|
+
: (dataDir ? resolve(dataDir, "skills") : null);
|
|
338
|
+
const isSafeName = (name) => typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name);
|
|
339
|
+
|
|
340
|
+
// name -> {path, assetsPath, skillsRoot} derived from the pi Skill filePath.
|
|
341
|
+
// filePath is the skill file itself (nested `<root>/<name>/SKILL.md` or a flat
|
|
342
|
+
// `<root>/<name>.md`): assetsPath is its directory; the note-only skills root is
|
|
343
|
+
// one level up for the nested layout, else the directory itself.
|
|
344
|
+
const filePathEntries = new Map();
|
|
345
|
+
for (const skill of Array.isArray(skills) ? skills : []) {
|
|
346
|
+
if (!skill || typeof skill !== "object") continue;
|
|
347
|
+
if (!isSafeName(skill.name) || typeof skill.filePath !== "string" || !skill.filePath) continue;
|
|
348
|
+
if (filePathEntries.has(skill.name)) continue;
|
|
349
|
+
const path = resolve(skill.filePath);
|
|
350
|
+
const assetsPath = dirname(path);
|
|
351
|
+
const root = basename(path) === "SKILL.md" ? dirname(assetsPath) : assetsPath;
|
|
352
|
+
filePathEntries.set(skill.name, { path, assetsPath, skillsRoot: root });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A shared root (minimal/legacy form) drives the tool exactly as before;
|
|
356
|
+
// filePath is consulted ONLY when it is absent — "derive root from
|
|
357
|
+
// skill.filePath when skillsRoot is absent".
|
|
358
|
+
const enumNames = sharedRoot
|
|
359
|
+
? skillNames.filter(isSafeName)
|
|
360
|
+
: [...filePathEntries.keys()];
|
|
361
|
+
if (!enumNames.length) return null;
|
|
362
|
+
|
|
270
363
|
return {
|
|
271
364
|
name: "read_skill",
|
|
272
365
|
label: "Read Skill",
|
|
273
366
|
description: "Load the full instructions for a named skill.",
|
|
274
|
-
parameters: objectSchema({ name: { type: "string", enum:
|
|
367
|
+
parameters: objectSchema({ name: { type: "string", enum: enumNames } }, ["name"]),
|
|
275
368
|
async execute(_toolCallId, { name }) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
369
|
+
if (sharedRoot) {
|
|
370
|
+
const path = resolve(sharedRoot, name, "SKILL.md");
|
|
371
|
+
if (!path.startsWith(sharedRoot + "/")) throw new Error(`invalid skill path: ${name}`);
|
|
372
|
+
if (!existsSync(path)) throw new Error(`SKILL.md not found for ${name}`);
|
|
373
|
+
return textResult(formatSkillBodyWithPathNote({
|
|
374
|
+
body: stripFrontmatter(readFileSync(path, "utf8")),
|
|
375
|
+
assetsPath: resolve(sharedRoot, name),
|
|
376
|
+
skillsRoot: sharedRoot,
|
|
377
|
+
}), { skill: name, path });
|
|
378
|
+
}
|
|
379
|
+
const entry = filePathEntries.get(name);
|
|
380
|
+
if (!entry || !existsSync(entry.path)) throw new Error(`SKILL.md not found for ${name}`);
|
|
280
381
|
return textResult(formatSkillBodyWithPathNote({
|
|
281
|
-
body: stripFrontmatter(readFileSync(path, "utf8")),
|
|
282
|
-
assetsPath:
|
|
283
|
-
skillsRoot:
|
|
284
|
-
}), { skill: name, path });
|
|
382
|
+
body: stripFrontmatter(readFileSync(entry.path, "utf8")),
|
|
383
|
+
assetsPath: entry.assetsPath,
|
|
384
|
+
skillsRoot: entry.skillsRoot,
|
|
385
|
+
}), { skill: name, path: entry.path });
|
|
285
386
|
},
|
|
286
387
|
};
|
|
287
388
|
}
|
|
@@ -305,8 +406,14 @@ export function createStructuredOutputTool(outputSchema, onStructuredOutput) {
|
|
|
305
406
|
};
|
|
306
407
|
}
|
|
307
408
|
|
|
409
|
+
/**
|
|
410
|
+
* @param {any} allowedTools
|
|
411
|
+
* @param {{skillNames?: any[], skills?: any[], skillsRoot?: any, dataDir?: any, cwd?: any, onEvent?: (event: any) => void, toolLimits?: any, persistArtifact?: any, onTruncate?: any, toolPayloadMaxBytes?: number, imageInlineMaxBytes?: any, toolPolicy?: any, sandboxPolicy?: any, sandboxEngine?: any, approvalManager?: any, approvalModel?: any, ctx?: any}} [options]
|
|
412
|
+
*/
|
|
308
413
|
export function getPiBuiltinTools(allowedTools, {
|
|
309
414
|
skillNames = [],
|
|
415
|
+
skills = [],
|
|
416
|
+
skillsRoot,
|
|
310
417
|
dataDir,
|
|
311
418
|
cwd,
|
|
312
419
|
onEvent,
|
|
@@ -314,11 +421,13 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
314
421
|
persistArtifact = null,
|
|
315
422
|
onTruncate = null,
|
|
316
423
|
toolPayloadMaxBytes = MAX_TOOL_RESULT_BYTES,
|
|
424
|
+
imageInlineMaxBytes = toolPayloadMaxBytes,
|
|
317
425
|
toolPolicy = null,
|
|
318
426
|
sandboxPolicy = null,
|
|
319
427
|
sandboxEngine = null,
|
|
320
428
|
approvalManager = null,
|
|
321
429
|
approvalModel = null,
|
|
430
|
+
ctx = null,
|
|
322
431
|
} = {}) {
|
|
323
432
|
const textLimitSchema = integerSchema();
|
|
324
433
|
const bashLimitSchema = integerSchema();
|
|
@@ -326,9 +435,11 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
326
435
|
type: "integer",
|
|
327
436
|
description: "Timeout in milliseconds. Use 30000 for 30 seconds; small values like 30 are treated as seconds for compatibility.",
|
|
328
437
|
};
|
|
329
|
-
|
|
438
|
+
// Per-tool closure config (cwd/event sink/limits/policy) plus the per-instance
|
|
439
|
+
// ToolContext `ctx` that the tool impls and shared helpers read from.
|
|
440
|
+
const toolContext = { cwd, onEvent, toolLimits, toolPolicy, sandboxPolicy, sandboxEngine, ctx };
|
|
330
441
|
const all = {
|
|
331
|
-
Read: createBuiltinTool("Read", "Read", "Read a local file
|
|
442
|
+
Read: createBuiltinTool("Read", "Read", "Read a local file. Text files return line-numbered content; image files (PNG, JPEG, GIF, WebP, BMP) are returned as a viewable image you can see directly — use this to look at image attachments.", objectSchema({
|
|
332
443
|
file_path: { type: "string" },
|
|
333
444
|
offset: { type: "integer" },
|
|
334
445
|
start_line: { type: "integer" },
|
|
@@ -386,7 +497,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
386
497
|
};
|
|
387
498
|
const names = Array.isArray(allowedTools) ? allowedTools : Object.keys(all);
|
|
388
499
|
const tools = names.map((name) => all[name]).filter(Boolean);
|
|
389
|
-
const skillTool = readSkillTool(skillNames, dataDir);
|
|
500
|
+
const skillTool = readSkillTool(skillNames, { skillsRoot, dataDir, skills });
|
|
390
501
|
if (skillTool) tools.push(skillTool);
|
|
391
502
|
const gated = approvalManager
|
|
392
503
|
? wrapToolsWithApprovalGate(tools, approvalManager, { model: approvalModel })
|
|
@@ -394,6 +505,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
394
505
|
return wrapToolsWithBloatGuard(gated, {
|
|
395
506
|
persistArtifact,
|
|
396
507
|
maxBytes: toolPayloadMaxBytes,
|
|
508
|
+
imageMaxBytes: imageInlineMaxBytes,
|
|
397
509
|
onTruncate,
|
|
398
510
|
});
|
|
399
511
|
}
|
|
@@ -405,9 +517,11 @@ export function resolveMcpStdioCwd(cfg = {}, cwd = null) {
|
|
|
405
517
|
return cwd || process.cwd();
|
|
406
518
|
}
|
|
407
519
|
|
|
408
|
-
export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPolicy = null, sandboxEngine = null } = {}) {
|
|
409
|
-
|
|
410
|
-
|
|
520
|
+
export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPolicy = null, sandboxEngine = null, ctx = null } = {}) {
|
|
521
|
+
const resolvedCtx = ctx ?? readToolRuntime();
|
|
522
|
+
const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
|
|
523
|
+
return sandbox.prepareCommand({
|
|
524
|
+
policy: resolveSandboxPolicy(resolvedCtx, sandboxPolicy),
|
|
411
525
|
engine: sandboxEngine ?? undefined,
|
|
412
526
|
command: {
|
|
413
527
|
command: cfg.command,
|
|
@@ -418,8 +532,13 @@ export async function prepareMcpStdioCommand(cfg = {}, { cwd = null, sandboxPoli
|
|
|
418
532
|
});
|
|
419
533
|
}
|
|
420
534
|
|
|
421
|
-
|
|
422
|
-
|
|
535
|
+
/**
|
|
536
|
+
* @param {any} name
|
|
537
|
+
* @param {any} cfg
|
|
538
|
+
* @param {{cwd?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
|
|
539
|
+
*/
|
|
540
|
+
async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine, ctx } = {}) {
|
|
541
|
+
const brand = (ctx ?? readToolRuntime()).runtimeBrand;
|
|
423
542
|
const client = new McpClient(
|
|
424
543
|
{ name: `${brand.mcpClientName}/${name}`, version: brand.mcpClientVersion },
|
|
425
544
|
{ capabilities: {} },
|
|
@@ -429,25 +548,28 @@ async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine }
|
|
|
429
548
|
transport = new StreamableHTTPClientTransport(new URL(cfg.url), { requestInit: { headers: cfg.headers || {} } });
|
|
430
549
|
} else if (cfg.type === "sse") {
|
|
431
550
|
transport = new SSEClientTransport(new URL(cfg.url), {
|
|
432
|
-
|
|
551
|
+
// SSE EventSourceInit's typed shape omits `headers`, but the transport
|
|
552
|
+
// forwards them to the underlying EventSource — keep the header pass-through.
|
|
553
|
+
eventSourceInit: /** @type {any} */ ({ headers: cfg.headers || {} }),
|
|
433
554
|
requestInit: { headers: cfg.headers || {} },
|
|
434
555
|
});
|
|
435
556
|
} else {
|
|
436
|
-
const prepared = await prepareMcpStdioCommand(cfg, { cwd, sandboxPolicy, sandboxEngine });
|
|
557
|
+
const prepared = await prepareMcpStdioCommand(cfg, { cwd, sandboxPolicy, sandboxEngine, ctx });
|
|
437
558
|
transport = new StdioClientTransport({
|
|
438
559
|
command: prepared.command,
|
|
439
560
|
args: prepared.args || [],
|
|
440
561
|
cwd: prepared.cwd,
|
|
441
562
|
env: { ...process.env, ...(prepared.env || {}) },
|
|
442
563
|
});
|
|
443
|
-
transport
|
|
564
|
+
// Monkey-patched cleanup handle: not part of the MCP transport's typed shape.
|
|
565
|
+
/** @type {any} */ (transport).__monoSandboxCleanup = prepared.cleanup;
|
|
444
566
|
}
|
|
445
567
|
try {
|
|
446
568
|
await client.connect(transport);
|
|
447
569
|
return { name, client, transport };
|
|
448
570
|
} catch (error) {
|
|
449
571
|
try { await transport?.close?.(); } catch { /* best-effort */ }
|
|
450
|
-
try { await transport?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
|
|
572
|
+
try { await /** @type {any} */ (transport)?.__monoSandboxCleanup?.(); } catch { /* best-effort */ }
|
|
451
573
|
throw error;
|
|
452
574
|
}
|
|
453
575
|
}
|
|
@@ -515,16 +637,29 @@ function mcpToolName(serverName, toolName, reservedNames) {
|
|
|
515
637
|
return `mcp__${serverName}__${toolName}`;
|
|
516
638
|
}
|
|
517
639
|
|
|
518
|
-
|
|
640
|
+
// Inactivity wall clock around an MCP call. `registerReset` (optional) hands the
|
|
641
|
+
// caller a rearm function so tool progress notifications can keep a legitimately
|
|
642
|
+
// long call alive; the SDK's maxTotalTimeout stays the hard cap.
|
|
643
|
+
function withTimeout(promise, timeoutMs, signal, label, registerReset) {
|
|
519
644
|
if (signal?.aborted) return Promise.reject(new Error("tool execution aborted"));
|
|
520
645
|
const ms = Number(timeoutMs) || 120000;
|
|
521
646
|
let timeout;
|
|
522
647
|
const timer = new Promise((_, reject) => {
|
|
523
|
-
|
|
648
|
+
const arm = () => setTimeout(() => reject(new Error(`${label || "MCP tool"} timed out after ${ms}ms`)), ms);
|
|
649
|
+
timeout = arm();
|
|
650
|
+
registerReset?.(() => {
|
|
651
|
+
clearTimeout(timeout);
|
|
652
|
+
timeout = arm();
|
|
653
|
+
});
|
|
524
654
|
});
|
|
525
655
|
return Promise.race([promise, timer]).finally(() => clearTimeout(timeout));
|
|
526
656
|
}
|
|
527
657
|
|
|
658
|
+
/**
|
|
659
|
+
* @param {any} mcpConfig
|
|
660
|
+
* @param {Set<any>} [reservedNames]
|
|
661
|
+
* @param {{limits?: any, cwd?: any, persistArtifact?: any, qaOutputDir?: any, onTruncate?: any, toolPayloadMaxBytes?: number, sandboxPolicy?: any, sandboxEngine?: any, onToolProgress?: any, ctx?: any}} [options]
|
|
662
|
+
*/
|
|
528
663
|
export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
529
664
|
limits = {},
|
|
530
665
|
cwd = null,
|
|
@@ -534,11 +669,13 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
534
669
|
toolPayloadMaxBytes = MAX_TOOL_RESULT_BYTES,
|
|
535
670
|
sandboxPolicy = null,
|
|
536
671
|
sandboxEngine = null,
|
|
672
|
+
onToolProgress = null,
|
|
673
|
+
ctx = null,
|
|
537
674
|
} = {}) {
|
|
538
675
|
const clients = [];
|
|
539
676
|
const tools = [];
|
|
540
677
|
const entries = Object.entries(mcpConfig || {});
|
|
541
|
-
const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine })));
|
|
678
|
+
const settled = await Promise.allSettled(entries.map(([name, cfg]) => connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine, ctx })));
|
|
542
679
|
const warnings = [];
|
|
543
680
|
const seen = new Set(reservedNames);
|
|
544
681
|
|
|
@@ -590,20 +727,65 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
590
727
|
name,
|
|
591
728
|
label: sourceTool.title || sourceTool.name,
|
|
592
729
|
description: sourceTool.description || `${serverName}:${sourceTool.name}`,
|
|
593
|
-
parameters: sourceTool.inputSchema || sourceTool.input_schema || objectSchema({}),
|
|
730
|
+
parameters: sourceTool.inputSchema || /** @type {any} */ (sourceTool).input_schema || objectSchema({}),
|
|
594
731
|
async execute(toolCallId, params, signal) {
|
|
595
732
|
if (signal?.aborted) throw new Error("tool execution aborted");
|
|
596
733
|
const textLimit = limits.mcpTextLimitChars || MCP_TEXT_RESULT_LIMIT;
|
|
597
734
|
const imageInlineMaxBytes = limits.imageInlineMaxBytes ?? MCP_IMAGE_INLINE_MAX_BYTES;
|
|
598
|
-
const normalizedParams = normalizeMcpToolParams(serverName, sourceTool.name, params || {}, { qaOutputDir });
|
|
735
|
+
const normalizedParams = normalizeMcpToolParams(serverName, sourceTool.name, params || {}, { qaOutputDir, ctx });
|
|
599
736
|
// Measure the MCP round-trip so observability can separate slow MCP
|
|
600
|
-
// servers (e.g. context-
|
|
737
|
+
// servers (e.g. context-example over a SOCKS proxy) from model latency.
|
|
601
738
|
const mcpCallStartMs = Date.now();
|
|
739
|
+
// The MCP SDK's per-request timeout defaults to 60s (DEFAULT_REQUEST_TIMEOUT_MSEC),
|
|
740
|
+
// which would fire -32001 well before the outer withTimeout wall-clock cap — fatal for
|
|
741
|
+
// in-process tools that run a whole agent turn (e.g. notify_conversation delivery).
|
|
742
|
+
// Pass it explicitly so the SDK request timeout matches our cap instead of pre-empting it.
|
|
743
|
+
const mcpCallTimeoutMs = limits.mcpCallTimeoutMs || 120000;
|
|
744
|
+
// Inactivity vs total: mcpCallTimeoutMs is reset by every progress
|
|
745
|
+
// notification (keep-alive for long tools like transcription or an
|
|
746
|
+
// ask-the-user wait); mcpCallMaxTotalTimeoutMs is the unresettable cap.
|
|
747
|
+
const mcpCallMaxTotalTimeoutMs = Math.max(
|
|
748
|
+
limits.mcpCallMaxTotalTimeoutMs || DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS,
|
|
749
|
+
mcpCallTimeoutMs,
|
|
750
|
+
);
|
|
751
|
+
// The SDK only attaches a progressToken (and thus honors
|
|
752
|
+
// resetTimeoutOnProgress) when an onprogress callback is present, so one
|
|
753
|
+
// is always attached: it rearms the outer wall clock and optionally
|
|
754
|
+
// surfaces the notification to the host.
|
|
755
|
+
let resetInactivityTimeout = null;
|
|
756
|
+
const onprogress = (progress) => {
|
|
757
|
+
resetInactivityTimeout?.();
|
|
758
|
+
onToolProgress?.({
|
|
759
|
+
type: "tool_progress",
|
|
760
|
+
server: serverName,
|
|
761
|
+
tool: sourceTool.name,
|
|
762
|
+
toolCallId,
|
|
763
|
+
...(progress?.progress === undefined ? {} : { progress: progress.progress }),
|
|
764
|
+
...(progress?.total === undefined ? {} : { total: progress.total }),
|
|
765
|
+
...(progress?.message === undefined ? {} : { message: progress.message }),
|
|
766
|
+
});
|
|
767
|
+
};
|
|
602
768
|
const out = await withTimeout(
|
|
603
|
-
connected.client.callTool(
|
|
604
|
-
|
|
769
|
+
connected.client.callTool(
|
|
770
|
+
{ name: sourceTool.name, arguments: normalizedParams || {} },
|
|
771
|
+
undefined,
|
|
772
|
+
// Forward the abort signal too, so a cancelled/timed-out call also cancels the
|
|
773
|
+
// in-flight MCP request on the wire (otherwise the SDK keeps awaiting until its own
|
|
774
|
+
// timeout, and an in-process loopback turn could post late after the bridge rejected).
|
|
775
|
+
{
|
|
776
|
+
timeout: mcpCallTimeoutMs,
|
|
777
|
+
resetTimeoutOnProgress: true,
|
|
778
|
+
maxTotalTimeout: mcpCallMaxTotalTimeoutMs,
|
|
779
|
+
signal,
|
|
780
|
+
onprogress,
|
|
781
|
+
},
|
|
782
|
+
),
|
|
783
|
+
mcpCallTimeoutMs,
|
|
605
784
|
signal,
|
|
606
785
|
`${serverName}:${sourceTool.name}`,
|
|
786
|
+
(reset) => {
|
|
787
|
+
resetInactivityTimeout = reset;
|
|
788
|
+
},
|
|
607
789
|
);
|
|
608
790
|
const mcpCallDurationMs = Date.now() - mcpCallStartMs;
|
|
609
791
|
const imageTruncations = [];
|
package/src/agent/tools/read.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
2
3
|
import {
|
|
3
4
|
DEFAULT_MAX_READ_CHARS,
|
|
4
5
|
DEFAULT_READ_LINES,
|
|
@@ -8,10 +9,33 @@ import { boundedInt, rememberRead, trimLine } from "./shared/dedup.js";
|
|
|
8
9
|
import { capChars } from "./shared/output-truncation.js";
|
|
9
10
|
import { isPathAllowed, resolveToolPath } from "./shared/path-resolver.js";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Raster image formats a vision model can consume directly. SVG is intentionally
|
|
13
|
+
// excluded — it is XML text, so it stays on the line-numbered text path.
|
|
14
|
+
const IMAGE_MIME_BY_EXT = {
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
".bmp": "image/bmp",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{file_path: string, offset?: number, start_line?: number, limit?: number, max_output_chars?: number, workdir?: string}} params
|
|
25
|
+
* @param {{sandboxPolicy?: any, ctx?: any}} [options]
|
|
26
|
+
*/
|
|
27
|
+
export async function readToolImpl({ file_path, offset = 0, start_line, limit, max_output_chars, workdir }, { sandboxPolicy, ctx } = {}) {
|
|
28
|
+
const target = resolveToolPath(file_path, workdir, ctx);
|
|
29
|
+
if (!isPathAllowed(target, workdir, { sandboxPolicy, ctx })) return `Error: Path not allowed: ${file_path}`;
|
|
14
30
|
if (!existsSync(target)) return `Error: File not found: ${file_path}`;
|
|
31
|
+
// Image files are returned as an image result so vision models see pixels
|
|
32
|
+
// rather than the raw bytes decoded (and garbled) as utf8 text. The builtin
|
|
33
|
+
// tool wrapper turns this into an image content block; oversize images are
|
|
34
|
+
// capped by the shared tool-result bloat guard.
|
|
35
|
+
const imageMime = IMAGE_MIME_BY_EXT[extname(target).toLowerCase()];
|
|
36
|
+
if (imageMime !== undefined) {
|
|
37
|
+
return { kind: "image", data: readFileSync(target).toString("base64"), mimeType: imageMime };
|
|
38
|
+
}
|
|
15
39
|
const content = readFileSync(target, "utf8");
|
|
16
40
|
let lines = content.split("\n");
|
|
17
41
|
const total = lines.length;
|
|
@@ -35,5 +59,6 @@ export async function readToolImpl({ file_path, offset = 0, start_line, limit, m
|
|
|
35
59
|
label: "Read",
|
|
36
60
|
maxChars: Number(max_output_chars) || DEFAULT_MAX_READ_CHARS,
|
|
37
61
|
hint: "Use Read with offset/start_line and limit for the specific range you need.",
|
|
62
|
+
ctx,
|
|
38
63
|
});
|
|
39
64
|
}
|
|
@@ -9,8 +9,8 @@ function sanitizeName(value) {
|
|
|
9
9
|
return String(value || "tool").replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "tool";
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function writeToolArtifact(label, text) {
|
|
13
|
-
const { toolArtifactDir, runId } = readToolRuntime();
|
|
12
|
+
export function writeToolArtifact(label, text, ctx) {
|
|
13
|
+
const { toolArtifactDir, runId } = ctx ?? readToolRuntime();
|
|
14
14
|
if (!toolArtifactDir) return null;
|
|
15
15
|
try {
|
|
16
16
|
const safeRunId = sanitizeName(runId || "manual");
|
|
@@ -33,16 +33,21 @@ export function truncationSuffix({ label, shown, total, artifact, hint }) {
|
|
|
33
33
|
].filter(Boolean).join("\n");
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @param {{label?: string, maxChars?: number, strategy?: string, hint?: string, ctx?: any}} [options]
|
|
39
|
+
*/
|
|
36
40
|
export function capChars(text, {
|
|
37
41
|
label = "tool",
|
|
38
42
|
maxChars = DEFAULT_MAX_TOOL_OUTPUT_CHARS,
|
|
39
43
|
strategy = "head",
|
|
40
44
|
hint,
|
|
45
|
+
ctx,
|
|
41
46
|
} = {}) {
|
|
42
47
|
const value = String(text || "");
|
|
43
48
|
const limit = boundedInt(maxChars, DEFAULT_MAX_TOOL_OUTPUT_CHARS, { min: 200 });
|
|
44
49
|
if (value.length <= limit) return value;
|
|
45
|
-
const artifact = writeToolArtifact(label, value);
|
|
50
|
+
const artifact = writeToolArtifact(label, value, ctx);
|
|
46
51
|
const suffix = truncationSuffix({ label, shown: limit, total: value.length, artifact, hint });
|
|
47
52
|
const budget = Math.max(0, limit - suffix.length);
|
|
48
53
|
if (strategy === "head_tail" && budget > 200) {
|