toolnexus 0.18.1 → 0.20.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/dist/acp.d.ts +42 -0
- package/dist/acp.js +246 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +3 -2
- package/dist/agents/home.js +4 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +76 -6
- package/dist/agents/runtime.js +92 -22
- package/dist/builtin.d.ts +32 -2
- package/dist/builtin.js +342 -58
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +33 -25
- package/dist/client.d.ts +116 -16
- package/dist/client.js +197 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/order.d.ts +46 -0
- package/dist/order.js +71 -0
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +110 -27
- package/dist/types.d.ts +11 -0
- package/dist/types.js +22 -0
- package/package.json +2 -2
- package/src/acp.ts +308 -0
- package/src/agents/agent.ts +15 -3
- package/src/agents/home.ts +4 -2
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +154 -29
- package/src/builtin.ts +379 -55
- package/src/classifier.ts +45 -27
- package/src/client.ts +220 -51
- package/src/index.ts +1 -0
- package/src/order.ts +70 -0
- package/src/skill.ts +121 -27
- package/src/types.ts +22 -0
package/src/builtin.ts
CHANGED
|
@@ -7,18 +7,196 @@
|
|
|
7
7
|
* ToolResult{isError:true}, never a thrown exception across the boundary. Paths
|
|
8
8
|
* resolve relative to the process working directory unless absolute.
|
|
9
9
|
*/
|
|
10
|
-
import { spawn } from "node:child_process"
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
11
11
|
import fs from "node:fs/promises"
|
|
12
|
-
import { existsSync, readdirSync } from "node:fs"
|
|
12
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs"
|
|
13
|
+
import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js"
|
|
13
14
|
import path from "node:path"
|
|
14
15
|
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
15
16
|
import { pending } from "./types.js"
|
|
16
17
|
import { mediaTypeFor } from "./content.js"
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Config for the single global builtin toggle (mirrors MCP isEnabled precedence),
|
|
21
|
+
* plus the host boundary: which interpreter `bash` runs, what a relative path
|
|
22
|
+
* resolves against, and whether paths leaving it are refused (SPEC §4A, ADR 0034).
|
|
23
|
+
*/
|
|
19
24
|
export type BuiltinsConfig =
|
|
20
25
|
| boolean
|
|
21
|
-
| {
|
|
26
|
+
| {
|
|
27
|
+
enabled?: boolean
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
tools?: Record<string, boolean>
|
|
30
|
+
/**
|
|
31
|
+
* The argv prefix `bash` runs a command with — `["sh","-c"]`,
|
|
32
|
+
* `["cmd","/d","/s","/c"]`, `["powershell","-NoProfile","-Command"]`. Set,
|
|
33
|
+
* it is used verbatim. Absent, an interpreter is DETECTED at toolkit
|
|
34
|
+
* construction (POSIX `sh -c`; Windows %COMSPEC% → pwsh → powershell → bash).
|
|
35
|
+
*/
|
|
36
|
+
shell?: string[]
|
|
37
|
+
/**
|
|
38
|
+
* What a RELATIVE path means, for every builtin that touches the
|
|
39
|
+
* filesystem — including the paths inside apply_patch's patch text, and as
|
|
40
|
+
* bash's default workdir. Empty ⇒ the process cwd, i.e. today's behaviour.
|
|
41
|
+
*/
|
|
42
|
+
baseDir?: string
|
|
43
|
+
/**
|
|
44
|
+
* Refuse any path whose CANONICAL form leaves baseDir (symlinks, Windows
|
|
45
|
+
* junctions and short names resolved on both sides), and Windows reserved
|
|
46
|
+
* device names. Default off. A guarantee about path resolution in the file
|
|
47
|
+
* builtins, NOT a sandbox — `bash` still reaches the whole filesystem.
|
|
48
|
+
*/
|
|
49
|
+
confineToBaseDir?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How long a job gets between "please stop" and "stop". Fixed, and identical in
|
|
54
|
+
* every port, so a timeout means the same thing everywhere.
|
|
55
|
+
*/
|
|
56
|
+
const KILL_GRACE_MS = 2000
|
|
57
|
+
|
|
58
|
+
const WIN_RESERVED = new Set([
|
|
59
|
+
"CON", "PRN", "AUX", "NUL",
|
|
60
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
61
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
/** Does `name` resolve as an executable on PATH? (PATHEXT-aware on Windows.) */
|
|
65
|
+
function resolvesOnPath(name: string): boolean {
|
|
66
|
+
if (name.includes(path.sep) || name.includes("/")) return existsSync(name)
|
|
67
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)
|
|
68
|
+
const exts =
|
|
69
|
+
process.platform === "win32"
|
|
70
|
+
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
71
|
+
: [""]
|
|
72
|
+
for (const dir of dirs) {
|
|
73
|
+
for (const ext of exts) {
|
|
74
|
+
if (existsSync(path.join(dir, name + ext))) return true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The interpreters tried when the host names none. %COMSPEC% leads on Windows
|
|
82
|
+
* because PowerShell is routinely blocked by execution or application-control
|
|
83
|
+
* policy, while %COMSPEC% is always present.
|
|
84
|
+
*/
|
|
85
|
+
function shellCandidates(): string[][] {
|
|
86
|
+
// `/bin/sh` first, then a PATH lookup: `shell: true` means literally
|
|
87
|
+
// `/bin/sh`, while spawning the bare name `sh` is a PATH lookup — two
|
|
88
|
+
// different binaries on a machine that has both. Naming the absolute path
|
|
89
|
+
// first is what makes dropping `shell: true` byte-identical here.
|
|
90
|
+
if (process.platform !== "win32") return [["/bin/sh", "-c"], ["sh", "-c"]]
|
|
91
|
+
const out: string[][] = []
|
|
92
|
+
if (process.env.COMSPEC) out.push([process.env.COMSPEC, "/d", "/s", "/c"])
|
|
93
|
+
out.push(
|
|
94
|
+
["cmd.exe", "/d", "/s", "/c"],
|
|
95
|
+
["pwsh", "-NoProfile", "-Command"],
|
|
96
|
+
["powershell", "-NoProfile", "-Command"],
|
|
97
|
+
["bash", "-lc"],
|
|
98
|
+
)
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The host boundary the builtins are allowed to know about. Constructed once,
|
|
104
|
+
* at toolkit construction: a missing interpreter is a configuration fact, and
|
|
105
|
+
* turn fourteen of a paid run is the expensive place to learn it.
|
|
106
|
+
*/
|
|
107
|
+
class BuiltinEnv {
|
|
108
|
+
readonly shell: string[]
|
|
109
|
+
readonly shellError?: Error
|
|
110
|
+
readonly baseDir: string
|
|
111
|
+
readonly confine: boolean
|
|
112
|
+
|
|
113
|
+
constructor(cfg?: BuiltinsConfig) {
|
|
114
|
+
const obj = typeof cfg === "object" && cfg !== null ? cfg : {}
|
|
115
|
+
this.baseDir = obj.baseDir ?? ""
|
|
116
|
+
this.confine = obj.confineToBaseDir === true
|
|
117
|
+
if (obj.shell && obj.shell.length > 0) {
|
|
118
|
+
this.shell = obj.shell
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const tried: string[] = []
|
|
122
|
+
for (const argv of shellCandidates()) {
|
|
123
|
+
tried.push(argv[0])
|
|
124
|
+
if (resolvesOnPath(argv[0])) {
|
|
125
|
+
this.shell = argv
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.shell = []
|
|
130
|
+
this.shellError = new Error(
|
|
131
|
+
`no shell interpreter found (tried: ${tried.join(", ")}); set builtins.shell, or disable the bash builtin with builtins.tools.bash = false`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The interpreter argv, or the detection error when there is none. */
|
|
136
|
+
shellArgv(): string[] {
|
|
137
|
+
if (this.shellError) throw this.shellError
|
|
138
|
+
return this.shell
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get shellLabel(): string {
|
|
142
|
+
return this.shell.join(" ")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The directory a command or a walk starts from. */
|
|
146
|
+
dir(): string {
|
|
147
|
+
return this.baseDir || process.cwd()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Map a tool-supplied path onto the filesystem: relative to baseDir (or, with
|
|
152
|
+
* none, exactly as before), and refused when confinement is on and the
|
|
153
|
+
* canonical target is outside the base.
|
|
154
|
+
*/
|
|
155
|
+
resolvePath(p: string): string {
|
|
156
|
+
if (this.confine && !this.baseDir) {
|
|
157
|
+
throw new Error("confineToBaseDir is set but baseDir is empty")
|
|
158
|
+
}
|
|
159
|
+
const full = this.baseDir && !path.isAbsolute(p) ? path.join(this.baseDir, p) : p
|
|
160
|
+
if (!this.confine) return full
|
|
161
|
+
if (process.platform === "win32") {
|
|
162
|
+
const base = path.basename(full).split(".")[0].trim().toUpperCase()
|
|
163
|
+
if (WIN_RESERVED.has(base)) {
|
|
164
|
+
throw new Error(`${p} names a reserved device, which is not a file inside ${this.baseDir}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const canonBase = canonicalPath(this.baseDir)
|
|
168
|
+
const canonTarget = canonicalPath(full)
|
|
169
|
+
const rel = path.relative(canonBase, canonTarget)
|
|
170
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
171
|
+
throw new Error(`${p} resolves outside baseDir ${this.baseDir}`)
|
|
172
|
+
}
|
|
173
|
+
return full
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a path for comparison. Symlinks — and, on Windows, directory
|
|
179
|
+
* junctions and 8.3 short names — are resolved on the DEEPEST EXISTING
|
|
180
|
+
* ancestor and the remaining segments re-attached, because a file `write` is
|
|
181
|
+
* about to create has no real path, and a check that only works on existing
|
|
182
|
+
* files is not a check for `write`.
|
|
183
|
+
*/
|
|
184
|
+
function canonicalPath(p: string): string {
|
|
185
|
+
const abs = path.resolve(p)
|
|
186
|
+
let cur = abs
|
|
187
|
+
let tail = ""
|
|
188
|
+
for (;;) {
|
|
189
|
+
try {
|
|
190
|
+
const resolved = realpathSync.native(cur)
|
|
191
|
+
return tail ? path.join(resolved, tail) : resolved
|
|
192
|
+
} catch {
|
|
193
|
+
const parent = path.dirname(cur)
|
|
194
|
+
if (parent === cur) return abs
|
|
195
|
+
tail = tail ? path.join(path.basename(cur), tail) : path.basename(cur)
|
|
196
|
+
cur = parent
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
22
200
|
|
|
23
201
|
/**
|
|
24
202
|
* Whether the builtin source is on. Default ON. Same precedence as MCP:
|
|
@@ -41,9 +219,13 @@ export function builtinsEnabled(cfg: BuiltinsConfig | undefined): boolean {
|
|
|
41
219
|
export function selectBuiltins(cfg: BuiltinsConfig | undefined): Tool[] {
|
|
42
220
|
if (!builtinsEnabled(cfg)) return []
|
|
43
221
|
const map = typeof cfg === "object" ? cfg.tools : undefined
|
|
44
|
-
const all = createBuiltinTools()
|
|
45
|
-
|
|
46
|
-
|
|
222
|
+
const all = createBuiltinTools(cfg)
|
|
223
|
+
const selected = map ? all.filter((t) => map[t.name] !== false) : all
|
|
224
|
+
// A missing interpreter is a construction-time failure, and only when `bash`
|
|
225
|
+
// survived the toggles: a host that disabled it should run fine on a box with
|
|
226
|
+
// no shell at all (ADR 0034 D1).
|
|
227
|
+
if (selected.some((t) => t.name === "bash")) builtinShell(cfg)
|
|
228
|
+
return selected
|
|
47
229
|
}
|
|
48
230
|
|
|
49
231
|
const err = (output: string, metadata?: Record<string, unknown>): ToolResult => ({ output, isError: true, metadata })
|
|
@@ -115,7 +297,9 @@ function walkFiles(root: string): string[] {
|
|
|
115
297
|
const dir = stack.pop()!
|
|
116
298
|
let entries
|
|
117
299
|
try {
|
|
118
|
-
|
|
300
|
+
// EXPLICIT (A24). `glob` breaks at its cap, so an unsorted read changes WHICH files the
|
|
301
|
+
// model is shown, not merely their order — the same content bug as <skill_files> had.
|
|
302
|
+
entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }))
|
|
119
303
|
} catch {
|
|
120
304
|
continue
|
|
121
305
|
}
|
|
@@ -136,7 +320,7 @@ function walkFiles(root: string): string[] {
|
|
|
136
320
|
// individual tools
|
|
137
321
|
// ---------------------------------------------------------------------------
|
|
138
322
|
|
|
139
|
-
function bashTool(): Tool {
|
|
323
|
+
function bashTool(env: BuiltinEnv): Tool {
|
|
140
324
|
return builtin(
|
|
141
325
|
"bash",
|
|
142
326
|
"Run a shell command and return its combined stdout+stderr. Non-zero exit is an error.",
|
|
@@ -151,38 +335,134 @@ function bashTool(): Tool {
|
|
|
151
335
|
required: ["command"],
|
|
152
336
|
additionalProperties: false,
|
|
153
337
|
},
|
|
154
|
-
(args) =>
|
|
338
|
+
(args, ctx) =>
|
|
155
339
|
new Promise<ToolResult>((resolve) => {
|
|
156
340
|
const command = String(args.command ?? "")
|
|
157
341
|
if (!command) return resolve(err("bash: command is required"))
|
|
158
|
-
|
|
342
|
+
let argv: string[]
|
|
343
|
+
let workdir: string
|
|
344
|
+
try {
|
|
345
|
+
argv = env.shellArgv()
|
|
346
|
+
workdir = args.workdir ? env.resolvePath(String(args.workdir)) : env.dir()
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return resolve(err(`bash: ${(e as Error).message}`))
|
|
349
|
+
}
|
|
159
350
|
const timeout = typeof args.timeout === "number" ? args.timeout : 60_000
|
|
160
|
-
const
|
|
351
|
+
const meta: Record<string, unknown> = { shell: env.shellLabel }
|
|
352
|
+
|
|
353
|
+
// NOT `shell: true`: on Windows that silently means cmd.exe while every
|
|
354
|
+
// other port fails loudly, and it hides WHICH interpreter ran. `detached`
|
|
355
|
+
// puts the child in its own process group so a kill reaches the command
|
|
356
|
+
// AND everything it started — without it the kill reaches the
|
|
357
|
+
// interpreter only and the real work runs on, reparented (ADR 0034 D4).
|
|
358
|
+
const child = spawn(argv[0], [...argv.slice(1), command], {
|
|
359
|
+
cwd: workdir,
|
|
360
|
+
detached: process.platform !== "win32",
|
|
361
|
+
})
|
|
161
362
|
let out = ""
|
|
162
|
-
let
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
363
|
+
let stopped: "" | "timeout" | "cancelled" = ""
|
|
364
|
+
let killedTree = false
|
|
365
|
+
let settled = false
|
|
366
|
+
|
|
367
|
+
// Captured once, at spawn: `detached` makes the child a process-group
|
|
368
|
+
// LEADER, so its pid is the pgid. Reading it later from `child` would
|
|
369
|
+
// risk signalling by a pid the runtime has already let go of.
|
|
370
|
+
const pgid = child.pid
|
|
371
|
+
|
|
372
|
+
const killJob = (graceful: boolean): boolean => {
|
|
373
|
+
// Nothing may be signalled once this call has settled: a stale timer
|
|
374
|
+
// firing after the fact would aim at a pid the OS may have reissued,
|
|
375
|
+
// and a NEGATED pid is a whole process group.
|
|
376
|
+
if (settled) return false
|
|
377
|
+
if (typeof pgid !== "number" || !Number.isInteger(pgid) || pgid <= 1) return false
|
|
378
|
+
try {
|
|
379
|
+
if (process.platform === "win32") {
|
|
380
|
+
// Windows has NO graceful termination for a console process, and
|
|
381
|
+
// asking anyway is worse than not asking: measured on a native
|
|
382
|
+
// Windows box, `taskkill /T` without `/F` refuses every console
|
|
383
|
+
// process in the tree ("can only be terminated forcefully") while
|
|
384
|
+
// still being able to take the parent down — which reparents the
|
|
385
|
+
// grandchild and leaves it running. So the job is terminated at
|
|
386
|
+
// once there, and the grace window is a POSIX effect (ADR 0034 D4).
|
|
387
|
+
const r = spawnSync("taskkill", ["/T", "/F", "/PID", String(pgid)])
|
|
388
|
+
return r.status === 0
|
|
389
|
+
}
|
|
390
|
+
// Never signal a REAPED child by negated pid: that pid may belong to
|
|
391
|
+
// something else by now, and the blast radius is a whole process
|
|
392
|
+
// GROUP. (On Windows above, taskkill targets one pid and a dead pid
|
|
393
|
+
// is merely an error, so the same guard is not needed there — and
|
|
394
|
+
// must not be applied, because the tree can outlive the child.)
|
|
395
|
+
if (child.exitCode !== null || child.signalCode !== null) return false
|
|
396
|
+
process.kill(-pgid, graceful ? "SIGTERM" : "SIGKILL")
|
|
397
|
+
return true
|
|
398
|
+
} catch {
|
|
399
|
+
return false
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Ask, wait out the grace window, then insist. A runner that gets
|
|
404
|
+
// SIGTERM removes its temp directories; one that gets SIGKILL does not.
|
|
405
|
+
const stop = (why: "timeout" | "cancelled") => {
|
|
406
|
+
if (stopped) return
|
|
407
|
+
stopped = why
|
|
408
|
+
killedTree = killJob(true)
|
|
409
|
+
graceTimer = setTimeout(() => {
|
|
410
|
+
if (killJob(false)) killedTree = true
|
|
411
|
+
}, KILL_GRACE_MS)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let graceTimer: NodeJS.Timeout | undefined
|
|
415
|
+
const timer = setTimeout(() => stop("timeout"), timeout)
|
|
416
|
+
const signal = ctx?.signal
|
|
417
|
+
const onAbort = () => stop("cancelled")
|
|
418
|
+
signal?.addEventListener?.("abort", onAbort, { once: true })
|
|
419
|
+
|
|
420
|
+
const finish = (result: ToolResult) => {
|
|
421
|
+
if (settled) return
|
|
422
|
+
settled = true
|
|
423
|
+
clearTimeout(timer)
|
|
424
|
+
if (graceTimer) clearTimeout(graceTimer)
|
|
425
|
+
signal?.removeEventListener?.("abort", onAbort)
|
|
426
|
+
resolve(result)
|
|
427
|
+
}
|
|
428
|
+
|
|
167
429
|
child.stdout?.on("data", (d) => (out += d.toString()))
|
|
168
430
|
child.stderr?.on("data", (d) => (out += d.toString()))
|
|
169
|
-
child.on("error", (e) => {
|
|
170
|
-
clearTimeout(timer)
|
|
171
|
-
resolve(err(`bash: ${e.message}`))
|
|
172
|
-
})
|
|
431
|
+
child.on("error", (e) => finish(err(`bash: ${e.message}`, meta)))
|
|
173
432
|
child.on("close", (code) => {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
433
|
+
if (stopped) {
|
|
434
|
+
// WINDOWS ONLY, and the platform guard is the point. There, the
|
|
435
|
+
// direct child closing does not mean the job is gone — a detached
|
|
436
|
+
// grandchild outlives it — so the tree is terminated here by pid.
|
|
437
|
+
//
|
|
438
|
+
// On POSIX this must NOT run. `close` only fires once the stdio the
|
|
439
|
+
// grandchild inherited has closed, so the job is already gone; and
|
|
440
|
+
// signalling `-pid` after the child has been reaped aims at whatever
|
|
441
|
+
// process now owns that pid. PIDs recycle fast on a busy CI box, and
|
|
442
|
+
// the blast radius of a negated pid is a whole process GROUP — which
|
|
443
|
+
// is how this killed sibling test workers on the GitHub runner while
|
|
444
|
+
// passing on macOS, in a container, and on its own.
|
|
445
|
+
if (!killedTree && process.platform === "win32") killedTree = killJob(false)
|
|
446
|
+
meta.timedOut = stopped === "timeout"
|
|
447
|
+
meta.killedTree = killedTree
|
|
448
|
+
return finish(
|
|
449
|
+
err(
|
|
450
|
+
stopped === "timeout"
|
|
451
|
+
? `bash: command timed out after ${timeout}ms\n${out}`
|
|
452
|
+
: `bash: command cancelled\n${out}`,
|
|
453
|
+
meta,
|
|
454
|
+
),
|
|
455
|
+
)
|
|
178
456
|
}
|
|
179
|
-
|
|
457
|
+
meta.exitCode = code
|
|
458
|
+
if (code !== 0) return finish(err(`${out}\nbash: command exited with code ${code}`, meta))
|
|
459
|
+
finish(ok(out, meta))
|
|
180
460
|
})
|
|
181
461
|
}),
|
|
182
462
|
)
|
|
183
463
|
}
|
|
184
464
|
|
|
185
|
-
function readTool(): Tool {
|
|
465
|
+
function readTool(env: BuiltinEnv): Tool {
|
|
186
466
|
return builtin(
|
|
187
467
|
"read",
|
|
188
468
|
"Read a file. Recognised media (png/jpg/jpeg/gif/webp/pdf/mp3/wav) comes back as a content part; anything else is read as UTF-8 text, with offset/limit returning that line window.",
|
|
@@ -200,14 +480,16 @@ function readTool(): Tool {
|
|
|
200
480
|
const p = String(args.path ?? "")
|
|
201
481
|
if (!p) return err("read: path is required")
|
|
202
482
|
let bytes: Buffer
|
|
483
|
+
let full: string
|
|
203
484
|
try {
|
|
204
|
-
|
|
485
|
+
full = env.resolvePath(p)
|
|
486
|
+
bytes = await fs.readFile(full)
|
|
205
487
|
} catch (e) {
|
|
206
488
|
return err(`read: ${e instanceof Error ? e.message : String(e)}`)
|
|
207
489
|
}
|
|
208
490
|
// §6: a recognised media extension comes back as a part. The table is fixed — no magic-byte
|
|
209
491
|
// sniffing and no platform mime database, whose contents vary per machine.
|
|
210
|
-
const media = mediaTypeFor(
|
|
492
|
+
const media = mediaTypeFor(full)
|
|
211
493
|
if (media) {
|
|
212
494
|
return {
|
|
213
495
|
output: `${p} (${media.mimeType}, ${bytes.length} bytes)`,
|
|
@@ -234,7 +516,7 @@ function readTool(): Tool {
|
|
|
234
516
|
)
|
|
235
517
|
}
|
|
236
518
|
|
|
237
|
-
function writeTool(): Tool {
|
|
519
|
+
function writeTool(env: BuiltinEnv): Tool {
|
|
238
520
|
return builtin(
|
|
239
521
|
"write",
|
|
240
522
|
"Write content to a file (create/overwrite), creating parent directories.",
|
|
@@ -251,15 +533,21 @@ function writeTool(): Tool {
|
|
|
251
533
|
const p = String(args.path ?? "")
|
|
252
534
|
if (!p) return err("write: path is required")
|
|
253
535
|
const content = typeof args.content === "string" ? args.content : String(args.content ?? "")
|
|
254
|
-
|
|
255
|
-
|
|
536
|
+
let full: string
|
|
537
|
+
try {
|
|
538
|
+
full = env.resolvePath(p)
|
|
539
|
+
} catch (e) {
|
|
540
|
+
return err(`write: ${e instanceof Error ? e.message : String(e)}`)
|
|
541
|
+
}
|
|
542
|
+
await fs.mkdir(path.dirname(path.resolve(full)), { recursive: true })
|
|
543
|
+
await fs.writeFile(full, content, "utf8")
|
|
256
544
|
const bytes = Buffer.byteLength(content, "utf8")
|
|
257
545
|
return ok(`Wrote ${bytes} bytes to ${p}`, { bytes })
|
|
258
546
|
},
|
|
259
547
|
)
|
|
260
548
|
}
|
|
261
549
|
|
|
262
|
-
function editTool(): Tool {
|
|
550
|
+
function editTool(env: BuiltinEnv): Tool {
|
|
263
551
|
return builtin(
|
|
264
552
|
"edit",
|
|
265
553
|
"Exact-string replace in a file. Default replaces a single unique occurrence; replaceAll replaces all.",
|
|
@@ -283,8 +571,10 @@ function editTool(): Tool {
|
|
|
283
571
|
const oldString = args.oldString
|
|
284
572
|
const newString = typeof args.newString === "string" ? args.newString : String(args.newString ?? "")
|
|
285
573
|
let content: string
|
|
574
|
+
let full: string
|
|
286
575
|
try {
|
|
287
|
-
|
|
576
|
+
full = env.resolvePath(p)
|
|
577
|
+
content = await fs.readFile(full, "utf8")
|
|
288
578
|
} catch (e) {
|
|
289
579
|
return err(`edit: ${e instanceof Error ? e.message : String(e)}`)
|
|
290
580
|
}
|
|
@@ -305,7 +595,7 @@ function editTool(): Tool {
|
|
|
305
595
|
)
|
|
306
596
|
}
|
|
307
597
|
|
|
308
|
-
function grepTool(): Tool {
|
|
598
|
+
function grepTool(env: BuiltinEnv): Tool {
|
|
309
599
|
return builtin(
|
|
310
600
|
"grep",
|
|
311
601
|
"Search file contents by regex under a directory. Output is file:line:text matches.",
|
|
@@ -329,13 +619,21 @@ function grepTool(): Tool {
|
|
|
329
619
|
} catch (e) {
|
|
330
620
|
return err(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`)
|
|
331
621
|
}
|
|
332
|
-
|
|
622
|
+
let root: string
|
|
623
|
+
try {
|
|
624
|
+
root = args.path ? env.resolvePath(String(args.path)) : env.dir()
|
|
625
|
+
} catch (e) {
|
|
626
|
+
return err(`grep: ${e instanceof Error ? e.message : String(e)}`)
|
|
627
|
+
}
|
|
333
628
|
const include = args.include ? String(args.include) : undefined
|
|
334
629
|
const limit = typeof args.limit === "number" ? args.limit : 100
|
|
335
|
-
|
|
630
|
+
// Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
|
|
631
|
+
// CONTENT and the order of the results were whatever the walk happened to reach first.
|
|
632
|
+
// Now: collect every match, order by relative path (code point) then line number ascending,
|
|
633
|
+
// and truncate last. The emitted path and the sort key are the same string (A28).
|
|
634
|
+
const hits: Array<{ rel: string; line: number; text: string }> = []
|
|
336
635
|
for (const file of walkFiles(root)) {
|
|
337
|
-
|
|
338
|
-
const rel = path.relative(root, file)
|
|
636
|
+
const rel = toPosixPath(path.relative(root, file), path.sep)
|
|
339
637
|
if (include && !matchGlob(rel, include)) continue
|
|
340
638
|
let text: string
|
|
341
639
|
try {
|
|
@@ -345,16 +643,17 @@ function grepTool(): Tool {
|
|
|
345
643
|
}
|
|
346
644
|
const lines = text.split("\n")
|
|
347
645
|
for (let i = 0; i < lines.length; i++) {
|
|
348
|
-
if (
|
|
349
|
-
if (re.test(lines[i])) matches.push(`${file}:${i + 1}:${lines[i]}`)
|
|
646
|
+
if (re.test(lines[i])) hits.push({ rel, line: i + 1, text: lines[i] })
|
|
350
647
|
}
|
|
351
648
|
}
|
|
649
|
+
hits.sort((a, b) => compareCodePoints(a.rel, b.rel) || a.line - b.line)
|
|
650
|
+
const matches = hits.slice(0, limit).map((h) => `${h.rel}:${h.line}:${h.text}`)
|
|
352
651
|
return ok(matches.join("\n"), { count: matches.length })
|
|
353
652
|
},
|
|
354
653
|
)
|
|
355
654
|
}
|
|
356
655
|
|
|
357
|
-
function globTool(): Tool {
|
|
656
|
+
function globTool(env: BuiltinEnv): Tool {
|
|
358
657
|
return builtin(
|
|
359
658
|
"glob",
|
|
360
659
|
"List files matching a glob under a directory. Output is newline-joined relative paths.",
|
|
@@ -371,15 +670,22 @@ function globTool(): Tool {
|
|
|
371
670
|
async (args) => {
|
|
372
671
|
const pattern = String(args.pattern ?? "")
|
|
373
672
|
if (!pattern) return err("glob: pattern is required")
|
|
374
|
-
|
|
673
|
+
let root: string
|
|
674
|
+
try {
|
|
675
|
+
root = args.path ? env.resolvePath(String(args.path)) : env.dir()
|
|
676
|
+
} catch (e) {
|
|
677
|
+
return err(`glob: ${e instanceof Error ? e.message : String(e)}`)
|
|
678
|
+
}
|
|
375
679
|
const limit = typeof args.limit === "number" ? args.limit : 100
|
|
680
|
+
// Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
|
|
681
|
+
// the filesystem decide WHICH files the model sees, not merely their order. The emitted
|
|
682
|
+
// string and the sort key are the SAME `/`-separated relative path (A28).
|
|
376
683
|
const found: string[] = []
|
|
377
684
|
for (const file of walkFiles(root)) {
|
|
378
|
-
|
|
379
|
-
const rel = path.relative(root, file)
|
|
685
|
+
const rel = toPosixPath(path.relative(root, file), path.sep)
|
|
380
686
|
if (matchGlob(rel, pattern)) found.push(rel)
|
|
381
687
|
}
|
|
382
|
-
found.sort()
|
|
688
|
+
found.sort(compareCodePoints)
|
|
383
689
|
return ok(found.slice(0, limit).join("\n"), { count: Math.min(found.length, limit) })
|
|
384
690
|
},
|
|
385
691
|
)
|
|
@@ -608,7 +914,7 @@ function applyUpdate(content: string, body: string[]): string {
|
|
|
608
914
|
return result
|
|
609
915
|
}
|
|
610
916
|
|
|
611
|
-
function applyPatchTool(): Tool {
|
|
917
|
+
function applyPatchTool(env: BuiltinEnv): Tool {
|
|
612
918
|
return builtin(
|
|
613
919
|
"apply_patch",
|
|
614
920
|
"Apply a patch (Begin/End Patch grammar: Add/Update/Delete File). Atomic — a non-matching hunk aborts with no writes.",
|
|
@@ -629,6 +935,14 @@ function applyPatchTool(): Tool {
|
|
|
629
935
|
} catch (e) {
|
|
630
936
|
return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`)
|
|
631
937
|
}
|
|
938
|
+
// The paths live INSIDE the patch text, not in the arguments, so a host
|
|
939
|
+
// cannot rewrite them from a hook — this is the one case that genuinely
|
|
940
|
+
// needs the base directory to be library-side (ADR 0034 D2).
|
|
941
|
+
try {
|
|
942
|
+
for (const op of ops) op.path = env.resolvePath(op.path)
|
|
943
|
+
} catch (e) {
|
|
944
|
+
return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`)
|
|
945
|
+
}
|
|
632
946
|
// Stage every write/delete first; only touch the filesystem once all hunks apply.
|
|
633
947
|
const writes: Array<{ path: string; content: string }> = []
|
|
634
948
|
const deletes: string[] = []
|
|
@@ -669,17 +983,27 @@ function applyPatchTool(): Tool {
|
|
|
669
983
|
* for parity: bash, read, write, edit, grep, glob, webfetch, question,
|
|
670
984
|
* apply_patch, todowrite.
|
|
671
985
|
*/
|
|
672
|
-
export function createBuiltinTools(): Tool[] {
|
|
986
|
+
export function createBuiltinTools(cfg?: BuiltinsConfig): Tool[] {
|
|
987
|
+
const env = new BuiltinEnv(cfg)
|
|
673
988
|
return [
|
|
674
|
-
bashTool(),
|
|
675
|
-
readTool(),
|
|
676
|
-
writeTool(),
|
|
677
|
-
editTool(),
|
|
678
|
-
grepTool(),
|
|
679
|
-
globTool(),
|
|
989
|
+
bashTool(env),
|
|
990
|
+
readTool(env),
|
|
991
|
+
writeTool(env),
|
|
992
|
+
editTool(env),
|
|
993
|
+
grepTool(env),
|
|
994
|
+
globTool(env),
|
|
680
995
|
webfetchTool(),
|
|
681
996
|
questionTool(),
|
|
682
|
-
applyPatchTool(),
|
|
997
|
+
applyPatchTool(env),
|
|
683
998
|
todowriteTool(),
|
|
684
999
|
]
|
|
685
1000
|
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* The interpreter the builtins resolved for `bash` — what a host prints, and
|
|
1004
|
+
* what `metadata.shell` carries on every bash result. Throws the same error
|
|
1005
|
+
* `selectBuiltins` fails construction with when nothing resolves.
|
|
1006
|
+
*/
|
|
1007
|
+
export function builtinShell(cfg?: BuiltinsConfig): string[] {
|
|
1008
|
+
return new BuiltinEnv(cfg).shellArgv()
|
|
1009
|
+
}
|