teapot-coding-agent 0.37.59 → 0.37.61
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/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +61 -2247
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +9 -310
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1180
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +166 -142
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/agent/tools.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool execution: file ops, shell (with process-group kill + timeout), git.
|
|
3
|
-
* Tool specs are plain JSON-schema function definitions — provider-agnostic.
|
|
4
|
-
*/
|
|
5
1
|
import { spawn } from "node:child_process";
|
|
6
2
|
import { existsSync, promises as fs, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
|
|
7
3
|
import path from "node:path";
|
|
@@ -20,16 +16,6 @@ function clip(s, max) {
|
|
|
20
16
|
async function readText(p) {
|
|
21
17
|
return fs.readFile(p, "utf8");
|
|
22
18
|
}
|
|
23
|
-
/**
|
|
24
|
-
* Is this file binary, i.e. not something `read_file` should return as text?
|
|
25
|
-
*
|
|
26
|
-
* `readText` decodes with "utf8" unconditionally, so pointing `read_file` at an image returned the
|
|
27
|
-
* raw bytes as a lossy string — reported as "画像をread_fileしてバイナリが帰ってきてる". The model then
|
|
28
|
-
* spends tokens on unreadable mojibake and cannot tell it is looking at data rather than source.
|
|
29
|
-
*
|
|
30
|
-
* The test is the classic one, applied to the FIRST block only: a NUL byte means "not text". Reads
|
|
31
|
-
* are bounded, because deciding costs a file read and the answer never needs the whole file.
|
|
32
|
-
*/
|
|
33
19
|
export async function looksBinary(p) {
|
|
34
20
|
const fh = await fs.open(p, "r");
|
|
35
21
|
try {
|
|
@@ -37,14 +23,6 @@ export async function looksBinary(p) {
|
|
|
37
23
|
const head = Buffer.alloc(Math.min(4096, Math.max(0, st.size)));
|
|
38
24
|
if (head.length)
|
|
39
25
|
await fh.read(head, 0, head.length, 0);
|
|
40
|
-
/**
|
|
41
|
-
* MAGIC BYTES FIRST, then the NUL test.
|
|
42
|
-
*
|
|
43
|
-
* A NUL scan alone missed PDF and zip: their headers contain no zero byte in the first few
|
|
44
|
-
* bytes, so `%PDF-1.4` and `PK\x03\x04` both read as "text" — MEASURED, and it would have
|
|
45
|
-
* shipped a silent hole. A known signature is proof regardless of what the bytes happen to
|
|
46
|
-
* contain; the NUL test is the fallback for everything unrecognised.
|
|
47
|
-
*/
|
|
48
26
|
const kind = sniff(head);
|
|
49
27
|
if (kind)
|
|
50
28
|
return { binary: true, bytes: st.size, kind };
|
|
@@ -54,7 +32,6 @@ export async function looksBinary(p) {
|
|
|
54
32
|
await fh.close();
|
|
55
33
|
}
|
|
56
34
|
}
|
|
57
|
-
/** the guessed format from magic bytes, or null when nothing is recognised */
|
|
58
35
|
function sniff(head) {
|
|
59
36
|
const hex = (n) => head.subarray(0, n).toString("hex");
|
|
60
37
|
if (hex(8).startsWith("89504e47"))
|
|
@@ -75,13 +52,6 @@ function sniff(head) {
|
|
|
75
52
|
return "RTF document";
|
|
76
53
|
return null;
|
|
77
54
|
}
|
|
78
|
-
/**
|
|
79
|
-
* Persist a `write_file` payload and report what line endings it used (#84/#109).
|
|
80
|
-
*
|
|
81
|
-
* Extracted so the two paths — a file whose own EOL was detected, and one decided
|
|
82
|
-
* by `.editorconfig` — share one write. The message names CRLF/CR only when a
|
|
83
|
-
* conversion actually happened, so an ordinary LF write stays quiet.
|
|
84
|
-
*/
|
|
85
55
|
async function finishWrite(p, out, eol, original) {
|
|
86
56
|
await fs.writeFile(p, out, "utf8");
|
|
87
57
|
const converted = eol !== null && eol !== "\n" && out !== original;
|
|
@@ -91,20 +61,13 @@ async function finishWrite(p, out, eol, original) {
|
|
|
91
61
|
(converted ? ` (kept ${eol === "\r\n" ? "CRLF" : "CR"} line endings)` : ""),
|
|
92
62
|
};
|
|
93
63
|
}
|
|
94
|
-
/**
|
|
95
|
-
* The file's dominant line ending, or null when it is mixed or empty.
|
|
96
|
-
*
|
|
97
|
-
* `null` means "do not normalise" — see the note above.
|
|
98
|
-
*/
|
|
99
64
|
export function detectEol(text) {
|
|
100
65
|
if (!text)
|
|
101
66
|
return null;
|
|
102
67
|
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
103
|
-
// a bare CR is a CR not followed by LF
|
|
104
68
|
const cr = (text.match(/\r(?!\n)/g) ?? []).length;
|
|
105
69
|
const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
|
|
106
70
|
const kinds = [crlf, cr, lf].filter((n) => n > 0).length;
|
|
107
|
-
// one kind only -> unambiguous. Mixed -> null.
|
|
108
71
|
if (kinds !== 1)
|
|
109
72
|
return null;
|
|
110
73
|
if (crlf > 0)
|
|
@@ -113,71 +76,22 @@ export function detectEol(text) {
|
|
|
113
76
|
return "\r";
|
|
114
77
|
return "\n";
|
|
115
78
|
}
|
|
116
|
-
/** Split on ANY of the three line endings, dropping the terminators. */
|
|
117
79
|
export function splitEolLines(text) {
|
|
118
80
|
return text.split(/\r\n|\r|\n/);
|
|
119
81
|
}
|
|
120
|
-
/**
|
|
121
|
-
* Re-apply `eol` to content the model supplied.
|
|
122
|
-
*
|
|
123
|
-
* Models emit `\n` essentially always, so a CRLF or CR file rewritten with LF
|
|
124
|
-
* content would otherwise be silently converted. Untouched terminators already
|
|
125
|
-
* in the right form are left alone, so this is safe to run on merged content.
|
|
126
|
-
*/
|
|
127
82
|
export function applyEol(content, eol) {
|
|
128
83
|
if (!eol || eol === "\n")
|
|
129
84
|
return content;
|
|
130
|
-
// normalise to \n first, then re-apply — avoids double-CR on "\r\n"
|
|
131
85
|
return content.replace(/\r\n|\r|\n/g, eol);
|
|
132
86
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Optimistic-concurrency guard. The model reads a file, thinks, then
|
|
135
|
-
* writes — and in between, a sub-agent, a background job or the operator may
|
|
136
|
-
* have changed it. Anchored edits usually fail safe (old_text is gone, so the
|
|
137
|
-
* call errors), but two paths silently clobber work nobody looked at:
|
|
138
|
-
* `replace_all` rewrites every occurrence present NOW, including ones that
|
|
139
|
-
* appeared after the model formed its plan, and write_file/apply_patch replace
|
|
140
|
-
* whole files unconditionally.
|
|
141
|
-
*
|
|
142
|
-
* A caller that passes `base_content` (the bytes it actually read) therefore
|
|
143
|
-
* gets a refusal instead of a lost update — the same contract the web file
|
|
144
|
-
* editor already enforces (see api.ts PUT /file). The check is advisory by
|
|
145
|
-
* design: omitting the parameter keeps today's behaviour, so this can never
|
|
146
|
-
* break a working agent loop that does not use it.
|
|
147
|
-
*/
|
|
148
87
|
function staleGuard(pathArg, base, current) {
|
|
149
88
|
if (typeof base !== "string")
|
|
150
|
-
return null;
|
|
151
|
-
/**
|
|
152
|
-
* A base_content that cannot BE the file is not evidence of a change.
|
|
153
|
-
*
|
|
154
|
-
* MEASURED in tyb2-9-8155867c: four `edit_file` calls, two of which passed `base_content: "x"` —
|
|
155
|
-
* a single byte — and were refused with "changed on disk since you read it … your base_content: 1
|
|
156
|
-
* bytes, on disk now: 52351 bytes". The guard was right that the bytes differ and useless about
|
|
157
|
-
* it: nothing in the file's history can make "x" the content of a 52KB script, so the message sent
|
|
158
|
-
* the model to re-read a file it had never successfully checked against.
|
|
159
|
-
*
|
|
160
|
-
* A guard whose input cannot be the thing it guards is noise. Below a plausible-file floor the
|
|
161
|
-
* value is treated as not supplied — the edit still has to pass the anchor checks, which is where
|
|
162
|
-
* a stale read is actually caught.
|
|
163
|
-
*/
|
|
164
|
-
/**
|
|
165
|
-
* A base_content that is a tiny FRACTION of the file cannot be its content.
|
|
166
|
-
*
|
|
167
|
-
* MEASURED in tyb2-9-8155867c: two of four `edit_file` calls passed `base_content: "x"` — one
|
|
168
|
-
* byte — and were refused with "changed on disk since you read it … your base_content: 1 bytes, on
|
|
169
|
-
* disk now: 52351 bytes". The guard was right that the bytes differ and useless about it: nothing
|
|
170
|
-
* can make "x" the content of a 52KB script, so the message sent the model to re-read a file whose
|
|
171
|
-
* state was never in question.
|
|
172
|
-
*
|
|
173
|
-
* The test is "unmistakably not this file", NOT "short": a legitimately small file edited by a
|
|
174
|
-
* model that re-read it must keep the guard, which is what the two #5 tests would have lost.
|
|
175
|
-
*/
|
|
89
|
+
return null;
|
|
176
90
|
const MIN_PLAUSIBLE_BASE = 16;
|
|
177
91
|
if (base.length < MIN_PLAUSIBLE_BASE && current !== null && current.length >= MIN_PLAUSIBLE_BASE * 4)
|
|
178
92
|
return null;
|
|
179
93
|
if (base === current)
|
|
180
|
-
return null;
|
|
94
|
+
return null;
|
|
181
95
|
const rel = pathArg;
|
|
182
96
|
return {
|
|
183
97
|
ok: false,
|
|
@@ -186,39 +100,15 @@ function staleGuard(pathArg, base, current) {
|
|
|
186
100
|
`Re-read ${rel}, redo the edit against the current content, and pass the fresh text as base_content.`,
|
|
187
101
|
};
|
|
188
102
|
}
|
|
189
|
-
/** Read the file for a guarded write, or null when it does not exist. */
|
|
190
103
|
async function currentOrNull(p) {
|
|
191
104
|
return fs.readFile(p, "utf8").catch(() => null);
|
|
192
105
|
}
|
|
193
|
-
/** Resolve a path inside the workspace; reject escapes (incl. symlink targets).
|
|
194
|
-
* KNOWN LIMIT (review finding, accepted): the check-then-open pattern has a
|
|
195
|
-
* TOCTOU window — a symlink swapped in between safeJoin() and fs.open() could
|
|
196
|
-
* point outside the workspace. Closing it needs openat(2)/O_NOFOLLOW at every
|
|
197
|
-
* call site; the operator-facing risk is low (requires workspace write access
|
|
198
|
-
* already), so we document rather than rewrite all call sites. */
|
|
199
106
|
export function safeJoin(cwd, p) {
|
|
200
107
|
const abs = path.resolve(cwd, p);
|
|
201
108
|
const rel = path.relative(cwd, abs);
|
|
202
109
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
203
110
|
throw new Error(`path escapes workspace: ${p}`);
|
|
204
111
|
}
|
|
205
|
-
// symlinks inside the workspace can point anywhere — resolve the real target
|
|
206
|
-
// and confine THAT too (path.resolve alone doesn't follow links)
|
|
207
|
-
//
|
|
208
|
-
// the old code resolved `abs` and swallowed a throw, treating failure as
|
|
209
|
-
// "fine". That is exactly backwards, and the failure is the NORMAL case:
|
|
210
|
-
// `realpathSync` throws ENOENT for a file that does not exist yet, which is
|
|
211
|
-
// every write_file creating something new. So `real` stayed as the LEXICAL
|
|
212
|
-
// path — which is inside the workspace — and the symlink guard never ran.
|
|
213
|
-
//
|
|
214
|
-
// Demonstrated: with `esc -> /outside` (a real directory), safeJoin allowed
|
|
215
|
-
// `esc/pwned.txt` and write_file wrote straight through it, landing outside
|
|
216
|
-
// the workspace. The dangling case happened to fail later with ENOTDIR, which
|
|
217
|
-
// is luck, not a control.
|
|
218
|
-
//
|
|
219
|
-
// So on ENOENT/ENOTDIR, resolve the nearest EXISTING ancestor instead of
|
|
220
|
-
// giving up. That still catches the escape — the ancestor IS the symlink —
|
|
221
|
-
// while allowing genuinely new files inside the workspace.
|
|
222
112
|
let real = abs;
|
|
223
113
|
try {
|
|
224
114
|
real = realpathSync(abs);
|
|
@@ -226,11 +116,6 @@ export function safeJoin(cwd, p) {
|
|
|
226
116
|
catch (err) {
|
|
227
117
|
const code = err.code;
|
|
228
118
|
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
229
|
-
// Walk up one component at a time. A component that is itself a SYMLINK
|
|
230
|
-
// must be resolved even when its target does not exist — realpathSync
|
|
231
|
-
// throws ENOENT for the dangling link itself, so naively walking to the
|
|
232
|
-
// first existing ancestor steps OVER the symlink and loses it. So read
|
|
233
|
-
// the link and check its target directly.
|
|
234
119
|
let probe = path.dirname(abs);
|
|
235
120
|
for (;;) {
|
|
236
121
|
try {
|
|
@@ -240,22 +125,19 @@ export function safeJoin(cwd, p) {
|
|
|
240
125
|
catch (inner) {
|
|
241
126
|
if (inner.code !== "ENOENT")
|
|
242
127
|
break;
|
|
243
|
-
// this component is a dangling symlink: resolve where it POINTS
|
|
244
128
|
try {
|
|
245
129
|
const target = readlinkSync(probe);
|
|
246
130
|
real = path.isAbsolute(target) ? target : path.resolve(path.dirname(probe), target);
|
|
247
131
|
break;
|
|
248
132
|
}
|
|
249
133
|
catch {
|
|
250
|
-
/* not a symlink after all — keep walking */
|
|
251
134
|
}
|
|
252
135
|
const next = path.dirname(probe);
|
|
253
136
|
if (next === probe)
|
|
254
|
-
break;
|
|
137
|
+
break;
|
|
255
138
|
probe = next;
|
|
256
139
|
}
|
|
257
140
|
}
|
|
258
|
-
// keep the un-resolved tail so the comparison below sees the full path
|
|
259
141
|
const tail = path.relative(probe, abs);
|
|
260
142
|
if (tail && !tail.startsWith(".."))
|
|
261
143
|
real = path.join(real, tail);
|
|
@@ -267,10 +149,8 @@ export function safeJoin(cwd, p) {
|
|
|
267
149
|
}
|
|
268
150
|
return abs;
|
|
269
151
|
}
|
|
270
|
-
/** per-workspace background shells (key: workspace path) */
|
|
271
152
|
const bgShellsByWs = new Map();
|
|
272
153
|
let bgSeq = 0;
|
|
273
|
-
/** how much of each job's output has already been handed to the model */
|
|
274
154
|
const bgReadCursors = new Map();
|
|
275
155
|
function bgMapFor(cwd) {
|
|
276
156
|
let m = bgShellsByWs.get(cwd);
|
|
@@ -280,32 +160,10 @@ function bgMapFor(cwd) {
|
|
|
280
160
|
}
|
|
281
161
|
return m;
|
|
282
162
|
}
|
|
283
|
-
/**
|
|
284
|
-
* Environment for every shell the agent runs.
|
|
285
|
-
*
|
|
286
|
-
* `PWD` is deliberately removed: the server's own PWD would contradict the
|
|
287
|
-
* `cwd` we spawn with, so a command trusting `$PWD` would act on the wrong
|
|
288
|
-
* directory. `bash` re-creates PWD from its actual cwd.
|
|
289
|
-
*/
|
|
290
163
|
function shellEnv() {
|
|
291
164
|
const { PWD: _pwd, ...rest } = process.env;
|
|
292
165
|
return { ...rest, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" };
|
|
293
166
|
}
|
|
294
|
-
/**
|
|
295
|
-
* The shell to run a `bash` tool command in, per platform.
|
|
296
|
-
*
|
|
297
|
-
* Both call sites used to hardcode `/bin/bash`, so on Windows every `bash` tool invocation
|
|
298
|
-
* failed with ENOENT — the tool was entirely unavailable on a platform this package claims to
|
|
299
|
-
* support. Windows has no `/bin/bash`; `cmd.exe` is always present, and Git for Windows (which
|
|
300
|
-
* the CI runner has) provides `bash.exe` on PATH.
|
|
301
|
-
*
|
|
302
|
-
* Preference order on Windows: `%TEAPOT_SHELL%`, then `bash.exe` if it is on PATH, then
|
|
303
|
-
* `cmd.exe`. `bash.exe` first because teapot's tool prompts and most commands the model writes
|
|
304
|
-
* are POSIX (`&&`, `$VAR`, single quotes); a `cmd.exe` fallback keeps the tool working on a bare
|
|
305
|
-
* install rather than failing outright.
|
|
306
|
-
*
|
|
307
|
-
* POSIX is unchanged: `$SHELL`, else `/bin/bash`.
|
|
308
|
-
*/
|
|
309
167
|
export function toolShellFor(platform, env, exists = existsSync) {
|
|
310
168
|
const override = env.TEAPOT_SHELL?.trim();
|
|
311
169
|
if (override)
|
|
@@ -323,23 +181,13 @@ export function toolShellFor(platform, env, exists = existsSync) {
|
|
|
323
181
|
return { file: "bash", args: (cmd) => ["-c", cmd] };
|
|
324
182
|
return { file: env.COMSPEC || "cmd.exe", args: (cmd) => ["/d", "/s", "/c", cmd] };
|
|
325
183
|
}
|
|
326
|
-
/** the shell for THIS process — see `toolShellFor` for the rules and why they exist */
|
|
327
184
|
function toolShell() {
|
|
328
185
|
return toolShellFor(process.platform, process.env);
|
|
329
186
|
}
|
|
330
|
-
/**
|
|
331
|
-
* Human-readable elapsed-time suffix appended to a shell result so the MODEL
|
|
332
|
-
* sees how long a command actually took. Only the operator saw it — the
|
|
333
|
-
* model happily re-issued commands that ran for tens of minutes.
|
|
334
|
-
*
|
|
335
|
-
* Kept terse and single-line: it rides along with the tool output, and mirrors
|
|
336
|
-
* what bash_output already prints for a still-running job.
|
|
337
|
-
*/
|
|
338
187
|
export function elapsed(t0, now = Date.now()) {
|
|
339
188
|
return `\n[took ${((now - t0) / 1000).toFixed(1)}s]`;
|
|
340
189
|
}
|
|
341
190
|
const jobsFile = (dir) => path.join(dir, "bg-jobs.json");
|
|
342
|
-
/** read the persisted records, tolerating a missing or corrupt file (never throws) */
|
|
343
191
|
function readJobRecords(dir) {
|
|
344
192
|
try {
|
|
345
193
|
const raw = readFileSync(jobsFile(dir), "utf8");
|
|
@@ -350,39 +198,22 @@ function readJobRecords(dir) {
|
|
|
350
198
|
return [];
|
|
351
199
|
}
|
|
352
200
|
}
|
|
353
|
-
/**
|
|
354
|
-
* Write the records atomically enough for this purpose.
|
|
355
|
-
*
|
|
356
|
-
* A torn write here is not worth a fsync: the file is a hint for the NEXT boot, and the worst case is
|
|
357
|
-
* losing one restart's worth of "this was running". Losing a job's identity entirely (the shipped
|
|
358
|
-
* behaviour) is what this exists to stop.
|
|
359
|
-
*/
|
|
360
201
|
function writeJobRecords(dir, records) {
|
|
361
202
|
try {
|
|
362
203
|
mkdirSync(dir, { recursive: true });
|
|
363
204
|
writeFileSync(jobsFile(dir), JSON.stringify(records, null, 1));
|
|
364
205
|
}
|
|
365
206
|
catch {
|
|
366
|
-
/* a read-only session dir must not break the tool */
|
|
367
207
|
}
|
|
368
208
|
}
|
|
369
|
-
/** add or update one record (by id) */
|
|
370
209
|
function rememberJob(dir, rec) {
|
|
371
210
|
const all = readJobRecords(dir).filter((r) => r.id !== rec.id);
|
|
372
211
|
all.push(rec);
|
|
373
212
|
writeJobRecords(dir, all);
|
|
374
213
|
}
|
|
375
|
-
/** drop a record — the job finished AND was reported, or was killed */
|
|
376
214
|
function forgetJob(dir, id) {
|
|
377
215
|
writeJobRecords(dir, readJobRecords(dir).filter((r) => r.id !== id));
|
|
378
216
|
}
|
|
379
|
-
/**
|
|
380
|
-
* Jobs a PREVIOUS process started and whose processes may still be alive.
|
|
381
|
-
*
|
|
382
|
-
* `alive` is answered by the caller (pidExists) rather than here, so this module stays free of the
|
|
383
|
-
* signal-plumbing and the test can drive either answer. A record whose pid is gone is stale and is
|
|
384
|
-
* dropped: reporting a finished job as running is the same class of lie as forgetting a live one.
|
|
385
|
-
*/
|
|
386
217
|
export function recoverableJobs(dir, alive) {
|
|
387
218
|
const all = readJobRecords(dir);
|
|
388
219
|
const live = [];
|
|
@@ -396,14 +227,11 @@ export function recoverableJobs(dir, alive) {
|
|
|
396
227
|
}
|
|
397
228
|
function startBackgroundShell(cmd, ctx) {
|
|
398
229
|
const map = bgMapFor(ctx.cwd);
|
|
399
|
-
// opportunistic cleanup of long-dead jobs so the map can't grow forever
|
|
400
230
|
for (const [id, sh] of map) {
|
|
401
231
|
if (sh.exited && Date.now() - sh.startedAt > 30 * 60_000 && !map.delete(id))
|
|
402
232
|
void id;
|
|
403
233
|
}
|
|
404
234
|
const id = `bg${++bgSeq}`;
|
|
405
|
-
// plain `-c`, not `-lc`: a login shell sources ~/.bash_profile, whose `cd`
|
|
406
|
-
// would move background jobs out of the workspace
|
|
407
235
|
const shellSpec = toolShell();
|
|
408
236
|
const child = spawn(shellSpec.file, shellSpec.args(cmd), {
|
|
409
237
|
cwd: ctx.cwd,
|
|
@@ -423,13 +251,6 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
423
251
|
else
|
|
424
252
|
sh.truncated = true;
|
|
425
253
|
};
|
|
426
|
-
/**
|
|
427
|
-
* Persist the RECORD, so a later process can at least say this job exists.
|
|
428
|
-
*
|
|
429
|
-
* The `pid` is what makes it checkable on boot: a job whose pid is gone is dropped, one whose pid
|
|
430
|
-
* is alive is reported as "still running, output no longer reachable". That is the honest answer
|
|
431
|
-
* after a restart — see `ToolContext.stateDir`.
|
|
432
|
-
*/
|
|
433
254
|
if (ctx.stateDir) {
|
|
434
255
|
rememberJob(ctx.stateDir, {
|
|
435
256
|
id, cmd, owner: ctx.agentId ?? "", workspace: ctx.cwd,
|
|
@@ -439,18 +260,6 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
439
260
|
}
|
|
440
261
|
child.stdout?.on("data", collect);
|
|
441
262
|
child.stderr?.on("data", collect);
|
|
442
|
-
// "exit" (process gone) and "close" (all stdio drained) are DIFFERENT events.
|
|
443
|
-
// A backgrounded grandchild that inherits stdout — `npm run dev &`,
|
|
444
|
-
// `docker compose up &`, a script that forks a watcher — keeps the pipe open,
|
|
445
|
-
// so "close" can stay pending long after the shell itself is dead. Keying
|
|
446
|
-
// the exit state off "close" alone left such jobs marked running FOREVER:
|
|
447
|
-
// hasRunningBgShells() said yes, the auto-continue loop kept breaking out on
|
|
448
|
-
// "background job still running", and the agent sat in "running" with the
|
|
449
|
-
// job long dead and no notification ever delivered.
|
|
450
|
-
//
|
|
451
|
-
// So: "exit" is authoritative for state and for the completion
|
|
452
|
-
// notification; "close" only means "output finally drained" and is used to
|
|
453
|
-
// capture the tail. The notify() helper keeps this exactly-once.
|
|
454
263
|
let exitNotified = false;
|
|
455
264
|
const notify = (code) => {
|
|
456
265
|
sh.exited = true;
|
|
@@ -459,10 +268,8 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
459
268
|
if (exitNotified)
|
|
460
269
|
return;
|
|
461
270
|
exitNotified = true;
|
|
462
|
-
// the job is over: its record must not claim otherwise on the next boot
|
|
463
271
|
if (ctx.stateDir)
|
|
464
272
|
forgetJob(ctx.stateDir, id);
|
|
465
|
-
// tell the harness so the agent learns the outcome WITHOUT polling
|
|
466
273
|
try {
|
|
467
274
|
ctx.onBackgroundExit?.({
|
|
468
275
|
id,
|
|
@@ -473,7 +280,6 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
473
280
|
});
|
|
474
281
|
}
|
|
475
282
|
catch {
|
|
476
|
-
/* notification is best-effort */
|
|
477
283
|
}
|
|
478
284
|
};
|
|
479
285
|
child.on("exit", (code) => notify(code));
|
|
@@ -481,42 +287,14 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
481
287
|
map.set(id, sh);
|
|
482
288
|
return id;
|
|
483
289
|
}
|
|
484
|
-
/** Heuristic: did this provider error mean "prompt exceeded the context window"?
|
|
485
|
-
* Wording varies by provider (OpenRouter/OpenAI/Anthropic/local servers). */
|
|
486
290
|
export function isContextOverflow(err) {
|
|
487
291
|
const msg = String(err?.message ?? "").toLowerCase();
|
|
488
292
|
return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) ||
|
|
489
293
|
/\b(400|413)\b.*token/.test(msg) ||
|
|
490
|
-
// some gateways answer an over-long request with HTTP 200 and put the
|
|
491
|
-
// real diagnosis in the body, which llm.ts now surfaces after the "no
|
|
492
|
-
// choices" prefix. Without this the compact-and-retry recovery never
|
|
493
|
-
// recognised it and the turn simply failed.
|
|
494
294
|
/no choices:[\s\S]{0,300}context (length|window|limit)/.test(msg));
|
|
495
295
|
}
|
|
496
|
-
/** Any background shell still running for this workspace? The auto-continue
|
|
497
|
-
* loop consults this before nudging a finished round: an agent that parked
|
|
498
|
-
* its work on `bash(background=true)` — dev server, watcher, long build —
|
|
499
|
-
* must NOT be nagged with "Continue working toward the current goal". The
|
|
500
|
-
* job's exit notification will wake the agent on its own. */
|
|
501
|
-
/**
|
|
502
|
-
* The background shells for a workspace, as the UI sees them.
|
|
503
|
-
*
|
|
504
|
-
* Only a VIEW: no child process, no cursors. The `out` field is the tail kept for `bash_output`, so
|
|
505
|
-
* showing it costs nothing extra — the buffer is already there.
|
|
506
|
-
*
|
|
507
|
-
* `owner` is included because several agents share one workspace (every sub-agent inherits its
|
|
508
|
-
* parent's), and "whose job is this" is the first thing an operator asks when deciding what to kill.
|
|
509
|
-
*/
|
|
510
296
|
export function listBgShells(cwd, stateDir) {
|
|
511
297
|
const live = liveShells(cwd);
|
|
512
|
-
/**
|
|
513
|
-
* Records from a previous process are appended, marked `detached`.
|
|
514
|
-
*
|
|
515
|
-
* Without them a job that survived a restart is INVISIBLE — MEASURED: a 3-hour GPU sweep kept
|
|
516
|
-
* writing while the panel showed nothing and `bash_output` said "forgotten on harness restart".
|
|
517
|
-
* The process cannot be re-attached (no fd survives the parent), so `detached` is the whole truth
|
|
518
|
-
* the UI can be given: it is running, and its output lives wherever the command redirected it.
|
|
519
|
-
*/
|
|
520
298
|
if (!stateDir)
|
|
521
299
|
return live;
|
|
522
300
|
const known = new Set(live.map((j) => j.id));
|
|
@@ -537,7 +315,6 @@ export function listBgShells(cwd, stateDir) {
|
|
|
537
315
|
}));
|
|
538
316
|
return [...live, ...recovered].sort((a, b) => b.startedAt - a.startedAt);
|
|
539
317
|
}
|
|
540
|
-
/** does this pid exist? (EPERM means it exists but is not ours to signal) */
|
|
541
318
|
function pidExists(pid) {
|
|
542
319
|
try {
|
|
543
320
|
process.kill(pid, 0);
|
|
@@ -547,7 +324,6 @@ function pidExists(pid) {
|
|
|
547
324
|
return err.code === "EPERM";
|
|
548
325
|
}
|
|
549
326
|
}
|
|
550
|
-
/** the jobs THIS process is running, as the UI sees them */
|
|
551
327
|
function liveShells(cwd) {
|
|
552
328
|
const m = bgShellsByWs.get(cwd);
|
|
553
329
|
if (!m)
|
|
@@ -560,22 +336,12 @@ function liveShells(cwd) {
|
|
|
560
336
|
running: !b.exited,
|
|
561
337
|
code: b.code,
|
|
562
338
|
startedAt: b.startedAt,
|
|
563
|
-
// the LAST slice: a job is watched to see what it is doing NOW, and the head of a 10MB log is
|
|
564
|
-
// the least interesting part of it
|
|
565
339
|
tail: b.out.slice(-4000),
|
|
566
340
|
bytes: b.out.length,
|
|
567
341
|
truncated: b.truncated,
|
|
568
342
|
}))
|
|
569
343
|
.sort((a, b) => b.startedAt - a.startedAt);
|
|
570
344
|
}
|
|
571
|
-
/**
|
|
572
|
-
* Kill ONE background job; returns whether it existed.
|
|
573
|
-
*
|
|
574
|
-
* A DETACHED job (a record from a previous process) is handled differently on purpose: the harness no
|
|
575
|
-
* longer owns that process, so this drops the RECORD and does NOT signal the pid. Killing a pid we
|
|
576
|
-
* merely remember is how an unrelated process gets killed after pid reuse — and the operator's own
|
|
577
|
-
* instruction was to never touch a job from an earlier session. The caller reports what happened.
|
|
578
|
-
*/
|
|
579
345
|
export function killBgShell(cwd, id, stateDir) {
|
|
580
346
|
const m = bgShellsByWs.get(cwd);
|
|
581
347
|
const b = m?.get(id);
|
|
@@ -584,14 +350,12 @@ export function killBgShell(cwd, id, stateDir) {
|
|
|
584
350
|
killTree(b.child);
|
|
585
351
|
}
|
|
586
352
|
catch {
|
|
587
|
-
/* already gone */
|
|
588
353
|
}
|
|
589
354
|
m.delete(id);
|
|
590
355
|
if (stateDir)
|
|
591
356
|
forgetJob(stateDir, id);
|
|
592
357
|
return true;
|
|
593
358
|
}
|
|
594
|
-
// not ours any more: forget it, leave the process alone
|
|
595
359
|
if (stateDir) {
|
|
596
360
|
const rec = readJobRecords(stateDir).find((r) => r.id === id);
|
|
597
361
|
if (rec) {
|
|
@@ -606,7 +370,6 @@ export function hasRunningBgShells(cwd) {
|
|
|
606
370
|
if (!m)
|
|
607
371
|
return false;
|
|
608
372
|
for (const sh of m.values()) {
|
|
609
|
-
// opportunistic cleanup mirrors startBackgroundShell's
|
|
610
373
|
if (sh.exited && Date.now() - sh.startedAt > 30 * 60_000) {
|
|
611
374
|
m.delete(sh.id);
|
|
612
375
|
continue;
|
|
@@ -616,17 +379,6 @@ export function hasRunningBgShells(cwd) {
|
|
|
616
379
|
}
|
|
617
380
|
return false;
|
|
618
381
|
}
|
|
619
|
-
/**
|
|
620
|
-
* Does THIS agent have a background job still running?
|
|
621
|
-
*
|
|
622
|
-
* `hasRunningBgShells(cwd)` answers for a DIRECTORY, which is the right question for
|
|
623
|
-
* `killBgShells` and the wrong one for "is this agent busy". Sub-agents inherit their parent's
|
|
624
|
-
* workspace, so a shared directory made every sibling of a busy agent report `live: true` —
|
|
625
|
-
* their stop buttons offered to stop work that was already gone.
|
|
626
|
-
*
|
|
627
|
-
* An empty owner means a job started before owners were recorded; those are treated as
|
|
628
|
-
* belonging to nobody, so they cannot make an unrelated agent look busy.
|
|
629
|
-
*/
|
|
630
382
|
export function hasRunningBgShellsFor(cwd, owner) {
|
|
631
383
|
const m = bgShellsByWs.get(cwd);
|
|
632
384
|
if (!m || !owner)
|
|
@@ -639,18 +391,6 @@ export function hasRunningBgShellsFor(cwd, owner) {
|
|
|
639
391
|
}
|
|
640
392
|
return false;
|
|
641
393
|
}
|
|
642
|
-
/**
|
|
643
|
-
* Kill every background shell for one workspace and forget the jobs.
|
|
644
|
-
*
|
|
645
|
-
* `stop()` left these running. They are spawned `detached: true` for group-killing
|
|
646
|
-
* and are NOT covered by `toolAbort` (that signal only reaches a tool that is
|
|
647
|
-
* still on the stack), so pressing stop on an agent that had parked its round on
|
|
648
|
-
* a background job killed nothing: the loop sat in its deliberate "wait for the
|
|
649
|
-
* job" idle state while the job kept burning CPU, and no control could end it.
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
* Returns how many were actually killed, so a caller can report it.
|
|
653
|
-
*/
|
|
654
394
|
export function killBgShells(cwd) {
|
|
655
395
|
const m = bgShellsByWs.get(cwd);
|
|
656
396
|
if (!m)
|
|
@@ -666,19 +406,13 @@ export function killBgShells(cwd) {
|
|
|
666
406
|
m.clear();
|
|
667
407
|
return killed;
|
|
668
408
|
}
|
|
669
|
-
|
|
670
|
-
function runShell(cmd, ctx, timeoutMs,
|
|
671
|
-
/** did the caller pass `timeout_ms`, or is this the default? the message says which */
|
|
672
|
-
explicitTimeout = false) {
|
|
409
|
+
function runShell(cmd, ctx, timeoutMs, explicitTimeout = false) {
|
|
673
410
|
return new Promise((resolve) => {
|
|
674
411
|
const t0 = Date.now();
|
|
675
|
-
// `bash -l` sources /etc/profile + ~/.bash_profile, either of which may
|
|
676
|
-
// `cd` somewhere — which silently moved every later relative path out of
|
|
677
|
-
// the workspace. Plain `-c` starts in ctx.cwd and stays there.
|
|
678
412
|
const sh = toolShell();
|
|
679
413
|
const child = spawn(sh.file, sh.args(cmd), {
|
|
680
414
|
cwd: ctx.cwd,
|
|
681
|
-
detached: true,
|
|
415
|
+
detached: true,
|
|
682
416
|
stdio: ["ignore", "pipe", "pipe"],
|
|
683
417
|
env: shellEnv(),
|
|
684
418
|
});
|
|
@@ -691,32 +425,8 @@ explicitTimeout = false) {
|
|
|
691
425
|
};
|
|
692
426
|
child.stdout.on("data", collect);
|
|
693
427
|
child.stderr.on("data", collect);
|
|
694
|
-
// the kill REPORTS whether it worked. It used to swallow every failure
|
|
695
|
-
// as "already gone", and on Windows a group kill throws ALWAYS — so a timeout
|
|
696
|
-
// would report `TIMEOUT after …ms` for a process that was still running, and
|
|
697
|
-
// shutdown would leave it alive. A timeout that claims success is worse than
|
|
698
|
-
// a crash, because nothing afterwards looks wrong.
|
|
699
428
|
const killGroup = () => killTree(child);
|
|
700
429
|
const timer = setTimeout(() => {
|
|
701
|
-
/**
|
|
702
|
-
* The message has to teach the agent what it did not know.
|
|
703
|
-
*
|
|
704
|
-
* It read `TIMEOUT after 120000ms — killed`, which names neither the limit in human terms nor
|
|
705
|
-
* the parameter that changes it. MEASURED: an agent that never noticed `timeout_ms` hit this
|
|
706
|
-
* repeatedly, retrying the same command. So the message says WHAT the limit was, WHERE it came
|
|
707
|
-
* from (default vs explicit), what to pass next time, and when to switch to `background`.
|
|
708
|
-
*/
|
|
709
|
-
/**
|
|
710
|
-
* The message has to teach the agent what it did not know.
|
|
711
|
-
*
|
|
712
|
-
* It read `TIMEOUT after 120000ms — killed`, which names neither the limit in human terms nor
|
|
713
|
-
* the parameter that changes it. MEASURED: an agent that never noticed `timeout_ms` hit this
|
|
714
|
-
* repeatedly, retrying the same command. So the message says WHAT the limit was, WHERE it came
|
|
715
|
-
* from (default vs explicit), what to pass next time, and when to switch to `background`.
|
|
716
|
-
*
|
|
717
|
-
* The limit keeps its raw `ms` when it is sub-second: "TIMEOUT after 0s" for a 300ms limit
|
|
718
|
-
* both reads wrong and loses the precision a caller needs to judge its next attempt.
|
|
719
|
-
*/
|
|
720
430
|
const limit = timeoutMs < 1000 ? `${timeoutMs}ms` : `${Math.round(timeoutMs / 1000)}s`;
|
|
721
431
|
killReason =
|
|
722
432
|
`TIMEOUT after ${limit} — ${describeKill(killGroup())}. ` +
|
|
@@ -728,7 +438,6 @@ explicitTimeout = false) {
|
|
|
728
438
|
`or use background=true for a job with no timeout at all.`
|
|
729
439
|
: `If it needs longer than the maximum, use background=true — a background job has no timeout.`);
|
|
730
440
|
}, timeoutMs);
|
|
731
|
-
// harness shutdown must not wait out a long-running command
|
|
732
441
|
const onAbort = () => {
|
|
733
442
|
killReason = `ABORTED (harness shutdown) — ${describeKill(killGroup())}`;
|
|
734
443
|
};
|
|
@@ -763,14 +472,11 @@ explicitTimeout = false) {
|
|
|
763
472
|
ok: code === 0,
|
|
764
473
|
result: (code === 0 ? "" : `exit=${code}${signal ? ` signal=${signal}` : ""}\n`) +
|
|
765
474
|
clip(out.trim() || "(no output)", ctx.maxOutputBytes) +
|
|
766
|
-
// the model, not just the operator, needs to know a command was slow —
|
|
767
|
-
// otherwise it happily re-issues something that takes 40 minutes
|
|
768
475
|
elapsed(t0),
|
|
769
476
|
});
|
|
770
477
|
});
|
|
771
478
|
});
|
|
772
479
|
}
|
|
773
|
-
/** 1-based line number of each occurrence of needle in text. */
|
|
774
480
|
function matchLines(text, needle) {
|
|
775
481
|
const out = [];
|
|
776
482
|
let idx = text.indexOf(needle);
|
|
@@ -780,10 +486,6 @@ function matchLines(text, needle) {
|
|
|
780
486
|
}
|
|
781
487
|
return out;
|
|
782
488
|
}
|
|
783
|
-
/**
|
|
784
|
-
* Fuzzy-but-safe locator: find windows of lines equal to the pattern after
|
|
785
|
-
* trimming trailing whitespace on each side. Returns 0-based start line hits.
|
|
786
|
-
*/
|
|
787
489
|
function trailingWsMatches(srcLines, patLines) {
|
|
788
490
|
const hits = [];
|
|
789
491
|
for (let i = 0; i + patLines.length <= srcLines.length; i++) {
|
|
@@ -799,64 +501,20 @@ function trailingWsMatches(srcLines, patLines) {
|
|
|
799
501
|
}
|
|
800
502
|
return hits;
|
|
801
503
|
}
|
|
802
|
-
/**
|
|
803
|
-
* Did `anchor` resolve to a span far larger than it asked for? Returns the refusal, or null.
|
|
804
|
-
*
|
|
805
|
-
* ## Why this is a separate function
|
|
806
|
-
*
|
|
807
|
-
* The first version was inlined and measured `text.indexOf(anchor)` — which by definition finds
|
|
808
|
-
* exactly `anchor.length` characters, so it could NEVER fire, and its test passed with the guard
|
|
809
|
-
* disabled. The overshoot does not come from the exact match: it comes from the TOLERANT path,
|
|
810
|
-
* where a model that omits indentation or trailing spaces hands over a short anchor and the
|
|
811
|
-
* per-line comparison then accepts a much longer region.
|
|
812
|
-
*
|
|
813
|
-
* So the question is: how many file lines does the anchor's FIRST line's text actually cover
|
|
814
|
-
* once tolerance is applied? If that region is more than twice the anchor's own line count, the
|
|
815
|
-
* anchor is not specific enough and the edit would rewrite something unintended.
|
|
816
|
-
*
|
|
817
|
-
* cases are exactly the ones where tolerance decides what matched. Being wrong there is silent.
|
|
818
|
-
*/
|
|
819
504
|
export function disproportionateSpan(text, anchor) {
|
|
820
505
|
const anchorLines = anchor.split("\n");
|
|
821
506
|
const askedLines = anchorLines.length;
|
|
822
507
|
if (askedLines < 2)
|
|
823
|
-
return null;
|
|
824
|
-
/**
|
|
825
|
-
* Do NOT require an exact substring match.
|
|
826
|
-
*
|
|
827
|
-
* The whole point is the near-miss case: the anchor does not appear verbatim, which is why the
|
|
828
|
-
* tolerant path is consulted at all and why it can overshoot. Requiring `indexOf` here made
|
|
829
|
-
* the guard unreachable exactly when it matters.
|
|
830
|
-
*/
|
|
508
|
+
return null;
|
|
831
509
|
const anchorFirst = anchorLines[0].trimEnd();
|
|
832
510
|
const allSrcLines = text.split("\n");
|
|
833
|
-
/** every place the anchor's first line appears — the BEST one decides, not the first */
|
|
834
511
|
const starts = [];
|
|
835
512
|
for (let i = 0; i < allSrcLines.length; i++)
|
|
836
513
|
if (allSrcLines[i].trimEnd() === anchorFirst)
|
|
837
514
|
starts.push(i);
|
|
838
515
|
if (!starts.length)
|
|
839
|
-
return null;
|
|
840
|
-
/**
|
|
841
|
-
* How many file lines does the anchor COVER, allowing the same trailing-whitespace tolerance
|
|
842
|
-
* the tolerant matcher uses?
|
|
843
|
-
*
|
|
844
|
-
* This walks the file and the anchor together, and counts file lines consumed. If matching
|
|
845
|
-
* the anchor requires consuming more than twice its own line count, the anchor is too loose:
|
|
846
|
-
* applying it would replace a region the model did not point at.
|
|
847
|
-
*/
|
|
516
|
+
return null;
|
|
848
517
|
const srcLines = allSrcLines;
|
|
849
|
-
/**
|
|
850
|
-
* EVERY candidate start is considered, and the guard only fires when ALL of them overshoot.
|
|
851
|
-
*
|
|
852
|
-
* Probing just the FIRST occurrence was wrong whenever the anchor's opening line is not unique —
|
|
853
|
-
* `\tif (pwrseq->skip_power_cycle) {` appears more than once in that file, and the first one led
|
|
854
|
-
* the walk into a distant match, so a legitimate 7-line anchor was refused for "covering 58
|
|
855
|
-
* lines". MEASURED in tyb2-9-8155867c: the model then abandoned `edit_file` for that region.
|
|
856
|
-
*
|
|
857
|
-
* The guard's question is "could this anchor be read as pointing somewhere else?" — if ANY
|
|
858
|
-
* plausible reading fits inside the tolerance, it cannot.
|
|
859
|
-
*/
|
|
860
518
|
let best = Number.POSITIVE_INFINITY;
|
|
861
519
|
for (const start of starts) {
|
|
862
520
|
let fileAt = start;
|
|
@@ -882,7 +540,7 @@ export function disproportionateSpan(text, anchor) {
|
|
|
882
540
|
best = Math.min(best, consumed);
|
|
883
541
|
}
|
|
884
542
|
if (!Number.isFinite(best))
|
|
885
|
-
return null;
|
|
543
|
+
return null;
|
|
886
544
|
if (best > askedLines * 2) {
|
|
887
545
|
return (`old_text spans ${askedLines} line(s) but the nearest reading of it covers ${best} ` +
|
|
888
546
|
`lines of the file — refusing a disproportionate match. An anchor this loose would ` +
|
|
@@ -890,62 +548,21 @@ export function disproportionateSpan(text, anchor) {
|
|
|
890
548
|
}
|
|
891
549
|
return null;
|
|
892
550
|
}
|
|
893
|
-
/**
|
|
894
|
-
* How long a `bash` command may run when `timeout_ms` is not given.
|
|
895
|
-
*
|
|
896
|
-
* The UI imports this (via `frontend/lib/bash-timeout.ts`) so the row shows the SAME cap the server
|
|
897
|
-
* enforces. They drifted once: the server applied 120s while the row drew nothing, because the row
|
|
898
|
-
* only looked at a `timeout_ms` the model had omitted.
|
|
899
|
-
*/
|
|
900
551
|
export const BASH_DEFAULT_TIMEOUT_MS = 120_000;
|
|
901
|
-
/** the hard ceiling, applied after any `timeout_ms` the model passes */
|
|
902
552
|
export const BASH_MAX_TIMEOUT_MS = 600_000;
|
|
903
553
|
export const DEFAULT_TIMEOUT_MS = BASH_DEFAULT_TIMEOUT_MS;
|
|
904
|
-
/* ---------- read_url cache ---------- */
|
|
905
554
|
const URL_CACHE_TTL_MS = 3_600_000;
|
|
906
555
|
const urlCache = new Map();
|
|
907
556
|
function clipText(s, max) {
|
|
908
557
|
const n = Math.max(1000, Math.min(max, 80_000));
|
|
909
558
|
return s.length <= n ? s : `${s.slice(0, n)}\n… [truncated, ${s.length} chars total]`;
|
|
910
559
|
}
|
|
911
|
-
/**
|
|
912
|
-
* Patterns that backtrack catastrophically.
|
|
913
|
-
*
|
|
914
|
-
* A nested quantifier — a quantified group whose body is itself quantified, or
|
|
915
|
-
* alternation that can match the same text more than one way — makes the engine
|
|
916
|
-
* explore exponentially many splits. Measured on this codebase: `(a+)+b` against
|
|
917
|
-
* 28 characters of "a" took 3.8s, roughly DOUBLING per character added, and a
|
|
918
|
-
* 40-character line would run for days.
|
|
919
|
-
*
|
|
920
|
-
* There is no timeout on this path: `ctx.defaultTimeoutMs` guards `bash` only,
|
|
921
|
-
* so the scan is a synchronous `re.test(line)` with nothing able to interrupt it.
|
|
922
|
-
* The agent process blocks and takes the UI and every other agent with it.
|
|
923
|
-
*
|
|
924
|
-
* A model is poorly placed to reason about backtracking cost and nothing tells it
|
|
925
|
-
* not to try, so the pattern is checked rather than trusted. This rejects the
|
|
926
|
-
* SHAPE rather than trying to bound the runtime, because a synchronous regex
|
|
927
|
-
* cannot be abandoned once `test` starts — the only reliable protection is to
|
|
928
|
-
* never compile one.
|
|
929
|
-
*/
|
|
930
560
|
const BACKTRACKING_PATTERNS = [
|
|
931
|
-
// (a+)+ (a*)* (a+)* (a?)* … — a quantifier wrapping another quantifier
|
|
932
561
|
{ re: /\([^()]*[+*}][^()]*\)\s*[+*{]/, why: "a quantifier nested inside another quantifier" },
|
|
933
|
-
// (a|a)+ — alternation whose branches can match the SAME text. Distinct
|
|
934
|
-
// branches like (foo|bar)+ are fine and common, so this only fires when two
|
|
935
|
-
// branches are literally identical, which is the shape that explodes.
|
|
936
562
|
{ re: /\(([^()|]*)\|\1\)\s*[+*{]/, why: "an alternation with identical branches inside a quantifier" },
|
|
937
|
-
// (.*)* (.+)+ — a wildcard quantified inside a quantifier
|
|
938
563
|
{ re: /\(\s*[.][*+]\s*\)\s*[+*{]/, why: "a wildcard quantified inside a quantifier" },
|
|
939
564
|
];
|
|
940
|
-
/** Above this, the O(lines x pattern) scan itself becomes the cost. */
|
|
941
565
|
const MAX_PATTERN_LENGTH = 1000;
|
|
942
|
-
/**
|
|
943
|
-
* Compile a model-supplied regex, refusing ones that can backtrack
|
|
944
|
-
* catastrophically.
|
|
945
|
-
*
|
|
946
|
-
* Returns an error STRING rather than a RegExp so every existing call site —
|
|
947
|
-
* which already handles that shape for an invalid pattern — needs no change.
|
|
948
|
-
*/
|
|
949
566
|
function compileRegex(pattern, ignoreCase) {
|
|
950
567
|
if (pattern.length > MAX_PATTERN_LENGTH)
|
|
951
568
|
return `pattern too long (${pattern.length} chars, max ${MAX_PATTERN_LENGTH}) — narrow it, or grep for a literal substring instead`;
|
|
@@ -960,7 +577,6 @@ function compileRegex(pattern, ignoreCase) {
|
|
|
960
577
|
return `invalid regex: ${e.message}`;
|
|
961
578
|
}
|
|
962
579
|
}
|
|
963
|
-
/** Codex seek_sequence: find pattern lines at/after `start`, loosening match rules stepwise. */
|
|
964
580
|
function seekSequence(lines, pattern, start, eof) {
|
|
965
581
|
if (pattern.length === 0)
|
|
966
582
|
return start;
|
|
@@ -970,7 +586,6 @@ function seekSequence(lines, pattern, start, eof) {
|
|
|
970
586
|
const eqExact = (a, b) => a === b;
|
|
971
587
|
const eqRstrip = (a, b) => a.trimEnd() === b.trimEnd();
|
|
972
588
|
const eqTrim = (a, b) => a.trim() === b.trim();
|
|
973
|
-
// typographic dashes/quotes/spaces → ASCII, mirroring codex's final pass
|
|
974
589
|
const normalise = (s) => s
|
|
975
590
|
.trim()
|
|
976
591
|
.replace(/[\u2010-\u2015\u2212]/g, "-")
|
|
@@ -995,7 +610,6 @@ function seekSequence(lines, pattern, start, eof) {
|
|
|
995
610
|
}
|
|
996
611
|
function parsePatch(patch) {
|
|
997
612
|
let text = patch.trim();
|
|
998
|
-
// lenient: strip a heredoc wrapper (<<EOF … EOF), as models sometimes emit one
|
|
999
613
|
const lines0 = text.split("\n");
|
|
1000
614
|
if (lines0.length >= 4 &&
|
|
1001
615
|
["<<EOF", "<<'EOF'", '<<"EOF"'].includes(lines0[0].trim()) &&
|
|
@@ -1005,12 +619,6 @@ function parsePatch(patch) {
|
|
|
1005
619
|
const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
|
|
1006
620
|
if (lines[0]?.trim() !== "*** Begin Patch")
|
|
1007
621
|
return `invalid patch: The first line must be '*** Begin Patch'`;
|
|
1008
|
-
// Backslash-corruption tripwire: models occasionally emit "\u0000" where a
|
|
1009
|
-
// literal backslash belonged (a provider-side JSON escaping failure, seen
|
|
1010
|
-
// live as 90× "\u0000sqrt{12}" in a LaTeX-heavy patch). Applying that
|
|
1011
|
-
// verbatim writes broken source; the model then burns turns repairing the
|
|
1012
|
-
// file (or discards its own work). Reject with an actionable message —
|
|
1013
|
-
// retrying the SAME patch won't help, so say what will.
|
|
1014
622
|
if (text.includes("\\u0000")) {
|
|
1015
623
|
const count = (text.match(/\\u0000/g) ?? []).length;
|
|
1016
624
|
return (`invalid patch: contains ${count} × "\\u0000" — this is mangled backslash output, not real content. ` +
|
|
@@ -1074,7 +682,7 @@ function parsePatch(patch) {
|
|
|
1074
682
|
continue;
|
|
1075
683
|
}
|
|
1076
684
|
if (l.startsWith("+") || l.startsWith("-") || l.startsWith(" ")) {
|
|
1077
|
-
cur ??= { changeContext: null, oldLines: [], newLines: [], isEndOfFile: false };
|
|
685
|
+
cur ??= { changeContext: null, oldLines: [], newLines: [], isEndOfFile: false };
|
|
1078
686
|
if (l.startsWith("+"))
|
|
1079
687
|
cur.newLines.push(l.slice(1));
|
|
1080
688
|
else if (l.startsWith("-"))
|
|
@@ -1095,7 +703,7 @@ function parsePatch(patch) {
|
|
|
1095
703
|
}
|
|
1096
704
|
if (!l.trim()) {
|
|
1097
705
|
i++;
|
|
1098
|
-
continue;
|
|
706
|
+
continue;
|
|
1099
707
|
}
|
|
1100
708
|
return `invalid patch: bad line in Update File '${filePath}': "${l.slice(0, 60)}" (expected ' ', '-', '+' or '@@')`;
|
|
1101
709
|
}
|
|
@@ -1109,7 +717,6 @@ function parsePatch(patch) {
|
|
|
1109
717
|
}
|
|
1110
718
|
return ops;
|
|
1111
719
|
}
|
|
1112
|
-
/** Compute the updated content of one file (no I/O writes). Error string on failure. */
|
|
1113
720
|
async function deriveUpdate(p, displayPath, chunks) {
|
|
1114
721
|
let raw;
|
|
1115
722
|
try {
|
|
@@ -1121,7 +728,7 @@ async function deriveUpdate(p, displayPath, chunks) {
|
|
|
1121
728
|
const hadCrlf = raw.includes("\r\n");
|
|
1122
729
|
const originalLines = raw.split("\n");
|
|
1123
730
|
if (originalLines.at(-1) === "")
|
|
1124
|
-
originalLines.pop();
|
|
731
|
+
originalLines.pop();
|
|
1125
732
|
const replacements = [];
|
|
1126
733
|
let lineIndex = 0;
|
|
1127
734
|
for (const ch of chunks) {
|
|
@@ -1134,13 +741,11 @@ async function deriveUpdate(p, displayPath, chunks) {
|
|
|
1134
741
|
let pattern = ch.oldLines;
|
|
1135
742
|
let newSlice = ch.newLines;
|
|
1136
743
|
if (pattern.length === 0) {
|
|
1137
|
-
// codex semantics: a chunk with no context/removed lines appends at end of file
|
|
1138
744
|
replacements.push([originalLines.length, 0, newSlice]);
|
|
1139
745
|
continue;
|
|
1140
746
|
}
|
|
1141
747
|
let found = seekSequence(originalLines, pattern, lineIndex, ch.isEndOfFile);
|
|
1142
748
|
if (found == null && pattern.at(-1) === "") {
|
|
1143
|
-
// trailing "" usually represents the file's final newline sentinel
|
|
1144
749
|
const p2 = pattern.slice(0, -1);
|
|
1145
750
|
const n2 = newSlice.at(-1) === "" ? newSlice.slice(0, -1) : newSlice;
|
|
1146
751
|
found = seekSequence(originalLines, p2, lineIndex, ch.isEndOfFile);
|
|
@@ -1155,7 +760,7 @@ async function deriveUpdate(p, displayPath, chunks) {
|
|
|
1155
760
|
replacements.push([found, pattern.length, newSlice]);
|
|
1156
761
|
lineIndex = found + pattern.length;
|
|
1157
762
|
}
|
|
1158
|
-
replacements.sort((a, b) => b[0] - a[0]);
|
|
763
|
+
replacements.sort((a, b) => b[0] - a[0]);
|
|
1159
764
|
const out = originalLines.slice();
|
|
1160
765
|
for (const [startIdx, oldLen, seg] of replacements)
|
|
1161
766
|
out.splice(startIdx, oldLen, ...seg);
|
|
@@ -1169,7 +774,6 @@ async function applyPatch(patch, ctx) {
|
|
|
1169
774
|
return { ok: false, result: ops };
|
|
1170
775
|
if (ops.length === 0)
|
|
1171
776
|
return { ok: false, result: "patch contains no file operations" };
|
|
1172
|
-
// resolve every path up front (workspace confinement + duplicate guard)
|
|
1173
777
|
const seen = new Set();
|
|
1174
778
|
const resolved = [];
|
|
1175
779
|
try {
|
|
@@ -1191,7 +795,6 @@ async function applyPatch(patch, ctx) {
|
|
|
1191
795
|
catch (e) {
|
|
1192
796
|
return { ok: false, result: e.message };
|
|
1193
797
|
}
|
|
1194
|
-
// phase 1 — validate everything, write nothing
|
|
1195
798
|
const writes = [];
|
|
1196
799
|
const deletes = [];
|
|
1197
800
|
const summary = [];
|
|
@@ -1226,20 +829,6 @@ async function applyPatch(patch, ctx) {
|
|
|
1226
829
|
catch (e) {
|
|
1227
830
|
return { ok: false, result: `patch validation failed: ${e.message}` };
|
|
1228
831
|
}
|
|
1229
|
-
// phase 2 — commit.
|
|
1230
|
-
//
|
|
1231
|
-
// Phase 1 validates every hunk in memory, so a CONTENT mismatch never
|
|
1232
|
-
// lands halfway. An I/O failure still could: the old loop wrote straight
|
|
1233
|
-
// to the final paths, so a failure on write k of n left writes 1..k-1
|
|
1234
|
-
// already on disk while the tool reported "patch failed". apply_patch is
|
|
1235
|
-
// documented as atomic, so make the commit phase live up to it:
|
|
1236
|
-
// 1. back up the current bytes of every path we are about to change
|
|
1237
|
-
// 2. write each new file to a sibling temp file, then rename() over the
|
|
1238
|
-
// target (rename within a directory is atomic, so a reader never
|
|
1239
|
-
// observes a half-written file)
|
|
1240
|
-
// 3. on ANY failure, restore the backups and remove temp files
|
|
1241
|
-
// Deletes are only performed once every write has landed, and a failed
|
|
1242
|
-
// delete is now reported instead of silently swallowed.
|
|
1243
832
|
const backups = new Map();
|
|
1244
833
|
const temps = [];
|
|
1245
834
|
try {
|
|
@@ -1261,9 +850,6 @@ async function applyPatch(patch, ctx) {
|
|
|
1261
850
|
"re-read the files and regenerate the patch",
|
|
1262
851
|
};
|
|
1263
852
|
}
|
|
1264
|
-
// writes are in — now the deletes. A delete that fails is a real, visible
|
|
1265
|
-
// inconsistency (the old content moved/updated but the source survived),
|
|
1266
|
-
// so roll the whole patch back rather than report a false success.
|
|
1267
853
|
const removed = [];
|
|
1268
854
|
try {
|
|
1269
855
|
for (const d of deletes) {
|
|
@@ -1285,11 +871,6 @@ async function applyPatch(patch, ctx) {
|
|
|
1285
871
|
}
|
|
1286
872
|
return { ok: true, result: `patch applied:\n${summary.join("\n")}` };
|
|
1287
873
|
}
|
|
1288
|
-
/**
|
|
1289
|
-
* Undo a partially committed patch: restore each target's original bytes
|
|
1290
|
-
* (null = it did not exist before, so remove the file we created) and clean
|
|
1291
|
-
* up any temp file that never made it through its rename.
|
|
1292
|
-
*/
|
|
1293
874
|
async function rollbackWrites(backups, temps) {
|
|
1294
875
|
for (const t of temps)
|
|
1295
876
|
await fs.rm(t, { force: true }).catch(() => { });
|
|
@@ -1300,35 +881,8 @@ async function rollbackWrites(backups, temps) {
|
|
|
1300
881
|
await fs.writeFile(abs, orig, "utf8").catch(() => { });
|
|
1301
882
|
}
|
|
1302
883
|
}
|
|
1303
|
-
|
|
1304
|
-
*
|
|
1305
|
-
* The default `N| ` gutter is a trap: read_file prints it, but edit_file's
|
|
1306
|
-
* old_text must NOT contain it — so the model strips the gutter by hand on
|
|
1307
|
-
* every read, and the only mitigation is a sentence in the tool description.
|
|
1308
|
-
*
|
|
1309
|
-
* Format follows the established "hashline" convention used across AI coding
|
|
1310
|
-
* harnesses (quangdang46/hashline, kebbbnnn/hashline, opencode-hashline,
|
|
1311
|
-
* pi-hashline-edit-pro, …) so anchors read the same everywhere:
|
|
1312
|
-
*
|
|
1313
|
-
* 12:a3f1| function calculateTotal(items) {
|
|
1314
|
-
*
|
|
1315
|
-
* i.e. `LINE:HASH| content` — the line number is KEPT (humans and models both
|
|
1316
|
-
* navigate by line) and the content hash is ADDED as an integrity anchor.
|
|
1317
|
-
*
|
|
1318
|
-
* Design points taken from those implementations rather than invented:
|
|
1319
|
-
* - the hash covers the STRIPPED content, so re-indenting a line does not
|
|
1320
|
-
* invalidate its anchor;
|
|
1321
|
-
* - blank/whitespace-only lines get a fixed reserved hash;
|
|
1322
|
-
* - when a hash occurs more than once, the anchor's line number disambiguates
|
|
1323
|
-
* via PROXIMITY search, instead of silently taking the first match.
|
|
1324
|
-
*/
|
|
1325
|
-
const HASH_BLANK = " "; // 4 spaces: reserved, cannot collide with a hex hash
|
|
1326
|
-
// A real hash is 4 HEX chars, but the shape also accepts other 4-8 char
|
|
1327
|
-
// alphanumerics: a model that mistypes or invents an anchor is common enough
|
|
1328
|
-
// that we should still recognise the SHAPE and say "that anchor is malformed"
|
|
1329
|
-
// rather than falling through to "no similar text found".
|
|
884
|
+
const HASH_BLANK = " ";
|
|
1330
885
|
const ANCHOR_RE = /^\s*(\d+):([0-9a-z]{4,8}| {4})\|/;
|
|
1331
|
-
/** 4-char content hash (FNV-1a 32-bit → hex), of the STRIPPED line */
|
|
1332
886
|
export function hashLine(content) {
|
|
1333
887
|
const stripped = content.trim();
|
|
1334
888
|
if (!stripped)
|
|
@@ -1340,60 +894,28 @@ export function hashLine(content) {
|
|
|
1340
894
|
}
|
|
1341
895
|
return (h >>> 0).toString(16).padStart(8, "0").slice(-4);
|
|
1342
896
|
}
|
|
1343
|
-
/** `LINE:HASH| content` for each line, 1-indexed */
|
|
1344
897
|
export function renderHashlines(lines, startLine = 1) {
|
|
1345
|
-
// same trailing-newline rule as renderGutter: a file ending in "\n"
|
|
1346
|
-
// splits into a final "" that is not a line, and an anchor for it is a lie
|
|
1347
|
-
// the model can copy into an edit that then never verifies
|
|
1348
898
|
const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
|
|
1349
899
|
return body
|
|
1350
900
|
.map((l, i) => `${startLine + i}:${hashLine(l)}| ${l}`)
|
|
1351
901
|
.join("\n");
|
|
1352
902
|
}
|
|
1353
|
-
/**
|
|
1354
|
-
* Width of the `N| ` gutter for a page, so every line in it lines up.
|
|
1355
|
-
*
|
|
1356
|
-
* the prefix had no padding, so the gutter silently widened at line 1000
|
|
1357
|
-
* and the whole block jumped sideways mid-file — the exact "unfriendly line
|
|
1358
|
-
* numbers" complaint. Padding to the widest number IN THE PAGE is enough: a
|
|
1359
|
-
* page is a bounded window, and aligning to the file's total length would
|
|
1360
|
-
* push every line of a 20-line read of a 500k-line file 6 characters right.
|
|
1361
|
-
*/
|
|
1362
903
|
export function gutterWidth(startLine, count) {
|
|
1363
904
|
return String(Math.max(0, startLine - 1) + count).length;
|
|
1364
905
|
}
|
|
1365
|
-
/**
|
|
1366
|
-
* The default `N| ` display, with a stable gutter width and no phantom line.
|
|
1367
|
-
*
|
|
1368
|
-
* #7, second half: the prefix was added AFTER `text.split("\n")`, so a file
|
|
1369
|
-
* ending in a newline produced a final numbered row for a line that does not
|
|
1370
|
-
* exist. A model that copies the tail verbatim then hands `edit_file` an
|
|
1371
|
-
* old_text with a phantom line in it, and the edit fails for no visible reason.
|
|
1372
|
-
* A trailing empty element is dropped, which is what the reader means.
|
|
1373
|
-
*/
|
|
1374
906
|
export function renderGutter(lines, startLine = 1) {
|
|
1375
|
-
// a trailing newline splits into a final "" — not a line of the file
|
|
1376
907
|
const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
|
|
1377
908
|
if (!body.length)
|
|
1378
909
|
return "";
|
|
1379
910
|
const w = gutterWidth(startLine, body.length);
|
|
1380
911
|
return body.map((l, i) => `${String(startLine + i).padStart(w)}| ${l}`).join("\n");
|
|
1381
912
|
}
|
|
1382
|
-
/** parse a `LINE:HASH` anchor; returns null when the text is not an anchor */
|
|
1383
913
|
export function parseAnchor(anchor) {
|
|
1384
914
|
const m = /^\s*(\d+):([0-9a-z]{4,8}| {4})\s*$/.exec(anchor);
|
|
1385
915
|
if (!m)
|
|
1386
916
|
return null;
|
|
1387
917
|
return { line: Number(m[1]), hash: m[2] };
|
|
1388
918
|
}
|
|
1389
|
-
/**
|
|
1390
|
-
* Locate the line an anchor refers to.
|
|
1391
|
-
*
|
|
1392
|
-
* The hash is authoritative; the line number only disambiguates when the same
|
|
1393
|
-
* content appears twice (proximity search). Falling back to "nearest line" for
|
|
1394
|
-
* an unknown hash is deliberately NOT done — that would silently edit the wrong
|
|
1395
|
-
* line, which is the entire failure mode hashline exists to prevent.
|
|
1396
|
-
*/
|
|
1397
919
|
export function resolveAnchor(lines, anchor) {
|
|
1398
920
|
const hits = [];
|
|
1399
921
|
for (let i = 0; i < lines.length; i++)
|
|
@@ -1403,25 +925,11 @@ export function resolveAnchor(lines, anchor) {
|
|
|
1403
925
|
return {
|
|
1404
926
|
error: /^[0-9a-f]{4}$/.test(anchor.hash) || anchor.hash === HASH_BLANK
|
|
1405
927
|
? `no line matches hash ${anchor.hash} — the file changed since you read it (re-read it)`
|
|
1406
|
-
:
|
|
1407
|
-
// which sends the agent to fix the right thing
|
|
928
|
+
:
|
|
1408
929
|
`hash ${JSON.stringify(anchor.hash)} is not a valid anchor (expected 4 hex chars) — re-read the file and copy the anchor verbatim`,
|
|
1409
930
|
};
|
|
1410
931
|
if (hits.length === 1)
|
|
1411
932
|
return { index: hits[0], how: "unique" };
|
|
1412
|
-
/**
|
|
1413
|
-
* Several lines share this hash — because the hash is DERIVED FROM CONTENT.
|
|
1414
|
-
*
|
|
1415
|
-
* That is the structural weakness of this format compared with the anchor-table implementations
|
|
1416
|
-
* (`pi-hashline-edit-pro` allocates an anchor per line "never derived from content, so
|
|
1417
|
-
* byte-identical lines never share an anchor"). Here two identical lines are one anchor, so the
|
|
1418
|
-
* line number has to break the tie by proximity — a guess, and a guess that can be wrong in a file
|
|
1419
|
-
* with repeated blocks.
|
|
1420
|
-
*
|
|
1421
|
-
* The guess is kept (refusing outright would make editing any file with repeated lines
|
|
1422
|
-
* impossible), but it is now REPORTED instead of being a silent choice: the caller appends it to
|
|
1423
|
-
* the tool result, so a model that landed on the wrong copy can see why.
|
|
1424
|
-
*/
|
|
1425
933
|
const target = anchor.line - 1;
|
|
1426
934
|
const best = hits.reduce((a, b) => (Math.abs(b - target) < Math.abs(a - target) ? b : a));
|
|
1427
935
|
return {
|
|
@@ -1430,19 +938,12 @@ export function resolveAnchor(lines, anchor) {
|
|
|
1430
938
|
`resolved by proximity, VERIFY you edited the copy you meant`,
|
|
1431
939
|
};
|
|
1432
940
|
}
|
|
1433
|
-
/** Is this whole block a hashline block (every line carries an anchor)? */
|
|
1434
941
|
export function isHashlineBlock(block) {
|
|
1435
942
|
const rows = block.split("\n").filter((l) => l !== "");
|
|
1436
943
|
if (!rows.length)
|
|
1437
944
|
return false;
|
|
1438
945
|
return rows.every((l) => ANCHOR_RE.test(l));
|
|
1439
946
|
}
|
|
1440
|
-
/**
|
|
1441
|
-
* Turn a copied hashline block back into file content.
|
|
1442
|
-
*
|
|
1443
|
-
* The anchors are verified against the CURRENT file, so a stale read is
|
|
1444
|
-
* refused instead of editing the wrong bytes — the whole point of the format.
|
|
1445
|
-
*/
|
|
1446
947
|
export function resolveHashlineBlock(block, lines) {
|
|
1447
948
|
const rows = block.split("\n").filter((l) => l !== "");
|
|
1448
949
|
const picked = [];
|
|
@@ -1468,7 +969,6 @@ export function resolveHashlineBlock(block, lines) {
|
|
|
1468
969
|
return { error: "empty hashline block" };
|
|
1469
970
|
return { text: content.join("\n"), indices: picked, notes };
|
|
1470
971
|
}
|
|
1471
|
-
/** normalize the read_file line-reference argument */
|
|
1472
972
|
export function lineIdMode(args) {
|
|
1473
973
|
const v = args.line_ids ?? args.hashline;
|
|
1474
974
|
if (v === true)
|
|
@@ -1519,13 +1019,6 @@ export const TOOLS = [
|
|
|
1519
1019
|
required: ["path"],
|
|
1520
1020
|
},
|
|
1521
1021
|
async run(args, ctx) {
|
|
1522
|
-
// MULTI-FILE and FUZZY reads.
|
|
1523
|
-
//
|
|
1524
|
-
// Both are opt-in and handled HERE rather than by restructuring this
|
|
1525
|
-
// tool: `paths` re-enters executeTool once per file, and `fuzzy` locates
|
|
1526
|
-
// a file then re-enters it. Nothing about the single-file path below
|
|
1527
|
-
// changes, so its output stays byte-identical — which #7's hashline
|
|
1528
|
-
// anchors and the existing tests both depend on.
|
|
1529
1022
|
if (Array.isArray(args.paths) && args.paths.length) {
|
|
1530
1023
|
const wanted = args.paths
|
|
1531
1024
|
.map((v) => str(v).trim())
|
|
@@ -1541,7 +1034,6 @@ export const TOOLS = [
|
|
|
1541
1034
|
const chunks = [];
|
|
1542
1035
|
for (const rel of wanted) {
|
|
1543
1036
|
const r = await executeTool("read_file", JSON.stringify({ ...args, path: rel, paths: undefined }), ctx);
|
|
1544
|
-
// one bad file must not sink the others: report it inline
|
|
1545
1037
|
chunks.push(`--- ${rel} ---\n${r.ok ? r.result : `ERROR: ${r.result}`}`);
|
|
1546
1038
|
}
|
|
1547
1039
|
return { ok: true, result: chunks.join("\n\n") };
|
|
@@ -1551,15 +1043,6 @@ export const TOOLS = [
|
|
|
1551
1043
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
1552
1044
|
let text;
|
|
1553
1045
|
try {
|
|
1554
|
-
/**
|
|
1555
|
-
* Refuse a BINARY file instead of decoding it as UTF-8.
|
|
1556
|
-
*
|
|
1557
|
-
* `readText` has no notion of encoding, so `read_file` on an image returned the raw bytes as
|
|
1558
|
-
* a lossy string — reported as "画像をread_fileしてバイナリが帰ってきてる". The model then pays
|
|
1559
|
-
* for mojibake it cannot interpret and has no way to tell "this is data" from "this is
|
|
1560
|
-
* source". Naming the likely type (PNG/JPEG/PDF/ELF/zip) is what lets it choose the right
|
|
1561
|
-
* next step instead of retrying the same read.
|
|
1562
|
-
*/
|
|
1563
1046
|
const probe = await looksBinary(p);
|
|
1564
1047
|
if (probe.binary) {
|
|
1565
1048
|
return {
|
|
@@ -1572,9 +1055,6 @@ export const TOOLS = [
|
|
|
1572
1055
|
text = await readText(p);
|
|
1573
1056
|
}
|
|
1574
1057
|
catch (err) {
|
|
1575
|
-
// name the file, not just the errno: a multi-file read reports each bad
|
|
1576
|
-
// path inline, and "ENOENT … /tmp/xyz/nope.ts" leaves the model to work
|
|
1577
|
-
// out which of the paths it asked for
|
|
1578
1058
|
const code = err.code;
|
|
1579
1059
|
return {
|
|
1580
1060
|
ok: false,
|
|
@@ -1582,15 +1062,8 @@ export const TOOLS = [
|
|
|
1582
1062
|
};
|
|
1583
1063
|
}
|
|
1584
1064
|
ctx.onFileRead?.(str(args.path));
|
|
1585
|
-
// split on ALL THREE line endings. A CR-only (old-Mac) file used to
|
|
1586
|
-
// come back as a single line, so the model could not address lines 2..n at
|
|
1587
|
-
// all — and a CRLF file left a trailing "\r" on every line, which then
|
|
1588
|
-
// had to be stripped again by every consumer.
|
|
1589
1065
|
const lines = splitEolLines(text);
|
|
1590
|
-
// #7/#4: opt-in hashline output. Default stays the historical `N| ` so
|
|
1591
|
-
// nothing that depends on it changes until the mode is chosen.
|
|
1592
1066
|
const idMode = lineIdMode(args);
|
|
1593
|
-
// grep mode
|
|
1594
1067
|
if (typeof args.pattern === "string" && args.pattern !== "") {
|
|
1595
1068
|
const re = compileRegex(args.pattern, args.ignore_case === true);
|
|
1596
1069
|
if (typeof re === "string")
|
|
@@ -1624,31 +1097,18 @@ export const TOOLS = [
|
|
|
1624
1097
|
result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
|
|
1625
1098
|
return { ok: true, result };
|
|
1626
1099
|
}
|
|
1627
|
-
// plain mode
|
|
1628
1100
|
let off = num(args.offset, 1);
|
|
1629
1101
|
off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
|
|
1630
1102
|
const lim = num(args.limit, 2000);
|
|
1631
|
-
// A LINE cap alone does not bound the output: a single 5MB minified line is one
|
|
1632
|
-
// "line", and 2000 lines of 2000 chars is 4MB. The three caps below each kill a
|
|
1633
|
-
// different shape of oversized file (#17 item 3):
|
|
1634
|
-
// lines → a 3000-line source file
|
|
1635
|
-
// bytes → a big log, whatever its line structure
|
|
1636
|
-
// per-line → minified.js / a one-line JSON blob
|
|
1637
1103
|
const pageLines = lines.slice(off, off + lim);
|
|
1638
|
-
// PER-LINE cap first: clipping lines before joining means one giant line cannot
|
|
1639
|
-
// blow the byte budget on its own, and the marker names how much was cut.
|
|
1640
1104
|
const cappedLines = pageLines.map((l) => l.length > MAX_READ_LINE_CHARS
|
|
1641
1105
|
? l.slice(0, MAX_READ_LINE_CHARS) +
|
|
1642
1106
|
` …[line clipped: ${l.length - MAX_READ_LINE_CHARS} more chars]`
|
|
1643
1107
|
: l);
|
|
1644
1108
|
const rendered = idMode === "hash" ? renderHashlines(cappedLines, off + 1) : renderGutter(cappedLines, off + 1);
|
|
1645
|
-
// the phantom trailing line is dropped for the count too, or a file that
|
|
1646
|
-
// ends in a newline always reports one line more than it has
|
|
1647
1109
|
const total = lines.length && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
1648
1110
|
const more = off + lim < total ? `\n... (${total - off - lim} more lines)` : "";
|
|
1649
1111
|
const out = rendered + more;
|
|
1650
|
-
// BYTE cap last, with the remaining line count in the marker so the model knows
|
|
1651
|
-
// how to continue rather than assuming it saw everything.
|
|
1652
1112
|
if (out.length > MAX_READ_BYTES) {
|
|
1653
1113
|
const head = out.slice(0, MAX_READ_BYTES);
|
|
1654
1114
|
const cutAt = head.lastIndexOf("\n");
|
|
@@ -1656,9 +1116,6 @@ export const TOOLS = [
|
|
|
1656
1116
|
ok: true,
|
|
1657
1117
|
result: (cutAt > 0 ? head.slice(0, cutAt) : head) +
|
|
1658
1118
|
`\n... [output clipped at ${MAX_READ_BYTES} bytes of ${out.length}; ` +
|
|
1659
|
-
// `head` holds N rendered lines separated by N-1 newlines, and rendering
|
|
1660
|
-
// started at 1-indexed line `off + 1`, so the next unread line is
|
|
1661
|
-
// off + 1 + N. Counting newlines alone gives N-1 and would re-read one line.
|
|
1662
1119
|
`continue with offset=${off + 1 + countLines(cutAt > 0 ? head.slice(0, cutAt) : head) + 1}]`,
|
|
1663
1120
|
};
|
|
1664
1121
|
}
|
|
@@ -1684,9 +1141,6 @@ export const TOOLS = [
|
|
|
1684
1141
|
required: ["path", "content"],
|
|
1685
1142
|
},
|
|
1686
1143
|
async run(args, ctx) {
|
|
1687
|
-
// content must be a real string: a non-string (object/array from bad
|
|
1688
|
-
// model output) coerced to "[object Object]" or silently truncated —
|
|
1689
|
-
// worse, an undefined slipped past required and WIPED the file
|
|
1690
1144
|
if (typeof args.content !== "string") {
|
|
1691
1145
|
return {
|
|
1692
1146
|
ok: false,
|
|
@@ -1700,22 +1154,6 @@ export const TOOLS = [
|
|
|
1700
1154
|
if (stale)
|
|
1701
1155
|
return stale;
|
|
1702
1156
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
1703
|
-
// a file's line endings belong to the FILE. Writing the model's
|
|
1704
|
-
// content verbatim silently converted a CRLF (or CR) file to LF whenever
|
|
1705
|
-
// the model rewrote it — the model never emits \r — so one logical edit
|
|
1706
|
-
// produced a whole-file diff, and any tool that normalises on EOL (git,
|
|
1707
|
-
// prettier) would rewrite it afterwards.
|
|
1708
|
-
//
|
|
1709
|
-
// So an EXISTING file's dominant EOL is re-applied. detectEol returns null
|
|
1710
|
-
// for a mixed file, which is left untouched on purpose: choosing a winner
|
|
1711
|
-
// there would itself be the silent whole-file rewrite this prevents.
|
|
1712
|
-
//
|
|
1713
|
-
// when the file CANNOT answer — it is new, or its EOL is mixed — ask
|
|
1714
|
-
// `.editorconfig` before falling back to LF. The order is deliberate: the
|
|
1715
|
-
// file's own bytes outrank configuration, which outranks the default. A
|
|
1716
|
-
// `end_of_line = crlf` in a repo whose files are all LF must not convert
|
|
1717
|
-
// every file the agent touches, so config is never consulted for a file that
|
|
1718
|
-
// already has an unambiguous ending.
|
|
1719
1157
|
const before = await currentOrNull(p);
|
|
1720
1158
|
const detected = before === null ? null : detectEol(before);
|
|
1721
1159
|
if (detected !== null)
|
|
@@ -1759,12 +1197,6 @@ export const TOOLS = [
|
|
|
1759
1197
|
return stale;
|
|
1760
1198
|
let oldText = str(args.old_text);
|
|
1761
1199
|
let newText = str(args.new_text);
|
|
1762
|
-
// the single most common edit_file failure in the real logs (22
|
|
1763
|
-
// occurrences) said only "old_text is required", while the tool's own
|
|
1764
|
-
// schema already documents the better form. Name it and say what to do —
|
|
1765
|
-
// the pattern master.ts uses for "not your sub-agent" (its
|
|
1766
|
-
// `messageChild`),
|
|
1767
|
-
// which turned a confusing refusal into a re-addressable one.
|
|
1768
1200
|
if (!oldText)
|
|
1769
1201
|
return {
|
|
1770
1202
|
ok: false,
|
|
@@ -1773,31 +1205,9 @@ export const TOOLS = [
|
|
|
1773
1205
|
"Do NOT copy read_file's `N| ` line-number prefixes.",
|
|
1774
1206
|
};
|
|
1775
1207
|
const replaceAll = args.replace_all === true;
|
|
1776
|
-
// #7/#4: accept a block copied straight out of read_file's hashline
|
|
1777
|
-
// output. When the anchors do not verify we FAIL with the specific reason
|
|
1778
|
-
// rather than silently falling back to literal matching — a literal match
|
|
1779
|
-
// that happened to succeed would edit the wrong bytes, which is the exact
|
|
1780
|
-
// failure mode hashline exists to prevent.
|
|
1781
|
-
//
|
|
1782
|
-
// On success we edit by RESOLVED LINE INDEX rather than by searching for
|
|
1783
|
-
// the text: the anchors already pinpointed the lines, so a block whose
|
|
1784
|
-
// content legitimately appears twice (the case proximity search exists
|
|
1785
|
-
// for) must not then fail the uniqueness check on the resolved text.
|
|
1786
1208
|
let hashNote = "";
|
|
1787
1209
|
let hashNotes = [];
|
|
1788
1210
|
let hashSpan = null;
|
|
1789
|
-
/**
|
|
1790
|
-
* A hashline-formatted `new_text` is STRIPPED, not pasted.
|
|
1791
|
-
*
|
|
1792
|
-
* MEASURED in tyb2-9-8155867c: `old_text` and `new_text` were IDENTICAL, both carrying anchors
|
|
1793
|
-
* (`155c| …`, `59d3| …`) copied from the read. The model meant "these lines, rewritten" but
|
|
1794
|
-
* pasted the anchor gutter on both sides. The result was refused as "old_text not found",
|
|
1795
|
-
* which names the wrong argument — the anchors had already been resolved away, so the literal
|
|
1796
|
-
* search could not find them, and the reply never mentioned the real mistake.
|
|
1797
|
-
*
|
|
1798
|
-
* The anchors belong to the READ, not to the file. Stripping them from `new_text` is what the
|
|
1799
|
-
* model meant, and writing them into the source would be corruption.
|
|
1800
|
-
*/
|
|
1801
1211
|
const newTextHasAnchors = isHashlineBlock(newText);
|
|
1802
1212
|
if (isHashlineBlock(oldText)) {
|
|
1803
1213
|
const srcLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
@@ -1810,13 +1220,6 @@ export const TOOLS = [
|
|
|
1810
1220
|
hashSpan = { start: r.indices[0], end: r.indices[r.indices.length - 1] + 1 };
|
|
1811
1221
|
}
|
|
1812
1222
|
}
|
|
1813
|
-
/**
|
|
1814
|
-
* Drop anchors from `new_text` — they are the READ's numbering, not file content.
|
|
1815
|
-
*
|
|
1816
|
-
* A block whose anchors cannot be resolved is still stripped (the content is what was meant);
|
|
1817
|
-
* writing `155c| ` into a source file would be corruption, so the safe direction is to remove
|
|
1818
|
-
* them and let the rest of the edit be judged on its text.
|
|
1819
|
-
*/
|
|
1820
1223
|
if (newTextHasAnchors) {
|
|
1821
1224
|
const stripped = newText
|
|
1822
1225
|
.split("\n")
|
|
@@ -1831,27 +1234,7 @@ export const TOOLS = [
|
|
|
1831
1234
|
}
|
|
1832
1235
|
}
|
|
1833
1236
|
let count = hashNote ? 0 : text.split(oldText).length - 1;
|
|
1834
|
-
/**
|
|
1835
|
-
* A MINIMUM ANCHOR LENGTH, from Gemini's `edit.ts`.
|
|
1836
|
-
*
|
|
1837
|
-
* Below this, an anchor cannot identify anything: " }" or "\n\n" appear everywhere, and
|
|
1838
|
-
* any tolerant matching on them either fails ambiguously or picks a wrong span. MEASURED
|
|
1839
|
-
* across every session: `old_text not found` is 117 of 168 edit failures, and a
|
|
1840
|
-
* large share of those are anchors too short to be unique, sent by a model that believed
|
|
1841
|
-
* them specific.
|
|
1842
|
-
*
|
|
1843
|
-
* Short anchors are still ACCEPTED when they match exactly once — a one-character edit is
|
|
1844
|
-
* legitimate. What is refused is trying to make a short anchor work by relaxing the match.
|
|
1845
|
-
*/
|
|
1846
1237
|
const MIN_FUZZY_ANCHOR = 10;
|
|
1847
|
-
/**
|
|
1848
|
-
* A MULTI-LINE anchor is specific by construction: two matching lines pin both a start
|
|
1849
|
-
* and a continuation, which is far stronger evidence than 10 characters on one line.
|
|
1850
|
-
* Gemini's floor is about single-line anchors, where " }" and "foo();" genuinely cannot
|
|
1851
|
-
* identify anything. Applying it by raw character count rejected `foo\nbar` — a perfectly
|
|
1852
|
-
* unambiguous two-line anchor — which is the guard making normal editing harder, the
|
|
1853
|
-
* exact failure mode #157 warns about.
|
|
1854
|
-
*/
|
|
1855
1238
|
const anchorLines = oldText.split("\n").length;
|
|
1856
1239
|
const anchorTooShort = anchorLines < 2 && oldText.trim().length < MIN_FUZZY_ANCHOR;
|
|
1857
1240
|
let normalized = false;
|
|
@@ -1864,17 +1247,11 @@ export const TOOLS = [
|
|
|
1864
1247
|
normalized = true;
|
|
1865
1248
|
}
|
|
1866
1249
|
}
|
|
1867
|
-
// tolerate patterns whose only difference is trailing whitespace per line
|
|
1868
|
-
// (the most common near-miss) — applied only when it resolves uniquely
|
|
1869
1250
|
if (count === 0 && !anchorTooShort) {
|
|
1870
1251
|
const srcLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
1871
1252
|
const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
|
|
1872
1253
|
const hits = trailingWsMatches(srcLines, patLines);
|
|
1873
1254
|
if (hits.length === 1 && patLines.length > 0) {
|
|
1874
|
-
// same detection as the hashline path — CR-only files are detected
|
|
1875
|
-
// too, and a MIXED file yields null and is left as-is rather than
|
|
1876
|
-
// homogenised. The old `includes("\r\n")` test called a CR-only file
|
|
1877
|
-
// "\n", so an edit to one silently converted its line endings.
|
|
1878
1255
|
const rebuilt = [
|
|
1879
1256
|
...srcLines.slice(0, hits[0]),
|
|
1880
1257
|
...splitEolLines(newText),
|
|
@@ -1893,17 +1270,12 @@ export const TOOLS = [
|
|
|
1893
1270
|
};
|
|
1894
1271
|
}
|
|
1895
1272
|
if (count === 0) {
|
|
1896
|
-
// actionable miss: point the model at recovery instead of pushing it
|
|
1897
|
-
// toward bash-based editing
|
|
1898
1273
|
const lines = matchLines(text, oldText.trim());
|
|
1899
1274
|
const hint = lines.length
|
|
1900
1275
|
? `A trimmed variant appears at line(s) ${lines.slice(0, 5).join(", ")}.`
|
|
1901
1276
|
: `No similar text found — re-read ${str(args.path)} around the target area and copy old_text exactly.`;
|
|
1902
1277
|
return {
|
|
1903
1278
|
ok: false,
|
|
1904
|
-
// a hashline block that did not resolve gets ITS reason — the generic
|
|
1905
|
-
// "not found" would send the model hunting for a whitespace problem
|
|
1906
|
-
// when the real issue is a stale read
|
|
1907
1279
|
result: hashNote
|
|
1908
1280
|
? `old_text is a hashline block but did not resolve: ${hashNote}.`
|
|
1909
1281
|
: `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
|
|
@@ -1916,35 +1288,11 @@ export const TOOLS = [
|
|
|
1916
1288
|
result: `old_text matched ${count} times (lines ${at.slice(0, 5).join(", ")}) and must be unique — add surrounding lines to old_text, or pass replace_all=true`,
|
|
1917
1289
|
};
|
|
1918
1290
|
}
|
|
1919
|
-
/**
|
|
1920
|
-
* DISPROPORTIONATE MATCH guard, from OpenCode's `isDisproportionateMatch`.
|
|
1921
|
-
*
|
|
1922
|
-
* The dangerous shape is not "no match" — that is loud and recoverable — but a match far
|
|
1923
|
-
* LARGER than the anchor, where asking to change one call site silently rewrites a whole
|
|
1924
|
-
* function. A confident wrong edit is worse than a failed one.
|
|
1925
|
-
*/
|
|
1926
1291
|
if (!hashSpan) {
|
|
1927
1292
|
const over = disproportionateSpan(text, oldText);
|
|
1928
1293
|
if (over)
|
|
1929
1294
|
return { ok: false, result: over };
|
|
1930
1295
|
}
|
|
1931
|
-
// A verified hashline block is addressed BY LINE INDEX: the anchors
|
|
1932
|
-
// already proved which lines it means, so a block whose content occurs
|
|
1933
|
-
// twice must still edit the anchored occurrence rather than fail the
|
|
1934
|
-
// uniqueness check.
|
|
1935
|
-
// The hashline path splits on "\n" to index by resolved line number, which
|
|
1936
|
-
// leaves a trailing "\r" on every line of a CRLF file. Rejoining with
|
|
1937
|
-
// "\n" then DROPS all of them, so a single edit silently rewrote a
|
|
1938
|
-
// Windows file's line endings: the replaced line arrived with the model's
|
|
1939
|
-
// "\n" while every untouched line kept its "\r\n". The file still parsed
|
|
1940
|
-
// and the edit "succeeded", so nothing reported it — but the next
|
|
1941
|
-
// checkout diff shows the whole file changed, and tools that normalise on
|
|
1942
|
-
// EOL (git, prettier, formatters) now rewrite it.
|
|
1943
|
-
//
|
|
1944
|
-
// So the EOL is detected ONCE from the untouched text and re-applied to
|
|
1945
|
-
// the replacement, which also keeps a CRLF file CRLF when the model sends
|
|
1946
|
-
// LF-separated new_text (the overwhelmingly common case — models never
|
|
1947
|
-
// emit \r).
|
|
1948
1296
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1949
1297
|
const out = hashSpan
|
|
1950
1298
|
? splitEolLines(text)
|
|
@@ -2077,13 +1425,10 @@ export const TOOLS = [
|
|
|
2077
1425
|
`jobs are per-workspace and forgotten on harness restart`,
|
|
2078
1426
|
};
|
|
2079
1427
|
if (str(args.action) === "kill") {
|
|
2080
|
-
// same swallowed-kill bug — this reported "killed" and dropped the
|
|
2081
|
-
// job from the map while the process kept running, now untrackable.
|
|
2082
1428
|
const note = sh.exited ? "(already exited)" : describeKill(killTree(sh.child));
|
|
2083
1429
|
map.delete(sh.id);
|
|
2084
1430
|
return { ok: true, result: `job ${sh.id} ${note}. Output so far:\n${clip(sh.out, 4000)}` };
|
|
2085
1431
|
}
|
|
2086
|
-
// action === read (default): hand back NEW bytes and remember the cursor
|
|
2087
1432
|
const fresh = sh.out.slice(bgReadCursors.get(sh.id) ?? 0);
|
|
2088
1433
|
bgReadCursors.set(sh.id, sh.out.length);
|
|
2089
1434
|
const status = sh.exited
|
|
@@ -2092,7 +1437,6 @@ export const TOOLS = [
|
|
|
2092
1437
|
const body = (fresh.length ? fresh : "(no new output)") +
|
|
2093
1438
|
(sh.truncated ? "\n… (output truncated at 200KB)" : "") +
|
|
2094
1439
|
status;
|
|
2095
|
-
// finished jobs are removed once fully drained
|
|
2096
1440
|
if (sh.exited && bgReadCursors.get(sh.id) >= sh.out.length) {
|
|
2097
1441
|
map.delete(sh.id);
|
|
2098
1442
|
bgReadCursors.delete(sh.id);
|
|
@@ -2138,7 +1482,6 @@ export const TOOLS = [
|
|
|
2138
1482
|
const html = await res.text();
|
|
2139
1483
|
if (!html.trim())
|
|
2140
1484
|
return { ok: false, result: `HTTP ${res.status} with an empty body` };
|
|
2141
|
-
// heavy DOM deps are loaded lazily so the master's idle startup stays lean
|
|
2142
1485
|
const { Browser } = await import("happy-dom");
|
|
2143
1486
|
const { Readability } = await import("@mozilla/readability");
|
|
2144
1487
|
const browser = new Browser();
|
|
@@ -2183,18 +1526,6 @@ export const TOOLS = [
|
|
|
2183
1526
|
task: { type: "string", description: "self-contained instructions for the sub-agent" },
|
|
2184
1527
|
context: { type: "string", enum: ["none", "fork"], description: "default none" },
|
|
2185
1528
|
name: { type: "string", description: "optional short name fragment for the id" },
|
|
2186
|
-
// reasoning effort for THIS sub-agent, overriding both the
|
|
2187
|
-
// configured subagent default and the parent's inherited value.
|
|
2188
|
-
//
|
|
2189
|
-
// Precedence mirrors OpenAI Codex (codex-rs/core/src/agent/child_config.rs):
|
|
2190
|
-
// spawn argument -> configured default -> parent's effort. Codex
|
|
2191
|
-
// deliberately does NOT inherit by default — its own test sets a parent at
|
|
2192
|
-
// XHigh and asserts a child spawned with Low runs at Low, because
|
|
2193
|
-
// sub-agents are usually narrow and cheap and inheriting a parent's high
|
|
2194
|
-
// effort across many trivial scouts multiplies cost for no gain.
|
|
2195
|
-
//
|
|
2196
|
-
// Naming it here is what stops the silent loss: previously a spawn could
|
|
2197
|
-
// not express an effort at all, so the parent's value simply vanished.
|
|
2198
1529
|
reasoning_effort: {
|
|
2199
1530
|
type: "string",
|
|
2200
1531
|
enum: ["minimal", "low", "medium", "high", "xhigh"],
|
|
@@ -2213,8 +1544,6 @@ export const TOOLS = [
|
|
|
2213
1544
|
return { ok: false, result: "task required" };
|
|
2214
1545
|
const context = args.context === "fork" ? "fork" : "none";
|
|
2215
1546
|
try {
|
|
2216
|
-
// validate like the per-agent setter does, so a typo cannot reach
|
|
2217
|
-
// the provider as an unrecognised effort value.
|
|
2218
1547
|
const effort = str(args.reasoning_effort).trim();
|
|
2219
1548
|
if (effort && !isReasoningEffort(effort))
|
|
2220
1549
|
return {
|
|
@@ -2298,12 +1627,6 @@ export const TOOLS = [
|
|
|
2298
1627
|
return { ok: false, result: "sub-agents are not available here" };
|
|
2299
1628
|
const ids = Array.isArray(args.ids) ? args.ids.map(String) : undefined;
|
|
2300
1629
|
const ms = Math.min(Math.max(num(args.timeout_ms, 300_000), 1_000), 3_600_000);
|
|
2301
|
-
// a prompt ALREADY in the queue must be consumed FIRST. The
|
|
2302
|
-
// enqueue-time wake only fires for prompts arriving DURING the park —
|
|
2303
|
-
// one queued before the park is invisible to it, and parking over it
|
|
2304
|
-
// strands the operator's message ("idle" display, nothing processed)
|
|
2305
|
-
// until a child settles. Stand down and let the next turn boundary
|
|
2306
|
-
// drain it; the model can call wait_children again afterwards.
|
|
2307
1630
|
if (ctx.hasPendingPrompts?.()) {
|
|
2308
1631
|
ctx.onProgressGateReset?.();
|
|
2309
1632
|
return {
|
|
@@ -2311,24 +1634,19 @@ export const TOOLS = [
|
|
|
2311
1634
|
result: "A prompt is already queued for you — handle it first, then call wait_children again if you still need to wait.",
|
|
2312
1635
|
};
|
|
2313
1636
|
}
|
|
2314
|
-
// park VISIBLY: the UI shows idle ("waiting on sub-agents") instead of a
|
|
2315
|
-
// running spinner, and any user prompt / stop wakes us instantly
|
|
2316
1637
|
const parked = ctx.onIdlePark?.(`waiting on sub-agent${ids?.length === 1 ? ` ${ids[0]}` : "s"}`);
|
|
2317
1638
|
if (parked === false) {
|
|
2318
|
-
// the agent refused the park (a prompt raced in) — same answer as above
|
|
2319
1639
|
ctx.onProgressGateReset?.();
|
|
2320
1640
|
return {
|
|
2321
1641
|
ok: true,
|
|
2322
1642
|
result: "A prompt arrived while you were about to wait — handle it first, then call wait_children again if you still need to wait.",
|
|
2323
1643
|
};
|
|
2324
1644
|
}
|
|
2325
|
-
// the child's report is about to land in our mailbox — a progress report
|
|
2326
|
-
// right after it would be pure noise, so re-arm the gate here
|
|
2327
1645
|
ctx.onProgressGateReset?.();
|
|
2328
1646
|
try {
|
|
2329
1647
|
const r = await sa.wait(ids, ms);
|
|
2330
1648
|
ctx.onIdleUnpark?.();
|
|
2331
|
-
ctx.onProgressGateReset?.();
|
|
1649
|
+
ctx.onProgressGateReset?.();
|
|
2332
1650
|
return { ok: true, result: r.note };
|
|
2333
1651
|
}
|
|
2334
1652
|
catch (e) {
|
|
@@ -2372,9 +1690,6 @@ export const TOOLS = [
|
|
|
2372
1690
|
name: "load_skill",
|
|
2373
1691
|
description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
|
|
2374
1692
|
"matches your current task; follow the loaded playbook. " +
|
|
2375
|
-
// the instruction used to need a SECOND, separately-queued message that
|
|
2376
|
-
// only arrived at the next turn boundary. Passing `instructions` delivers it
|
|
2377
|
-
// in the SAME turn as the playbook, so "load X and do Y" costs one round-trip.
|
|
2378
1693
|
"Pass `instructions` with what you want done with it to receive both at once.",
|
|
2379
1694
|
parameters: {
|
|
2380
1695
|
type: "object",
|
|
@@ -2400,8 +1715,6 @@ export const TOOLS = [
|
|
|
2400
1715
|
}
|
|
2401
1716
|
let result = await readSkillFile(def);
|
|
2402
1717
|
if (def.files.length) {
|
|
2403
|
-
// surface bundled scripts as runnable paths: workspace-relative when
|
|
2404
|
-
// possible (bash runs in the workspace), absolute for global skills
|
|
2405
1718
|
const dirAbs = path.dirname(def.filePath);
|
|
2406
1719
|
const rootDir = roots
|
|
2407
1720
|
.map((r) => r.dir)
|
|
@@ -2415,8 +1728,6 @@ export const TOOLS = [
|
|
|
2415
1728
|
listed.map((f) => `- ${f}`).join("\n") +
|
|
2416
1729
|
`\nRun scripts with bash (chmod +x first if needed).`;
|
|
2417
1730
|
}
|
|
2418
|
-
// deliver the caller's instructions in THIS result, so the model does
|
|
2419
|
-
// not have to queue a second message that only lands next turn.
|
|
2420
1731
|
const instructions = str(args.instructions).trim();
|
|
2421
1732
|
if (instructions)
|
|
2422
1733
|
result +=
|
|
@@ -2465,12 +1776,6 @@ export const TOOLS = [
|
|
|
2465
1776
|
const content = str(args.content);
|
|
2466
1777
|
if (!content.trim())
|
|
2467
1778
|
return { ok: false, result: "content required" };
|
|
2468
|
-
// save GLOBALLY (config dir) so skills survive workspace switches and
|
|
2469
|
-
// are shared by every agent. Same-name handling is safe:
|
|
2470
|
-
// · overwriting the global copy replaces it atomically (writeFile)
|
|
2471
|
-
// · a same-name skill in a HIGHER-priority root (workspace) still
|
|
2472
|
-
// shadows this one at load time — that's the intended override,
|
|
2473
|
-
// so tell the model when its save would be invisible
|
|
2474
1779
|
const globalRoot = roots.find((r) => r.source === "global") ?? roots[0];
|
|
2475
1780
|
const filePath = await saveSkill(globalRoot.dir, name, description, content.slice(0, 64_000));
|
|
2476
1781
|
let note = "";
|
|
@@ -2482,10 +1787,9 @@ export const TOOLS = [
|
|
|
2482
1787
|
await fs.access(path.join(r.dir, name, SKILL_FILE));
|
|
2483
1788
|
note = ` (note: a workspace skill with the same name "${name}" takes precedence at load time)`;
|
|
2484
1789
|
}
|
|
2485
|
-
catch {
|
|
1790
|
+
catch { }
|
|
2486
1791
|
}
|
|
2487
1792
|
}
|
|
2488
|
-
// bundled scripts/files next to SKILL.md
|
|
2489
1793
|
const written = [];
|
|
2490
1794
|
const rawFiles = Array.isArray(args.files) ? args.files : [];
|
|
2491
1795
|
for (const f of rawFiles.slice(0, 10)) {
|
|
@@ -2509,24 +1813,9 @@ export const TOOLS = [
|
|
|
2509
1813
|
},
|
|
2510
1814
|
},
|
|
2511
1815
|
];
|
|
2512
|
-
/** #16: how many files one read_file call may cover. Each costs context, so an
|
|
2513
|
-
* unbounded array would just be a way to overflow the window in one shot. */
|
|
2514
1816
|
const MAX_MULTI_READ = 10;
|
|
2515
|
-
/**
|
|
2516
|
-
* The three read caps (#17 item 3). A line cap alone is not a bound: one 5MB minified
|
|
2517
|
-
* line is a single "line", and 2000 lines of 2000 chars is 4MB. Each cap kills a
|
|
2518
|
-
* different shape of oversized file:
|
|
2519
|
-
*
|
|
2520
|
-
* 2000 lines → a 3000-line source file (the `limit` default, unchanged)
|
|
2521
|
-
* 256 KB → a 5MB log, whatever its lines
|
|
2522
|
-
* 2000 chars → minified.js / a one-line JSON blob
|
|
2523
|
-
*
|
|
2524
|
-
* The byte cap is a SHARE of what a tool result can be, not the whole budget: the model
|
|
2525
|
-
* still needs room to reason about what it read.
|
|
2526
|
-
*/
|
|
2527
1817
|
const MAX_READ_BYTES = 256 * 1024;
|
|
2528
1818
|
const MAX_READ_LINE_CHARS = 2_000;
|
|
2529
|
-
/** how many lines a string contains — used to say where to resume a clipped read */
|
|
2530
1819
|
function countLines(s) {
|
|
2531
1820
|
let n = 0;
|
|
2532
1821
|
for (let i = 0; i < s.length; i++)
|
|
@@ -2534,15 +1823,6 @@ function countLines(s) {
|
|
|
2534
1823
|
n++;
|
|
2535
1824
|
return n;
|
|
2536
1825
|
}
|
|
2537
|
-
/**
|
|
2538
|
-
* Subsequence match: every character of `needle` appears in `hay`, in order,
|
|
2539
|
-
* gaps allowed (#16 "fuzzy").
|
|
2540
|
-
*
|
|
2541
|
-
* Deliberately not a ranked edit-distance search. This answers "is this the
|
|
2542
|
-
* file I meant", and it can only say yes or no — it cannot rank a plausible
|
|
2543
|
-
* WRONG file above the right one, which is the failure mode that actually costs
|
|
2544
|
-
* an agent time. Searching file CONTENT is what bash/grep are for.
|
|
2545
|
-
*/
|
|
2546
1826
|
export function fuzzyPathMatch(needle, hay) {
|
|
2547
1827
|
const n = needle.toLowerCase().replace(/[\s._/-]+/g, "");
|
|
2548
1828
|
if (!n)
|
|
@@ -2557,14 +1837,6 @@ export function fuzzyPathMatch(needle, hay) {
|
|
|
2557
1837
|
}
|
|
2558
1838
|
return false;
|
|
2559
1839
|
}
|
|
2560
|
-
/**
|
|
2561
|
-
* Locate a file by a loose name and read it.
|
|
2562
|
-
*
|
|
2563
|
-
* Walks the workspace (skipping .git, node_modules and build noise) and ranks
|
|
2564
|
-
* matches: exact path, exact basename, ends-with, then subsequence. When
|
|
2565
|
-
* several match it LISTS them rather than picking one — guessing here would have
|
|
2566
|
-
* the agent edit the wrong file, which is the expensive mistake.
|
|
2567
|
-
*/
|
|
2568
1840
|
async function fuzzyRead(needle, ctx, args) {
|
|
2569
1841
|
const q = needle.trim();
|
|
2570
1842
|
if (!q)
|
|
@@ -2614,12 +1886,7 @@ async function fuzzyRead(needle, ctx, args) {
|
|
|
2614
1886
|
}
|
|
2615
1887
|
found.sort((a, b) => a.rank - b.rank || a.rel.length - b.rel.length || a.rel.localeCompare(b.rel));
|
|
2616
1888
|
const best = found[0];
|
|
2617
|
-
// Only a confident match is read directly. If the query is vague enough that
|
|
2618
|
-
// several files match, LIST them: guessing would hand the agent the wrong
|
|
2619
|
-
// file, and the cost of that mistake is far higher than one extra call.
|
|
2620
|
-
// An exact path or basename is unambiguous, so those still read straight away.
|
|
2621
1889
|
if (best.rank <= 1 || found.length === 1) {
|
|
2622
|
-
// re-enter the tool so a unique match behaves exactly like a normal read
|
|
2623
1890
|
return executeTool("read_file", JSON.stringify({ ...args, path: best.rel, fuzzy: undefined }), ctx);
|
|
2624
1891
|
}
|
|
2625
1892
|
return {
|
|
@@ -2628,7 +1895,6 @@ async function fuzzyRead(needle, ctx, args) {
|
|
|
2628
1895
|
found.slice(0, 8).map((f) => ` ${f.rel}`).join("\n"),
|
|
2629
1896
|
};
|
|
2630
1897
|
}
|
|
2631
|
-
/** Current skills across the configured roots (for the system prompt listing). */
|
|
2632
1898
|
export async function currentSkills(ctx) {
|
|
2633
1899
|
return discoverSkills(ctx.skillRoots ?? []);
|
|
2634
1900
|
}
|
|
@@ -2638,9 +1904,7 @@ export function toolSpecs() {
|
|
|
2638
1904
|
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
2639
1905
|
}));
|
|
2640
1906
|
}
|
|
2641
|
-
/** tools that mutate the workspace — blocked for read-only personas */
|
|
2642
1907
|
const MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch", "bash"]);
|
|
2643
|
-
/** per-tool aliases: the common wrong key -> the key the schema declares. */
|
|
2644
1908
|
const ARG_ALIASES = {
|
|
2645
1909
|
bash: { cmd: "command", script: "command", shell: "command", input: "command" },
|
|
2646
1910
|
read_file: { file_path: "path", filepath: "path", file: "path", name: "path" },
|
|
@@ -2651,11 +1915,6 @@ const ARG_ALIASES = {
|
|
|
2651
1915
|
list_children: { file_path: "path", dir: "path" },
|
|
2652
1916
|
read_url: { uri: "url", link: "url" },
|
|
2653
1917
|
};
|
|
2654
|
-
/**
|
|
2655
|
-
* The one argument each tool's whole call usually boils down to. Used to
|
|
2656
|
-
* salvage a bare word ("ls") that was never wrapped in JSON at all — but ONLY
|
|
2657
|
-
* for tools where that is unambiguous, so "12" is never mistaken for a path.
|
|
2658
|
-
*/
|
|
2659
1918
|
const PRIMARY_STRING_ARG = {
|
|
2660
1919
|
bash: "command",
|
|
2661
1920
|
read_file: "path",
|
|
@@ -2666,7 +1925,6 @@ const PRIMARY_STRING_ARG = {
|
|
|
2666
1925
|
load_skill: "name",
|
|
2667
1926
|
save_skill: "content",
|
|
2668
1927
|
};
|
|
2669
|
-
/** keys whose schema type is a number / boolean, per tool. */
|
|
2670
1928
|
const NUMERIC_ARGS = {
|
|
2671
1929
|
read_file: ["offset", "limit", "context"],
|
|
2672
1930
|
bash: ["timeout_ms"],
|
|
@@ -2680,12 +1938,10 @@ const BOOL_ARGS = {
|
|
|
2680
1938
|
write_file: [],
|
|
2681
1939
|
list_dir: [],
|
|
2682
1940
|
};
|
|
2683
|
-
/** markdown/backtick framing models like to wrap a path in */
|
|
2684
1941
|
function unframe(v) {
|
|
2685
1942
|
const m = /^[`*_\s]+(.+?)[`*_\s]*$/.exec(v);
|
|
2686
1943
|
return m ? m[1] : v;
|
|
2687
1944
|
}
|
|
2688
|
-
/** coerce "100" -> 100, "true" -> true; returns undefined when not coercible */
|
|
2689
1945
|
function coerceScalar(v) {
|
|
2690
1946
|
if (typeof v !== "string")
|
|
2691
1947
|
return v;
|
|
@@ -2698,7 +1954,6 @@ function coerceScalar(v) {
|
|
|
2698
1954
|
return Number(t);
|
|
2699
1955
|
return v;
|
|
2700
1956
|
}
|
|
2701
|
-
/** Turn a value into an array, tolerating a bare scalar or a JSON array. */
|
|
2702
1957
|
function toArray(v) {
|
|
2703
1958
|
if (Array.isArray(v))
|
|
2704
1959
|
return v;
|
|
@@ -2711,7 +1966,6 @@ function toArray(v) {
|
|
|
2711
1966
|
return p;
|
|
2712
1967
|
}
|
|
2713
1968
|
catch {
|
|
2714
|
-
/* fall through — treat as a single element */
|
|
2715
1969
|
}
|
|
2716
1970
|
}
|
|
2717
1971
|
return [v];
|
|
@@ -2727,12 +1981,8 @@ export function repairToolInput(name, rawArgs) {
|
|
|
2727
1981
|
parsed = rawArgs?.trim() ? JSON.parse(rawArgs) : {};
|
|
2728
1982
|
}
|
|
2729
1983
|
catch {
|
|
2730
|
-
// not JSON at all: a whole call was stringified, or the model emitted a
|
|
2731
|
-
// bare word. Only salvageable when the tool takes a single obvious string.
|
|
2732
1984
|
const bare = rawArgs?.trim().replace(/^[`*\s]+|[`*\s]+$/g, "");
|
|
2733
1985
|
const primary = PRIMARY_STRING_ARG[name];
|
|
2734
|
-
// a bare number/boolean is never a path or a command — refusing is far
|
|
2735
|
-
// better than inventing one
|
|
2736
1986
|
const isScalarish = bare !== "" && /^(true|false|null|-?\d+(\.\d+)?)$/i.test(bare);
|
|
2737
1987
|
if (bare && primary && !isScalarish && !bare.startsWith("{") && !bare.startsWith("[")) {
|
|
2738
1988
|
notes.push(`wrapped a bare string as {"${primary}": …}`);
|
|
@@ -2740,23 +1990,17 @@ export function repairToolInput(name, rawArgs) {
|
|
|
2740
1990
|
}
|
|
2741
1991
|
return { args: {}, notes, error: `arguments were not valid JSON — send a JSON object, e.g. {"…"}` };
|
|
2742
1992
|
}
|
|
2743
|
-
// a JSON-stringified object arrives as a string
|
|
2744
1993
|
if (typeof parsed === "string" && /[{[]/.test(parsed.trim().slice(0, 2))) {
|
|
2745
1994
|
try {
|
|
2746
1995
|
parsed = JSON.parse(parsed);
|
|
2747
1996
|
notes.push("unwrapped a JSON-stringified argument object");
|
|
2748
1997
|
}
|
|
2749
1998
|
catch {
|
|
2750
|
-
/* leave as-is; it may genuinely be a string argument */
|
|
2751
1999
|
}
|
|
2752
2000
|
}
|
|
2753
|
-
// a bare scalar where an object is required
|
|
2754
2001
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2755
2002
|
if (parsed === null || parsed === undefined)
|
|
2756
2003
|
return { args: {}, notes, error: "arguments were empty" };
|
|
2757
|
-
// An ARRAY is a legitimate value for a path-ish argument, but only for the
|
|
2758
|
-
// tools that actually take one — inventing {path:[…]} for apply_patch would
|
|
2759
|
-
// hand the tool a key it has never heard of.
|
|
2760
2004
|
if (Array.isArray(parsed) && name === "read_file") {
|
|
2761
2005
|
notes.push("kept the array as a paths list");
|
|
2762
2006
|
return { args: { paths: parsed }, notes };
|
|
@@ -2774,7 +2018,6 @@ export function repairToolInput(name, rawArgs) {
|
|
|
2774
2018
|
};
|
|
2775
2019
|
}
|
|
2776
2020
|
const args = { ...parsed };
|
|
2777
|
-
// alias normalisation
|
|
2778
2021
|
for (const [from, to] of Object.entries(ARG_ALIASES[name] ?? {})) {
|
|
2779
2022
|
if (!(from in args))
|
|
2780
2023
|
continue;
|
|
@@ -2783,13 +2026,10 @@ export function repairToolInput(name, rawArgs) {
|
|
|
2783
2026
|
notes.push(`renamed "${from}" -> "${to}"`);
|
|
2784
2027
|
}
|
|
2785
2028
|
else {
|
|
2786
|
-
// both present: the schema key wins and the stray alias is DROPPED, not
|
|
2787
|
-
// left behind as an unknown argument the tool would ignore
|
|
2788
2029
|
notes.push(`dropped "${from}" — "${to}" was also given and wins`);
|
|
2789
2030
|
}
|
|
2790
2031
|
delete args[from];
|
|
2791
2032
|
}
|
|
2792
|
-
// string -> number / boolean for the keys whose schema says so
|
|
2793
2033
|
for (const k of NUMERIC_ARGS[name] ?? []) {
|
|
2794
2034
|
if (k in args) {
|
|
2795
2035
|
const c = coerceScalar(args[k]);
|
|
@@ -2808,18 +2048,12 @@ export function repairToolInput(name, rawArgs) {
|
|
|
2808
2048
|
}
|
|
2809
2049
|
}
|
|
2810
2050
|
}
|
|
2811
|
-
// an array-typed arg arriving as a scalar
|
|
2812
2051
|
if (name === "read_file" && "paths" in args) {
|
|
2813
2052
|
const before = args.paths;
|
|
2814
2053
|
args.paths = toArray(before);
|
|
2815
2054
|
if (!Array.isArray(before))
|
|
2816
2055
|
notes.push('coerced "paths" to an array');
|
|
2817
2056
|
}
|
|
2818
|
-
// a JSON-stringified object hiding inside a single argument, e.g.
|
|
2819
|
-
// {"path": "{\"path\": \"a.ts\"}"} — unwrap it and merge its keys
|
|
2820
|
-
// Only PATH-like keys: `content`/`old_text`/`new_text` legitimately hold JSON
|
|
2821
|
-
// (writing a .json file!), and unwrapping those would REPLACE the file's
|
|
2822
|
-
// actual data with an inner field — silent data loss, not repair.
|
|
2823
2057
|
for (const k of ["path", "command"]) {
|
|
2824
2058
|
const v = args[k];
|
|
2825
2059
|
if (typeof v !== "string")
|
|
@@ -2837,10 +2071,8 @@ export function repairToolInput(name, rawArgs) {
|
|
|
2837
2071
|
}
|
|
2838
2072
|
}
|
|
2839
2073
|
catch {
|
|
2840
|
-
/* genuinely not JSON — leave it alone */
|
|
2841
2074
|
}
|
|
2842
2075
|
}
|
|
2843
|
-
// markdown framing on a path
|
|
2844
2076
|
for (const k of ["path", "file_path"]) {
|
|
2845
2077
|
if (typeof args[k] === "string") {
|
|
2846
2078
|
const f = unframe(args[k]);
|
|
@@ -2853,9 +2085,6 @@ export function repairToolInput(name, rawArgs) {
|
|
|
2853
2085
|
return { args, notes };
|
|
2854
2086
|
}
|
|
2855
2087
|
export async function executeTool(name, rawArgs, ctx) {
|
|
2856
|
-
// MCP tools are namespaced (`mcp__server__tool`) so they can never reach this point
|
|
2857
|
-
// by shadowing a built-in. Dispatched BEFORE the built-in lookup because there is no
|
|
2858
|
-
// static TOOLS entry to find — the tool list belongs to the server.
|
|
2859
2088
|
if (name.startsWith(MCP_PREFIX)) {
|
|
2860
2089
|
if (ctx.signal?.aborted)
|
|
2861
2090
|
return { ok: false, result: "aborted (harness shutdown)" };
|
|
@@ -2876,12 +2105,8 @@ export async function executeTool(name, rawArgs, ctx) {
|
|
|
2876
2105
|
return { ok: false, result: `unknown tool: ${name}` };
|
|
2877
2106
|
if (ctx.signal?.aborted)
|
|
2878
2107
|
return { ok: false, result: "aborted (harness shutdown)" };
|
|
2879
|
-
// read-only personas (researcher/reviewer) are enforced, not just asked
|
|
2880
2108
|
if (ctx.readOnly && MUTATING_TOOLS.has(name) && !ctx.allowMutatingForReview?.includes(name))
|
|
2881
2109
|
return { ok: false, result: `${name} is blocked: this agent runs with read-only tools` };
|
|
2882
|
-
// repair the call before handing it to the tool. A model that sends
|
|
2883
|
-
// {"cmd":"ls"} or {"limit":"100"} should get the work done, not a generic parse
|
|
2884
|
-
// failure and a wasted turn. Unrepairable calls say what to send instead.
|
|
2885
2110
|
const repaired = repairToolInput(name, rawArgs);
|
|
2886
2111
|
if (repaired.error)
|
|
2887
2112
|
return { ok: false, result: `${name}: ${repaired.error}` };
|
|
@@ -2895,24 +2120,6 @@ export async function executeTool(name, rawArgs, ctx) {
|
|
|
2895
2120
|
return { ok: false, result: `tool error: ${err.message}` };
|
|
2896
2121
|
}
|
|
2897
2122
|
}
|
|
2898
|
-
/**
|
|
2899
|
-
* The gateway's cap on the REQUEST BODY'S STRUCTURE, not on its token count.
|
|
2900
|
-
*
|
|
2901
|
-
* Command Code's `/alpha` gateway rejects with
|
|
2902
|
-
* `400 Invalid request JSON: JSON node count cannot exceed 32768`, and the distinction matters
|
|
2903
|
-
* because every usual recovery is wrong for it:
|
|
2904
|
-
*
|
|
2905
|
-
* - COMPACTING does not reliably help. It does reduce message and content-part counts, but a
|
|
2906
|
-
* session heavy in tool calls and images stays above the cap — measured upstream at 22.6% of a
|
|
2907
|
-
* 1,000,000-token window with 1,436 messages and 61 image parts. Retrying after a compaction
|
|
2908
|
-
* therefore burns a full-price summarisation to be refused again.
|
|
2909
|
-
* - RESENDING the same structure cannot succeed at all, so this is not a transient error.
|
|
2910
|
-
*
|
|
2911
|
-
* So it is detected SEPARATELY from `isContextOverflow`, and the remedy is the honest one: a new
|
|
2912
|
-
* session, or fewer messages / tool calls / images in this one.
|
|
2913
|
-
*
|
|
2914
|
-
* Reference: Mars-Sea/dsh-commandcode-provider issue #96 and its `JSON_NODE_LIMIT_REJECTION`.
|
|
2915
|
-
*/
|
|
2916
2123
|
export function isRequestNodeLimit(err) {
|
|
2917
2124
|
const msg = String(err?.message ?? "");
|
|
2918
2125
|
const m = /JSON node count cannot exceed\s+(\d+)/i.exec(msg);
|