teapot-coding-agent 0.23.0 → 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/dist/acp/adapter.js +198 -0
- package/dist/agent/agent.js +263 -12
- package/dist/agent/llm.js +33 -0
- package/dist/agent/skills.js +165 -32
- package/dist/agent/tools.js +632 -27
- package/dist/index.js +74 -27
- package/dist/log/events.js +14 -0
- package/dist/master.js +134 -12
- package/dist/model-meta.js +96 -36
- package/dist/server/api.js +21 -5
- 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-NOpdigkY.js → shiki-CG8vVdKn.js} +2 -2
- package/public/assets/{shiki-NOpdigkY.js.map → shiki-CG8vVdKn.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-CNmd6GUS.js +0 -30
- package/public/assets/index-CNmd6GUS.js.map +0 -1
- package/public/assets/index-QCwmIYsM.css +0 -1
package/dist/agent/tools.js
CHANGED
|
@@ -88,6 +88,28 @@ function bgMapFor(cwd) {
|
|
|
88
88
|
}
|
|
89
89
|
return m;
|
|
90
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
|
+
}
|
|
91
113
|
function startBackgroundShell(cmd, ctx) {
|
|
92
114
|
const map = bgMapFor(ctx.cwd);
|
|
93
115
|
// opportunistic cleanup of long-dead jobs so the map can't grow forever
|
|
@@ -96,11 +118,13 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
96
118
|
void id;
|
|
97
119
|
}
|
|
98
120
|
const id = `bg${++bgSeq}`;
|
|
99
|
-
|
|
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], {
|
|
100
124
|
cwd: ctx.cwd,
|
|
101
125
|
detached: true,
|
|
102
126
|
stdio: ["ignore", "pipe", "pipe"],
|
|
103
|
-
env:
|
|
127
|
+
env: shellEnv(),
|
|
104
128
|
});
|
|
105
129
|
const sh = {
|
|
106
130
|
id, cmd, child,
|
|
@@ -183,11 +207,15 @@ export function hasRunningBgShells(cwd) {
|
|
|
183
207
|
/** Run a command in its own process group; kill the whole group on timeout. */
|
|
184
208
|
function runShell(cmd, ctx, timeoutMs) {
|
|
185
209
|
return new Promise((resolve) => {
|
|
186
|
-
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], {
|
|
187
215
|
cwd: ctx.cwd,
|
|
188
216
|
detached: true, // own process group
|
|
189
217
|
stdio: ["ignore", "pipe", "pipe"],
|
|
190
|
-
env:
|
|
218
|
+
env: shellEnv(),
|
|
191
219
|
});
|
|
192
220
|
let out = "";
|
|
193
221
|
let done = false;
|
|
@@ -227,7 +255,7 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
227
255
|
ctx.signal?.removeEventListener("abort", onAbort);
|
|
228
256
|
if (!done) {
|
|
229
257
|
done = true;
|
|
230
|
-
resolve({ ok: false, result: `spawn error: ${err.message}` });
|
|
258
|
+
resolve({ ok: false, result: `spawn error: ${err.message}${elapsed(t0)}` });
|
|
231
259
|
}
|
|
232
260
|
});
|
|
233
261
|
child.on("close", (code, signal) => {
|
|
@@ -239,14 +267,17 @@ function runShell(cmd, ctx, timeoutMs) {
|
|
|
239
267
|
if (killReason) {
|
|
240
268
|
resolve({
|
|
241
269
|
ok: false,
|
|
242
|
-
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)}`,
|
|
243
271
|
});
|
|
244
272
|
return;
|
|
245
273
|
}
|
|
246
274
|
resolve({
|
|
247
275
|
ok: code === 0,
|
|
248
276
|
result: (code === 0 ? "" : `exit=${code}${signal ? ` signal=${signal}` : ""}\n`) +
|
|
249
|
-
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),
|
|
250
281
|
});
|
|
251
282
|
});
|
|
252
283
|
});
|
|
@@ -636,10 +667,140 @@ async function rollbackWrites(backups, temps) {
|
|
|
636
667
|
await fs.writeFile(abs, orig, "utf8").catch(() => { });
|
|
637
668
|
}
|
|
638
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
|
+
}
|
|
639
796
|
export const TOOLS = [
|
|
640
797
|
{
|
|
641
798
|
name: "read_file",
|
|
642
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. " +
|
|
643
804
|
"With `pattern`, acts like grep: only matching lines (JS regex, optional `ignore_case`) plus `context` surrounding lines are returned. " +
|
|
644
805
|
"A negative `offset` counts from the end (-30 → last 30 lines, or last 30 matches in pattern mode).",
|
|
645
806
|
parameters: {
|
|
@@ -651,14 +812,79 @@ export const TOOLS = [
|
|
|
651
812
|
pattern: { type: "string", description: "JS regex — return only matching lines (+context) instead of the whole file" },
|
|
652
813
|
context: { type: "number", description: "context lines around each pattern match (max 5)" },
|
|
653
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
|
+
},
|
|
654
835
|
},
|
|
655
836
|
required: ["path"],
|
|
656
837
|
},
|
|
657
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);
|
|
658
868
|
const p = safeJoin(ctx.cwd, str(args.path));
|
|
659
|
-
|
|
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
|
+
}
|
|
660
883
|
ctx.onFileRead?.(str(args.path));
|
|
661
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);
|
|
662
888
|
// grep mode
|
|
663
889
|
if (typeof args.pattern === "string" && args.pattern !== "") {
|
|
664
890
|
const re = compileRegex(args.pattern, args.ignore_case === true);
|
|
@@ -685,7 +911,9 @@ export const TOOLS = [
|
|
|
685
911
|
else
|
|
686
912
|
regions.push([s, e]);
|
|
687
913
|
}
|
|
688
|
-
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"));
|
|
689
917
|
let result = parts.join("\n--\n");
|
|
690
918
|
if (idxs.length > page.length || off > 0)
|
|
691
919
|
result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
|
|
@@ -695,9 +923,10 @@ export const TOOLS = [
|
|
|
695
923
|
let off = num(args.offset, 1);
|
|
696
924
|
off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
|
|
697
925
|
const lim = num(args.limit, 2000);
|
|
698
|
-
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");
|
|
699
928
|
const more = off + lim < lines.length ? `\n... (${lines.length - off - lim} more lines)` : "";
|
|
700
|
-
return { ok: true, result: slice
|
|
929
|
+
return { ok: true, result: slice + more };
|
|
701
930
|
},
|
|
702
931
|
},
|
|
703
932
|
{
|
|
@@ -744,6 +973,8 @@ export const TOOLS = [
|
|
|
744
973
|
description: "Make exactly ONE small, unique replacement in one existing file — the cheapest tool for a single spot change. " +
|
|
745
974
|
"Copy old_text from the file contents (NOT from read_file's `N| ` prefixed display); it must appear exactly once — " +
|
|
746
975
|
"if it matches several places, add surrounding lines or pass replace_all=true. " +
|
|
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. " +
|
|
747
978
|
"Two or more changes (or a rename/delete) → use apply_patch instead. " +
|
|
748
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.",
|
|
749
980
|
parameters: {
|
|
@@ -766,14 +997,37 @@ export const TOOLS = [
|
|
|
766
997
|
const stale = staleGuard(str(args.path), args.base_content, text);
|
|
767
998
|
if (stale)
|
|
768
999
|
return stale;
|
|
769
|
-
|
|
1000
|
+
let oldText = str(args.old_text);
|
|
770
1001
|
const newText = str(args.new_text);
|
|
771
1002
|
if (!oldText)
|
|
772
1003
|
return { ok: false, result: "old_text is required" };
|
|
773
1004
|
const replaceAll = args.replace_all === true;
|
|
774
|
-
|
|
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;
|
|
775
1030
|
let normalized = false;
|
|
776
|
-
// tolerate LF patterns against CRLF files (convert once, on success)
|
|
777
1031
|
if (count === 0 && oldText.includes("\n") && text.includes("\r\n")) {
|
|
778
1032
|
const lf = text.replace(/\r\n/g, "\n");
|
|
779
1033
|
const lfCount = lf.split(oldText).length - 1;
|
|
@@ -817,21 +1071,45 @@ export const TOOLS = [
|
|
|
817
1071
|
: `No similar text found — re-read ${str(args.path)} around the target area and copy old_text exactly.`;
|
|
818
1072
|
return {
|
|
819
1073
|
ok: false,
|
|
820
|
-
|
|
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.`,
|
|
821
1080
|
};
|
|
822
1081
|
}
|
|
823
|
-
if (count > 1 && !replaceAll) {
|
|
1082
|
+
if (count > 1 && !replaceAll && !hashSpan) {
|
|
824
1083
|
const at = matchLines(text, oldText);
|
|
825
1084
|
return {
|
|
826
1085
|
ok: false,
|
|
827
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`,
|
|
828
1087
|
};
|
|
829
1088
|
}
|
|
830
|
-
|
|
831
|
-
|
|
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
|
+
: "";
|
|
832
1108
|
return {
|
|
833
1109
|
ok: true,
|
|
834
|
-
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)" : ""}`,
|
|
835
1113
|
};
|
|
836
1114
|
},
|
|
837
1115
|
},
|
|
@@ -1173,11 +1451,20 @@ export const TOOLS = [
|
|
|
1173
1451
|
{
|
|
1174
1452
|
name: "load_skill",
|
|
1175
1453
|
description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
|
|
1176
|
-
"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.",
|
|
1177
1459
|
parameters: {
|
|
1178
1460
|
type: "object",
|
|
1179
1461
|
properties: {
|
|
1180
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
|
+
},
|
|
1181
1468
|
},
|
|
1182
1469
|
required: ["name"],
|
|
1183
1470
|
},
|
|
@@ -1208,6 +1495,13 @@ export const TOOLS = [
|
|
|
1208
1495
|
listed.map((f) => `- ${f}`).join("\n") +
|
|
1209
1496
|
`\nRun scripts with bash (chmod +x first if needed).`;
|
|
1210
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.";
|
|
1211
1505
|
return { ok: true, result };
|
|
1212
1506
|
},
|
|
1213
1507
|
},
|
|
@@ -1295,6 +1589,103 @@ export const TOOLS = [
|
|
|
1295
1589
|
},
|
|
1296
1590
|
},
|
|
1297
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
|
+
}
|
|
1298
1689
|
/** Current skills across the configured roots (for the system prompt listing). */
|
|
1299
1690
|
export async function currentSkills(ctx) {
|
|
1300
1691
|
return discoverSkills(ctx.skillRoots ?? []);
|
|
@@ -1307,6 +1698,218 @@ export function toolSpecs() {
|
|
|
1307
1698
|
}
|
|
1308
1699
|
/** tools that mutate the workspace — blocked for read-only personas */
|
|
1309
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
|
+
}
|
|
1310
1913
|
export async function executeTool(name, rawArgs, ctx) {
|
|
1311
1914
|
const def = TOOLS.find((t) => t.name === name);
|
|
1312
1915
|
if (!def)
|
|
@@ -1316,13 +1919,15 @@ export async function executeTool(name, rawArgs, ctx) {
|
|
|
1316
1919
|
// read-only personas (researcher/reviewer) are enforced, not just asked
|
|
1317
1920
|
if (ctx.readOnly && MUTATING_TOOLS.has(name))
|
|
1318
1921
|
return { ok: false, result: `${name} is blocked: this agent runs with read-only tools` };
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
return { ok: false, result:
|
|
1325
|
-
|
|
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("; ")}`);
|
|
1326
1931
|
try {
|
|
1327
1932
|
return await def.run(args, ctx);
|
|
1328
1933
|
}
|