@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.
- package/ARCHITECTURE.md +219 -0
- package/LICENSE +674 -0
- package/README.md +430 -0
- package/package.json +46 -0
- package/src/agent/allowlists.js +49 -0
- package/src/agent/approval.js +211 -0
- package/src/agent/compaction.js +752 -0
- package/src/agent/index.js +40 -0
- package/src/agent/prompt/skill-index.js +66 -0
- package/src/agent/tool-bloat.js +164 -0
- package/src/agent/tools/bash.js +156 -0
- package/src/agent/tools/edit.js +15 -0
- package/src/agent/tools/glob.js +71 -0
- package/src/agent/tools/grep.js +84 -0
- package/src/agent/tools/index.js +17 -0
- package/src/agent/tools/pi-bridge.js +638 -0
- package/src/agent/tools/read.js +39 -0
- package/src/agent/tools/shared/constants.js +21 -0
- package/src/agent/tools/shared/dedup.js +31 -0
- package/src/agent/tools/shared/output-truncation.js +54 -0
- package/src/agent/tools/shared/path-resolver.js +156 -0
- package/src/agent/tools/shared/ripgrep.js +130 -0
- package/src/agent/tools/shared/runtime-context.js +69 -0
- package/src/agent/tools/web-fetch.js +59 -0
- package/src/agent/tools/web-search.js +21 -0
- package/src/agent/tools/write.js +14 -0
- package/src/agent/transcript.js +227 -0
- package/src/ai/backend.js +17 -0
- package/src/ai/cost.js +164 -0
- package/src/ai/failure.js +165 -0
- package/src/ai/file-change-stats.js +234 -0
- package/src/ai/index.js +16 -0
- package/src/ai/live-input-prompt.js +15 -0
- package/src/ai/observer.js +233 -0
- package/src/ai/providers/claude-cli.js +694 -0
- package/src/ai/providers/claude-sdk.js +864 -0
- package/src/ai/providers/claude-subagents.js +67 -0
- package/src/ai/providers/codex-app.js +1045 -0
- package/src/ai/providers/opencode-app.js +356 -0
- package/src/ai/providers/opencode-discovery.js +39 -0
- package/src/ai/providers/pi-events.js +62 -0
- package/src/ai/providers/pi-messages.js +68 -0
- package/src/ai/providers/pi-models.js +111 -0
- package/src/ai/providers/pi-sdk.js +1310 -0
- package/src/ai/registry.js +5 -0
- package/src/ai/runtime/capabilities-used.js +56 -0
- package/src/ai/runtime/capabilities.js +44 -0
- package/src/ai/runtime/context-windows.js +38 -0
- package/src/ai/runtime/fast-mode.js +8 -0
- package/src/ai/runtime/model-refs.js +144 -0
- package/src/ai/runtime/registry.js +57 -0
- package/src/ai/runtime/router.js +214 -0
- package/src/ai/runtime/sessions.js +126 -0
- package/src/ai/streaming/codex-events.js +139 -0
- package/src/ai/streaming/opencode-events.js +54 -0
- package/src/ai/types.js +70 -0
- package/src/index.js +23 -0
- package/src/pi-auth.js +80 -0
- package/src/runtime-brand.js +32 -0
- package/src/runtime.js +104 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { MAX_READ_LINE_CHARS, READ_HISTORY_LIMIT } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
export const readHistory = new Map();
|
|
5
|
+
|
|
6
|
+
export function boundedInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
7
|
+
const n = Number(value);
|
|
8
|
+
if (!Number.isFinite(n)) return fallback;
|
|
9
|
+
return Math.min(Math.max(Math.floor(n), min), max);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function safeStat(path) {
|
|
13
|
+
try { return statSync(path); } catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function rememberRead(target, start, count) {
|
|
17
|
+
const key = `${target}:${start}:${count}`;
|
|
18
|
+
const repeated = readHistory.has(key);
|
|
19
|
+
readHistory.set(key, Date.now());
|
|
20
|
+
if (readHistory.size > READ_HISTORY_LIMIT) {
|
|
21
|
+
const oldest = [...readHistory.entries()].sort((a, b) => a[1] - b[1]).slice(0, readHistory.size - READ_HISTORY_LIMIT);
|
|
22
|
+
for (const [entry] of oldest) readHistory.delete(entry);
|
|
23
|
+
}
|
|
24
|
+
return repeated;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function trimLine(line) {
|
|
28
|
+
const text = String(line ?? "");
|
|
29
|
+
if (text.length <= MAX_READ_LINE_CHARS) return text;
|
|
30
|
+
return `${text.slice(0, MAX_READ_LINE_CHARS)} [line truncated at ${MAX_READ_LINE_CHARS} of ${text.length} chars]`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { DEFAULT_MAX_TOOL_OUTPUT_CHARS } from "./constants.js";
|
|
5
|
+
import { boundedInt } from "./dedup.js";
|
|
6
|
+
import { readToolRuntime } from "./runtime-context.js";
|
|
7
|
+
|
|
8
|
+
function sanitizeName(value) {
|
|
9
|
+
return String(value || "tool").replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "tool";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function writeToolArtifact(label, text) {
|
|
13
|
+
const { toolArtifactDir, runId } = readToolRuntime();
|
|
14
|
+
if (!toolArtifactDir) return null;
|
|
15
|
+
try {
|
|
16
|
+
const safeRunId = sanitizeName(runId || "manual");
|
|
17
|
+
const dir = resolve(toolArtifactDir, "tool-output", safeRunId);
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
const path = join(dir, `${Date.now()}-${sanitizeName(label)}-${randomUUID()}.txt`);
|
|
20
|
+
writeFileSync(path, String(text || ""), "utf8");
|
|
21
|
+
return { path, bytes: Buffer.byteLength(String(text || ""), "utf8") };
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function truncationSuffix({ label, shown, total, artifact, hint }) {
|
|
28
|
+
return [
|
|
29
|
+
"",
|
|
30
|
+
`[truncated ${label} output: showing ${shown} of ${total} characters.]`,
|
|
31
|
+
artifact ? `Full output saved to: ${artifact.path}` : null,
|
|
32
|
+
hint || "Use a narrower path, range, command, or query for the missing detail.",
|
|
33
|
+
].filter(Boolean).join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function capChars(text, {
|
|
37
|
+
label = "tool",
|
|
38
|
+
maxChars = DEFAULT_MAX_TOOL_OUTPUT_CHARS,
|
|
39
|
+
strategy = "head",
|
|
40
|
+
hint,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const value = String(text || "");
|
|
43
|
+
const limit = boundedInt(maxChars, DEFAULT_MAX_TOOL_OUTPUT_CHARS, { min: 200 });
|
|
44
|
+
if (value.length <= limit) return value;
|
|
45
|
+
const artifact = writeToolArtifact(label, value);
|
|
46
|
+
const suffix = truncationSuffix({ label, shown: limit, total: value.length, artifact, hint });
|
|
47
|
+
const budget = Math.max(0, limit - suffix.length);
|
|
48
|
+
if (strategy === "head_tail" && budget > 200) {
|
|
49
|
+
const head = Math.floor(budget * 0.6);
|
|
50
|
+
const tail = Math.max(0, budget - head - 40);
|
|
51
|
+
return `${value.slice(0, head)}\n\n[... middle omitted ...]\n\n${value.slice(Math.max(0, value.length - tail))}${suffix}`;
|
|
52
|
+
}
|
|
53
|
+
return `${value.slice(0, budget)}${suffix}`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { readToolRuntime, resolveSandboxPolicy } from "./runtime-context.js";
|
|
4
|
+
|
|
5
|
+
function configured() {
|
|
6
|
+
const { workspace, repoRoot } = readToolRuntime();
|
|
7
|
+
return { workspace, repoRoot };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function workspaceRoot(workdir) {
|
|
11
|
+
const { workspace, repoRoot } = configured();
|
|
12
|
+
return resolve(workdir || workspace || repoRoot || process.cwd());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveToolPath(path, workdir) {
|
|
16
|
+
if (!path || typeof path !== "string") return path;
|
|
17
|
+
return resolve(isAbsolute(path) ? path : resolve(workspaceRoot(workdir), path));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isPathAllowed(path, workdir, options = {}) {
|
|
21
|
+
return isPathAllowedFor(path, workdir, "read", options);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isWritablePathAllowed(path, workdir, options = {}) {
|
|
25
|
+
return isPathAllowedFor(path, workdir, "write", options);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPathAllowedFor(path, workdir, access, options) {
|
|
29
|
+
const r = resolveToolPath(path, workdir);
|
|
30
|
+
const policy = resolveSandboxPolicy(options.sandboxPolicy);
|
|
31
|
+
if (policy) {
|
|
32
|
+
const field = access === "write" ? policy.writableRoots : policy.readableRoots;
|
|
33
|
+
return insideSandboxRoots(Array.isArray(field) ? field : [], r)
|
|
34
|
+
&& (access !== "write" || !sandboxDeniesWrite(policy, r));
|
|
35
|
+
}
|
|
36
|
+
const { workspace, repoRoot } = configured();
|
|
37
|
+
return insideLegacyRoots([workdir, workspace, repoRoot, process.cwd(), "/tmp"], r);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isWorkdirAllowed(workdir, options = {}) {
|
|
41
|
+
if (!workdir) return true;
|
|
42
|
+
const r = resolve(workdir);
|
|
43
|
+
const policy = resolveSandboxPolicy(options.sandboxPolicy);
|
|
44
|
+
if (policy) {
|
|
45
|
+
return insideSandboxRoots(Array.isArray(policy.readableRoots) ? policy.readableRoots : [], r);
|
|
46
|
+
}
|
|
47
|
+
const { workspace, repoRoot } = configured();
|
|
48
|
+
return insideLegacyRoots([workspace, repoRoot, process.cwd(), "/tmp"], r);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Sandbox roots also enforce realpath containment so a symlink inside an
|
|
52
|
+
// allowed root cannot escape to a target outside the policy.
|
|
53
|
+
function insideSandboxRoots(roots, target) {
|
|
54
|
+
const allowedRoots = normalizeRoots(roots);
|
|
55
|
+
const real = realTargetPath(target);
|
|
56
|
+
return allowedRoots.some((root) => isInsidePath(root, target))
|
|
57
|
+
&& allowedRoots.some((root) => isInsidePath(root, real));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Without a sandbox policy, keep the historical literal containment check —
|
|
61
|
+
// symlinks out of the workspace (npm link et al.) stay usable by default.
|
|
62
|
+
function insideLegacyRoots(roots, target) {
|
|
63
|
+
const allowedRoots = [...new Set(roots.filter(Boolean).map((p) => resolve(p)))];
|
|
64
|
+
return allowedRoots.some((root) => isInsidePath(root, target));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeRoots(paths) {
|
|
68
|
+
const out = new Set();
|
|
69
|
+
for (const path of paths.filter(Boolean)) {
|
|
70
|
+
const resolved = resolve(path);
|
|
71
|
+
out.add(resolved);
|
|
72
|
+
out.add(realTargetPath(resolved));
|
|
73
|
+
}
|
|
74
|
+
return [...out];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isInsidePath(root, target) {
|
|
78
|
+
if (!root || !target) return false;
|
|
79
|
+
const rel = relative(resolve(root), resolve(target));
|
|
80
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function realTargetPath(target) {
|
|
84
|
+
const resolved = resolve(target);
|
|
85
|
+
if (existsSync(resolved)) {
|
|
86
|
+
try { return realpathSync(resolved); } catch { return resolved; }
|
|
87
|
+
}
|
|
88
|
+
let current = dirname(resolved);
|
|
89
|
+
while (current && current !== dirname(current)) {
|
|
90
|
+
if (existsSync(current)) {
|
|
91
|
+
try { return resolve(realpathSync(current), relative(current, resolved)); } catch { return resolved; }
|
|
92
|
+
}
|
|
93
|
+
current = dirname(current);
|
|
94
|
+
}
|
|
95
|
+
return resolved;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sandboxDeniesWrite(policy, target) {
|
|
99
|
+
const patterns = Array.isArray(policy.denyWrite) ? policy.denyWrite : [];
|
|
100
|
+
if (patterns.length === 0) return false;
|
|
101
|
+
const candidates = [...new Set([resolve(target), realTargetPath(target)])];
|
|
102
|
+
return candidates.some((candidate) =>
|
|
103
|
+
patterns.some((pattern) => denyWritePatternMatches(policy, pattern, candidate)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function denyWritePatternMatches(policy, pattern, target) {
|
|
107
|
+
if (!pattern || typeof pattern !== "string") return false;
|
|
108
|
+
const normalizedPattern = normalizeMatchPath(pattern);
|
|
109
|
+
if (isAbsolute(pattern)) {
|
|
110
|
+
return globPatternMatches(normalizedPattern, normalizeMatchPath(target));
|
|
111
|
+
}
|
|
112
|
+
const root = resolve(policy.root || workspaceRoot());
|
|
113
|
+
const rel = relative(root, resolve(target));
|
|
114
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return false;
|
|
115
|
+
return globPatternMatches(stripDotSlash(normalizedPattern), normalizeMatchPath(rel));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeMatchPath(path) {
|
|
119
|
+
return path.replaceAll("\\", "/");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function stripDotSlash(path) {
|
|
123
|
+
let out = path;
|
|
124
|
+
while (out.startsWith("./")) out = out.slice(2);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function globPatternMatches(pattern, target) {
|
|
129
|
+
return globPatternToRegExp(pattern).test(target);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function globPatternToRegExp(pattern) {
|
|
133
|
+
let source = "^";
|
|
134
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
135
|
+
const char = pattern[index];
|
|
136
|
+
if (char === "*") {
|
|
137
|
+
if (pattern[index + 1] === "*") {
|
|
138
|
+
source += ".*";
|
|
139
|
+
index += 1;
|
|
140
|
+
} else {
|
|
141
|
+
source += "[^/]*";
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (char === "?") {
|
|
146
|
+
source += "[^/]";
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
source += escapeRegExp(char);
|
|
150
|
+
}
|
|
151
|
+
return new RegExp(`${source}$`, "u");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function escapeRegExp(char) {
|
|
155
|
+
return /[\\^$.*+?()[\]{}|]/u.test(char) ? `\\${char}` : char;
|
|
156
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_EXCLUDED_DIRS,
|
|
6
|
+
DEFAULT_EXCLUDED_FILES,
|
|
7
|
+
DEFAULT_MAX_SEARCH_CHARS,
|
|
8
|
+
DEFAULT_MAX_SEARCH_LINES,
|
|
9
|
+
} from "./constants.js";
|
|
10
|
+
import { boundedInt } from "./dedup.js";
|
|
11
|
+
import { writeToolArtifact } from "./output-truncation.js";
|
|
12
|
+
import { readRuntimeBrand, readToolRuntime } from "./runtime-context.js";
|
|
13
|
+
|
|
14
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
// Lazy so the message respects whatever runtimeBrand the host configured.
|
|
17
|
+
export function ripgrepMissingMessage() {
|
|
18
|
+
const brand = readRuntimeBrand();
|
|
19
|
+
return `Error: ripgrep (rg) is not available. Configure ripgrepPath via configureToolRuntime() or install ripgrep on PATH; run \`${brand.doctorCommand}\` for details.`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Mutable cache of the resolved ripgrep binary path. Stored on an object so
|
|
23
|
+
// callers can read the latest value without re-importing the module.
|
|
24
|
+
export const cachedRgPath = { value: undefined };
|
|
25
|
+
|
|
26
|
+
function vendoredRgPath() {
|
|
27
|
+
try {
|
|
28
|
+
const sdkPkg = requireFromHere.resolve("@anthropic-ai/claude-agent-sdk/package.json");
|
|
29
|
+
const platform = process.platform === "win32" ? "win32" : process.platform;
|
|
30
|
+
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
|
|
31
|
+
if (!arch) return null;
|
|
32
|
+
const binaryName = process.platform === "win32" ? "rg.exe" : "rg";
|
|
33
|
+
const candidate = join(dirname(sdkPkg), "vendor", "ripgrep", `${arch}-${platform}`, binaryName);
|
|
34
|
+
return existsSync(candidate) ? candidate : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rgFromPath() {
|
|
41
|
+
const pathEnv = process.env.PATH || "";
|
|
42
|
+
if (!pathEnv) return null;
|
|
43
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE").split(";") : [""];
|
|
44
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
45
|
+
if (!dir) continue;
|
|
46
|
+
for (const ext of exts) {
|
|
47
|
+
const candidate = join(dir, `rg${ext.toLowerCase()}`);
|
|
48
|
+
if (existsSync(candidate)) return candidate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveRgPath({ refresh = false } = {}) {
|
|
55
|
+
if (!refresh && cachedRgPath.value !== undefined) return cachedRgPath.value;
|
|
56
|
+
const { ripgrepPath } = readToolRuntime();
|
|
57
|
+
if (ripgrepPath) {
|
|
58
|
+
cachedRgPath.value = existsSync(ripgrepPath) ? ripgrepPath : null;
|
|
59
|
+
} else {
|
|
60
|
+
cachedRgPath.value = vendoredRgPath() || rgFromPath() || null;
|
|
61
|
+
}
|
|
62
|
+
return cachedRgPath.value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function excludedGlobArgs() {
|
|
66
|
+
const args = [];
|
|
67
|
+
for (const dir of DEFAULT_EXCLUDED_DIRS) {
|
|
68
|
+
args.push("--glob", `!${dir}/**`, "--glob", `!**/${dir}/**`);
|
|
69
|
+
}
|
|
70
|
+
for (const filePattern of DEFAULT_EXCLUDED_FILES) args.push("--glob", `!${filePattern}`);
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeGlobPattern(pattern) {
|
|
75
|
+
const raw = String(pattern || "**/*").trim().replace(/^\.\//, "");
|
|
76
|
+
return raw || "**/*";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatSearchLines(rawLines, {
|
|
80
|
+
label,
|
|
81
|
+
noMatches,
|
|
82
|
+
maxLines = DEFAULT_MAX_SEARCH_LINES,
|
|
83
|
+
maxChars = DEFAULT_MAX_SEARCH_CHARS,
|
|
84
|
+
offset = 0,
|
|
85
|
+
} = {}) {
|
|
86
|
+
const lines = Array.isArray(rawLines) ? rawLines.filter(Boolean) : String(rawLines || "").trim().split("\n").filter(Boolean);
|
|
87
|
+
if (!lines.length) return noMatches;
|
|
88
|
+
const start = boundedInt(offset, 0, { min: 0 });
|
|
89
|
+
const total = lines.length;
|
|
90
|
+
const slice = lines.slice(start);
|
|
91
|
+
const kept = [];
|
|
92
|
+
let chars = 0;
|
|
93
|
+
const lineLimit = boundedInt(maxLines, DEFAULT_MAX_SEARCH_LINES, { min: 1 });
|
|
94
|
+
const charLimit = boundedInt(maxChars, DEFAULT_MAX_SEARCH_CHARS, { min: 200 });
|
|
95
|
+
for (const line of slice) {
|
|
96
|
+
if (kept.length >= lineLimit || chars + line.length + 1 > charLimit) break;
|
|
97
|
+
kept.push(line);
|
|
98
|
+
chars += line.length + 1;
|
|
99
|
+
}
|
|
100
|
+
if (kept.length === slice.length) return kept.join("\n");
|
|
101
|
+
const fullText = lines.join("\n");
|
|
102
|
+
const artifact = writeToolArtifact(label, fullText);
|
|
103
|
+
const suffix = [
|
|
104
|
+
`[truncated ${label || "search"} result: showing ${kept.length} of ${total} lines after excluding generated/vendor paths.`,
|
|
105
|
+
start ? `Offset ${start} was applied.` : null,
|
|
106
|
+
artifact ? `Full output saved to: ${artifact.path}` : null,
|
|
107
|
+
"Use a narrower path, glob, or pattern for the full result.]",
|
|
108
|
+
].filter(Boolean).join(" ");
|
|
109
|
+
return `${kept.join("\n")}\n\n${suffix}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function capLines(text, {
|
|
113
|
+
label,
|
|
114
|
+
noMatches,
|
|
115
|
+
maxLines = DEFAULT_MAX_SEARCH_LINES,
|
|
116
|
+
maxChars = DEFAULT_MAX_SEARCH_CHARS,
|
|
117
|
+
offset = 0,
|
|
118
|
+
} = {}) {
|
|
119
|
+
return formatSearchLines(String(text || "").trim().split("\n").filter(Boolean), {
|
|
120
|
+
label,
|
|
121
|
+
noMatches,
|
|
122
|
+
maxLines,
|
|
123
|
+
maxChars,
|
|
124
|
+
offset,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function excludedPathSummary() {
|
|
129
|
+
return `Excluded directories: ${DEFAULT_EXCLUDED_DIRS.join(", ")}; excluded files: ${DEFAULT_EXCLUDED_FILES.join(", ")}.`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Process-level configuration for the agent kernel's internal tool helpers.
|
|
2
|
+
// The host configures this once at worker boot; internal
|
|
3
|
+
// modules (output-truncation, ripgrep, path-resolver, pi-bridge) read from
|
|
4
|
+
// it instead of reaching into process.env.
|
|
5
|
+
//
|
|
6
|
+
// Single shared object is acceptable because the worker is one-task-per-process.
|
|
7
|
+
//
|
|
8
|
+
// Recognized keys:
|
|
9
|
+
// workspace — fallback for tool workdir resolution. Default: process.cwd().
|
|
10
|
+
// repoRoot — secondary allowed root (the host's installation root).
|
|
11
|
+
// Tool path-allowlist checks accept this in addition to workspace.
|
|
12
|
+
// runId — used as the subdirectory under toolArtifactDir for tool output.
|
|
13
|
+
// toolArtifactDir — root for {dir}/tool-output/{runId}/{file} artifact writes
|
|
14
|
+
// from capChars/formatSearchLines. Null = no persistence.
|
|
15
|
+
// ripgrepPath — absolute path to the ripgrep binary. When unset, falls
|
|
16
|
+
// back to vendored binary, then PATH lookup.
|
|
17
|
+
// qaOutputDir — fallback for normalizeMcpToolParams when the per-call
|
|
18
|
+
// runArtifactDir isn't supplied.
|
|
19
|
+
// sandboxPolicy — optional strict filesystem/process/network sandbox policy.
|
|
20
|
+
// runtimeBrand — resolved RuntimeBrand object (see runtime-brand.js).
|
|
21
|
+
// Internal helpers read it to stamp host-specific names
|
|
22
|
+
// (MCP client name, transcript schema id, doctor command).
|
|
23
|
+
|
|
24
|
+
import { mergeSandboxPolicies } from "@mono-agent/sandbox";
|
|
25
|
+
import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-brand.js";
|
|
26
|
+
|
|
27
|
+
const context = {
|
|
28
|
+
workspace: undefined,
|
|
29
|
+
repoRoot: undefined,
|
|
30
|
+
runId: undefined,
|
|
31
|
+
toolArtifactDir: undefined,
|
|
32
|
+
ripgrepPath: undefined,
|
|
33
|
+
qaOutputDir: undefined,
|
|
34
|
+
sandboxPolicy: undefined,
|
|
35
|
+
runtimeBrand: { ...DEFAULT_RUNTIME_BRAND },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function configureToolRuntime(next = {}) {
|
|
39
|
+
for (const key of Object.keys(context)) {
|
|
40
|
+
if (key === "runtimeBrand") continue;
|
|
41
|
+
if (key in next) context[key] = next[key];
|
|
42
|
+
}
|
|
43
|
+
if (next.runtimeBrand !== undefined) {
|
|
44
|
+
context.runtimeBrand = resolveRuntimeBrand(next.runtimeBrand);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readToolRuntime() {
|
|
49
|
+
return context;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Single source of truth for the sandbox policy a tool call runs under.
|
|
53
|
+
// Merging (rather than letting the per-call option shadow the context policy)
|
|
54
|
+
// keeps the guarantee monotonic: a request-scoped policy can tighten the
|
|
55
|
+
// host-configured policy but never weaken or disable it.
|
|
56
|
+
export function resolveSandboxPolicy(requestPolicy = undefined) {
|
|
57
|
+
const merged = mergeSandboxPolicies(context.sandboxPolicy ?? undefined, requestPolicy ?? undefined);
|
|
58
|
+
return merged && merged.mode !== "off" ? merged : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function readRuntimeBrand() {
|
|
62
|
+
return context.runtimeBrand || { ...DEFAULT_RUNTIME_BRAND };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resetToolRuntime() {
|
|
66
|
+
for (const key of Object.keys(context)) {
|
|
67
|
+
context[key] = key === "runtimeBrand" ? { ...DEFAULT_RUNTIME_BRAND } : undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { DEFAULT_MAX_TOOL_OUTPUT_CHARS } from "./shared/constants.js";
|
|
2
|
+
import { capChars } from "./shared/output-truncation.js";
|
|
3
|
+
import { networkPolicyAllowsUrl } from "@mono-agent/sandbox";
|
|
4
|
+
import { resolveSandboxPolicy } from "./shared/runtime-context.js";
|
|
5
|
+
|
|
6
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
7
|
+
const MAX_REDIRECTS = 5;
|
|
8
|
+
|
|
9
|
+
export async function webFetchToolImpl({ url, headers = {}, max_output_chars }, { sandboxPolicy } = {}) {
|
|
10
|
+
const maxChars = Number(max_output_chars) || DEFAULT_MAX_TOOL_OUTPUT_CHARS;
|
|
11
|
+
let parsed;
|
|
12
|
+
try { parsed = new URL(url); } catch { return "Error: Invalid URL"; }
|
|
13
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14
|
+
return "Error: WebFetch only supports http(s) URLs.";
|
|
15
|
+
}
|
|
16
|
+
const policy = resolveSandboxPolicy(sandboxPolicy);
|
|
17
|
+
if (!networkPolicyAllowsUrl(policy, parsed.href)) return "Error: Network access denied by sandbox policy.";
|
|
18
|
+
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}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// fetch() follows redirects transparently, which would let an allowed host
|
|
34
|
+
// bounce the request to a denied one — follow them manually and re-check the
|
|
35
|
+
// policy on every hop. Custom headers only travel to the original origin.
|
|
36
|
+
async function fetchCheckingRedirects(initialUrl, headers, policy) {
|
|
37
|
+
let current = initialUrl;
|
|
38
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
39
|
+
const sameOrigin = current.origin === initialUrl.origin;
|
|
40
|
+
const resp = await fetch(current, {
|
|
41
|
+
headers: sameOrigin ? headers : { "User-Agent": headers["User-Agent"] },
|
|
42
|
+
redirect: "manual",
|
|
43
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
44
|
+
});
|
|
45
|
+
const location = resp.headers.get("location");
|
|
46
|
+
if (resp.status < 300 || resp.status >= 400 || !location) return resp;
|
|
47
|
+
let next;
|
|
48
|
+
try { next = new URL(location, current); } catch { return "Error: Invalid redirect URL."; }
|
|
49
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
50
|
+
return "Error: WebFetch only supports http(s) URLs.";
|
|
51
|
+
}
|
|
52
|
+
if (!networkPolicyAllowsUrl(policy, next.href)) {
|
|
53
|
+
return "Error: Network access denied by sandbox policy (redirect).";
|
|
54
|
+
}
|
|
55
|
+
try { await resp.body?.cancel(); } catch { /* best-effort */ }
|
|
56
|
+
current = next;
|
|
57
|
+
}
|
|
58
|
+
return "Error: Too many redirects.";
|
|
59
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { networkPolicyAllowsUrl } from "@mono-agent/sandbox";
|
|
2
|
+
import { resolveSandboxPolicy } from "./shared/runtime-context.js";
|
|
3
|
+
|
|
4
|
+
export async function webSearchToolImpl({ query, limit = 5 }, { sandboxPolicy } = {}) {
|
|
5
|
+
const max = Math.min(Math.max(Number(limit) || 5, 1), 10);
|
|
6
|
+
const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
7
|
+
if (!networkPolicyAllowsUrl(resolveSandboxPolicy(sandboxPolicy), url)) return "Error: Network access denied by sandbox policy.";
|
|
8
|
+
const resp = await fetch(url, {
|
|
9
|
+
headers: { "User-Agent": "Mozilla/5.0 AgentRuntime/0.1" },
|
|
10
|
+
signal: AbortSignal.timeout(15000),
|
|
11
|
+
});
|
|
12
|
+
if (!resp.ok) return `Search failed: HTTP ${resp.status}`;
|
|
13
|
+
const html = await resp.text();
|
|
14
|
+
const results = [];
|
|
15
|
+
const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
|
16
|
+
let m;
|
|
17
|
+
while ((m = re.exec(html)) && results.length < max) {
|
|
18
|
+
results.push(`${m[2].replace(/<[^>]+>/g, "").trim()}\n${m[1]}\n${m[3].replace(/<[^>]+>/g, "").trim()}`);
|
|
19
|
+
}
|
|
20
|
+
return results.length ? results.join("\n\n") : "No results.";
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { MAX_WRITE_BYTES } from "./shared/constants.js";
|
|
4
|
+
import { isWritablePathAllowed, resolveToolPath } from "./shared/path-resolver.js";
|
|
5
|
+
|
|
6
|
+
export async function writeToolImpl({ file_path, content, workdir }, { sandboxPolicy } = {}) {
|
|
7
|
+
const target = resolveToolPath(file_path, workdir);
|
|
8
|
+
if (!isWritablePathAllowed(target, workdir, { sandboxPolicy })) return `Error: Path not allowed: ${file_path}`;
|
|
9
|
+
const bytes = Buffer.byteLength(content || "", "utf8");
|
|
10
|
+
if (bytes > MAX_WRITE_BYTES) return `Error: Content too large (${bytes} bytes)`;
|
|
11
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
12
|
+
writeFileSync(target, content || "", "utf8");
|
|
13
|
+
return `Successfully wrote ${bytes} bytes to ${target}`;
|
|
14
|
+
}
|