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.
Files changed (103) hide show
  1. package/README.md +207 -77
  2. package/README.zh-CN.md +208 -78
  3. package/THIRD_PARTY_NOTICES.md +8 -58
  4. package/dist/packages/agent/src/index.d.ts +49 -11
  5. package/dist/packages/agent/src/index.js +50 -55
  6. package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
  7. package/dist/packages/agent/src/restrict-tools.js +11 -0
  8. package/dist/packages/agent/src/system-prompt.d.ts +5 -0
  9. package/dist/packages/agent/src/system-prompt.js +1 -1
  10. package/dist/packages/agent/src/wake.d.ts +41 -0
  11. package/dist/packages/agent/src/wake.js +71 -0
  12. package/dist/packages/env/src/confine.d.ts +13 -0
  13. package/dist/packages/env/src/confine.js +40 -0
  14. package/dist/packages/env/src/connect-ssh.d.ts +45 -0
  15. package/dist/packages/env/src/connect-ssh.js +242 -0
  16. package/dist/packages/env/src/index.d.ts +6 -1
  17. package/dist/packages/env/src/index.js +3 -1
  18. package/dist/packages/env/src/local.d.ts +11 -2
  19. package/dist/packages/env/src/local.js +42 -10
  20. package/dist/packages/env/src/remote-stream.d.ts +48 -0
  21. package/dist/packages/env/src/remote-stream.js +143 -0
  22. package/dist/packages/env/src/remote-types.d.ts +17 -0
  23. package/dist/packages/env/src/remote-types.js +10 -0
  24. package/dist/packages/env/src/remote.d.ts +16 -7
  25. package/dist/packages/env/src/remote.js +73 -10
  26. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  27. package/dist/packages/evals/src/bfcl.js +71 -0
  28. package/dist/packages/evals/src/index.d.ts +3 -0
  29. package/dist/packages/evals/src/index.js +2 -0
  30. package/dist/packages/evals/src/png.d.ts +2 -0
  31. package/dist/packages/evals/src/png.js +30 -0
  32. package/dist/packages/evals/src/pool.d.ts +3 -0
  33. package/dist/packages/evals/src/pool.js +22 -0
  34. package/dist/packages/evals/src/providers.d.ts +13 -0
  35. package/dist/packages/evals/src/providers.js +23 -0
  36. package/dist/packages/kernel/src/compaction.d.ts +24 -3
  37. package/dist/packages/kernel/src/compaction.js +23 -16
  38. package/dist/packages/kernel/src/goal.d.ts +10 -1
  39. package/dist/packages/kernel/src/goal.js +28 -0
  40. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  41. package/dist/packages/kernel/src/hooks.js +21 -1
  42. package/dist/packages/kernel/src/index.d.ts +3 -2
  43. package/dist/packages/kernel/src/index.js +1 -0
  44. package/dist/packages/kernel/src/loop.d.ts +28 -4
  45. package/dist/packages/kernel/src/loop.js +69 -47
  46. package/dist/packages/kernel/src/types.d.ts +25 -4
  47. package/dist/packages/kernel/src/usage.d.ts +3 -0
  48. package/dist/packages/kernel/src/usage.js +16 -0
  49. package/dist/packages/llm/src/chat/errors.d.ts +11 -0
  50. package/dist/packages/llm/src/chat/errors.js +17 -0
  51. package/dist/packages/llm/src/chat/request.js +22 -4
  52. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  53. package/dist/packages/llm/src/chat/retry.js +1 -1
  54. package/dist/packages/llm/src/chat/stream.js +2 -1
  55. package/dist/packages/llm/src/index.js +7 -2
  56. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  57. package/dist/packages/plugins/context/src/index.js +2 -1
  58. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  59. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  60. package/dist/packages/plugins/redaction/src/index.js +3 -0
  61. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  62. package/dist/packages/plugins/review/src/chain.d.ts +10 -1
  63. package/dist/packages/plugins/review/src/chain.js +7 -4
  64. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  65. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  66. package/dist/packages/plugins/review/src/index.d.ts +21 -0
  67. package/dist/packages/plugins/review/src/index.js +42 -7
  68. package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
  69. package/dist/packages/plugins/review/src/reviewer.js +115 -27
  70. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  71. package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
  72. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  73. package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
  74. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  75. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  76. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  77. package/dist/packages/plugins/toolset/src/index.js +1 -1
  78. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  79. package/dist/packages/plugins/trace/src/index.js +29 -0
  80. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  81. package/dist/packages/plugins/trace/src/modules.js +28 -0
  82. package/dist/packages/session/src/file-log.d.ts +6 -1
  83. package/dist/packages/session/src/file-log.js +45 -25
  84. package/dist/packages/session/src/fork.d.ts +16 -0
  85. package/dist/packages/session/src/fork.js +70 -0
  86. package/dist/packages/session/src/index.d.ts +2 -0
  87. package/dist/packages/session/src/index.js +1 -0
  88. package/dist/packages/session/src/log-core.d.ts +37 -0
  89. package/dist/packages/session/src/log-core.js +47 -0
  90. package/dist/packages/session/src/memory-log.d.ts +3 -12
  91. package/dist/packages/session/src/memory-log.js +3 -23
  92. package/dist/packages/session/src/projection.js +22 -3
  93. package/dist/packages/testing/src/faux.d.ts +36 -0
  94. package/dist/packages/testing/src/faux.js +69 -0
  95. package/dist/packages/testing/src/index.d.ts +4 -0
  96. package/dist/packages/testing/src/index.js +2 -0
  97. package/dist/packages/testing/src/rng.d.ts +7 -0
  98. package/dist/packages/testing/src/rng.js +17 -0
  99. package/dist/sdk/index.d.ts +11 -3
  100. package/dist/sdk/index.js +5 -1
  101. package/dist/sdk/testing.d.ts +2 -0
  102. package/dist/sdk/testing.js +2 -0
  103. package/package.json +7 -2
@@ -1,5 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { posix } from "node:path";
3
+ import { RemoteConnectionError } from "./remote-types.js";
4
+ import { createStreamedSpawn } from "./remote-stream.js";
5
+ export { RemoteConnectionError } from "./remote-types.js";
3
6
  const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
4
7
  const CHUNK = 48_000;
5
8
  /**
@@ -8,7 +11,7 @@ const CHUNK = 48_000;
8
11
  */
9
12
  export function createRemoteEnvironment(options) {
10
13
  const root = posix.resolve("/", options.cwd);
11
- const workDir = posix.join(root, ".sakura", "remote");
14
+ const workDir = posix.resolve("/", options.workDir ?? "/tmp/.sakura-remote");
12
15
  const { run } = options;
13
16
  const resolvePath = (path) => (path.startsWith("/") ? posix.normalize(path) : posix.resolve(root, path));
14
17
  const running = new Set();
@@ -20,7 +23,8 @@ export function createRemoteEnvironment(options) {
20
23
  throw new Error(result.output.trim() || `command failed with exit code ${result.exitCode}`);
21
24
  return result.output;
22
25
  }
23
- function spawn(command, spawnOptions) {
26
+ const killing = new Map();
27
+ function spawnPolled(command, spawnOptions) {
24
28
  const id = `${prefix}-${++counter}`;
25
29
  const log = posix.join(workDir, `${id}.log`);
26
30
  const codeFile = posix.join(workDir, `${id}.code`);
@@ -28,16 +32,34 @@ export function createRemoteEnvironment(options) {
28
32
  let pid = 0;
29
33
  let finished = false;
30
34
  let result;
35
+ let startFailure;
31
36
  const script = `cd ${quote(spawnOptions.cwd ? resolvePath(spawnOptions.cwd) : root)} || exit 1\n${command}\n`;
32
37
  const started = run(`mkdir -p ${quote(workDir)} && echo ${Buffer.from(script).toString("base64")} | base64 -d > ${quote(scriptFile)} || exit $?; ` +
33
38
  `setsid bash -c ${quote(`bash ${quote(scriptFile)} > ${quote(log)} 2>&1; echo $? > ${quote(codeFile)}`)} > /dev/null 2>&1 & echo $!`, { cwd: root }).then((outcome) => {
34
39
  if (outcome.exitCode === 0)
35
40
  pid = Number.parseInt(outcome.output.trim().split("\n").at(-1) ?? "", 10) || 0;
41
+ }, (error) => {
42
+ if (!(error instanceof RemoteConnectionError))
43
+ throw error;
44
+ startFailure = error;
36
45
  });
46
+ // Stopping is done once, however often it is asked for, and can be waited for (dispose does).
37
47
  const kill = () => {
38
- void started.then(() => (pid > 0 ? run(`kill -TERM -- -${pid} 2>/dev/null; sleep 1; kill -KILL -- -${pid} 2>/dev/null; true`, { cwd: root }) : undefined)).catch(() => undefined);
48
+ if (killing.has(handle))
49
+ return;
50
+ killing.set(handle, started
51
+ .then(() => (pid > 0 ? run(`kill -TERM -- -${pid} 2>/dev/null; sleep 1; kill -KILL -- -${pid} 2>/dev/null; true`, { cwd: root }) : undefined))
52
+ .then(() => undefined, () => undefined));
39
53
  };
40
54
  spawnOptions.signal?.addEventListener("abort", kill, { once: true });
55
+ /** The connection failed and nothing can be said about how the command ended: say that, and where its output is. */
56
+ const lost = (error, what) => {
57
+ finished = true;
58
+ running.delete(handle);
59
+ return error instanceof RemoteConnectionError
60
+ ? new RemoteConnectionError(`${what} (${error.message}). The command may still be running; its output is being written to ${log} on the remote machine.`)
61
+ : error;
62
+ };
41
63
  let done;
42
64
  const handle = {
43
65
  get pid() {
@@ -52,6 +74,11 @@ export function createRemoteEnvironment(options) {
52
74
  };
53
75
  done = (async () => {
54
76
  await started;
77
+ if (startFailure) {
78
+ finished = true;
79
+ running.delete(handle);
80
+ 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.`);
81
+ }
55
82
  if (pid === 0) {
56
83
  finished = true;
57
84
  running.delete(handle);
@@ -60,10 +87,14 @@ export function createRemoteEnvironment(options) {
60
87
  for (let attempt = 0;; attempt++) {
61
88
  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
89
  cwd: root,
90
+ }).catch((error) => {
91
+ throw lost(error, "The connection to the remote machine was lost while waiting for the command");
63
92
  });
64
93
  const text = state.output.trim();
65
94
  if (text !== "running") {
66
- const output = await run(`cat ${quote(log)} 2>/dev/null`, { cwd: root });
95
+ const output = await run(`cat ${quote(log)} 2>/dev/null`, { cwd: root }).catch((error) => {
96
+ throw lost(error, "The connection to the remote machine was lost while reading the output of the command");
97
+ });
67
98
  const exitCode = /^\d+$/.test(text) ? Number(text) : null;
68
99
  finished = true;
69
100
  running.delete(handle);
@@ -73,11 +104,32 @@ export function createRemoteEnvironment(options) {
73
104
  await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 250 : 1000));
74
105
  }
75
106
  })();
107
+ done.catch(() => undefined); // a caller that never waits must not cause an unhandled rejection
76
108
  running.add(handle);
77
109
  return handle;
78
110
  }
111
+ const spawn = options.stream ? createStreamedSpawn({ stream: options.stream, run, root, workDir, resolvePath, nextId: () => `${prefix}-${++counter}`, running, killing }) : spawnPolled;
112
+ /** A command run to its end: started like any other, so stopping it (timeout, cancel) stops it on the other side too. */
113
+ async function execStreamed(command, execOptions) {
114
+ const handle = spawn(command, { ...(execOptions.cwd !== undefined ? { cwd: execOptions.cwd } : {}), ...(execOptions.signal ? { signal: execOptions.signal } : {}) });
115
+ let timedOut = false;
116
+ const timer = execOptions.timeoutMs !== undefined
117
+ ? setTimeout(() => {
118
+ timedOut = true;
119
+ handle.kill();
120
+ }, execOptions.timeoutMs)
121
+ : undefined;
122
+ try {
123
+ return { ...(await handle.wait()), timedOut };
124
+ }
125
+ finally {
126
+ clearTimeout(timer);
127
+ }
128
+ }
129
+ let disposed = false;
79
130
  return {
80
131
  cwd: root,
132
+ ...(options.description !== undefined ? { description: options.description } : {}),
81
133
  resolve: resolvePath,
82
134
  async readFile(path) {
83
135
  const output = await must(`set -o pipefail; base64 < ${quote(resolvePath(path))} | tr -d '\\n'`);
@@ -107,14 +159,25 @@ export function createRemoteEnvironment(options) {
107
159
  return output.split("\n").filter(Boolean).sort();
108
160
  },
109
161
  spawn,
110
- exec: (command, execOptions) => run(command, {
111
- cwd: execOptions.cwd ? resolvePath(execOptions.cwd) : root,
112
- ...(execOptions.timeoutMs !== undefined ? { timeoutMs: execOptions.timeoutMs } : {}),
113
- ...(execOptions.signal ? { signal: execOptions.signal } : {}),
114
- }),
162
+ exec: (command, execOptions) => options.stream
163
+ ? execStreamed(command, execOptions)
164
+ : run(command, {
165
+ cwd: execOptions.cwd ? resolvePath(execOptions.cwd) : root,
166
+ ...(execOptions.timeoutMs !== undefined ? { timeoutMs: execOptions.timeoutMs } : {}),
167
+ ...(execOptions.signal ? { signal: execOptions.signal } : {}),
168
+ }),
115
169
  async dispose() {
116
- for (const handle of [...running])
170
+ if (disposed)
171
+ return;
172
+ disposed = true;
173
+ const handles = [...running];
174
+ for (const handle of handles)
117
175
  handle.kill();
176
+ await Promise.all(handles.map((handle) => killing.get(handle)));
177
+ // the scripts, logs and exit codes of this session are of no use to anyone afterwards
178
+ if (counter > 0)
179
+ await run(`rm -f ${quote(posix.join(workDir, `${prefix}-`))}*`, { cwd: root }).catch(() => undefined);
180
+ await options.close?.();
118
181
  },
119
182
  };
120
183
  }
@@ -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,3 @@
1
+ export { liveProviders, apiKeyFor } from "./providers.js";
2
+ export type { LiveProvider } from "./providers.js";
3
+ export { solidPng } from "./png.js";
@@ -0,0 +1,2 @@
1
+ export { liveProviders, apiKeyFor } from "./providers.js";
2
+ export { solidPng } from "./png.js";
@@ -0,0 +1,2 @@
1
+ /** A solid-colour PNG, used to check that a model can really see an image. */
2
+ export declare function solidPng(width: number, height: number, [red, green, blue]: [number, number, number]): Buffer;
@@ -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
+ }
@@ -2,6 +2,11 @@ import type { LogEntry, Message } from "./types.js";
2
2
  export interface CompactionConfig {
3
3
  /** Compact when the context is within this many tokens of the model's window. */
4
4
  reserveTokens: number;
5
+ /**
6
+ * Compact when the free part of the window is smaller than this share of it (default 0.17, so at 83% full). The larger of this and
7
+ * `reserveTokens` applies. Left out or not a number between 0 and 1, it counts as 0.
8
+ */
9
+ reserveFraction?: number;
5
10
  /** Roughly how many recent tokens stay word for word after a compaction. */
6
11
  keepRecentTokens: number;
7
12
  }
@@ -10,6 +15,20 @@ export declare const defaultCompaction: CompactionConfig;
10
15
  export declare function estimateTokens(message: Message): number;
11
16
  /** Tokens in the context now: the last reported usage plus an estimate for what came after it. */
12
17
  export declare function contextTokens(messages: readonly Message[]): number;
18
+ /** How many tokens of the window must stay free: the larger of the fixed amount and the share of the window. */
19
+ export declare function reservedTokens(contextWindow: number, config: CompactionConfig): number;
20
+ /** How full the conversation is, for a host that shows it. */
21
+ export interface ContextStatus {
22
+ /** Tokens in the context now (see contextTokens). */
23
+ tokens: number;
24
+ /** The model's window. */
25
+ window: number;
26
+ /** What is left of the window; never below zero. */
27
+ free: number;
28
+ /** The size at which the conversation is compacted by itself; undefined when automatic compaction is off. */
29
+ compactAt: number | undefined;
30
+ }
31
+ export declare function contextStatus(messages: readonly Message[], window: number, config: CompactionConfig | undefined): ContextStatus;
13
32
  export declare function shouldCompact(messages: readonly Message[], contextWindow: number, config: CompactionConfig): boolean;
14
33
  export interface CompactionPlan {
15
34
  /** Id of the first entry that stays word for word. */
@@ -18,8 +37,10 @@ export interface CompactionPlan {
18
37
  splitTurn: boolean;
19
38
  }
20
39
  /**
21
- * Chooses where to cut. History before the cut is summarised, the rest stays. The cut is placed at a user
22
- * message where possible; it is never placed on a tool result, so a call and its results stay together.
23
- * Returns undefined when there is nothing worth summarising.
40
+ * Chooses where to cut. History before the cut is summarised, the rest stays. The cut is at the newest user or
41
+ * assistant message that keeps at least `keepRecentTokens`, so about that much stays word for word; it does not wait for a
42
+ * user message, because with user messages far apart that would keep far more (or, when the user message is the only
43
+ * boundary, nearly everything). A cut at an assistant message splits the turn (`splitTurn`). It is never placed on a tool
44
+ * result, so a call and its results stay together. Returns undefined when there is nothing worth summarising.
24
45
  */
25
46
  export declare function planCompaction(branch: readonly LogEntry[], config: CompactionConfig): CompactionPlan | undefined;
@@ -1,4 +1,4 @@
1
- export const defaultCompaction = { reserveTokens: 16_384, keepRecentTokens: 20_000 };
1
+ export const defaultCompaction = { reserveTokens: 16_384, reserveFraction: 0.17, keepRecentTokens: 20_000 };
2
2
  /** A rough token count: four characters per token, a flat cost for images. */
3
3
  export function estimateTokens(message) {
4
4
  let characters = 0;
@@ -27,13 +27,25 @@ export function contextTokens(messages) {
27
27
  const base = reported ? reported.input + reported.output + reported.cacheRead + reported.cacheWrite : 0;
28
28
  return messages.slice(lastUsage + 1).reduce((sum, message) => sum + estimateTokens(message), base);
29
29
  }
30
+ /** How many tokens of the window must stay free: the larger of the fixed amount and the share of the window. */
31
+ export function reservedTokens(contextWindow, config) {
32
+ const share = config.reserveFraction;
33
+ const fraction = typeof share === "number" && share > 0 && share < 1 ? share : 0;
34
+ return Math.max(config.reserveTokens, Math.ceil(contextWindow * fraction));
35
+ }
36
+ export function contextStatus(messages, window, config) {
37
+ const tokens = contextTokens(messages);
38
+ return { tokens, window, free: Math.max(0, window - tokens), compactAt: config ? window - reservedTokens(window, config) : undefined };
39
+ }
30
40
  export function shouldCompact(messages, contextWindow, config) {
31
- return messages.length > 1 && contextTokens(messages) > contextWindow - config.reserveTokens;
41
+ return messages.length > 1 && contextTokens(messages) > contextWindow - reservedTokens(contextWindow, config);
32
42
  }
33
43
  /**
34
- * Chooses where to cut. History before the cut is summarised, the rest stays. The cut is placed at a user
35
- * message where possible; it is never placed on a tool result, so a call and its results stay together.
36
- * Returns undefined when there is nothing worth summarising.
44
+ * Chooses where to cut. History before the cut is summarised, the rest stays. The cut is at the newest user or
45
+ * assistant message that keeps at least `keepRecentTokens`, so about that much stays word for word; it does not wait for a
46
+ * user message, because with user messages far apart that would keep far more (or, when the user message is the only
47
+ * boundary, nearly everything). A cut at an assistant message splits the turn (`splitTurn`). It is never placed on a tool
48
+ * result, so a call and its results stay together. Returns undefined when there is nothing worth summarising.
37
49
  */
38
50
  export function planCompaction(branch, config) {
39
51
  const start = (() => {
@@ -57,19 +69,14 @@ export function planCompaction(branch, config) {
57
69
  candidates.push({ index, tokensAfter: running, role: entry.message.role });
58
70
  }
59
71
  }
60
- // candidates run from newest to oldest; the first one with enough tokens after it is where we cut,
61
- // but never at the very first entry (that would summarise nothing)
72
+ // never at the very first entry (that would summarise nothing)
62
73
  const firstIndex = branch.findIndex((entry, index) => index >= start && entry.type === "message");
63
74
  const usable = candidates.filter((candidate) => candidate.index > firstIndex);
64
- const enough = usable.filter((candidate) => candidate.tokensAfter >= config.keepRecentTokens);
65
- const pool = enough.length > 0 ? enough : usable;
66
- if (pool.length === 0)
67
- return undefined;
68
- // prefer the newest user boundary among the candidates that keep at least keepRecentTokens; otherwise
69
- // the newest candidate that keeps enough; if only assistant boundaries exist the turn is split
70
- const byAge = [...pool].sort((a, b) => b.index - a.index);
71
- const userCut = byAge.find((candidate) => candidate.role === "user");
72
- const chosen = userCut ?? byAge[0];
75
+ // candidates run from newest to oldest, so the first one with enough tokens after it is the newest such
76
+ const chosen = usable.find((candidate) => candidate.tokensAfter >= config.keepRecentTokens) ??
77
+ // the whole visible history is smaller than keepRecentTokens (a manual compaction of a short conversation): the newest user message, else the newest message
78
+ usable.find((candidate) => candidate.role === "user") ??
79
+ usable[0];
73
80
  if (!chosen)
74
81
  return undefined;
75
82
  const entry = branch[chosen.index];
@@ -1,4 +1,4 @@
1
- import type { GoalRecord } from "./types.js";
1
+ import type { GoalRecord, Log, LogEntry } from "./types.js";
2
2
  export type ItemStatus = "done" | "todo" | "human";
3
3
  export interface ChecklistItem {
4
4
  status: ItemStatus;
@@ -24,3 +24,12 @@ export declare function judgeChecklist(items: readonly ChecklistItem[]): GoalVer
24
24
  export declare const BLOCKED_AFTER = 3;
25
25
  export declare function describeRemaining(items: readonly ChecklistItem[]): string;
26
26
  export declare const newGoal: (objective: string, verify?: string) => GoalRecord;
27
+ /** The goal of the conversation as the log says it is now: the last goal entry on the branch. */
28
+ export declare function currentGoal(branch: readonly LogEntry[]): GoalRecord | undefined;
29
+ /** Starts a goal: the record, and a pinned message that tells the model about it. A goal that was there before is replaced. The one place that writes a new goal. */
30
+ export declare function startGoal(log: Log, goal: {
31
+ objective: string;
32
+ verify?: string;
33
+ }, statement: string): Promise<void>;
34
+ /** Ends the goal by hand: it counts as complete and the model is no longer told about it. Does nothing when there is no goal or it has ended. */
35
+ export declare function endGoal(log: Log): Promise<void>;
@@ -35,3 +35,31 @@ export const newGoal = (objective, verify) => ({
35
35
  status: "active",
36
36
  humanStreak: 0,
37
37
  });
38
+ /** The goal of the conversation as the log says it is now: the last goal entry on the branch. */
39
+ export function currentGoal(branch) {
40
+ const entry = [...branch].reverse().find((candidate) => candidate.type === "goal");
41
+ return entry?.type === "goal" ? entry.goal : undefined;
42
+ }
43
+ /** Takes the earlier "your goal is ..." messages out of the context, so the model is told about one goal only. */
44
+ async function withdrawGoalMessages(log) {
45
+ const branch = await log.branch();
46
+ const withdrawn = new Set(branch.flatMap((entry) => (entry.type === "context_edit" ? [entry.targetId] : [])));
47
+ for (const entry of branch) {
48
+ if (entry.type === "custom_message" && entry.customType === "goal" && !withdrawn.has(entry.id))
49
+ await log.append({ type: "context_edit", targetId: entry.id, replacement: null });
50
+ }
51
+ }
52
+ /** Starts a goal: the record, and a pinned message that tells the model about it. A goal that was there before is replaced. The one place that writes a new goal. */
53
+ export async function startGoal(log, goal, statement) {
54
+ await withdrawGoalMessages(log);
55
+ await log.append({ type: "goal", goal: newGoal(goal.objective, goal.verify) });
56
+ await log.append({ type: "custom_message", customType: "goal", pinned: true, content: [{ type: "text", text: statement }] });
57
+ }
58
+ /** Ends the goal by hand: it counts as complete and the model is no longer told about it. Does nothing when there is no goal or it has ended. */
59
+ export async function endGoal(log) {
60
+ const goal = currentGoal(await log.branch());
61
+ if (!goal || goal.status === "complete")
62
+ return;
63
+ await withdrawGoalMessages(log);
64
+ await log.append({ type: "goal", goal: { ...goal, status: "complete", humanStreak: 0 } });
65
+ }
@@ -1,5 +1,10 @@
1
1
  import type { Decision, Event, EventTransform, HookContext, LlmRequest, Message, Plugin, ProviderError, Tool, ToolCall, ToolResult, TurnInfo } from "./types.js";
2
2
  import type { PauseReason } from "./types.js";
3
+ /**
4
+ * A short account of why a check failed, for the model: the first line of the message, at most 200 characters, no stack. The whole error goes to the
5
+ * `plugin` event for the person running the agent; the model needs enough to choose what to do next and no more.
6
+ */
7
+ export declare function describeFailure(error: unknown): string;
3
8
  export type ReportFailure = (plugin: string, hook: string, error: unknown) => void;
4
9
  export interface TurnEndOutcome {
5
10
  append: Message[];
@@ -1,4 +1,18 @@
1
1
  import { FatalHookError } from "./types.js";
2
+ /**
3
+ * A short account of why a check failed, for the model: the first line of the message, at most 200 characters, no stack. The whole error goes to the
4
+ * `plugin` event for the person running the agent; the model needs enough to choose what to do next and no more.
5
+ */
6
+ export function describeFailure(error) {
7
+ const message = error instanceof Error ? error.message : String(error);
8
+ const line = message
9
+ .split("\n")
10
+ .find((candidate) => candidate.trim() !== "")
11
+ ?.trim();
12
+ if (line === undefined)
13
+ return error instanceof Error ? error.name : "unknown error";
14
+ return line.length > 200 ? `${line.slice(0, 200)}…` : line;
15
+ }
2
16
  export function createHookSet(plugins, builtinTools, report, emitFromPlugin) {
3
17
  const registry = {
4
18
  context: [],
@@ -77,7 +91,13 @@ export function createHookSet(plugins, builtinTools, report, emitFromPlugin) {
77
91
  }
78
92
  catch (error) {
79
93
  report(plugin, "tool.before", error);
80
- return { decision: { kind: "deny", reason: `The check by '${plugin}' failed, so the call was not allowed.` }, args };
94
+ return {
95
+ decision: {
96
+ kind: "deny",
97
+ reason: `The check by '${plugin}' failed (${describeFailure(error)}), so the call was not allowed. Do not simply repeat the call; choose another approach, or tell the user what you need.`,
98
+ },
99
+ args,
100
+ };
81
101
  }
82
102
  }
83
103
  return { decision: { kind: "allow" }, args };
@@ -1,6 +1,7 @@
1
1
  export * from "./types.js";
2
2
  export { COMPACTION_PREFIX } from "./constants.js";
3
3
  export { createKernel } from "./loop.js";
4
- export type { Kernel, KernelConfig, RunHandle, RunResult, UserInput } from "./loop.js";
5
- export type { CompactionConfig } from "./compaction.js";
4
+ export { describeFailure } from "./hooks.js";
5
+ export type { Kernel, KernelConfig, RunHandle, RunOptions, RunResult, UserInput } from "./loop.js";
6
+ export type { CompactionConfig, ContextStatus } from "./compaction.js";
6
7
  export type { Prompts } from "./prompts.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./types.js";
2
2
  export { COMPACTION_PREFIX } from "./constants.js";
3
3
  export { createKernel } from "./loop.js";
4
+ export { describeFailure } from "./hooks.js";