sakura-agent 0.1.0 → 0.1.2
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 +207 -77
- package/README.zh-CN.md +208 -78
- 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 +45 -0
- package/dist/packages/env/src/connect-ssh.js +242 -0
- package/dist/packages/env/src/index.d.ts +6 -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-stream.d.ts +48 -0
- package/dist/packages/env/src/remote-stream.js +143 -0
- package/dist/packages/env/src/remote-types.d.ts +17 -0
- package/dist/packages/env/src/remote-types.js +10 -0
- package/dist/packages/env/src/remote.d.ts +16 -7
- package/dist/packages/env/src/remote.js +73 -10
- 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 +24 -3
- package/dist/packages/kernel/src/compaction.js +23 -16
- 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/errors.d.ts +11 -0
- package/dist/packages/llm/src/chat/errors.js +17 -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/chat/stream.js +2 -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 +10 -1
- package/dist/packages/plugins/review/src/chain.js +7 -4
- 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 +21 -0
- package/dist/packages/plugins/review/src/index.js +42 -7
- package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
- package/dist/packages/plugins/review/src/reviewer.js +115 -27
- package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
- package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
- package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
- package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
- 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 +22 -3
- 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 +7 -2
|
@@ -20,13 +20,15 @@ export function createBashTool(options = {}) {
|
|
|
20
20
|
"A command that is still running after 120 seconds is moved to the background. You then receive its pid, the path of its output file and how to stop it. " +
|
|
21
21
|
"Set `background: true` to start a server or a long job and return at once; you are told when it ends. " +
|
|
22
22
|
"To wait for something, run `sleep N`; a command that starts with `sleep N` gets N extra seconds before it is moved to the background. " +
|
|
23
|
-
"Optional `timeout` (seconds) kills the command when it is reached; there is no default timeout."
|
|
23
|
+
"Optional `timeout` (seconds) kills the command when it is reached; there is no default timeout. " +
|
|
24
|
+
"A command that runs in the background tells you when it ends, in your next turn. Set `wake: true` to be woken up for it even if you have nothing else to do by then (to act on the result when it ends), or `wake: false` if you do not need to be. If you leave it out, the application's own setting applies.",
|
|
24
25
|
parameters: {
|
|
25
26
|
type: "object",
|
|
26
27
|
properties: {
|
|
27
28
|
command: { type: "string", description: "The shell command to run" },
|
|
28
29
|
timeout: { type: "number", description: "Kill the command after this many seconds" },
|
|
29
30
|
background: { type: "boolean", description: "Start the command and return at once, without waiting for it to end" },
|
|
31
|
+
wake: { type: "boolean", description: "For a command that runs in the background: wake you up when it ends (true), or not (false)" },
|
|
30
32
|
},
|
|
31
33
|
required: ["command"],
|
|
32
34
|
},
|
|
@@ -37,6 +39,7 @@ export function createBashTool(options = {}) {
|
|
|
37
39
|
const command = requireString(args, "command");
|
|
38
40
|
const timeout = optionalNumber(args, "timeout", 0);
|
|
39
41
|
const background = optionalBoolean(args, "background") === true;
|
|
42
|
+
const wake = optionalBoolean(args, "wake");
|
|
40
43
|
const handle = env.spawn(command, { signal });
|
|
41
44
|
let timedOut = false;
|
|
42
45
|
const hardTimer = timeout !== undefined
|
|
@@ -47,21 +50,32 @@ export function createBashTool(options = {}) {
|
|
|
47
50
|
: undefined;
|
|
48
51
|
const soft = background ? graceMs : softLimitFor(command, softBase); // in the background it still gets a moment, so a command that fails at once is reported as a failure
|
|
49
52
|
let softTimer;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
let raced;
|
|
54
|
+
try {
|
|
55
|
+
raced = await Promise.race([
|
|
56
|
+
handle.wait().then((result) => ({ background: false, result })),
|
|
57
|
+
new Promise((resolve) => {
|
|
58
|
+
softTimer = setTimeout(() => resolve({ background: true }), soft);
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// the environment lost contact with the command: no timer may be left running, and the model is told what happened
|
|
64
|
+
clearTimeout(softTimer);
|
|
65
|
+
if (hardTimer)
|
|
66
|
+
clearTimeout(hardTimer);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
56
69
|
clearTimeout(softTimer);
|
|
57
70
|
if (raced.background) {
|
|
58
71
|
// the hard timeout, if one was given, keeps running and still stops the command
|
|
59
72
|
// tell the agent when it ends, so it does not have to keep looking at the output file
|
|
60
|
-
const ended = watch(`background command (pid ${handle.pid})
|
|
73
|
+
const ended = watch(`background command (pid ${handle.pid})`, wake === undefined ? undefined : { wake });
|
|
61
74
|
void handle.wait().then((finished) => {
|
|
62
75
|
const how = finished.timedOut || timedOut ? "was stopped by its timeout" : finished.exitCode === null ? "was stopped" : `finished with exit code ${finished.exitCode}`;
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
// a command that was stopped because the run was cancelled must not start the agent again: whoever cancelled wanted it to stop
|
|
77
|
+
ended(`The background command (pid ${handle.pid}) ${how}. Its output is in ${handle.outputFile}.`, signal.aborted ? { wake: false } : undefined);
|
|
78
|
+
}, (error) => ended(`The background command (pid ${handle.pid}) could not be followed to its end: ${error instanceof Error ? error.message : String(error)}`));
|
|
65
79
|
const seen = await env.readFile(handle.outputFile).then((bytes) => new TextDecoder().decode(bytes), () => "");
|
|
66
80
|
const tail = foldTail(seen, { maxLines: 20, maxBytes: 4000 }).text;
|
|
67
81
|
return text((background
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The image formats that model APIs take. Anything else must be converted or refused before it is sent: a refused image is refused for good. */
|
|
2
|
+
export declare const SENDABLE_IMAGE_TYPES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
3
|
+
export type ImageType = (typeof SENDABLE_IMAGE_TYPES)[number] | "image/bmp";
|
|
4
|
+
/** The format a file really has, read from its first bytes (a file name proves nothing). Undefined when it is not an image we know. */
|
|
5
|
+
export declare function sniffImageType(bytes: Uint8Array): ImageType | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Turns an uncompressed 24-bit or 32-bit BMP (the kind programs write when they dump a frame) into a PNG. Undefined for anything else: a palette,
|
|
8
|
+
* run-length coding, 16 bits, unusual colour masks, a truncated file. The caller then tells the model the file cannot be shown. The alpha byte of a
|
|
9
|
+
* 32-bit BMP is dropped: in this form it is almost always unused (zero) and would make the picture invisible.
|
|
10
|
+
*/
|
|
11
|
+
export declare function bmpToPng(bytes: Uint8Array): Uint8Array | undefined;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { crc32, deflateSync } from "node:zlib";
|
|
2
|
+
/** The image formats that model APIs take. Anything else must be converted or refused before it is sent: a refused image is refused for good. */
|
|
3
|
+
export const SENDABLE_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
4
|
+
const startsWith = (bytes, signature, at = 0) => signature.every((value, index) => bytes[at + index] === value);
|
|
5
|
+
/** The format a file really has, read from its first bytes (a file name proves nothing). Undefined when it is not an image we know. */
|
|
6
|
+
export function sniffImageType(bytes) {
|
|
7
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
8
|
+
return "image/png";
|
|
9
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff]))
|
|
10
|
+
return "image/jpeg";
|
|
11
|
+
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38]))
|
|
12
|
+
return "image/gif"; // "GIF8"
|
|
13
|
+
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8))
|
|
14
|
+
return "image/webp"; // "RIFF....WEBP"
|
|
15
|
+
if (startsWith(bytes, [0x42, 0x4d]))
|
|
16
|
+
return "image/bmp"; // "BM"
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
/** A guard against a header that promises a picture too large to hold in memory. */
|
|
20
|
+
const MAX_PIXELS = 50_000_000;
|
|
21
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
22
|
+
function pngChunk(type, data) {
|
|
23
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
24
|
+
const length = Buffer.alloc(4);
|
|
25
|
+
length.writeUInt32BE(data.length);
|
|
26
|
+
const checksum = Buffer.alloc(4);
|
|
27
|
+
checksum.writeUInt32BE(crc32(body));
|
|
28
|
+
return Buffer.concat([length, body, checksum]);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Turns an uncompressed 24-bit or 32-bit BMP (the kind programs write when they dump a frame) into a PNG. Undefined for anything else: a palette,
|
|
32
|
+
* run-length coding, 16 bits, unusual colour masks, a truncated file. The caller then tells the model the file cannot be shown. The alpha byte of a
|
|
33
|
+
* 32-bit BMP is dropped: in this form it is almost always unused (zero) and would make the picture invisible.
|
|
34
|
+
*/
|
|
35
|
+
export function bmpToPng(bytes) {
|
|
36
|
+
if (sniffImageType(bytes) !== "image/bmp" || bytes.length < 54)
|
|
37
|
+
return undefined;
|
|
38
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
39
|
+
const dataOffset = view.getUint32(10, true);
|
|
40
|
+
const headerSize = view.getUint32(14, true);
|
|
41
|
+
if (headerSize < 40)
|
|
42
|
+
return undefined; // the old 12-byte header is not handled
|
|
43
|
+
const width = view.getInt32(18, true);
|
|
44
|
+
const rawHeight = view.getInt32(22, true);
|
|
45
|
+
const bitsPerPixel = view.getUint16(28, true);
|
|
46
|
+
const compression = view.getUint32(30, true);
|
|
47
|
+
const height = Math.abs(rawHeight);
|
|
48
|
+
const topDown = rawHeight < 0;
|
|
49
|
+
if (width <= 0 || height === 0 || width * height > MAX_PIXELS)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (bitsPerPixel !== 24 && bitsPerPixel !== 32)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (compression === 3) {
|
|
54
|
+
// BI_BITFIELDS: only the usual layout (red 0x00FF0000, green 0x0000FF00, blue 0x000000FF) is understood
|
|
55
|
+
if (bitsPerPixel !== 32 || bytes.length < 66 || view.getUint32(54, true) !== 0x00ff0000 || view.getUint32(58, true) !== 0x0000ff00 || view.getUint32(62, true) !== 0x000000ff)
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
else if (compression !== 0) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const bytesPerPixel = bitsPerPixel / 8;
|
|
62
|
+
const rowSize = Math.ceil((width * bitsPerPixel) / 32) * 4;
|
|
63
|
+
if (dataOffset + rowSize * height > bytes.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
// PNG rows: a filter byte (0 = none) and then red, green, blue for every pixel
|
|
66
|
+
const scanline = 1 + width * 3;
|
|
67
|
+
const raw = Buffer.alloc(scanline * height);
|
|
68
|
+
for (let row = 0; row < height; row++) {
|
|
69
|
+
const source = dataOffset + (topDown ? row : height - 1 - row) * rowSize;
|
|
70
|
+
const target = row * scanline + 1;
|
|
71
|
+
for (let column = 0; column < width; column++) {
|
|
72
|
+
const pixel = source + column * bytesPerPixel;
|
|
73
|
+
raw[target + column * 3] = bytes[pixel + 2] ?? 0;
|
|
74
|
+
raw[target + column * 3 + 1] = bytes[pixel + 1] ?? 0;
|
|
75
|
+
raw[target + column * 3 + 2] = bytes[pixel] ?? 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const header = Buffer.alloc(13);
|
|
79
|
+
header.writeUInt32BE(width, 0);
|
|
80
|
+
header.writeUInt32BE(height, 4);
|
|
81
|
+
header[8] = 8; // bit depth
|
|
82
|
+
header[9] = 2; // colour type: truecolour
|
|
83
|
+
return new Uint8Array(Buffer.concat([PNG_SIGNATURE, pngChunk("IHDR", header), pngChunk("IDAT", deflateSync(raw)), pngChunk("IEND", Buffer.alloc(0))]));
|
|
84
|
+
}
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { DEFAULT_FOLD, foldHead } from "../fold.js";
|
|
2
2
|
import { ArgumentError, optionalInteger, requireString } from "./args.js";
|
|
3
|
-
|
|
3
|
+
import { bmpToPng, SENDABLE_IMAGE_TYPES, sniffImageType } from "./image.js";
|
|
4
|
+
/** File names that say "this is a picture". What the file really is, is decided by its first bytes (image.ts). */
|
|
5
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
|
|
6
|
+
const CONVERTED_DIRECTORY = ".sakura/artifacts";
|
|
4
7
|
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
5
8
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
9
|
+
const imageResult = (mediaType, ref, note = "") => ({
|
|
10
|
+
content: [
|
|
11
|
+
{ type: "text", text: `Read image file [${mediaType}]${note ? ` (${note})` : ""}` },
|
|
12
|
+
{ type: "image", mediaType, ref },
|
|
13
|
+
],
|
|
14
|
+
});
|
|
6
15
|
/*
|
|
7
16
|
* Description adapted from Pi's read tool (MIT, Copyright (c) 2025 Mario Zechner; see THIRD_PARTY_NOTICES.md).
|
|
8
17
|
*/
|
|
9
18
|
export const readTool = {
|
|
10
19
|
name: "read",
|
|
11
|
-
description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp
|
|
20
|
+
description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp; a plain bmp is converted to png); images are sent as attachments. " +
|
|
12
21
|
"For text files, output is truncated to the first 2000 lines or 50KB, whichever is hit first. " +
|
|
13
22
|
"Use offset and limit for large files, and continue with offset until you have the whole file.",
|
|
14
23
|
parameters: {
|
|
@@ -21,7 +30,7 @@ export const readTool = {
|
|
|
21
30
|
required: ["path"],
|
|
22
31
|
},
|
|
23
32
|
effect: "read",
|
|
24
|
-
async execute(args, { env }) {
|
|
33
|
+
async execute(args, { env, callId }) {
|
|
25
34
|
try {
|
|
26
35
|
const path = requireString(args, "path");
|
|
27
36
|
const offset = optionalInteger(args, "offset", 1);
|
|
@@ -33,16 +42,21 @@ export const readTool = {
|
|
|
33
42
|
if (info.isDirectory)
|
|
34
43
|
return fail(`${path} is a directory, not a file.`);
|
|
35
44
|
const extension = absolute.slice(absolute.lastIndexOf(".")).toLowerCase();
|
|
36
|
-
const mediaType = IMAGE_TYPES[extension];
|
|
37
|
-
if (mediaType) {
|
|
38
|
-
return {
|
|
39
|
-
content: [
|
|
40
|
-
{ type: "text", text: `Read image file [${mediaType}]` },
|
|
41
|
-
{ type: "image", mediaType, ref: absolute },
|
|
42
|
-
],
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
45
|
const bytes = await env.readFile(absolute);
|
|
46
|
+
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
47
|
+
const type = sniffImageType(bytes);
|
|
48
|
+
if (type && SENDABLE_IMAGE_TYPES.includes(type))
|
|
49
|
+
return imageResult(type, absolute);
|
|
50
|
+
if (type === "image/bmp") {
|
|
51
|
+
const png = bmpToPng(bytes);
|
|
52
|
+
if (!png)
|
|
53
|
+
return fail(`${path} is a BMP image in a form this tool cannot convert, and the model API does not accept BMP. Convert it to PNG with a command (for example ImageMagick: magick ${path} out.png) and read the PNG.`);
|
|
54
|
+
const converted = `${CONVERTED_DIRECTORY}/read-${callId.replace(/[^A-Za-z0-9_.-]/g, "_")}.png`;
|
|
55
|
+
await env.writeFile(converted, png);
|
|
56
|
+
return imageResult("image/png", env.resolve(converted), `converted from BMP to PNG, original ${path}`);
|
|
57
|
+
}
|
|
58
|
+
// a name that says picture, bytes that are not one: shown as the binary or text file it is
|
|
59
|
+
}
|
|
46
60
|
if (bytes.subarray(0, 8000).includes(0))
|
|
47
61
|
return fail(`${path} looks like a binary file and cannot be shown as text.`);
|
|
48
62
|
const text = new TextDecoder().decode(bytes);
|
|
@@ -7,6 +7,7 @@ export type Authorize = (call: ToolCall, context: {
|
|
|
7
7
|
log: Log;
|
|
8
8
|
signal: AbortSignal;
|
|
9
9
|
tool(name: string): Tool | undefined;
|
|
10
|
+
cwd?: string;
|
|
10
11
|
}) => Promise<{
|
|
11
12
|
kind: "allow";
|
|
12
13
|
} | {
|
|
@@ -16,21 +17,31 @@ export type Authorize = (call: ToolCall, context: {
|
|
|
16
17
|
export interface CodemodeOptions {
|
|
17
18
|
/** The tools the script can call as `tools.<name>(args)`. */
|
|
18
19
|
catalog: Tool[];
|
|
19
|
-
/** Tools with these effects can be called from a script (default:
|
|
20
|
+
/** Tools with these effects can be called from a script (default: all of them). Others raise an error inside the script. */
|
|
20
21
|
allowEffects?: ToolEffect[];
|
|
21
|
-
/** Asked before every tool call a script makes, as the loop's hooks cannot see them. A refusal raises an error inside the script. Without it,
|
|
22
|
+
/** Asked before every tool call a script makes, as the loop's hooks cannot see them. A refusal raises an error inside the script. Without it, every allowed call simply runs. */
|
|
22
23
|
authorize?: Authorize;
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The script is stopped after running this long (default 10 seconds). This is the script's own time: time spent running JavaScript, and time spent
|
|
26
|
+
* waiting while no tool call is under way. Time spent waiting for a tool call does not count, because tools have limits of their own.
|
|
27
|
+
*/
|
|
24
28
|
timeoutMs?: number;
|
|
25
|
-
/**
|
|
29
|
+
/** A limit on the whole run of the script, tool calls included. Not set by default. */
|
|
30
|
+
totalTimeoutMs?: number;
|
|
31
|
+
/** Memory limit of the script runtime in bytes (default 64 MB). */
|
|
26
32
|
memoryBytes?: number;
|
|
27
33
|
/** Longest text returned to the model, in characters; the middle is cut (default 20000). */
|
|
28
34
|
maxOutputChars?: number;
|
|
29
35
|
}
|
|
36
|
+
/** The name of the tool that runs a script. Whoever needs to refer to it takes it from here. */
|
|
37
|
+
export declare const CODEMODE_TOOL = "run_code";
|
|
30
38
|
/**
|
|
31
39
|
* Lets the model write one JavaScript program that calls tools, instead of one call per request. The program runs in QuickJS
|
|
32
40
|
* (WebAssembly), which has no access to the machine: only the tools in the catalog, and only those whose effect is allowed.
|
|
33
|
-
* Calls made from the script do not pass through the hooks of the loop
|
|
34
|
-
*
|
|
41
|
+
* Calls made from the script do not pass through the hooks of the loop, so `authorize` (for example the review plugin's) is asked
|
|
42
|
+
* before each call. With no `authorize`, every call runs, as a direct call would with no review installed.
|
|
43
|
+
*
|
|
44
|
+
* The runtime only keeps the script itself away from the machine. The tools it calls (a shell command, a file write, an MCP tool) run outside the
|
|
45
|
+
* runtime with their full power, so a call from a script is exactly as risky as the same call made directly, and needs the same review.
|
|
35
46
|
*/
|
|
36
47
|
export declare function codemodePlugin(options: CodemodeOptions): Plugin;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getQuickJS } from "quickjs-emscripten";
|
|
2
|
-
|
|
2
|
+
/** The name of the tool that runs a script. Whoever needs to refer to it takes it from here. */
|
|
3
|
+
export const CODEMODE_TOOL = "run_code";
|
|
4
|
+
const DESCRIPTION = "Runs a short JavaScript program in an isolated runtime (no files, no network, no timers) and returns what it prints and returns. " +
|
|
3
5
|
"Inside the program, `tools.<name>(args)` calls a tool and returns a promise of its text; use `await`. `console.log` output is returned. " +
|
|
4
6
|
"Use it to combine several tool calls or to process their results without sending everything through the conversation. Available tools: ";
|
|
5
7
|
const PRELUDE = `
|
|
@@ -10,7 +12,7 @@ globalThis.tools = new Proxy({}, { get: (_t, name) => async (args) => {
|
|
|
10
12
|
} });
|
|
11
13
|
globalThis.console = { log: (...parts) => __print(parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")) };
|
|
12
14
|
`;
|
|
13
|
-
/** Deeper than this, the host's own stack overflows before the
|
|
15
|
+
/** Deeper than this, the host's own stack overflows before the runtime notices, which aborts the whole WebAssembly module; 256 KB still allows recursion about 1500 deep. */
|
|
14
16
|
const STACK_BYTES = 256 * 1024;
|
|
15
17
|
const text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError } : {}) });
|
|
16
18
|
const textOfContent = (content) => content.map((part) => (part.type === "text" ? part.text : `[${part.type}]`)).join("\n");
|
|
@@ -23,11 +25,14 @@ function cut(value, limit) {
|
|
|
23
25
|
/**
|
|
24
26
|
* Lets the model write one JavaScript program that calls tools, instead of one call per request. The program runs in QuickJS
|
|
25
27
|
* (WebAssembly), which has no access to the machine: only the tools in the catalog, and only those whose effect is allowed.
|
|
26
|
-
* Calls made from the script do not pass through the hooks of the loop
|
|
27
|
-
*
|
|
28
|
+
* Calls made from the script do not pass through the hooks of the loop, so `authorize` (for example the review plugin's) is asked
|
|
29
|
+
* before each call. With no `authorize`, every call runs, as a direct call would with no review installed.
|
|
30
|
+
*
|
|
31
|
+
* The runtime only keeps the script itself away from the machine. The tools it calls (a shell command, a file write, an MCP tool) run outside the
|
|
32
|
+
* runtime with their full power, so a call from a script is exactly as risky as the same call made directly, and needs the same review.
|
|
28
33
|
*/
|
|
29
34
|
export function codemodePlugin(options) {
|
|
30
|
-
const allowed = new Set(options.allowEffects ?? ["read"]);
|
|
35
|
+
const allowed = new Set(options.allowEffects ?? ["read", "write", "exec", "network", "irreversible"]);
|
|
31
36
|
const byName = new Map(options.catalog.map((tool) => [tool.name, tool]));
|
|
32
37
|
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
33
38
|
const memoryBytes = options.memoryBytes ?? 64 * 1024 * 1024;
|
|
@@ -42,7 +47,7 @@ export function codemodePlugin(options) {
|
|
|
42
47
|
try {
|
|
43
48
|
const args = JSON.parse(rawArgs);
|
|
44
49
|
if (options.authorize) {
|
|
45
|
-
const decision = await options.authorize({ id: `${context.callId}/${++calls}`, name, args }, { log: context.log, signal: context.signal, tool: (wanted) => byName.get(wanted) });
|
|
50
|
+
const decision = await options.authorize({ id: `${context.callId}/${++calls}`, name, args }, { log: context.log, signal: context.signal, tool: (wanted) => byName.get(wanted), ...(context.env?.cwd !== undefined ? { cwd: context.env.cwd } : {}) });
|
|
46
51
|
if (decision.kind === "deny")
|
|
47
52
|
return JSON.stringify({ error: decision.reason });
|
|
48
53
|
}
|
|
@@ -55,7 +60,7 @@ export function codemodePlugin(options) {
|
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
const runCode = {
|
|
58
|
-
name:
|
|
63
|
+
name: CODEMODE_TOOL,
|
|
59
64
|
description: DESCRIPTION + options.catalog.map((tool) => tool.name).join(", "),
|
|
60
65
|
parameters: { type: "object", properties: { code: { type: "string", description: "The body of an async function; use `return` for the result" } }, required: ["code"] },
|
|
61
66
|
effect: "exec",
|
|
@@ -68,13 +73,45 @@ export function codemodePlugin(options) {
|
|
|
68
73
|
const runtime = QuickJS.newRuntime();
|
|
69
74
|
runtime.setMemoryLimit(memoryBytes);
|
|
70
75
|
runtime.setMaxStackSize(STACK_BYTES);
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
// The script's own time is the time spent inside the runtime plus the time spent waiting while no tool call is under way.
|
|
77
|
+
// Waiting for a tool call is not counted, but a loop that runs while a call is under way is (it is counted as it runs).
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
let insideMs = 0;
|
|
80
|
+
let idleMs = 0;
|
|
81
|
+
let sliceStart = 0;
|
|
82
|
+
const used = () => insideMs + idleMs + (sliceStart > 0 ? Date.now() - sliceStart : 0);
|
|
83
|
+
const inRuntime = (work) => {
|
|
84
|
+
if (sliceStart > 0)
|
|
85
|
+
return work();
|
|
86
|
+
sliceStart = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
return work();
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
insideMs += Date.now() - sliceStart;
|
|
92
|
+
sliceStart = 0;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const overTotal = () => options.totalTimeoutMs !== undefined && Date.now() - startedAt > options.totalTimeoutMs;
|
|
96
|
+
runtime.setInterruptHandler(() => used() > timeoutMs || overTotal() || context.signal.aborted);
|
|
73
97
|
const vm = runtime.newContext();
|
|
74
98
|
const printed = [];
|
|
75
|
-
const
|
|
99
|
+
const unsettled = new Set(); // the calls that have not come back yet; the wait below must not include the ones that have, or it would not wait
|
|
76
100
|
const handles = [];
|
|
77
101
|
const deferreds = [];
|
|
102
|
+
// The calls that have not returned. Each has its own signal, joined to the run's: cancelling the run stops them all, and when the script
|
|
103
|
+
// ends the ones still under way are stopped. A call that has returned (a background command, say) is left to the run.
|
|
104
|
+
const underway = new Set();
|
|
105
|
+
const failure = (message) => text(printed.length > 0 ? cut(`${printed.join("\n")}\n${message}`, maxOutput) : message, true);
|
|
106
|
+
const late = () => {
|
|
107
|
+
if (context.signal.aborted)
|
|
108
|
+
return "The script was cancelled."; // the calls it was waiting for were stopped with the run, so the script may report their errors instead
|
|
109
|
+
if (used() > timeoutMs)
|
|
110
|
+
return `The script took longer than ${seconds(timeoutMs)} seconds and was stopped.`;
|
|
111
|
+
if (overTotal())
|
|
112
|
+
return `The script took longer than ${seconds(options.totalTimeoutMs ?? 0)} seconds in total and was stopped.`;
|
|
113
|
+
return undefined;
|
|
114
|
+
};
|
|
78
115
|
try {
|
|
79
116
|
const print = vm.newFunction("__print", (line) => {
|
|
80
117
|
printed.push(vm.getString(line));
|
|
@@ -86,54 +123,71 @@ export function codemodePlugin(options) {
|
|
|
86
123
|
deferreds.push(promise);
|
|
87
124
|
const name = vm.getString(nameHandle);
|
|
88
125
|
const rawArgs = vm.getString(argsHandle);
|
|
89
|
-
const
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const stop = () => controller.abort();
|
|
128
|
+
if (context.signal.aborted)
|
|
129
|
+
stop();
|
|
130
|
+
else
|
|
131
|
+
context.signal.addEventListener("abort", stop, { once: true }); // stays joined after the call returns, so a background command it started is still stopped with the run
|
|
132
|
+
underway.add(controller);
|
|
133
|
+
const work = callTool(name, rawArgs, { ...context, signal: controller.signal })
|
|
134
|
+
.then((reply) => {
|
|
90
135
|
if (!vm.alive)
|
|
91
136
|
return;
|
|
92
137
|
const value = vm.newString(reply);
|
|
93
138
|
promise.resolve(value);
|
|
94
139
|
value.dispose();
|
|
140
|
+
})
|
|
141
|
+
.catch(() => undefined)
|
|
142
|
+
.finally(() => {
|
|
143
|
+
underway.delete(controller);
|
|
144
|
+
unsettled.delete(work);
|
|
95
145
|
});
|
|
96
|
-
|
|
146
|
+
unsettled.add(work);
|
|
97
147
|
void promise.settled
|
|
98
148
|
.then(() => {
|
|
99
149
|
if (runtime.alive)
|
|
100
|
-
runtime.executePendingJobs(); // the script may be over and the
|
|
150
|
+
inRuntime(() => runtime.executePendingJobs()); // the script may be over and the runtime gone by now
|
|
101
151
|
})
|
|
102
152
|
.catch(() => undefined);
|
|
103
153
|
return promise.handle;
|
|
104
154
|
});
|
|
105
155
|
vm.setProp(vm.global, "__callTool", call);
|
|
106
156
|
call.dispose();
|
|
107
|
-
const prelude = vm.evalCode(PRELUDE);
|
|
157
|
+
const prelude = inRuntime(() => vm.evalCode(PRELUDE));
|
|
108
158
|
if (prelude.error) {
|
|
109
159
|
prelude.error.dispose();
|
|
110
|
-
return text("The
|
|
160
|
+
return text("The script runtime could not start.", true);
|
|
111
161
|
}
|
|
112
162
|
prelude.value.dispose();
|
|
113
|
-
const evaluated = vm.evalCode(`(async () => {\n${code}\n})()`);
|
|
163
|
+
const evaluated = inRuntime(() => vm.evalCode(`(async () => {\n${code}\n})()`));
|
|
114
164
|
if (evaluated.error) {
|
|
115
165
|
const message = vm.dump(evaluated.error);
|
|
116
166
|
evaluated.error.dispose();
|
|
117
|
-
return
|
|
167
|
+
return failure(late() ?? `Script error: ${formatError(message)}`);
|
|
118
168
|
}
|
|
119
169
|
handles.push(evaluated.value);
|
|
120
|
-
runtime.executePendingJobs();
|
|
170
|
+
inRuntime(() => runtime.executePendingJobs());
|
|
121
171
|
// the script is done when its promise settles; tool calls it started finish in between
|
|
122
172
|
let state = vm.getPromiseState(evaluated.value);
|
|
173
|
+
let tick = Date.now();
|
|
123
174
|
while (state.type === "pending") {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (underway.size === 0)
|
|
177
|
+
idleMs += now - tick;
|
|
178
|
+
tick = now;
|
|
179
|
+
const over = late();
|
|
180
|
+
if (over)
|
|
181
|
+
return failure(over);
|
|
182
|
+
await Promise.race([...unsettled, new Promise((resolve) => setTimeout(resolve, 20))]);
|
|
183
|
+
inRuntime(() => runtime.executePendingJobs());
|
|
130
184
|
state = vm.getPromiseState(evaluated.value);
|
|
131
185
|
}
|
|
132
186
|
const output = printed.length > 0 ? `${printed.join("\n")}\n` : "";
|
|
133
187
|
if (state.type === "rejected") {
|
|
134
188
|
const message = vm.dump(state.error);
|
|
135
189
|
state.error.dispose();
|
|
136
|
-
return
|
|
190
|
+
return failure(late() ?? `Script error: ${formatError(message)}`);
|
|
137
191
|
}
|
|
138
192
|
const value = vm.dump(state.value);
|
|
139
193
|
state.value.dispose();
|
|
@@ -142,9 +196,11 @@ export function codemodePlugin(options) {
|
|
|
142
196
|
return text(cut(combined === "" ? "(no output)" : combined, maxOutput));
|
|
143
197
|
}
|
|
144
198
|
catch (error) {
|
|
145
|
-
return
|
|
199
|
+
return failure(`Script failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
146
200
|
}
|
|
147
201
|
finally {
|
|
202
|
+
for (const controller of underway)
|
|
203
|
+
controller.abort(); // calls still under way when the script ends are stopped
|
|
148
204
|
for (const handle of handles)
|
|
149
205
|
if (handle.alive)
|
|
150
206
|
handle.dispose();
|
|
@@ -158,6 +214,8 @@ export function codemodePlugin(options) {
|
|
|
158
214
|
};
|
|
159
215
|
return { name: "codemode", compensates: "one tool call per model request is slow and fills the context with intermediate results", setup: (api) => api.registerTool(runCode) };
|
|
160
216
|
}
|
|
217
|
+
/** Seconds for an error message: a whole number when it is one, otherwise one decimal (0.3 and not 0). */
|
|
218
|
+
const seconds = (ms) => Number((ms / 1000).toFixed(1));
|
|
161
219
|
function formatError(dumped) {
|
|
162
220
|
if (typeof dumped === "object" && dumped !== null) {
|
|
163
221
|
const { name, message } = dumped;
|
|
@@ -2,5 +2,5 @@ export { searchPlugin } from "./search.js";
|
|
|
2
2
|
export type { SearchOptions } from "./search.js";
|
|
3
3
|
export { connectMcp } from "./mcp.js";
|
|
4
4
|
export type { McpConnection, McpServerConfig, McpServerStatus } from "./mcp.js";
|
|
5
|
-
export { codemodePlugin } from "./codemode.js";
|
|
5
|
+
export { CODEMODE_TOOL, codemodePlugin } from "./codemode.js";
|
|
6
6
|
export type { Authorize, CodemodeOptions } from "./codemode.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Plugin } from "../../../kernel/src/index.js";
|
|
2
|
+
/** One step of a run, tagged with the module that did the work. Written one per line to a trace file. */
|
|
3
|
+
export interface TraceRecord {
|
|
4
|
+
/** Milliseconds since the trace began. */
|
|
5
|
+
t: number;
|
|
6
|
+
/** Module id, matching the node ids of the architecture diagram. */
|
|
7
|
+
module: string;
|
|
8
|
+
/** What happened (the event type). */
|
|
9
|
+
fn: string;
|
|
10
|
+
/** A short detail, never message text. */
|
|
11
|
+
detail?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TraceOptions {
|
|
14
|
+
/** Append records to this file as JSON lines. */
|
|
15
|
+
file?: string;
|
|
16
|
+
/** Receive each record. */
|
|
17
|
+
sink?(record: TraceRecord): void;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
}
|
|
20
|
+
export interface TracePlugin extends Plugin {
|
|
21
|
+
records: TraceRecord[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Records what runs, for the module map. It only watches events, never changes anything the model sees,
|
|
25
|
+
* and keeps no message text. A development aid, off unless added.
|
|
26
|
+
*/
|
|
27
|
+
export declare function tracePlugin(options?: TraceOptions): TracePlugin;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { moduleOf } from "./modules.js";
|
|
4
|
+
/**
|
|
5
|
+
* Records what runs, for the module map. It only watches events, never changes anything the model sees,
|
|
6
|
+
* and keeps no message text. A development aid, off unless added.
|
|
7
|
+
*/
|
|
8
|
+
export function tracePlugin(options = {}) {
|
|
9
|
+
const now = options.now ?? Date.now;
|
|
10
|
+
const started = now();
|
|
11
|
+
const records = [];
|
|
12
|
+
if (options.file)
|
|
13
|
+
mkdirSync(dirname(options.file), { recursive: true });
|
|
14
|
+
return {
|
|
15
|
+
name: "trace",
|
|
16
|
+
compensates: "nothing: it only watches",
|
|
17
|
+
records,
|
|
18
|
+
setup(api) {
|
|
19
|
+
api.on("event", (event) => {
|
|
20
|
+
const { module, detail } = moduleOf(event);
|
|
21
|
+
const record = { t: now() - started, module, fn: event.type, ...(detail !== undefined ? { detail } : {}) };
|
|
22
|
+
records.push(record);
|
|
23
|
+
options.sink?.(record);
|
|
24
|
+
if (options.file)
|
|
25
|
+
appendFileSync(options.file, `${JSON.stringify(record)}\n`);
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Which module does the work behind each kind of event. */
|
|
2
|
+
export function moduleOf(event) {
|
|
3
|
+
switch (event.type) {
|
|
4
|
+
case "run_start":
|
|
5
|
+
case "run_end":
|
|
6
|
+
case "turn_start":
|
|
7
|
+
case "turn_end":
|
|
8
|
+
case "paused":
|
|
9
|
+
return { module: "kernel", ...(event.type === "run_end" ? { detail: event.reason } : event.type === "paused" ? { detail: event.reason } : {}) };
|
|
10
|
+
case "message_start":
|
|
11
|
+
case "message_update":
|
|
12
|
+
case "reasoning_update":
|
|
13
|
+
case "message_reset":
|
|
14
|
+
case "message_end":
|
|
15
|
+
return { module: "llm" };
|
|
16
|
+
case "tool_start":
|
|
17
|
+
case "tool_end":
|
|
18
|
+
return { module: "tools", detail: event.call.name };
|
|
19
|
+
case "compaction":
|
|
20
|
+
return { module: "compaction" };
|
|
21
|
+
case "goal":
|
|
22
|
+
return { module: "goal", detail: event.status };
|
|
23
|
+
case "signal":
|
|
24
|
+
return { module: "kernel", detail: "signal" };
|
|
25
|
+
case "plugin":
|
|
26
|
+
return { module: `plugin-${event.name}` };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import type { LogOptions, RewindableLog } from "./
|
|
1
|
+
import type { LogOptions, RewindableLog } from "./log-core.js";
|
|
2
2
|
export interface SessionHeader {
|
|
3
3
|
type: "header";
|
|
4
4
|
version: number;
|
|
5
5
|
id: string;
|
|
6
6
|
cwd: string;
|
|
7
7
|
ts: number;
|
|
8
|
+
/** Set on a session that was made by forking another: the entry it was copied up to, and the session it came from when that is known. */
|
|
9
|
+
forkedFrom?: {
|
|
10
|
+
entry: string;
|
|
11
|
+
session?: string;
|
|
12
|
+
};
|
|
8
13
|
}
|
|
9
14
|
export interface FileLog extends RewindableLog {
|
|
10
15
|
header: SessionHeader;
|