toolnexus 0.19.0 → 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/agents/home.js +4 -2
- package/dist/agents/runtime.d.ts +12 -3
- package/dist/agents/runtime.js +4 -3
- package/dist/builtin.d.ts +32 -2
- package/dist/builtin.js +324 -46
- package/dist/classifier.js +3 -1
- package/dist/client.d.ts +18 -4
- package/dist/client.js +68 -12
- package/package.json +2 -2
- package/src/agents/home.ts +4 -2
- package/src/agents/runtime.ts +12 -5
- package/src/builtin.ts +361 -46
- package/src/classifier.ts +3 -1
- package/src/client.ts +68 -12
package/dist/agents/home.js
CHANGED
|
@@ -188,9 +188,11 @@ export function startAgent(a, run, hb) {
|
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
-
|
|
191
|
+
// keepAlive:false — a heartbeat is housekeeping and must never be the reason
|
|
192
|
+
// a host process stays up.
|
|
193
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }); // self-reschedule → one live timer
|
|
192
194
|
};
|
|
193
|
-
cancel = runtime.clock.setTimeout(beat, hb.everyMs);
|
|
195
|
+
cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false });
|
|
194
196
|
return {
|
|
195
197
|
runtime,
|
|
196
198
|
beats,
|
package/dist/agents/runtime.d.ts
CHANGED
|
@@ -22,10 +22,19 @@ import type { Answer, Request, Tool } from "../types.js";
|
|
|
22
22
|
export interface Clock {
|
|
23
23
|
/** Current time in ms (epoch or virtual). */
|
|
24
24
|
now(): number;
|
|
25
|
-
/** Schedule `fn` after `ms`; returns a cancel function.
|
|
26
|
-
|
|
25
|
+
/** Schedule `fn` after `ms`; returns a cancel function.
|
|
26
|
+
*
|
|
27
|
+
* `keepAlive` defaults to TRUE, and the default is the important half: a timer
|
|
28
|
+
* that cannot hold the event loop open never fires when it is the only thing
|
|
29
|
+
* left to do — which is precisely a deadline whose promise nothing else will
|
|
30
|
+
* settle. Background housekeeping that should never hold a process open (a
|
|
31
|
+
* heartbeat) passes `false` explicitly. A host's own Clock may ignore the
|
|
32
|
+
* option; it then behaves as that host's timers always have. */
|
|
33
|
+
setTimeout(fn: () => void, ms: number, opts?: {
|
|
34
|
+
keepAlive?: boolean;
|
|
35
|
+
}): () => void;
|
|
27
36
|
}
|
|
28
|
-
/** The real clock (default).
|
|
37
|
+
/** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
|
|
29
38
|
export declare const systemClock: Clock;
|
|
30
39
|
/** §7D hierarchical budget. Carved at spawn (`min(own, parent remaining)`) and
|
|
31
40
|
* live-enforced against the whole ancestor chain before each turn and spawn.
|
package/dist/agents/runtime.js
CHANGED
|
@@ -2,12 +2,13 @@ import { createClient, createInProcessFetch, InMemoryConversationStore, } from "
|
|
|
2
2
|
import { runGated } from "./loop.js";
|
|
3
3
|
import { createToolkit } from "../toolkit.js";
|
|
4
4
|
import { defineTool } from "../native.js";
|
|
5
|
-
/** The real clock (default).
|
|
5
|
+
/** The real clock (default). See `Clock.setTimeout` for why `keepAlive` defaults to true. */
|
|
6
6
|
export const systemClock = {
|
|
7
7
|
now: () => Date.now(),
|
|
8
|
-
setTimeout(fn, ms) {
|
|
8
|
+
setTimeout(fn, ms, opts) {
|
|
9
9
|
const t = setTimeout(fn, ms);
|
|
10
|
-
|
|
10
|
+
if (opts?.keepAlive === false)
|
|
11
|
+
t.unref?.();
|
|
11
12
|
return () => clearTimeout(t);
|
|
12
13
|
},
|
|
13
14
|
};
|
package/dist/builtin.d.ts
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
import type { Tool } from "./types.js";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Config for the single global builtin toggle (mirrors MCP isEnabled precedence),
|
|
4
|
+
* plus the host boundary: which interpreter `bash` runs, what a relative path
|
|
5
|
+
* resolves against, and whether paths leaving it are refused (SPEC §4A, ADR 0034).
|
|
6
|
+
*/
|
|
3
7
|
export type BuiltinsConfig = boolean | {
|
|
4
8
|
enabled?: boolean;
|
|
5
9
|
disabled?: boolean;
|
|
6
10
|
tools?: Record<string, boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* The argv prefix `bash` runs a command with — `["sh","-c"]`,
|
|
13
|
+
* `["cmd","/d","/s","/c"]`, `["powershell","-NoProfile","-Command"]`. Set,
|
|
14
|
+
* it is used verbatim. Absent, an interpreter is DETECTED at toolkit
|
|
15
|
+
* construction (POSIX `sh -c`; Windows %COMSPEC% → pwsh → powershell → bash).
|
|
16
|
+
*/
|
|
17
|
+
shell?: string[];
|
|
18
|
+
/**
|
|
19
|
+
* What a RELATIVE path means, for every builtin that touches the
|
|
20
|
+
* filesystem — including the paths inside apply_patch's patch text, and as
|
|
21
|
+
* bash's default workdir. Empty ⇒ the process cwd, i.e. today's behaviour.
|
|
22
|
+
*/
|
|
23
|
+
baseDir?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Refuse any path whose CANONICAL form leaves baseDir (symlinks, Windows
|
|
26
|
+
* junctions and short names resolved on both sides), and Windows reserved
|
|
27
|
+
* device names. Default off. A guarantee about path resolution in the file
|
|
28
|
+
* builtins, NOT a sandbox — `bash` still reaches the whole filesystem.
|
|
29
|
+
*/
|
|
30
|
+
confineToBaseDir?: boolean;
|
|
7
31
|
};
|
|
8
32
|
/**
|
|
9
33
|
* Whether the builtin source is on. Default ON. Same precedence as MCP:
|
|
@@ -22,4 +46,10 @@ export declare function selectBuiltins(cfg: BuiltinsConfig | undefined): Tool[];
|
|
|
22
46
|
* for parity: bash, read, write, edit, grep, glob, webfetch, question,
|
|
23
47
|
* apply_patch, todowrite.
|
|
24
48
|
*/
|
|
25
|
-
export declare function createBuiltinTools(): Tool[];
|
|
49
|
+
export declare function createBuiltinTools(cfg?: BuiltinsConfig): Tool[];
|
|
50
|
+
/**
|
|
51
|
+
* The interpreter the builtins resolved for `bash` — what a host prints, and
|
|
52
|
+
* what `metadata.shell` carries on every bash result. Throws the same error
|
|
53
|
+
* `selectBuiltins` fails construction with when nothing resolves.
|
|
54
|
+
*/
|
|
55
|
+
export declare function builtinShell(cfg?: BuiltinsConfig): string[];
|
package/dist/builtin.js
CHANGED
|
@@ -7,13 +7,151 @@
|
|
|
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
13
|
import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { pending } from "./types.js";
|
|
16
16
|
import { mediaTypeFor } from "./content.js";
|
|
17
|
+
/**
|
|
18
|
+
* How long a job gets between "please stop" and "stop". Fixed, and identical in
|
|
19
|
+
* every port, so a timeout means the same thing everywhere.
|
|
20
|
+
*/
|
|
21
|
+
const KILL_GRACE_MS = 2000;
|
|
22
|
+
const WIN_RESERVED = new Set([
|
|
23
|
+
"CON", "PRN", "AUX", "NUL",
|
|
24
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
25
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
26
|
+
]);
|
|
27
|
+
/** Does `name` resolve as an executable on PATH? (PATHEXT-aware on Windows.) */
|
|
28
|
+
function resolvesOnPath(name) {
|
|
29
|
+
if (name.includes(path.sep) || name.includes("/"))
|
|
30
|
+
return existsSync(name);
|
|
31
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
32
|
+
const exts = process.platform === "win32"
|
|
33
|
+
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
34
|
+
: [""];
|
|
35
|
+
for (const dir of dirs) {
|
|
36
|
+
for (const ext of exts) {
|
|
37
|
+
if (existsSync(path.join(dir, name + ext)))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The interpreters tried when the host names none. %COMSPEC% leads on Windows
|
|
45
|
+
* because PowerShell is routinely blocked by execution or application-control
|
|
46
|
+
* policy, while %COMSPEC% is always present.
|
|
47
|
+
*/
|
|
48
|
+
function shellCandidates() {
|
|
49
|
+
// `/bin/sh` first, then a PATH lookup: `shell: true` means literally
|
|
50
|
+
// `/bin/sh`, while spawning the bare name `sh` is a PATH lookup — two
|
|
51
|
+
// different binaries on a machine that has both. Naming the absolute path
|
|
52
|
+
// first is what makes dropping `shell: true` byte-identical here.
|
|
53
|
+
if (process.platform !== "win32")
|
|
54
|
+
return [["/bin/sh", "-c"], ["sh", "-c"]];
|
|
55
|
+
const out = [];
|
|
56
|
+
if (process.env.COMSPEC)
|
|
57
|
+
out.push([process.env.COMSPEC, "/d", "/s", "/c"]);
|
|
58
|
+
out.push(["cmd.exe", "/d", "/s", "/c"], ["pwsh", "-NoProfile", "-Command"], ["powershell", "-NoProfile", "-Command"], ["bash", "-lc"]);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The host boundary the builtins are allowed to know about. Constructed once,
|
|
63
|
+
* at toolkit construction: a missing interpreter is a configuration fact, and
|
|
64
|
+
* turn fourteen of a paid run is the expensive place to learn it.
|
|
65
|
+
*/
|
|
66
|
+
class BuiltinEnv {
|
|
67
|
+
shell;
|
|
68
|
+
shellError;
|
|
69
|
+
baseDir;
|
|
70
|
+
confine;
|
|
71
|
+
constructor(cfg) {
|
|
72
|
+
const obj = typeof cfg === "object" && cfg !== null ? cfg : {};
|
|
73
|
+
this.baseDir = obj.baseDir ?? "";
|
|
74
|
+
this.confine = obj.confineToBaseDir === true;
|
|
75
|
+
if (obj.shell && obj.shell.length > 0) {
|
|
76
|
+
this.shell = obj.shell;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const tried = [];
|
|
80
|
+
for (const argv of shellCandidates()) {
|
|
81
|
+
tried.push(argv[0]);
|
|
82
|
+
if (resolvesOnPath(argv[0])) {
|
|
83
|
+
this.shell = argv;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.shell = [];
|
|
88
|
+
this.shellError = new Error(`no shell interpreter found (tried: ${tried.join(", ")}); set builtins.shell, or disable the bash builtin with builtins.tools.bash = false`);
|
|
89
|
+
}
|
|
90
|
+
/** The interpreter argv, or the detection error when there is none. */
|
|
91
|
+
shellArgv() {
|
|
92
|
+
if (this.shellError)
|
|
93
|
+
throw this.shellError;
|
|
94
|
+
return this.shell;
|
|
95
|
+
}
|
|
96
|
+
get shellLabel() {
|
|
97
|
+
return this.shell.join(" ");
|
|
98
|
+
}
|
|
99
|
+
/** The directory a command or a walk starts from. */
|
|
100
|
+
dir() {
|
|
101
|
+
return this.baseDir || process.cwd();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Map a tool-supplied path onto the filesystem: relative to baseDir (or, with
|
|
105
|
+
* none, exactly as before), and refused when confinement is on and the
|
|
106
|
+
* canonical target is outside the base.
|
|
107
|
+
*/
|
|
108
|
+
resolvePath(p) {
|
|
109
|
+
if (this.confine && !this.baseDir) {
|
|
110
|
+
throw new Error("confineToBaseDir is set but baseDir is empty");
|
|
111
|
+
}
|
|
112
|
+
const full = this.baseDir && !path.isAbsolute(p) ? path.join(this.baseDir, p) : p;
|
|
113
|
+
if (!this.confine)
|
|
114
|
+
return full;
|
|
115
|
+
if (process.platform === "win32") {
|
|
116
|
+
const base = path.basename(full).split(".")[0].trim().toUpperCase();
|
|
117
|
+
if (WIN_RESERVED.has(base)) {
|
|
118
|
+
throw new Error(`${p} names a reserved device, which is not a file inside ${this.baseDir}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const canonBase = canonicalPath(this.baseDir);
|
|
122
|
+
const canonTarget = canonicalPath(full);
|
|
123
|
+
const rel = path.relative(canonBase, canonTarget);
|
|
124
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
125
|
+
throw new Error(`${p} resolves outside baseDir ${this.baseDir}`);
|
|
126
|
+
}
|
|
127
|
+
return full;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a path for comparison. Symlinks — and, on Windows, directory
|
|
132
|
+
* junctions and 8.3 short names — are resolved on the DEEPEST EXISTING
|
|
133
|
+
* ancestor and the remaining segments re-attached, because a file `write` is
|
|
134
|
+
* about to create has no real path, and a check that only works on existing
|
|
135
|
+
* files is not a check for `write`.
|
|
136
|
+
*/
|
|
137
|
+
function canonicalPath(p) {
|
|
138
|
+
const abs = path.resolve(p);
|
|
139
|
+
let cur = abs;
|
|
140
|
+
let tail = "";
|
|
141
|
+
for (;;) {
|
|
142
|
+
try {
|
|
143
|
+
const resolved = realpathSync.native(cur);
|
|
144
|
+
return tail ? path.join(resolved, tail) : resolved;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
const parent = path.dirname(cur);
|
|
148
|
+
if (parent === cur)
|
|
149
|
+
return abs;
|
|
150
|
+
tail = tail ? path.join(path.basename(cur), tail) : path.basename(cur);
|
|
151
|
+
cur = parent;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
17
155
|
/**
|
|
18
156
|
* Whether the builtin source is on. Default ON. Same precedence as MCP:
|
|
19
157
|
* `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
|
|
@@ -39,10 +177,14 @@ export function selectBuiltins(cfg) {
|
|
|
39
177
|
if (!builtinsEnabled(cfg))
|
|
40
178
|
return [];
|
|
41
179
|
const map = typeof cfg === "object" ? cfg.tools : undefined;
|
|
42
|
-
const all = createBuiltinTools();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
180
|
+
const all = createBuiltinTools(cfg);
|
|
181
|
+
const selected = map ? all.filter((t) => map[t.name] !== false) : all;
|
|
182
|
+
// A missing interpreter is a construction-time failure, and only when `bash`
|
|
183
|
+
// survived the toggles: a host that disabled it should run fine on a box with
|
|
184
|
+
// no shell at all (ADR 0034 D1).
|
|
185
|
+
if (selected.some((t) => t.name === "bash"))
|
|
186
|
+
builtinShell(cfg);
|
|
187
|
+
return selected;
|
|
46
188
|
}
|
|
47
189
|
const err = (output, metadata) => ({ output, isError: true, metadata });
|
|
48
190
|
const ok = (output, metadata) => ({ output, isError: false, metadata });
|
|
@@ -133,7 +275,7 @@ function walkFiles(root) {
|
|
|
133
275
|
// ---------------------------------------------------------------------------
|
|
134
276
|
// individual tools
|
|
135
277
|
// ---------------------------------------------------------------------------
|
|
136
|
-
function bashTool() {
|
|
278
|
+
function bashTool(env) {
|
|
137
279
|
return builtin("bash", "Run a shell command and return its combined stdout+stderr. Non-zero exit is an error.", {
|
|
138
280
|
type: "object",
|
|
139
281
|
properties: {
|
|
@@ -144,37 +286,131 @@ function bashTool() {
|
|
|
144
286
|
},
|
|
145
287
|
required: ["command"],
|
|
146
288
|
additionalProperties: false,
|
|
147
|
-
}, (args) => new Promise((resolve) => {
|
|
289
|
+
}, (args, ctx) => new Promise((resolve) => {
|
|
148
290
|
const command = String(args.command ?? "");
|
|
149
291
|
if (!command)
|
|
150
292
|
return resolve(err("bash: command is required"));
|
|
151
|
-
|
|
293
|
+
let argv;
|
|
294
|
+
let workdir;
|
|
295
|
+
try {
|
|
296
|
+
argv = env.shellArgv();
|
|
297
|
+
workdir = args.workdir ? env.resolvePath(String(args.workdir)) : env.dir();
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
return resolve(err(`bash: ${e.message}`));
|
|
301
|
+
}
|
|
152
302
|
const timeout = typeof args.timeout === "number" ? args.timeout : 60_000;
|
|
153
|
-
const
|
|
303
|
+
const meta = { shell: env.shellLabel };
|
|
304
|
+
// NOT `shell: true`: on Windows that silently means cmd.exe while every
|
|
305
|
+
// other port fails loudly, and it hides WHICH interpreter ran. `detached`
|
|
306
|
+
// puts the child in its own process group so a kill reaches the command
|
|
307
|
+
// AND everything it started — without it the kill reaches the
|
|
308
|
+
// interpreter only and the real work runs on, reparented (ADR 0034 D4).
|
|
309
|
+
const child = spawn(argv[0], [...argv.slice(1), command], {
|
|
310
|
+
cwd: workdir,
|
|
311
|
+
detached: process.platform !== "win32",
|
|
312
|
+
});
|
|
154
313
|
let out = "";
|
|
155
|
-
let
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
314
|
+
let stopped = "";
|
|
315
|
+
let killedTree = false;
|
|
316
|
+
let settled = false;
|
|
317
|
+
// Captured once, at spawn: `detached` makes the child a process-group
|
|
318
|
+
// LEADER, so its pid is the pgid. Reading it later from `child` would
|
|
319
|
+
// risk signalling by a pid the runtime has already let go of.
|
|
320
|
+
const pgid = child.pid;
|
|
321
|
+
const killJob = (graceful) => {
|
|
322
|
+
// Nothing may be signalled once this call has settled: a stale timer
|
|
323
|
+
// firing after the fact would aim at a pid the OS may have reissued,
|
|
324
|
+
// and a NEGATED pid is a whole process group.
|
|
325
|
+
if (settled)
|
|
326
|
+
return false;
|
|
327
|
+
if (typeof pgid !== "number" || !Number.isInteger(pgid) || pgid <= 1)
|
|
328
|
+
return false;
|
|
329
|
+
try {
|
|
330
|
+
if (process.platform === "win32") {
|
|
331
|
+
// Windows has NO graceful termination for a console process, and
|
|
332
|
+
// asking anyway is worse than not asking: measured on a native
|
|
333
|
+
// Windows box, `taskkill /T` without `/F` refuses every console
|
|
334
|
+
// process in the tree ("can only be terminated forcefully") while
|
|
335
|
+
// still being able to take the parent down — which reparents the
|
|
336
|
+
// grandchild and leaves it running. So the job is terminated at
|
|
337
|
+
// once there, and the grace window is a POSIX effect (ADR 0034 D4).
|
|
338
|
+
const r = spawnSync("taskkill", ["/T", "/F", "/PID", String(pgid)]);
|
|
339
|
+
return r.status === 0;
|
|
340
|
+
}
|
|
341
|
+
// Never signal a REAPED child by negated pid: that pid may belong to
|
|
342
|
+
// something else by now, and the blast radius is a whole process
|
|
343
|
+
// GROUP. (On Windows above, taskkill targets one pid and a dead pid
|
|
344
|
+
// is merely an error, so the same guard is not needed there — and
|
|
345
|
+
// must not be applied, because the tree can outlive the child.)
|
|
346
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
347
|
+
return false;
|
|
348
|
+
process.kill(-pgid, graceful ? "SIGTERM" : "SIGKILL");
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
// Ask, wait out the grace window, then insist. A runner that gets
|
|
356
|
+
// SIGTERM removes its temp directories; one that gets SIGKILL does not.
|
|
357
|
+
const stop = (why) => {
|
|
358
|
+
if (stopped)
|
|
359
|
+
return;
|
|
360
|
+
stopped = why;
|
|
361
|
+
killedTree = killJob(true);
|
|
362
|
+
graceTimer = setTimeout(() => {
|
|
363
|
+
if (killJob(false))
|
|
364
|
+
killedTree = true;
|
|
365
|
+
}, KILL_GRACE_MS);
|
|
366
|
+
};
|
|
367
|
+
let graceTimer;
|
|
368
|
+
const timer = setTimeout(() => stop("timeout"), timeout);
|
|
369
|
+
const signal = ctx?.signal;
|
|
370
|
+
const onAbort = () => stop("cancelled");
|
|
371
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
372
|
+
const finish = (result) => {
|
|
373
|
+
if (settled)
|
|
374
|
+
return;
|
|
375
|
+
settled = true;
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
if (graceTimer)
|
|
378
|
+
clearTimeout(graceTimer);
|
|
379
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
380
|
+
resolve(result);
|
|
381
|
+
};
|
|
160
382
|
child.stdout?.on("data", (d) => (out += d.toString()));
|
|
161
383
|
child.stderr?.on("data", (d) => (out += d.toString()));
|
|
162
|
-
child.on("error", (e) => {
|
|
163
|
-
clearTimeout(timer);
|
|
164
|
-
resolve(err(`bash: ${e.message}`));
|
|
165
|
-
});
|
|
384
|
+
child.on("error", (e) => finish(err(`bash: ${e.message}`, meta)));
|
|
166
385
|
child.on("close", (code) => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
386
|
+
if (stopped) {
|
|
387
|
+
// WINDOWS ONLY, and the platform guard is the point. There, the
|
|
388
|
+
// direct child closing does not mean the job is gone — a detached
|
|
389
|
+
// grandchild outlives it — so the tree is terminated here by pid.
|
|
390
|
+
//
|
|
391
|
+
// On POSIX this must NOT run. `close` only fires once the stdio the
|
|
392
|
+
// grandchild inherited has closed, so the job is already gone; and
|
|
393
|
+
// signalling `-pid` after the child has been reaped aims at whatever
|
|
394
|
+
// process now owns that pid. PIDs recycle fast on a busy CI box, and
|
|
395
|
+
// the blast radius of a negated pid is a whole process GROUP — which
|
|
396
|
+
// is how this killed sibling test workers on the GitHub runner while
|
|
397
|
+
// passing on macOS, in a container, and on its own.
|
|
398
|
+
if (!killedTree && process.platform === "win32")
|
|
399
|
+
killedTree = killJob(false);
|
|
400
|
+
meta.timedOut = stopped === "timeout";
|
|
401
|
+
meta.killedTree = killedTree;
|
|
402
|
+
return finish(err(stopped === "timeout"
|
|
403
|
+
? `bash: command timed out after ${timeout}ms\n${out}`
|
|
404
|
+
: `bash: command cancelled\n${out}`, meta));
|
|
172
405
|
}
|
|
173
|
-
|
|
406
|
+
meta.exitCode = code;
|
|
407
|
+
if (code !== 0)
|
|
408
|
+
return finish(err(`${out}\nbash: command exited with code ${code}`, meta));
|
|
409
|
+
finish(ok(out, meta));
|
|
174
410
|
});
|
|
175
411
|
}));
|
|
176
412
|
}
|
|
177
|
-
function readTool() {
|
|
413
|
+
function readTool(env) {
|
|
178
414
|
return builtin("read", "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.", {
|
|
179
415
|
type: "object",
|
|
180
416
|
properties: {
|
|
@@ -189,15 +425,17 @@ function readTool() {
|
|
|
189
425
|
if (!p)
|
|
190
426
|
return err("read: path is required");
|
|
191
427
|
let bytes;
|
|
428
|
+
let full;
|
|
192
429
|
try {
|
|
193
|
-
|
|
430
|
+
full = env.resolvePath(p);
|
|
431
|
+
bytes = await fs.readFile(full);
|
|
194
432
|
}
|
|
195
433
|
catch (e) {
|
|
196
434
|
return err(`read: ${e instanceof Error ? e.message : String(e)}`);
|
|
197
435
|
}
|
|
198
436
|
// §6: a recognised media extension comes back as a part. The table is fixed — no magic-byte
|
|
199
437
|
// sniffing and no platform mime database, whose contents vary per machine.
|
|
200
|
-
const media = mediaTypeFor(
|
|
438
|
+
const media = mediaTypeFor(full);
|
|
201
439
|
if (media) {
|
|
202
440
|
return {
|
|
203
441
|
output: `${p} (${media.mimeType}, ${bytes.length} bytes)`,
|
|
@@ -224,7 +462,7 @@ function readTool() {
|
|
|
224
462
|
return ok(lines.slice(start, start + limit).join("\n"));
|
|
225
463
|
});
|
|
226
464
|
}
|
|
227
|
-
function writeTool() {
|
|
465
|
+
function writeTool(env) {
|
|
228
466
|
return builtin("write", "Write content to a file (create/overwrite), creating parent directories.", {
|
|
229
467
|
type: "object",
|
|
230
468
|
properties: {
|
|
@@ -238,13 +476,20 @@ function writeTool() {
|
|
|
238
476
|
if (!p)
|
|
239
477
|
return err("write: path is required");
|
|
240
478
|
const content = typeof args.content === "string" ? args.content : String(args.content ?? "");
|
|
241
|
-
|
|
242
|
-
|
|
479
|
+
let full;
|
|
480
|
+
try {
|
|
481
|
+
full = env.resolvePath(p);
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
return err(`write: ${e instanceof Error ? e.message : String(e)}`);
|
|
485
|
+
}
|
|
486
|
+
await fs.mkdir(path.dirname(path.resolve(full)), { recursive: true });
|
|
487
|
+
await fs.writeFile(full, content, "utf8");
|
|
243
488
|
const bytes = Buffer.byteLength(content, "utf8");
|
|
244
489
|
return ok(`Wrote ${bytes} bytes to ${p}`, { bytes });
|
|
245
490
|
});
|
|
246
491
|
}
|
|
247
|
-
function editTool() {
|
|
492
|
+
function editTool(env) {
|
|
248
493
|
return builtin("edit", "Exact-string replace in a file. Default replaces a single unique occurrence; replaceAll replaces all.", {
|
|
249
494
|
type: "object",
|
|
250
495
|
properties: {
|
|
@@ -265,8 +510,10 @@ function editTool() {
|
|
|
265
510
|
const oldString = args.oldString;
|
|
266
511
|
const newString = typeof args.newString === "string" ? args.newString : String(args.newString ?? "");
|
|
267
512
|
let content;
|
|
513
|
+
let full;
|
|
268
514
|
try {
|
|
269
|
-
|
|
515
|
+
full = env.resolvePath(p);
|
|
516
|
+
content = await fs.readFile(full, "utf8");
|
|
270
517
|
}
|
|
271
518
|
catch (e) {
|
|
272
519
|
return err(`edit: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -289,7 +536,7 @@ function editTool() {
|
|
|
289
536
|
});
|
|
290
537
|
});
|
|
291
538
|
}
|
|
292
|
-
function grepTool() {
|
|
539
|
+
function grepTool(env) {
|
|
293
540
|
return builtin("grep", "Search file contents by regex under a directory. Output is file:line:text matches.", {
|
|
294
541
|
type: "object",
|
|
295
542
|
properties: {
|
|
@@ -311,7 +558,13 @@ function grepTool() {
|
|
|
311
558
|
catch (e) {
|
|
312
559
|
return err(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`);
|
|
313
560
|
}
|
|
314
|
-
|
|
561
|
+
let root;
|
|
562
|
+
try {
|
|
563
|
+
root = args.path ? env.resolvePath(String(args.path)) : env.dir();
|
|
564
|
+
}
|
|
565
|
+
catch (e) {
|
|
566
|
+
return err(`grep: ${e instanceof Error ? e.message : String(e)}`);
|
|
567
|
+
}
|
|
315
568
|
const include = args.include ? String(args.include) : undefined;
|
|
316
569
|
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
317
570
|
// Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
|
|
@@ -341,7 +594,7 @@ function grepTool() {
|
|
|
341
594
|
return ok(matches.join("\n"), { count: matches.length });
|
|
342
595
|
});
|
|
343
596
|
}
|
|
344
|
-
function globTool() {
|
|
597
|
+
function globTool(env) {
|
|
345
598
|
return builtin("glob", "List files matching a glob under a directory. Output is newline-joined relative paths.", {
|
|
346
599
|
type: "object",
|
|
347
600
|
properties: {
|
|
@@ -355,7 +608,13 @@ function globTool() {
|
|
|
355
608
|
const pattern = String(args.pattern ?? "");
|
|
356
609
|
if (!pattern)
|
|
357
610
|
return err("glob: pattern is required");
|
|
358
|
-
|
|
611
|
+
let root;
|
|
612
|
+
try {
|
|
613
|
+
root = args.path ? env.resolvePath(String(args.path)) : env.dir();
|
|
614
|
+
}
|
|
615
|
+
catch (e) {
|
|
616
|
+
return err(`glob: ${e instanceof Error ? e.message : String(e)}`);
|
|
617
|
+
}
|
|
359
618
|
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
360
619
|
// Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
|
|
361
620
|
// the filesystem decide WHICH files the model sees, not merely their order. The emitted
|
|
@@ -581,7 +840,7 @@ function applyUpdate(content, body) {
|
|
|
581
840
|
}
|
|
582
841
|
return result;
|
|
583
842
|
}
|
|
584
|
-
function applyPatchTool() {
|
|
843
|
+
function applyPatchTool(env) {
|
|
585
844
|
return builtin("apply_patch", "Apply a patch (Begin/End Patch grammar: Add/Update/Delete File). Atomic — a non-matching hunk aborts with no writes.", {
|
|
586
845
|
type: "object",
|
|
587
846
|
properties: {
|
|
@@ -600,6 +859,16 @@ function applyPatchTool() {
|
|
|
600
859
|
catch (e) {
|
|
601
860
|
return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`);
|
|
602
861
|
}
|
|
862
|
+
// The paths live INSIDE the patch text, not in the arguments, so a host
|
|
863
|
+
// cannot rewrite them from a hook — this is the one case that genuinely
|
|
864
|
+
// needs the base directory to be library-side (ADR 0034 D2).
|
|
865
|
+
try {
|
|
866
|
+
for (const op of ops)
|
|
867
|
+
op.path = env.resolvePath(op.path);
|
|
868
|
+
}
|
|
869
|
+
catch (e) {
|
|
870
|
+
return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`);
|
|
871
|
+
}
|
|
603
872
|
// Stage every write/delete first; only touch the filesystem once all hunks apply.
|
|
604
873
|
const writes = [];
|
|
605
874
|
const deletes = [];
|
|
@@ -643,17 +912,26 @@ function applyPatchTool() {
|
|
|
643
912
|
* for parity: bash, read, write, edit, grep, glob, webfetch, question,
|
|
644
913
|
* apply_patch, todowrite.
|
|
645
914
|
*/
|
|
646
|
-
export function createBuiltinTools() {
|
|
915
|
+
export function createBuiltinTools(cfg) {
|
|
916
|
+
const env = new BuiltinEnv(cfg);
|
|
647
917
|
return [
|
|
648
|
-
bashTool(),
|
|
649
|
-
readTool(),
|
|
650
|
-
writeTool(),
|
|
651
|
-
editTool(),
|
|
652
|
-
grepTool(),
|
|
653
|
-
globTool(),
|
|
918
|
+
bashTool(env),
|
|
919
|
+
readTool(env),
|
|
920
|
+
writeTool(env),
|
|
921
|
+
editTool(env),
|
|
922
|
+
grepTool(env),
|
|
923
|
+
globTool(env),
|
|
654
924
|
webfetchTool(),
|
|
655
925
|
questionTool(),
|
|
656
|
-
applyPatchTool(),
|
|
926
|
+
applyPatchTool(env),
|
|
657
927
|
todowriteTool(),
|
|
658
928
|
];
|
|
659
929
|
}
|
|
930
|
+
/**
|
|
931
|
+
* The interpreter the builtins resolved for `bash` — what a host prints, and
|
|
932
|
+
* what `metadata.shell` carries on every bash result. Throws the same error
|
|
933
|
+
* `selectBuiltins` fails construction with when nothing resolves.
|
|
934
|
+
*/
|
|
935
|
+
export function builtinShell(cfg) {
|
|
936
|
+
return new BuiltinEnv(cfg).shellArgv();
|
|
937
|
+
}
|
package/dist/classifier.js
CHANGED
|
@@ -443,8 +443,10 @@ export class Classifier {
|
|
|
443
443
|
let lastErr;
|
|
444
444
|
for (let attempt = 0;; attempt++) {
|
|
445
445
|
const timer = new AbortController();
|
|
446
|
+
// NOT unref'd: this watchdog is the only thing that will end a request an
|
|
447
|
+
// injected `fetch` never settles, and an unref'd timer cannot keep the
|
|
448
|
+
// loop alive long enough to fire. It is cleared on every exit path below.
|
|
446
449
|
const t = setTimeout(() => timer.abort(new Error(`classifier: request timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
|
|
447
|
-
t.unref?.();
|
|
448
450
|
const onOuter = () => timer.abort(signal.reason);
|
|
449
451
|
if (signal) {
|
|
450
452
|
if (signal.aborted) {
|
package/dist/client.d.ts
CHANGED
|
@@ -412,7 +412,17 @@ export declare class Client {
|
|
|
412
412
|
/** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
|
|
413
413
|
* the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
|
|
414
414
|
private system;
|
|
415
|
-
/**
|
|
415
|
+
/** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
|
|
416
|
+
private readonly runTimers;
|
|
417
|
+
/** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
|
|
418
|
+
private releaseSignal;
|
|
419
|
+
/** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
|
|
420
|
+
*
|
|
421
|
+
* The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
|
|
422
|
+
* alive, so a run whose only pending work is a request that never settles — an injected
|
|
423
|
+
* `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
|
|
424
|
+
* fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
|
|
425
|
+
* caller clears it however the run leaves, so a finished run holds nothing open. */
|
|
416
426
|
private makeSignal;
|
|
417
427
|
/** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
|
|
418
428
|
* plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
|
|
@@ -425,6 +435,10 @@ export declare class Client {
|
|
|
425
435
|
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
426
436
|
private llmCallJson;
|
|
427
437
|
private runOpenAI;
|
|
438
|
+
private runAnthropic;
|
|
439
|
+
private streamOpenAI;
|
|
440
|
+
private streamAnthropic;
|
|
441
|
+
private runOpenAIWithSignal;
|
|
428
442
|
private result;
|
|
429
443
|
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
430
444
|
private pendingRun;
|
|
@@ -434,9 +448,9 @@ export declare class Client {
|
|
|
434
448
|
* no `waitFor` is configured (the run should stop and surface the request).
|
|
435
449
|
*/
|
|
436
450
|
private resolvePending;
|
|
437
|
-
private
|
|
438
|
-
private
|
|
439
|
-
private
|
|
451
|
+
private runAnthropicWithSignal;
|
|
452
|
+
private streamOpenAIWithSignal;
|
|
453
|
+
private streamAnthropicWithSignal;
|
|
440
454
|
}
|
|
441
455
|
export declare function createClient(opts: ClientOptions): Client;
|
|
442
456
|
/** One tool call an in-process model asks for. Flat on purpose: the nested
|