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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { appendFile, mkdir, open, readFile, rm, truncate, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { createLog } from "./log-core.js";
|
|
5
5
|
import { createTree } from "./tree.js";
|
|
6
6
|
const FORMAT_VERSION = 1;
|
|
7
7
|
function isProcessAlive(pid) {
|
|
@@ -34,6 +34,7 @@ function parseFile(text) {
|
|
|
34
34
|
const complete = lines.slice(0, -1); // the last piece has no trailing newline: it is empty or a torn write
|
|
35
35
|
let header;
|
|
36
36
|
const entries = [];
|
|
37
|
+
let rewoundTo;
|
|
37
38
|
let validBytes = 0;
|
|
38
39
|
for (const [index, line] of complete.entries()) {
|
|
39
40
|
let value;
|
|
@@ -50,14 +51,18 @@ function parseFile(text) {
|
|
|
50
51
|
if (header.version > FORMAT_VERSION)
|
|
51
52
|
throw new Error(`session file version ${header.version} is newer than supported`);
|
|
52
53
|
}
|
|
54
|
+
else if (value.type === "rewind") {
|
|
55
|
+
rewoundTo = value.to;
|
|
56
|
+
}
|
|
53
57
|
else {
|
|
54
58
|
entries.push(value);
|
|
59
|
+
rewoundTo = undefined;
|
|
55
60
|
}
|
|
56
61
|
validBytes += Buffer.byteLength(line) + 1;
|
|
57
62
|
}
|
|
58
63
|
if (!header)
|
|
59
64
|
throw new Error("corrupt session file: missing header");
|
|
60
|
-
return { header, entries, validBytes };
|
|
65
|
+
return { header, entries, validBytes, ...(rewoundTo !== undefined ? { rewoundTo } : {}) };
|
|
61
66
|
}
|
|
62
67
|
/**
|
|
63
68
|
* One JSONL file per session: a header line, then one complete entry per line.
|
|
@@ -71,6 +76,8 @@ export async function openFileLog(path, options = {}) {
|
|
|
71
76
|
await acquireLock(lockPath);
|
|
72
77
|
let header;
|
|
73
78
|
let entries;
|
|
79
|
+
let rewoundTo;
|
|
80
|
+
let size; // bytes of the file that are known to be good
|
|
74
81
|
try {
|
|
75
82
|
const existing = await readFile(path, "utf8").catch((error) => {
|
|
76
83
|
if (error.code === "ENOENT")
|
|
@@ -79,15 +86,19 @@ export async function openFileLog(path, options = {}) {
|
|
|
79
86
|
});
|
|
80
87
|
if (existing === undefined || existing === "") {
|
|
81
88
|
header = { type: "header", version: FORMAT_VERSION, id: newId(), cwd: options.cwd ?? process.cwd(), ts: now() };
|
|
82
|
-
|
|
89
|
+
const line = `${JSON.stringify(header)}\n`;
|
|
90
|
+
await writeFile(path, line);
|
|
83
91
|
entries = [];
|
|
92
|
+
size = Buffer.byteLength(line);
|
|
84
93
|
}
|
|
85
94
|
else {
|
|
86
95
|
const parsed = parseFile(existing);
|
|
87
96
|
header = parsed.header;
|
|
88
97
|
entries = parsed.entries;
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
rewoundTo = parsed.rewoundTo;
|
|
99
|
+
size = parsed.validBytes;
|
|
100
|
+
if (size < Buffer.byteLength(existing))
|
|
101
|
+
await truncate(path, size);
|
|
91
102
|
}
|
|
92
103
|
}
|
|
93
104
|
catch (error) {
|
|
@@ -95,29 +106,38 @@ export async function openFileLog(path, options = {}) {
|
|
|
95
106
|
throw error;
|
|
96
107
|
}
|
|
97
108
|
const tree = createTree(entries);
|
|
98
|
-
|
|
109
|
+
if (rewoundTo !== undefined && (rewoundTo === null || entries.some((entry) => entry.id === rewoundTo)))
|
|
110
|
+
tree.moveLeaf(rewoundTo);
|
|
111
|
+
// A write that fails can leave half a line behind. The next write first cuts the file back to what is known to be good, so that half line
|
|
112
|
+
// never ends up in the middle of the file where it would make the whole session unreadable.
|
|
113
|
+
let damaged = false;
|
|
114
|
+
async function write(line) {
|
|
115
|
+
try {
|
|
116
|
+
if (damaged) {
|
|
117
|
+
await truncate(path, size);
|
|
118
|
+
damaged = false;
|
|
119
|
+
}
|
|
120
|
+
await appendFile(path, line);
|
|
121
|
+
size += Buffer.byteLength(line);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
damaged = true;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const log = createLog(tree, {
|
|
129
|
+
saveEntry: (entry) => write(`${JSON.stringify(entry)}\n`),
|
|
130
|
+
saveRewind: (to) => write(`${JSON.stringify({ type: "rewind", to })}\n`),
|
|
131
|
+
}, options);
|
|
99
132
|
return {
|
|
100
133
|
header,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return entry;
|
|
107
|
-
},
|
|
108
|
-
async branch() {
|
|
109
|
-
return tree.branch();
|
|
110
|
-
},
|
|
111
|
-
async project(upTo) {
|
|
112
|
-
const branch = tree.branch();
|
|
113
|
-
return projectMessages(upTo === undefined
|
|
114
|
-
? branch
|
|
115
|
-
: branch.slice(0, Math.max(0, branch.findIndex((entry) => entry.id === upTo))));
|
|
116
|
-
},
|
|
117
|
-
rewindTo: (entryId) => tree.moveLeaf(entryId),
|
|
118
|
-
leaf: () => tree.leaf(),
|
|
134
|
+
append: log.append,
|
|
135
|
+
branch: log.branch,
|
|
136
|
+
project: log.project,
|
|
137
|
+
rewindTo: log.rewindTo,
|
|
138
|
+
leaf: log.leaf,
|
|
119
139
|
async close() {
|
|
120
|
-
await
|
|
140
|
+
await log.settled();
|
|
121
141
|
await rm(lockPath, { force: true });
|
|
122
142
|
},
|
|
123
143
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Log } from "../../kernel/src/index.js";
|
|
2
|
+
import type { SessionHeader } from "./file-log.js";
|
|
3
|
+
export interface ForkOptions {
|
|
4
|
+
/** The last entry to copy, an id from `log.branch()`. Left out, the whole current branch is copied. */
|
|
5
|
+
upTo?: string;
|
|
6
|
+
/** The working directory to record in the new session. Default: this process's. */
|
|
7
|
+
cwd?: string;
|
|
8
|
+
/** The id of the session the log comes from, if it has one; recorded so that the new session says where it came from. */
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Copies the conversation up to a point into a new session file, which is then a session of its own: its entries are the same entries (same ids, same
|
|
13
|
+
* parents), so what the model is sent from it is exactly what it was sent from the original at that point, and a provider's prompt cache still applies.
|
|
14
|
+
* The source is only read. The file appears whole or not at all, and an existing file is never replaced.
|
|
15
|
+
*/
|
|
16
|
+
export declare function forkLog(log: Log, path: string, options?: ForkOptions): Promise<SessionHeader>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
/** Where to stop copying so that no turn is cut in two: after the results that answer the calls of the last assistant message that was copied. */
|
|
5
|
+
function endOfTurn(branch, last) {
|
|
6
|
+
let assistant = -1;
|
|
7
|
+
for (let index = last; index >= 0; index--) {
|
|
8
|
+
const entry = branch[index];
|
|
9
|
+
if (entry?.type === "message" && entry.message.role === "assistant") {
|
|
10
|
+
assistant = index;
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const opened = assistant >= 0 ? branch[assistant] : undefined;
|
|
15
|
+
if (opened?.type !== "message")
|
|
16
|
+
return last;
|
|
17
|
+
const open = new Set(opened.message.content.flatMap((part) => (part.type === "tool_call" ? [part.id] : [])));
|
|
18
|
+
for (let index = assistant + 1; index <= last; index++) {
|
|
19
|
+
const entry = branch[index];
|
|
20
|
+
if (entry?.type === "message" && entry.message.toolCallId)
|
|
21
|
+
open.delete(entry.message.toolCallId);
|
|
22
|
+
}
|
|
23
|
+
let end = last;
|
|
24
|
+
while (open.size > 0 && end + 1 < branch.length) {
|
|
25
|
+
const next = branch[end + 1];
|
|
26
|
+
if (next.type === "message" && next.message.role !== "tool")
|
|
27
|
+
break; // a new turn begins: calls that were never answered stay as they were in the source
|
|
28
|
+
end++;
|
|
29
|
+
if (next.type === "message" && next.message.toolCallId)
|
|
30
|
+
open.delete(next.message.toolCallId);
|
|
31
|
+
}
|
|
32
|
+
return end;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Copies the conversation up to a point into a new session file, which is then a session of its own: its entries are the same entries (same ids, same
|
|
36
|
+
* parents), so what the model is sent from it is exactly what it was sent from the original at that point, and a provider's prompt cache still applies.
|
|
37
|
+
* The source is only read. The file appears whole or not at all, and an existing file is never replaced.
|
|
38
|
+
*/
|
|
39
|
+
export async function forkLog(log, path, options = {}) {
|
|
40
|
+
const branch = await log.branch();
|
|
41
|
+
let last = branch.length - 1;
|
|
42
|
+
if (options.upTo !== undefined) {
|
|
43
|
+
last = branch.findIndex((entry) => entry.id === options.upTo);
|
|
44
|
+
if (last === -1)
|
|
45
|
+
throw new Error(`entry ${options.upTo} is not on the current branch`);
|
|
46
|
+
}
|
|
47
|
+
const copied = branch.slice(0, endOfTurn(branch, last) + 1);
|
|
48
|
+
const header = {
|
|
49
|
+
type: "header",
|
|
50
|
+
version: 1,
|
|
51
|
+
id: randomUUID().slice(0, 8),
|
|
52
|
+
cwd: options.cwd ?? process.cwd(),
|
|
53
|
+
ts: Date.now(),
|
|
54
|
+
...(copied.length > 0 ? { forkedFrom: { entry: copied.at(-1).id, ...(options.sessionId !== undefined ? { session: options.sessionId } : {}) } } : {}),
|
|
55
|
+
};
|
|
56
|
+
const text = [header, ...copied].map((line) => `${JSON.stringify(line)}\n`).join("");
|
|
57
|
+
await mkdir(dirname(path), { recursive: true });
|
|
58
|
+
const temporary = `${path}.${randomUUID().slice(0, 8)}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(temporary, text, { flag: "wx" });
|
|
61
|
+
// a hard link fails if the target exists, so a session that is there is never replaced, and the new one never shows up half written
|
|
62
|
+
await link(temporary, path).catch((error) => {
|
|
63
|
+
throw error.code === "EEXIST" ? new Error(`${path} already exists`) : error;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await unlink(temporary).catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
return header;
|
|
70
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { createMemoryLog } from "./memory-log.js";
|
|
2
2
|
export type { LogOptions, RewindableLog } from "./memory-log.js";
|
|
3
3
|
export { openFileLog } from "./file-log.js";
|
|
4
|
+
export { forkLog } from "./fork.js";
|
|
5
|
+
export type { ForkOptions } from "./fork.js";
|
|
4
6
|
export type { FileLog, SessionHeader } from "./file-log.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { EntryBody, Log, LogEntry } from "../../kernel/src/index.js";
|
|
2
|
+
import type { createTree } from "./tree.js";
|
|
3
|
+
export interface LogOptions {
|
|
4
|
+
now?: () => number;
|
|
5
|
+
newId?: () => string;
|
|
6
|
+
}
|
|
7
|
+
/** A session log that can go back to an earlier entry and carry on from there. */
|
|
8
|
+
export interface RewindableLog extends Log {
|
|
9
|
+
/** Moves the end of the conversation back to an earlier entry (or to the very start with null). Later entries start a new branch. */
|
|
10
|
+
rewindTo(entryId: string | null): void;
|
|
11
|
+
/** Id of the entry the next one will follow. */
|
|
12
|
+
leaf(): string | null;
|
|
13
|
+
}
|
|
14
|
+
/** Where a log keeps its entries. A memory log keeps them nowhere else. */
|
|
15
|
+
export interface Storage {
|
|
16
|
+
/** Makes the entry durable. If this fails the entry is not part of the log. */
|
|
17
|
+
saveEntry(entry: LogEntry): Promise<void>;
|
|
18
|
+
/** Records that the end of the conversation moved. Best effort: the next entry carries the same information, so a lost record only matters if nothing follows. */
|
|
19
|
+
saveRewind(to: string | null): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The one implementation of "a log": entries in a tree, one leaf, appends in order. The log is only as durable as its storage, and a log and its
|
|
23
|
+
* storage never disagree: an entry joins the tree after it is saved, and an append that fails changes nothing and does not stop the ones after it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createLog(tree: ReturnType<typeof createTree>, storage: Storage, options?: LogOptions): {
|
|
26
|
+
/** Resolves when everything started so far has been saved or has failed. */
|
|
27
|
+
settled: () => Promise<undefined>;
|
|
28
|
+
append(body: EntryBody): Promise<LogEntry>;
|
|
29
|
+
branch(): Promise<LogEntry[]>;
|
|
30
|
+
project(upTo?: string): Promise<import("../../kernel/src/index.js").Message[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Takes effect at once. Entries that were already waiting to be saved follow the new end, so do not rewind while appends are in flight
|
|
33
|
+
* (the kernel does not: one run at a time, and it waits for each append).
|
|
34
|
+
*/
|
|
35
|
+
rewindTo(entryId: string | null): void;
|
|
36
|
+
leaf: () => string | null;
|
|
37
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { projectMessages } from "./projection.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one implementation of "a log": entries in a tree, one leaf, appends in order. The log is only as durable as its storage, and a log and its
|
|
5
|
+
* storage never disagree: an entry joins the tree after it is saved, and an append that fails changes nothing and does not stop the ones after it.
|
|
6
|
+
*/
|
|
7
|
+
export function createLog(tree, storage, options = {}) {
|
|
8
|
+
const now = options.now ?? Date.now;
|
|
9
|
+
const newId = options.newId ?? (() => randomUUID().slice(0, 8));
|
|
10
|
+
// Appends run one after another, each starting from where the one before ended. A failed one is reported to its caller and not to the next.
|
|
11
|
+
let queue = Promise.resolve();
|
|
12
|
+
return {
|
|
13
|
+
/** Resolves when everything started so far has been saved or has failed. */
|
|
14
|
+
settled: () => queue.then(() => undefined, () => undefined),
|
|
15
|
+
append(body) {
|
|
16
|
+
const run = queue.then(async () => {
|
|
17
|
+
const entry = { ...body, id: newId(), parentId: tree.leaf(), ts: now() };
|
|
18
|
+
await storage.saveEntry(entry);
|
|
19
|
+
tree.add(entry);
|
|
20
|
+
return entry;
|
|
21
|
+
});
|
|
22
|
+
queue = run.catch(() => undefined);
|
|
23
|
+
return run;
|
|
24
|
+
},
|
|
25
|
+
async branch() {
|
|
26
|
+
return tree.branch();
|
|
27
|
+
},
|
|
28
|
+
async project(upTo) {
|
|
29
|
+
const branch = tree.branch();
|
|
30
|
+
return projectMessages(upTo === undefined
|
|
31
|
+
? branch
|
|
32
|
+
: branch.slice(0, Math.max(0, branch.findIndex((entry) => entry.id === upTo))));
|
|
33
|
+
},
|
|
34
|
+
/**
|
|
35
|
+
* Takes effect at once. Entries that were already waiting to be saved follow the new end, so do not rewind while appends are in flight
|
|
36
|
+
* (the kernel does not: one run at a time, and it waits for each append).
|
|
37
|
+
*/
|
|
38
|
+
rewindTo(entryId) {
|
|
39
|
+
const before = tree.leaf();
|
|
40
|
+
tree.moveLeaf(entryId);
|
|
41
|
+
if (entryId === before)
|
|
42
|
+
return;
|
|
43
|
+
queue = queue.then(() => storage.saveRewind(entryId)).catch(() => undefined);
|
|
44
|
+
},
|
|
45
|
+
leaf: () => tree.leaf(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
newId?: () => string;
|
|
5
|
-
}
|
|
6
|
-
/** A session log that can go back to an earlier entry and carry on from there. */
|
|
7
|
-
export interface RewindableLog extends Log {
|
|
8
|
-
/** Moves the end of the conversation back to an earlier entry (or to the very start with null). Later entries start a new branch. */
|
|
9
|
-
rewindTo(entryId: string | null): void;
|
|
10
|
-
/** Id of the entry the next one will follow. */
|
|
11
|
-
leaf(): string | null;
|
|
12
|
-
}
|
|
1
|
+
import type { LogEntry } from "../../kernel/src/index.js";
|
|
2
|
+
import type { LogOptions, RewindableLog } from "./log-core.js";
|
|
3
|
+
export type { LogOptions, RewindableLog } from "./log-core.js";
|
|
13
4
|
/** A log that lives only in memory (or wraps entries that were loaded elsewhere). */
|
|
14
5
|
export declare function createMemoryLog(options?: LogOptions & {
|
|
15
6
|
entries?: readonly LogEntry[];
|
|
@@ -1,27 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { projectMessages } from "./projection.js";
|
|
1
|
+
import { createLog } from "./log-core.js";
|
|
3
2
|
import { createTree } from "./tree.js";
|
|
4
3
|
/** A log that lives only in memory (or wraps entries that were loaded elsewhere). */
|
|
5
4
|
export function createMemoryLog(options = {}) {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const tree = createTree(options.entries ?? []);
|
|
9
|
-
return {
|
|
10
|
-
async append(body) {
|
|
11
|
-
const entry = { ...body, id: newId(), parentId: tree.leaf(), ts: now() };
|
|
12
|
-
tree.add(entry);
|
|
13
|
-
return entry;
|
|
14
|
-
},
|
|
15
|
-
async branch() {
|
|
16
|
-
return tree.branch();
|
|
17
|
-
},
|
|
18
|
-
async project(upTo) {
|
|
19
|
-
const branch = tree.branch();
|
|
20
|
-
return projectMessages(upTo === undefined
|
|
21
|
-
? branch
|
|
22
|
-
: branch.slice(0, Math.max(0, branch.findIndex((entry) => entry.id === upTo))));
|
|
23
|
-
},
|
|
24
|
-
rewindTo: (entryId) => tree.moveLeaf(entryId),
|
|
25
|
-
leaf: () => tree.leaf(),
|
|
26
|
-
};
|
|
5
|
+
const { settled: _settled, ...log } = createLog(createTree(options.entries ?? []), { saveEntry: async () => undefined, saveRewind: async () => undefined }, options);
|
|
6
|
+
return log;
|
|
27
7
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { COMPACTION_PREFIX } from "../../kernel/src/index.js";
|
|
2
|
+
/** The message that tells the model about its goal. It is the one pinned message that does not sit in front: see `projectMessages`. */
|
|
3
|
+
const isGoalStatement = (entry) => entry?.type === "custom_message" && entry.customType === "goal";
|
|
2
4
|
function entryToMessage(entry) {
|
|
3
5
|
if (entry.type === "message")
|
|
4
6
|
return entry.message;
|
|
@@ -55,25 +57,42 @@ function orderToolResults(messages) {
|
|
|
55
57
|
}
|
|
56
58
|
return ordered;
|
|
57
59
|
}
|
|
60
|
+
/** The goal statement still in force that lies before `start` (so the compaction took it out of the visible history), as a message. */
|
|
61
|
+
function goalCompactedAway(branch, start, edits) {
|
|
62
|
+
for (let index = start - 1; index >= 0; index--) {
|
|
63
|
+
const entry = branch[index];
|
|
64
|
+
if (!entry || !isGoalStatement(entry))
|
|
65
|
+
continue;
|
|
66
|
+
return edits.get(entry.id) === null ? undefined : entryToMessage(entry);
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
58
70
|
export function projectMessages(branch) {
|
|
59
71
|
const { start, summary, compactedAt } = findStart(branch);
|
|
60
72
|
const edits = collectEdits(branch);
|
|
61
73
|
const messages = [];
|
|
62
|
-
// Pinned entries (project instructions) stay in front even after compaction.
|
|
74
|
+
// Pinned entries (project instructions) stay in front even after compaction. They are written once, at the start, so the front never changes.
|
|
75
|
+
// The goal is pinned too, but it is set in the middle of a conversation: in front it would change everything after it and cost the provider's
|
|
76
|
+
// prompt cache. It stays where it was written, and after a compaction it follows the summary.
|
|
63
77
|
for (let index = 0; index < branch.length; index++) {
|
|
64
78
|
const entry = branch[index];
|
|
65
|
-
if (entry?.type !== "custom_message" || !entry.pinned)
|
|
79
|
+
if (entry?.type !== "custom_message" || !entry.pinned || isGoalStatement(entry))
|
|
66
80
|
continue;
|
|
81
|
+
if (edits.get(entry.id) === null)
|
|
82
|
+
continue; // a pinned message can be withdrawn too
|
|
67
83
|
const message = entryToMessage(entry);
|
|
68
84
|
if (message)
|
|
69
85
|
messages.push(message);
|
|
70
86
|
}
|
|
71
87
|
if (summary !== undefined) {
|
|
72
88
|
messages.push({ role: "user", content: [{ type: "text", text: `${COMPACTION_PREFIX}\n\n${summary}` }], ts: 0 });
|
|
89
|
+
const carried = goalCompactedAway(branch, start, edits);
|
|
90
|
+
if (carried)
|
|
91
|
+
messages.push(carried);
|
|
73
92
|
}
|
|
74
93
|
for (let index = start; index < branch.length; index++) {
|
|
75
94
|
const entry = branch[index];
|
|
76
|
-
if (!entry || (entry.type === "custom_message" && entry.pinned))
|
|
95
|
+
if (!entry || (entry.type === "custom_message" && entry.pinned && !isGoalStatement(entry)))
|
|
77
96
|
continue;
|
|
78
97
|
const message = entryToMessage(entry);
|
|
79
98
|
if (!message)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LlmClient, LlmRequest, ProviderError, StopReason, Usage } from "../../kernel/src/index.js";
|
|
2
|
+
export interface FauxTurn {
|
|
3
|
+
text?: string;
|
|
4
|
+
reasoning?: string;
|
|
5
|
+
toolCalls?: Array<{
|
|
6
|
+
name: string;
|
|
7
|
+
args: unknown;
|
|
8
|
+
id?: string;
|
|
9
|
+
}>;
|
|
10
|
+
/** Cut the output off in the middle of this tool call (finish_reason "length"). */
|
|
11
|
+
truncatedToolCall?: {
|
|
12
|
+
name: string;
|
|
13
|
+
id?: string;
|
|
14
|
+
};
|
|
15
|
+
usage?: Partial<Usage>;
|
|
16
|
+
stop?: StopReason;
|
|
17
|
+
/** Fail the request instead of answering. */
|
|
18
|
+
error?: ProviderError;
|
|
19
|
+
/** Never answer; wait until the request is aborted. */
|
|
20
|
+
hang?: boolean;
|
|
21
|
+
/** Send this text, then a restart (as an adapter does after a dropped connection), then the real reply. */
|
|
22
|
+
droppedFirst?: string;
|
|
23
|
+
/** Split text into pieces of this many characters (default: one piece). */
|
|
24
|
+
chunkSize?: number;
|
|
25
|
+
}
|
|
26
|
+
export type FauxScript = FauxTurn[] | ((request: LlmRequest, index: number) => FauxTurn);
|
|
27
|
+
export interface FauxClient extends LlmClient {
|
|
28
|
+
/** Every request received, in order. */
|
|
29
|
+
requests: LlmRequest[];
|
|
30
|
+
}
|
|
31
|
+
export interface FauxOptions {
|
|
32
|
+
contextWindow?: number;
|
|
33
|
+
supportsImages?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** A scripted stand-in for a model: replays the given turns, one per request. */
|
|
36
|
+
export declare function createFauxClient(script: FauxScript, options?: FauxOptions): FauxClient;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function abortError() {
|
|
2
|
+
const error = new Error("aborted");
|
|
3
|
+
error.name = "AbortError";
|
|
4
|
+
return error;
|
|
5
|
+
}
|
|
6
|
+
function waitForAbort(signal) {
|
|
7
|
+
return new Promise((_, reject) => {
|
|
8
|
+
if (signal.aborted)
|
|
9
|
+
reject(abortError());
|
|
10
|
+
signal.addEventListener("abort", () => reject(abortError()), { once: true });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function estimateTokens(value) {
|
|
14
|
+
return Math.ceil(JSON.stringify(value).length / 4);
|
|
15
|
+
}
|
|
16
|
+
function* pieces(value, size) {
|
|
17
|
+
if (!size || size >= value.length) {
|
|
18
|
+
yield value;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (let start = 0; start < value.length; start += size)
|
|
22
|
+
yield value.slice(start, start + size);
|
|
23
|
+
}
|
|
24
|
+
/** A scripted stand-in for a model: replays the given turns, one per request. */
|
|
25
|
+
export function createFauxClient(script, options = {}) {
|
|
26
|
+
const requests = [];
|
|
27
|
+
let callCount = 0;
|
|
28
|
+
return {
|
|
29
|
+
requests,
|
|
30
|
+
model: { id: "faux", contextWindow: options.contextWindow ?? 100_000, supportsImages: options.supportsImages ?? true },
|
|
31
|
+
async *stream(request, signal) {
|
|
32
|
+
requests.push(request);
|
|
33
|
+
const index = callCount++;
|
|
34
|
+
const turn = typeof script === "function" ? script(request, index) : script[index];
|
|
35
|
+
if (!turn)
|
|
36
|
+
throw new Error(`faux script has no turn for request ${index + 1}`);
|
|
37
|
+
if (signal.aborted)
|
|
38
|
+
throw abortError();
|
|
39
|
+
if (turn.error)
|
|
40
|
+
throw turn.error;
|
|
41
|
+
if (turn.hang)
|
|
42
|
+
await waitForAbort(signal);
|
|
43
|
+
if (turn.droppedFirst) {
|
|
44
|
+
yield { type: "text_delta", delta: turn.droppedFirst };
|
|
45
|
+
yield { type: "restart" };
|
|
46
|
+
}
|
|
47
|
+
if (turn.reasoning)
|
|
48
|
+
yield { type: "reasoning_delta", delta: turn.reasoning };
|
|
49
|
+
if (turn.text)
|
|
50
|
+
for (const piece of pieces(turn.text, turn.chunkSize))
|
|
51
|
+
yield { type: "text_delta", delta: piece };
|
|
52
|
+
let toolCount = 0;
|
|
53
|
+
for (const call of turn.toolCalls ?? []) {
|
|
54
|
+
toolCount++;
|
|
55
|
+
yield { type: "tool_call", call: { id: call.id ?? `call_${index + 1}_${toolCount}`, name: call.name, args: call.args } };
|
|
56
|
+
}
|
|
57
|
+
if (turn.truncatedToolCall) {
|
|
58
|
+
yield { type: "tool_call_truncated", id: turn.truncatedToolCall.id ?? `call_${index + 1}_cut`, name: turn.truncatedToolCall.name };
|
|
59
|
+
}
|
|
60
|
+
const output = estimateTokens([turn.text, turn.reasoning, turn.toolCalls]);
|
|
61
|
+
yield {
|
|
62
|
+
type: "usage",
|
|
63
|
+
usage: { input: estimateTokens(request.messages), output, cacheRead: 0, cacheWrite: 0, ...turn.usage },
|
|
64
|
+
};
|
|
65
|
+
const stop = turn.stop ?? (turn.truncatedToolCall ? "length" : toolCount > 0 ? "tool_use" : "stop");
|
|
66
|
+
yield { type: "done", stop };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Small seeded random generator (mulberry32) so generated test cases are reproducible.
|
|
2
|
+
export function createRng(seed) {
|
|
3
|
+
let state = seed >>> 0;
|
|
4
|
+
const next = () => {
|
|
5
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
6
|
+
let t = state;
|
|
7
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
8
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
9
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
next,
|
|
13
|
+
int: (min, max) => min + Math.floor(next() * (max - min + 1)),
|
|
14
|
+
pick: (items) => items[Math.floor(next() * items.length)],
|
|
15
|
+
chance: (probability) => next() < probability,
|
|
16
|
+
};
|
|
17
|
+
}
|
package/dist/sdk/index.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
export { buildSystemPrompt, bundle, compose, CompositionError, corePlugins, createAgent } from "../packages/agent/src/index.js";
|
|
2
2
|
export type { Agent, AgentConfig, Composable, ModelConfig } from "../packages/agent/src/index.js";
|
|
3
|
-
export { createLocalEnvironment, createRemoteEnvironment } from "../packages/env/src/index.js";
|
|
4
|
-
export type { RemoteRunner } from "../packages/env/src/index.js";
|
|
5
|
-
export type { Content, Environment, Event, LlmClient, Plugin, RunHandle, RunResult, Tool, ToolContext, ToolResult } from "../packages/kernel/src/index.js";
|
|
3
|
+
export { createLocalEnvironment, createRemoteEnvironment, createSshEnvironment, OutsideDirectoryError, RemoteConnectionError } from "../packages/env/src/index.js";
|
|
4
|
+
export type { LocalOptions, RemoteRunner, SshOptions } from "../packages/env/src/index.js";
|
|
5
|
+
export type { CompactionConfig, Content, ContextStatus, Decision, EntryBody, Environment, Event, EventTransform, ExecResult, FileInfo, GoalRecord, GoalStatus, HookContext, HookName, Hooks, LlmClient, LlmEvent, LlmRequest, Log, LogEntry, Message, ModelInfo, PauseReason, Plugin, PluginApi, PrepareContext, ProcessHandle, Prompts, ProviderErrorKind, Role, RunEndReason, RunHandle, RunOptions, RunResult, StopReason, ThinkingLevel, Tool, ToolCall, ToolContext, ToolDecl, ToolEffect, ToolResult, TurnInfo, Usage, UsageTotals, UserInput, } from "../packages/kernel/src/index.js";
|
|
6
|
+
export { FatalHookError, ProviderError } from "../packages/kernel/src/index.js";
|
|
6
7
|
export { compatPresets, createChatClient } from "../packages/llm/src/index.js";
|
|
7
8
|
export type { ChatClientOptions, ChatCompat } from "../packages/llm/src/index.js";
|
|
9
|
+
export { contextPlugin } from "../packages/plugins/context/src/index.js";
|
|
10
|
+
export type { ContextOptions, ContextPlugin } from "../packages/plugins/context/src/index.js";
|
|
8
11
|
export { limitsPlugin } from "../packages/plugins/limits/src/index.js";
|
|
12
|
+
export { resiliencePlugin } from "../packages/plugins/resilience/src/index.js";
|
|
13
|
+
export type { FixOptions, ResilienceOptions } from "../packages/plugins/resilience/src/index.js";
|
|
9
14
|
export { redactionPlugin } from "../packages/plugins/redaction/src/index.js";
|
|
10
15
|
export { reviewPlugin } from "../packages/plugins/review/src/index.js";
|
|
16
|
+
export type { McpServerConfig, McpServerStatus } from "../packages/plugins/toolset/src/index.js";
|
|
17
|
+
export { toolsPlugin } from "../packages/plugins/tools/src/index.js";
|
|
18
|
+
export type { ToolsPluginOptions } from "../packages/plugins/tools/src/index.js";
|
package/dist/sdk/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { buildSystemPrompt, bundle, compose, CompositionError, corePlugins, createAgent } from "../packages/agent/src/index.js";
|
|
2
|
-
export { createLocalEnvironment, createRemoteEnvironment } from "../packages/env/src/index.js";
|
|
2
|
+
export { createLocalEnvironment, createRemoteEnvironment, createSshEnvironment, OutsideDirectoryError, RemoteConnectionError } from "../packages/env/src/index.js";
|
|
3
|
+
export { FatalHookError, ProviderError } from "../packages/kernel/src/index.js";
|
|
3
4
|
export { compatPresets, createChatClient } from "../packages/llm/src/index.js";
|
|
5
|
+
export { contextPlugin } from "../packages/plugins/context/src/index.js";
|
|
4
6
|
export { limitsPlugin } from "../packages/plugins/limits/src/index.js";
|
|
7
|
+
export { resiliencePlugin } from "../packages/plugins/resilience/src/index.js";
|
|
5
8
|
export { redactionPlugin } from "../packages/plugins/redaction/src/index.js";
|
|
6
9
|
export { reviewPlugin } from "../packages/plugins/review/src/index.js";
|
|
10
|
+
export { toolsPlugin } from "../packages/plugins/tools/src/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sakura-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"example": "node examples/quickstart.ts",
|
|
24
24
|
"build": "node scripts/build-sdk.mjs",
|
|
25
25
|
"prepack": "npm run build",
|
|
26
|
-
"example:tools": "node examples/tools.ts"
|
|
26
|
+
"example:tools": "node examples/tools.ts",
|
|
27
|
+
"example:fleet": "node examples/fleet.ts"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@biomejs/biome": "^2.6.4",
|
|
@@ -55,6 +56,10 @@
|
|
|
55
56
|
".": {
|
|
56
57
|
"types": "./dist/sdk/index.d.ts",
|
|
57
58
|
"import": "./dist/sdk/index.js"
|
|
59
|
+
},
|
|
60
|
+
"./testing": {
|
|
61
|
+
"types": "./dist/sdk/testing.d.ts",
|
|
62
|
+
"import": "./dist/sdk/testing.js"
|
|
58
63
|
}
|
|
59
64
|
},
|
|
60
65
|
"types": "./dist/sdk/index.d.ts",
|