teapot-coding-agent 0.22.13 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/acp/adapter.js +198 -0
- package/dist/agent/agent.js +282 -15
- package/dist/agent/llm.js +33 -0
- package/dist/agent/skills.js +212 -32
- package/dist/agent/tools.js +776 -39
- package/dist/index.js +74 -27
- package/dist/log/events.js +14 -0
- package/dist/master.js +146 -14
- package/dist/model-meta.js +96 -36
- package/dist/server/api.js +44 -8
- package/dist/server/term-size.js +62 -0
- package/package.json +1 -1
- package/public/assets/index-B_XaPTQR.css +1 -0
- package/public/assets/index-Y_KYu5nW.js +30 -0
- package/public/assets/index-Y_KYu5nW.js.map +1 -0
- package/public/assets/{shiki-B-DhHuic.js → shiki-CG8vVdKn.js} +2 -2
- package/public/assets/{shiki-B-DhHuic.js.map → shiki-CG8vVdKn.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-B-RJz1_I.css +0 -1
- package/public/assets/index-Bg8Y4MGZ.js +0 -30
- package/public/assets/index-Bg8Y4MGZ.js.map +0 -1
package/dist/agent/tools.js
CHANGED
|
@@ -16,6 +16,38 @@ function clip(s, max) {
|
|
|
16
16
|
async function readText(p) {
|
|
17
17
|
return fs.readFile(p, "utf8");
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Optimistic-concurrency guard (#5). The model reads a file, thinks, then
|
|
21
|
+
* writes — and in between, a sub-agent, a background job or the operator may
|
|
22
|
+
* have changed it. Anchored edits usually fail safe (old_text is gone, so the
|
|
23
|
+
* call errors), but two paths silently clobber work nobody looked at:
|
|
24
|
+
* `replace_all` rewrites every occurrence present NOW, including ones that
|
|
25
|
+
* appeared after the model formed its plan, and write_file/apply_patch replace
|
|
26
|
+
* whole files unconditionally.
|
|
27
|
+
*
|
|
28
|
+
* A caller that passes `base_content` (the bytes it actually read) therefore
|
|
29
|
+
* gets a refusal instead of a lost update — the same contract the web file
|
|
30
|
+
* editor already enforces (see api.ts PUT /file). The check is advisory by
|
|
31
|
+
* design: omitting the parameter keeps today's behaviour, so this can never
|
|
32
|
+
* break a working agent loop that does not use it.
|
|
33
|
+
*/
|
|
34
|
+
function staleGuard(pathArg, base, current) {
|
|
35
|
+
if (typeof base !== "string")
|
|
36
|
+
return null; // not supplied → no check
|
|
37
|
+
if (base === current)
|
|
38
|
+
return null; // unchanged → proceed
|
|
39
|
+
const rel = pathArg;
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
result: `${rel} changed on disk since you read it — nothing was written.\n` +
|
|
43
|
+
`your base_content: ${base.length} bytes, on disk now: ${current === null ? "(deleted)" : `${current.length} bytes`}.\n` +
|
|
44
|
+
`Re-read ${rel}, redo the edit against the current content, and pass the fresh text as base_content.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Read the file for a guarded write, or null when it does not exist. */
|
|
48
|
+
async function currentOrNull(p) {
|
|
49
|
+
return fs.readFile(p, "utf8").catch(() => null);
|
|
50
|
+
}
|
|
19
51
|
/** Resolve a path inside the workspace; reject escapes (incl. symlink targets).
|
|
20
52
|
* KNOWN LIMIT (review finding, accepted): the check-then-open pattern has a
|
|
21
53
|
* TOCTOU window — a symlink swapped in between safeJoin() and fs.open() could
|
|
@@ -56,6 +88,28 @@ function bgMapFor(cwd) {
|
|
|
56
88
|
}
|
|
57
89
|
return m;
|
|
58
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Environment for every shell the agent runs.
|
|
93
|
+
*
|
|
94
|
+
* `PWD` is deliberately removed: the server's own PWD would contradict the
|
|
95
|
+
* `cwd` we spawn with, so a command trusting `$PWD` would act on the wrong
|
|
96
|
+
* directory (#34). `bash` re-creates PWD from its actual cwd.
|
|
97
|
+
*/
|
|
98
|
+
function shellEnv() {
|
|
99
|
+
const { PWD: _pwd, ...rest } = process.env;
|
|
100
|
+
return { ...rest, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Human-readable elapsed-time suffix appended to a shell result so the MODEL
|
|
104
|
+
* sees how long a command actually took (#26). Only the operator saw it — the
|
|
105
|
+
* model happily re-issued commands that ran for tens of minutes.
|
|
106
|
+
*
|
|
107
|
+
* Kept terse and single-line: it rides along with the tool output, and mirrors
|
|
108
|
+
* what bash_output already prints for a still-running job.
|
|
109
|
+
*/
|
|
110
|
+
export function elapsed(t0, now = Date.now()) {
|
|
111
|
+
return `\n[took ${((now - t0) / 1000).toFixed(1)}s]`;
|
|
112
|
+
}
|
|
59
113
|
function startBackgroundShell(cmd, ctx) {
|
|
60
114
|
const map = bgMapFor(ctx.cwd);
|
|
61
115
|
// opportunistic cleanup of long-dead jobs so the map can't grow forever
|
|
@@ -64,11 +118,13 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
64
118
|
void id;
|
|
65
119
|
}
|
|
66
120
|
const id = `bg${++bgSeq}`;
|
|
67
|
-
|
|
121
|
+
// plain `-c`, not `-lc`: a login shell sources ~/.bash_profile, whose `cd`
|
|
122
|
+
// would move background jobs out of the workspace (#34)
|
|
123
|
+
const child = spawn("/bin/bash", ["-c", cmd], {
|
|
68
124
|
cwd: ctx.cwd,
|
|
69
125
|
detached: true,
|
|
70
126
|
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
-
env:
|
|
127
|
+
env: shellEnv(),
|
|
72
128
|
});
|
|
73
129
|
const sh = {
|
|
74
130
|
id, cmd, child,
|
|
@@ -83,14 +139,31 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
83
139
|
};
|
|
84
140
|
child.stdout?.on("data", collect);
|
|
85
141
|
child.stderr?.on("data", collect);
|
|
86
|
-
|
|
142
|
+
// "exit" (process gone) and "close" (all stdio drained) are DIFFERENT events.
|
|
143
|
+
// A backgrounded grandchild that inherits stdout — `npm run dev &`,
|
|
144
|
+
// `docker compose up &`, a script that forks a watcher — keeps the pipe open,
|
|
145
|
+
// so "close" can stay pending long after the shell itself is dead. Keying
|
|
146
|
+
// the exit state off "close" alone left such jobs marked running FOREVER:
|
|
147
|
+
// hasRunningBgShells() said yes, the auto-continue loop kept breaking out on
|
|
148
|
+
// "background job still running", and the agent sat in "running" with the
|
|
149
|
+
// job long dead and no notification ever delivered (#23).
|
|
150
|
+
//
|
|
151
|
+
// So: "exit" is authoritative for state and for the completion
|
|
152
|
+
// notification; "close" only means "output finally drained" and is used to
|
|
153
|
+
// capture the tail. The notify() helper keeps this exactly-once.
|
|
154
|
+
let exitNotified = false;
|
|
155
|
+
const notify = (code) => {
|
|
87
156
|
sh.exited = true;
|
|
88
|
-
|
|
157
|
+
if (code !== null)
|
|
158
|
+
sh.code = code;
|
|
159
|
+
if (exitNotified)
|
|
160
|
+
return;
|
|
161
|
+
exitNotified = true;
|
|
89
162
|
// tell the harness so the agent learns the outcome WITHOUT polling
|
|
90
163
|
try {
|
|
91
164
|
ctx.onBackgroundExit?.({
|
|
92
165
|
id,
|
|
93
|
-
code,
|
|
166
|
+
code: sh.code,
|
|
94
167
|
cmd,
|
|
95
168
|
durationMs: Date.now() - sh.startedAt,
|
|
96
169
|
outputTail: sh.out.slice(-2000),
|
|
@@ -99,7 +172,9 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
99
172
|
catch {
|
|
100
173
|
/* notification is best-effort */
|
|
101
174
|
}
|
|
102
|
-
}
|
|
175
|
+
};
|
|
176
|
+
child.on("exit", (code) => notify(code));
|
|
177
|
+
child.on("close", (code) => notify(code));
|
|
103
178
|
map.set(id, sh);
|
|
104
179
|
return id;
|
|
105
180
|
}
|
|
@@ -132,11 +207,15 @@ export function hasRunningBgShells(cwd) {
|
|
|
132
207
|
/** Run a command in its own process group; kill the whole group on timeout. */
|
|
133
208
|
function runShell(cmd, ctx, timeoutMs) {
|
|
134
209
|
return new Promise((resolve) => {
|
|
135
|
-
const
|
|
210
|
+
const t0 = Date.now();
|
|
211
|
+
// `bash -l` sources /etc/profile + ~/.bash_profile, either of which may
|
|
212
|
+
// `cd` somewhere — which silently moved every later relative path out of
|
|
213
|
+
// the workspace (#34). Plain `-c` starts in ctx.cwd and stays there.
|
|
214
|
+
const child = spawn("/bin/bash", ["-c", cmd], {
|
|
136
215
|
cwd: ctx.cwd,
|
|
137
216
|
detached: true, // own process group
|
|
138
217
|
stdio: ["ignore", "pipe", "pipe"],
|
|
139
|
-
env:
|
|
218
|
+
env: shellEnv(),
|
|
140
219
|
});
|
|
141
220
|
let out = "";
|
|
142
221
|
let done = false;
|
|
@@ -176,7 +255,7 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
176
255
|
ctx.signal?.removeEventListener("abort", onAbort);
|
|
177
256
|
if (!done) {
|
|
178
257
|
done = true;
|
|
179
|
-
resolve({ ok: false, result: `spawn error: ${err.message}` });
|
|
258
|
+
resolve({ ok: false, result: `spawn error: ${err.message}${elapsed(t0)}` });
|
|
180
259
|
}
|
|
181
260
|
});
|
|
182
261
|
child.on("close", (code, signal) => {
|
|
@@ -188,14 +267,17 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
188
267
|
if (killReason) {
|
|
189
268
|
resolve({
|
|
190
269
|
ok: false,
|
|
191
|
-
result: `${killReason}. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}`,
|
|
270
|
+
result: `${killReason}. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}${elapsed(t0)}`,
|
|
192
271
|
});
|
|
193
272
|
return;
|
|
194
273
|
}
|
|
195
274
|
resolve({
|
|
196
275
|
ok: code === 0,
|
|
197
276
|
result: (code === 0 ? "" : `exit=${code}${signal ? ` signal=${signal}` : ""}\n`) +
|
|
198
|
-
clip(out.trim() || "(no output)", ctx.maxOutputBytes)
|
|
277
|
+
clip(out.trim() || "(no output)", ctx.maxOutputBytes) +
|
|
278
|
+
// the model, not just the operator, needs to know a command was slow —
|
|
279
|
+
// otherwise it happily re-issues something that takes 40 minutes (#26)
|
|
280
|
+
elapsed(t0),
|
|
199
281
|
});
|
|
200
282
|
});
|
|
201
283
|
});
|
|
@@ -511,19 +593,214 @@ async function applyPatch(patch, ctx) {
|
|
|
511
593
|
catch (e) {
|
|
512
594
|
return { ok: false, result: `patch validation failed: ${e.message}` };
|
|
513
595
|
}
|
|
514
|
-
// phase 2 — commit
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
596
|
+
// phase 2 — commit.
|
|
597
|
+
//
|
|
598
|
+
// Phase 1 validates every hunk in memory, so a CONTENT mismatch never
|
|
599
|
+
// lands halfway. An I/O failure still could: the old loop wrote straight
|
|
600
|
+
// to the final paths, so a failure on write k of n left writes 1..k-1
|
|
601
|
+
// already on disk while the tool reported "patch failed". apply_patch is
|
|
602
|
+
// documented as atomic, so make the commit phase live up to it:
|
|
603
|
+
// 1. back up the current bytes of every path we are about to change
|
|
604
|
+
// 2. write each new file to a sibling temp file, then rename() over the
|
|
605
|
+
// target (rename within a directory is atomic, so a reader never
|
|
606
|
+
// observes a half-written file)
|
|
607
|
+
// 3. on ANY failure, restore the backups and remove temp files
|
|
608
|
+
// Deletes are only performed once every write has landed, and a failed
|
|
609
|
+
// delete is now reported instead of silently swallowed.
|
|
610
|
+
const backups = new Map();
|
|
611
|
+
const temps = [];
|
|
612
|
+
try {
|
|
613
|
+
for (const w of writes) {
|
|
614
|
+
if (!backups.has(w.abs))
|
|
615
|
+
backups.set(w.abs, await fs.readFile(w.abs, "utf8").catch(() => null));
|
|
616
|
+
await fs.mkdir(path.dirname(w.abs), { recursive: true });
|
|
617
|
+
const tmp = `${w.abs}.teapot-patch-${process.pid}-${temps.length}.tmp`;
|
|
618
|
+
temps.push(tmp);
|
|
619
|
+
await fs.writeFile(tmp, w.content, "utf8");
|
|
620
|
+
await fs.rename(tmp, w.abs);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
catch (e) {
|
|
624
|
+
await rollbackWrites(backups, temps);
|
|
625
|
+
return {
|
|
626
|
+
ok: false,
|
|
627
|
+
result: `patch commit failed and was rolled back (no files changed): ${e.message}\n` +
|
|
628
|
+
"re-read the files and regenerate the patch",
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
// writes are in — now the deletes. A delete that fails is a real, visible
|
|
632
|
+
// inconsistency (the old content moved/updated but the source survived),
|
|
633
|
+
// so roll the whole patch back rather than report a false success.
|
|
634
|
+
const removed = [];
|
|
635
|
+
try {
|
|
636
|
+
for (const d of deletes) {
|
|
637
|
+
await fs.rm(d);
|
|
638
|
+
removed.push(d);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
for (const d of removed) {
|
|
643
|
+
const orig = await fs.readFile(d, "utf8").catch(() => null);
|
|
644
|
+
if (orig !== null)
|
|
645
|
+
await fs.writeFile(d, orig, "utf8").catch(() => { });
|
|
646
|
+
}
|
|
647
|
+
await rollbackWrites(backups, temps);
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
result: `patch commit failed and was rolled back (no files changed): ${e.message}`,
|
|
651
|
+
};
|
|
518
652
|
}
|
|
519
|
-
for (const d of deletes)
|
|
520
|
-
await fs.rm(d).catch(() => { });
|
|
521
653
|
return { ok: true, result: `patch applied:\n${summary.join("\n")}` };
|
|
522
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Undo a partially committed patch: restore each target's original bytes
|
|
657
|
+
* (null = it did not exist before, so remove the file we created) and clean
|
|
658
|
+
* up any temp file that never made it through its rename.
|
|
659
|
+
*/
|
|
660
|
+
async function rollbackWrites(backups, temps) {
|
|
661
|
+
for (const t of temps)
|
|
662
|
+
await fs.rm(t, { force: true }).catch(() => { });
|
|
663
|
+
for (const [abs, orig] of backups) {
|
|
664
|
+
if (orig === null)
|
|
665
|
+
await fs.rm(abs, { force: true }).catch(() => { });
|
|
666
|
+
else
|
|
667
|
+
await fs.writeFile(abs, orig, "utf8").catch(() => { });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/* ---------- hashline: stable, verifiable line anchors (#7 / #4) ----------
|
|
671
|
+
*
|
|
672
|
+
* The default `N| ` gutter is a trap: read_file prints it, but edit_file's
|
|
673
|
+
* old_text must NOT contain it — so the model strips the gutter by hand on
|
|
674
|
+
* every read, and the only mitigation is a sentence in the tool description.
|
|
675
|
+
*
|
|
676
|
+
* Format follows the established "hashline" convention used across AI coding
|
|
677
|
+
* harnesses (quangdang46/hashline, kebbbnnn/hashline, opencode-hashline,
|
|
678
|
+
* pi-hashline-edit-pro, …) so anchors read the same everywhere:
|
|
679
|
+
*
|
|
680
|
+
* 12:a3f1| function calculateTotal(items) {
|
|
681
|
+
*
|
|
682
|
+
* i.e. `LINE:HASH| content` — the line number is KEPT (humans and models both
|
|
683
|
+
* navigate by line) and the content hash is ADDED as an integrity anchor.
|
|
684
|
+
*
|
|
685
|
+
* Design points taken from those implementations rather than invented:
|
|
686
|
+
* - the hash covers the STRIPPED content, so re-indenting a line does not
|
|
687
|
+
* invalidate its anchor;
|
|
688
|
+
* - blank/whitespace-only lines get a fixed reserved hash;
|
|
689
|
+
* - when a hash occurs more than once, the anchor's line number disambiguates
|
|
690
|
+
* via PROXIMITY search, instead of silently taking the first match.
|
|
691
|
+
*/
|
|
692
|
+
const HASH_BLANK = " "; // 4 spaces: reserved, cannot collide with a hex hash
|
|
693
|
+
// A real hash is 4 HEX chars, but the shape also accepts other 4-8 char
|
|
694
|
+
// alphanumerics: a model that mistypes or invents an anchor is common enough
|
|
695
|
+
// that we should still recognise the SHAPE and say "that anchor is malformed"
|
|
696
|
+
// rather than falling through to "no similar text found" (#7).
|
|
697
|
+
const ANCHOR_RE = /^\s*(\d+):([0-9a-z]{4,8}| {4})\|/;
|
|
698
|
+
/** 4-char content hash (FNV-1a 32-bit → hex), of the STRIPPED line */
|
|
699
|
+
export function hashLine(content) {
|
|
700
|
+
const stripped = content.trim();
|
|
701
|
+
if (!stripped)
|
|
702
|
+
return HASH_BLANK;
|
|
703
|
+
let h = 0x811c9dc5;
|
|
704
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
705
|
+
h ^= stripped.charCodeAt(i);
|
|
706
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
707
|
+
}
|
|
708
|
+
return (h >>> 0).toString(16).padStart(8, "0").slice(-4);
|
|
709
|
+
}
|
|
710
|
+
/** `LINE:HASH| content` for each line, 1-indexed */
|
|
711
|
+
export function renderHashlines(lines, startLine = 1) {
|
|
712
|
+
return lines
|
|
713
|
+
.map((l, i) => `${startLine + i}:${hashLine(l)}| ${l}`)
|
|
714
|
+
.join("\n");
|
|
715
|
+
}
|
|
716
|
+
/** parse a `LINE:HASH` anchor; returns null when the text is not an anchor */
|
|
717
|
+
export function parseAnchor(anchor) {
|
|
718
|
+
const m = /^\s*(\d+):([0-9a-z]{4,8}| {4})\s*$/.exec(anchor);
|
|
719
|
+
if (!m)
|
|
720
|
+
return null;
|
|
721
|
+
return { line: Number(m[1]), hash: m[2] };
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Locate the line an anchor refers to.
|
|
725
|
+
*
|
|
726
|
+
* The hash is authoritative; the line number only disambiguates when the same
|
|
727
|
+
* content appears twice (proximity search). Falling back to "nearest line" for
|
|
728
|
+
* an unknown hash is deliberately NOT done — that would silently edit the wrong
|
|
729
|
+
* line, which is the entire failure mode hashline exists to prevent.
|
|
730
|
+
*/
|
|
731
|
+
export function resolveAnchor(lines, anchor) {
|
|
732
|
+
const hits = [];
|
|
733
|
+
for (let i = 0; i < lines.length; i++)
|
|
734
|
+
if (hashLine(lines[i]) === anchor.hash)
|
|
735
|
+
hits.push(i);
|
|
736
|
+
if (!hits.length)
|
|
737
|
+
return {
|
|
738
|
+
error: /^[0-9a-f]{4}$/.test(anchor.hash) || anchor.hash === HASH_BLANK
|
|
739
|
+
? `no line matches hash ${anchor.hash} — the file changed since you read it (re-read it)`
|
|
740
|
+
: // distinguish "you invented/mistyped this anchor" from "the file moved",
|
|
741
|
+
// which sends the agent to fix the right thing (#7)
|
|
742
|
+
`hash ${JSON.stringify(anchor.hash)} is not a valid anchor (expected 4 hex chars) — re-read the file and copy the anchor verbatim`,
|
|
743
|
+
};
|
|
744
|
+
if (hits.length === 1)
|
|
745
|
+
return { index: hits[0], how: "unique" };
|
|
746
|
+
const target = anchor.line - 1;
|
|
747
|
+
const best = hits.reduce((a, b) => (Math.abs(b - target) < Math.abs(a - target) ? b : a));
|
|
748
|
+
return { index: best, how: `ambiguous, resolved by proximity to line ${anchor.line}` };
|
|
749
|
+
}
|
|
750
|
+
/** Is this whole block a hashline block (every line carries an anchor)? */
|
|
751
|
+
export function isHashlineBlock(block) {
|
|
752
|
+
const rows = block.split("\n").filter((l) => l !== "");
|
|
753
|
+
if (!rows.length)
|
|
754
|
+
return false;
|
|
755
|
+
return rows.every((l) => ANCHOR_RE.test(l));
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Turn a copied hashline block back into file content.
|
|
759
|
+
*
|
|
760
|
+
* The anchors are verified against the CURRENT file, so a stale read is
|
|
761
|
+
* refused instead of editing the wrong bytes — the whole point of the format.
|
|
762
|
+
*/
|
|
763
|
+
export function resolveHashlineBlock(block, lines) {
|
|
764
|
+
const rows = block.split("\n").filter((l) => l !== "");
|
|
765
|
+
const picked = [];
|
|
766
|
+
const content = [];
|
|
767
|
+
const notes = [];
|
|
768
|
+
const used = new Set();
|
|
769
|
+
for (const row of rows) {
|
|
770
|
+
const a = parseAnchor(row.slice(0, row.indexOf("|")));
|
|
771
|
+
if (!a)
|
|
772
|
+
return { error: `not a valid hashline anchor: ${row.slice(0, 12)}…` };
|
|
773
|
+
const r = resolveAnchor(lines, a);
|
|
774
|
+
if ("error" in r)
|
|
775
|
+
return { error: r.error };
|
|
776
|
+
if (used.has(r.index))
|
|
777
|
+
return { error: `anchor ${a.line}:${a.hash} resolves to a line already used in this block` };
|
|
778
|
+
used.add(r.index);
|
|
779
|
+
picked.push(r.index);
|
|
780
|
+
content.push(lines[r.index]);
|
|
781
|
+
if (r.how !== "unique")
|
|
782
|
+
notes.push(r.how);
|
|
783
|
+
}
|
|
784
|
+
if (!picked.length)
|
|
785
|
+
return { error: "empty hashline block" };
|
|
786
|
+
return { text: content.join("\n"), indices: picked, notes };
|
|
787
|
+
}
|
|
788
|
+
/** normalize the read_file line-reference argument */
|
|
789
|
+
export function lineIdMode(args) {
|
|
790
|
+
const v = args.line_ids ?? args.hashline;
|
|
791
|
+
if (v === true)
|
|
792
|
+
return "hash";
|
|
793
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
794
|
+
return s === "hash" || s === "hashline" || s === "true" ? "hash" : "none";
|
|
795
|
+
}
|
|
523
796
|
export const TOOLS = [
|
|
524
797
|
{
|
|
525
798
|
name: "read_file",
|
|
526
799
|
description: "Read a text file from the workspace. Returns numbered lines (`N| ` prefixes are display-only — never copy them into edit_file). " +
|
|
800
|
+
"With `line_ids:\"hash\"` it instead returns `LINE:HASH| content` anchors (the hashline format used by other coding harnesses), " +
|
|
801
|
+
"which edit_file accepts VERBATIM and re-verifies against the file — so prefer it when you intend to edit what you just read. " +
|
|
802
|
+
"Pass `paths` (max 10) to read SEVERAL files in one call instead of one call per file; " +
|
|
803
|
+
"pass `fuzzy` when you do not know the exact path. " +
|
|
527
804
|
"With `pattern`, acts like grep: only matching lines (JS regex, optional `ignore_case`) plus `context` surrounding lines are returned. " +
|
|
528
805
|
"A negative `offset` counts from the end (-30 → last 30 lines, or last 30 matches in pattern mode).",
|
|
529
806
|
parameters: {
|
|
@@ -535,14 +812,79 @@ export const TOOLS = [
|
|
|
535
812
|
pattern: { type: "string", description: "JS regex — return only matching lines (+context) instead of the whole file" },
|
|
536
813
|
context: { type: "number", description: "context lines around each pattern match (max 5)" },
|
|
537
814
|
ignore_case: { type: "boolean", description: "case-insensitive pattern matching" },
|
|
815
|
+
paths: {
|
|
816
|
+
type: "array",
|
|
817
|
+
items: { type: "string" },
|
|
818
|
+
description: "Read SEVERAL files in one call (max 10) instead of one call per file. " +
|
|
819
|
+
"Each comes back under its own `--- path ---` header, and a file that " +
|
|
820
|
+
"cannot be read is reported inline without failing the others.",
|
|
821
|
+
},
|
|
822
|
+
fuzzy: {
|
|
823
|
+
type: "string",
|
|
824
|
+
description: "Read the file when you do not know its exact path. Matches loosely " +
|
|
825
|
+
"(characters in order, ignoring separators and case); if several files " +
|
|
826
|
+
"match it lists them so you can pick the right one with `path`.",
|
|
827
|
+
},
|
|
828
|
+
line_ids: {
|
|
829
|
+
type: "string",
|
|
830
|
+
enum: ["none", "hash"],
|
|
831
|
+
description: "'hash' → `LINE:HASH| content` anchors that you can hand to edit_file VERBATIM; " +
|
|
832
|
+
"the hash is verified against the file, so a stale read is refused instead of editing the wrong lines. " +
|
|
833
|
+
"'none' (default) → `N| ` gutters, which are display-only and must be stripped by hand.",
|
|
834
|
+
},
|
|
538
835
|
},
|
|
539
836
|
required: ["path"],
|
|
540
837
|
},
|
|
541
838
|
async run(args, ctx) {
|
|
839
|
+
// #16: MULTI-FILE and FUZZY reads.
|
|
840
|
+
//
|
|
841
|
+
// Both are opt-in and handled HERE rather than by restructuring this
|
|
842
|
+
// tool: `paths` re-enters executeTool once per file, and `fuzzy` locates
|
|
843
|
+
// a file then re-enters it. Nothing about the single-file path below
|
|
844
|
+
// changes, so its output stays byte-identical — which #7's hashline
|
|
845
|
+
// anchors and the existing tests both depend on.
|
|
846
|
+
if (Array.isArray(args.paths) && args.paths.length) {
|
|
847
|
+
const wanted = args.paths
|
|
848
|
+
.map((v) => str(v).trim())
|
|
849
|
+
.filter(Boolean);
|
|
850
|
+
if (!wanted.length)
|
|
851
|
+
return { ok: false, result: "paths must contain at least one path" };
|
|
852
|
+
if (wanted.length > MAX_MULTI_READ) {
|
|
853
|
+
return {
|
|
854
|
+
ok: false,
|
|
855
|
+
result: `too many paths (${wanted.length}); the maximum is ${MAX_MULTI_READ} — read fewer files per call`,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
const chunks = [];
|
|
859
|
+
for (const rel of wanted) {
|
|
860
|
+
const r = await executeTool("read_file", JSON.stringify({ ...args, path: rel, paths: undefined }), ctx);
|
|
861
|
+
// one bad file must not sink the others: report it inline
|
|
862
|
+
chunks.push(`--- ${rel} ---\n${r.ok ? r.result : `ERROR: ${r.result}`}`);
|
|
863
|
+
}
|
|
864
|
+
return { ok: true, result: chunks.join("\n\n") };
|
|
865
|
+
}
|
|
866
|
+
if (args.fuzzy)
|
|
867
|
+
return fuzzyRead(str(args.fuzzy), ctx, args);
|
|
542
868
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
543
|
-
|
|
869
|
+
let text;
|
|
870
|
+
try {
|
|
871
|
+
text = await readText(p);
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
// name the file, not just the errno: a multi-file read reports each bad
|
|
875
|
+
// path inline, and "ENOENT … /tmp/xyz/nope.ts" leaves the model to work
|
|
876
|
+
// out which of the paths it asked for (#16)
|
|
877
|
+
const code = err.code;
|
|
878
|
+
return {
|
|
879
|
+
ok: false,
|
|
880
|
+
result: code === "ENOENT" ? `cannot read ${str(args.path)}: no such file` : `cannot read ${str(args.path)}: ${err.message}`,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
544
883
|
ctx.onFileRead?.(str(args.path));
|
|
545
884
|
const lines = text.split("\n");
|
|
885
|
+
// #7/#4: opt-in hashline output. Default stays the historical `N| ` so
|
|
886
|
+
// nothing that depends on it changes until the mode is chosen.
|
|
887
|
+
const idMode = lineIdMode(args);
|
|
546
888
|
// grep mode
|
|
547
889
|
if (typeof args.pattern === "string" && args.pattern !== "") {
|
|
548
890
|
const re = compileRegex(args.pattern, args.ignore_case === true);
|
|
@@ -569,7 +911,9 @@ export const TOOLS = [
|
|
|
569
911
|
else
|
|
570
912
|
regions.push([s, e]);
|
|
571
913
|
}
|
|
572
|
-
const parts = regions.map(([s, e]) =>
|
|
914
|
+
const parts = regions.map(([s, e]) => idMode === "hash"
|
|
915
|
+
? renderHashlines(lines.slice(s, e + 1), s + 1)
|
|
916
|
+
: lines.slice(s, e + 1).map((l, k) => `${s + k + 1}| ${l}`).join("\n"));
|
|
573
917
|
let result = parts.join("\n--\n");
|
|
574
918
|
if (idxs.length > page.length || off > 0)
|
|
575
919
|
result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
|
|
@@ -579,21 +923,27 @@ export const TOOLS = [
|
|
|
579
923
|
let off = num(args.offset, 1);
|
|
580
924
|
off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
|
|
581
925
|
const lim = num(args.limit, 2000);
|
|
582
|
-
const
|
|
926
|
+
const pageLines = lines.slice(off, off + lim);
|
|
927
|
+
const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : pageLines.map((l, i) => `${off + i + 1}| ${l}`).join("\n");
|
|
583
928
|
const more = off + lim < lines.length ? `\n... (${lines.length - off - lim} more lines)` : "";
|
|
584
|
-
return { ok: true, result: slice
|
|
929
|
+
return { ok: true, result: slice + more };
|
|
585
930
|
},
|
|
586
931
|
},
|
|
587
932
|
{
|
|
588
933
|
name: "write_file",
|
|
589
934
|
description: "Create ONE new file, or replace a file's entire content (parent dirs auto-created). " +
|
|
590
935
|
"Creating files as part of a larger batch of edits → one apply_patch instead. " +
|
|
591
|
-
"Partial changes to an existing file → edit_file."
|
|
936
|
+
"Partial changes to an existing file → edit_file. " +
|
|
937
|
+
"Pass base_content (the bytes you read) when rewriting a file that may have changed under you — the write is refused rather than clobbering a concurrent edit.",
|
|
592
938
|
parameters: {
|
|
593
939
|
type: "object",
|
|
594
940
|
properties: {
|
|
595
941
|
path: { type: "string" },
|
|
596
942
|
content: { type: "string" },
|
|
943
|
+
base_content: {
|
|
944
|
+
type: "string",
|
|
945
|
+
description: "Optional: the exact bytes you read before writing. The write is REFUSED if the file changed since — re-read and retry instead of clobbering someone else's edit.",
|
|
946
|
+
},
|
|
597
947
|
},
|
|
598
948
|
required: ["path", "content"],
|
|
599
949
|
},
|
|
@@ -610,6 +960,9 @@ export const TOOLS = [
|
|
|
610
960
|
};
|
|
611
961
|
}
|
|
612
962
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
963
|
+
const stale = staleGuard(str(args.path), args.base_content, await currentOrNull(p));
|
|
964
|
+
if (stale)
|
|
965
|
+
return stale;
|
|
613
966
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
614
967
|
await fs.writeFile(p, args.content, "utf8");
|
|
615
968
|
return { ok: true, result: `wrote ${p} (${args.content.length} bytes)` };
|
|
@@ -620,7 +973,10 @@ export const TOOLS = [
|
|
|
620
973
|
description: "Make exactly ONE small, unique replacement in one existing file — the cheapest tool for a single spot change. " +
|
|
621
974
|
"Copy old_text from the file contents (NOT from read_file's `N| ` prefixed display); it must appear exactly once — " +
|
|
622
975
|
"if it matches several places, add surrounding lines or pass replace_all=true. " +
|
|
623
|
-
"
|
|
976
|
+
"Or read with read_file(line_ids:\"hash\") and pass that block VERBATIM — `LINE:HASH| ` anchors are verified against the " +
|
|
977
|
+
"current file, and a block whose anchors no longer match is refused as a stale read instead of editing the wrong lines. " +
|
|
978
|
+
"Two or more changes (or a rename/delete) → use apply_patch instead. " +
|
|
979
|
+
"Pass base_content (the bytes you read) whenever the file may have changed under you — especially with replace_all, which otherwise rewrites occurrences that appeared after you read.",
|
|
624
980
|
parameters: {
|
|
625
981
|
type: "object",
|
|
626
982
|
properties: {
|
|
@@ -628,20 +984,50 @@ export const TOOLS = [
|
|
|
628
984
|
old_text: { type: "string" },
|
|
629
985
|
new_text: { type: "string" },
|
|
630
986
|
replace_all: { type: "boolean", description: "replace every occurrence instead of requiring uniqueness" },
|
|
987
|
+
base_content: {
|
|
988
|
+
type: "string",
|
|
989
|
+
description: "Optional: the exact bytes you read before editing. Refuses the edit if the file changed since — important with replace_all, which otherwise rewrites occurrences that appeared after you read.",
|
|
990
|
+
},
|
|
631
991
|
},
|
|
632
992
|
required: ["path", "old_text", "new_text"],
|
|
633
993
|
},
|
|
634
994
|
async run(args, ctx) {
|
|
635
995
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
636
996
|
let text = await readText(p);
|
|
637
|
-
const
|
|
997
|
+
const stale = staleGuard(str(args.path), args.base_content, text);
|
|
998
|
+
if (stale)
|
|
999
|
+
return stale;
|
|
1000
|
+
let oldText = str(args.old_text);
|
|
638
1001
|
const newText = str(args.new_text);
|
|
639
1002
|
if (!oldText)
|
|
640
1003
|
return { ok: false, result: "old_text is required" };
|
|
641
1004
|
const replaceAll = args.replace_all === true;
|
|
642
|
-
|
|
1005
|
+
// #7/#4: accept a block copied straight out of read_file's hashline
|
|
1006
|
+
// output. When the anchors do not verify we FAIL with the specific reason
|
|
1007
|
+
// rather than silently falling back to literal matching — a literal match
|
|
1008
|
+
// that happened to succeed would edit the wrong bytes, which is the exact
|
|
1009
|
+
// failure mode hashline exists to prevent.
|
|
1010
|
+
//
|
|
1011
|
+
// On success we edit by RESOLVED LINE INDEX rather than by searching for
|
|
1012
|
+
// the text: the anchors already pinpointed the lines, so a block whose
|
|
1013
|
+
// content legitimately appears twice (the case proximity search exists
|
|
1014
|
+
// for) must not then fail the uniqueness check on the resolved text.
|
|
1015
|
+
let hashNote = "";
|
|
1016
|
+
let hashNotes = [];
|
|
1017
|
+
let hashSpan = null;
|
|
1018
|
+
if (isHashlineBlock(oldText)) {
|
|
1019
|
+
const srcLines = text.replace(/\r\n/g, "\n").split("\n");
|
|
1020
|
+
const r = resolveHashlineBlock(oldText, srcLines);
|
|
1021
|
+
if ("error" in r)
|
|
1022
|
+
hashNote = r.error;
|
|
1023
|
+
else {
|
|
1024
|
+
oldText = r.text;
|
|
1025
|
+
hashNotes = r.notes;
|
|
1026
|
+
hashSpan = { start: r.indices[0], end: r.indices[r.indices.length - 1] + 1 };
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
let count = hashNote ? 0 : text.split(oldText).length - 1;
|
|
643
1030
|
let normalized = false;
|
|
644
|
-
// tolerate LF patterns against CRLF files (convert once, on success)
|
|
645
1031
|
if (count === 0 && oldText.includes("\n") && text.includes("\r\n")) {
|
|
646
1032
|
const lf = text.replace(/\r\n/g, "\n");
|
|
647
1033
|
const lfCount = lf.split(oldText).length - 1;
|
|
@@ -685,21 +1071,45 @@ export const TOOLS = [
|
|
|
685
1071
|
: `No similar text found — re-read ${str(args.path)} around the target area and copy old_text exactly.`;
|
|
686
1072
|
return {
|
|
687
1073
|
ok: false,
|
|
688
|
-
|
|
1074
|
+
// a hashline block that did not resolve gets ITS reason — the generic
|
|
1075
|
+
// "not found" would send the model hunting for a whitespace problem
|
|
1076
|
+
// when the real issue is a stale read (#7)
|
|
1077
|
+
result: hashNote
|
|
1078
|
+
? `old_text is a hashline block but did not resolve: ${hashNote}.`
|
|
1079
|
+
: `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
|
|
689
1080
|
};
|
|
690
1081
|
}
|
|
691
|
-
if (count > 1 && !replaceAll) {
|
|
1082
|
+
if (count > 1 && !replaceAll && !hashSpan) {
|
|
692
1083
|
const at = matchLines(text, oldText);
|
|
693
1084
|
return {
|
|
694
1085
|
ok: false,
|
|
695
1086
|
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`,
|
|
696
1087
|
};
|
|
697
1088
|
}
|
|
698
|
-
|
|
699
|
-
|
|
1089
|
+
// A verified hashline block is addressed BY LINE INDEX: the anchors
|
|
1090
|
+
// already proved which lines it means, so a block whose content occurs
|
|
1091
|
+
// twice must still edit the anchored occurrence rather than fail the
|
|
1092
|
+
// uniqueness check (#4).
|
|
1093
|
+
const out = hashSpan
|
|
1094
|
+
? text
|
|
1095
|
+
.split("\n")
|
|
1096
|
+
.slice(0, hashSpan.start)
|
|
1097
|
+
.concat(newText.split("\n"), text.split("\n").slice(hashSpan.end))
|
|
1098
|
+
.join("\n")
|
|
1099
|
+
: replaceAll
|
|
1100
|
+
? text.split(oldText).join(newText)
|
|
1101
|
+
: text.replace(oldText, newText);
|
|
1102
|
+
await fs.writeFile(p, out, "utf8");
|
|
1103
|
+
const where = hashSpan
|
|
1104
|
+
? ` (lines ${hashSpan.start + 1}–${hashSpan.end}, verified by hash anchor)`
|
|
1105
|
+
: count > 1
|
|
1106
|
+
? ` (${count} occurrences)`
|
|
1107
|
+
: "";
|
|
700
1108
|
return {
|
|
701
1109
|
ok: true,
|
|
702
|
-
result: `${replaceAll ? "replaced all" : "edited"}${where}
|
|
1110
|
+
result: `${replaceAll && !hashSpan ? "replaced all" : "edited"}${where}` +
|
|
1111
|
+
`${hashNotes.length ? ` (${hashNotes[0]})` : ""}` +
|
|
1112
|
+
`${normalized ? " (file converted CRLF→LF)" : ""}`,
|
|
703
1113
|
};
|
|
704
1114
|
},
|
|
705
1115
|
},
|
|
@@ -1041,11 +1451,20 @@ export const TOOLS = [
|
|
|
1041
1451
|
{
|
|
1042
1452
|
name: "load_skill",
|
|
1043
1453
|
description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
|
|
1044
|
-
"matches your current task; follow the loaded playbook."
|
|
1454
|
+
"matches your current task; follow the loaded playbook. " +
|
|
1455
|
+
// #25: the instruction used to need a SECOND, separately-queued message that
|
|
1456
|
+
// only arrived at the next turn boundary. Passing `instructions` delivers it
|
|
1457
|
+
// in the SAME turn as the playbook, so "load X and do Y" costs one round-trip.
|
|
1458
|
+
"Pass `instructions` with what you want done with it to receive both at once.",
|
|
1045
1459
|
parameters: {
|
|
1046
1460
|
type: "object",
|
|
1047
1461
|
properties: {
|
|
1048
1462
|
name: { type: "string", description: "skill name from the list in your system prompt" },
|
|
1463
|
+
instructions: {
|
|
1464
|
+
type: "string",
|
|
1465
|
+
description: "Optional: what you want done with this skill (the task, constraints, scope). " +
|
|
1466
|
+
"Delivered in this same turn — no need to queue a follow-up message.",
|
|
1467
|
+
},
|
|
1049
1468
|
},
|
|
1050
1469
|
required: ["name"],
|
|
1051
1470
|
},
|
|
@@ -1076,6 +1495,13 @@ export const TOOLS = [
|
|
|
1076
1495
|
listed.map((f) => `- ${f}`).join("\n") +
|
|
1077
1496
|
`\nRun scripts with bash (chmod +x first if needed).`;
|
|
1078
1497
|
}
|
|
1498
|
+
// #25: deliver the caller's instructions in THIS result, so the model does
|
|
1499
|
+
// not have to queue a second message that only lands next turn.
|
|
1500
|
+
const instructions = str(args.instructions).trim();
|
|
1501
|
+
if (instructions)
|
|
1502
|
+
result +=
|
|
1503
|
+
`\n\n--- Instructions for this task (from the same call):\n${instructions}\n` +
|
|
1504
|
+
"Apply the playbook above to these instructions now, in this turn.";
|
|
1079
1505
|
return { ok: true, result };
|
|
1080
1506
|
},
|
|
1081
1507
|
},
|
|
@@ -1163,6 +1589,103 @@ export const TOOLS = [
|
|
|
1163
1589
|
},
|
|
1164
1590
|
},
|
|
1165
1591
|
];
|
|
1592
|
+
/** #16: how many files one read_file call may cover. Each costs context, so an
|
|
1593
|
+
* unbounded array would just be a way to overflow the window in one shot. */
|
|
1594
|
+
const MAX_MULTI_READ = 10;
|
|
1595
|
+
/**
|
|
1596
|
+
* Subsequence match: every character of `needle` appears in `hay`, in order,
|
|
1597
|
+
* gaps allowed (#16 "fuzzy").
|
|
1598
|
+
*
|
|
1599
|
+
* Deliberately not a ranked edit-distance search. This answers "is this the
|
|
1600
|
+
* file I meant", and it can only say yes or no — it cannot rank a plausible
|
|
1601
|
+
* WRONG file above the right one, which is the failure mode that actually costs
|
|
1602
|
+
* an agent time. Searching file CONTENT is what bash/grep are for.
|
|
1603
|
+
*/
|
|
1604
|
+
export function fuzzyPathMatch(needle, hay) {
|
|
1605
|
+
const n = needle.toLowerCase().replace(/[\s._/-]+/g, "");
|
|
1606
|
+
if (!n)
|
|
1607
|
+
return false;
|
|
1608
|
+
const h = hay.toLowerCase();
|
|
1609
|
+
let i = 0;
|
|
1610
|
+
for (const ch of h) {
|
|
1611
|
+
if (ch === n[i])
|
|
1612
|
+
i++;
|
|
1613
|
+
if (i === n.length)
|
|
1614
|
+
return true;
|
|
1615
|
+
}
|
|
1616
|
+
return false;
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Locate a file by a loose name and read it (#16).
|
|
1620
|
+
*
|
|
1621
|
+
* Walks the workspace (skipping .git, node_modules and build noise) and ranks
|
|
1622
|
+
* matches: exact path, exact basename, ends-with, then subsequence. When
|
|
1623
|
+
* several match it LISTS them rather than picking one — guessing here would have
|
|
1624
|
+
* the agent edit the wrong file, which is the expensive mistake.
|
|
1625
|
+
*/
|
|
1626
|
+
async function fuzzyRead(needle, ctx, args) {
|
|
1627
|
+
const q = needle.trim();
|
|
1628
|
+
if (!q)
|
|
1629
|
+
return { ok: false, result: "fuzzy needs a non-empty search string" };
|
|
1630
|
+
const SKIP = new Set([".git", "node_modules", "dist", "build", ".cache", "coverage", "__pycache__"]);
|
|
1631
|
+
const found = [];
|
|
1632
|
+
const walk = async (dir, rel, depth) => {
|
|
1633
|
+
if (depth > 12)
|
|
1634
|
+
return;
|
|
1635
|
+
let entries;
|
|
1636
|
+
try {
|
|
1637
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1638
|
+
}
|
|
1639
|
+
catch {
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
for (const e of entries) {
|
|
1643
|
+
if (e.name.startsWith("."))
|
|
1644
|
+
continue;
|
|
1645
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
1646
|
+
if (e.isDirectory()) {
|
|
1647
|
+
if (SKIP.has(e.name))
|
|
1648
|
+
continue;
|
|
1649
|
+
await walk(path.join(dir, e.name), r, depth + 1);
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
let rank = -1;
|
|
1653
|
+
if (r === q)
|
|
1654
|
+
rank = 0;
|
|
1655
|
+
else if (e.name === q)
|
|
1656
|
+
rank = 1;
|
|
1657
|
+
else if (r.endsWith("/" + q) || r.endsWith(q))
|
|
1658
|
+
rank = 2;
|
|
1659
|
+
else if (fuzzyPathMatch(q, r))
|
|
1660
|
+
rank = 3;
|
|
1661
|
+
if (rank >= 0)
|
|
1662
|
+
found.push({ rel: r, rank });
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
await walk(ctx.cwd, "", 0);
|
|
1666
|
+
if (!found.length) {
|
|
1667
|
+
return {
|
|
1668
|
+
ok: false,
|
|
1669
|
+
result: `no file matches "${q}" under the workspace. ` +
|
|
1670
|
+
"Use list_dir to see what exists, or pass the exact path instead of fuzzy.",
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
found.sort((a, b) => a.rank - b.rank || a.rel.length - b.rel.length || a.rel.localeCompare(b.rel));
|
|
1674
|
+
const best = found[0];
|
|
1675
|
+
// Only a confident match is read directly. If the query is vague enough that
|
|
1676
|
+
// several files match, LIST them: guessing would hand the agent the wrong
|
|
1677
|
+
// file, and the cost of that mistake is far higher than one extra call.
|
|
1678
|
+
// An exact path or basename is unambiguous, so those still read straight away.
|
|
1679
|
+
if (best.rank <= 1 || found.length === 1) {
|
|
1680
|
+
// re-enter the tool so a unique match behaves exactly like a normal read
|
|
1681
|
+
return executeTool("read_file", JSON.stringify({ ...args, path: best.rel, fuzzy: undefined }), ctx);
|
|
1682
|
+
}
|
|
1683
|
+
return {
|
|
1684
|
+
ok: true,
|
|
1685
|
+
result: `${found.length} files match "${q}" — pass the exact path (or a longer query) to read one:\n` +
|
|
1686
|
+
found.slice(0, 8).map((f) => ` ${f.rel}`).join("\n"),
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1166
1689
|
/** Current skills across the configured roots (for the system prompt listing). */
|
|
1167
1690
|
export async function currentSkills(ctx) {
|
|
1168
1691
|
return discoverSkills(ctx.skillRoots ?? []);
|
|
@@ -1175,6 +1698,218 @@ export function toolSpecs() {
|
|
|
1175
1698
|
}
|
|
1176
1699
|
/** tools that mutate the workspace — blocked for read-only personas */
|
|
1177
1700
|
const MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch", "bash"]);
|
|
1701
|
+
/** per-tool aliases: the common wrong key -> the key the schema declares. */
|
|
1702
|
+
const ARG_ALIASES = {
|
|
1703
|
+
bash: { cmd: "command", script: "command", shell: "command", input: "command" },
|
|
1704
|
+
read_file: { file_path: "path", filepath: "path", file: "path", name: "path" },
|
|
1705
|
+
write_file: { file_path: "path", filepath: "path", file: "path" },
|
|
1706
|
+
edit_file: { file_path: "path", filepath: "path", oldValue: "old_text", newValue: "new_text" },
|
|
1707
|
+
apply_patch: { patch_text: "patch", diff: "patch" },
|
|
1708
|
+
list_dir: { file_path: "path", dir: "path", directory: "path" },
|
|
1709
|
+
list_children: { file_path: "path", dir: "path" },
|
|
1710
|
+
read_url: { uri: "url", link: "url" },
|
|
1711
|
+
};
|
|
1712
|
+
/**
|
|
1713
|
+
* The one argument each tool's whole call usually boils down to. Used to
|
|
1714
|
+
* salvage a bare word ("ls") that was never wrapped in JSON at all — but ONLY
|
|
1715
|
+
* for tools where that is unambiguous, so "12" is never mistaken for a path.
|
|
1716
|
+
*/
|
|
1717
|
+
const PRIMARY_STRING_ARG = {
|
|
1718
|
+
bash: "command",
|
|
1719
|
+
read_file: "path",
|
|
1720
|
+
write_file: "content",
|
|
1721
|
+
list_dir: "path",
|
|
1722
|
+
list_children: "path",
|
|
1723
|
+
read_url: "url",
|
|
1724
|
+
load_skill: "name",
|
|
1725
|
+
save_skill: "content",
|
|
1726
|
+
};
|
|
1727
|
+
/** keys whose schema type is a number / boolean, per tool. */
|
|
1728
|
+
const NUMERIC_ARGS = {
|
|
1729
|
+
read_file: ["offset", "limit", "context"],
|
|
1730
|
+
bash: ["timeout_ms"],
|
|
1731
|
+
list_dir: [],
|
|
1732
|
+
write_file: [],
|
|
1733
|
+
};
|
|
1734
|
+
const BOOL_ARGS = {
|
|
1735
|
+
read_file: ["ignore_case"],
|
|
1736
|
+
bash: ["background"],
|
|
1737
|
+
edit_file: ["replace_all"],
|
|
1738
|
+
write_file: [],
|
|
1739
|
+
list_dir: [],
|
|
1740
|
+
};
|
|
1741
|
+
/** markdown/backtick framing models like to wrap a path in */
|
|
1742
|
+
function unframe(v) {
|
|
1743
|
+
const m = /^[`*_\s]+(.+?)[`*_\s]*$/.exec(v);
|
|
1744
|
+
return m ? m[1] : v;
|
|
1745
|
+
}
|
|
1746
|
+
/** coerce "100" -> 100, "true" -> true; returns undefined when not coercible */
|
|
1747
|
+
function coerceScalar(v) {
|
|
1748
|
+
if (typeof v !== "string")
|
|
1749
|
+
return v;
|
|
1750
|
+
const t = v.trim();
|
|
1751
|
+
if (t === "true")
|
|
1752
|
+
return true;
|
|
1753
|
+
if (t === "false")
|
|
1754
|
+
return false;
|
|
1755
|
+
if (t !== "" && !Number.isNaN(Number(t)) && /^[-+]?\d+(\.\d+)?$/.test(t))
|
|
1756
|
+
return Number(t);
|
|
1757
|
+
return v;
|
|
1758
|
+
}
|
|
1759
|
+
/** Turn a value into an array, tolerating a bare scalar or a JSON array. */
|
|
1760
|
+
function toArray(v) {
|
|
1761
|
+
if (Array.isArray(v))
|
|
1762
|
+
return v;
|
|
1763
|
+
if (typeof v === "string") {
|
|
1764
|
+
const t = v.trim();
|
|
1765
|
+
if (t.startsWith("[")) {
|
|
1766
|
+
try {
|
|
1767
|
+
const p = JSON.parse(t);
|
|
1768
|
+
if (Array.isArray(p))
|
|
1769
|
+
return p;
|
|
1770
|
+
}
|
|
1771
|
+
catch {
|
|
1772
|
+
/* fall through — treat as a single element */
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
return [v];
|
|
1776
|
+
}
|
|
1777
|
+
if (v === undefined || v === null)
|
|
1778
|
+
return [];
|
|
1779
|
+
return [v];
|
|
1780
|
+
}
|
|
1781
|
+
export function repairToolInput(name, rawArgs) {
|
|
1782
|
+
const notes = [];
|
|
1783
|
+
let parsed;
|
|
1784
|
+
try {
|
|
1785
|
+
parsed = rawArgs?.trim() ? JSON.parse(rawArgs) : {};
|
|
1786
|
+
}
|
|
1787
|
+
catch {
|
|
1788
|
+
// not JSON at all: a whole call was stringified, or the model emitted a
|
|
1789
|
+
// bare word. Only salvageable when the tool takes a single obvious string.
|
|
1790
|
+
const bare = rawArgs?.trim().replace(/^[`*\s]+|[`*\s]+$/g, "");
|
|
1791
|
+
const primary = PRIMARY_STRING_ARG[name];
|
|
1792
|
+
// a bare number/boolean is never a path or a command — refusing is far
|
|
1793
|
+
// better than inventing one (#17)
|
|
1794
|
+
const isScalarish = bare !== "" && /^(true|false|null|-?\d+(\.\d+)?)$/i.test(bare);
|
|
1795
|
+
if (bare && primary && !isScalarish && !bare.startsWith("{") && !bare.startsWith("[")) {
|
|
1796
|
+
notes.push(`wrapped a bare string as {"${primary}": …}`);
|
|
1797
|
+
return { args: { [primary]: unframe(bare) }, notes };
|
|
1798
|
+
}
|
|
1799
|
+
return { args: {}, notes, error: `arguments were not valid JSON — send a JSON object, e.g. {"…"}` };
|
|
1800
|
+
}
|
|
1801
|
+
// a JSON-stringified object arrives as a string
|
|
1802
|
+
if (typeof parsed === "string" && /[{[]/.test(parsed.trim().slice(0, 2))) {
|
|
1803
|
+
try {
|
|
1804
|
+
parsed = JSON.parse(parsed);
|
|
1805
|
+
notes.push("unwrapped a JSON-stringified argument object");
|
|
1806
|
+
}
|
|
1807
|
+
catch {
|
|
1808
|
+
/* leave as-is; it may genuinely be a string argument */
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// a bare scalar where an object is required
|
|
1812
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1813
|
+
if (parsed === null || parsed === undefined)
|
|
1814
|
+
return { args: {}, notes, error: "arguments were empty" };
|
|
1815
|
+
// An ARRAY is a legitimate value for a path-ish argument, but only for the
|
|
1816
|
+
// tools that actually take one — inventing {path:[…]} for apply_patch would
|
|
1817
|
+
// hand the tool a key it has never heard of (#17).
|
|
1818
|
+
if (Array.isArray(parsed) && name === "read_file") {
|
|
1819
|
+
notes.push("kept the array as a paths list");
|
|
1820
|
+
return { args: { paths: parsed }, notes };
|
|
1821
|
+
}
|
|
1822
|
+
const primary = PRIMARY_STRING_ARG[name];
|
|
1823
|
+
if (primary && typeof parsed === "string") {
|
|
1824
|
+
notes.push(`wrapped a bare string as {"${primary}": …}`);
|
|
1825
|
+
return { args: { [primary]: unframe(parsed) }, notes };
|
|
1826
|
+
}
|
|
1827
|
+
return {
|
|
1828
|
+
args: {},
|
|
1829
|
+
notes,
|
|
1830
|
+
error: `expected a JSON object for ${name}, got a bare ${Array.isArray(parsed) ? "array" : typeof parsed}` +
|
|
1831
|
+
(primary ? ` — send {"${primary}": …}` : ""),
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
const args = { ...parsed };
|
|
1835
|
+
// alias normalisation
|
|
1836
|
+
for (const [from, to] of Object.entries(ARG_ALIASES[name] ?? {})) {
|
|
1837
|
+
if (!(from in args))
|
|
1838
|
+
continue;
|
|
1839
|
+
if (!(to in args)) {
|
|
1840
|
+
args[to] = args[from];
|
|
1841
|
+
notes.push(`renamed "${from}" -> "${to}"`);
|
|
1842
|
+
}
|
|
1843
|
+
else {
|
|
1844
|
+
// both present: the schema key wins and the stray alias is DROPPED, not
|
|
1845
|
+
// left behind as an unknown argument the tool would ignore (#17)
|
|
1846
|
+
notes.push(`dropped "${from}" — "${to}" was also given and wins`);
|
|
1847
|
+
}
|
|
1848
|
+
delete args[from];
|
|
1849
|
+
}
|
|
1850
|
+
// string -> number / boolean for the keys whose schema says so
|
|
1851
|
+
for (const k of NUMERIC_ARGS[name] ?? []) {
|
|
1852
|
+
if (k in args) {
|
|
1853
|
+
const c = coerceScalar(args[k]);
|
|
1854
|
+
if (typeof c !== typeof args[k]) {
|
|
1855
|
+
args[k] = c;
|
|
1856
|
+
notes.push(`coerced "${k}" to ${typeof c}`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
for (const k of BOOL_ARGS[name] ?? []) {
|
|
1861
|
+
if (k in args) {
|
|
1862
|
+
const c = coerceScalar(args[k]);
|
|
1863
|
+
if (typeof c !== typeof args[k]) {
|
|
1864
|
+
args[k] = c;
|
|
1865
|
+
notes.push(`coerced "${k}" to ${typeof c}`);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
// an array-typed arg arriving as a scalar
|
|
1870
|
+
if (name === "read_file" && "paths" in args) {
|
|
1871
|
+
const before = args.paths;
|
|
1872
|
+
args.paths = toArray(before);
|
|
1873
|
+
if (!Array.isArray(before))
|
|
1874
|
+
notes.push('coerced "paths" to an array');
|
|
1875
|
+
}
|
|
1876
|
+
// a JSON-stringified object hiding inside a single argument, e.g.
|
|
1877
|
+
// {"path": "{\"path\": \"a.ts\"}"} — unwrap it and merge its keys
|
|
1878
|
+
// Only PATH-like keys: `content`/`old_text`/`new_text` legitimately hold JSON
|
|
1879
|
+
// (writing a .json file!), and unwrapping those would REPLACE the file's
|
|
1880
|
+
// actual data with an inner field — silent data loss, not repair.
|
|
1881
|
+
for (const k of ["path", "command"]) {
|
|
1882
|
+
const v = args[k];
|
|
1883
|
+
if (typeof v !== "string")
|
|
1884
|
+
continue;
|
|
1885
|
+
const t = v.trim();
|
|
1886
|
+
if (!/^[{[]/.test(t))
|
|
1887
|
+
continue;
|
|
1888
|
+
try {
|
|
1889
|
+
const inner = JSON.parse(t);
|
|
1890
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
1891
|
+
delete args[k];
|
|
1892
|
+
Object.assign(args, inner);
|
|
1893
|
+
notes.push(`unwrapped a JSON-stringified object out of "${k}"`);
|
|
1894
|
+
break;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
catch {
|
|
1898
|
+
/* genuinely not JSON — leave it alone */
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
// markdown framing on a path
|
|
1902
|
+
for (const k of ["path", "file_path"]) {
|
|
1903
|
+
if (typeof args[k] === "string") {
|
|
1904
|
+
const f = unframe(args[k]);
|
|
1905
|
+
if (f !== args[k]) {
|
|
1906
|
+
args[k] = f;
|
|
1907
|
+
notes.push(`stripped markdown framing from "${k}"`);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return { args, notes };
|
|
1912
|
+
}
|
|
1178
1913
|
export async function executeTool(name, rawArgs, ctx) {
|
|
1179
1914
|
const def = TOOLS.find((t) => t.name === name);
|
|
1180
1915
|
if (!def)
|
|
@@ -1184,13 +1919,15 @@ export async function executeTool(name, rawArgs, ctx) {
|
|
|
1184
1919
|
// read-only personas (researcher/reviewer) are enforced, not just asked
|
|
1185
1920
|
if (ctx.readOnly && MUTATING_TOOLS.has(name))
|
|
1186
1921
|
return { ok: false, result: `${name} is blocked: this agent runs with read-only tools` };
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
return { ok: false, result:
|
|
1193
|
-
|
|
1922
|
+
// #17: repair the call before handing it to the tool. A model that sends
|
|
1923
|
+
// {"cmd":"ls"} or {"limit":"100"} should get the work done, not a generic parse
|
|
1924
|
+
// failure and a wasted turn. Unrepairable calls say what to send instead.
|
|
1925
|
+
const repaired = repairToolInput(name, rawArgs);
|
|
1926
|
+
if (repaired.error)
|
|
1927
|
+
return { ok: false, result: `${name}: ${repaired.error}` };
|
|
1928
|
+
const args = repaired.args;
|
|
1929
|
+
if (repaired.notes.length && process.env.TEAPOT_DEBUG_REPAIR)
|
|
1930
|
+
console.error(`[repair] ${name}: ${repaired.notes.join("; ")}`);
|
|
1194
1931
|
try {
|
|
1195
1932
|
return await def.run(args, ctx);
|
|
1196
1933
|
}
|