sakura-agent 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +132 -2
- package/README.zh-CN.md +133 -0
- package/THIRD_PARTY_NOTICES.md +63 -0
- package/dist/packages/agent/src/compose.d.ts +25 -0
- package/dist/packages/agent/src/compose.js +91 -0
- package/dist/packages/agent/src/index.d.ts +92 -0
- package/dist/packages/agent/src/index.js +166 -0
- package/dist/packages/agent/src/system-prompt.d.ts +10 -0
- package/dist/packages/agent/src/system-prompt.js +49 -0
- package/dist/packages/env/src/index.d.ts +3 -0
- package/dist/packages/env/src/index.js +2 -0
- package/dist/packages/env/src/local.d.ts +3 -0
- package/dist/packages/env/src/local.js +174 -0
- package/dist/packages/env/src/remote.d.ts +15 -0
- package/dist/packages/env/src/remote.js +120 -0
- package/dist/packages/kernel/src/channel.d.ts +7 -0
- package/dist/packages/kernel/src/channel.js +34 -0
- package/dist/packages/kernel/src/compaction.d.ts +25 -0
- package/dist/packages/kernel/src/compaction.js +79 -0
- package/dist/packages/kernel/src/constants.d.ts +2 -0
- package/dist/packages/kernel/src/constants.js +5 -0
- package/dist/packages/kernel/src/goal.d.ts +26 -0
- package/dist/packages/kernel/src/goal.js +37 -0
- package/dist/packages/kernel/src/hooks.d.ts +29 -0
- package/dist/packages/kernel/src/hooks.js +133 -0
- package/dist/packages/kernel/src/index.d.ts +6 -0
- package/dist/packages/kernel/src/index.js +3 -0
- package/dist/packages/kernel/src/loop.d.ts +51 -0
- package/dist/packages/kernel/src/loop.js +534 -0
- package/dist/packages/kernel/src/prompts.d.ts +18 -0
- package/dist/packages/kernel/src/prompts.js +99 -0
- package/dist/packages/kernel/src/system.d.ts +25 -0
- package/dist/packages/kernel/src/system.js +49 -0
- package/dist/packages/kernel/src/types.d.ts +377 -0
- package/dist/packages/kernel/src/types.js +23 -0
- package/dist/packages/llm/src/chat/compat.d.ts +19 -0
- package/dist/packages/llm/src/chat/compat.js +16 -0
- package/dist/packages/llm/src/chat/errors.d.ts +8 -0
- package/dist/packages/llm/src/chat/errors.js +44 -0
- package/dist/packages/llm/src/chat/request.d.ts +12 -0
- package/dist/packages/llm/src/chat/request.js +91 -0
- package/dist/packages/llm/src/chat/retry.d.ts +26 -0
- package/dist/packages/llm/src/chat/retry.js +57 -0
- package/dist/packages/llm/src/chat/stream.d.ts +17 -0
- package/dist/packages/llm/src/chat/stream.js +121 -0
- package/dist/packages/llm/src/index.d.ts +45 -0
- package/dist/packages/llm/src/index.js +115 -0
- package/dist/packages/plugins/agents/src/index.d.ts +33 -0
- package/dist/packages/plugins/agents/src/index.js +206 -0
- package/dist/packages/plugins/agents/src/view.d.ts +24 -0
- package/dist/packages/plugins/agents/src/view.js +60 -0
- package/dist/packages/plugins/context/src/index.d.ts +26 -0
- package/dist/packages/plugins/context/src/index.js +66 -0
- package/dist/packages/plugins/limits/src/cost.d.ts +9 -0
- package/dist/packages/plugins/limits/src/cost.js +3 -0
- package/dist/packages/plugins/limits/src/index.d.ts +28 -0
- package/dist/packages/plugins/limits/src/index.js +31 -0
- package/dist/packages/plugins/redaction/src/engine.d.ts +24 -0
- package/dist/packages/plugins/redaction/src/engine.js +110 -0
- package/dist/packages/plugins/redaction/src/index.d.ts +25 -0
- package/dist/packages/plugins/redaction/src/index.js +66 -0
- package/dist/packages/plugins/redaction/src/restore.d.ts +6 -0
- package/dist/packages/plugins/redaction/src/restore.js +44 -0
- package/dist/packages/plugins/redaction/src/rules.gen.d.ts +14 -0
- package/dist/packages/plugins/redaction/src/rules.gen.js +2059 -0
- package/dist/packages/plugins/resilience/src/index.d.ts +31 -0
- package/dist/packages/plugins/resilience/src/index.js +86 -0
- package/dist/packages/plugins/review/src/chain.d.ts +31 -0
- package/dist/packages/plugins/review/src/chain.js +24 -0
- package/dist/packages/plugins/review/src/hard-rules.d.ts +7 -0
- package/dist/packages/plugins/review/src/hard-rules.js +123 -0
- package/dist/packages/plugins/review/src/index.d.ts +45 -0
- package/dist/packages/plugins/review/src/index.js +58 -0
- package/dist/packages/plugins/review/src/policy.d.ts +15 -0
- package/dist/packages/plugins/review/src/policy.js +65 -0
- package/dist/packages/plugins/review/src/reviewer.d.ts +39 -0
- package/dist/packages/plugins/review/src/reviewer.js +119 -0
- package/dist/packages/plugins/review/src/shell.d.ts +10 -0
- package/dist/packages/plugins/review/src/shell.js +124 -0
- package/dist/packages/plugins/tools/src/artifacts.d.ts +27 -0
- package/dist/packages/plugins/tools/src/artifacts.js +61 -0
- package/dist/packages/plugins/tools/src/builtin/args.d.ts +11 -0
- package/dist/packages/plugins/tools/src/builtin/args.js +46 -0
- package/dist/packages/plugins/tools/src/builtin/bash.d.ts +11 -0
- package/dist/packages/plugins/tools/src/builtin/bash.js +96 -0
- package/dist/packages/plugins/tools/src/builtin/edit.d.ts +2 -0
- package/dist/packages/plugins/tools/src/builtin/edit.js +71 -0
- package/dist/packages/plugins/tools/src/builtin/read.d.ts +2 -0
- package/dist/packages/plugins/tools/src/builtin/read.js +75 -0
- package/dist/packages/plugins/tools/src/builtin/write.d.ts +2 -0
- package/dist/packages/plugins/tools/src/builtin/write.js +35 -0
- package/dist/packages/plugins/tools/src/edit-match.d.ts +21 -0
- package/dist/packages/plugins/tools/src/edit-match.js +159 -0
- package/dist/packages/plugins/tools/src/fold.d.ts +16 -0
- package/dist/packages/plugins/tools/src/fold.js +65 -0
- package/dist/packages/plugins/tools/src/index.d.ts +10 -0
- package/dist/packages/plugins/tools/src/index.js +10 -0
- package/dist/packages/plugins/toolset/src/bm25.d.ts +8 -0
- package/dist/packages/plugins/toolset/src/bm25.js +35 -0
- package/dist/packages/plugins/toolset/src/codemode.d.ts +36 -0
- package/dist/packages/plugins/toolset/src/codemode.js +168 -0
- package/dist/packages/plugins/toolset/src/index.d.ts +6 -0
- package/dist/packages/plugins/toolset/src/index.js +3 -0
- package/dist/packages/plugins/toolset/src/mcp.d.ts +27 -0
- package/dist/packages/plugins/toolset/src/mcp.js +173 -0
- package/dist/packages/plugins/toolset/src/search.d.ts +12 -0
- package/dist/packages/plugins/toolset/src/search.js +57 -0
- package/dist/packages/session/src/file-log.d.ts +20 -0
- package/dist/packages/session/src/file-log.js +124 -0
- package/dist/packages/session/src/index.d.ts +4 -0
- package/dist/packages/session/src/index.js +2 -0
- package/dist/packages/session/src/memory-log.d.ts +16 -0
- package/dist/packages/session/src/memory-log.js +27 -0
- package/dist/packages/session/src/projection.d.ts +2 -0
- package/dist/packages/session/src/projection.js +88 -0
- package/dist/packages/session/src/tree.d.ts +13 -0
- package/dist/packages/session/src/tree.js +35 -0
- package/dist/sdk/cli.d.ts +2 -0
- package/dist/sdk/cli.js +118 -0
- package/dist/sdk/index.d.ts +10 -0
- package/dist/sdk/index.js +6 -0
- package/licenses/codex-Apache-2.0.txt +201 -0
- package/licenses/gitleaks-MIT.txt +21 -0
- package/licenses/pi-mono-MIT.txt +21 -0
- package/package.json +74 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createChatClient } from "../../llm/src/index.js";
|
|
2
|
+
import { createLocalEnvironment } from "../../env/src/index.js";
|
|
3
|
+
import { createKernel } from "../../kernel/src/index.js";
|
|
4
|
+
import { agentsPlugin } from "../../plugins/agents/src/index.js";
|
|
5
|
+
import { contextPlugin } from "../../plugins/context/src/index.js";
|
|
6
|
+
import { codemodePlugin, connectMcp, searchPlugin } from "../../plugins/toolset/src/index.js";
|
|
7
|
+
import { limitsPlugin } from "../../plugins/limits/src/index.js";
|
|
8
|
+
import { resiliencePlugin } from "../../plugins/resilience/src/index.js";
|
|
9
|
+
import { builtinTools, toolsPlugin } from "../../plugins/tools/src/index.js";
|
|
10
|
+
import { createMemoryLog, openFileLog } from "../../session/src/index.js";
|
|
11
|
+
import { compose } from "./compose.js";
|
|
12
|
+
import { buildSystemPrompt } from "./system-prompt.js";
|
|
13
|
+
/** The plugins every agent gets unless the caller passes its own list: project instructions (AGENTS.md), long tool output saved to files, resilience (replies that were cut off or came back empty are asked again), and limits (unlimited until configured). */
|
|
14
|
+
export function corePlugins() {
|
|
15
|
+
return [contextPlugin(), toolsPlugin(), resiliencePlugin(), limitsPlugin()];
|
|
16
|
+
}
|
|
17
|
+
const SUB_AGENT_NOTE = "You are a sub-agent working for another agent. Do the task you are given completely, then finish with a clear written report of what you did and found. That report is the only thing that goes back to the agent that asked.";
|
|
18
|
+
const CACHE_CHECK_MS = 240_000;
|
|
19
|
+
const checkInput = (labels) => `Background work you started is still running: ${labels.join(", ")}. Check on it once now (for example read its output), then stop and wait; you will be told when it ends. Do not poll.`;
|
|
20
|
+
const isClient = (model) => "stream" in model;
|
|
21
|
+
export async function createAgent(config) {
|
|
22
|
+
const cwd = config.cwd ?? process.cwd();
|
|
23
|
+
const env = config.environment ?? createLocalEnvironment(cwd);
|
|
24
|
+
const connection = config.mcp ? await connectMcp(config.mcp.servers, config.mcp.timeoutMs !== undefined ? { timeoutMs: config.mcp.timeoutMs } : {}) : undefined;
|
|
25
|
+
const mcpTools = connection?.tools ?? [];
|
|
26
|
+
const expose = config.mcp?.expose ?? (mcpTools.length > 10 ? "search" : "direct");
|
|
27
|
+
const bashOptions = {
|
|
28
|
+
...(config.bashSoftTimeoutMs !== undefined ? { softTimeoutMs: config.bashSoftTimeoutMs } : {}),
|
|
29
|
+
...(config.bashBackgroundGraceMs !== undefined ? { backgroundGraceMs: config.bashBackgroundGraceMs } : {}),
|
|
30
|
+
};
|
|
31
|
+
const tools = builtinTools(config.tools, { bash: bashOptions });
|
|
32
|
+
const declared = expose === "direct" ? [...tools, ...mcpTools] : tools;
|
|
33
|
+
const clientFor = (model) => isClient(model)
|
|
34
|
+
? model
|
|
35
|
+
: createChatClient({
|
|
36
|
+
baseUrl: model.baseUrl,
|
|
37
|
+
apiKey: model.apiKey,
|
|
38
|
+
model: model.model,
|
|
39
|
+
contextWindow: model.contextWindow,
|
|
40
|
+
...(model.compat ? { compat: model.compat } : {}),
|
|
41
|
+
...(model.headers ? { headers: model.headers } : {}),
|
|
42
|
+
});
|
|
43
|
+
const llm = clientFor(config.model);
|
|
44
|
+
const thinkingModel = !isClient(config.model) && config.model.compat?.thinkingFormat !== undefined && config.model.compat.thinkingFormat !== "none";
|
|
45
|
+
const thinking = config.thinking ?? (thinkingModel ? "medium" : undefined);
|
|
46
|
+
const system = config.system ?? buildSystemPrompt({ tools, cwd: env.cwd, platform: process.platform });
|
|
47
|
+
// bundles are opened and a plugin that appears twice is installed once; a missing requirement, a cycle or two plugins with one name fail here, before anything runs
|
|
48
|
+
const plugins = compose(config.plugins ?? corePlugins());
|
|
49
|
+
const notInherited = new Set(config.subAgents?.notInherited ?? ["agents", "limits", "context", "tools"]);
|
|
50
|
+
const inherited = plugins.filter((plugin) => !notInherited.has(plugin.name));
|
|
51
|
+
if (mcpTools.length > 0 && expose === "search")
|
|
52
|
+
plugins.push(searchPlugin({ catalog: mcpTools }));
|
|
53
|
+
// a plugin that offers `authorize` (the review plugin does) is asked before each call a script makes, because those calls do not pass the loop's hooks
|
|
54
|
+
const authorize = plugins.map((plugin) => plugin.authorize).find((candidate) => typeof candidate === "function");
|
|
55
|
+
if (mcpTools.length > 0 && expose === "codemode")
|
|
56
|
+
plugins.push(codemodePlugin({ catalog: mcpTools, ...(authorize ? { authorize } : {}) }));
|
|
57
|
+
const childLogs = [];
|
|
58
|
+
const childModels = new Map(Object.entries(config.subAgents?.models ?? {}).map(([name, model]) => [name, clientFor(model)]));
|
|
59
|
+
if (config.subAgents) {
|
|
60
|
+
const childLogDirectory = config.sessionFile?.replace(/\.jsonl$/, "") ?? undefined;
|
|
61
|
+
plugins.push(agentsPlugin({
|
|
62
|
+
...(({ models: _models, notInherited: _notInherited, ...rest }) => rest)(config.subAgents),
|
|
63
|
+
createChild: ({ definition, maxTurns, model }) => createKernel({
|
|
64
|
+
llm: (model !== undefined && childModels.get(model)) || llm,
|
|
65
|
+
env,
|
|
66
|
+
system: `${definition.systemPrompt ?? system}\n\n${SUB_AGENT_NOTE}`,
|
|
67
|
+
tools: definition.tools ? tools.filter((tool) => definition.tools?.includes(tool.name)) : tools,
|
|
68
|
+
plugins: maxTurns !== undefined ? [...inherited, limitsPlugin({ maxTurns })] : inherited,
|
|
69
|
+
...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
|
|
70
|
+
...(thinking !== undefined ? { thinking } : {}),
|
|
71
|
+
...(config.compaction !== undefined ? { compaction: config.compaction } : {}),
|
|
72
|
+
}),
|
|
73
|
+
...(childLogDirectory
|
|
74
|
+
? {
|
|
75
|
+
openLog: async (id) => {
|
|
76
|
+
const log = await openFileLog(`${childLogDirectory}/agents/${id}.jsonl`, { cwd: env.cwd });
|
|
77
|
+
childLogs.push(log);
|
|
78
|
+
return log;
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
: {}),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
const kernel = createKernel({
|
|
85
|
+
llm,
|
|
86
|
+
env,
|
|
87
|
+
system,
|
|
88
|
+
tools: declared,
|
|
89
|
+
plugins,
|
|
90
|
+
...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
|
|
91
|
+
...(thinking !== undefined ? { thinking } : {}),
|
|
92
|
+
...(config.now ? { now: config.now } : {}),
|
|
93
|
+
...(config.wake ? { onSignal: () => onBackgroundEnd() } : {}),
|
|
94
|
+
...(config.compaction !== undefined ? { compaction: config.compaction } : {}),
|
|
95
|
+
...(config.goal ? { goal: config.goal } : {}),
|
|
96
|
+
...(config.prompts ? { prompts: config.prompts } : {}),
|
|
97
|
+
});
|
|
98
|
+
const fileLog = config.sessionFile ? await openFileLog(config.sessionFile, { cwd: env.cwd }) : undefined;
|
|
99
|
+
const log = fileLog ?? createMemoryLog();
|
|
100
|
+
let active;
|
|
101
|
+
let closed = false;
|
|
102
|
+
let timer;
|
|
103
|
+
let checkUsed = false; // the one proactive check per quiet period has been made
|
|
104
|
+
function start(input) {
|
|
105
|
+
const handle = kernel.run(log, input);
|
|
106
|
+
active = handle;
|
|
107
|
+
void handle.result.finally(() => {
|
|
108
|
+
if (active === handle)
|
|
109
|
+
active = undefined;
|
|
110
|
+
afterRun();
|
|
111
|
+
});
|
|
112
|
+
return handle;
|
|
113
|
+
}
|
|
114
|
+
function wakeWith(input) {
|
|
115
|
+
if (closed || active || !config.wake)
|
|
116
|
+
return;
|
|
117
|
+
config.wake.onRun(start(input));
|
|
118
|
+
}
|
|
119
|
+
function onBackgroundEnd() {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
checkUsed = false;
|
|
122
|
+
wakeWith(undefined); // while a run is going, it delivers the notice itself or afterRun does
|
|
123
|
+
}
|
|
124
|
+
/** Called when a run ends: wakes for anything the run did not get to tell the agent, or arms the one check before the cache expires. */
|
|
125
|
+
function afterRun() {
|
|
126
|
+
if (closed || !config.wake)
|
|
127
|
+
return;
|
|
128
|
+
const pending = kernel.pending(log);
|
|
129
|
+
if (pending.signals.length > 0) {
|
|
130
|
+
wakeWith(undefined);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (pending.watching.length === 0 || checkUsed)
|
|
134
|
+
return;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
const now = kernel.pending(log);
|
|
138
|
+
if (now.signals.length > 0)
|
|
139
|
+
return wakeWith(undefined);
|
|
140
|
+
if (now.watching.length === 0)
|
|
141
|
+
return;
|
|
142
|
+
checkUsed = true;
|
|
143
|
+
wakeWith(checkInput(now.watching));
|
|
144
|
+
}, config.wake.cacheCheckMs ?? CACHE_CHECK_MS);
|
|
145
|
+
timer.unref();
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
log,
|
|
149
|
+
mcp: connection?.status ?? {},
|
|
150
|
+
rewindTo: (entryId) => log.rewindTo(entryId),
|
|
151
|
+
run: (input) => start(input),
|
|
152
|
+
queue: (text) => active?.queue(text),
|
|
153
|
+
steer: (text) => active?.steer(text),
|
|
154
|
+
cancel: () => active?.cancel(),
|
|
155
|
+
close: async () => {
|
|
156
|
+
closed = true;
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
await connection?.close();
|
|
159
|
+
await env.dispose(); // stops commands that are still running in the background
|
|
160
|
+
await Promise.all(childLogs.map((log) => log.close()));
|
|
161
|
+
await fileLog?.close();
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export { buildSystemPrompt } from "./system-prompt.js";
|
|
166
|
+
export { bundle, compose, CompositionError } from "./compose.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Tool } from "../../kernel/src/index.js";
|
|
2
|
+
export interface SystemPromptOptions {
|
|
3
|
+
tools: readonly Tool[];
|
|
4
|
+
cwd: string;
|
|
5
|
+
platform: string;
|
|
6
|
+
/** Extra rules from plugins. */
|
|
7
|
+
rules?: string[];
|
|
8
|
+
}
|
|
9
|
+
/** Builds the system prompt: preamble, tools, rules, environment. Callers can replace the whole thing. */
|
|
10
|
+
export declare function buildSystemPrompt(options: SystemPromptOptions): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Rule texts adapted from the ideas in Pi's system prompt (MIT, Copyright (c) 2025 Mario Zechner) and the
|
|
3
|
+
* task-execution principles of OpenAI Codex's base instructions (Apache-2.0); reworded and shortened.
|
|
4
|
+
* See THIRD_PARTY_NOTICES.md.
|
|
5
|
+
*/
|
|
6
|
+
const PREAMBLE = "You are a coding agent. You help the user by reading files, running commands, editing code and writing new files. Be precise, safe and helpful.";
|
|
7
|
+
const RULES = [
|
|
8
|
+
"Keep going until the user's request is completely resolved before you end your turn. Do not stop at a plan or a partial result.",
|
|
9
|
+
"Fix problems at the root cause rather than patching symptoms, and avoid unneeded complexity.",
|
|
10
|
+
"Keep changes minimal and consistent with the style of the existing code. Do not fix unrelated bugs; you may mention them in your final message.",
|
|
11
|
+
"In an existing codebase, do exactly what was asked with precision. In a brand-new project you may be more ambitious.",
|
|
12
|
+
"If the project has tests or a way to build or run, use them to check your work. Start with the most specific check for what you changed, then widen.",
|
|
13
|
+
"Do not re-read a file only to confirm an edit succeeded; a failed edit returns an error.",
|
|
14
|
+
"Do not commit changes or create branches unless the user asks.",
|
|
15
|
+
"Do not add comments or copyright headers to code unless the user asks.",
|
|
16
|
+
"Before a long or risky step, say in one short sentence what you are about to do. Do not narrate trivial reads.",
|
|
17
|
+
"Be concise. Show file paths clearly so the user can open them. Do not paste the full contents of files you have already written.",
|
|
18
|
+
"Instruction files from the project (AGENTS.md) and direct user messages take precedence over these rules.",
|
|
19
|
+
];
|
|
20
|
+
/** Rules that belong to one tool; they appear only when that tool is available. */
|
|
21
|
+
const TOOL_RULES = {
|
|
22
|
+
read: ["Use read to examine files instead of cat or sed."],
|
|
23
|
+
write: ["Use write only for new files or complete rewrites. Keep each file under about 2000 lines; split larger work across several files, because a very long tool call can be cut off."],
|
|
24
|
+
edit: [
|
|
25
|
+
"Use edit for precise changes (edits[].oldText must match exactly).",
|
|
26
|
+
"When changing multiple separate locations in one file, use one edit call with several entries in edits[].",
|
|
27
|
+
"Each edits[].oldText is matched against the original file, not after earlier edits. Do not emit overlapping or nested edits; merge nearby changes.",
|
|
28
|
+
"Keep edits[].oldText as small as possible while still unique in the file.",
|
|
29
|
+
],
|
|
30
|
+
bash: [
|
|
31
|
+
"When searching for text or files, prefer rg (rg --files for file names); fall back to grep or find if rg is missing.",
|
|
32
|
+
"Do not use scripts to print large chunks of a file; use read with offset and limit.",
|
|
33
|
+
"Commands run without a terminal and cannot read input. Always pass non-interactive flags (for example --yes, -y, --no-input, CI=1); a command that waits for input will hang.",
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
const firstSentence = (description) => (description.split(/(?<=\.)\s/)[0] ?? description).trim();
|
|
37
|
+
/** Builds the system prompt: preamble, tools, rules, environment. Callers can replace the whole thing. */
|
|
38
|
+
export function buildSystemPrompt(options) {
|
|
39
|
+
const toolLines = options.tools.map((tool) => `- ${tool.name}: ${firstSentence(tool.description)}`);
|
|
40
|
+
const rules = [...new Set([...options.tools.flatMap((tool) => TOOL_RULES[tool.name] ?? []), ...RULES, ...(options.rules ?? [])])];
|
|
41
|
+
return [
|
|
42
|
+
PREAMBLE,
|
|
43
|
+
options.tools.length > 0 ? `<tools>\n${toolLines.join("\n")}\n</tools>` : "",
|
|
44
|
+
`<rules>\n${rules.map((rule) => `- ${rule}`).join("\n")}\n</rules>`,
|
|
45
|
+
`<environment>\nWorking directory: ${options.cwd}\nPlatform: ${options.platform}\n</environment>`,
|
|
46
|
+
]
|
|
47
|
+
.filter((section) => section !== "")
|
|
48
|
+
.join("\n\n");
|
|
49
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
const MAX_OUTPUT_CHARS = 20_000_000;
|
|
7
|
+
const KILL_GRACE_MS = 1500;
|
|
8
|
+
function killGroup(pid, signal) {
|
|
9
|
+
if (pid === undefined)
|
|
10
|
+
return;
|
|
11
|
+
try {
|
|
12
|
+
process.kill(-pid, signal);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// the group is already gone
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Commands run in their own process group, so they would outlive a host process that is stopped without closing the environment
|
|
19
|
+
// (Ctrl-C, process.exit, a script that ends while a background command still runs). Every group still running is stopped when the host exits.
|
|
20
|
+
const liveGroups = new Set();
|
|
21
|
+
let exitHooked = false;
|
|
22
|
+
function stopAllGroups() {
|
|
23
|
+
for (const pid of liveGroups)
|
|
24
|
+
killGroup(pid, "SIGKILL");
|
|
25
|
+
}
|
|
26
|
+
function hookExit() {
|
|
27
|
+
if (exitHooked)
|
|
28
|
+
return;
|
|
29
|
+
exitHooked = true;
|
|
30
|
+
process.on("exit", stopAllGroups);
|
|
31
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
32
|
+
const onSignal = () => {
|
|
33
|
+
stopAllGroups();
|
|
34
|
+
process.removeListener(signal, onSignal);
|
|
35
|
+
// with no other handler the signal would have ended the process; our handler must not turn that into "keeps running"
|
|
36
|
+
if (process.listenerCount(signal) === 0)
|
|
37
|
+
process.kill(process.pid, signal);
|
|
38
|
+
};
|
|
39
|
+
process.on(signal, onSignal);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The environment tools run in when there is no sandbox: the local machine. */
|
|
43
|
+
export function createLocalEnvironment(cwd) {
|
|
44
|
+
const root = resolve(cwd);
|
|
45
|
+
const resolvePath = (path) => (isAbsolute(path) ? resolve(path) : resolve(root, path));
|
|
46
|
+
const running = new Set();
|
|
47
|
+
let outputDir;
|
|
48
|
+
let counter = 0;
|
|
49
|
+
const nextOutputFile = async () => {
|
|
50
|
+
outputDir ??= mkdtemp(join(tmpdir(), "sakura-env-"));
|
|
51
|
+
return join(await outputDir, `command-${++counter}.log`);
|
|
52
|
+
};
|
|
53
|
+
function start(command, options) {
|
|
54
|
+
const child = spawnProcess("/bin/bash", ["-c", command], {
|
|
55
|
+
cwd: options.cwd ? resolvePath(options.cwd) : root,
|
|
56
|
+
detached: true, // its own process group, so the whole tree can be stopped
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
});
|
|
59
|
+
const pid = child.pid ?? -1;
|
|
60
|
+
if (child.pid !== undefined) {
|
|
61
|
+
hookExit();
|
|
62
|
+
liveGroups.add(child.pid);
|
|
63
|
+
}
|
|
64
|
+
let output = "";
|
|
65
|
+
let finished = false;
|
|
66
|
+
let killScheduled = false;
|
|
67
|
+
const fileReady = nextOutputFile().then((path) => ({ path, stream: createWriteStream(path) }));
|
|
68
|
+
const collect = (chunk) => {
|
|
69
|
+
output += chunk.toString("utf8");
|
|
70
|
+
if (output.length > MAX_OUTPUT_CHARS)
|
|
71
|
+
output = output.slice(output.length - MAX_OUTPUT_CHARS);
|
|
72
|
+
void fileReady.then(({ stream }) => stream.write(chunk));
|
|
73
|
+
};
|
|
74
|
+
child.stdout.on("data", collect);
|
|
75
|
+
child.stderr.on("data", collect);
|
|
76
|
+
const kill = () => {
|
|
77
|
+
killGroup(child.pid, "SIGTERM");
|
|
78
|
+
if (!killScheduled) {
|
|
79
|
+
killScheduled = true;
|
|
80
|
+
setTimeout(() => killGroup(child.pid, "SIGKILL"), KILL_GRACE_MS).unref();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
if (options.signal?.aborted)
|
|
84
|
+
kill();
|
|
85
|
+
options.signal?.addEventListener("abort", kill, { once: true });
|
|
86
|
+
let outputFile = "";
|
|
87
|
+
const done = new Promise((resolveResult) => {
|
|
88
|
+
let settled = false;
|
|
89
|
+
const finish = async (exitCode) => {
|
|
90
|
+
if (settled)
|
|
91
|
+
return;
|
|
92
|
+
settled = true;
|
|
93
|
+
options.signal?.removeEventListener("abort", kill);
|
|
94
|
+
child.stdout.destroy();
|
|
95
|
+
child.stderr.destroy();
|
|
96
|
+
const file = await fileReady;
|
|
97
|
+
await new Promise((closed) => file.stream.end(closed));
|
|
98
|
+
finished = true;
|
|
99
|
+
if (child.pid !== undefined)
|
|
100
|
+
liveGroups.delete(child.pid);
|
|
101
|
+
running.delete(handle);
|
|
102
|
+
resolveResult({ exitCode, output, timedOut: false });
|
|
103
|
+
};
|
|
104
|
+
child.on("error", (error) => {
|
|
105
|
+
output += `\n${error.message}`;
|
|
106
|
+
void finish(null);
|
|
107
|
+
});
|
|
108
|
+
// A command that leaves a background process holding the pipes open must not make us wait for it:
|
|
109
|
+
// once the shell itself has exited, give the pipes a moment to drain and finish.
|
|
110
|
+
child.on("exit", (code) => setTimeout(() => void finish(code), 50));
|
|
111
|
+
child.on("close", (code) => void finish(code));
|
|
112
|
+
});
|
|
113
|
+
void fileReady.then(({ path }) => {
|
|
114
|
+
outputFile = path;
|
|
115
|
+
});
|
|
116
|
+
const handle = {
|
|
117
|
+
pid,
|
|
118
|
+
get outputFile() {
|
|
119
|
+
return outputFile;
|
|
120
|
+
},
|
|
121
|
+
wait: () => done,
|
|
122
|
+
kill,
|
|
123
|
+
get finished() {
|
|
124
|
+
return finished;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
running.add(handle);
|
|
128
|
+
return handle;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
cwd: root,
|
|
132
|
+
resolve: resolvePath,
|
|
133
|
+
readFile: async (path) => new Uint8Array(await readFile(resolvePath(path))),
|
|
134
|
+
async writeFile(path, data) {
|
|
135
|
+
const target = resolvePath(path);
|
|
136
|
+
await mkdir(dirname(target), { recursive: true });
|
|
137
|
+
await writeFile(target, data);
|
|
138
|
+
},
|
|
139
|
+
async stat(path) {
|
|
140
|
+
try {
|
|
141
|
+
const info = await stat(resolvePath(path));
|
|
142
|
+
return { isFile: info.isFile(), isDirectory: info.isDirectory(), size: info.size };
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code === "ENOENT")
|
|
146
|
+
return undefined;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
async list(path) {
|
|
151
|
+
return (await readdir(resolvePath(path))).sort();
|
|
152
|
+
},
|
|
153
|
+
spawn: start,
|
|
154
|
+
async exec(command, options) {
|
|
155
|
+
const handle = start(command, options.signal ? { ...(options.cwd ? { cwd: options.cwd } : {}), signal: options.signal } : options.cwd ? { cwd: options.cwd } : {});
|
|
156
|
+
let timedOut = false;
|
|
157
|
+
const timer = options.timeoutMs !== undefined
|
|
158
|
+
? setTimeout(() => {
|
|
159
|
+
timedOut = true;
|
|
160
|
+
handle.kill();
|
|
161
|
+
}, options.timeoutMs)
|
|
162
|
+
: undefined;
|
|
163
|
+
const result = await handle.wait();
|
|
164
|
+
if (timer)
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
return { ...result, timedOut };
|
|
167
|
+
},
|
|
168
|
+
async dispose() {
|
|
169
|
+
for (const handle of [...running])
|
|
170
|
+
handle.kill();
|
|
171
|
+
await Promise.all([...running].map((handle) => handle.wait()));
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Environment, ExecResult } from "../../kernel/src/index.js";
|
|
2
|
+
/** The one thing a remote environment needs: a way to run a shell command somewhere else and get its output back. */
|
|
3
|
+
export type RemoteRunner = (command: string, options: {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}) => Promise<ExecResult>;
|
|
8
|
+
/**
|
|
9
|
+
* An environment on another machine (a container, say) reached only through `run`. Files are read and written
|
|
10
|
+
* with base64 over the shell, and long commands are started detached so they can outlive a single call.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createRemoteEnvironment(options: {
|
|
13
|
+
cwd: string;
|
|
14
|
+
run: RemoteRunner;
|
|
15
|
+
}): Environment;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
|
|
4
|
+
const CHUNK = 48_000;
|
|
5
|
+
/**
|
|
6
|
+
* An environment on another machine (a container, say) reached only through `run`. Files are read and written
|
|
7
|
+
* with base64 over the shell, and long commands are started detached so they can outlive a single call.
|
|
8
|
+
*/
|
|
9
|
+
export function createRemoteEnvironment(options) {
|
|
10
|
+
const root = posix.resolve("/", options.cwd);
|
|
11
|
+
const workDir = posix.join(root, ".sakura", "remote");
|
|
12
|
+
const { run } = options;
|
|
13
|
+
const resolvePath = (path) => (path.startsWith("/") ? posix.normalize(path) : posix.resolve(root, path));
|
|
14
|
+
const running = new Set();
|
|
15
|
+
let counter = 0;
|
|
16
|
+
const prefix = randomBytes(3).toString("hex");
|
|
17
|
+
async function must(command) {
|
|
18
|
+
const result = await run(command, { cwd: root });
|
|
19
|
+
if (result.exitCode !== 0)
|
|
20
|
+
throw new Error(result.output.trim() || `command failed with exit code ${result.exitCode}`);
|
|
21
|
+
return result.output;
|
|
22
|
+
}
|
|
23
|
+
function spawn(command, spawnOptions) {
|
|
24
|
+
const id = `${prefix}-${++counter}`;
|
|
25
|
+
const log = posix.join(workDir, `${id}.log`);
|
|
26
|
+
const codeFile = posix.join(workDir, `${id}.code`);
|
|
27
|
+
const scriptFile = posix.join(workDir, `${id}.sh`);
|
|
28
|
+
let pid = 0;
|
|
29
|
+
let finished = false;
|
|
30
|
+
let result;
|
|
31
|
+
const script = `cd ${quote(spawnOptions.cwd ? resolvePath(spawnOptions.cwd) : root)} || exit 1\n${command}\n`;
|
|
32
|
+
const started = run(`mkdir -p ${quote(workDir)} && echo ${Buffer.from(script).toString("base64")} | base64 -d > ${quote(scriptFile)} || exit $?; ` +
|
|
33
|
+
`setsid bash -c ${quote(`bash ${quote(scriptFile)} > ${quote(log)} 2>&1; echo $? > ${quote(codeFile)}`)} > /dev/null 2>&1 & echo $!`, { cwd: root }).then((outcome) => {
|
|
34
|
+
if (outcome.exitCode === 0)
|
|
35
|
+
pid = Number.parseInt(outcome.output.trim().split("\n").at(-1) ?? "", 10) || 0;
|
|
36
|
+
});
|
|
37
|
+
const kill = () => {
|
|
38
|
+
void started.then(() => (pid > 0 ? run(`kill -TERM -- -${pid} 2>/dev/null; sleep 1; kill -KILL -- -${pid} 2>/dev/null; true`, { cwd: root }) : undefined)).catch(() => undefined);
|
|
39
|
+
};
|
|
40
|
+
spawnOptions.signal?.addEventListener("abort", kill, { once: true });
|
|
41
|
+
let done;
|
|
42
|
+
const handle = {
|
|
43
|
+
get pid() {
|
|
44
|
+
return pid;
|
|
45
|
+
},
|
|
46
|
+
outputFile: log,
|
|
47
|
+
wait: () => done,
|
|
48
|
+
kill,
|
|
49
|
+
get finished() {
|
|
50
|
+
return finished;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
done = (async () => {
|
|
54
|
+
await started;
|
|
55
|
+
if (pid === 0) {
|
|
56
|
+
finished = true;
|
|
57
|
+
running.delete(handle);
|
|
58
|
+
return { exitCode: null, output: "the command could not be started", timedOut: false };
|
|
59
|
+
}
|
|
60
|
+
for (let attempt = 0;; attempt++) {
|
|
61
|
+
const state = await run(`if [ -f ${quote(codeFile)} ]; then cat ${quote(codeFile)}; elif kill -0 ${pid} 2>/dev/null; then echo running; else echo gone; fi`, {
|
|
62
|
+
cwd: root,
|
|
63
|
+
});
|
|
64
|
+
const text = state.output.trim();
|
|
65
|
+
if (text !== "running") {
|
|
66
|
+
const output = await run(`cat ${quote(log)} 2>/dev/null`, { cwd: root });
|
|
67
|
+
const exitCode = /^\d+$/.test(text) ? Number(text) : null;
|
|
68
|
+
finished = true;
|
|
69
|
+
running.delete(handle);
|
|
70
|
+
result = { exitCode, output: output.output, timedOut: false };
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 250 : 1000));
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
running.add(handle);
|
|
77
|
+
return handle;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
cwd: root,
|
|
81
|
+
resolve: resolvePath,
|
|
82
|
+
async readFile(path) {
|
|
83
|
+
const output = await must(`set -o pipefail; base64 < ${quote(resolvePath(path))} | tr -d '\\n'`);
|
|
84
|
+
return new Uint8Array(Buffer.from(output.trim(), "base64"));
|
|
85
|
+
},
|
|
86
|
+
async writeFile(path, data) {
|
|
87
|
+
const target = resolvePath(path);
|
|
88
|
+
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
89
|
+
const temp = `${target}.af-${randomBytes(4).toString("hex")}`;
|
|
90
|
+
await must(`mkdir -p ${quote(posix.dirname(target))} && : > ${quote(temp)}`);
|
|
91
|
+
const encoded = bytes.toString("base64");
|
|
92
|
+
for (let offset = 0; offset < encoded.length; offset += CHUNK) {
|
|
93
|
+
await must(`printf %s ${quote(encoded.slice(offset, offset + CHUNK))} >> ${quote(temp)}`);
|
|
94
|
+
}
|
|
95
|
+
await must(`base64 -d < ${quote(temp)} > ${quote(target)} && rm -f ${quote(temp)}`);
|
|
96
|
+
},
|
|
97
|
+
async stat(path) {
|
|
98
|
+
const p = quote(resolvePath(path));
|
|
99
|
+
const output = (await must(`if [ -d ${p} ]; then echo dir 0; elif [ -f ${p} ]; then echo file $(wc -c < ${p}); else echo none 0; fi`)).trim();
|
|
100
|
+
const [kind, size] = output.split(/\s+/);
|
|
101
|
+
if (kind === "none")
|
|
102
|
+
return undefined;
|
|
103
|
+
return { isFile: kind === "file", isDirectory: kind === "dir", size: Number(size) || 0 };
|
|
104
|
+
},
|
|
105
|
+
async list(path) {
|
|
106
|
+
const output = await must(`ls -A1 ${quote(resolvePath(path))}`);
|
|
107
|
+
return output.split("\n").filter(Boolean).sort();
|
|
108
|
+
},
|
|
109
|
+
spawn,
|
|
110
|
+
exec: (command, execOptions) => run(command, {
|
|
111
|
+
cwd: execOptions.cwd ? resolvePath(execOptions.cwd) : root,
|
|
112
|
+
...(execOptions.timeoutMs !== undefined ? { timeoutMs: execOptions.timeoutMs } : {}),
|
|
113
|
+
...(execOptions.signal ? { signal: execOptions.signal } : {}),
|
|
114
|
+
}),
|
|
115
|
+
async dispose() {
|
|
116
|
+
for (const handle of [...running])
|
|
117
|
+
handle.kill();
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function createChannel() {
|
|
2
|
+
const buffer = [];
|
|
3
|
+
let ended = false;
|
|
4
|
+
let wake;
|
|
5
|
+
const notify = () => {
|
|
6
|
+
wake?.();
|
|
7
|
+
wake = undefined;
|
|
8
|
+
};
|
|
9
|
+
async function* iterate() {
|
|
10
|
+
let position = 0;
|
|
11
|
+
while (true) {
|
|
12
|
+
if (position < buffer.length) {
|
|
13
|
+
yield buffer[position++];
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (ended)
|
|
17
|
+
return;
|
|
18
|
+
await new Promise((resolve) => {
|
|
19
|
+
wake = resolve;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
push(value) {
|
|
25
|
+
buffer.push(value);
|
|
26
|
+
notify();
|
|
27
|
+
},
|
|
28
|
+
end() {
|
|
29
|
+
ended = true;
|
|
30
|
+
notify();
|
|
31
|
+
},
|
|
32
|
+
iterable: { [Symbol.asyncIterator]: iterate },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { LogEntry, Message } from "./types.js";
|
|
2
|
+
export interface CompactionConfig {
|
|
3
|
+
/** Compact when the context is within this many tokens of the model's window. */
|
|
4
|
+
reserveTokens: number;
|
|
5
|
+
/** Roughly how many recent tokens stay word for word after a compaction. */
|
|
6
|
+
keepRecentTokens: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const defaultCompaction: CompactionConfig;
|
|
9
|
+
/** A rough token count: four characters per token, a flat cost for images. */
|
|
10
|
+
export declare function estimateTokens(message: Message): number;
|
|
11
|
+
/** Tokens in the context now: the last reported usage plus an estimate for what came after it. */
|
|
12
|
+
export declare function contextTokens(messages: readonly Message[]): number;
|
|
13
|
+
export declare function shouldCompact(messages: readonly Message[], contextWindow: number, config: CompactionConfig): boolean;
|
|
14
|
+
export interface CompactionPlan {
|
|
15
|
+
/** Id of the first entry that stays word for word. */
|
|
16
|
+
firstKeptEntryId: string;
|
|
17
|
+
/** True when the cut falls inside a turn (no user message boundary was available). */
|
|
18
|
+
splitTurn: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Chooses where to cut. History before the cut is summarised, the rest stays. The cut is placed at a user
|
|
22
|
+
* message where possible; it is never placed on a tool result, so a call and its results stay together.
|
|
23
|
+
* Returns undefined when there is nothing worth summarising.
|
|
24
|
+
*/
|
|
25
|
+
export declare function planCompaction(branch: readonly LogEntry[], config: CompactionConfig): CompactionPlan | undefined;
|