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.
Files changed (49) hide show
  1. package/README.md +40 -0
  2. package/dist/agents/agent.d.ts +12 -1
  3. package/dist/agents/agent.js +43 -7
  4. package/dist/agents/compaction.d.ts +19 -0
  5. package/dist/agents/compaction.js +26 -4
  6. package/dist/agents/runtime.d.ts +5 -0
  7. package/dist/agents/runtime.js +13 -2
  8. package/dist/batteries.d.ts +223 -0
  9. package/dist/batteries.js +488 -0
  10. package/dist/builtin.d.ts +16 -0
  11. package/dist/builtin.js +315 -17
  12. package/dist/builtin_extras.d.ts +80 -0
  13. package/dist/builtin_extras.js +248 -0
  14. package/dist/classifier.d.ts +14 -0
  15. package/dist/classifier.js +34 -1
  16. package/dist/client.d.ts +91 -15
  17. package/dist/client.js +377 -110
  18. package/dist/index.d.ts +8 -0
  19. package/dist/index.js +11 -0
  20. package/dist/judge.d.ts +98 -0
  21. package/dist/judge.js +199 -0
  22. package/dist/mcp.d.ts +32 -1
  23. package/dist/mcp.js +184 -6
  24. package/dist/models.d.ts +20 -0
  25. package/dist/models.js +46 -0
  26. package/dist/promptcache.d.ts +21 -0
  27. package/dist/promptcache.js +59 -0
  28. package/dist/store.d.ts +32 -0
  29. package/dist/store.js +131 -0
  30. package/dist/tooltiers.d.ts +49 -0
  31. package/dist/tooltiers.js +197 -0
  32. package/dist/types.d.ts +19 -0
  33. package/package.json +2 -2
  34. package/src/agents/agent.ts +39 -8
  35. package/src/agents/compaction.ts +45 -4
  36. package/src/agents/runtime.ts +16 -2
  37. package/src/batteries.ts +614 -0
  38. package/src/builtin.ts +333 -18
  39. package/src/builtin_extras.ts +285 -0
  40. package/src/classifier.ts +36 -1
  41. package/src/client.ts +415 -115
  42. package/src/index.ts +31 -0
  43. package/src/judge.ts +305 -0
  44. package/src/mcp.ts +204 -7
  45. package/src/models.ts +56 -0
  46. package/src/promptcache.ts +63 -0
  47. package/src/store.ts +142 -0
  48. package/src/tooltiers.ts +221 -0
  49. 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". Fixed, and identical in
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 KILL_GRACE_MS = 2000
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
- }, KILL_GRACE_MS)
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
- return ok(lines.slice(start, start + limit).join("\n"))
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(p, next, "utf8")
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 matches = hits.slice(0, limit).map((h) => `${h.rel}:${h.line}:${h.text}`)
651
- return ok(matches.join("\n"), { count: matches.length })
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
- writes.push({ path: op.path, content: applyUpdate(content, op.body) })
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
- return [
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
  /**