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.
- package/README.md +75 -11
- package/README.zh-CN.md +75 -11
- package/dist/packages/env/src/connect-ssh.d.ts +14 -2
- package/dist/packages/env/src/connect-ssh.js +118 -8
- package/dist/packages/env/src/index.d.ts +1 -0
- 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 +10 -17
- package/dist/packages/env/src/remote.js +29 -16
- package/dist/packages/kernel/src/compaction.d.ts +5 -3
- package/dist/packages/kernel/src/compaction.js +11 -14
- 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/stream.js +2 -1
- package/dist/packages/plugins/review/src/chain.d.ts +8 -1
- package/dist/packages/plugins/review/src/chain.js +5 -3
- package/dist/packages/plugins/review/src/index.d.ts +8 -0
- package/dist/packages/plugins/review/src/index.js +34 -3
- 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/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/session/src/projection.js +21 -4
- package/package.json +3 -2
|
@@ -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",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The image formats that model APIs take. Anything else must be converted or refused before it is sent: a refused image is refused for good. */
|
|
2
|
+
export declare const SENDABLE_IMAGE_TYPES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
3
|
+
export type ImageType = (typeof SENDABLE_IMAGE_TYPES)[number] | "image/bmp";
|
|
4
|
+
/** The format a file really has, read from its first bytes (a file name proves nothing). Undefined when it is not an image we know. */
|
|
5
|
+
export declare function sniffImageType(bytes: Uint8Array): ImageType | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Turns an uncompressed 24-bit or 32-bit BMP (the kind programs write when they dump a frame) into a PNG. Undefined for anything else: a palette,
|
|
8
|
+
* run-length coding, 16 bits, unusual colour masks, a truncated file. The caller then tells the model the file cannot be shown. The alpha byte of a
|
|
9
|
+
* 32-bit BMP is dropped: in this form it is almost always unused (zero) and would make the picture invisible.
|
|
10
|
+
*/
|
|
11
|
+
export declare function bmpToPng(bytes: Uint8Array): Uint8Array | undefined;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { crc32, deflateSync } from "node:zlib";
|
|
2
|
+
/** The image formats that model APIs take. Anything else must be converted or refused before it is sent: a refused image is refused for good. */
|
|
3
|
+
export const SENDABLE_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
4
|
+
const startsWith = (bytes, signature, at = 0) => signature.every((value, index) => bytes[at + index] === value);
|
|
5
|
+
/** The format a file really has, read from its first bytes (a file name proves nothing). Undefined when it is not an image we know. */
|
|
6
|
+
export function sniffImageType(bytes) {
|
|
7
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
8
|
+
return "image/png";
|
|
9
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff]))
|
|
10
|
+
return "image/jpeg";
|
|
11
|
+
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38]))
|
|
12
|
+
return "image/gif"; // "GIF8"
|
|
13
|
+
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8))
|
|
14
|
+
return "image/webp"; // "RIFF....WEBP"
|
|
15
|
+
if (startsWith(bytes, [0x42, 0x4d]))
|
|
16
|
+
return "image/bmp"; // "BM"
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
/** A guard against a header that promises a picture too large to hold in memory. */
|
|
20
|
+
const MAX_PIXELS = 50_000_000;
|
|
21
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
22
|
+
function pngChunk(type, data) {
|
|
23
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
24
|
+
const length = Buffer.alloc(4);
|
|
25
|
+
length.writeUInt32BE(data.length);
|
|
26
|
+
const checksum = Buffer.alloc(4);
|
|
27
|
+
checksum.writeUInt32BE(crc32(body));
|
|
28
|
+
return Buffer.concat([length, body, checksum]);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Turns an uncompressed 24-bit or 32-bit BMP (the kind programs write when they dump a frame) into a PNG. Undefined for anything else: a palette,
|
|
32
|
+
* run-length coding, 16 bits, unusual colour masks, a truncated file. The caller then tells the model the file cannot be shown. The alpha byte of a
|
|
33
|
+
* 32-bit BMP is dropped: in this form it is almost always unused (zero) and would make the picture invisible.
|
|
34
|
+
*/
|
|
35
|
+
export function bmpToPng(bytes) {
|
|
36
|
+
if (sniffImageType(bytes) !== "image/bmp" || bytes.length < 54)
|
|
37
|
+
return undefined;
|
|
38
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
39
|
+
const dataOffset = view.getUint32(10, true);
|
|
40
|
+
const headerSize = view.getUint32(14, true);
|
|
41
|
+
if (headerSize < 40)
|
|
42
|
+
return undefined; // the old 12-byte header is not handled
|
|
43
|
+
const width = view.getInt32(18, true);
|
|
44
|
+
const rawHeight = view.getInt32(22, true);
|
|
45
|
+
const bitsPerPixel = view.getUint16(28, true);
|
|
46
|
+
const compression = view.getUint32(30, true);
|
|
47
|
+
const height = Math.abs(rawHeight);
|
|
48
|
+
const topDown = rawHeight < 0;
|
|
49
|
+
if (width <= 0 || height === 0 || width * height > MAX_PIXELS)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (bitsPerPixel !== 24 && bitsPerPixel !== 32)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (compression === 3) {
|
|
54
|
+
// BI_BITFIELDS: only the usual layout (red 0x00FF0000, green 0x0000FF00, blue 0x000000FF) is understood
|
|
55
|
+
if (bitsPerPixel !== 32 || bytes.length < 66 || view.getUint32(54, true) !== 0x00ff0000 || view.getUint32(58, true) !== 0x0000ff00 || view.getUint32(62, true) !== 0x000000ff)
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
else if (compression !== 0) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const bytesPerPixel = bitsPerPixel / 8;
|
|
62
|
+
const rowSize = Math.ceil((width * bitsPerPixel) / 32) * 4;
|
|
63
|
+
if (dataOffset + rowSize * height > bytes.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
// PNG rows: a filter byte (0 = none) and then red, green, blue for every pixel
|
|
66
|
+
const scanline = 1 + width * 3;
|
|
67
|
+
const raw = Buffer.alloc(scanline * height);
|
|
68
|
+
for (let row = 0; row < height; row++) {
|
|
69
|
+
const source = dataOffset + (topDown ? row : height - 1 - row) * rowSize;
|
|
70
|
+
const target = row * scanline + 1;
|
|
71
|
+
for (let column = 0; column < width; column++) {
|
|
72
|
+
const pixel = source + column * bytesPerPixel;
|
|
73
|
+
raw[target + column * 3] = bytes[pixel + 2] ?? 0;
|
|
74
|
+
raw[target + column * 3 + 1] = bytes[pixel + 1] ?? 0;
|
|
75
|
+
raw[target + column * 3 + 2] = bytes[pixel] ?? 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const header = Buffer.alloc(13);
|
|
79
|
+
header.writeUInt32BE(width, 0);
|
|
80
|
+
header.writeUInt32BE(height, 4);
|
|
81
|
+
header[8] = 8; // bit depth
|
|
82
|
+
header[9] = 2; // colour type: truecolour
|
|
83
|
+
return new Uint8Array(Buffer.concat([PNG_SIGNATURE, pngChunk("IHDR", header), pngChunk("IDAT", deflateSync(raw)), pngChunk("IEND", Buffer.alloc(0))]));
|
|
84
|
+
}
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { DEFAULT_FOLD, foldHead } from "../fold.js";
|
|
2
2
|
import { ArgumentError, optionalInteger, requireString } from "./args.js";
|
|
3
|
-
|
|
3
|
+
import { bmpToPng, SENDABLE_IMAGE_TYPES, sniffImageType } from "./image.js";
|
|
4
|
+
/** File names that say "this is a picture". What the file really is, is decided by its first bytes (image.ts). */
|
|
5
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
|
|
6
|
+
const CONVERTED_DIRECTORY = ".sakura/artifacts";
|
|
4
7
|
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
5
8
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
9
|
+
const imageResult = (mediaType, ref, note = "") => ({
|
|
10
|
+
content: [
|
|
11
|
+
{ type: "text", text: `Read image file [${mediaType}]${note ? ` (${note})` : ""}` },
|
|
12
|
+
{ type: "image", mediaType, ref },
|
|
13
|
+
],
|
|
14
|
+
});
|
|
6
15
|
/*
|
|
7
16
|
* Description adapted from Pi's read tool (MIT, Copyright (c) 2025 Mario Zechner; see THIRD_PARTY_NOTICES.md).
|
|
8
17
|
*/
|
|
9
18
|
export const readTool = {
|
|
10
19
|
name: "read",
|
|
11
|
-
description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp
|
|
20
|
+
description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp; a plain bmp is converted to png); images are sent as attachments. " +
|
|
12
21
|
"For text files, output is truncated to the first 2000 lines or 50KB, whichever is hit first. " +
|
|
13
22
|
"Use offset and limit for large files, and continue with offset until you have the whole file.",
|
|
14
23
|
parameters: {
|
|
@@ -21,7 +30,7 @@ export const readTool = {
|
|
|
21
30
|
required: ["path"],
|
|
22
31
|
},
|
|
23
32
|
effect: "read",
|
|
24
|
-
async execute(args, { env }) {
|
|
33
|
+
async execute(args, { env, callId }) {
|
|
25
34
|
try {
|
|
26
35
|
const path = requireString(args, "path");
|
|
27
36
|
const offset = optionalInteger(args, "offset", 1);
|
|
@@ -33,16 +42,21 @@ export const readTool = {
|
|
|
33
42
|
if (info.isDirectory)
|
|
34
43
|
return fail(`${path} is a directory, not a file.`);
|
|
35
44
|
const extension = absolute.slice(absolute.lastIndexOf(".")).toLowerCase();
|
|
36
|
-
const mediaType = IMAGE_TYPES[extension];
|
|
37
|
-
if (mediaType) {
|
|
38
|
-
return {
|
|
39
|
-
content: [
|
|
40
|
-
{ type: "text", text: `Read image file [${mediaType}]` },
|
|
41
|
-
{ type: "image", mediaType, ref: absolute },
|
|
42
|
-
],
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
45
|
const bytes = await env.readFile(absolute);
|
|
46
|
+
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
47
|
+
const type = sniffImageType(bytes);
|
|
48
|
+
if (type && SENDABLE_IMAGE_TYPES.includes(type))
|
|
49
|
+
return imageResult(type, absolute);
|
|
50
|
+
if (type === "image/bmp") {
|
|
51
|
+
const png = bmpToPng(bytes);
|
|
52
|
+
if (!png)
|
|
53
|
+
return fail(`${path} is a BMP image in a form this tool cannot convert, and the model API does not accept BMP. Convert it to PNG with a command (for example ImageMagick: magick ${path} out.png) and read the PNG.`);
|
|
54
|
+
const converted = `${CONVERTED_DIRECTORY}/read-${callId.replace(/[^A-Za-z0-9_.-]/g, "_")}.png`;
|
|
55
|
+
await env.writeFile(converted, png);
|
|
56
|
+
return imageResult("image/png", env.resolve(converted), `converted from BMP to PNG, original ${path}`);
|
|
57
|
+
}
|
|
58
|
+
// a name that says picture, bytes that are not one: shown as the binary or text file it is
|
|
59
|
+
}
|
|
46
60
|
if (bytes.subarray(0, 8000).includes(0))
|
|
47
61
|
return fail(`${path} looks like a binary file and cannot be shown as text.`);
|
|
48
62
|
const text = new TextDecoder().decode(bytes);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { COMPACTION_PREFIX } from "../../kernel/src/index.js";
|
|
2
|
+
/** The message that tells the model about its goal. It is the one pinned message that does not sit in front: see `projectMessages`. */
|
|
3
|
+
const isGoalStatement = (entry) => entry?.type === "custom_message" && entry.customType === "goal";
|
|
2
4
|
function entryToMessage(entry) {
|
|
3
5
|
if (entry.type === "message")
|
|
4
6
|
return entry.message;
|
|
@@ -55,27 +57,42 @@ function orderToolResults(messages) {
|
|
|
55
57
|
}
|
|
56
58
|
return ordered;
|
|
57
59
|
}
|
|
60
|
+
/** The goal statement still in force that lies before `start` (so the compaction took it out of the visible history), as a message. */
|
|
61
|
+
function goalCompactedAway(branch, start, edits) {
|
|
62
|
+
for (let index = start - 1; index >= 0; index--) {
|
|
63
|
+
const entry = branch[index];
|
|
64
|
+
if (!entry || !isGoalStatement(entry))
|
|
65
|
+
continue;
|
|
66
|
+
return edits.get(entry.id) === null ? undefined : entryToMessage(entry);
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
58
70
|
export function projectMessages(branch) {
|
|
59
71
|
const { start, summary, compactedAt } = findStart(branch);
|
|
60
72
|
const edits = collectEdits(branch);
|
|
61
73
|
const messages = [];
|
|
62
|
-
// Pinned entries (project instructions) stay in front even after compaction.
|
|
74
|
+
// Pinned entries (project instructions) stay in front even after compaction. They are written once, at the start, so the front never changes.
|
|
75
|
+
// The goal is pinned too, but it is set in the middle of a conversation: in front it would change everything after it and cost the provider's
|
|
76
|
+
// prompt cache. It stays where it was written, and after a compaction it follows the summary.
|
|
63
77
|
for (let index = 0; index < branch.length; index++) {
|
|
64
78
|
const entry = branch[index];
|
|
65
|
-
if (entry?.type !== "custom_message" || !entry.pinned)
|
|
79
|
+
if (entry?.type !== "custom_message" || !entry.pinned || isGoalStatement(entry))
|
|
66
80
|
continue;
|
|
67
81
|
if (edits.get(entry.id) === null)
|
|
68
|
-
continue; // a pinned message can be withdrawn too
|
|
82
|
+
continue; // a pinned message can be withdrawn too
|
|
69
83
|
const message = entryToMessage(entry);
|
|
70
84
|
if (message)
|
|
71
85
|
messages.push(message);
|
|
72
86
|
}
|
|
73
87
|
if (summary !== undefined) {
|
|
74
88
|
messages.push({ role: "user", content: [{ type: "text", text: `${COMPACTION_PREFIX}\n\n${summary}` }], ts: 0 });
|
|
89
|
+
const carried = goalCompactedAway(branch, start, edits);
|
|
90
|
+
if (carried)
|
|
91
|
+
messages.push(carried);
|
|
75
92
|
}
|
|
76
93
|
for (let index = start; index < branch.length; index++) {
|
|
77
94
|
const entry = branch[index];
|
|
78
|
-
if (!entry || (entry.type === "custom_message" && entry.pinned))
|
|
95
|
+
if (!entry || (entry.type === "custom_message" && entry.pinned && !isGoalStatement(entry)))
|
|
79
96
|
continue;
|
|
80
97
|
const message = entryToMessage(entry);
|
|
81
98
|
if (!message)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sakura-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"example": "node examples/quickstart.ts",
|
|
24
24
|
"build": "node scripts/build-sdk.mjs",
|
|
25
25
|
"prepack": "npm run build",
|
|
26
|
-
"example:tools": "node examples/tools.ts"
|
|
26
|
+
"example:tools": "node examples/tools.ts",
|
|
27
|
+
"example:fleet": "node examples/fleet.ts"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@biomejs/biome": "^2.6.4",
|