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.
@@ -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
- const child = spawn("/bin/bash", ["-lc", cmd], {
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: { ...process.env, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" },
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
- child.on("close", (code) => {
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
- sh.code = code;
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 child = spawn("/bin/bash", ["-lc", cmd], {
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: { ...process.env, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" },
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
- for (const w of writes) {
516
- await fs.mkdir(path.dirname(w.abs), { recursive: true });
517
- await fs.writeFile(w.abs, w.content, "utf8");
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
- const text = await readText(p);
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]) => lines.slice(s, e + 1).map((l, k) => `${s + k + 1}| ${l}`).join("\n"));
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 slice = lines.slice(off, off + lim).map((l, i) => `${off + i + 1}| ${l}`);
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.join("\n") + more };
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
- "Two or more changes (or a rename/delete) → use apply_patch instead.",
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 oldText = str(args.old_text);
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
- let count = text.split(oldText).length - 1;
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
- result: `old_text not found in file. ${hint} Watch indentation/trailing spaces and drop the \`N| \` line-number prefixes.`,
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
- await fs.writeFile(p, replaceAll ? text.split(oldText).join(newText) : text.replace(oldText, newText), "utf8");
699
- const where = count > 1 ? ` (${count} occurrences)` : "";
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}${normalized ? " (file converted CRLF→LF)" : ""}`,
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
- let args;
1188
- try {
1189
- args = rawArgs ? JSON.parse(rawArgs) : {};
1190
- }
1191
- catch {
1192
- return { ok: false, result: `invalid JSON arguments: ${rawArgs.slice(0, 200)}` };
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
  }