sakura-agent 0.1.1 → 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.
@@ -0,0 +1,143 @@
1
+ import { posix } from "node:path";
2
+ import { RemoteConnectionError } from "./remote-types.js";
3
+ const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
4
+ /** Output kept from one command, as for a local one. */
5
+ const MAX_OUTPUT_CHARS = 20_000_000;
6
+ /** After a stop was asked for, how long the connection gets to report the end before the local child is killed. */
7
+ const STOP_GRACE_MS = 4000;
8
+ /** What the model sees of a command that was lost in the middle. */
9
+ const TAIL_LINES = 20;
10
+ /**
11
+ * The shell text one streamed command is sent as. It writes the script, starts it detached (own session, output into a log file, so it survives the
12
+ * connection and the log can be read afterwards), prints the process group id, then copies the log to its own output as the log grows. When the command
13
+ * has ended and the log is copied to the last byte, it prints the exit code after a marker. `wait` gives the exit code without anyone looking for it.
14
+ */
15
+ export function launcherFor(options) {
16
+ const { id, workDir } = options;
17
+ const script = posix.join(workDir, `${id}.sh`);
18
+ const log = posix.join(workDir, `${id}.log`);
19
+ const stop = posix.join(workDir, `${id}.stop`);
20
+ const body = `cd ${quote(options.cwd)} || exit 1\n${options.command}\n`;
21
+ const detached = `bash ${quote(script)} > ${quote(log)} 2>&1; code=$?; exit $code`;
22
+ return [
23
+ `mkdir -p ${quote(workDir)} && echo ${Buffer.from(body).toString("base64")} | base64 -d > ${quote(script)} && : > ${quote(log)} && rm -f ${quote(stop)} || exit 126`,
24
+ `setsid bash -c ${quote(detached)} < /dev/null > /dev/null 2>&1 &`,
25
+ "main=$$ job=$!",
26
+ `printf '@@${id}-pid %s\\n' "$job"`,
27
+ // copy the log as it grows; the end is known when the stop file exists (or the shell that started this is gone) AND nothing more was written after that was seen
28
+ `( sent=0; while :; do ended=0; [ -e ${quote(stop)} ] && ended=1; kill -0 "$main" 2>/dev/null || ended=1; size=$(wc -c < ${quote(log)}); if [ "$size" -gt "$sent" ]; then tail -c +$((sent+1)) ${quote(log)} | head -c $((size-sent)); sent=$size; continue; fi; [ $ended = 1 ] && break; sleep 0.05; done ) &`,
29
+ "follower=$!",
30
+ 'wait "$job"; code=$?',
31
+ `: > ${quote(stop)}`,
32
+ 'wait "$follower"',
33
+ `printf '\\n@@${id}-exit %s\\n' "$code"`,
34
+ // the command's own code is in the line above; the child ends with 0, so that an ssh that ends with 255 can only mean that the connection failed
35
+ "exit 0",
36
+ ].join("\n");
37
+ }
38
+ /** `kill` text for the group of a command: ask nicely, then insist. */
39
+ export const stopCommand = (pid) => `kill -TERM -- -${pid} 2>/dev/null; sleep 1; kill -KILL -- -${pid} 2>/dev/null; true`;
40
+ const lastLines = (text) => text.trimEnd().split("\n").slice(-TAIL_LINES).join("\n");
41
+ /**
42
+ * `spawn` for an environment that has a streamer. Each command is one child of the connection; the exit code is the end of that child's script, no
43
+ * file is looked at to find it; stopping a command stops its process group on the other side. When the connection is lost the command is NOT followed:
44
+ * it keeps running there (it is detached), and the error says that, where its output is, and that the model must stop and report it.
45
+ */
46
+ export function createStreamedSpawn(deps) {
47
+ return (command, spawnOptions) => {
48
+ const id = deps.nextId();
49
+ const log = posix.join(deps.workDir, `${id}.log`);
50
+ const pidMarker = `@@${id}-pid `;
51
+ const exitMarker = `\n@@${id}-exit `;
52
+ const local = new AbortController();
53
+ let pid = 0;
54
+ let finished = false;
55
+ let stopRequested = false;
56
+ let received = "";
57
+ let pidSeen;
58
+ const pidKnown = new Promise((resolve) => {
59
+ pidSeen = resolve;
60
+ });
61
+ const launcher = launcherFor({ id, command, cwd: spawnOptions.cwd ? deps.resolvePath(spawnOptions.cwd) : deps.root, workDir: deps.workDir });
62
+ const kill = () => {
63
+ if (deps.killing.has(handle))
64
+ return;
65
+ stopRequested = true;
66
+ deps.killing.set(handle, (async () => {
67
+ const ended = new Promise((resolve) => void done.then(() => resolve(), () => resolve()));
68
+ await Promise.race([pidKnown, ended]);
69
+ if (pid > 0 && !finished)
70
+ await deps.run(stopCommand(pid), { cwd: deps.root }).catch(() => undefined);
71
+ // the connection normally reports the end now; if it does not, the local child is stopped so that nobody waits for ever
72
+ const timer = setTimeout(() => local.abort(), STOP_GRACE_MS);
73
+ await ended;
74
+ clearTimeout(timer);
75
+ })());
76
+ };
77
+ spawnOptions.signal?.addEventListener("abort", kill, { once: true });
78
+ if (spawnOptions.signal?.aborted)
79
+ queueMicrotask(kill);
80
+ const lostMessage = (reason) => `The connection to the remote machine was lost while the command was running (${reason}). The command may still be running or may have finished; its output is being written to ${log} on the remote machine. ` +
81
+ "Stop here and tell the user that the connection was lost, and where that output file is. Do not reconnect or run the command again yourself." +
82
+ (received.trim() !== "" ? `\nLast output received:\n${lastLines(received)}` : "");
83
+ const handle = {
84
+ get pid() {
85
+ return pid;
86
+ },
87
+ outputFile: log,
88
+ wait: () => done,
89
+ kill,
90
+ get finished() {
91
+ return finished;
92
+ },
93
+ };
94
+ const done = (async () => {
95
+ const end = () => {
96
+ finished = true;
97
+ deps.running.delete(handle);
98
+ };
99
+ let outcome;
100
+ try {
101
+ outcome = await deps.stream(launcher, {
102
+ cwd: deps.root,
103
+ signal: local.signal,
104
+ onData: (text) => {
105
+ received += text;
106
+ if (received.length > MAX_OUTPUT_CHARS)
107
+ received = received.slice(received.length - MAX_OUTPUT_CHARS);
108
+ if (pid === 0 && received.startsWith(pidMarker) && received.includes("\n")) {
109
+ pid = Number.parseInt(received.slice(pidMarker.length, received.indexOf("\n")), 10) || 0;
110
+ received = received.slice(received.indexOf("\n") + 1);
111
+ pidSeen();
112
+ }
113
+ },
114
+ });
115
+ }
116
+ catch (error) {
117
+ end();
118
+ pidSeen();
119
+ if (!(error instanceof RemoteConnectionError))
120
+ throw error;
121
+ throw new RemoteConnectionError(pid === 0
122
+ ? `The connection to the remote machine failed while the command was being started, so it may or may not be running (${error.message}). If it is, its output is being written to ${log} on the remote machine. Stop here and tell the user.`
123
+ : lostMessage(error.message));
124
+ }
125
+ end();
126
+ pidSeen();
127
+ const at = received.lastIndexOf(exitMarker);
128
+ const trailer = at === -1 ? undefined : /^(\d+)\n$/.exec(received.slice(at + exitMarker.length));
129
+ if (at !== -1 && trailer) {
130
+ const code = Number(trailer[1]);
131
+ const output = received.slice(0, at);
132
+ // stopped on request: the number says which signal ended it, which nobody asked about
133
+ return { exitCode: stopRequested && code > 128 ? null : code, output: output.length > MAX_OUTPUT_CHARS ? output.slice(output.length - MAX_OUTPUT_CHARS) : output, timedOut: false };
134
+ }
135
+ if (pid === 0)
136
+ return { exitCode: null, output: `the command could not be started${outcome.stderr.trim() ? `: ${outcome.stderr.trim()}` : ""}`, timedOut: false };
137
+ throw new RemoteConnectionError(lostMessage(outcome.stderr.trim() || `the connection ended with ${outcome.exitCode === null ? "a signal" : `exit code ${outcome.exitCode}`}`));
138
+ })();
139
+ done.catch(() => undefined); // a caller that never waits must not cause an unhandled rejection
140
+ deps.running.add(handle);
141
+ return handle;
142
+ };
143
+ }
@@ -0,0 +1,17 @@
1
+ import type { ExecResult } from "../../kernel/src/index.js";
2
+ /**
3
+ * Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
4
+ * is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
5
+ */
6
+ export declare class RemoteConnectionError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /**
10
+ * The one thing a remote environment needs: a way to run a shell command somewhere else and get its output back.
11
+ * It resolves with the result of a command that ran, whatever its exit code, and rejects with a RemoteConnectionError when it could not reach the other side.
12
+ */
13
+ export type RemoteRunner = (command: string, options: {
14
+ cwd?: string;
15
+ timeoutMs?: number;
16
+ signal?: AbortSignal;
17
+ }) => Promise<ExecResult>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
3
+ * is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
4
+ */
5
+ export class RemoteConnectionError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "RemoteConnectionError";
9
+ }
10
+ }
@@ -1,20 +1,8 @@
1
- import type { Environment, ExecResult } from "../../kernel/src/index.js";
2
- /**
3
- * Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
4
- * is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
5
- */
6
- export declare class RemoteConnectionError extends Error {
7
- constructor(message: string);
8
- }
9
- /**
10
- * The one thing a remote environment needs: a way to run a shell command somewhere else and get its output back.
11
- * It resolves with the result of a command that ran, whatever its exit code, and rejects with a RemoteConnectionError when it could not reach the other side.
12
- */
13
- export type RemoteRunner = (command: string, options: {
14
- cwd?: string;
15
- timeoutMs?: number;
16
- signal?: AbortSignal;
17
- }) => Promise<ExecResult>;
1
+ import type { Environment } from "../../kernel/src/index.js";
2
+ import type { RemoteRunner } from "./remote-types.js";
3
+ import type { RemoteStreamer } from "./remote-stream.js";
4
+ export { RemoteConnectionError } from "./remote-types.js";
5
+ export type { RemoteRunner } from "./remote-types.js";
18
6
  /**
19
7
  * An environment on another machine (a container, say) reached only through `run`. Files are read and written
20
8
  * with base64 over the shell, and long commands are started detached so they can outlive a single call.
@@ -22,6 +10,11 @@ export type RemoteRunner = (command: string, options: {
22
10
  export declare function createRemoteEnvironment(options: {
23
11
  cwd: string;
24
12
  run: RemoteRunner;
13
+ /**
14
+ * Optional. With it, every command (`spawn` and `exec`) is one streamed child of the connection and its end is the end of that child; without it the
15
+ * environment starts commands detached through `run` and looks for their exit code from time to time, which is all a runner without a live channel can do.
16
+ */
17
+ stream?: RemoteStreamer;
25
18
  /** Called once when the environment is disposed, after its commands were stopped: close the connection the runner uses. */
26
19
  close?: () => Promise<void>;
27
20
  /** What the model is told about where its tools run (see Environment.description). */
@@ -1,15 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { posix } from "node:path";
3
- /**
4
- * Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
5
- * is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
6
- */
7
- export class RemoteConnectionError extends Error {
8
- constructor(message) {
9
- super(message);
10
- this.name = "RemoteConnectionError";
11
- }
12
- }
3
+ import { RemoteConnectionError } from "./remote-types.js";
4
+ import { createStreamedSpawn } from "./remote-stream.js";
5
+ export { RemoteConnectionError } from "./remote-types.js";
13
6
  const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
14
7
  const CHUNK = 48_000;
15
8
  /**
@@ -31,7 +24,7 @@ export function createRemoteEnvironment(options) {
31
24
  return result.output;
32
25
  }
33
26
  const killing = new Map();
34
- function spawn(command, spawnOptions) {
27
+ function spawnPolled(command, spawnOptions) {
35
28
  const id = `${prefix}-${++counter}`;
36
29
  const log = posix.join(workDir, `${id}.log`);
37
30
  const codeFile = posix.join(workDir, `${id}.code`);
@@ -115,6 +108,24 @@ export function createRemoteEnvironment(options) {
115
108
  running.add(handle);
116
109
  return handle;
117
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
+ }
118
129
  let disposed = false;
119
130
  return {
120
131
  cwd: root,
@@ -148,11 +159,13 @@ export function createRemoteEnvironment(options) {
148
159
  return output.split("\n").filter(Boolean).sort();
149
160
  },
150
161
  spawn,
151
- exec: (command, execOptions) => run(command, {
152
- cwd: execOptions.cwd ? resolvePath(execOptions.cwd) : root,
153
- ...(execOptions.timeoutMs !== undefined ? { timeoutMs: execOptions.timeoutMs } : {}),
154
- ...(execOptions.signal ? { signal: execOptions.signal } : {}),
155
- }),
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
+ }),
156
169
  async dispose() {
157
170
  if (disposed)
158
171
  return;
@@ -37,8 +37,10 @@ export interface CompactionPlan {
37
37
  splitTurn: boolean;
38
38
  }
39
39
  /**
40
- * Chooses where to cut. History before the cut is summarised, the rest stays. The cut is placed at a user
41
- * message where possible; it is never placed on a tool result, so a call and its results stay together.
42
- * 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.
43
45
  */
44
46
  export declare function planCompaction(branch: readonly LogEntry[], config: CompactionConfig): CompactionPlan | undefined;
@@ -41,9 +41,11 @@ export function shouldCompact(messages, contextWindow, config) {
41
41
  return messages.length > 1 && contextTokens(messages) > contextWindow - reservedTokens(contextWindow, config);
42
42
  }
43
43
  /**
44
- * Chooses where to cut. History before the cut is summarised, the rest stays. The cut is placed at a user
45
- * message where possible; it is never placed on a tool result, so a call and its results stay together.
46
- * 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.
47
49
  */
48
50
  export function planCompaction(branch, config) {
49
51
  const start = (() => {
@@ -67,19 +69,14 @@ export function planCompaction(branch, config) {
67
69
  candidates.push({ index, tokensAfter: running, role: entry.message.role });
68
70
  }
69
71
  }
70
- // candidates run from newest to oldest; the first one with enough tokens after it is where we cut,
71
- // but never at the very first entry (that would summarise nothing)
72
+ // never at the very first entry (that would summarise nothing)
72
73
  const firstIndex = branch.findIndex((entry, index) => index >= start && entry.type === "message");
73
74
  const usable = candidates.filter((candidate) => candidate.index > firstIndex);
74
- const enough = usable.filter((candidate) => candidate.tokensAfter >= config.keepRecentTokens);
75
- const pool = enough.length > 0 ? enough : usable;
76
- if (pool.length === 0)
77
- return undefined;
78
- // prefer the newest user boundary among the candidates that keep at least keepRecentTokens; otherwise
79
- // the newest candidate that keeps enough; if only assistant boundaries exist the turn is split
80
- const byAge = [...pool].sort((a, b) => b.index - a.index);
81
- const userCut = byAge.find((candidate) => candidate.role === "user");
82
- 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];
83
80
  if (!chosen)
84
81
  return undefined;
85
82
  const entry = branch[chosen.index];
@@ -1,6 +1,17 @@
1
1
  import { ProviderError } from "../../../kernel/src/index.js";
2
2
  /** Maps an HTTP error response to a ProviderError. */
3
3
  export declare function classifyHttpError(status: number, bodyText: string, headers: Headers): ProviderError;
4
+ /**
5
+ * An error sent inside a stream that already started (the HTTP status was 200). It is classified like the same error would be as an HTTP status:
6
+ * the status comes from the frame's `code` or `status`, or from a bracketed number in the text ("[500] EngineCore ..."). Without a status, a type
7
+ * that names a server-side failure counts as overloaded; anything else stays "other" and is not retried, because a request that is wrong would be wrong again.
8
+ */
9
+ export declare function classifyStreamError(error: {
10
+ message?: string;
11
+ code?: unknown;
12
+ status?: unknown;
13
+ type?: unknown;
14
+ }): ProviderError;
4
15
  export declare const isRetryable: (error: ProviderError) => boolean;
5
16
  /** The connection is open but nothing has arrived for too long. */
6
17
  export declare class StalledStreamError extends ProviderError {
@@ -34,6 +34,23 @@ export function classifyHttpError(status, bodyText, headers) {
34
34
  return new ProviderError("quota", message, status);
35
35
  return new ProviderError("invalid_request", message, status);
36
36
  }
37
+ const BRACKETED_STATUS = /\[(\d{3})\]/;
38
+ const SERVER_SIDE_TYPE = /server_error|overloaded|internal/i;
39
+ /**
40
+ * An error sent inside a stream that already started (the HTTP status was 200). It is classified like the same error would be as an HTTP status:
41
+ * the status comes from the frame's `code` or `status`, or from a bracketed number in the text ("[500] EngineCore ..."). Without a status, a type
42
+ * that names a server-side failure counts as overloaded; anything else stays "other" and is not retried, because a request that is wrong would be wrong again.
43
+ */
44
+ export function classifyStreamError(error) {
45
+ const message = error.message?.trim() || "the provider reported an error in the stream";
46
+ const given = Number(error.code ?? error.status);
47
+ const status = Number.isInteger(given) && given >= 400 && given < 600 ? given : Number(BRACKETED_STATUS.exec(message)?.[1]);
48
+ if (status >= 400 && status < 600)
49
+ return classifyHttpError(status, message, new Headers());
50
+ if (typeof error.type === "string" && SERVER_SIDE_TYPE.test(error.type))
51
+ return new ProviderError("overloaded", message);
52
+ return new ProviderError("other", message);
53
+ }
37
54
  export const isRetryable = (error) => error.kind === "rate_limit" || error.kind === "overloaded" || error.kind === "network";
38
55
  /** The connection is open but nothing has arrived for too long. */
39
56
  export class StalledStreamError extends ProviderError {
@@ -1,4 +1,5 @@
1
1
  import { ProviderError } from "../../../kernel/src/index.js";
2
+ import { classifyStreamError } from "./errors.js";
2
3
  /** Input excludes cached tokens, so the four numbers add up to what was processed. */
3
4
  export function normalizeUsage(raw) {
4
5
  const cached = raw.prompt_cache_hit_tokens ?? raw.prompt_tokens_details?.cached_tokens ?? 0;
@@ -62,7 +63,7 @@ export async function* parseChatStream(body, compat) {
62
63
  throw new ProviderError("network", "the provider sent a malformed stream chunk");
63
64
  }
64
65
  if (chunk.error)
65
- throw new ProviderError("other", chunk.error.message ?? "the provider reported an error in the stream");
66
+ throw classifyStreamError(chunk.error);
66
67
  if (chunk.usage)
67
68
  usage = normalizeUsage(chunk.usage);
68
69
  const choice = chunk.choices?.[0];
@@ -1,11 +1,15 @@
1
- import type { Tool, ToolCall } from "../../../kernel/src/index.js";
1
+ import type { Tool, ToolCall, Usage } from "../../../kernel/src/index.js";
2
2
  import type { Log } from "../../../kernel/src/index.js";
3
+ /** What an authorizer may report besides its answer: the tokens it spent, and (for a refusal) that the check itself failed rather than judged. */
3
4
  export type Verdict = {
4
5
  kind: "allow";
5
6
  reason?: string;
7
+ usage?: Usage;
6
8
  } | {
7
9
  kind: "deny";
8
10
  reason: string;
11
+ technical?: true;
12
+ usage?: Usage;
9
13
  } | {
10
14
  kind: "pass";
11
15
  };
@@ -25,6 +29,9 @@ export interface ChainResult {
25
29
  kind: "allow" | "deny";
26
30
  reason: string;
27
31
  decidedBy: string;
32
+ /** True when the check itself failed (an unreadable answer, a timeout, an exception): the action was not judged, and trying it again is fine. */
33
+ technical?: true;
34
+ usage?: Usage;
28
35
  }
29
36
  /**
30
37
  * Asks each authorizer in turn. The first answer that is not `pass` wins. The chain always ends in an answer:
@@ -14,12 +14,14 @@ export async function runChain(authorizers, call, context) {
14
14
  kind: "deny",
15
15
  reason: `The safety check '${authorizer.name}' failed (${describeFailure(error)}), so the action was not allowed.`,
16
16
  decidedBy: authorizer.name,
17
+ technical: true,
17
18
  };
18
19
  }
19
20
  if (verdict.kind === "allow")
20
- return { kind: "allow", reason: verdict.reason ?? "allowed", decidedBy: authorizer.name };
21
- if (verdict.kind === "deny")
22
- return { kind: "deny", reason: verdict.reason, decidedBy: authorizer.name };
21
+ return { kind: "allow", reason: verdict.reason ?? "allowed", decidedBy: authorizer.name, ...(verdict.usage ? { usage: verdict.usage } : {}) };
22
+ if (verdict.kind === "deny") {
23
+ return { kind: "deny", reason: verdict.reason, decidedBy: authorizer.name, ...(verdict.technical ? { technical: true } : {}), ...(verdict.usage ? { usage: verdict.usage } : {}) };
24
+ }
23
25
  }
24
26
  return { kind: "deny", reason: "No safety check approved this action.", decidedBy: "end-of-chain" };
25
27
  }
@@ -1,8 +1,10 @@
1
1
  import type { Decision, LlmClient, Log, Plugin, Tool, ToolCall } from "../../../kernel/src/index.js";
2
2
  import type { Authorizer } from "./chain.js";
3
3
  import type { PolicyInput } from "./policy.js";
4
+ import type { ReviewerLimits } from "./reviewer.js";
4
5
  export type { Authorizer, Verdict } from "./chain.js";
5
6
  export type { PolicyInput } from "./policy.js";
7
+ export type { ReviewerLimits } from "./reviewer.js";
6
8
  export interface BreakerOptions {
7
9
  /** Stop the run after this many refusals in a row. */
8
10
  consecutive?: number;
@@ -25,6 +27,12 @@ export interface ReviewOptions {
25
27
  /** Extra authorizers, asked after the hard rules and before the reviewer. */
26
28
  authorizers?: Authorizer[];
27
29
  timeoutMs?: number;
30
+ /**
31
+ * How big the reviewer's conversation may get and what goes into it (see ReviewerLimits; every field is optional). The conversation only grows, so the
32
+ * provider's cache works, until it passes `maxThreadTokens` (default 24000); then a new one starts with the goal, the project instructions, the latest
33
+ * user messages and a digest of the latest verdicts, which costs one cache miss. A value that is not a whole number of at least 1 throws here.
34
+ */
35
+ limits?: Partial<ReviewerLimits>;
28
36
  /** Off unless given. When it trips, refusals continue and the run is paused for the owner to look at. */
29
37
  breaker?: BreakerOptions;
30
38
  }
@@ -12,7 +12,7 @@ export function reviewPlugin(options) {
12
12
  const policy = resolvePolicy(options.policy);
13
13
  const states = new WeakMap();
14
14
  const fresh = () => ({ verdicts: new Map(), recent: [], consecutive: 0, tripped: false });
15
- const reviewer = createReviewer({ llm: options.llm, policy, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}) });
15
+ const reviewer = createReviewer({ llm: options.llm, policy, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.limits ? { limits: options.limits } : {}) });
16
16
  const chain = [createHardRules({ directoryRules: options.directoryRules !== false }), ...(options.authorizers ?? []), reviewer];
17
17
  function record(state, refused) {
18
18
  state.recent.push(refused);
@@ -25,6 +25,30 @@ export function reviewPlugin(options) {
25
25
  }
26
26
  let emit = () => undefined;
27
27
  const exempted = new Set();
28
+ /** Every decision is written to the conversation log as a `custom` entry (never shown to the model) and what the reviewer spent as a `usage` entry. */
29
+ async function writeRecord(log, call, result, technical, latencyMs, usage) {
30
+ try {
31
+ await log.append({
32
+ type: "custom",
33
+ customType: "review",
34
+ data: {
35
+ callId: call.id,
36
+ tool: call.name,
37
+ outcome: result.kind,
38
+ decidedBy: result.decidedBy,
39
+ reason: result.reason,
40
+ latencyMs,
41
+ ...(technical ? { technical } : {}),
42
+ ...(usage ? { usage } : {}),
43
+ },
44
+ });
45
+ if (usage && usage.input + usage.output + usage.cacheRead + usage.cacheWrite > 0)
46
+ await log.append({ type: "usage", kind: "review", usage });
47
+ }
48
+ catch {
49
+ // the decision matters more than its record; the same facts go out as a `review` event
50
+ }
51
+ }
28
52
  async function authorize(call, context) {
29
53
  const state = states.get(context.log) ?? fresh();
30
54
  states.set(context.log, state);
@@ -32,17 +56,24 @@ export function reviewPlugin(options) {
32
56
  return { kind: "deny", reason: "Too many actions were refused in this run, so further actions are blocked until the owner looks at it." };
33
57
  const key = `${call.name}\u0000${stable(call.args)}`;
34
58
  const cached = state.verdicts.get(key);
59
+ const started = Date.now();
35
60
  const result = cached
36
61
  ? { ...cached, decidedBy: "cache" }
37
62
  : await runChain(chain, call, { log: context.log, signal: context.signal, tool: context.tool, ...(context.cwd !== undefined ? { cwd: context.cwd } : {}) });
63
+ const technical = "technical" in result && result.technical === true;
38
64
  if (!cached) {
39
- if (result.decidedBy === "reviewer")
65
+ // a failed review is not a judgement: it is not remembered, so the next try asks again
66
+ if (result.decidedBy === "reviewer" && !technical)
40
67
  state.verdicts.set(key, { kind: result.kind, reason: result.reason });
41
68
  record(state, result.kind === "deny");
42
69
  }
43
- emit({ kind: "review", tool: call.name, callId: call.id, outcome: result.kind, decidedBy: result.decidedBy, reason: result.reason });
70
+ const usage = "usage" in result ? result.usage : undefined;
71
+ await writeRecord(context.log, call, result, technical, Date.now() - started, usage);
72
+ emit({ kind: "review", tool: call.name, callId: call.id, outcome: result.kind, decidedBy: result.decidedBy, reason: result.reason, ...(technical ? { technical } : {}) });
44
73
  if (result.kind === "allow")
45
74
  return { kind: "allow" };
75
+ if (technical)
76
+ return { kind: "deny", reason: `${result.reason} This was a failure of the review, not a decision about the action. Trying the same action again is fine; if it keeps failing, tell the user.` };
46
77
  return { kind: "deny", reason: `${result.reason} Do not try the same action again; choose another approach, or tell the user what you need.` };
47
78
  }
48
79
  return {
@@ -13,16 +13,45 @@ export interface Assessment {
13
13
  export declare function parseAssessment(text: string): Assessment | undefined;
14
14
  /** The outcome that follows from risk and authorization: low and medium pass, high needs clear authorization, critical never passes. */
15
15
  export declare function outcomeFor(risk: Risk, authorization: Authorization): "allow" | "deny";
16
+ /** The sizes that keep the reviewer's conversation small. All are numbers of characters unless the name says tokens. */
17
+ export interface ReviewerLimits {
18
+ /** When the reviewer's conversation is estimated to pass this many tokens, the next review starts a new one (default 24000). */
19
+ maxThreadTokens: number;
20
+ /** How many of the latest user messages a new conversation starts with (default 5). */
21
+ recentUserMessages: number;
22
+ /** How many of the latest earlier verdicts a new conversation starts with, one line each (default 10). */
23
+ digestVerdicts: number;
24
+ /** Cap for each user message and each tool call put into the conversation: head and tail, the middle left out (default 1200). */
25
+ entryChars: number;
26
+ /** Cap for the goal and the project instructions (default 4000). */
27
+ instructionChars: number;
28
+ /** Cap for the action under review (default 6000, head and tail). */
29
+ actionChars: number;
30
+ }
31
+ export declare const defaultReviewerLimits: ReviewerLimits;
32
+ /** The limits with the defaults filled in; throws when one is not a positive whole number (a mistake in the definition is found at the definition). */
33
+ export declare function resolveReviewerLimits(given?: Partial<ReviewerLimits>): ReviewerLimits;
34
+ /** At most `max` characters: the beginning and the end, with a note of how much was left out between them. */
35
+ export declare function shorten(text: string, max: number): string;
16
36
  /**
17
37
  * What the reviewer is told since the last time: the user's words, project instructions and goal, and the tool
18
38
  * calls the agent made. Tool results and the agent's own text are left out on purpose.
19
39
  */
20
- export declare function transcriptDelta(entries: readonly LogEntry[], from: number, skipCallId: string): string[];
40
+ export declare function transcriptDelta(entries: readonly LogEntry[], from: number, skipCallId: string, limits?: ReviewerLimits): string[];
41
+ /** One line per earlier verdict, from the `review` records in the log: the last `limit` of them, oldest first. A review that failed technically is not a verdict. */
42
+ export declare function verdictDigest(entries: readonly LogEntry[], limit: number): string[];
43
+ /**
44
+ * What a new conversation starts with: the active goal, the project instructions, the latest few user messages and the digest of the latest verdicts.
45
+ * Everything before is left out, and the reviewer is told so.
46
+ */
47
+ export declare function seedLines(entries: readonly LogEntry[], limits: ReviewerLimits): string[];
21
48
  export interface ReviewerOptions {
22
49
  llm: LlmClient;
23
50
  policy: PolicyLists;
24
- /** Default 90 seconds. A timeout counts as a refusal. */
51
+ /** Default 90 seconds for each try. A timeout counts as a technical failure. */
25
52
  timeoutMs?: number;
53
+ /** See ReviewerLimits. */
54
+ limits?: Partial<ReviewerLimits>;
26
55
  /** Called with every assessment, for logs and evaluation. */
27
56
  onAssessment?(info: {
28
57
  call: ToolCall;
@@ -32,7 +61,7 @@ export interface ReviewerOptions {
32
61
  }
33
62
  /**
34
63
  * A model asks "may this run?". One conversation per log is kept and only ever appended to, so the provider's
35
- * prompt cache keeps working. Anything unexpected (timeout, unreadable answer, error) is a refusal.
64
+ * prompt cache keeps working. Anything unexpected (timeout, unreadable answer, error) is tried once more and then refused, marked as a technical failure.
36
65
  */
37
66
  export declare function createReviewer(options: ReviewerOptions): Authorizer & {
38
67
  reset(log: Log): void;