sakura-agent 0.1.0 → 0.1.1
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 +144 -78
- package/README.zh-CN.md +145 -79
- package/THIRD_PARTY_NOTICES.md +8 -58
- package/dist/packages/agent/src/index.d.ts +49 -11
- package/dist/packages/agent/src/index.js +50 -55
- package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
- package/dist/packages/agent/src/restrict-tools.js +11 -0
- package/dist/packages/agent/src/system-prompt.d.ts +5 -0
- package/dist/packages/agent/src/system-prompt.js +1 -1
- package/dist/packages/agent/src/wake.d.ts +41 -0
- package/dist/packages/agent/src/wake.js +71 -0
- package/dist/packages/env/src/confine.d.ts +13 -0
- package/dist/packages/env/src/confine.js +40 -0
- package/dist/packages/env/src/connect-ssh.d.ts +33 -0
- package/dist/packages/env/src/connect-ssh.js +132 -0
- package/dist/packages/env/src/index.d.ts +5 -1
- package/dist/packages/env/src/index.js +3 -1
- package/dist/packages/env/src/local.d.ts +11 -2
- package/dist/packages/env/src/local.js +42 -10
- package/dist/packages/env/src/remote.d.ts +17 -1
- package/dist/packages/env/src/remote.js +54 -4
- package/dist/packages/evals/src/bfcl.d.ts +24 -0
- package/dist/packages/evals/src/bfcl.js +71 -0
- package/dist/packages/evals/src/index.d.ts +3 -0
- package/dist/packages/evals/src/index.js +2 -0
- package/dist/packages/evals/src/png.d.ts +2 -0
- package/dist/packages/evals/src/png.js +30 -0
- package/dist/packages/evals/src/pool.d.ts +3 -0
- package/dist/packages/evals/src/pool.js +22 -0
- package/dist/packages/evals/src/providers.d.ts +13 -0
- package/dist/packages/evals/src/providers.js +23 -0
- package/dist/packages/kernel/src/compaction.d.ts +19 -0
- package/dist/packages/kernel/src/compaction.js +12 -2
- package/dist/packages/kernel/src/goal.d.ts +10 -1
- package/dist/packages/kernel/src/goal.js +28 -0
- package/dist/packages/kernel/src/hooks.d.ts +5 -0
- package/dist/packages/kernel/src/hooks.js +21 -1
- package/dist/packages/kernel/src/index.d.ts +3 -2
- package/dist/packages/kernel/src/index.js +1 -0
- package/dist/packages/kernel/src/loop.d.ts +28 -4
- package/dist/packages/kernel/src/loop.js +69 -47
- package/dist/packages/kernel/src/types.d.ts +25 -4
- package/dist/packages/kernel/src/usage.d.ts +3 -0
- package/dist/packages/kernel/src/usage.js +16 -0
- package/dist/packages/llm/src/chat/request.js +22 -4
- package/dist/packages/llm/src/chat/retry.d.ts +1 -1
- package/dist/packages/llm/src/chat/retry.js +1 -1
- package/dist/packages/llm/src/index.js +7 -2
- package/dist/packages/plugins/context/src/index.d.ts +2 -1
- package/dist/packages/plugins/context/src/index.js +2 -1
- package/dist/packages/plugins/limits/src/index.d.ts +4 -1
- package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
- package/dist/packages/plugins/redaction/src/index.js +3 -0
- package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
- package/dist/packages/plugins/review/src/chain.d.ts +2 -0
- package/dist/packages/plugins/review/src/chain.js +2 -1
- package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
- package/dist/packages/plugins/review/src/hard-rules.js +58 -25
- package/dist/packages/plugins/review/src/index.d.ts +13 -0
- package/dist/packages/plugins/review/src/index.js +9 -5
- package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
- package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
- package/dist/packages/plugins/toolset/src/codemode.js +84 -26
- package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
- package/dist/packages/plugins/toolset/src/index.js +1 -1
- package/dist/packages/plugins/trace/src/index.d.ts +27 -0
- package/dist/packages/plugins/trace/src/index.js +29 -0
- package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
- package/dist/packages/plugins/trace/src/modules.js +28 -0
- package/dist/packages/session/src/file-log.d.ts +6 -1
- package/dist/packages/session/src/file-log.js +45 -25
- package/dist/packages/session/src/fork.d.ts +16 -0
- package/dist/packages/session/src/fork.js +70 -0
- package/dist/packages/session/src/index.d.ts +2 -0
- package/dist/packages/session/src/index.js +1 -0
- package/dist/packages/session/src/log-core.d.ts +37 -0
- package/dist/packages/session/src/log-core.js +47 -0
- package/dist/packages/session/src/memory-log.d.ts +3 -12
- package/dist/packages/session/src/memory-log.js +3 -23
- package/dist/packages/session/src/projection.js +2 -0
- package/dist/packages/testing/src/faux.d.ts +36 -0
- package/dist/packages/testing/src/faux.js +69 -0
- package/dist/packages/testing/src/index.d.ts +4 -0
- package/dist/packages/testing/src/index.js +2 -0
- package/dist/packages/testing/src/rng.d.ts +7 -0
- package/dist/packages/testing/src/rng.js +17 -0
- package/dist/sdk/index.d.ts +11 -3
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/testing.d.ts +2 -0
- package/dist/sdk/testing.js +2 -0
- package/package.json +5 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { createRemoteEnvironment, RemoteConnectionError } from "./remote.js";
|
|
4
|
+
/** Output kept from one command, as for a local one. */
|
|
5
|
+
const MAX_OUTPUT_CHARS = 20_000_000;
|
|
6
|
+
const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
/** Runs ssh on this machine and collects what it prints. */
|
|
8
|
+
function runSsh(program, args, options) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(program, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
11
|
+
let output = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
let timedOut = false;
|
|
14
|
+
const keep = (chunk, target) => {
|
|
15
|
+
const text = chunk.toString("utf8");
|
|
16
|
+
output += text;
|
|
17
|
+
if (output.length > MAX_OUTPUT_CHARS)
|
|
18
|
+
output = output.slice(output.length - MAX_OUTPUT_CHARS);
|
|
19
|
+
if (target === "stderr")
|
|
20
|
+
stderr += text;
|
|
21
|
+
};
|
|
22
|
+
child.stdout.on("data", (chunk) => keep(chunk, "both"));
|
|
23
|
+
child.stderr.on("data", (chunk) => keep(chunk, "stderr"));
|
|
24
|
+
const stop = () => {
|
|
25
|
+
child.kill("SIGTERM");
|
|
26
|
+
setTimeout(() => child.kill("SIGKILL"), 1000).unref();
|
|
27
|
+
};
|
|
28
|
+
const timer = options.timeoutMs !== undefined
|
|
29
|
+
? setTimeout(() => {
|
|
30
|
+
timedOut = true;
|
|
31
|
+
stop();
|
|
32
|
+
}, options.timeoutMs)
|
|
33
|
+
: undefined;
|
|
34
|
+
if (options.signal?.aborted)
|
|
35
|
+
stop();
|
|
36
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
37
|
+
child.on("error", (error) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
reject(new RemoteConnectionError(`could not run ${program}: ${error.message}`));
|
|
40
|
+
});
|
|
41
|
+
const finish = (code) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
options.signal?.removeEventListener("abort", stop);
|
|
44
|
+
resolve({ exitCode: code, output, timedOut, stderr });
|
|
45
|
+
};
|
|
46
|
+
child.on("close", finish);
|
|
47
|
+
// An ssh that makes the connection leaves a copy of itself behind in the background (the first one does, and so does one that has to make it again), and
|
|
48
|
+
// that copy may keep our pipes open for as long as it lives. So the answer is the ssh we started exiting, with a moment for what it wrote to arrive.
|
|
49
|
+
child.on("exit", (code) => setTimeout(() => {
|
|
50
|
+
child.stdout.destroy();
|
|
51
|
+
child.stderr.destroy();
|
|
52
|
+
finish(code);
|
|
53
|
+
}, 100));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Opens one connection to the machine and runs every command over it. The first ssh logs in (once) and stays in the background; each later ssh finds it
|
|
58
|
+
* through a socket in a directory of ours and only starts a new process on the other side, so a command costs milliseconds and not a login. If the
|
|
59
|
+
* connection dies, the next command makes a new one by itself. Nothing is promised about the connection: when it cannot be reached, the command's
|
|
60
|
+
* result is a RemoteConnectionError and the caller says so.
|
|
61
|
+
*/
|
|
62
|
+
export async function connectSsh(options) {
|
|
63
|
+
const program = options.ssh ?? "ssh";
|
|
64
|
+
// sockets have a short length limit (104 characters on macOS), so the directory is under /tmp and the name is a hash
|
|
65
|
+
const directory = await mkdtemp("/tmp/sk-ssh-");
|
|
66
|
+
const control = `${directory}/%C`;
|
|
67
|
+
const common = [
|
|
68
|
+
"-o",
|
|
69
|
+
`ControlPath=${control}`,
|
|
70
|
+
"-o",
|
|
71
|
+
"BatchMode=yes",
|
|
72
|
+
"-o",
|
|
73
|
+
`ConnectTimeout=${options.connectTimeoutSec ?? 15}`,
|
|
74
|
+
"-o",
|
|
75
|
+
"ServerAliveInterval=15",
|
|
76
|
+
"-o",
|
|
77
|
+
"ServerAliveCountMax=3",
|
|
78
|
+
...(options.port !== undefined ? ["-p", String(options.port)] : []),
|
|
79
|
+
...(options.identityFile !== undefined ? ["-i", options.identityFile] : []),
|
|
80
|
+
...Object.entries(options.options ?? {}).flatMap(([name, value]) => ["-o", `${name}=${value}`]),
|
|
81
|
+
];
|
|
82
|
+
let closed = false;
|
|
83
|
+
const cleanup = () => rm(directory, { recursive: true, force: true });
|
|
84
|
+
try {
|
|
85
|
+
// log in now, so a wrong host or key shows up here and not in the middle of a task
|
|
86
|
+
const opened = await runSsh(program, [...common, "-o", "ControlMaster=yes", "-o", "ControlPersist=10m", "-N", options.host], {});
|
|
87
|
+
if (opened.exitCode !== 0)
|
|
88
|
+
throw new RemoteConnectionError(`could not connect to ${options.host}: ${opened.stderr.trim() || `ssh exited with ${opened.exitCode}`}`);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await cleanup();
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
const run = async (command, runOptions) => {
|
|
95
|
+
if (closed)
|
|
96
|
+
throw new RemoteConnectionError("the connection was closed");
|
|
97
|
+
const remote = `${runOptions.cwd !== undefined ? `cd ${quote(runOptions.cwd)} && ` : ""}bash -c ${quote(command)}`;
|
|
98
|
+
const result = await runSsh(program, [...common, "-o", "ControlMaster=auto", "-o", "ControlPersist=10m", options.host, remote], { timeoutMs: runOptions.timeoutMs, signal: runOptions.signal });
|
|
99
|
+
// ssh itself exits with 255 when it could not reach the machine; a command that exits with 255 on its own is taken for that too
|
|
100
|
+
if (result.exitCode === 255 && !result.timedOut && !runOptions.signal?.aborted)
|
|
101
|
+
throw new RemoteConnectionError(result.stderr.trim() || `could not reach ${options.host}`);
|
|
102
|
+
return { exitCode: result.exitCode, output: result.output, timedOut: result.timedOut };
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
run,
|
|
106
|
+
async close() {
|
|
107
|
+
if (closed)
|
|
108
|
+
return;
|
|
109
|
+
closed = true;
|
|
110
|
+
await runSsh(program, [...common, "-O", "exit", options.host], {}).catch(() => undefined);
|
|
111
|
+
await cleanup();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** What the model is told about where its tools run, over ssh. */
|
|
116
|
+
export function describeSsh(host, cwd) {
|
|
117
|
+
return (`a remote machine (${host}), not the computer that runs you. Every command is sent over an SSH connection and starts fresh in ${cwd}: cd and export do not carry over to the next command. ` +
|
|
118
|
+
"The connection can be slow or drop, and a command can fail for reasons that have nothing to do with it, so results are not guaranteed to be repeatable. " +
|
|
119
|
+
"If a tool says the connection failed, the command may or may not have run: look at the current state (list the files, read the output file) before you repeat anything that changes something. " +
|
|
120
|
+
"Files are on the remote machine.");
|
|
121
|
+
}
|
|
122
|
+
/** An environment whose tools run on another machine over ssh. Close it (or dispose of the agent) to hang up. */
|
|
123
|
+
export async function createSshEnvironment(options) {
|
|
124
|
+
const connection = await connectSsh(options);
|
|
125
|
+
return createRemoteEnvironment({
|
|
126
|
+
cwd: options.cwd,
|
|
127
|
+
run: connection.run,
|
|
128
|
+
close: connection.close,
|
|
129
|
+
description: describeSsh(options.host, options.cwd),
|
|
130
|
+
...(options.workDir !== undefined ? { workDir: options.workDir } : {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { createLocalEnvironment } from "./local.js";
|
|
2
|
-
export {
|
|
2
|
+
export type { LocalOptions } from "./local.js";
|
|
3
|
+
export { OutsideDirectoryError } from "./confine.js";
|
|
4
|
+
export { createRemoteEnvironment, RemoteConnectionError } from "./remote.js";
|
|
3
5
|
export type { RemoteRunner } from "./remote.js";
|
|
6
|
+
export { connectSsh, createSshEnvironment } from "./connect-ssh.js";
|
|
7
|
+
export type { SshConnection, SshOptions } from "./connect-ssh.js";
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { createLocalEnvironment } from "./local.js";
|
|
2
|
-
export {
|
|
2
|
+
export { OutsideDirectoryError } from "./confine.js";
|
|
3
|
+
export { createRemoteEnvironment, RemoteConnectionError } from "./remote.js";
|
|
4
|
+
export { connectSsh, createSshEnvironment } from "./connect-ssh.js";
|
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
import type { Environment } from "../../kernel/src/index.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
export interface LocalOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Keep every file operation inside the working directory: reading, writing, listing and the directory of a command are refused (OutsideDirectoryError)
|
|
5
|
+
* for a path outside it, however it is written (absolute, with .., through a link). This is a boundary for the file tools. It is not a sandbox: a shell
|
|
6
|
+
* command can still reach anything this process can, so with a shell tool use it together with the review, or without a shell tool at all.
|
|
7
|
+
* It guards against honest mistakes and a model that wanders, not against a program that races the check.
|
|
8
|
+
*/
|
|
9
|
+
confine?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** The environment tools run in by default: this machine, with the permissions of this process. It confines nothing unless `confine` is set. */
|
|
12
|
+
export declare function createLocalEnvironment(cwd: string, options?: LocalOptions): Environment;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn as spawnProcess } from "node:child_process";
|
|
2
2
|
import { createWriteStream } from "node:fs";
|
|
3
|
-
import { mkdir, mkdtemp, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { isInside, OutsideDirectoryError, realLocation } from "./confine.js";
|
|
6
7
|
const MAX_OUTPUT_CHARS = 20_000_000;
|
|
7
8
|
const KILL_GRACE_MS = 1500;
|
|
8
9
|
function killGroup(pid, signal) {
|
|
@@ -15,7 +16,9 @@ function killGroup(pid, signal) {
|
|
|
15
16
|
// the group is already gone
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
|
-
// Commands run in their own process group, so
|
|
19
|
+
// Commands run in their own process group, so that stopping one stops everything it started. (A command that starts a session of its own, with
|
|
20
|
+
// setsid for example, leaves the group and is not reached by that; to stop everything, run the commands in a container.)
|
|
21
|
+
// They would also outlive a host process that is stopped without closing the environment
|
|
19
22
|
// (Ctrl-C, process.exit, a script that ends while a background command still runs). Every group still running is stopped when the host exits.
|
|
20
23
|
const liveGroups = new Set();
|
|
21
24
|
let exitHooked = false;
|
|
@@ -39,15 +42,43 @@ function hookExit() {
|
|
|
39
42
|
process.on(signal, onSignal);
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
|
-
/** The environment tools run in
|
|
43
|
-
export function createLocalEnvironment(cwd) {
|
|
45
|
+
/** The environment tools run in by default: this machine, with the permissions of this process. It confines nothing unless `confine` is set. */
|
|
46
|
+
export function createLocalEnvironment(cwd, options = {}) {
|
|
44
47
|
const root = resolve(cwd);
|
|
45
|
-
const
|
|
48
|
+
const confine = options.confine === true;
|
|
49
|
+
/** Where the output files of the commands of this environment are: outside the working directory, and the model is told to read them. */
|
|
50
|
+
let outputRoot;
|
|
51
|
+
const inOutputRoot = (absolute) => outputRoot !== undefined && isInside(outputRoot, absolute);
|
|
52
|
+
const resolvePath = (path) => {
|
|
53
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
54
|
+
if (confine && !isInside(root, absolute) && !inOutputRoot(absolute))
|
|
55
|
+
throw new OutsideDirectoryError(absolute, root);
|
|
56
|
+
return absolute;
|
|
57
|
+
};
|
|
58
|
+
let realRoot;
|
|
59
|
+
/** For file operations: the text check of resolvePath, and then where the path really is, because a link inside the folder can lead out of it. */
|
|
60
|
+
const checked = async (path, forWriting = false) => {
|
|
61
|
+
const absolute = resolvePath(path);
|
|
62
|
+
if (confine && inOutputRoot(absolute)) {
|
|
63
|
+
if (forWriting)
|
|
64
|
+
throw new OutsideDirectoryError(absolute, root); // the output files may be read, not written
|
|
65
|
+
return absolute;
|
|
66
|
+
}
|
|
67
|
+
if (confine) {
|
|
68
|
+
realRoot ??= realpath(root);
|
|
69
|
+
if (!isInside(await realRoot, await realLocation(absolute, root)))
|
|
70
|
+
throw new OutsideDirectoryError(absolute, root);
|
|
71
|
+
}
|
|
72
|
+
return absolute;
|
|
73
|
+
};
|
|
46
74
|
const running = new Set();
|
|
47
75
|
let outputDir;
|
|
48
76
|
let counter = 0;
|
|
49
77
|
const nextOutputFile = async () => {
|
|
50
|
-
outputDir ??= mkdtemp(join(tmpdir(), "sakura-env-"))
|
|
78
|
+
outputDir ??= mkdtemp(join(tmpdir(), "sakura-env-")).then((directory) => {
|
|
79
|
+
outputRoot = resolve(directory);
|
|
80
|
+
return directory;
|
|
81
|
+
});
|
|
51
82
|
return join(await outputDir, `command-${++counter}.log`);
|
|
52
83
|
};
|
|
53
84
|
function start(command, options) {
|
|
@@ -129,16 +160,17 @@ export function createLocalEnvironment(cwd) {
|
|
|
129
160
|
}
|
|
130
161
|
return {
|
|
131
162
|
cwd: root,
|
|
163
|
+
...(confine ? { description: `this machine (${process.platform}). The file tools work only inside ${root}; a path outside it is refused.` } : {}),
|
|
132
164
|
resolve: resolvePath,
|
|
133
|
-
readFile: async (path) => new Uint8Array(await readFile(
|
|
165
|
+
readFile: async (path) => new Uint8Array(await readFile(await checked(path))),
|
|
134
166
|
async writeFile(path, data) {
|
|
135
|
-
const target =
|
|
167
|
+
const target = await checked(path, true);
|
|
136
168
|
await mkdir(dirname(target), { recursive: true });
|
|
137
169
|
await writeFile(target, data);
|
|
138
170
|
},
|
|
139
171
|
async stat(path) {
|
|
140
172
|
try {
|
|
141
|
-
const info = await stat(
|
|
173
|
+
const info = await stat(await checked(path));
|
|
142
174
|
return { isFile: info.isFile(), isDirectory: info.isDirectory(), size: info.size };
|
|
143
175
|
}
|
|
144
176
|
catch (error) {
|
|
@@ -148,7 +180,7 @@ export function createLocalEnvironment(cwd) {
|
|
|
148
180
|
}
|
|
149
181
|
},
|
|
150
182
|
async list(path) {
|
|
151
|
-
return (await readdir(
|
|
183
|
+
return (await readdir(await checked(path))).sort();
|
|
152
184
|
},
|
|
153
185
|
spawn: start,
|
|
154
186
|
async exec(command, options) {
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Environment, ExecResult } from "../../kernel/src/index.js";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
|
|
4
|
+
* is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
|
|
5
|
+
*/
|
|
6
|
+
export declare class RemoteConnectionError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The one thing a remote environment needs: a way to run a shell command somewhere else and get its output back.
|
|
11
|
+
* It resolves with the result of a command that ran, whatever its exit code, and rejects with a RemoteConnectionError when it could not reach the other side.
|
|
12
|
+
*/
|
|
3
13
|
export type RemoteRunner = (command: string, options: {
|
|
4
14
|
cwd?: string;
|
|
5
15
|
timeoutMs?: number;
|
|
@@ -12,4 +22,10 @@ export type RemoteRunner = (command: string, options: {
|
|
|
12
22
|
export declare function createRemoteEnvironment(options: {
|
|
13
23
|
cwd: string;
|
|
14
24
|
run: RemoteRunner;
|
|
25
|
+
/** Called once when the environment is disposed, after its commands were stopped: close the connection the runner uses. */
|
|
26
|
+
close?: () => Promise<void>;
|
|
27
|
+
/** What the model is told about where its tools run (see Environment.description). */
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Where the scripts and logs of started commands are kept on the other machine, and where a background command's output file is. Default /tmp/.sakura-remote, so the working directory is left as the task has it. */
|
|
30
|
+
workDir?: string;
|
|
15
31
|
}): Environment;
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { posix } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
|
|
5
|
+
* is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
|
|
6
|
+
*/
|
|
7
|
+
export class RemoteConnectionError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "RemoteConnectionError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
3
13
|
const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
|
|
4
14
|
const CHUNK = 48_000;
|
|
5
15
|
/**
|
|
@@ -8,7 +18,7 @@ const CHUNK = 48_000;
|
|
|
8
18
|
*/
|
|
9
19
|
export function createRemoteEnvironment(options) {
|
|
10
20
|
const root = posix.resolve("/", options.cwd);
|
|
11
|
-
const workDir = posix.
|
|
21
|
+
const workDir = posix.resolve("/", options.workDir ?? "/tmp/.sakura-remote");
|
|
12
22
|
const { run } = options;
|
|
13
23
|
const resolvePath = (path) => (path.startsWith("/") ? posix.normalize(path) : posix.resolve(root, path));
|
|
14
24
|
const running = new Set();
|
|
@@ -20,6 +30,7 @@ export function createRemoteEnvironment(options) {
|
|
|
20
30
|
throw new Error(result.output.trim() || `command failed with exit code ${result.exitCode}`);
|
|
21
31
|
return result.output;
|
|
22
32
|
}
|
|
33
|
+
const killing = new Map();
|
|
23
34
|
function spawn(command, spawnOptions) {
|
|
24
35
|
const id = `${prefix}-${++counter}`;
|
|
25
36
|
const log = posix.join(workDir, `${id}.log`);
|
|
@@ -28,16 +39,34 @@ export function createRemoteEnvironment(options) {
|
|
|
28
39
|
let pid = 0;
|
|
29
40
|
let finished = false;
|
|
30
41
|
let result;
|
|
42
|
+
let startFailure;
|
|
31
43
|
const script = `cd ${quote(spawnOptions.cwd ? resolvePath(spawnOptions.cwd) : root)} || exit 1\n${command}\n`;
|
|
32
44
|
const started = run(`mkdir -p ${quote(workDir)} && echo ${Buffer.from(script).toString("base64")} | base64 -d > ${quote(scriptFile)} || exit $?; ` +
|
|
33
45
|
`setsid bash -c ${quote(`bash ${quote(scriptFile)} > ${quote(log)} 2>&1; echo $? > ${quote(codeFile)}`)} > /dev/null 2>&1 & echo $!`, { cwd: root }).then((outcome) => {
|
|
34
46
|
if (outcome.exitCode === 0)
|
|
35
47
|
pid = Number.parseInt(outcome.output.trim().split("\n").at(-1) ?? "", 10) || 0;
|
|
48
|
+
}, (error) => {
|
|
49
|
+
if (!(error instanceof RemoteConnectionError))
|
|
50
|
+
throw error;
|
|
51
|
+
startFailure = error;
|
|
36
52
|
});
|
|
53
|
+
// Stopping is done once, however often it is asked for, and can be waited for (dispose does).
|
|
37
54
|
const kill = () => {
|
|
38
|
-
|
|
55
|
+
if (killing.has(handle))
|
|
56
|
+
return;
|
|
57
|
+
killing.set(handle, started
|
|
58
|
+
.then(() => (pid > 0 ? run(`kill -TERM -- -${pid} 2>/dev/null; sleep 1; kill -KILL -- -${pid} 2>/dev/null; true`, { cwd: root }) : undefined))
|
|
59
|
+
.then(() => undefined, () => undefined));
|
|
39
60
|
};
|
|
40
61
|
spawnOptions.signal?.addEventListener("abort", kill, { once: true });
|
|
62
|
+
/** The connection failed and nothing can be said about how the command ended: say that, and where its output is. */
|
|
63
|
+
const lost = (error, what) => {
|
|
64
|
+
finished = true;
|
|
65
|
+
running.delete(handle);
|
|
66
|
+
return error instanceof RemoteConnectionError
|
|
67
|
+
? new RemoteConnectionError(`${what} (${error.message}). The command may still be running; its output is being written to ${log} on the remote machine.`)
|
|
68
|
+
: error;
|
|
69
|
+
};
|
|
41
70
|
let done;
|
|
42
71
|
const handle = {
|
|
43
72
|
get pid() {
|
|
@@ -52,6 +81,11 @@ export function createRemoteEnvironment(options) {
|
|
|
52
81
|
};
|
|
53
82
|
done = (async () => {
|
|
54
83
|
await started;
|
|
84
|
+
if (startFailure) {
|
|
85
|
+
finished = true;
|
|
86
|
+
running.delete(handle);
|
|
87
|
+
throw new RemoteConnectionError(`The connection to the remote machine failed while the command was being started, so it may or may not be running (${startFailure.message}). If it is, its output is being written to ${log} on the remote machine.`);
|
|
88
|
+
}
|
|
55
89
|
if (pid === 0) {
|
|
56
90
|
finished = true;
|
|
57
91
|
running.delete(handle);
|
|
@@ -60,10 +94,14 @@ export function createRemoteEnvironment(options) {
|
|
|
60
94
|
for (let attempt = 0;; attempt++) {
|
|
61
95
|
const state = await run(`if [ -f ${quote(codeFile)} ]; then cat ${quote(codeFile)}; elif kill -0 ${pid} 2>/dev/null; then echo running; else echo gone; fi`, {
|
|
62
96
|
cwd: root,
|
|
97
|
+
}).catch((error) => {
|
|
98
|
+
throw lost(error, "The connection to the remote machine was lost while waiting for the command");
|
|
63
99
|
});
|
|
64
100
|
const text = state.output.trim();
|
|
65
101
|
if (text !== "running") {
|
|
66
|
-
const output = await run(`cat ${quote(log)} 2>/dev/null`, { cwd: root })
|
|
102
|
+
const output = await run(`cat ${quote(log)} 2>/dev/null`, { cwd: root }).catch((error) => {
|
|
103
|
+
throw lost(error, "The connection to the remote machine was lost while reading the output of the command");
|
|
104
|
+
});
|
|
67
105
|
const exitCode = /^\d+$/.test(text) ? Number(text) : null;
|
|
68
106
|
finished = true;
|
|
69
107
|
running.delete(handle);
|
|
@@ -73,11 +111,14 @@ export function createRemoteEnvironment(options) {
|
|
|
73
111
|
await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 250 : 1000));
|
|
74
112
|
}
|
|
75
113
|
})();
|
|
114
|
+
done.catch(() => undefined); // a caller that never waits must not cause an unhandled rejection
|
|
76
115
|
running.add(handle);
|
|
77
116
|
return handle;
|
|
78
117
|
}
|
|
118
|
+
let disposed = false;
|
|
79
119
|
return {
|
|
80
120
|
cwd: root,
|
|
121
|
+
...(options.description !== undefined ? { description: options.description } : {}),
|
|
81
122
|
resolve: resolvePath,
|
|
82
123
|
async readFile(path) {
|
|
83
124
|
const output = await must(`set -o pipefail; base64 < ${quote(resolvePath(path))} | tr -d '\\n'`);
|
|
@@ -113,8 +154,17 @@ export function createRemoteEnvironment(options) {
|
|
|
113
154
|
...(execOptions.signal ? { signal: execOptions.signal } : {}),
|
|
114
155
|
}),
|
|
115
156
|
async dispose() {
|
|
116
|
-
|
|
157
|
+
if (disposed)
|
|
158
|
+
return;
|
|
159
|
+
disposed = true;
|
|
160
|
+
const handles = [...running];
|
|
161
|
+
for (const handle of handles)
|
|
117
162
|
handle.kill();
|
|
163
|
+
await Promise.all(handles.map((handle) => killing.get(handle)));
|
|
164
|
+
// the scripts, logs and exit codes of this session are of no use to anyone afterwards
|
|
165
|
+
if (counter > 0)
|
|
166
|
+
await run(`rm -f ${quote(posix.join(workDir, `${prefix}-`))}*`, { cwd: root }).catch(() => undefined);
|
|
167
|
+
await options.close?.();
|
|
118
168
|
},
|
|
119
169
|
};
|
|
120
170
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface BfclFunction {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
parameters: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface BfclItem {
|
|
7
|
+
id: string;
|
|
8
|
+
question: Array<Array<{
|
|
9
|
+
role: string;
|
|
10
|
+
content: string;
|
|
11
|
+
}>>;
|
|
12
|
+
function: BfclFunction[];
|
|
13
|
+
}
|
|
14
|
+
export type GroundTruth = Array<Record<string, Record<string, unknown[]>>>;
|
|
15
|
+
/** The wire tool name: BFCL names may contain dots, which tool name rules do not allow. */
|
|
16
|
+
export declare const wireName: (name: string) => string;
|
|
17
|
+
/** BFCL schemas use "dict", "float", "tuple" and similar; map them to JSON Schema types. */
|
|
18
|
+
export declare function toJsonSchema(value: unknown): unknown;
|
|
19
|
+
export interface ActualCall {
|
|
20
|
+
name: string;
|
|
21
|
+
args: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
/** Every expected call must be matched by a different actual call (order does not matter). */
|
|
24
|
+
export declare function scoreCalls(calls: ActualCall[], truth: GroundTruth): boolean;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Scoring for the Berkeley Function Calling Leaderboard (Gorilla, Apache-2.0) "simple", "multiple" and
|
|
3
|
+
* "parallel" sets, in a simplified form: function names and argument values are compared; types are
|
|
4
|
+
* compared loosely. Data files live in external/bfcl (downloaded, not committed).
|
|
5
|
+
*/
|
|
6
|
+
/** The wire tool name: BFCL names may contain dots, which tool name rules do not allow. */
|
|
7
|
+
export const wireName = (name) => name.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
8
|
+
/** BFCL schemas use "dict", "float", "tuple" and similar; map them to JSON Schema types. */
|
|
9
|
+
export function toJsonSchema(value) {
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return value.map(toJsonSchema);
|
|
12
|
+
if (typeof value !== "object" || value === null)
|
|
13
|
+
return value;
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
16
|
+
if (key === "type" && typeof inner === "string") {
|
|
17
|
+
out.type = { dict: "object", float: "number", tuple: "array", any: "string", integer: "integer" }[inner] ?? inner;
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
out[key] = toJsonSchema(inner);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
const normalizeString = (value) => value.toLowerCase().replace(/[\s,.\-_/:'"`()]/g, "");
|
|
25
|
+
function valuesEqual(actual, allowed) {
|
|
26
|
+
if (typeof allowed === "number" && typeof actual === "number")
|
|
27
|
+
return Math.abs(allowed - actual) < 1e-9 * Math.max(1, Math.abs(allowed));
|
|
28
|
+
if (typeof allowed === "number" && typeof actual === "string")
|
|
29
|
+
return Number(actual) === allowed;
|
|
30
|
+
if (typeof allowed === "string" && typeof actual === "string")
|
|
31
|
+
return normalizeString(allowed) === normalizeString(actual);
|
|
32
|
+
if (typeof allowed === "string" && typeof actual === "number")
|
|
33
|
+
return normalizeString(allowed) === normalizeString(String(actual));
|
|
34
|
+
if (Array.isArray(allowed) && Array.isArray(actual))
|
|
35
|
+
return allowed.length === actual.length && allowed.every((item, index) => valuesEqual(actual[index], item));
|
|
36
|
+
if (typeof allowed === "object" && allowed && typeof actual === "object" && actual) {
|
|
37
|
+
const keys = Object.keys(allowed);
|
|
38
|
+
return keys.length === Object.keys(actual).length && keys.every((key) => valuesEqual(actual[key], allowed[key]));
|
|
39
|
+
}
|
|
40
|
+
return actual === allowed;
|
|
41
|
+
}
|
|
42
|
+
/** One call matches one ground-truth entry when the name matches and every argument is an allowed value. */
|
|
43
|
+
function callMatches(call, truth) {
|
|
44
|
+
const [name, allowedArgs] = Object.entries(truth)[0] ?? [];
|
|
45
|
+
if (!name || !allowedArgs || wireName(name) !== call.name)
|
|
46
|
+
return false;
|
|
47
|
+
for (const [parameter, allowed] of Object.entries(allowedArgs)) {
|
|
48
|
+
const given = call.args[parameter];
|
|
49
|
+
if (given === undefined) {
|
|
50
|
+
if (allowed.includes(""))
|
|
51
|
+
continue; // optional parameter
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (!allowed.some((candidate) => valuesEqual(given, candidate)))
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return Object.keys(call.args).every((parameter) => parameter in allowedArgs);
|
|
58
|
+
}
|
|
59
|
+
/** Every expected call must be matched by a different actual call (order does not matter). */
|
|
60
|
+
export function scoreCalls(calls, truth) {
|
|
61
|
+
if (calls.length !== truth.length)
|
|
62
|
+
return false;
|
|
63
|
+
const used = new Set();
|
|
64
|
+
return truth.every((expected) => {
|
|
65
|
+
const index = calls.findIndex((call, position) => !used.has(position) && callMatches(call, expected));
|
|
66
|
+
if (index === -1)
|
|
67
|
+
return false;
|
|
68
|
+
used.add(index);
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { deflateSync } from "node:zlib";
|
|
2
|
+
function crc32(bytes) {
|
|
3
|
+
let crc = 0xffffffff;
|
|
4
|
+
for (const byte of bytes) {
|
|
5
|
+
crc ^= byte;
|
|
6
|
+
for (let bit = 0; bit < 8; bit++)
|
|
7
|
+
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
8
|
+
}
|
|
9
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
10
|
+
}
|
|
11
|
+
function chunk(type, data) {
|
|
12
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
13
|
+
const out = Buffer.alloc(8 + data.length + 4);
|
|
14
|
+
out.writeUInt32BE(data.length, 0);
|
|
15
|
+
body.copy(out, 4);
|
|
16
|
+
out.writeUInt32BE(crc32(body), 8 + data.length);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** A solid-colour PNG, used to check that a model can really see an image. */
|
|
20
|
+
export function solidPng(width, height, [red, green, blue]) {
|
|
21
|
+
const header = Buffer.alloc(13);
|
|
22
|
+
header.writeUInt32BE(width, 0);
|
|
23
|
+
header.writeUInt32BE(height, 4);
|
|
24
|
+
header.set([8, 2, 0, 0, 0], 8);
|
|
25
|
+
const row = Buffer.alloc(1 + width * 3);
|
|
26
|
+
for (let x = 0; x < width; x++)
|
|
27
|
+
row.set([red, green, blue], 1 + x * 3);
|
|
28
|
+
const raw = Buffer.concat(Array.from({ length: height }, () => row));
|
|
29
|
+
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", header), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Runs async jobs with a fixed number at a time and returns their results in order. */
|
|
2
|
+
export declare function mapPool<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
3
|
+
export declare function parseArgs(argv: string[]): Record<string, string>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Runs async jobs with a fixed number at a time and returns their results in order. */
|
|
2
|
+
export async function mapPool(items, limit, work) {
|
|
3
|
+
const results = new Array(items.length);
|
|
4
|
+
let next = 0;
|
|
5
|
+
async function worker() {
|
|
6
|
+
while (next < items.length) {
|
|
7
|
+
const index = next++;
|
|
8
|
+
results[index] = await work(items[index], index);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
12
|
+
return results;
|
|
13
|
+
}
|
|
14
|
+
export function parseArgs(argv) {
|
|
15
|
+
const args = {};
|
|
16
|
+
for (let index = 0; index < argv.length; index++) {
|
|
17
|
+
const key = argv[index];
|
|
18
|
+
if (key?.startsWith("--"))
|
|
19
|
+
args[key.slice(2)] = argv[index + 1] ?? "true";
|
|
20
|
+
}
|
|
21
|
+
return args;
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ChatCompat } from "../../llm/src/index.js";
|
|
2
|
+
/** The real endpoints used by live tests. Keys are read from the environment and never printed. */
|
|
3
|
+
export interface LiveProvider {
|
|
4
|
+
name: string;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
keyEnv: string[];
|
|
7
|
+
model: string;
|
|
8
|
+
contextWindow: number;
|
|
9
|
+
compat: Partial<ChatCompat>;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export declare const liveProviders: LiveProvider[];
|
|
13
|
+
export declare function apiKeyFor(provider: LiveProvider): string | undefined;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { compatPresets } from "../../llm/src/index.js";
|
|
3
|
+
export const liveProviders = [
|
|
4
|
+
{ name: "deepseek", baseUrl: "https://api.deepseek.com", keyEnv: ["DEEPSEEK_API_KEY"], model: "deepseek-flash", contextWindow: 128_000, compat: compatPresets.deepseek },
|
|
5
|
+
{ name: "kimi", baseUrl: "https://api.moonshot.cn/v1", keyEnv: ["MOONSHOT_API_KEY", "KIMI_API_KEY"], model: "kimi-k3", contextWindow: 128_000, compat: compatPresets.moonshot },
|
|
6
|
+
{
|
|
7
|
+
name: "opencode",
|
|
8
|
+
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
9
|
+
keyEnv: ["OPENCODE_API_KEY"],
|
|
10
|
+
model: "deepseek-v4.1-flash",
|
|
11
|
+
contextWindow: 128_000,
|
|
12
|
+
compat: compatPresets.deepseek,
|
|
13
|
+
headers: { "x-opencode-session": randomUUID() },
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
export function apiKeyFor(provider) {
|
|
17
|
+
for (const name of provider.keyEnv) {
|
|
18
|
+
const value = process.env[name];
|
|
19
|
+
if (value)
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|