toolnexus 0.20.0 → 0.22.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/README.md +40 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +43 -7
- package/dist/agents/compaction.d.ts +19 -0
- package/dist/agents/compaction.js +26 -4
- package/dist/agents/runtime.d.ts +5 -0
- package/dist/agents/runtime.js +13 -2
- package/dist/batteries.d.ts +223 -0
- package/dist/batteries.js +488 -0
- package/dist/builtin.d.ts +16 -0
- package/dist/builtin.js +315 -17
- package/dist/builtin_extras.d.ts +80 -0
- package/dist/builtin_extras.js +248 -0
- package/dist/classifier.d.ts +14 -0
- package/dist/classifier.js +34 -1
- package/dist/client.d.ts +91 -15
- package/dist/client.js +377 -110
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/judge.d.ts +98 -0
- package/dist/judge.js +199 -0
- package/dist/mcp.d.ts +32 -1
- package/dist/mcp.js +184 -6
- package/dist/models.d.ts +20 -0
- package/dist/models.js +46 -0
- package/dist/promptcache.d.ts +21 -0
- package/dist/promptcache.js +59 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.js +131 -0
- package/dist/tooltiers.d.ts +49 -0
- package/dist/tooltiers.js +197 -0
- package/dist/types.d.ts +19 -0
- package/package.json +2 -2
- package/src/agents/agent.ts +39 -8
- package/src/agents/compaction.ts +45 -4
- package/src/agents/runtime.ts +16 -2
- package/src/batteries.ts +614 -0
- package/src/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/classifier.ts +36 -1
- package/src/client.ts +415 -115
- package/src/index.ts +31 -0
- package/src/judge.ts +305 -0
- package/src/mcp.ts +204 -7
- package/src/models.ts +56 -0
- package/src/promptcache.ts +63 -0
- package/src/store.ts +142 -0
- package/src/tooltiers.ts +221 -0
- package/src/types.ts +20 -0
package/src/builtin.ts
CHANGED
|
@@ -12,9 +12,12 @@ import fs from "node:fs/promises"
|
|
|
12
12
|
import { existsSync, readdirSync, realpathSync } from "node:fs"
|
|
13
13
|
import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js"
|
|
14
14
|
import path from "node:path"
|
|
15
|
-
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
15
|
+
import type { JSONSchema, Tool, ToolContext, ToolHints, ToolResult } from "./types.js"
|
|
16
16
|
import { pending } from "./types.js"
|
|
17
17
|
import { mediaTypeFor } from "./content.js"
|
|
18
|
+
import {
|
|
19
|
+
BackgroundShells, bashOutput, killShell, runWebSearch, editNotebook, goJsonIndent, type WebSearchFunc,
|
|
20
|
+
} from "./builtin_extras.js"
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Config for the single global builtin toggle (mirrors MCP isEnabled precedence),
|
|
@@ -47,13 +50,28 @@ export type BuiltinsConfig =
|
|
|
47
50
|
* builtins, NOT a sandbox — `bash` still reaches the whole filesystem.
|
|
48
51
|
*/
|
|
49
52
|
confineToBaseDir?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* POSIX wait in ms between SIGTERM and SIGKILL of a timed-out or cancelled
|
|
55
|
+
* `bash` job's process group. Absent or 0 ⇒ 2000. No effect on Windows,
|
|
56
|
+
* which has no graceful stop for a console process (SPEC §4A).
|
|
57
|
+
*/
|
|
58
|
+
killGraceMs?: number
|
|
59
|
+
/**
|
|
60
|
+
* Opt-in (SPEC §4C): `bash` takes `run_in_background` and the `bash_output` / `kill_shell`
|
|
61
|
+
* tools exist. The host owns the registry: call `closeAll()` on shutdown.
|
|
62
|
+
*/
|
|
63
|
+
background?: BackgroundShells
|
|
64
|
+
/** Opt-in (SPEC §4C): adds a `websearch` tool backed by this provider. The library ships none. */
|
|
65
|
+
webSearch?: WebSearchFunc
|
|
66
|
+
/** Opt-in (SPEC §4C): adds the `notebook_edit` tool for .ipynb cells. */
|
|
67
|
+
notebookEdit?: boolean
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
/**
|
|
53
|
-
* How long a job gets between "please stop" and "stop"
|
|
54
|
-
* every port, so a timeout means the same thing everywhere.
|
|
71
|
+
* How long a job gets between "please stop" and "stop" when `killGraceMs` is
|
|
72
|
+
* not set. Identical in every port, so a timeout means the same thing everywhere.
|
|
55
73
|
*/
|
|
56
|
-
const
|
|
74
|
+
const DEFAULT_KILL_GRACE_MS = 2000
|
|
57
75
|
|
|
58
76
|
const WIN_RESERVED = new Set([
|
|
59
77
|
"CON", "PRN", "AUX", "NUL",
|
|
@@ -109,9 +127,17 @@ class BuiltinEnv {
|
|
|
109
127
|
readonly shellError?: Error
|
|
110
128
|
readonly baseDir: string
|
|
111
129
|
readonly confine: boolean
|
|
130
|
+
readonly killGraceMs: number
|
|
131
|
+
readonly background?: BackgroundShells
|
|
132
|
+
readonly webSearch?: WebSearchFunc
|
|
133
|
+
readonly notebookEdit: boolean
|
|
112
134
|
|
|
113
135
|
constructor(cfg?: BuiltinsConfig) {
|
|
114
136
|
const obj = typeof cfg === "object" && cfg !== null ? cfg : {}
|
|
137
|
+
this.background = obj.background
|
|
138
|
+
this.webSearch = obj.webSearch
|
|
139
|
+
this.notebookEdit = obj.notebookEdit === true
|
|
140
|
+
this.killGraceMs = typeof obj.killGraceMs === "number" && obj.killGraceMs > 0 ? obj.killGraceMs : DEFAULT_KILL_GRACE_MS
|
|
115
141
|
this.baseDir = obj.baseDir ?? ""
|
|
116
142
|
this.confine = obj.confineToBaseDir === true
|
|
117
143
|
if (obj.shell && obj.shell.length > 0) {
|
|
@@ -321,17 +347,24 @@ function walkFiles(root: string): string[] {
|
|
|
321
347
|
// ---------------------------------------------------------------------------
|
|
322
348
|
|
|
323
349
|
function bashTool(env: BuiltinEnv): Tool {
|
|
350
|
+
const properties: Record<string, unknown> = {
|
|
351
|
+
command: { type: "string", description: "The shell command to run" },
|
|
352
|
+
workdir: { type: "string", description: "Working directory (default: process cwd)" },
|
|
353
|
+
timeout: { type: "number", description: "Timeout in milliseconds (default 60000)" },
|
|
354
|
+
description: { type: "string", description: "Human-readable description of the command" },
|
|
355
|
+
}
|
|
356
|
+
if (env.background) {
|
|
357
|
+
properties.run_in_background = {
|
|
358
|
+
type: "boolean",
|
|
359
|
+
description: "Start the command and return at once with a background shell id; read it with bash_output, stop it with kill_shell. No timeout applies.",
|
|
360
|
+
}
|
|
361
|
+
}
|
|
324
362
|
return builtin(
|
|
325
363
|
"bash",
|
|
326
364
|
"Run a shell command and return its combined stdout+stderr. Non-zero exit is an error.",
|
|
327
365
|
{
|
|
328
366
|
type: "object",
|
|
329
|
-
properties
|
|
330
|
-
command: { type: "string", description: "The shell command to run" },
|
|
331
|
-
workdir: { type: "string", description: "Working directory (default: process cwd)" },
|
|
332
|
-
timeout: { type: "number", description: "Timeout in milliseconds (default 60000)" },
|
|
333
|
-
description: { type: "string", description: "Human-readable description of the command" },
|
|
334
|
-
},
|
|
367
|
+
properties,
|
|
335
368
|
required: ["command"],
|
|
336
369
|
additionalProperties: false,
|
|
337
370
|
},
|
|
@@ -349,6 +382,10 @@ function bashTool(env: BuiltinEnv): Tool {
|
|
|
349
382
|
}
|
|
350
383
|
const timeout = typeof args.timeout === "number" ? args.timeout : 60_000
|
|
351
384
|
const meta: Record<string, unknown> = { shell: env.shellLabel }
|
|
385
|
+
if (env.background && args.run_in_background === true) {
|
|
386
|
+
const j = env.background.start(argv, command, workdir, env.killGraceMs)
|
|
387
|
+
return resolve(ok(`Started background shell ${j.id}. Read its output with bash_output, stop it with kill_shell.`, { ...meta, backgroundId: j.id }))
|
|
388
|
+
}
|
|
352
389
|
|
|
353
390
|
// NOT `shell: true`: on Windows that silently means cmd.exe while every
|
|
354
391
|
// other port fails loudly, and it hides WHICH interpreter ran. `detached`
|
|
@@ -408,7 +445,7 @@ function bashTool(env: BuiltinEnv): Tool {
|
|
|
408
445
|
killedTree = killJob(true)
|
|
409
446
|
graceTimer = setTimeout(() => {
|
|
410
447
|
if (killJob(false)) killedTree = true
|
|
411
|
-
},
|
|
448
|
+
}, env.killGraceMs)
|
|
412
449
|
}
|
|
413
450
|
|
|
414
451
|
let graceTimer: NodeJS.Timeout | undefined
|
|
@@ -511,7 +548,14 @@ function readTool(env: BuiltinEnv): Tool {
|
|
|
511
548
|
const offset = typeof args.offset === "number" ? Math.max(1, Math.trunc(args.offset)) : 1
|
|
512
549
|
const start = offset - 1
|
|
513
550
|
const limit = typeof args.limit === "number" ? Math.max(0, Math.trunc(args.limit)) : lines.length - start
|
|
514
|
-
|
|
551
|
+
const end = Math.min(Math.max(start, start + limit), lines.length)
|
|
552
|
+
const out = lines.slice(start, start + limit).join("\n")
|
|
553
|
+
// A window that stops before the end of the file says so in metadata, with the offset to
|
|
554
|
+
// continue from (SPEC §4C). The text stays byte-identical. A trailing newline is not a line.
|
|
555
|
+
let total = lines.length
|
|
556
|
+
if (total > 0 && lines[total - 1] === "") total--
|
|
557
|
+
if (end < total) return ok(out, { truncated: true, totalLines: total, nextOffset: end + 1 })
|
|
558
|
+
return ok(out)
|
|
515
559
|
},
|
|
516
560
|
)
|
|
517
561
|
}
|
|
@@ -539,10 +583,11 @@ function writeTool(env: BuiltinEnv): Tool {
|
|
|
539
583
|
} catch (e) {
|
|
540
584
|
return err(`write: ${e instanceof Error ? e.message : String(e)}`)
|
|
541
585
|
}
|
|
586
|
+
const before = await readIfExists(full)
|
|
542
587
|
await fs.mkdir(path.dirname(path.resolve(full)), { recursive: true })
|
|
543
588
|
await fs.writeFile(full, content, "utf8")
|
|
544
589
|
const bytes = Buffer.byteLength(content, "utf8")
|
|
545
|
-
return ok(`Wrote ${bytes} bytes to ${p}`, { bytes })
|
|
590
|
+
return ok(`Wrote ${bytes} bytes to ${p}`, { bytes, ...editMetadata(env, [{ path: full, before, after: content }]) })
|
|
546
591
|
},
|
|
547
592
|
)
|
|
548
593
|
}
|
|
@@ -587,9 +632,10 @@ function editTool(env: BuiltinEnv): Tool {
|
|
|
587
632
|
if (count > 1) return err(`edit: oldString is not unique in ${p} (${count} occurrences); use replaceAll`)
|
|
588
633
|
next = content.replace(oldString, newString)
|
|
589
634
|
}
|
|
590
|
-
await fs.writeFile(
|
|
635
|
+
await fs.writeFile(full, next, "utf8")
|
|
591
636
|
return ok(`Edited ${p} (${args.replaceAll === true ? count : 1} replacement${(args.replaceAll === true ? count : 1) === 1 ? "" : "s"})`, {
|
|
592
637
|
replacements: args.replaceAll === true ? count : 1,
|
|
638
|
+
...editMetadata(env, [{ path: full, before: content, after: next }]),
|
|
593
639
|
})
|
|
594
640
|
},
|
|
595
641
|
)
|
|
@@ -606,6 +652,9 @@ function grepTool(env: BuiltinEnv): Tool {
|
|
|
606
652
|
path: { type: "string", description: "Directory to search (default: process cwd)" },
|
|
607
653
|
include: { type: "string", description: "Glob filter for file names" },
|
|
608
654
|
limit: { type: "number", description: "Maximum number of matches (default 100)" },
|
|
655
|
+
context: { type: "number", description: "Lines of context before and after each match" },
|
|
656
|
+
before: { type: "number", description: "Lines of context before each match (overrides context)" },
|
|
657
|
+
after: { type: "number", description: "Lines of context after each match (overrides context)" },
|
|
609
658
|
},
|
|
610
659
|
required: ["pattern"],
|
|
611
660
|
additionalProperties: false,
|
|
@@ -631,6 +680,11 @@ function grepTool(env: BuiltinEnv): Tool {
|
|
|
631
680
|
// CONTENT and the order of the results were whatever the walk happened to reach first.
|
|
632
681
|
// Now: collect every match, order by relative path (code point) then line number ascending,
|
|
633
682
|
// and truncate last. The emitted path and the sort key are the same string (A28).
|
|
683
|
+
let before = 0, after = 0
|
|
684
|
+
if (typeof args.context === "number" && args.context > 0) before = after = Math.trunc(args.context)
|
|
685
|
+
if (typeof args.before === "number" && args.before >= 0) before = Math.trunc(args.before)
|
|
686
|
+
if (typeof args.after === "number" && args.after >= 0) after = Math.trunc(args.after)
|
|
687
|
+
const fileLines = new Map<string, string[]>() // only filled when context is asked for
|
|
634
688
|
const hits: Array<{ rel: string; line: number; text: string }> = []
|
|
635
689
|
for (const file of walkFiles(root)) {
|
|
636
690
|
const rel = toPosixPath(path.relative(root, file), path.sep)
|
|
@@ -642,13 +696,50 @@ function grepTool(env: BuiltinEnv): Tool {
|
|
|
642
696
|
continue
|
|
643
697
|
}
|
|
644
698
|
const lines = text.split("\n")
|
|
699
|
+
if (before > 0 || after > 0) fileLines.set(rel, lines)
|
|
645
700
|
for (let i = 0; i < lines.length; i++) {
|
|
646
701
|
if (re.test(lines[i])) hits.push({ rel, line: i + 1, text: lines[i] })
|
|
647
702
|
}
|
|
648
703
|
}
|
|
649
704
|
hits.sort((a, b) => compareCodePoints(a.rel, b.rel) || a.line - b.line)
|
|
650
|
-
const
|
|
651
|
-
|
|
705
|
+
const total = hits.length
|
|
706
|
+
const kept = hits.slice(0, limit)
|
|
707
|
+
let outLines: string[]
|
|
708
|
+
if (before === 0 && after === 0) {
|
|
709
|
+
outLines = kept.map((h) => `${h.rel}:${h.line}:${h.text}`)
|
|
710
|
+
} else {
|
|
711
|
+
// ripgrep-style: match lines path:N:text, context lines path-N-text, "--" between
|
|
712
|
+
// groups that do not touch.
|
|
713
|
+
outLines = []
|
|
714
|
+
const matchAt = new Map<string, Set<number>>()
|
|
715
|
+
for (const h of kept) {
|
|
716
|
+
if (!matchAt.has(h.rel)) matchAt.set(h.rel, new Set())
|
|
717
|
+
matchAt.get(h.rel)!.add(h.line)
|
|
718
|
+
}
|
|
719
|
+
let lastRel = "", lastLine = 0
|
|
720
|
+
for (const h of kept) {
|
|
721
|
+
const lines = fileLines.get(h.rel) ?? []
|
|
722
|
+
let n = lines.length
|
|
723
|
+
if (n > 0 && lines[n - 1] === "") n-- // a trailing newline is not a line
|
|
724
|
+
let from = Math.max(1, h.line - before)
|
|
725
|
+
const to = Math.min(n, h.line + after)
|
|
726
|
+
if (h.rel === lastRel && from <= lastLine + 1) from = lastLine + 1
|
|
727
|
+
else if (lastRel !== "") outLines.push("--")
|
|
728
|
+
for (let k = from; k <= to; k++) {
|
|
729
|
+
const sep = matchAt.get(h.rel)!.has(k) ? ":" : "-"
|
|
730
|
+
outLines.push(`${h.rel}${sep}${k}${sep}${lines[k - 1]}`)
|
|
731
|
+
}
|
|
732
|
+
if (to > lastLine || h.rel !== lastRel) lastLine = to
|
|
733
|
+
lastRel = h.rel
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const meta: Record<string, unknown> = { count: kept.length }
|
|
737
|
+
// Say the list was cut, and by how much, in metadata (SPEC §4C). The text is unchanged.
|
|
738
|
+
if (total > kept.length) {
|
|
739
|
+
meta.truncated = true
|
|
740
|
+
meta.total = total
|
|
741
|
+
}
|
|
742
|
+
return ok(outLines.join("\n"), meta)
|
|
652
743
|
},
|
|
653
744
|
)
|
|
654
745
|
}
|
|
@@ -946,17 +1037,22 @@ function applyPatchTool(env: BuiltinEnv): Tool {
|
|
|
946
1037
|
// Stage every write/delete first; only touch the filesystem once all hunks apply.
|
|
947
1038
|
const writes: Array<{ path: string; content: string }> = []
|
|
948
1039
|
const deletes: string[] = []
|
|
1040
|
+
const changes: FileChange[] = []
|
|
949
1041
|
try {
|
|
950
1042
|
for (const op of ops) {
|
|
951
1043
|
if (op.type === "add") {
|
|
952
1044
|
if (existsSync(op.path)) throw new Error(`file already exists: ${op.path}`)
|
|
953
1045
|
writes.push({ path: op.path, content: op.content })
|
|
1046
|
+
changes.push({ path: op.path, before: undefined, after: op.content })
|
|
954
1047
|
} else if (op.type === "delete") {
|
|
955
1048
|
if (!existsSync(op.path)) throw new Error(`file not found: ${op.path}`)
|
|
956
1049
|
deletes.push(op.path)
|
|
1050
|
+
changes.push({ path: op.path, before: await fs.readFile(op.path, "utf8"), after: undefined })
|
|
957
1051
|
} else {
|
|
958
1052
|
const content = await fs.readFile(op.path, "utf8")
|
|
959
|
-
|
|
1053
|
+
const next = applyUpdate(content, op.body)
|
|
1054
|
+
writes.push({ path: op.path, content: next })
|
|
1055
|
+
changes.push({ path: op.path, before: content, after: next })
|
|
960
1056
|
}
|
|
961
1057
|
}
|
|
962
1058
|
} catch (e) {
|
|
@@ -973,11 +1069,115 @@ function applyPatchTool(env: BuiltinEnv): Tool {
|
|
|
973
1069
|
added: ops.filter((o) => o.type === "add").length,
|
|
974
1070
|
updated: ops.filter((o) => o.type === "update").length,
|
|
975
1071
|
deleted: ops.filter((o) => o.type === "delete").length,
|
|
1072
|
+
...editMetadata(env, changes),
|
|
976
1073
|
})
|
|
977
1074
|
},
|
|
978
1075
|
)
|
|
979
1076
|
}
|
|
980
1077
|
|
|
1078
|
+
// ---------------------------------------------------------------------------
|
|
1079
|
+
// edit diffs (SPEC §4A "Edit diffs"): metadata.files + metadata.diff
|
|
1080
|
+
// ---------------------------------------------------------------------------
|
|
1081
|
+
|
|
1082
|
+
/** One file a builtin changed; `undefined` before = created, `undefined` after = deleted. */
|
|
1083
|
+
type FileChange = { path: string; before: string | undefined; after: string | undefined }
|
|
1084
|
+
|
|
1085
|
+
async function readIfExists(p: string): Promise<string | undefined> {
|
|
1086
|
+
try {
|
|
1087
|
+
return await fs.readFile(p, "utf8")
|
|
1088
|
+
} catch {
|
|
1089
|
+
return undefined
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Lines of a file's text; a trailing newline does not make an extra empty line. */
|
|
1094
|
+
function diffLines(text: string | undefined): string[] {
|
|
1095
|
+
if (!text) return []
|
|
1096
|
+
const lines = text.split("\n")
|
|
1097
|
+
if (lines[lines.length - 1] === "") lines.pop()
|
|
1098
|
+
return lines
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
type DiffOp = { kind: " " | "-" | "+"; line: string }
|
|
1102
|
+
|
|
1103
|
+
/** Line diff by longest common subsequence — minimal, so the counts agree across ports. */
|
|
1104
|
+
function lineDiff(a0: string[], b0: string[]): DiffOp[] {
|
|
1105
|
+
// Common prefix/suffix first: an edit touches a few lines, so the LCS table stays small.
|
|
1106
|
+
let pre = 0
|
|
1107
|
+
while (pre < a0.length && pre < b0.length && a0[pre] === b0[pre]) pre++
|
|
1108
|
+
let suf = 0
|
|
1109
|
+
while (suf < a0.length - pre && suf < b0.length - pre && a0[a0.length - 1 - suf] === b0[b0.length - 1 - suf]) suf++
|
|
1110
|
+
const head: DiffOp[] = a0.slice(0, pre).map((line) => ({ kind: " ", line }))
|
|
1111
|
+
const tail: DiffOp[] = a0.slice(a0.length - suf).map((line) => ({ kind: " ", line }))
|
|
1112
|
+
const a = a0.slice(pre, a0.length - suf), b = b0.slice(pre, b0.length - suf)
|
|
1113
|
+
const n = a.length, m = b.length
|
|
1114
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
|
|
1115
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1116
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
1117
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
const ops: DiffOp[] = []
|
|
1121
|
+
let i = 0, j = 0
|
|
1122
|
+
while (i < n && j < m) {
|
|
1123
|
+
if (a[i] === b[j]) { ops.push({ kind: " ", line: a[i] }); i++; j++ }
|
|
1124
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) ops.push({ kind: "-", line: a[i++] })
|
|
1125
|
+
else ops.push({ kind: "+", line: b[j++] })
|
|
1126
|
+
}
|
|
1127
|
+
while (i < n) ops.push({ kind: "-", line: a[i++] })
|
|
1128
|
+
while (j < m) ops.push({ kind: "+", line: b[j++] })
|
|
1129
|
+
return [...head, ...ops, ...tail]
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Unified hunks with 3 lines of context. */
|
|
1133
|
+
function unifiedHunks(ops: DiffOp[]): string[] {
|
|
1134
|
+
const CTX = 3
|
|
1135
|
+
const out: string[] = []
|
|
1136
|
+
const changed = ops.map((o, k) => (o.kind === " " ? -1 : k)).filter((k) => k >= 0)
|
|
1137
|
+
if (!changed.length) return out
|
|
1138
|
+
// group change indices whose gap of context is <= 2*CTX
|
|
1139
|
+
const groups: Array<[number, number]> = []
|
|
1140
|
+
for (const k of changed) {
|
|
1141
|
+
const g = groups[groups.length - 1]
|
|
1142
|
+
if (g && k - g[1] <= 2 * CTX) g[1] = k
|
|
1143
|
+
else groups.push([k, k])
|
|
1144
|
+
}
|
|
1145
|
+
// old/new line number before each op
|
|
1146
|
+
const oldAt: number[] = [], newAt: number[] = []
|
|
1147
|
+
let o = 1, nn = 1
|
|
1148
|
+
for (const op of ops) {
|
|
1149
|
+
oldAt.push(o); newAt.push(nn)
|
|
1150
|
+
if (op.kind !== "+") o++
|
|
1151
|
+
if (op.kind !== "-") nn++
|
|
1152
|
+
}
|
|
1153
|
+
for (const [s0, e0] of groups) {
|
|
1154
|
+
const s = Math.max(0, s0 - CTX), e = Math.min(ops.length - 1, e0 + CTX)
|
|
1155
|
+
const slice = ops.slice(s, e + 1)
|
|
1156
|
+
const oldLen = slice.filter((x) => x.kind !== "+").length
|
|
1157
|
+
const newLen = slice.filter((x) => x.kind !== "-").length
|
|
1158
|
+
const oldStart = oldLen ? oldAt[s] : oldAt[s] - 1
|
|
1159
|
+
const newStart = newLen ? newAt[s] : newAt[s] - 1
|
|
1160
|
+
out.push(`@@ -${oldStart},${oldLen} +${newStart},${newLen} @@`)
|
|
1161
|
+
for (const x of slice) out.push(x.kind + x.line)
|
|
1162
|
+
}
|
|
1163
|
+
return out
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function editMetadata(env: BuiltinEnv, changes: FileChange[]): { files: unknown[]; diff: string } {
|
|
1167
|
+
const files: unknown[] = []
|
|
1168
|
+
const diff: string[] = []
|
|
1169
|
+
for (const c of changes) {
|
|
1170
|
+
const rel = toPosixPath(env.baseDir ? path.relative(path.resolve(env.baseDir), path.resolve(c.path)) : c.path, path.sep)
|
|
1171
|
+
const ops = lineDiff(diffLines(c.before), diffLines(c.after))
|
|
1172
|
+
const additions = ops.filter((x) => x.kind === "+").length
|
|
1173
|
+
const deletions = ops.filter((x) => x.kind === "-").length
|
|
1174
|
+
const status = c.before === undefined ? "added" : c.after === undefined ? "deleted" : "modified"
|
|
1175
|
+
files.push({ path: rel, status, additions, deletions })
|
|
1176
|
+
diff.push(`--- ${c.before === undefined ? "/dev/null" : `a/${rel}`}`, `+++ ${c.after === undefined ? "/dev/null" : `b/${rel}`}`, ...unifiedHunks(ops))
|
|
1177
|
+
}
|
|
1178
|
+
return { files, diff: diff.length ? diff.join("\n") + "\n" : "" }
|
|
1179
|
+
}
|
|
1180
|
+
|
|
981
1181
|
/**
|
|
982
1182
|
* Build the ten built-in tools (each source:"builtin"). The order is fixed
|
|
983
1183
|
* for parity: bash, read, write, edit, grep, glob, webfetch, question,
|
|
@@ -985,7 +1185,7 @@ function applyPatchTool(env: BuiltinEnv): Tool {
|
|
|
985
1185
|
*/
|
|
986
1186
|
export function createBuiltinTools(cfg?: BuiltinsConfig): Tool[] {
|
|
987
1187
|
const env = new BuiltinEnv(cfg)
|
|
988
|
-
|
|
1188
|
+
const tools = [
|
|
989
1189
|
bashTool(env),
|
|
990
1190
|
readTool(env),
|
|
991
1191
|
writeTool(env),
|
|
@@ -997,6 +1197,121 @@ export function createBuiltinTools(cfg?: BuiltinsConfig): Tool[] {
|
|
|
997
1197
|
applyPatchTool(env),
|
|
998
1198
|
todowriteTool(),
|
|
999
1199
|
]
|
|
1200
|
+
if (env.background) tools.push(bashOutputTool(env.background), killShellTool(env.background))
|
|
1201
|
+
if (env.webSearch) tools.push(webSearchTool(env.webSearch))
|
|
1202
|
+
if (env.notebookEdit) tools.push(notebookEditTool(env))
|
|
1203
|
+
for (const t of tools) t.hints = { ...BUILTIN_HINTS[t.name] }
|
|
1204
|
+
return tools
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const hint = (readOnly: boolean, destructive: boolean, openWorld: boolean): ToolHints => ({ readOnly, destructive, openWorld })
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Hints on every builtin, for host permission gates (SPEC §4C). bash can do anything, so it is
|
|
1211
|
+
* mutating, destructive and open-world. todowrite and question only touch the agent's own state
|
|
1212
|
+
* or ask the human, so they count as read-only for the host's files and systems.
|
|
1213
|
+
*/
|
|
1214
|
+
const BUILTIN_HINTS: Record<string, ToolHints> = {
|
|
1215
|
+
bash: hint(false, true, true),
|
|
1216
|
+
read: hint(true, false, false),
|
|
1217
|
+
write: hint(false, true, false),
|
|
1218
|
+
edit: hint(false, true, false),
|
|
1219
|
+
grep: hint(true, false, false),
|
|
1220
|
+
glob: hint(true, false, false),
|
|
1221
|
+
webfetch: hint(true, false, true),
|
|
1222
|
+
question: hint(true, false, false),
|
|
1223
|
+
apply_patch: hint(false, true, false),
|
|
1224
|
+
todowrite: hint(true, false, false),
|
|
1225
|
+
bash_output: hint(true, false, false),
|
|
1226
|
+
kill_shell: hint(false, true, false),
|
|
1227
|
+
websearch: hint(true, false, true),
|
|
1228
|
+
notebook_edit: hint(false, true, false),
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const idSchema: JSONSchema = {
|
|
1232
|
+
type: "object",
|
|
1233
|
+
properties: { id: { type: "string", description: "The background shell id bash returned" } },
|
|
1234
|
+
required: ["id"],
|
|
1235
|
+
additionalProperties: false,
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function bashOutputTool(bg: BackgroundShells): Tool {
|
|
1239
|
+
return builtin(
|
|
1240
|
+
"bash_output",
|
|
1241
|
+
"Read the output a background bash command (started with run_in_background) produced since the last read, and whether it is still running.",
|
|
1242
|
+
idSchema,
|
|
1243
|
+
async (args) => bashOutput(bg, typeof args.id === "string" ? args.id : ""),
|
|
1244
|
+
)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function killShellTool(bg: BackgroundShells): Tool {
|
|
1248
|
+
return builtin(
|
|
1249
|
+
"kill_shell",
|
|
1250
|
+
"Stop a background bash command and everything it started.",
|
|
1251
|
+
idSchema,
|
|
1252
|
+
async (args) => killShell(bg, typeof args.id === "string" ? args.id : ""),
|
|
1253
|
+
)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function webSearchTool(search: WebSearchFunc): Tool {
|
|
1257
|
+
return builtin(
|
|
1258
|
+
"websearch",
|
|
1259
|
+
"Search the web. Returns numbered results with title, URL and snippet.",
|
|
1260
|
+
{
|
|
1261
|
+
type: "object",
|
|
1262
|
+
properties: {
|
|
1263
|
+
query: { type: "string", description: "The search query" },
|
|
1264
|
+
limit: { type: "number", description: "Maximum number of results (default 5)" },
|
|
1265
|
+
},
|
|
1266
|
+
required: ["query"],
|
|
1267
|
+
additionalProperties: false,
|
|
1268
|
+
},
|
|
1269
|
+
async (args, ctx) => runWebSearch(search, args, ctx?.signal),
|
|
1270
|
+
)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function notebookEditTool(env: BuiltinEnv): Tool {
|
|
1274
|
+
return builtin(
|
|
1275
|
+
"notebook_edit",
|
|
1276
|
+
"Edit a Jupyter notebook (.ipynb) cell: replace its source, insert a new cell after it, or delete it. Replacing a code cell clears its outputs.",
|
|
1277
|
+
{
|
|
1278
|
+
type: "object",
|
|
1279
|
+
properties: {
|
|
1280
|
+
path: { type: "string", description: "Path to the .ipynb file" },
|
|
1281
|
+
cell_id: { type: "string", description: "The cell's id (or use cell_index)" },
|
|
1282
|
+
cell_index: { type: "number", description: "0-based cell position (used when cell_id is absent)" },
|
|
1283
|
+
new_source: { type: "string", description: "The new cell source (replace/insert)" },
|
|
1284
|
+
cell_type: { type: "string", enum: ["code", "markdown"], description: "Cell type for insert (default code) or to change on replace" },
|
|
1285
|
+
edit_mode: { type: "string", enum: ["replace", "insert", "delete"], description: "Default replace. insert adds the new cell AFTER the given cell, or first when none is given" },
|
|
1286
|
+
},
|
|
1287
|
+
required: ["path"],
|
|
1288
|
+
additionalProperties: false,
|
|
1289
|
+
},
|
|
1290
|
+
async (args) => {
|
|
1291
|
+
const p = typeof args.path === "string" ? args.path : ""
|
|
1292
|
+
if (!p) return err("notebook_edit: path is required")
|
|
1293
|
+
let full: string
|
|
1294
|
+
let raw: string
|
|
1295
|
+
try {
|
|
1296
|
+
full = env.resolvePath(p)
|
|
1297
|
+
raw = await fs.readFile(full, "utf8")
|
|
1298
|
+
} catch (e) {
|
|
1299
|
+
return err(`notebook_edit: ${e instanceof Error ? e.message : String(e)}`)
|
|
1300
|
+
}
|
|
1301
|
+
let nb: Record<string, any>
|
|
1302
|
+
try {
|
|
1303
|
+
nb = JSON.parse(raw)
|
|
1304
|
+
if (!nb || typeof nb !== "object" || Array.isArray(nb)) throw new Error("not a JSON object")
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
return err(`notebook_edit: ${p} is not a notebook: ${e instanceof Error ? e.message : String(e)}`)
|
|
1307
|
+
}
|
|
1308
|
+
const r = editNotebook(nb, args, p)
|
|
1309
|
+
if (!("summary" in r)) return r
|
|
1310
|
+
const out = goJsonIndent(nb) + "\n"
|
|
1311
|
+
await fs.writeFile(full, out, "utf8")
|
|
1312
|
+
return ok(r.summary, { cells: nb.cells.length, ...editMetadata(env, [{ path: full, before: raw, after: out }]) })
|
|
1313
|
+
},
|
|
1314
|
+
)
|
|
1000
1315
|
}
|
|
1001
1316
|
|
|
1002
1317
|
/**
|