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,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
|
};
|
|
@@ -13,6 +17,8 @@ export interface ReviewContext {
|
|
|
13
17
|
log: Log;
|
|
14
18
|
signal: AbortSignal;
|
|
15
19
|
tool(name: string): Tool | undefined;
|
|
20
|
+
/** The working directory the call runs in. Paths that leave it are not waved through as read-only. */
|
|
21
|
+
cwd?: string;
|
|
16
22
|
}
|
|
17
23
|
/** One link of the chain. `pass` hands the call to the next link. */
|
|
18
24
|
export interface Authorizer {
|
|
@@ -23,6 +29,9 @@ export interface ChainResult {
|
|
|
23
29
|
kind: "allow" | "deny";
|
|
24
30
|
reason: string;
|
|
25
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;
|
|
26
35
|
}
|
|
27
36
|
/**
|
|
28
37
|
* Asks each authorizer in turn. The first answer that is not `pass` wins. The chain always ends in an answer:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeFailure } from "../../../kernel/src/index.js";
|
|
1
2
|
/**
|
|
2
3
|
* Asks each authorizer in turn. The first answer that is not `pass` wins. The chain always ends in an answer:
|
|
3
4
|
* if every authorizer passes (or one fails), the call is denied. An empty chain is the caller's choice to skip review.
|
|
@@ -11,14 +12,16 @@ export async function runChain(authorizers, call, context) {
|
|
|
11
12
|
catch (error) {
|
|
12
13
|
return {
|
|
13
14
|
kind: "deny",
|
|
14
|
-
reason: `The safety check '${authorizer.name}' failed (${
|
|
15
|
+
reason: `The safety check '${authorizer.name}' failed (${describeFailure(error)}), so the action was not allowed.`,
|
|
15
16
|
decidedBy: authorizer.name,
|
|
17
|
+
technical: true,
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
if (verdict.kind === "allow")
|
|
19
|
-
return { kind: "allow", reason: verdict.reason ?? "allowed", decidedBy: authorizer.name };
|
|
20
|
-
if (verdict.kind === "deny")
|
|
21
|
-
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
|
+
}
|
|
22
25
|
}
|
|
23
26
|
return { kind: "deny", reason: "No safety check approved this action.", decidedBy: "end-of-chain" };
|
|
24
27
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { Authorizer } from "./chain.js";
|
|
2
|
+
/** Whether a word contains a variable or command substitution, so what it stands for cannot be told from the text. */
|
|
3
|
+
export declare function mayExpand(word: string): boolean;
|
|
4
|
+
export declare function mayLeaveDirectory(word: string, cwd: string | undefined): boolean;
|
|
2
5
|
export declare function mentionsSensitivePath(text: string): boolean;
|
|
3
6
|
/**
|
|
4
7
|
* Deterministic rules, no model: read-only calls are allowed, catastrophic ones are refused, everything else
|
|
5
8
|
* is passed on. Reading credentials counts as sensitive and is passed on instead of allowed.
|
|
6
9
|
*/
|
|
10
|
+
export declare function createHardRules(options?: {
|
|
11
|
+
directoryRules?: boolean;
|
|
12
|
+
}): Authorizer;
|
|
7
13
|
export declare const hardRules: Authorizer;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
1
2
|
import { parseShell } from "./shell.js";
|
|
2
3
|
const READ_ONLY = new Set([
|
|
3
4
|
"ls",
|
|
@@ -88,6 +89,31 @@ const CATASTROPHIC = [
|
|
|
88
89
|
[new RegExp(`${RUNS}(shutdown|reboot|halt|poweroff)(\\s|[);&|]|$)`), "shutting the machine down"],
|
|
89
90
|
[/(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(sh|bash|zsh|dash|ksh|python3?|perl|ruby|node)\b/, "running downloaded code without looking at it"],
|
|
90
91
|
];
|
|
92
|
+
/**
|
|
93
|
+
* Whether a word of a command may point outside the working directory, or cannot be told. Such a word sends a read-only call on to the
|
|
94
|
+
* next link (the reviewer) instead of waving it through. Only the text is read: a symbolic link inside the directory is not followed.
|
|
95
|
+
*/
|
|
96
|
+
const optionValue = (word) => (word.startsWith("-") ? (word.includes("=") ? word.slice(word.indexOf("=") + 1) : "") : word);
|
|
97
|
+
/** Whether a word contains a variable or command substitution, so what it stands for cannot be told from the text. */
|
|
98
|
+
export function mayExpand(word) {
|
|
99
|
+
return /[$`]/.test(optionValue(word));
|
|
100
|
+
}
|
|
101
|
+
export function mayLeaveDirectory(word, cwd) {
|
|
102
|
+
const value = optionValue(word);
|
|
103
|
+
if (value === "")
|
|
104
|
+
return false;
|
|
105
|
+
if (/[$`]/.test(value) || value.startsWith("~"))
|
|
106
|
+
return true;
|
|
107
|
+
if (value.startsWith("/")) {
|
|
108
|
+
if (cwd === undefined)
|
|
109
|
+
return true;
|
|
110
|
+
const root = posix.normalize(cwd).replace(/\/+$/, "");
|
|
111
|
+
const target = posix.normalize(value);
|
|
112
|
+
return !(target === root || target.startsWith(`${root}/`));
|
|
113
|
+
}
|
|
114
|
+
const target = posix.normalize(value);
|
|
115
|
+
return target === ".." || target.startsWith("../");
|
|
116
|
+
}
|
|
91
117
|
export function mentionsSensitivePath(text) {
|
|
92
118
|
return SENSITIVE.test(text);
|
|
93
119
|
}
|
|
@@ -95,29 +121,36 @@ export function mentionsSensitivePath(text) {
|
|
|
95
121
|
* Deterministic rules, no model: read-only calls are allowed, catastrophic ones are refused, everything else
|
|
96
122
|
* is passed on. Reading credentials counts as sensitive and is passed on instead of allowed.
|
|
97
123
|
*/
|
|
98
|
-
export
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
export function createHardRules(options = {}) {
|
|
125
|
+
const directoryRules = options.directoryRules !== false;
|
|
126
|
+
return {
|
|
127
|
+
name: "hard-rules",
|
|
128
|
+
async review(call, { tool, cwd }) {
|
|
129
|
+
const outside = (word) => (directoryRules ? mayLeaveDirectory(word, cwd) : mayExpand(word));
|
|
130
|
+
const args = (typeof call.args === "object" && call.args !== null ? call.args : {});
|
|
131
|
+
if (call.name === "bash" || tool(call.name)?.effect === "exec") {
|
|
132
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
133
|
+
for (const [pattern, what] of CATASTROPHIC) {
|
|
134
|
+
if (pattern.test(command))
|
|
135
|
+
return { kind: "deny", reason: `This command looks like ${what}. It is never allowed.` };
|
|
136
|
+
}
|
|
137
|
+
const parsed = parseShell(command);
|
|
138
|
+
if (parsed.complex || parsed.writesFile || parsed.commands.length === 0)
|
|
139
|
+
return { kind: "pass" };
|
|
140
|
+
if (!parsed.commands.every(commandIsReadOnly))
|
|
141
|
+
return { kind: "pass" };
|
|
142
|
+
if (mentionsSensitivePath(command))
|
|
143
|
+
return { kind: "pass" };
|
|
144
|
+
if (parsed.commands.some((words) => words.slice(1).some(outside)))
|
|
145
|
+
return { kind: "pass" };
|
|
146
|
+
return { kind: "allow", reason: "read-only command" };
|
|
107
147
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return { kind: "pass" };
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (tool(call.name)?.effect === "read") {
|
|
118
|
-
const path = typeof args.path === "string" ? args.path : "";
|
|
119
|
-
return mentionsSensitivePath(path) ? { kind: "pass" } : { kind: "allow", reason: "read-only tool" };
|
|
120
|
-
}
|
|
121
|
-
return { kind: "pass" };
|
|
122
|
-
},
|
|
123
|
-
};
|
|
148
|
+
if (tool(call.name)?.effect === "read") {
|
|
149
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
150
|
+
return mentionsSensitivePath(path) || outside(path) ? { kind: "pass" } : { kind: "allow", reason: "read-only tool" };
|
|
151
|
+
}
|
|
152
|
+
return { kind: "pass" };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export const hardRules = createHardRules();
|
|
@@ -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;
|
|
@@ -16,9 +18,21 @@ export interface ReviewOptions {
|
|
|
16
18
|
/** The model that reviews. Usually the same one as the agent's. */
|
|
17
19
|
llm: LlmClient;
|
|
18
20
|
policy?: PolicyInput;
|
|
21
|
+
/**
|
|
22
|
+
* By default a read-only command or tool that points outside the working directory is not allowed by the hard rules but sent to the reviewer.
|
|
23
|
+
* `false` removes that rule, for an application where the agent works on the whole machine and has no working directory in that sense. The other rules
|
|
24
|
+
* stay: commands that write, expansions the text cannot show, credentials and the catastrophic patterns.
|
|
25
|
+
*/
|
|
26
|
+
directoryRules?: boolean;
|
|
19
27
|
/** Extra authorizers, asked after the hard rules and before the reviewer. */
|
|
20
28
|
authorizers?: Authorizer[];
|
|
21
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>;
|
|
22
36
|
/** Off unless given. When it trips, refusals continue and the run is paused for the owner to look at. */
|
|
23
37
|
breaker?: BreakerOptions;
|
|
24
38
|
}
|
|
@@ -27,6 +41,8 @@ export interface AuthorizeContext {
|
|
|
27
41
|
log: Log;
|
|
28
42
|
signal: AbortSignal;
|
|
29
43
|
tool(name: string): Tool | undefined;
|
|
44
|
+
/** The working directory the call runs in (optional; without it any absolute path counts as outside). */
|
|
45
|
+
cwd?: string;
|
|
30
46
|
}
|
|
31
47
|
export interface ReviewPlugin extends Plugin {
|
|
32
48
|
/**
|
|
@@ -36,6 +52,11 @@ export interface ReviewPlugin extends Plugin {
|
|
|
36
52
|
authorize(call: ToolCall, context: AuthorizeContext): Promise<Extract<Decision, {
|
|
37
53
|
kind: "allow" | "deny";
|
|
38
54
|
}>>;
|
|
55
|
+
/**
|
|
56
|
+
* Stops the loop from reviewing calls to this tool itself. For a tool that only runs other tools and has each of them reviewed through `authorize`
|
|
57
|
+
* (code mode's `run_code`), so one script is not reviewed twice. `createAgent` does this for code mode; a plugin wired by hand is reviewed as a whole unless told.
|
|
58
|
+
*/
|
|
59
|
+
exempt(toolName: string): void;
|
|
39
60
|
}
|
|
40
61
|
/**
|
|
41
62
|
* Automatic review of tool calls, with nobody to ask: hard rules, then any extra authorizers, then a reviewer
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { runChain } from "./chain.js";
|
|
2
|
-
import {
|
|
2
|
+
import { createHardRules } from "./hard-rules.js";
|
|
3
3
|
import { resolvePolicy } from "./policy.js";
|
|
4
4
|
import { createReviewer } from "./reviewer.js";
|
|
5
5
|
const stable = (value) => JSON.stringify(value, (_key, inner) => (inner && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a.localeCompare(b))) : inner));
|
|
@@ -12,8 +12,8 @@ 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 } : {}) });
|
|
16
|
-
const chain = [
|
|
15
|
+
const reviewer = createReviewer({ llm: options.llm, policy, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.limits ? { limits: options.limits } : {}) });
|
|
16
|
+
const chain = [createHardRules({ directoryRules: options.directoryRules !== false }), ...(options.authorizers ?? []), reviewer];
|
|
17
17
|
function record(state, refused) {
|
|
18
18
|
state.recent.push(refused);
|
|
19
19
|
if (state.recent.length > (options.breaker?.window?.size ?? 50))
|
|
@@ -24,6 +24,31 @@ export function reviewPlugin(options) {
|
|
|
24
24
|
state.tripped = true;
|
|
25
25
|
}
|
|
26
26
|
let emit = () => undefined;
|
|
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
|
+
}
|
|
27
52
|
async function authorize(call, context) {
|
|
28
53
|
const state = states.get(context.log) ?? fresh();
|
|
29
54
|
states.set(context.log, state);
|
|
@@ -31,27 +56,37 @@ export function reviewPlugin(options) {
|
|
|
31
56
|
return { kind: "deny", reason: "Too many actions were refused in this run, so further actions are blocked until the owner looks at it." };
|
|
32
57
|
const key = `${call.name}\u0000${stable(call.args)}`;
|
|
33
58
|
const cached = state.verdicts.get(key);
|
|
34
|
-
const
|
|
59
|
+
const started = Date.now();
|
|
60
|
+
const result = cached
|
|
61
|
+
? { ...cached, decidedBy: "cache" }
|
|
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;
|
|
35
64
|
if (!cached) {
|
|
36
|
-
|
|
65
|
+
// a failed review is not a judgement: it is not remembered, so the next try asks again
|
|
66
|
+
if (result.decidedBy === "reviewer" && !technical)
|
|
37
67
|
state.verdicts.set(key, { kind: result.kind, reason: result.reason });
|
|
38
68
|
record(state, result.kind === "deny");
|
|
39
69
|
}
|
|
40
|
-
|
|
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 } : {}) });
|
|
41
73
|
if (result.kind === "allow")
|
|
42
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.` };
|
|
43
77
|
return { kind: "deny", reason: `${result.reason} Do not try the same action again; choose another approach, or tell the user what you need.` };
|
|
44
78
|
}
|
|
45
79
|
return {
|
|
46
80
|
name: "review",
|
|
47
81
|
compensates: "models act without anyone checking the risky ones",
|
|
48
82
|
authorize,
|
|
83
|
+
exempt: (toolName) => void exempted.add(toolName),
|
|
49
84
|
async prepare({ log }) {
|
|
50
85
|
states.set(log, fresh()); // decisions are remembered within one run only
|
|
51
86
|
},
|
|
52
87
|
setup(api) {
|
|
53
88
|
emit = api.emit;
|
|
54
|
-
api.on("tool.before", (call, context) => authorize(call, context));
|
|
89
|
+
api.on("tool.before", (call, context) => exempted.has(call.name) ? undefined : authorize(call, { log: context.log, signal: context.signal, tool: context.tool, cwd: context.env.cwd }));
|
|
55
90
|
api.on("turn.end", ({ log }) => (states.get(log)?.tripped ? { pause: "approval" } : undefined));
|
|
56
91
|
},
|
|
57
92
|
};
|
|
@@ -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
|
|
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
|
|
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;
|
|
@@ -33,75 +33,163 @@ export function outcomeFor(risk, authorization) {
|
|
|
33
33
|
return authorization === "high" || authorization === "medium" ? "allow" : "deny";
|
|
34
34
|
return "deny";
|
|
35
35
|
}
|
|
36
|
-
const
|
|
36
|
+
export const defaultReviewerLimits = { maxThreadTokens: 24_000, recentUserMessages: 5, digestVerdicts: 10, entryChars: 1200, instructionChars: 4000, actionChars: 6000 };
|
|
37
|
+
/** 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). */
|
|
38
|
+
export function resolveReviewerLimits(given = {}) {
|
|
39
|
+
const limits = { ...defaultReviewerLimits };
|
|
40
|
+
for (const key of Object.keys(defaultReviewerLimits)) {
|
|
41
|
+
const value = given[key];
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
if (!Number.isInteger(value) || value < 1)
|
|
45
|
+
throw new RangeError(`review: ${key} must be a whole number of at least 1, got ${String(value)}`);
|
|
46
|
+
limits[key] = value;
|
|
47
|
+
}
|
|
48
|
+
return limits;
|
|
49
|
+
}
|
|
50
|
+
/** At most `max` characters: the beginning and the end, with a note of how much was left out between them. */
|
|
51
|
+
export function shorten(text, max) {
|
|
52
|
+
if (text.length <= max)
|
|
53
|
+
return text;
|
|
54
|
+
const note = (left) => `… [${left} characters left out] …`;
|
|
55
|
+
const room = Math.max(2, max - note(text.length).length);
|
|
56
|
+
const head = Math.ceil(room / 2);
|
|
57
|
+
const tail = Math.floor(room / 2);
|
|
58
|
+
return `${text.slice(0, head)}${note(text.length - head - tail)}${text.slice(text.length - tail)}`;
|
|
59
|
+
}
|
|
60
|
+
const clip = shorten;
|
|
37
61
|
const textOf = (content) => content.map((part) => (part.type === "text" ? part.text : part.type === "image" ? "[image]" : "")).join("");
|
|
38
62
|
/**
|
|
39
63
|
* What the reviewer is told since the last time: the user's words, project instructions and goal, and the tool
|
|
40
64
|
* calls the agent made. Tool results and the agent's own text are left out on purpose.
|
|
41
65
|
*/
|
|
42
|
-
export function transcriptDelta(entries, from, skipCallId) {
|
|
66
|
+
export function transcriptDelta(entries, from, skipCallId, limits = defaultReviewerLimits) {
|
|
43
67
|
const lines = [];
|
|
44
68
|
for (const entry of entries.slice(from)) {
|
|
45
69
|
if (entry.type === "custom_message") {
|
|
46
70
|
const label = entry.customType === "goal" ? "goal" : entry.customType === "agents_md" ? "project instructions" : entry.customType;
|
|
47
|
-
lines.push(`[${label}] ${clip(textOf(entry.content),
|
|
71
|
+
lines.push(`[${label}] ${clip(textOf(entry.content), limits.instructionChars)}`);
|
|
48
72
|
}
|
|
49
73
|
else if (entry.type === "message" && entry.message.role === "user") {
|
|
50
|
-
lines.push(`[user] ${clip(textOf(entry.message.content))}`);
|
|
74
|
+
lines.push(`[user] ${clip(textOf(entry.message.content), limits.entryChars)}`);
|
|
51
75
|
}
|
|
52
76
|
else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
53
77
|
for (const part of entry.message.content) {
|
|
54
78
|
if (part.type === "tool_call" && part.id !== skipCallId)
|
|
55
|
-
lines.push(`[agent tool call] ${part.name} ${clip(JSON.stringify(part.args ?? {}))}`);
|
|
79
|
+
lines.push(`[agent tool call] ${part.name} ${clip(JSON.stringify(part.args ?? {}), limits.entryChars)}`);
|
|
56
80
|
}
|
|
57
81
|
}
|
|
58
82
|
}
|
|
59
83
|
return lines;
|
|
60
84
|
}
|
|
85
|
+
/** 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. */
|
|
86
|
+
export function verdictDigest(entries, limit) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.type !== "custom" || entry.customType !== "review")
|
|
90
|
+
continue;
|
|
91
|
+
const data = entry.data;
|
|
92
|
+
if (!data || data.technical === true || typeof data.tool !== "string" || (data.outcome !== "allow" && data.outcome !== "deny"))
|
|
93
|
+
continue;
|
|
94
|
+
const reason = typeof data.reason === "string" ? data.reason.replace(/\s+/g, " ").trim() : "";
|
|
95
|
+
lines.push(`[earlier review] ${data.tool}: ${data.outcome}${reason ? ` - ${shorten(reason, 160)}` : ""}`);
|
|
96
|
+
}
|
|
97
|
+
return lines.slice(-limit);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* What a new conversation starts with: the active goal, the project instructions, the latest few user messages and the digest of the latest verdicts.
|
|
101
|
+
* Everything before is left out, and the reviewer is told so.
|
|
102
|
+
*/
|
|
103
|
+
export function seedLines(entries, limits) {
|
|
104
|
+
const withdrawn = new Set(entries.flatMap((entry) => (entry.type === "context_edit" && entry.replacement === null ? [entry.targetId] : [])));
|
|
105
|
+
const newest = (customType) => entries.findLast((entry) => entry.type === "custom_message" && entry.customType === customType && !withdrawn.has(entry.id));
|
|
106
|
+
const lines = ["(Earlier parts of the conversation are left out. What follows is the project's instructions, the goal, the latest user messages and the latest verdicts.)"];
|
|
107
|
+
const instructions = newest("agents_md");
|
|
108
|
+
const goal = newest("goal");
|
|
109
|
+
if (instructions?.type === "custom_message")
|
|
110
|
+
lines.push(`[project instructions] ${clip(textOf(instructions.content), limits.instructionChars)}`);
|
|
111
|
+
if (goal?.type === "custom_message")
|
|
112
|
+
lines.push(`[goal] ${clip(textOf(goal.content), limits.instructionChars)}`);
|
|
113
|
+
const users = entries.flatMap((entry) => (entry.type === "message" && entry.message.role === "user" ? [`[user] ${clip(textOf(entry.message.content), limits.entryChars)}`] : []));
|
|
114
|
+
lines.push(...users.slice(-limits.recentUserMessages), ...verdictDigest(entries, limits.digestVerdicts));
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
/** The review is asked twice at most: a second try for a technical failure (timeout, error, unreadable answer). */
|
|
118
|
+
const ATTEMPTS = 2;
|
|
119
|
+
/** `{ usage }` when the reviewer spent any tokens, otherwise nothing. */
|
|
120
|
+
const usageOf = (spent) => (spent.input + spent.output + spent.cacheRead + spent.cacheWrite > 0 ? { usage: spent } : {});
|
|
121
|
+
/** Four characters to a token, as everywhere else. */
|
|
122
|
+
const estimatedTokens = (texts) => Math.ceil(texts.reduce((sum, text) => sum + text.length, 0) / 4);
|
|
61
123
|
const userMessage = (text) => ({ role: "user", content: [{ type: "text", text }], ts: 0 });
|
|
62
124
|
/**
|
|
63
125
|
* A model asks "may this run?". One conversation per log is kept and only ever appended to, so the provider's
|
|
64
|
-
* prompt cache keeps working. Anything unexpected (timeout, unreadable answer, error) is a
|
|
126
|
+
* prompt cache keeps working. Anything unexpected (timeout, unreadable answer, error) is tried once more and then refused, marked as a technical failure.
|
|
65
127
|
*/
|
|
66
128
|
export function createReviewer(options) {
|
|
67
129
|
const system = reviewerSystemPrompt(options.policy);
|
|
68
130
|
const timeoutMs = options.timeoutMs ?? 90_000;
|
|
131
|
+
const limits = resolveReviewerLimits(options.limits);
|
|
69
132
|
const threads = new WeakMap();
|
|
70
133
|
const queues = new WeakMap();
|
|
134
|
+
/** One request to the reviewer model; returns its text and adds the tokens it used to `spent`. Throws on a timeout or an error. */
|
|
135
|
+
async function ask(messages, runSignal, spent) {
|
|
136
|
+
const signal = AbortSignal.any([runSignal, AbortSignal.timeout(timeoutMs)]);
|
|
137
|
+
let answer = "";
|
|
138
|
+
for await (const event of options.llm.stream({ system, messages, tools: [], maxTokens: 1000 }, signal)) {
|
|
139
|
+
if (event.type === "text_delta")
|
|
140
|
+
answer += event.delta;
|
|
141
|
+
else if (event.type === "usage") {
|
|
142
|
+
spent.input += event.usage.input;
|
|
143
|
+
spent.output += event.usage.output;
|
|
144
|
+
spent.cacheRead += event.usage.cacheRead;
|
|
145
|
+
spent.cacheWrite += event.usage.cacheWrite;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return answer;
|
|
149
|
+
}
|
|
71
150
|
async function review(call, context) {
|
|
72
151
|
const entries = await context.log.branch();
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
152
|
+
const known = threads.get(context.log) ?? { messages: [], cursor: 0 };
|
|
153
|
+
const action = `<action>\ntool: ${call.name}\narguments: ${shorten(JSON.stringify(call.args ?? {}), limits.actionChars)}\n</action>`;
|
|
154
|
+
const promptFor = (lines) => (lines.length > 0 ? `<context>\n${lines.join("\n")}\n</context>\n\n` : "") + action;
|
|
155
|
+
// The conversation only grows, so the provider's cache keeps working, until it is too big or its basis changed; then a new one starts from a seed.
|
|
156
|
+
const rewritten = entries.length < known.cursor || entries.slice(known.cursor).some((entry) => entry.type === "compaction");
|
|
157
|
+
let thread = rewritten ? { messages: [], cursor: 0 } : known;
|
|
158
|
+
let prompt = promptFor(thread.messages.length === 0 ? seedLines(entries, limits) : transcriptDelta(entries, thread.cursor, call.id, limits));
|
|
159
|
+
if (thread.messages.length > 0 && estimatedTokens([system, ...thread.messages.map((message) => textOf(message.content)), prompt]) > limits.maxThreadTokens) {
|
|
160
|
+
thread = { messages: [], cursor: 0 };
|
|
161
|
+
prompt = promptFor(seedLines(entries, limits));
|
|
162
|
+
}
|
|
76
163
|
const messages = [...thread.messages, userMessage(prompt)];
|
|
164
|
+
// a technical failure is tried once more; what the attempts cost is counted either way
|
|
165
|
+
const spent = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
166
|
+
let failure = "";
|
|
77
167
|
let answer = "";
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
168
|
+
let assessment;
|
|
169
|
+
for (let attempt = 1; attempt <= ATTEMPTS && !assessment; attempt++) {
|
|
170
|
+
try {
|
|
171
|
+
answer = await ask(messages, context.signal, spent);
|
|
172
|
+
assessment = parseAssessment(answer);
|
|
173
|
+
if (!assessment)
|
|
174
|
+
failure = "it gave an answer that could not be read";
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (context.signal.aborted)
|
|
178
|
+
return { kind: "deny", reason: "The run was stopped during the safety review, so the action was not allowed.", technical: true, ...usageOf(spent) };
|
|
179
|
+
const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
180
|
+
failure = timedOut ? "it did not finish in time" : `it failed (${error instanceof Error ? error.message : "error"})`;
|
|
83
181
|
}
|
|
84
182
|
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
87
|
-
return {
|
|
88
|
-
kind: "deny",
|
|
89
|
-
reason: timedOut
|
|
90
|
-
? "The safety review did not finish in time, so the action was not allowed."
|
|
91
|
-
: `The safety review failed (${error instanceof Error ? error.message : "error"}), so the action was not allowed.`,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
const assessment = parseAssessment(answer);
|
|
95
183
|
if (!assessment)
|
|
96
|
-
return { kind: "deny", reason:
|
|
184
|
+
return { kind: "deny", reason: `The safety review failed technically (${failure}, also on a second try), so the action was not judged and not allowed.`, technical: true, ...usageOf(spent) };
|
|
97
185
|
// keep the conversation append-only: this exchange becomes part of the prefix of the next one
|
|
98
186
|
threads.set(context.log, { messages: [...messages, { role: "assistant", content: [{ type: "text", text: answer }], ts: 0 }], cursor: entries.length });
|
|
99
187
|
const computed = outcomeFor(assessment.risk_level, assessment.user_authorization);
|
|
100
188
|
const outcome = computed === "deny" || assessment.outcome === "deny" ? "deny" : "allow";
|
|
101
189
|
options.onAssessment?.({ call, assessment, outcome });
|
|
102
190
|
return outcome === "allow"
|
|
103
|
-
? { kind: "allow", reason: assessment.rationale || "approved by the safety review" }
|
|
104
|
-
: { kind: "deny", reason: assessment.rationale || `risk ${assessment.risk_level}, user authorization ${assessment.user_authorization}
|
|
191
|
+
? { kind: "allow", reason: assessment.rationale || "approved by the safety review", ...usageOf(spent) }
|
|
192
|
+
: { kind: "deny", reason: assessment.rationale || `risk ${assessment.risk_level}, user authorization ${assessment.user_authorization}`, ...usageOf(spent) };
|
|
105
193
|
}
|
|
106
194
|
return {
|
|
107
195
|
name: "reviewer",
|