toolnexus 0.21.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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Opt-in builtins for coding-agent hosts (SPEC §4C): background shells (`bash`
3
+ * run_in_background + `bash_output` + `kill_shell`), `websearch` over a host-supplied provider,
4
+ * and `notebook_edit`. None is on by default: a host that sets nothing sees the same ten builtins.
5
+ */
6
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process"
7
+ import type { ToolResult } from "./types.js"
8
+
9
+ const err = (output: string, metadata?: Record<string, unknown>): ToolResult => ({ output, isError: true, metadata })
10
+ const ok = (output: string, metadata?: Record<string, unknown>): ToolResult => ({ output, isError: false, metadata })
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // background shells
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** One background command, for a host UI. */
17
+ export interface BackgroundShellInfo {
18
+ id: string
19
+ command: string
20
+ started: Date
21
+ running: boolean
22
+ /** Valid when not running; -1 when killed by a signal. */
23
+ exitCode: number
24
+ killed: boolean
25
+ }
26
+
27
+ interface BgJob {
28
+ id: string
29
+ command: string
30
+ started: Date
31
+ child: ChildProcess
32
+ pgid?: number
33
+ grace: number
34
+ out: string
35
+ read: number
36
+ running: boolean
37
+ exitCode: number
38
+ killed: boolean
39
+ done: Promise<void>
40
+ }
41
+
42
+ /**
43
+ * Owns the commands `bash` started with `run_in_background`. Pass one as
44
+ * `builtins.background`, and call `closeAll()` when the host shuts down — a background command
45
+ * is NOT tied to the turn that started it, so nothing else stops it.
46
+ */
47
+ export class BackgroundShells {
48
+ private next = 0
49
+ private readonly jobs = new Map<string, BgJob>()
50
+
51
+ /** @internal Start `command` under `argv` in its own process group. */
52
+ start(argv: string[], command: string, workdir: string, grace: number): BgJob {
53
+ const child = spawn(argv[0], [...argv.slice(1), command], { cwd: workdir, detached: process.platform !== "win32" })
54
+ let resolveDone!: () => void
55
+ const done = new Promise<void>((r) => (resolveDone = r))
56
+ const j: BgJob = {
57
+ id: `bg${++this.next}`, command, started: new Date(), child, pgid: child.pid, grace,
58
+ out: "", read: 0, running: true, exitCode: 0, killed: false, done,
59
+ }
60
+ child.stdout?.on("data", (d) => (j.out += d.toString()))
61
+ child.stderr?.on("data", (d) => (j.out += d.toString()))
62
+ const finish = (code: number | null) => {
63
+ if (!j.running) return
64
+ j.running = false
65
+ j.exitCode = code ?? -1
66
+ resolveDone()
67
+ }
68
+ child.on("error", (e) => {
69
+ j.out += `bash: ${e.message}\n`
70
+ finish(-1)
71
+ })
72
+ child.on("close", (code) => finish(code))
73
+ // A background job must never keep the host's event loop alive on its own.
74
+ child.unref()
75
+ ;(child.stdout as any)?.unref?.()
76
+ ;(child.stderr as any)?.unref?.()
77
+ this.jobs.set(j.id, j)
78
+ return j
79
+ }
80
+
81
+ /** @internal */
82
+ get(id: string): BgJob | undefined {
83
+ return this.jobs.get(id)
84
+ }
85
+
86
+ private signal(j: BgJob, graceful: boolean): void {
87
+ const pgid = j.pgid
88
+ if (typeof pgid !== "number" || pgid <= 1 || !j.running) return
89
+ try {
90
+ if (process.platform === "win32") spawnSync("taskkill", ["/T", "/F", "/PID", String(pgid)])
91
+ else process.kill(-pgid, graceful ? "SIGTERM" : "SIGKILL")
92
+ } catch {
93
+ /* already gone */
94
+ }
95
+ }
96
+
97
+ /** @internal Stop the job's whole process group: SIGTERM, the grace window, SIGKILL.
98
+ * Resolves whether it was still running. */
99
+ async kill(j: BgJob): Promise<boolean> {
100
+ if (!j.running) return false
101
+ j.killed = true
102
+ // The child and its pipes are unref'd so an idle job never pins the host. While we are
103
+ // WAITING on it, though, they must be ref'd: otherwise, once the grace timer is cleared,
104
+ // nothing holds the event loop open until 'close' fires and Node exits with this promise
105
+ // still pending (seen on Linux, where 'close' lands an iteration later than on macOS).
106
+ j.child.ref()
107
+ ;(j.child.stdout as any)?.ref?.()
108
+ ;(j.child.stderr as any)?.ref?.()
109
+ this.signal(j, true)
110
+ let t: NodeJS.Timeout | undefined
111
+ const timedOut = await Promise.race([j.done.then(() => false), new Promise<boolean>((r) => (t = setTimeout(() => r(true), j.grace)))])
112
+ if (t) clearTimeout(t)
113
+ if (timedOut) {
114
+ this.signal(j, false)
115
+ await j.done
116
+ }
117
+ return true
118
+ }
119
+
120
+ static info(j: BgJob): BackgroundShellInfo {
121
+ return { id: j.id, command: j.command, started: j.started, running: j.running, exitCode: j.exitCode, killed: j.killed }
122
+ }
123
+
124
+ /** Every background command, oldest first. */
125
+ list(): BackgroundShellInfo[] {
126
+ return [...this.jobs.values()].sort((a, b) => a.started.getTime() - b.started.getTime()).map(BackgroundShells.info)
127
+ }
128
+
129
+ /** Stop one background command's process group. Unknown id ⇒ throws. */
130
+ async killShell(id: string): Promise<void> {
131
+ const j = this.jobs.get(id)
132
+ if (!j) throw new Error(`no background shell "${id}"`)
133
+ await this.kill(j)
134
+ }
135
+
136
+ /** Stop every background command still running, concurrently. */
137
+ async closeAll(): Promise<void> {
138
+ await Promise.all([...this.jobs.values()].map((j) => this.kill(j)))
139
+ }
140
+ }
141
+
142
+ export function bgStatus(i: BackgroundShellInfo): string {
143
+ if (i.running) return "running"
144
+ if (i.killed) return "killed"
145
+ return `exited with code ${i.exitCode}`
146
+ }
147
+
148
+ /** `bash_output(id)`. */
149
+ export function bashOutput(bg: BackgroundShells, id: string): ToolResult {
150
+ const j = bg.get(id)
151
+ if (!j) return err(`bash_output: no background shell "${id}"`)
152
+ const fresh = j.out.slice(j.read)
153
+ j.read = j.out.length
154
+ const info = BackgroundShells.info(j)
155
+ const status = bgStatus(info)
156
+ const meta: Record<string, unknown> = { id, status: status.split(" ")[0] }
157
+ if (!info.running && !info.killed) meta.exitCode = info.exitCode
158
+ return ok(`${fresh}\n[status: ${status}]`, meta)
159
+ }
160
+
161
+ /** `kill_shell(id)`. */
162
+ export async function killShell(bg: BackgroundShells, id: string): Promise<ToolResult> {
163
+ const j = bg.get(id)
164
+ if (!j) return err(`kill_shell: no background shell "${id}"`)
165
+ if (!(await bg.kill(j))) {
166
+ return ok(`Background shell ${id} had already ${bgStatus(BackgroundShells.info(j))}`, { id, killed: false })
167
+ }
168
+ return ok(`Killed background shell ${id}`, { id, killed: true })
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // websearch
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** One hit from a host search provider. */
176
+ export interface WebSearchResult {
177
+ title: string
178
+ url: string
179
+ snippet?: string
180
+ }
181
+
182
+ /** The host's search provider. The library ships none. */
183
+ export type WebSearchFunc = (query: string, limit: number, ctx: { signal?: AbortSignal }) => Promise<WebSearchResult[]>
184
+
185
+ export async function runWebSearch(search: WebSearchFunc, args: Record<string, unknown>, signal?: AbortSignal): Promise<ToolResult> {
186
+ const q = typeof args.query === "string" ? args.query : ""
187
+ if (!q.trim()) return err("websearch: query is required")
188
+ const limit = typeof args.limit === "number" && args.limit >= 1 ? Math.trunc(args.limit) : 5
189
+ let results: WebSearchResult[]
190
+ try {
191
+ results = await search(q, limit, { signal })
192
+ } catch (e) {
193
+ return err(`websearch: ${e instanceof Error ? e.message : String(e)}`)
194
+ }
195
+ results = (results ?? []).slice(0, limit)
196
+ if (results.length === 0) return ok("No results.", { count: 0 })
197
+ const text = results
198
+ .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}` + (r.snippet ? `\n ${r.snippet}` : ""))
199
+ .join("\n\n")
200
+ return ok(text, { count: results.length, results: results.map((r) => ({ title: r.title, url: r.url, snippet: r.snippet ?? "" })) })
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // notebook_edit
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /** Split text into nbformat's list of lines, each keeping its "\n". */
208
+ export function notebookSource(s: string): string[] {
209
+ if (!s) return []
210
+ return s.split(/(?<=\n)/).filter((p) => p !== "")
211
+ }
212
+
213
+ /** Go's json.MarshalIndent(v, "", " ") with sorted keys, plus Go's HTML escaping. */
214
+ export function goJsonIndent(v: unknown): string {
215
+ const sort = (x: unknown): unknown => {
216
+ if (Array.isArray(x)) return x.map(sort)
217
+ if (x && typeof x === "object") {
218
+ const out: Record<string, unknown> = {}
219
+ for (const k of Object.keys(x as object).sort()) out[k] = sort((x as Record<string, unknown>)[k])
220
+ return out
221
+ }
222
+ return x
223
+ }
224
+ return JSON.stringify(sort(v), null, " ")
225
+ .replace(/</g, "\\u003c")
226
+ .replace(/>/g, "\\u003e")
227
+ .replace(/&/g, "\\u0026")
228
+ .replace(new RegExp(String.fromCharCode(0x2028), "g"), "\\u2028")
229
+ .replace(new RegExp(String.fromCharCode(0x2029), "g"), "\\u2029")
230
+ }
231
+
232
+ /** Apply one notebook_edit to the parsed notebook, in place. Returns the summary, or an error result. */
233
+ export function editNotebook(nb: Record<string, any>, args: Record<string, unknown>, p: string): { summary: string } | ToolResult {
234
+ let cells: any[] = Array.isArray(nb.cells) ? nb.cells : []
235
+ const mode = typeof args.edit_mode === "string" && args.edit_mode ? args.edit_mode : "replace"
236
+ let idx = -1
237
+ if (typeof args.cell_id === "string" && args.cell_id) {
238
+ idx = cells.findIndex((c) => c && typeof c === "object" && c.id === args.cell_id)
239
+ if (idx < 0) return err(`notebook_edit: no cell with id "${args.cell_id}"`)
240
+ } else if (typeof args.cell_index === "number") {
241
+ idx = Math.trunc(args.cell_index)
242
+ if (idx < 0 || idx >= cells.length) return err(`notebook_edit: cell_index ${idx} out of range (0-${cells.length - 1})`)
243
+ } else if (mode !== "insert") {
244
+ return err("notebook_edit: cell_id or cell_index is required")
245
+ }
246
+ const src = typeof args.new_source === "string" ? args.new_source : ""
247
+ let cellType = typeof args.cell_type === "string" ? args.cell_type : ""
248
+ let summary: string
249
+ switch (mode) {
250
+ case "replace": {
251
+ const cm = cells[idx]
252
+ cm.source = notebookSource(src)
253
+ if (cellType) cm.cell_type = cellType
254
+ if (cm.cell_type === "code") {
255
+ cm.outputs = []
256
+ cm.execution_count = null
257
+ } else {
258
+ delete cm.outputs
259
+ delete cm.execution_count
260
+ }
261
+ summary = `Replaced cell ${idx} in ${p}`
262
+ break
263
+ }
264
+ case "insert": {
265
+ if (!cellType) cellType = "code"
266
+ const cell: Record<string, unknown> = { cell_type: cellType, metadata: {}, source: notebookSource(src) }
267
+ if (cellType === "code") {
268
+ cell.outputs = []
269
+ cell.execution_count = null
270
+ }
271
+ const at = idx + 1
272
+ cells = [...cells.slice(0, at), cell, ...cells.slice(at)]
273
+ summary = `Inserted ${cellType} cell at ${at} in ${p}`
274
+ break
275
+ }
276
+ case "delete":
277
+ cells = [...cells.slice(0, idx), ...cells.slice(idx + 1)]
278
+ summary = `Deleted cell ${idx} from ${p}`
279
+ break
280
+ default:
281
+ return err(`notebook_edit: unknown edit_mode "${mode}"`)
282
+ }
283
+ nb.cells = cells
284
+ return { summary }
285
+ }