@elbow-dev/acp 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 +4 -0
- package/acp.mjs +623 -0
- package/agent.mjs +875 -0
- package/bin.mjs +101 -0
- package/package.json +21 -4
- package/README.md +0 -3
package/agent.mjs
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_TOKENS = 32_768;
|
|
6
|
+
const MAX_CALLS = 128;
|
|
7
|
+
// What one call returns, as Claude Code's Read (2,000 lines, long lines cut) and Bash (30k characters) do: a whole
|
|
8
|
+
// large file or build log in every later turn's context buries the task and slows each turn.
|
|
9
|
+
const READ_LINES = 2000;
|
|
10
|
+
const LINE_CHARS = 2000;
|
|
11
|
+
const OUTPUT_CHARS = 30_000;
|
|
12
|
+
const TREE_FILES = 200;
|
|
13
|
+
const SEARCH_RESULTS = 100;
|
|
14
|
+
const SEARCH_SECONDS = 30;
|
|
15
|
+
// Lines of context around an edit's new text in its result, as Claude Code shows them, so the model needn't read the
|
|
16
|
+
// file again to see the edit landed.
|
|
17
|
+
const AROUND = 3;
|
|
18
|
+
const SNIPPET_LINES = 40;
|
|
19
|
+
// Calls that change nothing run together, as Claude Code runs its reads and searches.
|
|
20
|
+
const READ_ONLY = new Set(['read_file', 'search']);
|
|
21
|
+
const BASH_SECONDS = 120;
|
|
22
|
+
const CHECK_SECONDS = 120;
|
|
23
|
+
// A turn that ends with failing checks is told so and goes on, this many times, before the run fails.
|
|
24
|
+
const CHECK_ROUNDS = 3;
|
|
25
|
+
// GLM and DeepSeek on the ultra modes read on and on to be certain: two threads in production spent all their calls
|
|
26
|
+
// exploring and changed nothing. Every this many calls without a write, and near the limit, the next turn is told so.
|
|
27
|
+
const NUDGE_EVERY = 20;
|
|
28
|
+
const LAST_CALLS = 16;
|
|
29
|
+
// Past this many tokens of context the run's history is summarized and the run goes on from the summary, as Claude
|
|
30
|
+
// Code and Codex compact. Below every routed model's window and OpenAI's 272k long-context price, and early enough
|
|
31
|
+
// that the ultra modes' models, which lose the thread in long contexts, still work from a short one.
|
|
32
|
+
export const COMPACT_TOKENS = 100_000;
|
|
33
|
+
// Elbow tells a compaction request by the start of Claude Code's prompt (server/messages.js), and then keeps it on the
|
|
34
|
+
// thread's model with tools off, and reads the next conversation from its summary (server/content.js).
|
|
35
|
+
export const COMPACTION = `Your task is to create a detailed summary of the conversation so far, because the context is nearly full and the run will go on from your summary alone, with the original task. Call no tools; reply only with the summary, in plain text, covering:
|
|
36
|
+
1. The task and what it asks for, including details the person gave that are easy to lose.
|
|
37
|
+
2. Files read and what matters in them: paths, the functions, types and names involved, conventions to match.
|
|
38
|
+
3. Every change made so far, file by file, and whether it is finished.
|
|
39
|
+
4. Commands run that matter: tests, lint, builds, and what they showed last, with the exact errors still open.
|
|
40
|
+
5. What was tried and didn't work, and why.
|
|
41
|
+
6. What is left, and the very next step.`;
|
|
42
|
+
const CONTINUED =
|
|
43
|
+
'This session is being continued from a previous conversation that ran out of context.';
|
|
44
|
+
|
|
45
|
+
const object = (properties, required) => ({
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties,
|
|
48
|
+
required,
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
});
|
|
51
|
+
const text = { type: 'string' };
|
|
52
|
+
|
|
53
|
+
export const TOOLS = [
|
|
54
|
+
{
|
|
55
|
+
name: 'read_file',
|
|
56
|
+
description: `Read a file in the checkout, by its path relative to the repository root. Lines come numbered ("12\t…"); the number and tab aren't part of the file, so leave them out of edit_file's old_string. Returns up to ${READ_LINES} lines from offset (the first line is 1); read the part you need of a long file.`,
|
|
57
|
+
input_schema: object(
|
|
58
|
+
{ path: text, offset: { type: 'integer' }, limit: { type: 'integer' } },
|
|
59
|
+
['path'],
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'write_file',
|
|
64
|
+
description:
|
|
65
|
+
'Write a whole file, creating it and its folders if needed. Use it for new files and rewrites.',
|
|
66
|
+
input_schema: object({ path: text, content: text }, ['path', 'content']),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'edit_file',
|
|
70
|
+
description:
|
|
71
|
+
"Replace old_string with new_string in a file. old_string must appear exactly once, unless replace_all. For several changes, in one file or many, give them as edits instead (each with its own path, old_string and new_string): they apply in order, and if one doesn't match none is made. Returns the changed lines, numbered, so there's no need to read the file again; when old_string isn't found, the lines closest to it.",
|
|
72
|
+
input_schema: object(
|
|
73
|
+
{
|
|
74
|
+
path: text,
|
|
75
|
+
old_string: text,
|
|
76
|
+
new_string: text,
|
|
77
|
+
replace_all: { type: 'boolean' },
|
|
78
|
+
edits: {
|
|
79
|
+
type: 'array',
|
|
80
|
+
items: object(
|
|
81
|
+
{ path: text, old_string: text, new_string: text, replace_all: { type: 'boolean' } },
|
|
82
|
+
['path', 'old_string', 'new_string'],
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
[],
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'search',
|
|
91
|
+
description: `Search the checkout with ripgrep (a regular expression; .gitignore'd files are skipped). Returns path:line:text, or with files_only the matching files, or with no pattern every file under path; at most limit results (default ${SEARCH_RESULTS}). Narrow it with path (a folder or file) and glob (such as "*.mjs").`,
|
|
92
|
+
input_schema: object(
|
|
93
|
+
{
|
|
94
|
+
pattern: text,
|
|
95
|
+
path: text,
|
|
96
|
+
glob: text,
|
|
97
|
+
files_only: { type: 'boolean' },
|
|
98
|
+
ignore_case: { type: 'boolean' },
|
|
99
|
+
limit: { type: 'integer' },
|
|
100
|
+
},
|
|
101
|
+
[],
|
|
102
|
+
),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'bash',
|
|
106
|
+
description: `Run a shell command in the repository root (bash -c) and return its exit code and output. Use it to install dependencies, run tests and use git; search, read and change files with the other tools. A non-zero exit is reported, not an error: rg finding nothing exits 1. Output past ${OUTPUT_CHARS} characters keeps its start and end. Stops after timeout_seconds (default ${BASH_SECONDS}, at most 600).`,
|
|
107
|
+
input_schema: object({ command: text, timeout_seconds: { type: 'integer' } }, ['command']),
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const BATCH =
|
|
112
|
+
'Make independent tool calls together in one reply: read the three files you need, or search for two names, in one turn rather than three; reads and searches in one reply run at once. Make related changes in one edit_file call with edits. Each reply costs a round trip; only calls that depend on an earlier result need a turn of their own.';
|
|
113
|
+
const COMMIT_EARLY = `Commit to a change early. Look only at the files the task names or a search turns up, then make your best edit within your first ${NUDGE_EVERY} tool calls and let the tests tell you what's wrong. You don't need to be sure before you edit: a first attempt you then fix costs less than more reading. You have ${MAX_CALLS} tool calls in all.`;
|
|
114
|
+
|
|
115
|
+
// What Claude Code and Codex tell their agents and our first runs lacked: a semver.mjs that passed its tests came back
|
|
116
|
+
// with function declarations in a repository of const arrows, JSDoc and a section divider, and range bugs no test
|
|
117
|
+
// covered.
|
|
118
|
+
export const system = ({ workspace, checks, guide, memory = [], context = '', base = null }) =>
|
|
119
|
+
[
|
|
120
|
+
`You are a coding agent working on one task in a git checkout at ${workspace}, on a machine of its own.`,
|
|
121
|
+
'Use the tools to read the code, make the change the task asks for and run the tests. Paths are relative to the repository root.',
|
|
122
|
+
BATCH,
|
|
123
|
+
COMMIT_EARLY,
|
|
124
|
+
"Write code that reads like the code around it: match how the files you've read declare functions, name things and comment. For a new file with nothing of its own to match, read one neighbouring source file first. Comment only what the code can't say: no file headers, section dividers or comments that restate the next line.",
|
|
125
|
+
'Do what the task asks, and handle the cases its own spec or documentation describes even where no test covers them; nothing more. Don\'t go looking for further cases, refactors or improvements.',
|
|
126
|
+
'If the repository has a lint or format command (a package.json script, or a Biome, ESLint or Prettier config), run it once on what you changed before you finish.',
|
|
127
|
+
// Threads in production spent up to 95 calls hunting for a GitHub credential to push with, and rebased onto a stale
|
|
128
|
+
// base when fetch failed: the runner does all of that, and the agent has no network git.
|
|
129
|
+
`Elbow commits what you leave in the checkout, pushes it and opens or updates the pull request once you finish; you have no GitHub access, and git can't reach the network. Don't commit, push, fetch, stash, reset or switch branches, and don't look for credentials.${base ? ` origin/${base} is the base as the run began: git diff origin/${base} shows the branch's changes. When the task asks to rebase on or merge ${base}, run git merge origin/${base} and resolve any conflicts; Elbow keeps the merge.` : ''}`,
|
|
130
|
+
checks.length
|
|
131
|
+
? `The task is done when these tests pass with node --test: ${checks.join(', ')}. Don't edit them: they are put back as they were before they run.`
|
|
132
|
+
: 'Run the tests that cover your change before you finish.',
|
|
133
|
+
'When the work is done, reply with one or two plain sentences on what you changed and why, and call no more tools. It becomes the pull request description: no headings, lists, file-by-file notes or test results.',
|
|
134
|
+
...(context ? [`The checkout as the run began:\n${context}`] : []),
|
|
135
|
+
...(memory.length
|
|
136
|
+
? [`What the team has saved about this repository:\n${memory.map((fact) => `- ${fact}`).join('\n')}`]
|
|
137
|
+
: []),
|
|
138
|
+
...(guide ? [`The repository's own instructions for agents (${guide.file}) follow; they win over anything above.\n\n${guide.text}`] : []),
|
|
139
|
+
].join('\n');
|
|
140
|
+
|
|
141
|
+
// The same agent on a benchmark task (eval/agentic/thread.mjs): no pull request, no repository of the team's, and a
|
|
142
|
+
// task that may be about the whole machine rather than one checkout.
|
|
143
|
+
export const benchSystem = ({ workspace, guide, context = '' }) =>
|
|
144
|
+
[
|
|
145
|
+
`You are a coding agent working on one task on a machine of its own, in ${workspace}. Nobody will answer questions: decide for yourself and keep going until the task is done.`,
|
|
146
|
+
`read_file, write_file, edit_file and search take paths relative to ${workspace} and stay inside it; for anything elsewhere on the machine, use bash.`,
|
|
147
|
+
BATCH,
|
|
148
|
+
COMMIT_EARLY,
|
|
149
|
+
'Do what the task asks, and check the result against each of its requirements before you finish; nothing more.',
|
|
150
|
+
'When the work is done, reply with one or two plain sentences on what you did, and call no more tools.',
|
|
151
|
+
...(context ? [`${workspace} as the run began:\n${context}`] : []),
|
|
152
|
+
...(guide ? [`The repository's own instructions for agents (${guide.file}) follow; they win over anything above.\n\n${guide.text}`] : []),
|
|
153
|
+
].join('\n');
|
|
154
|
+
|
|
155
|
+
// The same agent in a person's own checkout, through an app such as T3 Code (cli/acp.mjs): they read each reply and
|
|
156
|
+
// answer, and a turn may be a question rather than a change.
|
|
157
|
+
export const chatSystem = ({ workspace, guide, context = '' }) =>
|
|
158
|
+
[
|
|
159
|
+
`You are Elbow, a coding agent working with a person in their git checkout at ${workspace}, on their own machine. They read your replies and answer them.`,
|
|
160
|
+
'Do what their message asks: answer a question from the code without changing files, and make a change when they ask for one. If a request is unclear in a way that matters, ask before you change anything.',
|
|
161
|
+
BATCH,
|
|
162
|
+
"Write code that reads like the code around it: match how the files you've read declare functions, name things and comment. Comment only what the code can't say.",
|
|
163
|
+
'Do what was asked and nothing more: no further cases, refactors or improvements they didn\'t ask for.',
|
|
164
|
+
'Run the tests that cover a change, and the repository\'s lint or format command if it has one, before you finish.',
|
|
165
|
+
"Don't commit, push, reset, stash or switch branches unless they ask you to.",
|
|
166
|
+
'When you are done, reply briefly with the answer or what you changed and why. Markdown is fine.',
|
|
167
|
+
...(context ? [`The checkout as the conversation began:\n${context}`] : []),
|
|
168
|
+
...(guide ? [`The repository's own instructions for agents (${guide.file}) follow; they win over anything above.\n\n${guide.text}`] : []),
|
|
169
|
+
].join('\n');
|
|
170
|
+
|
|
171
|
+
const GUIDE_CHARS = 32 * 1024;
|
|
172
|
+
|
|
173
|
+
// The repository's instructions for coding agents, as Claude Code and Codex read them: AGENTS.md, else CLAUDE.md.
|
|
174
|
+
export const guideOf = async (workspace, box = local) => {
|
|
175
|
+
for (const file of ['AGENTS.md', 'CLAUDE.md']) {
|
|
176
|
+
const text = await box.read(path.join(workspace, file)).then(String, () => null);
|
|
177
|
+
if (text?.trim()) return { file, text: text.slice(0, GUIDE_CHARS) };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// What Claude Code and Codex put in the prompt so the agent needn't spend its first calls on ls, git status and
|
|
183
|
+
// node --version: the tracked files, what's changed, and the tools at hand. Read once, so every turn's prefix stays cached.
|
|
184
|
+
export const contextOf = async (workspace, box = local) => {
|
|
185
|
+
const run = (command) =>
|
|
186
|
+
box
|
|
187
|
+
.run(['bash', '-c', command], { cwd: workspace, seconds: 10 })
|
|
188
|
+
.then(({ code, output }) => (code ? '' : output.trim()), () => '');
|
|
189
|
+
const [files, status, tools] = await Promise.all([
|
|
190
|
+
run('git ls-files'),
|
|
191
|
+
run('git status --short'),
|
|
192
|
+
run('echo "node $(node --version 2>/dev/null)"; for t in rg npm pnpm yarn python3; do command -v $t >/dev/null && printf "%s " $t; done'),
|
|
193
|
+
]);
|
|
194
|
+
const list = files ? files.split('\n') : [];
|
|
195
|
+
const shown = list.slice(0, TREE_FILES).join('\n');
|
|
196
|
+
return [
|
|
197
|
+
list.length ? `Tracked files (${list.length}):\n${shown}${list.length > TREE_FILES ? `\n[… ${list.length - TREE_FILES} more; rg --files lists them]` : ''}` : '',
|
|
198
|
+
list.length ? (status ? `git status --short:\n${status}` : 'git status: clean') : '',
|
|
199
|
+
tools ? `On the machine: ${tools.replace('\n', '; ')}` : '',
|
|
200
|
+
]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join('\n\n');
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// The start of a command's output says what ran and the end how it finished, so both stay.
|
|
206
|
+
const clip = (value, limit = OUTPUT_CHARS) => {
|
|
207
|
+
if (value.length <= limit) return value;
|
|
208
|
+
const head = Math.floor(limit / 4);
|
|
209
|
+
return `${value.slice(0, head)}\n[… ${value.length - limit} characters cut]\n${value.slice(head - limit)}`;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// The run's key lives only in the runner's memory; nothing the agent runs sees it.
|
|
213
|
+
const BARE_ENV = () => ({ PATH: process.env.PATH, HOME: process.env.HOME, LANG: 'C.UTF-8', CI: '1' });
|
|
214
|
+
|
|
215
|
+
// Runs a command in its own process group, so a timeout or Stop ends whatever it started too.
|
|
216
|
+
// `onOutput`, when given, gets the output as it comes, for an app that shows a command while it runs.
|
|
217
|
+
export const sh = (command, args, { cwd, seconds, signal, stop, env = BARE_ENV(), onOutput }) =>
|
|
218
|
+
new Promise((resolve) => {
|
|
219
|
+
const child = spawn(command, args, {
|
|
220
|
+
cwd,
|
|
221
|
+
detached: true,
|
|
222
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
223
|
+
env,
|
|
224
|
+
});
|
|
225
|
+
let head = '';
|
|
226
|
+
let tail = '';
|
|
227
|
+
let dropped = 0;
|
|
228
|
+
const add = (d) => {
|
|
229
|
+
const s = String(d);
|
|
230
|
+
onOutput?.(s);
|
|
231
|
+
if (head.length < OUTPUT_CHARS) {
|
|
232
|
+
const room = OUTPUT_CHARS - head.length;
|
|
233
|
+
head += s.slice(0, room);
|
|
234
|
+
tail += s.slice(room);
|
|
235
|
+
} else tail += s;
|
|
236
|
+
if (tail.length > OUTPUT_CHARS) {
|
|
237
|
+
dropped += tail.length - OUTPUT_CHARS;
|
|
238
|
+
tail = tail.slice(-OUTPUT_CHARS);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const all = () => {
|
|
242
|
+
const kept = head + tail;
|
|
243
|
+
if (!dropped) return clip(kept);
|
|
244
|
+
const start = Math.floor(OUTPUT_CHARS / 4);
|
|
245
|
+
const cut = dropped + kept.length - OUTPUT_CHARS;
|
|
246
|
+
return `${kept.slice(0, start)}\n[… ${cut} characters cut]\n${kept.slice(start - OUTPUT_CHARS)}`;
|
|
247
|
+
};
|
|
248
|
+
child.stdout.on('data', add);
|
|
249
|
+
child.stderr.on('data', add);
|
|
250
|
+
const kill = () => {
|
|
251
|
+
if (stop) return stop();
|
|
252
|
+
try {
|
|
253
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
254
|
+
} catch {}
|
|
255
|
+
};
|
|
256
|
+
const timer = setTimeout(kill, seconds * 1000);
|
|
257
|
+
signal?.addEventListener('abort', kill, { once: true });
|
|
258
|
+
child.on('close', (code, killed) => {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
signal?.removeEventListener('abort', kill);
|
|
261
|
+
resolve({ code: code ?? (killed ? 124 : 1), output: all() });
|
|
262
|
+
});
|
|
263
|
+
child.on('error', (e) => resolve({ code: 127, output: e.message }));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Where the agent's tools act. `local` is the runner's own user, for tests and the agent's own tests; on a VM it's
|
|
267
|
+
// `asUser`, the run's own Unix user.
|
|
268
|
+
export const local = {
|
|
269
|
+
run: ([command, ...args], options) => sh(command, args, options),
|
|
270
|
+
read: (file) => fs.readFile(file),
|
|
271
|
+
write: async (file, data) => {
|
|
272
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
273
|
+
await fs.writeFile(file, data);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const piped = (args, input) =>
|
|
278
|
+
new Promise((resolve, reject) => {
|
|
279
|
+
const child = execFile(
|
|
280
|
+
args[0],
|
|
281
|
+
args.slice(1),
|
|
282
|
+
{ encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 },
|
|
283
|
+
(error, stdout, stderr) =>
|
|
284
|
+
error ? reject(new Error(String(stderr).trim() || error.message)) : resolve(stdout),
|
|
285
|
+
);
|
|
286
|
+
child.stdin.end(input ?? '');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// The run's user has no sudo and can't read or write the runner's files (~/.elbow), so the files and commands the
|
|
290
|
+
// agent asks for go through it: a symlink in the checkout reaches only what that user could read anyway. The runner
|
|
291
|
+
// can't signal that user's processes, so a timeout or Stop ends all of them, through sudo.
|
|
292
|
+
export const asUser = (user, home) => {
|
|
293
|
+
const as = (...args) => [
|
|
294
|
+
'sudo',
|
|
295
|
+
'-n',
|
|
296
|
+
'-u',
|
|
297
|
+
user,
|
|
298
|
+
'--',
|
|
299
|
+
'/usr/bin/env',
|
|
300
|
+
'-i',
|
|
301
|
+
`PATH=${process.env.PATH}`,
|
|
302
|
+
`HOME=${home}`,
|
|
303
|
+
'LANG=C.UTF-8',
|
|
304
|
+
'CI=1',
|
|
305
|
+
...args,
|
|
306
|
+
];
|
|
307
|
+
const stop = () => {
|
|
308
|
+
execFile('sudo', ['-n', 'pkill', '-KILL', '-u', user], () => {});
|
|
309
|
+
};
|
|
310
|
+
return {
|
|
311
|
+
run: (args, { cwd, seconds, signal }) => {
|
|
312
|
+
const [command, ...rest] = as('/bin/sh', '-c', 'cd -- "$0" && exec "$@"', cwd, ...args);
|
|
313
|
+
return sh(command, rest, { seconds, signal, stop });
|
|
314
|
+
},
|
|
315
|
+
read: (file) => piped(as('/bin/cat', '--', file)),
|
|
316
|
+
write: (file, data) =>
|
|
317
|
+
piped(as('/bin/sh', '-c', 'mkdir -p -- "$(dirname -- "$1")" && cat >"$1"', 'sh', file), data),
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Paths stay inside the checkout and out of .git: not a sandbox (the VM is that), just where the work belongs.
|
|
322
|
+
const inside = (workspace, file) => {
|
|
323
|
+
const full = path.resolve(workspace, String(file ?? ''));
|
|
324
|
+
const rel = path.relative(workspace, full);
|
|
325
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel) || rel.split(path.sep)[0] === '.git')
|
|
326
|
+
throw new Error(`${file} is not a file in the repository.`);
|
|
327
|
+
return { full, rel };
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// One edit, or the several an edit_file call gives as edits, each with its path.
|
|
331
|
+
const editsOf = (input = {}) =>
|
|
332
|
+
Array.isArray(input.edits)
|
|
333
|
+
? input.edits.map((e) => ({ ...e, path: e?.path ?? input.path }))
|
|
334
|
+
: input.old_string != null || input.new_string != null
|
|
335
|
+
? [{ path: input.path, old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all }]
|
|
336
|
+
: [];
|
|
337
|
+
|
|
338
|
+
const numbered = (lines, from) => lines.map((line, i) => `${from + i}\t${line}`).join('\n');
|
|
339
|
+
|
|
340
|
+
const snippet = (text, at, inserted) => {
|
|
341
|
+
const lines = text.split('\n');
|
|
342
|
+
if (lines.at(-1) === '') lines.pop();
|
|
343
|
+
const start = text.slice(0, at).split('\n').length;
|
|
344
|
+
const end = start + inserted.split('\n').length - 1;
|
|
345
|
+
const from = Math.max(start - AROUND, 1);
|
|
346
|
+
const to = Math.min(end + AROUND, lines.length);
|
|
347
|
+
const shown = lines.slice(from - 1, Math.min(to, from - 1 + SNIPPET_LINES));
|
|
348
|
+
const rest = to - (from - 1 + shown.length);
|
|
349
|
+
return numbered(shown, from) + (rest > 0 ? `\n[… ${rest} more changed lines]` : '');
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Where old_string most nearly is, by lines that match once indentation is set aside: a missed edit is mostly
|
|
353
|
+
// whitespace or a line that changed, and the model can fix it from these without reading the file again.
|
|
354
|
+
const closest = (text, old) => {
|
|
355
|
+
const lines = text.split('\n');
|
|
356
|
+
const want = old.split('\n').slice(0, 50).map((l) => l.trim());
|
|
357
|
+
let best = 0;
|
|
358
|
+
let at = -1;
|
|
359
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
360
|
+
let score = 0;
|
|
361
|
+
for (let j = 0; j < want.length && i + j < lines.length; j += 1)
|
|
362
|
+
if (want[j] && lines[i + j].trim() === want[j]) score += 1;
|
|
363
|
+
if (score > best) [best, at] = [score, i];
|
|
364
|
+
}
|
|
365
|
+
if (!best) return '';
|
|
366
|
+
const from = Math.max(at - 2, 0);
|
|
367
|
+
const shown = lines.slice(from, Math.min(at + want.length + 2, from + 30));
|
|
368
|
+
return `\nThe closest lines (${best} of ${want.length} match, setting indentation aside):\n${numbered(shown, from + 1)}`;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const replace = (before, { old_string, new_string, replace_all }, rel) => {
|
|
372
|
+
const old = String(old_string ?? '');
|
|
373
|
+
const count = old ? before.split(old).length - 1 : 0;
|
|
374
|
+
if (!count) {
|
|
375
|
+
const numbers = old && old.split('\n').every((l) => /^\s*\d+\t/.test(l));
|
|
376
|
+
throw new Error(
|
|
377
|
+
`old_string isn't in ${rel}.${numbers ? " It starts its lines with read_file's line numbers and tabs; leave them out." : ''}${closest(before, old)}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (count > 1 && !replace_all)
|
|
381
|
+
throw new Error(`old_string appears ${count} times in ${rel}; give more context or replace_all.`);
|
|
382
|
+
return { text: before.split(old).join(String(new_string ?? '')), at: before.indexOf(old) };
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const tools = {
|
|
386
|
+
read_file: async ({ path: file, offset, limit }, { workspace, box }) => {
|
|
387
|
+
const lines = String(await box.read(inside(workspace, file).full)).split('\n');
|
|
388
|
+
if (lines.at(-1) === '') lines.pop();
|
|
389
|
+
const from = Math.max(Number(offset) || 1, 1);
|
|
390
|
+
const count = Math.min(Math.max(Number(limit) || READ_LINES, 1), READ_LINES);
|
|
391
|
+
const shown = lines.slice(from - 1, from - 1 + count);
|
|
392
|
+
if (!shown.length) return lines.length ? `${file} has ${lines.length} lines.` : `${file} is empty.`;
|
|
393
|
+
const out = shown.map((line, i) => {
|
|
394
|
+
const cut = line.length > LINE_CHARS ? `${line.slice(0, LINE_CHARS)}[… line cut]` : line;
|
|
395
|
+
return `${from + i}\t${cut}`;
|
|
396
|
+
});
|
|
397
|
+
const rest = lines.length - (from - 1 + shown.length);
|
|
398
|
+
if (rest > 0) out.push(`[${rest} more lines; read on with offset ${from + shown.length}]`);
|
|
399
|
+
return out.join('\n');
|
|
400
|
+
},
|
|
401
|
+
write_file: async ({ path: file, content }, { workspace, box, wrote }) => {
|
|
402
|
+
const { full, rel } = inside(workspace, file);
|
|
403
|
+
await box.write(full, String(content ?? ''));
|
|
404
|
+
wrote(rel);
|
|
405
|
+
return `Wrote ${rel}.`;
|
|
406
|
+
},
|
|
407
|
+
edit_file: async (input, { workspace, box, wrote }) => {
|
|
408
|
+
const edits = editsOf(input);
|
|
409
|
+
if (!edits.length) throw new Error('Give path, old_string and new_string, or edits.');
|
|
410
|
+
const files = new Map();
|
|
411
|
+
const shown = [];
|
|
412
|
+
for (const [n, edit] of edits.entries()) {
|
|
413
|
+
const { full, rel } = inside(workspace, edit.path);
|
|
414
|
+
if (!files.has(rel)) files.set(rel, { full, text: String(await box.read(full)) });
|
|
415
|
+
const file = files.get(rel);
|
|
416
|
+
try {
|
|
417
|
+
const { text, at } = replace(file.text, edit, rel);
|
|
418
|
+
file.text = text;
|
|
419
|
+
shown.push({ rel, lines: snippet(text, at, String(edit.new_string ?? '')) });
|
|
420
|
+
} catch (e) {
|
|
421
|
+
if (edits.length === 1) throw e;
|
|
422
|
+
throw new Error(`Edit ${n + 1} of ${edits.length}: ${e.message}\nNo file was changed.`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
for (const [rel, { full, text }] of files) {
|
|
426
|
+
await box.write(full, text);
|
|
427
|
+
wrote(rel);
|
|
428
|
+
}
|
|
429
|
+
if (files.size === 1) return `Edited ${shown[0].rel}:\n${shown.map((e) => e.lines).join('\n…\n')}`;
|
|
430
|
+
return `Edited ${[...files.keys()].join(', ')}:\n\n${shown.map((e) => `${e.rel}:\n${e.lines}`).join('\n\n')}`;
|
|
431
|
+
},
|
|
432
|
+
search: async ({ pattern, path: where, glob, files_only, ignore_case, limit }, { workspace, box, signal }) => {
|
|
433
|
+
const dir = !where || where === '.' ? '.' : inside(workspace, where).rel;
|
|
434
|
+
const listing = pattern == null || pattern === '';
|
|
435
|
+
const args = [
|
|
436
|
+
'rg',
|
|
437
|
+
'--no-heading',
|
|
438
|
+
'--color=never',
|
|
439
|
+
'--sort=path',
|
|
440
|
+
'--max-columns=300',
|
|
441
|
+
'--max-columns-preview',
|
|
442
|
+
...(ignore_case ? ['-i'] : []),
|
|
443
|
+
...(glob ? ['-g', String(glob)] : []),
|
|
444
|
+
...(listing ? ['--files'] : [files_only ? '-l' : '-n', '-e', String(pattern)]),
|
|
445
|
+
'--',
|
|
446
|
+
dir,
|
|
447
|
+
];
|
|
448
|
+
const { code, output } = await box.run(args, { cwd: workspace, seconds: SEARCH_SECONDS, signal });
|
|
449
|
+
if (code === 1) return 'No matches.';
|
|
450
|
+
if (code !== 0) throw new Error(`rg exited ${code}\n${output}`);
|
|
451
|
+
const max = Math.min(Math.max(Number(limit) || SEARCH_RESULTS, 1), 1000);
|
|
452
|
+
// Paths as the other tools take them, without the ./ rg puts before them under '.'.
|
|
453
|
+
const lines = output.trimEnd().replace(/^\.\//gm, '').split('\n');
|
|
454
|
+
const more = lines.length - max;
|
|
455
|
+
return more > 0
|
|
456
|
+
? `${lines.slice(0, max).join('\n')}\n[${more} more; narrow the pattern, path or glob]`
|
|
457
|
+
: lines.join('\n');
|
|
458
|
+
},
|
|
459
|
+
bash: async ({ command, timeout_seconds }, { workspace, box, signal }) => {
|
|
460
|
+
const seconds = Math.min(Math.max(Number(timeout_seconds) || BASH_SECONDS, 1), 600);
|
|
461
|
+
const { code, output } = await box.run(['bash', '-c', String(command ?? '')], {
|
|
462
|
+
cwd: workspace,
|
|
463
|
+
seconds,
|
|
464
|
+
signal,
|
|
465
|
+
});
|
|
466
|
+
return { output: `exit ${code}\n${output}`, failed: code !== 0 };
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Elbow's Messages stream, put back together as the assistant message it carries, with when the first block that
|
|
471
|
+
// isn't thinking began (the app's "Thought for 14s").
|
|
472
|
+
// `stream`, when given, gets each piece of text and thinking as it arrives.
|
|
473
|
+
const assemble = async (res, stream) => {
|
|
474
|
+
const content = [];
|
|
475
|
+
const json = [];
|
|
476
|
+
// The tool calls a reply ended in the middle of, when it ran out of output tokens: their JSON input stops short.
|
|
477
|
+
const cut = new Set();
|
|
478
|
+
let stop;
|
|
479
|
+
let answered;
|
|
480
|
+
// Each count as the largest any event gave: a provider can send a field again as 0 in message_delta.
|
|
481
|
+
const usage = {};
|
|
482
|
+
const count = (u = {}) => {
|
|
483
|
+
for (const [k, v] of Object.entries(u)) if (typeof v === 'number') usage[k] = Math.max(usage[k] ?? 0, v);
|
|
484
|
+
};
|
|
485
|
+
let buffer = '';
|
|
486
|
+
const decoder = new TextDecoder();
|
|
487
|
+
const handle = (event) => {
|
|
488
|
+
if (event.type === 'error') throw new Error(event.error?.message ?? 'Elbow sent an error.');
|
|
489
|
+
if (event.type === 'content_block_start') {
|
|
490
|
+
content[event.index] = { ...event.content_block };
|
|
491
|
+
if (event.content_block?.type !== 'thinking') answered ??= Date.now();
|
|
492
|
+
json[event.index] = '';
|
|
493
|
+
} else if (event.type === 'content_block_delta') {
|
|
494
|
+
const block = content[event.index];
|
|
495
|
+
const d = event.delta;
|
|
496
|
+
if (d.type === 'text_delta') {
|
|
497
|
+
block.text += d.text;
|
|
498
|
+
stream?.({ type: 'text', text: d.text });
|
|
499
|
+
} else if (d.type === 'thinking_delta') {
|
|
500
|
+
block.thinking += d.thinking;
|
|
501
|
+
stream?.({ type: 'thinking', text: d.thinking });
|
|
502
|
+
}
|
|
503
|
+
else if (d.type === 'signature_delta') block.signature = (block.signature ?? '') + d.signature;
|
|
504
|
+
else if (d.type === 'input_json_delta') json[event.index] += d.partial_json;
|
|
505
|
+
} else if (event.type === 'content_block_stop') {
|
|
506
|
+
const block = content[event.index];
|
|
507
|
+
if (block.type === 'tool_use')
|
|
508
|
+
try {
|
|
509
|
+
block.input = json[event.index] ? JSON.parse(json[event.index]) : {};
|
|
510
|
+
} catch {
|
|
511
|
+
block.input = {};
|
|
512
|
+
cut.add(block.id);
|
|
513
|
+
}
|
|
514
|
+
} else if (event.type === 'message_start') count(event.message?.usage);
|
|
515
|
+
else if (event.type === 'message_delta') {
|
|
516
|
+
stop = event.delta?.stop_reason ?? stop;
|
|
517
|
+
count(event.usage);
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
for await (const part of res.body) {
|
|
521
|
+
buffer += decoder.decode(part, { stream: true });
|
|
522
|
+
const lines = buffer.split('\n');
|
|
523
|
+
buffer = lines.pop();
|
|
524
|
+
for (const line of lines) if (line.startsWith('data:')) handle(JSON.parse(line.slice(5)));
|
|
525
|
+
}
|
|
526
|
+
const tokens =
|
|
527
|
+
(usage.input_tokens ?? 0) +
|
|
528
|
+
(usage.cache_read_input_tokens ?? 0) +
|
|
529
|
+
(usage.cache_creation_input_tokens ?? 0) +
|
|
530
|
+
(usage.output_tokens ?? 0);
|
|
531
|
+
return { content: content.filter(Boolean), stop, answered: answered ?? Date.now(), cut, tokens };
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const ask = async ({
|
|
535
|
+
url,
|
|
536
|
+
key,
|
|
537
|
+
model,
|
|
538
|
+
run,
|
|
539
|
+
instructions = system,
|
|
540
|
+
workspace,
|
|
541
|
+
checks,
|
|
542
|
+
guide,
|
|
543
|
+
memory,
|
|
544
|
+
context,
|
|
545
|
+
base,
|
|
546
|
+
messages,
|
|
547
|
+
signal,
|
|
548
|
+
headers = {},
|
|
549
|
+
stream,
|
|
550
|
+
}) => {
|
|
551
|
+
const asked = Date.now();
|
|
552
|
+
const res = await fetch(`${url}/v1/messages`, {
|
|
553
|
+
method: 'POST',
|
|
554
|
+
headers: {
|
|
555
|
+
'x-api-key': key,
|
|
556
|
+
'anthropic-version': '2023-06-01',
|
|
557
|
+
'content-type': 'application/json',
|
|
558
|
+
'user-agent': 'elbow-harness',
|
|
559
|
+
...headers,
|
|
560
|
+
},
|
|
561
|
+
body: JSON.stringify({
|
|
562
|
+
model,
|
|
563
|
+
// One session per run, so Elbow keeps its turns together, as it does a Claude Code session's.
|
|
564
|
+
metadata: { user_id: JSON.stringify({ session_id: run }) },
|
|
565
|
+
max_tokens: MAX_TOKENS,
|
|
566
|
+
stream: true,
|
|
567
|
+
system: instructions({ workspace, checks, guide, memory, context, base }),
|
|
568
|
+
tools: TOOLS,
|
|
569
|
+
messages,
|
|
570
|
+
}),
|
|
571
|
+
signal,
|
|
572
|
+
});
|
|
573
|
+
if (!res.ok) {
|
|
574
|
+
const body = await res.json().catch(() => ({}));
|
|
575
|
+
throw new Error(`Elbow answered ${res.status}: ${body.error?.message ?? ''}`.trim());
|
|
576
|
+
}
|
|
577
|
+
const turn = await assemble(res, stream);
|
|
578
|
+
const thought = turn.content.some((b) => b.type === 'thinking') ? turn.answered - asked : null;
|
|
579
|
+
return { content: turn.content, stop: turn.stop, thought, cut: turn.cut, tokens: turn.tokens };
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// A rough count for what Elbow hasn't reported yet: four characters a token, and an image at its usual cost rather
|
|
583
|
+
// than its base64 length.
|
|
584
|
+
const IMAGE_TOKENS = 1600;
|
|
585
|
+
export const estimate = (messages) =>
|
|
586
|
+
Math.ceil(
|
|
587
|
+
JSON.stringify(messages, (k, v) => (k === 'source' && v?.type === 'base64' ? '' : v)).length / 4,
|
|
588
|
+
) +
|
|
589
|
+
IMAGE_TOKENS *
|
|
590
|
+
messages.flatMap((m) => (Array.isArray(m.content) ? m.content : [])).filter((b) => b.type === 'image')
|
|
591
|
+
.length;
|
|
592
|
+
|
|
593
|
+
// The request the summary is asked on is the run's own conversation with the prompt added to its last user message,
|
|
594
|
+
// as Claude Code sends it, so the prompt cache holds and the model summarizes what it has read.
|
|
595
|
+
export const compactionRequest = (messages) => {
|
|
596
|
+
const last = messages.at(-1);
|
|
597
|
+
const prompt = { type: 'text', text: COMPACTION };
|
|
598
|
+
const content = typeof last.content === 'string' ? [{ type: 'text', text: last.content }] : last.content;
|
|
599
|
+
return [...messages.slice(0, -1), { ...last, content: [...content, prompt] }];
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Screenshots the person attached come before the task, which refers to them.
|
|
603
|
+
const opening = (images, text) => [
|
|
604
|
+
{
|
|
605
|
+
role: 'user',
|
|
606
|
+
content: images.length
|
|
607
|
+
? [
|
|
608
|
+
...images.map(({ media_type, data }) => ({
|
|
609
|
+
type: 'image',
|
|
610
|
+
source: { type: 'base64', media_type, data },
|
|
611
|
+
})),
|
|
612
|
+
{ type: 'text', text },
|
|
613
|
+
]
|
|
614
|
+
: text,
|
|
615
|
+
},
|
|
616
|
+
];
|
|
617
|
+
|
|
618
|
+
// What the run goes on from: the task as it was asked, screenshots included, and the summary.
|
|
619
|
+
export const continued = ({ task, images = [], summary }) =>
|
|
620
|
+
opening(
|
|
621
|
+
images,
|
|
622
|
+
`${CONTINUED} The task, as it was asked:\n\n${task}\n\nSummary:\n${summary}\n\nThe checkout holds every change made so far. Carry on from the next step in the summary without asking anything; read a file again before you edit it.`,
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
// The summary, or null when the model gave none: it called a tool instead, ran out of output or Elbow failed.
|
|
626
|
+
const compact = async ({ messages, signal, ...rest }) => {
|
|
627
|
+
try {
|
|
628
|
+
const turn = await ask({ ...rest, messages: compactionRequest(messages), signal });
|
|
629
|
+
if (turn.stop === 'max_tokens') return null;
|
|
630
|
+
const summary = turn.content
|
|
631
|
+
.filter((b) => b.type === 'text')
|
|
632
|
+
.map((b) => b.text)
|
|
633
|
+
.join('\n')
|
|
634
|
+
.trim();
|
|
635
|
+
return summary || null;
|
|
636
|
+
} catch (e) {
|
|
637
|
+
if (signal?.aborted) throw e;
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// A conversation summarized on request (an app's /compact): the summary replaces its messages, or null when the model
|
|
643
|
+
// gave none and they stay as they were.
|
|
644
|
+
export const compactConversation = async ({ workspace, box = local, messages, ...rest }) => {
|
|
645
|
+
if (!messages.length) return null;
|
|
646
|
+
const [guide, context] = await Promise.all([guideOf(workspace, box), contextOf(workspace, box)]);
|
|
647
|
+
// Asked as a message of its own: between a person's turns the conversation ends on the agent's reply.
|
|
648
|
+
const turn = await ask({
|
|
649
|
+
...rest,
|
|
650
|
+
workspace,
|
|
651
|
+
checks: [],
|
|
652
|
+
guide,
|
|
653
|
+
context,
|
|
654
|
+
messages: [...messages, { role: 'user', content: COMPACTION }],
|
|
655
|
+
});
|
|
656
|
+
const summary =
|
|
657
|
+
turn.stop === 'max_tokens'
|
|
658
|
+
? null
|
|
659
|
+
: turn.content
|
|
660
|
+
.filter((b) => b.type === 'text')
|
|
661
|
+
.map((b) => b.text)
|
|
662
|
+
.join('\n')
|
|
663
|
+
.trim() || null;
|
|
664
|
+
if (summary)
|
|
665
|
+
messages.splice(0, messages.length, {
|
|
666
|
+
role: 'user',
|
|
667
|
+
content: `${CONTINUED} Summary:\n${summary}\n\nThe checkout holds every change made so far; read a file again before you edit it.`,
|
|
668
|
+
});
|
|
669
|
+
return summary;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// One task, as a loop of model turns and tool calls until the model stops calling tools and the checks pass.
|
|
673
|
+
// `record` gets the receipts Elbow for Mac shows (mac/core/src/Harness.swift).
|
|
674
|
+
// What goes with a turn's tool results when the run is exploring instead of working, or running out of calls.
|
|
675
|
+
export const nudgeOf = ({ calls, idle, idleBefore }) => {
|
|
676
|
+
const left = MAX_CALLS - calls;
|
|
677
|
+
if (left <= LAST_CALLS)
|
|
678
|
+
return `${left} tool calls left. Finish the change you have, run its tests once and stop; don't start anything new.`;
|
|
679
|
+
if (Math.floor(idle / NUDGE_EVERY) > Math.floor(idleBefore / NUDGE_EVERY))
|
|
680
|
+
return `${idle} tool calls without changing a file. Stop exploring: you know enough to try. Make your best change now with edit_file or write_file, then run the tests and fix what they show.`;
|
|
681
|
+
return null;
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
export const runAgent = async ({
|
|
685
|
+
url,
|
|
686
|
+
key,
|
|
687
|
+
model,
|
|
688
|
+
run,
|
|
689
|
+
task,
|
|
690
|
+
images = [],
|
|
691
|
+
workspace,
|
|
692
|
+
checks,
|
|
693
|
+
memory = [],
|
|
694
|
+
record,
|
|
695
|
+
signal,
|
|
696
|
+
box = local,
|
|
697
|
+
compactAt = COMPACT_TOKENS,
|
|
698
|
+
base = null,
|
|
699
|
+
instructions = system,
|
|
700
|
+
// A conversation that goes on across tasks (cli/acp.mjs) hands in its messages; the run adds to them in place.
|
|
701
|
+
conversation = [],
|
|
702
|
+
// Asked before a call that changes something or runs a command; false declines it. Left out, every call runs.
|
|
703
|
+
approve = null,
|
|
704
|
+
nudge = nudgeOf,
|
|
705
|
+
headers = {},
|
|
706
|
+
// Each turn's text and thinking as they arrive, for an app that shows them as they're written (cli/acp.mjs).
|
|
707
|
+
stream,
|
|
708
|
+
// The context Elbow reports after each turn, in tokens.
|
|
709
|
+
measured,
|
|
710
|
+
}) => {
|
|
711
|
+
record({ event: 'run_started', task, model, checks, ...(images.length && { images: images.length }) });
|
|
712
|
+
const originals = Object.fromEntries(
|
|
713
|
+
await Promise.all(
|
|
714
|
+
checks.map(async (c) => [c, await box.read(path.join(workspace, c)).catch(() => null)]),
|
|
715
|
+
),
|
|
716
|
+
);
|
|
717
|
+
const [guide, context] = await Promise.all([guideOf(workspace, box), contextOf(workspace, box)]);
|
|
718
|
+
const messages = conversation;
|
|
719
|
+
messages.push(...opening(images, task));
|
|
720
|
+
const prompt = { instructions, workspace, checks, guide, memory, context, base, headers };
|
|
721
|
+
// The prompt and tools are sent on every request; Elbow's usage counts them, the estimate needs them added.
|
|
722
|
+
const fixed = Math.ceil((instructions(prompt).length + JSON.stringify(TOOLS).length) / 4);
|
|
723
|
+
// The context Elbow last reported, and how many messages it covered.
|
|
724
|
+
let known = { tokens: 0, at: 0 };
|
|
725
|
+
let compactable = true;
|
|
726
|
+
let calls = 0;
|
|
727
|
+
let idle = 0;
|
|
728
|
+
let rounds = 0;
|
|
729
|
+
const finish = (ok, error, message) => {
|
|
730
|
+
record({ event: 'run_completed', outcome: ok ? 'ok' : 'error', error: error ?? null });
|
|
731
|
+
return { ok, error, message };
|
|
732
|
+
};
|
|
733
|
+
const missing = checks.filter((c) => !originals[c]);
|
|
734
|
+
if (missing.length)
|
|
735
|
+
return finish(false, `These checks aren't files in the repository: ${missing.join(', ')}.`);
|
|
736
|
+
try {
|
|
737
|
+
for (;;) {
|
|
738
|
+
const tokens = known.tokens
|
|
739
|
+
? known.tokens + estimate(messages.slice(known.at))
|
|
740
|
+
: fixed + estimate(messages);
|
|
741
|
+
if (compactable && tokens > compactAt) {
|
|
742
|
+
const summary = await compact({ url, key, model, run, ...prompt, messages, signal });
|
|
743
|
+
if (summary) {
|
|
744
|
+
messages.splice(0, messages.length, ...continued({ task, images, summary }));
|
|
745
|
+
known = { tokens: 0, at: 0 };
|
|
746
|
+
record({ event: 'compacted', tokens, summary });
|
|
747
|
+
} else {
|
|
748
|
+
// A run that can't be summarized goes on as it is, until the model's own window ends it.
|
|
749
|
+
compactable = false;
|
|
750
|
+
record({ event: 'compaction_failed', tokens });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const turn = await ask({ url, key, model, run, ...prompt, messages, signal, stream });
|
|
754
|
+
messages.push({ role: 'assistant', content: turn.content });
|
|
755
|
+
if (turn.tokens) {
|
|
756
|
+
known = { tokens: turn.tokens, at: messages.length };
|
|
757
|
+
measured?.(turn.tokens);
|
|
758
|
+
}
|
|
759
|
+
if (turn.thought !== null) record({ event: 'thought', ms: turn.thought });
|
|
760
|
+
const said = turn.content
|
|
761
|
+
.filter((b) => b.type === 'text')
|
|
762
|
+
.map((b) => b.text)
|
|
763
|
+
.join('\n')
|
|
764
|
+
.trim();
|
|
765
|
+
if (said) record({ event: 'assistant_message', text: said });
|
|
766
|
+
const uses = turn.content.filter((b) => b.type === 'tool_use');
|
|
767
|
+
if (!uses.length) {
|
|
768
|
+
if (turn.stop === 'max_tokens') return finish(false, 'A model turn ran out of output tokens.');
|
|
769
|
+
const failing = await verify({ workspace, box, checks, originals, record, signal, next: () => (calls += 1) });
|
|
770
|
+
if (!failing) return finish(true, null, said);
|
|
771
|
+
rounds += 1;
|
|
772
|
+
if (rounds >= CHECK_ROUNDS) return finish(false, "The checks didn't all pass.", said);
|
|
773
|
+
messages.push({
|
|
774
|
+
role: 'user',
|
|
775
|
+
content: `These checks fail, so the task isn't done. Fix the code, not the tests:\n\n${failing}`,
|
|
776
|
+
});
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
const idleBefore = idle;
|
|
780
|
+
const results = [];
|
|
781
|
+
// A call's receipts, one per edit for an edit_file with edits, so Elbow for Mac shows each with its diff.
|
|
782
|
+
const perform = async (use, parts, first) => {
|
|
783
|
+
parts.forEach((input, k) => record({ event: 'tool_started', call: first + k, tool: use.name, input }));
|
|
784
|
+
const callOf = (file) => first + Math.max(parts.findIndex((p) => inside(workspace, p.path).rel === file), 0);
|
|
785
|
+
let output;
|
|
786
|
+
let ok = true;
|
|
787
|
+
// A command that exits non-zero reads as an error in Elbow for Mac, but goes back to the model as a result.
|
|
788
|
+
let failed = false;
|
|
789
|
+
try {
|
|
790
|
+
const tool = tools[use.name];
|
|
791
|
+
if (turn.cut.has(use.id))
|
|
792
|
+
throw new Error(
|
|
793
|
+
"This call was cut off: the reply ran out of output tokens before its input was complete, so it didn't run. Make it again, smaller; write a long file in parts.",
|
|
794
|
+
);
|
|
795
|
+
if (!tool) throw new Error(`There's no tool called ${use.name}.`);
|
|
796
|
+
// true runs the call; anything else declines it, a string saying why.
|
|
797
|
+
const verdict = approve && !READ_ONLY.has(use.name) ? await approve(use, first) : true;
|
|
798
|
+
if (verdict !== true)
|
|
799
|
+
throw new Error(
|
|
800
|
+
typeof verdict === 'string'
|
|
801
|
+
? verdict
|
|
802
|
+
: signal?.aborted
|
|
803
|
+
? 'The person stopped the turn before this call ran.'
|
|
804
|
+
: "The person declined this call. Don't make it again; ask them what they'd like instead.",
|
|
805
|
+
);
|
|
806
|
+
const out = await tool(use.input ?? {}, {
|
|
807
|
+
workspace,
|
|
808
|
+
box,
|
|
809
|
+
signal,
|
|
810
|
+
wrote: (file) => record({ event: 'write_completed', call: callOf(file), path: file }),
|
|
811
|
+
});
|
|
812
|
+
output = out?.output ?? out;
|
|
813
|
+
failed = !!out?.failed;
|
|
814
|
+
} catch (e) {
|
|
815
|
+
ok = false;
|
|
816
|
+
output = e.message;
|
|
817
|
+
}
|
|
818
|
+
parts.forEach((_, k) =>
|
|
819
|
+
record({ event: 'tool_completed', call: first + k, tool: use.name, outcome: ok && !failed ? 'ok' : 'error', output: clip(output, 16_384) }),
|
|
820
|
+
);
|
|
821
|
+
return { ok, result: { type: 'tool_result', tool_use_id: use.id, content: output, ...(ok ? {} : { is_error: true }) } };
|
|
822
|
+
};
|
|
823
|
+
let reading = [];
|
|
824
|
+
const settle = async () => {
|
|
825
|
+
await Promise.all(reading);
|
|
826
|
+
reading = [];
|
|
827
|
+
};
|
|
828
|
+
for (const [i, use] of uses.entries()) {
|
|
829
|
+
const edits = use.name === 'edit_file' && !turn.cut.has(use.id) ? editsOf(use.input) : [];
|
|
830
|
+
const parts = edits.length > 1 ? edits : [use.input];
|
|
831
|
+
const first = calls + 1;
|
|
832
|
+
calls += parts.length;
|
|
833
|
+
if (calls > MAX_CALLS) {
|
|
834
|
+
await settle();
|
|
835
|
+
record({ event: 'limit', output: `More than ${MAX_CALLS} tool calls.` });
|
|
836
|
+
return finish(false, `The run made more than ${MAX_CALLS} tool calls.`);
|
|
837
|
+
}
|
|
838
|
+
const job = perform(use, parts, first).then((done) => {
|
|
839
|
+
results[i] = done;
|
|
840
|
+
});
|
|
841
|
+
if (READ_ONLY.has(use.name)) reading.push(job);
|
|
842
|
+
else {
|
|
843
|
+
await settle();
|
|
844
|
+
await job;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
await settle();
|
|
848
|
+
for (const [i, use] of uses.entries())
|
|
849
|
+
idle = results[i].ok && (use.name === 'write_file' || use.name === 'edit_file') ? 0 : idle + 1;
|
|
850
|
+
const note = nudge({ calls, idle, idleBefore });
|
|
851
|
+
const content = results.map((r) => r.result);
|
|
852
|
+
messages.push({ role: 'user', content: note ? [...content, { type: 'text', text: note }] : content });
|
|
853
|
+
}
|
|
854
|
+
} catch (e) {
|
|
855
|
+
return finish(false, signal?.aborted ? 'Stopped.' : e.message);
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// The checks run on the tests as they were, whatever the agent did to them. Returns what failed, or '' when all pass.
|
|
860
|
+
const verify = async ({ workspace, box, checks, originals, record, signal, next }) => {
|
|
861
|
+
const failed = [];
|
|
862
|
+
for (const check of checks) {
|
|
863
|
+
await box.write(path.join(workspace, check), originals[check]);
|
|
864
|
+
const call = next();
|
|
865
|
+
record({ event: 'tool_started', call, tool: 'run_check', input: { path: check } });
|
|
866
|
+
const { code, output } = await box.run([process.execPath, '--test', '--test-reporter=spec', check], {
|
|
867
|
+
cwd: workspace,
|
|
868
|
+
seconds: CHECK_SECONDS,
|
|
869
|
+
signal,
|
|
870
|
+
});
|
|
871
|
+
record({ event: 'tool_completed', call, tool: 'run_check', outcome: code ? 'error' : 'ok', output: clip(output, 16_384) });
|
|
872
|
+
if (code) failed.push(`${check}:\n${clip(output, 8_000)}`);
|
|
873
|
+
}
|
|
874
|
+
return failed.join('\n\n');
|
|
875
|
+
};
|