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.
- 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/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/client.d.ts +74 -2
- package/dist/client.js +292 -55
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -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/builtin.ts +333 -18
- package/src/builtin_extras.ts +285 -0
- package/src/client.ts +317 -60
- package/src/index.ts +5 -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
|
@@ -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
|
+
}
|