zidane 2.2.2 → 3.0.1

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.
@@ -11,13 +11,104 @@ import {
11
11
  } from "./chunk-2EQT4EHD.js";
12
12
  import {
13
13
  connectMcpServers
14
- } from "./chunk-DRAYZZ23.js";
14
+ } from "./chunk-R74LQKAM.js";
15
+ import {
16
+ toolOutputByteLength
17
+ } from "./chunk-JH6IAAFA.js";
15
18
  import {
16
19
  AgentAbortedError,
17
20
  AgentProviderError,
18
21
  toTypedError
19
22
  } from "./chunk-LNN5UTS2.js";
20
23
 
24
+ // src/tools/edit-utils.ts
25
+ function countExactMatches(haystack, needle) {
26
+ if (needle.length === 0)
27
+ return 0;
28
+ let count = 0;
29
+ let idx = 0;
30
+ while (true) {
31
+ const next = haystack.indexOf(needle, idx);
32
+ if (next === -1)
33
+ break;
34
+ count++;
35
+ idx = next + needle.length;
36
+ }
37
+ return count;
38
+ }
39
+
40
+ // src/tools/edit.ts
41
+ var edit = {
42
+ spec: {
43
+ name: "edit",
44
+ description: "Replace exact `old_string` with `new_string` in a file. Fails if `old_string` is not unique unless `replace_all: true`. Prefer over `write_file` for surgical changes \u2014 preserves the rest of the file.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ path: { type: "string", description: "Relative file path." },
49
+ old_string: { type: "string", description: "Exact substring to find." },
50
+ new_string: { type: "string", description: "Replacement substring." },
51
+ replace_all: { type: "boolean", description: "Replace every occurrence. Default: false." }
52
+ },
53
+ required: ["path", "old_string", "new_string"]
54
+ }
55
+ },
56
+ async execute({ path, old_string, new_string, replace_all }, ctx) {
57
+ const target = path;
58
+ const find = old_string;
59
+ const replacement = new_string;
60
+ const replaceAll = replace_all === true;
61
+ if (find === replacement)
62
+ return `Edit error: old_string and new_string are identical \u2014 nothing to change in ${target}.`;
63
+ if (find.length === 0)
64
+ return `Edit error: old_string is empty. Use write_file to create or fully overwrite a file.`;
65
+ let original;
66
+ try {
67
+ original = await ctx.execution.readFile(ctx.handle, target);
68
+ } catch {
69
+ return `Edit error: file not found: ${target}`;
70
+ }
71
+ const occurrences = countExactMatches(original, find);
72
+ if (occurrences === 0) {
73
+ const preview = nearestMatchPreview(original, find);
74
+ return preview ? `Edit error: old_string not found in ${target}. Closest match in the file: ${preview}` : `Edit error: old_string not found in ${target}.`;
75
+ }
76
+ if (occurrences > 1 && !replaceAll)
77
+ return `Edit error: old_string appears ${occurrences} times in ${target}. Pass replace_all=true or expand old_string for uniqueness.`;
78
+ const updated = replaceAll ? original.split(find).join(replacement) : original.replace(find, replacement);
79
+ if (updated === original)
80
+ return `Edit error: replacement produced no change in ${target}.`;
81
+ await ctx.execution.writeFile(ctx.handle, target, updated);
82
+ return `Edited ${target}: replaced ${occurrences} occurrence${occurrences === 1 ? "" : "s"}.`;
83
+ }
84
+ };
85
+ function nearestMatchPreview(haystack, needle) {
86
+ const needleFirstLine = needle.split("\n")[0];
87
+ if (needleFirstLine.length < 3)
88
+ return null;
89
+ const lines = haystack.split("\n");
90
+ let bestScore = 0;
91
+ let bestIdx = -1;
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const score = sharedPrefixLength(lines[i], needleFirstLine);
94
+ if (score > bestScore) {
95
+ bestScore = score;
96
+ bestIdx = i;
97
+ }
98
+ }
99
+ if (bestIdx < 0 || bestScore < Math.min(8, Math.floor(needleFirstLine.length / 2)))
100
+ return null;
101
+ const snippet = lines[bestIdx].slice(0, 80);
102
+ return `line ${bestIdx + 1}: ${JSON.stringify(snippet)}`;
103
+ }
104
+ function sharedPrefixLength(a, b) {
105
+ const max = Math.min(a.length, b.length);
106
+ let i = 0;
107
+ while (i < max && a.charCodeAt(i) === b.charCodeAt(i))
108
+ i++;
109
+ return i;
110
+ }
111
+
21
112
  // src/tools/glob.ts
22
113
  var DEFAULT_LIMIT = 1e3;
23
114
  var SAFE_GLOB_PATTERN_RE = /^[\w./*?[\]{}!,^@+-]+$/;
@@ -74,6 +165,207 @@ var glob = {
74
165
  }
75
166
  };
76
167
 
168
+ // src/tools/grep.ts
169
+ var DEFAULT_HEAD_LIMIT = 250;
170
+ var DEFAULT_OUTPUT_MODE = "files_with_matches";
171
+ var grep = {
172
+ spec: {
173
+ name: "grep",
174
+ description: "Search file contents by regex. Returns matching paths (default), match content, or per-file counts. Backed by ripgrep when available with a Bun.Glob fallback for in-process runs.",
175
+ inputSchema: {
176
+ type: "object",
177
+ properties: {
178
+ "pattern": { type: "string", description: "Regex (PCRE-flavored via ripgrep, JS regex via fallback)." },
179
+ "path": { type: "string", description: 'File or directory to search. Default: ".".' },
180
+ "glob": { type: "string", description: 'Restrict to files matching this glob, e.g. "**/*.ts".' },
181
+ "type": { type: "string", description: 'rg file type filter, e.g. "ts", "py", "rust". Ignored by the fallback.' },
182
+ "output_mode": { type: "string", enum: ["content", "files_with_matches", "count"], description: 'Default: "files_with_matches".' },
183
+ "-i": { type: "boolean", description: "Case-insensitive match." },
184
+ "-n": { type: "boolean", description: "Show line numbers (content mode). Default: true." },
185
+ "-A": { type: "integer", description: "Lines of trailing context (content mode)." },
186
+ "-B": { type: "integer", description: "Lines of leading context (content mode)." },
187
+ "-C": { type: "integer", description: "Lines of surrounding context (content mode). Overridden by -A/-B if set." },
188
+ "multiline": { type: "boolean", description: "Allow patterns to match across line boundaries." },
189
+ "head_limit": { type: "integer", description: "Cap output entries. Default: 250. Set 0 for unlimited." },
190
+ "offset": { type: "integer", description: "Skip first N entries. Default: 0." }
191
+ },
192
+ required: ["pattern"]
193
+ }
194
+ },
195
+ async execute(rawInput, ctx) {
196
+ const input = rawInput;
197
+ const useRg = await isRipgrepAvailable(ctx);
198
+ if (useRg)
199
+ return runViaRipgrep(input, ctx);
200
+ if (ctx.execution.type === "process")
201
+ return runInProcess(input, ctx);
202
+ return "grep error: ripgrep is not available in the execution context. Install `rg` or use the `shell` tool with grep/awk.";
203
+ }
204
+ };
205
+ async function isRipgrepAvailable(ctx) {
206
+ const result = await ctx.execution.exec(ctx.handle, "rg --version");
207
+ return result.exitCode === 0;
208
+ }
209
+ async function runViaRipgrep(input, ctx) {
210
+ const args = ["rg"];
211
+ const mode = input.output_mode ?? DEFAULT_OUTPUT_MODE;
212
+ if (mode === "files_with_matches")
213
+ args.push("--files-with-matches");
214
+ else if (mode === "count")
215
+ args.push("--count");
216
+ else
217
+ args.push(input["-n"] ?? true ? "--line-number" : "--no-line-number");
218
+ if (input["-i"])
219
+ args.push("-i");
220
+ if (mode === "content") {
221
+ if (typeof input["-A"] === "number")
222
+ args.push("-A", String(input["-A"]));
223
+ if (typeof input["-B"] === "number")
224
+ args.push("-B", String(input["-B"]));
225
+ if (typeof input["-C"] === "number" && typeof input["-A"] !== "number" && typeof input["-B"] !== "number")
226
+ args.push("-C", String(input["-C"]));
227
+ }
228
+ if (input.multiline)
229
+ args.push("--multiline", "--multiline-dotall");
230
+ if (input.glob)
231
+ args.push("--glob", input.glob);
232
+ if (input.type)
233
+ args.push("--type", input.type);
234
+ args.push("--", input.pattern);
235
+ args.push(input.path ?? ".");
236
+ const command = args.map(shellQuote).join(" ");
237
+ const result = await ctx.execution.exec(ctx.handle, command);
238
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
239
+ return `grep error: ${result.stderr.trim() || `rg exited with code ${result.exitCode}`}`;
240
+ }
241
+ return formatPaginated(result.stdout, input);
242
+ }
243
+ function shellQuote(arg) {
244
+ if (/^[\w@%+=:,./-]+$/.test(arg))
245
+ return arg;
246
+ return `'${arg.replace(/'/g, "'\\''")}'`;
247
+ }
248
+ async function runInProcess(input, ctx) {
249
+ const mode = input.output_mode ?? DEFAULT_OUTPUT_MODE;
250
+ const flags = `${input["-i"] ? "i" : ""}${input.multiline ? "s" : ""}${mode !== "content" ? "" : "g"}`;
251
+ let regex;
252
+ try {
253
+ regex = new RegExp(input.pattern, flags || void 0);
254
+ } catch (err) {
255
+ return `grep error: invalid regex: ${err.message}`;
256
+ }
257
+ const files = await enumerateFiles(input, ctx);
258
+ const showLineNumbers = input["-n"] ?? true;
259
+ const before = input["-B"] ?? input["-C"] ?? 0;
260
+ const after = input["-A"] ?? input["-C"] ?? 0;
261
+ const lines = [];
262
+ for (const path of files) {
263
+ let content;
264
+ try {
265
+ content = await ctx.execution.readFile(ctx.handle, path);
266
+ } catch {
267
+ continue;
268
+ }
269
+ if (input.multiline) {
270
+ const allMatches = [...content.matchAll(new RegExp(regex.source, `${flags.replace(/g/, "")}g`))];
271
+ if (allMatches.length === 0)
272
+ continue;
273
+ if (mode === "files_with_matches") {
274
+ lines.push(path);
275
+ continue;
276
+ }
277
+ if (mode === "count") {
278
+ lines.push(`${path}:${allMatches.length}`);
279
+ continue;
280
+ }
281
+ for (const m of allMatches) {
282
+ const lineStart = content.lastIndexOf("\n", m.index - 1) + 1;
283
+ const lineEnd = content.indexOf("\n", m.index);
284
+ const snippet = content.slice(lineStart, lineEnd === -1 ? void 0 : lineEnd);
285
+ const lineNo = content.slice(0, m.index).split("\n").length;
286
+ lines.push(formatContentLine(path, lineNo, snippet, showLineNumbers));
287
+ }
288
+ continue;
289
+ }
290
+ const fileLines = content.split("\n");
291
+ const matched = [];
292
+ for (let i = 0; i < fileLines.length; i++) {
293
+ regex.lastIndex = 0;
294
+ if (regex.test(fileLines[i]))
295
+ matched.push(i);
296
+ }
297
+ if (matched.length === 0)
298
+ continue;
299
+ if (mode === "files_with_matches") {
300
+ lines.push(path);
301
+ continue;
302
+ }
303
+ if (mode === "count") {
304
+ lines.push(`${path}:${matched.length}`);
305
+ continue;
306
+ }
307
+ const includeLineNos = /* @__PURE__ */ new Set();
308
+ for (const m of matched) {
309
+ for (let i = Math.max(0, m - before); i <= Math.min(fileLines.length - 1, m + after); i++)
310
+ includeLineNos.add(i);
311
+ }
312
+ const sorted = [...includeLineNos].sort((a, b) => a - b);
313
+ let prev = -2;
314
+ for (const lineNo of sorted) {
315
+ if (lineNo > prev + 1 && lines.length > 0)
316
+ lines.push("--");
317
+ const snippet = fileLines[lineNo];
318
+ lines.push(formatContentLine(path, lineNo + 1, snippet, showLineNumbers));
319
+ prev = lineNo;
320
+ }
321
+ }
322
+ return formatPaginated(lines.join("\n"), input);
323
+ }
324
+ function formatContentLine(path, lineNo, snippet, showLineNumbers) {
325
+ return showLineNumbers ? `${path}:${lineNo}:${snippet}` : `${path}:${snippet}`;
326
+ }
327
+ async function enumerateFiles(input, ctx) {
328
+ const cwd = ctx.handle.cwd;
329
+ const root = input.path ?? ".";
330
+ if (input.path && !input.path.includes("*") && !input.path.includes("?")) {
331
+ try {
332
+ const stat = await ctx.execution.exec(ctx.handle, `test -f ${shellQuote(input.path)} && echo file || echo dir`);
333
+ if (stat.stdout.trim() === "file")
334
+ return [input.path];
335
+ } catch {
336
+ }
337
+ }
338
+ const pattern = input.glob ?? "**/*";
339
+ const glob2 = new Bun.Glob(pattern);
340
+ const out = [];
341
+ const scanRoot = root === "." ? cwd : `${cwd.replace(/\/$/, "")}/${root.replace(/^\.\//, "")}`;
342
+ for await (const file of glob2.scan({ cwd: scanRoot, onlyFiles: true })) {
343
+ out.push(root === "." ? file : `${root.replace(/\/$/, "")}/${file}`);
344
+ }
345
+ return out.sort();
346
+ }
347
+ function formatPaginated(text, input) {
348
+ const headLimit = typeof input.head_limit === "number" && input.head_limit >= 0 ? input.head_limit : DEFAULT_HEAD_LIMIT;
349
+ const offset = typeof input.offset === "number" && input.offset > 0 ? Math.floor(input.offset) : 0;
350
+ if (!text.trim())
351
+ return "(no matches)";
352
+ const lines = text.split("\n").filter((l) => l.length > 0);
353
+ const total = lines.length;
354
+ const sliced = headLimit === 0 ? lines.slice(offset) : lines.slice(offset, offset + headLimit);
355
+ if (sliced.length === 0)
356
+ return "(no matches in this slice)";
357
+ const truncatedHead = offset > 0;
358
+ const truncatedTail = headLimit > 0 && offset + headLimit < total;
359
+ let out = sliced.join("\n");
360
+ if (truncatedHead)
361
+ out = `\u2026(${offset} earlier matches skipped)\u2026
362
+ ${out}`;
363
+ if (truncatedTail)
364
+ out = `${out}
365
+ \u2026(${total - offset - headLimit} more matches; re-run with offset=${offset + headLimit} or larger head_limit)`;
366
+ return out;
367
+ }
368
+
77
369
  // src/tools/interaction.ts
78
370
  function createInteractionTool(options) {
79
371
  const name = options.name ?? "interaction";
@@ -114,55 +406,251 @@ var listFiles = {
114
406
  }
115
407
  };
116
408
 
409
+ // src/tools/multi-edit.ts
410
+ var multiEdit = {
411
+ spec: {
412
+ name: "multi_edit",
413
+ description: "Apply a sequential list of edits to a file atomically. Each edit operates on the result of the previous edit. All edits must succeed for any to be written. Prefer this over multiple `edit` calls when several non-overlapping changes are needed in the same file.",
414
+ inputSchema: {
415
+ type: "object",
416
+ properties: {
417
+ path: { type: "string", description: "Relative file path." },
418
+ edits: {
419
+ type: "array",
420
+ description: "List of edits applied in order; each operates on the previous edit's output.",
421
+ items: {
422
+ type: "object",
423
+ properties: {
424
+ old_string: { type: "string" },
425
+ new_string: { type: "string" },
426
+ replace_all: { type: "boolean" }
427
+ },
428
+ required: ["old_string", "new_string"]
429
+ }
430
+ }
431
+ },
432
+ required: ["path", "edits"]
433
+ }
434
+ },
435
+ async execute({ path, edits }, ctx) {
436
+ const target = path;
437
+ const steps = edits;
438
+ if (!Array.isArray(steps) || steps.length === 0)
439
+ return `multi_edit error: edits must be a non-empty array.`;
440
+ let current;
441
+ try {
442
+ current = await ctx.execution.readFile(ctx.handle, target);
443
+ } catch {
444
+ return `multi_edit error: file not found: ${target}`;
445
+ }
446
+ let applied = 0;
447
+ for (let i = 0; i < steps.length; i++) {
448
+ const step = steps[i];
449
+ const find = step.old_string;
450
+ const replacement = step.new_string;
451
+ const replaceAll = step.replace_all === true;
452
+ if (typeof find !== "string" || typeof replacement !== "string")
453
+ return `multi_edit error: edit #${i + 1} is missing old_string or new_string.`;
454
+ if (find.length === 0)
455
+ return `multi_edit error: edit #${i + 1} has empty old_string. Use write_file to fully replace a file.`;
456
+ if (find === replacement)
457
+ return `multi_edit error: edit #${i + 1} old_string and new_string are identical.`;
458
+ const occurrences = countExactMatches(current, find);
459
+ if (occurrences === 0)
460
+ return `multi_edit error: edit #${i + 1} old_string not found in ${target}.`;
461
+ if (occurrences > 1 && !replaceAll)
462
+ return `multi_edit error: edit #${i + 1} old_string appears ${occurrences} times. Pass replace_all=true on this edit or expand old_string for uniqueness.`;
463
+ current = replaceAll ? current.split(find).join(replacement) : current.replace(find, replacement);
464
+ applied += occurrences;
465
+ }
466
+ await ctx.execution.writeFile(ctx.handle, target, current);
467
+ return `Edited ${target}: applied ${steps.length} edit${steps.length === 1 ? "" : "s"} (${applied} replacement${applied === 1 ? "" : "s"}).`;
468
+ }
469
+ };
470
+
117
471
  // src/tools/read-file.ts
472
+ import { Buffer } from "buffer";
473
+ var DEFAULT_LINE_LIMIT = 2e3;
474
+ var DEFAULT_BYTE_CAP = 65536;
475
+ var BINARY_PROBE_BYTES = 8e3;
118
476
  var readFile = {
119
477
  spec: {
120
478
  name: "read_file",
121
- description: "Read the contents of a file at the given path (relative to project root).",
479
+ description: "Read a file by path. Returns lines [offset..offset+limit). Default offset=1, limit=2000. A trailing footer explains how to read the rest when truncated. Binary files return a short marker rather than mojibake.",
122
480
  inputSchema: {
123
481
  type: "object",
124
482
  properties: {
125
- path: { type: "string", description: "Relative file path" }
483
+ path: { type: "string", description: "Relative file path." },
484
+ offset: { type: "integer", description: "1-indexed line number to start from. Default: 1." },
485
+ limit: { type: "integer", description: "Max lines to return. Default: 2000. Set 0 for unlimited." },
486
+ maxBytes: { type: "integer", description: "Hard byte cap regardless of line count. Default: 65536. Set 0 for unlimited." }
126
487
  },
127
488
  required: ["path"]
128
489
  }
129
490
  },
130
- async execute({ path }, ctx) {
491
+ async execute({ path, offset, limit, maxBytes }, ctx) {
492
+ let raw;
131
493
  try {
132
- return await ctx.execution.readFile(ctx.handle, path);
494
+ raw = await ctx.execution.readFile(ctx.handle, path);
133
495
  } catch {
134
496
  return `File not found: ${path}`;
135
497
  }
498
+ const totalBytes = Buffer.byteLength(raw);
499
+ if (looksBinary(raw)) {
500
+ return `[binary file: ${path}, ${totalBytes} bytes; use shell with hexdump | xxd | od to inspect]`;
501
+ }
502
+ const offsetN = normalizeInteger(offset, 1);
503
+ const limitN = normalizeInteger(limit, DEFAULT_LINE_LIMIT);
504
+ const maxBytesN = normalizeInteger(maxBytes, DEFAULT_BYTE_CAP);
505
+ const lines = raw.split("\n");
506
+ const totalLines = lines.length;
507
+ const startIdx = Math.max(0, offsetN - 1);
508
+ const endIdx = limitN > 0 ? Math.min(totalLines, startIdx + limitN) : totalLines;
509
+ let slice = lines.slice(startIdx, endIdx);
510
+ let bytesUsed = 0;
511
+ let bytesCut = false;
512
+ if (maxBytesN > 0) {
513
+ const truncatedSlice = [];
514
+ for (const line of slice) {
515
+ const lineBytes = Buffer.byteLength(line) + 1;
516
+ if (bytesUsed + lineBytes > maxBytesN && truncatedSlice.length > 0) {
517
+ bytesCut = true;
518
+ break;
519
+ }
520
+ truncatedSlice.push(line);
521
+ bytesUsed += lineBytes;
522
+ if (bytesUsed >= maxBytesN) {
523
+ break;
524
+ }
525
+ }
526
+ if (truncatedSlice.length < slice.length)
527
+ bytesCut = true;
528
+ slice = truncatedSlice;
529
+ }
530
+ let midLineCut = false;
531
+ if (maxBytesN > 0 && slice.length > 0) {
532
+ const bodyBytes = Buffer.byteLength(slice.join("\n"));
533
+ if (bodyBytes > maxBytesN) {
534
+ const lastIdx = slice.length - 1;
535
+ const lastLine = slice[lastIdx];
536
+ const otherBytes = lastIdx > 0 ? Buffer.byteLength(slice.slice(0, lastIdx).join("\n")) + 1 : 0;
537
+ const budgetForLast = Math.max(0, maxBytesN - otherBytes);
538
+ let cut = Math.min(lastLine.length, budgetForLast);
539
+ while (cut > 0 && Buffer.byteLength(lastLine.slice(0, cut)) > budgetForLast)
540
+ cut--;
541
+ slice[lastIdx] = lastLine.slice(0, cut);
542
+ midLineCut = true;
543
+ bytesCut = true;
544
+ }
545
+ }
546
+ const body = slice.join("\n");
547
+ const linesReturned = slice.length;
548
+ const lastLineRead = startIdx + linesReturned;
549
+ const linesTruncated = endIdx < totalLines || bytesCut;
550
+ if (!linesTruncated && offsetN === 1)
551
+ return body;
552
+ if (!linesTruncated) {
553
+ return `${body}
554
+
555
+ \u2026read lines ${offsetN}-${lastLineRead} of ${totalLines}.`;
556
+ }
557
+ if (midLineCut) {
558
+ return `${body}
559
+
560
+ \u2026truncated mid-line at line ${lastLineRead} (byte cap ${maxBytesN} reached). File has ${totalLines} lines, ${totalBytes} bytes total. Raise maxBytes, or use shell with sed/awk to read the remainder of this line.`;
561
+ }
562
+ const reason = bytesCut ? `byte cap (${maxBytesN}) reached` : `line limit (${limitN}) reached`;
563
+ return `${body}
564
+
565
+ \u2026truncated at line ${lastLineRead} (${reason}). File has ${totalLines} lines, ${totalBytes} bytes total \u2014 re-read with offset=${lastLineRead + 1} to continue.`;
136
566
  }
137
567
  };
568
+ function normalizeInteger(value, fallback) {
569
+ if (typeof value !== "number" || !Number.isFinite(value))
570
+ return fallback;
571
+ if (value < 0)
572
+ return fallback;
573
+ return Math.floor(value);
574
+ }
575
+ var REPLACEMENT_RATIO_THRESHOLD = 0.01;
576
+ var REPLACEMENT_MIN_COUNT = 5;
577
+ function looksBinary(text) {
578
+ const sample = text.length > BINARY_PROBE_BYTES ? text.slice(0, BINARY_PROBE_BYTES) : text;
579
+ if (sample.includes("\0"))
580
+ return true;
581
+ if (sample.length === 0)
582
+ return false;
583
+ let replacementCount = 0;
584
+ for (let i = 0; i < sample.length; i++) {
585
+ if (sample.charCodeAt(i) === 65533)
586
+ replacementCount++;
587
+ }
588
+ return replacementCount >= REPLACEMENT_MIN_COUNT && replacementCount / sample.length > REPLACEMENT_RATIO_THRESHOLD;
589
+ }
138
590
 
139
591
  // src/tools/shell.ts
592
+ import { Buffer as Buffer2 } from "buffer";
593
+ var DEFAULT_MAX_OUTPUT_BYTES = 8192;
140
594
  var shell = {
141
595
  spec: {
142
596
  name: "shell",
143
- description: "Execute a shell command and return stdout+stderr. Runs in the project root.",
597
+ description: "Execute a shell command in the project root and return its combined stdout/stderr. Output is tail-priority truncated at 8 KB by default; errors and exit-code summaries live in the tail. Set maxOutputBytes=0 to disable truncation.",
144
598
  inputSchema: {
145
599
  type: "object",
146
600
  properties: {
147
- command: { type: "string", description: "The shell command to run" }
601
+ command: { type: "string", description: "Shell command to run." },
602
+ timeout: { type: "integer", description: "Per-call timeout in milliseconds." },
603
+ maxOutputBytes: { type: "integer", description: "Truncate combined stdout+stderr beyond this many bytes. Default: 8192. Set 0 for unlimited." }
148
604
  },
149
605
  required: ["command"]
150
606
  }
151
607
  },
152
- async execute({ command }, ctx) {
153
- const result = await ctx.execution.exec(ctx.handle, command);
154
- if (result.exitCode === 0) {
155
- return result.stdout || "(no output)";
156
- }
157
- return `Exit code ${result.exitCode}
158
- ${result.stdout}
608
+ async execute({ command, timeout, maxOutputBytes }, ctx) {
609
+ const execOpts = {};
610
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0)
611
+ execOpts.timeout = Math.max(1, Math.ceil(timeout / 1e3));
612
+ const result = await ctx.execution.exec(ctx.handle, command, execOpts);
613
+ const cap = normalizeCap(maxOutputBytes);
614
+ if (result.exitCode === 0)
615
+ return truncateTail(result.stdout || "(no output)", cap);
616
+ const combined = `${result.stdout}
159
617
  ${result.stderr}`.trim();
618
+ return `Exit code ${result.exitCode}
619
+ ${truncateTail(combined, cap)}`;
160
620
  }
161
621
  };
622
+ function normalizeCap(value) {
623
+ if (typeof value !== "number" || !Number.isFinite(value))
624
+ return DEFAULT_MAX_OUTPUT_BYTES;
625
+ if (value < 0)
626
+ return DEFAULT_MAX_OUTPUT_BYTES;
627
+ return Math.floor(value);
628
+ }
629
+ function truncateTail(text, cap) {
630
+ if (cap === 0)
631
+ return text;
632
+ const totalBytes = Buffer2.byteLength(text);
633
+ if (totalBytes <= cap)
634
+ return text;
635
+ let bytes = 0;
636
+ let charIdx = text.length;
637
+ while (charIdx > 0) {
638
+ const ch = text[charIdx - 1];
639
+ const chBytes = Buffer2.byteLength(ch);
640
+ if (bytes + chBytes > cap)
641
+ break;
642
+ bytes += chBytes;
643
+ charIdx--;
644
+ }
645
+ const tail = text.slice(charIdx);
646
+ const droppedBytes = totalBytes - Buffer2.byteLength(tail);
647
+ return `\u2026(${droppedBytes} bytes truncated from head)\u2026
648
+ ${tail}`;
649
+ }
162
650
 
163
651
  // src/tools/skills-read.ts
164
652
  var SNIFF_BYTES = 8192;
165
- function looksBinary(text) {
653
+ function looksBinary2(text) {
166
654
  const len = Math.min(text.length, SNIFF_BYTES);
167
655
  for (let i = 0; i < len; i++) {
168
656
  if (text.charCodeAt(i) === 0)
@@ -214,7 +702,7 @@ function createSkillsReadTool(options) {
214
702
  const message = err instanceof Error ? err.message : String(err);
215
703
  return `Error reading "${relPath}" in skill "${skillName}": ${message}`;
216
704
  }
217
- if (looksBinary(content)) {
705
+ if (looksBinary2(content)) {
218
706
  return JSON.stringify({
219
707
  kind: "binary-unsupported",
220
708
  path: validated.absolutePath,
@@ -456,14 +944,156 @@ function rewriteMessagesToWire(messages, maps) {
456
944
  }
457
945
 
458
946
  // src/tools/validation.ts
947
+ var TRUE_STRINGS = /* @__PURE__ */ new Set(["true", "True", "TRUE", "1", "yes", "Yes", "YES"]);
948
+ var FALSE_STRINGS = /* @__PURE__ */ new Set(["false", "False", "FALSE", "0", "no", "No", "NO"]);
459
949
  function validateToolArgs(input, schema) {
460
950
  const required = schema.required ?? [];
951
+ const properties = schema.properties ?? {};
461
952
  for (const field of required) {
462
953
  if (!(field in input) || input[field] === void 0 || input[field] === null) {
463
954
  return { valid: false, error: `Missing required field: ${field}` };
464
955
  }
465
956
  }
466
- return { valid: true };
957
+ let coerced;
958
+ const coercions = [];
959
+ for (const [key, value] of Object.entries(input)) {
960
+ const propSchema = properties[key];
961
+ if (!propSchema?.type)
962
+ continue;
963
+ if (value === void 0 || value === null)
964
+ continue;
965
+ const outcome = coerceValue(value, propSchema);
966
+ if (outcome.error) {
967
+ return { valid: false, error: `Field "${key}": ${outcome.error}` };
968
+ }
969
+ if (outcome.changed) {
970
+ if (!coerced)
971
+ coerced = { ...input };
972
+ coerced[key] = outcome.value;
973
+ coercions.push(key);
974
+ }
975
+ }
976
+ return {
977
+ valid: true,
978
+ coercedInput: coerced ?? input,
979
+ coercions
980
+ };
981
+ }
982
+ function coerceValue(value, schema) {
983
+ const declaredTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
984
+ for (const t of declaredTypes) {
985
+ if (matchesType(value, t)) {
986
+ if (schema.enum && !schema.enum.includes(value)) {
987
+ return {
988
+ value,
989
+ changed: false,
990
+ error: `must be one of ${JSON.stringify(schema.enum)}, got ${formatValue(value)}`
991
+ };
992
+ }
993
+ return { value, changed: false };
994
+ }
995
+ }
996
+ for (const t of declaredTypes) {
997
+ const coerced = tryCoerce(value, t);
998
+ if (coerced.ok) {
999
+ if (schema.enum && !schema.enum.includes(coerced.value)) {
1000
+ return {
1001
+ value,
1002
+ changed: false,
1003
+ error: `must be one of ${JSON.stringify(schema.enum)}, got ${formatValue(coerced.value)}`
1004
+ };
1005
+ }
1006
+ return { value: coerced.value, changed: true };
1007
+ }
1008
+ }
1009
+ const expected = declaredTypes.join(" | ");
1010
+ return {
1011
+ value,
1012
+ changed: false,
1013
+ error: `expected ${expected}, got ${jsonType(value)} ${formatValue(value)}`
1014
+ };
1015
+ }
1016
+ function matchesType(value, type) {
1017
+ switch (type) {
1018
+ case "string":
1019
+ return typeof value === "string";
1020
+ case "number":
1021
+ return typeof value === "number" && Number.isFinite(value);
1022
+ case "integer":
1023
+ return typeof value === "number" && Number.isInteger(value);
1024
+ case "boolean":
1025
+ return typeof value === "boolean";
1026
+ case "array":
1027
+ return Array.isArray(value);
1028
+ case "object":
1029
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1030
+ case "null":
1031
+ return value === null;
1032
+ default:
1033
+ return true;
1034
+ }
1035
+ }
1036
+ function tryCoerce(value, type) {
1037
+ if (typeof value === "string") {
1038
+ if (type === "boolean") {
1039
+ const trimmed = value.trim();
1040
+ if (TRUE_STRINGS.has(trimmed))
1041
+ return { ok: true, value: true };
1042
+ if (FALSE_STRINGS.has(trimmed))
1043
+ return { ok: true, value: false };
1044
+ return { ok: false };
1045
+ }
1046
+ if (type === "number") {
1047
+ const n = Number(value.trim());
1048
+ return Number.isFinite(n) ? { ok: true, value: n } : { ok: false };
1049
+ }
1050
+ if (type === "integer") {
1051
+ const n = Number(value.trim());
1052
+ return Number.isInteger(n) ? { ok: true, value: n } : { ok: false };
1053
+ }
1054
+ if (type === "array" || type === "object") {
1055
+ try {
1056
+ const parsed = JSON.parse(value);
1057
+ if (type === "array" && Array.isArray(parsed))
1058
+ return { ok: true, value: parsed };
1059
+ if (type === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
1060
+ return { ok: true, value: parsed };
1061
+ return { ok: false };
1062
+ } catch {
1063
+ return { ok: false };
1064
+ }
1065
+ }
1066
+ if (type === "null") {
1067
+ return value === "" || value === "null" ? { ok: true, value: null } : { ok: false };
1068
+ }
1069
+ }
1070
+ if (typeof value === "number" && Number.isFinite(value)) {
1071
+ if (type === "string")
1072
+ return { ok: true, value: String(value) };
1073
+ if (type === "integer" && Number.isInteger(value))
1074
+ return { ok: true, value };
1075
+ }
1076
+ if (typeof value === "boolean" && type === "string")
1077
+ return { ok: true, value: String(value) };
1078
+ return { ok: false };
1079
+ }
1080
+ function jsonType(value) {
1081
+ if (value === null)
1082
+ return "null";
1083
+ if (Array.isArray(value))
1084
+ return "array";
1085
+ return typeof value;
1086
+ }
1087
+ function formatValue(value) {
1088
+ let s;
1089
+ try {
1090
+ s = JSON.stringify(value);
1091
+ } catch {
1092
+ s = String(value);
1093
+ }
1094
+ if (s === void 0)
1095
+ s = String(value);
1096
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
467
1097
  }
468
1098
 
469
1099
  // src/loop.ts
@@ -716,6 +1346,29 @@ async function executeTurn(ctx, turn) {
716
1346
  content: toolResultMsg.content,
717
1347
  createdAt: Date.now()
718
1348
  });
1349
+ if (typeof ctx.toolOutputBudget === "number" && ctx.toolOutputBudget > 0) {
1350
+ const totalBytes = toolResults.reduce(
1351
+ (sum, r) => sum + toolOutputByteLength(r.content),
1352
+ 0
1353
+ );
1354
+ if (totalBytes > ctx.toolOutputBudget) {
1355
+ const warning = `[Tool output budget exceeded: ${totalBytes} bytes returned in this turn (cap: ${ctx.toolOutputBudget}). Summarize the salient findings before calling more tools.]`;
1356
+ const userMsg = ctx.provider.userMessage(warning);
1357
+ ctx.turns.push({
1358
+ id: await ctx.generateTurnId(),
1359
+ runId: ctx.runId,
1360
+ role: userMsg.role,
1361
+ content: userMsg.content,
1362
+ createdAt: Date.now()
1363
+ });
1364
+ await ctx.hooks.callHook("budget:exceeded", {
1365
+ turn,
1366
+ turnId,
1367
+ bytes: totalBytes,
1368
+ budget: ctx.toolOutputBudget
1369
+ });
1370
+ }
1371
+ }
719
1372
  return { ended: false, turnId, usage: result.usage };
720
1373
  }
721
1374
  function stripImagesForNonVision(provider, output) {
@@ -742,29 +1395,64 @@ async function executeSingleTool(ctx, call, turnId) {
742
1395
  if (gateCtx.block) {
743
1396
  return { result: { id: callId, content: `Blocked: ${gateCtx.reason}` } };
744
1397
  }
745
- const effectiveInput = gateCtx.input;
1398
+ let effectiveInput = gateCtx.input;
746
1399
  if (!toolDef) {
747
- const err = new Error(`Unknown tool: ${call.name}`);
748
- await ctx.hooks.callHook("tool:error", {
1400
+ const unknownCtx = {
749
1401
  turnId,
750
1402
  callId,
751
1403
  name: call.name,
752
1404
  displayName,
753
1405
  input: effectiveInput,
754
- error: err
755
- });
756
- return { result: { id: callId, content: `Tool error: ${err.message}` } };
1406
+ suppressError: false
1407
+ };
1408
+ await ctx.hooks.callHook("tool:unknown", unknownCtx);
1409
+ const content = unknownCtx.result ?? `Tool error: Unknown tool: ${call.name}`;
1410
+ if (!unknownCtx.suppressError) {
1411
+ const err = new Error(`Unknown tool: ${call.name}`);
1412
+ await ctx.hooks.callHook("tool:error", {
1413
+ turnId,
1414
+ callId,
1415
+ name: call.name,
1416
+ displayName,
1417
+ input: effectiveInput,
1418
+ error: err
1419
+ });
1420
+ }
1421
+ return { result: { id: callId, content } };
757
1422
  }
758
1423
  const validation = validateToolArgs(effectiveInput, toolDef.spec.inputSchema);
759
1424
  if (!validation.valid) {
1425
+ await ctx.hooks.callHook("validation:reject", {
1426
+ turnId,
1427
+ callId,
1428
+ name: call.name,
1429
+ displayName,
1430
+ input: effectiveInput,
1431
+ reason: validation.error ?? "invalid input",
1432
+ schema: toolDef.spec.inputSchema
1433
+ });
760
1434
  return { result: { id: callId, content: `Validation error: ${validation.error}` } };
761
1435
  }
1436
+ effectiveInput = validation.coercedInput ?? effectiveInput;
1437
+ const coercions = validation.coercions && validation.coercions.length > 0 ? validation.coercions : void 0;
1438
+ if (coercions) {
1439
+ await ctx.hooks.callHook("validation:coerce", {
1440
+ turnId,
1441
+ callId,
1442
+ name: call.name,
1443
+ displayName,
1444
+ input: effectiveInput,
1445
+ coercions,
1446
+ schema: toolDef.spec.inputSchema
1447
+ });
1448
+ }
762
1449
  await ctx.hooks.callHook("tool:before", {
763
1450
  turnId,
764
1451
  callId,
765
1452
  name: call.name,
766
1453
  displayName,
767
- input: effectiveInput
1454
+ input: effectiveInput,
1455
+ ...coercions ? { coercions } : {}
768
1456
  });
769
1457
  let output;
770
1458
  let isError = false;
@@ -809,7 +1497,9 @@ async function executeSingleTool(ctx, call, turnId) {
809
1497
  displayName,
810
1498
  input: effectiveInput,
811
1499
  result: output,
812
- isError
1500
+ isError,
1501
+ outputBytes: toolOutputByteLength(output),
1502
+ ...coercions ? { coercions } : {}
813
1503
  };
814
1504
  await ctx.hooks.callHook("tool:transform", transformCtx);
815
1505
  output = transformCtx.result;
@@ -821,7 +1511,9 @@ async function executeSingleTool(ctx, call, turnId) {
821
1511
  name: call.name,
822
1512
  displayName,
823
1513
  input: effectiveInput,
824
- result: output
1514
+ result: output,
1515
+ outputBytes: toolOutputByteLength(output),
1516
+ ...coercions ? { coercions } : {}
825
1517
  });
826
1518
  return { result: { id: callId, content: output } };
827
1519
  }
@@ -946,6 +1638,9 @@ var HOOK_EVENT_NAMES = [
946
1638
  "tool:after",
947
1639
  "tool:error",
948
1640
  "tool:transform",
1641
+ "tool:unknown",
1642
+ "validation:reject",
1643
+ "validation:coerce",
949
1644
  "context:transform",
950
1645
  "steer:inject",
951
1646
  "spawn:before",
@@ -974,6 +1669,7 @@ var HOOK_EVENT_NAMES = [
974
1669
  "skills:deactivate",
975
1670
  "usage",
976
1671
  "output",
1672
+ "budget:exceeded",
977
1673
  "agent:abort",
978
1674
  "agent:done",
979
1675
  "session:start",
@@ -993,7 +1689,8 @@ function resolveBehavior(agentBehavior, runBehavior) {
993
1689
  maxTokens: runBehavior?.maxTokens ?? agentBehavior?.maxTokens,
994
1690
  thinkingBudget: runBehavior?.thinkingBudget ?? agentBehavior?.thinkingBudget,
995
1691
  schema: runBehavior?.schema ?? agentBehavior?.schema,
996
- cache: runBehavior?.cache ?? agentBehavior?.cache ?? true
1692
+ cache: runBehavior?.cache ?? agentBehavior?.cache ?? true,
1693
+ toolOutputBudget: runBehavior?.toolOutputBudget ?? agentBehavior?.toolOutputBudget
997
1694
  };
998
1695
  }
999
1696
  function createAgent({ provider, name: agentName, system: agentSystem, tools: agentTools, toolAliases, behavior: agentBehavior, execution, mcpServers, session, skills: agentSkills, mcpConnector, eager }) {
@@ -1116,7 +1813,7 @@ function createAgent({ provider, name: agentName, system: agentSystem, tools: ag
1116
1813
  }
1117
1814
  const thinking = options.thinking ?? "off";
1118
1815
  const model = options.model ?? provider.meta.defaultModel;
1119
- const { toolExecution, maxTurns, maxTokens, thinkingBudget, schema, cache } = resolveBehavior(agentBehavior, options.behavior);
1816
+ const { toolExecution, maxTurns, maxTokens, thinkingBudget, schema, cache, toolOutputBudget } = resolveBehavior(agentBehavior, options.behavior);
1120
1817
  let system = options.system || agentSystem || "You are a helpful assistant.";
1121
1818
  if (skillsCatalog) {
1122
1819
  system = `${system}
@@ -1251,6 +1948,7 @@ ${skillsCatalog}`;
1251
1948
  thinkingBudget,
1252
1949
  schema,
1253
1950
  cache,
1951
+ toolOutputBudget,
1254
1952
  runStartMs
1255
1953
  });
1256
1954
  const finalStats = {
@@ -1546,7 +2244,7 @@ function createSpawnTool(options = {}) {
1546
2244
  },
1547
2245
  spec: {
1548
2246
  name: "spawn",
1549
- description: "Spawn a sub-agent to work on a specific task. The sub-agent runs independently with its own tool access and returns its final response. Use this to delegate work, parallelize tasks, or isolate concerns.",
2247
+ description: "Spawn a sub-agent for a self-contained task that benefits from isolation (separate context window, separate retries) \u2014 for example, a deep research dive or a long codegen pass on a specific file. The sub-agent runs independently with its own tool access and returns its final response. Do NOT spawn for sequential steps you could do yourself.",
1550
2248
  inputSchema: {
1551
2249
  type: "object",
1552
2250
  properties: {
@@ -1695,22 +2393,33 @@ function createSpawnTool(options = {}) {
1695
2393
  var spawn = createSpawnTool();
1696
2394
 
1697
2395
  // src/tools/write-file.ts
2396
+ import { Buffer as Buffer3 } from "buffer";
1698
2397
  var writeFile = {
1699
2398
  spec: {
1700
2399
  name: "write_file",
1701
- description: "Write content to a file. Creates parent directories if needed.",
2400
+ description: 'Write content to a file (creates parent directories). Returns Created / Updated / "No change needed" so the model can detect no-ops without a separate read.',
1702
2401
  inputSchema: {
1703
2402
  type: "object",
1704
2403
  properties: {
1705
- path: { type: "string", description: "Relative file path" },
1706
- content: { type: "string", description: "File content to write" }
2404
+ path: { type: "string", description: "Relative file path." },
2405
+ content: { type: "string", description: "File content." }
1707
2406
  },
1708
2407
  required: ["path", "content"]
1709
2408
  }
1710
2409
  },
1711
2410
  async execute({ path, content }, ctx) {
1712
- await ctx.execution.writeFile(ctx.handle, path, content);
1713
- return `Wrote ${content.length} bytes to ${path}`;
2411
+ const targetPath = path;
2412
+ const targetContent = content;
2413
+ let existing;
2414
+ try {
2415
+ existing = await ctx.execution.readFile(ctx.handle, targetPath);
2416
+ } catch {
2417
+ }
2418
+ const bytes = Buffer3.byteLength(targetContent);
2419
+ if (existing === targetContent)
2420
+ return `No change needed: ${targetPath} already at target state (${bytes} bytes).`;
2421
+ await ctx.execution.writeFile(ctx.handle, targetPath, targetContent);
2422
+ return existing === void 0 ? `Created ${targetPath} (${bytes} bytes).` : `Updated ${targetPath} (${bytes} bytes).`;
1714
2423
  }
1715
2424
  };
1716
2425
 
@@ -1720,9 +2429,12 @@ export {
1720
2429
  createSkillsRunScriptTool,
1721
2430
  createSkillsUseTool,
1722
2431
  createAgent,
2432
+ edit,
1723
2433
  glob,
2434
+ grep,
1724
2435
  createInteractionTool,
1725
2436
  listFiles,
2437
+ multiEdit,
1726
2438
  readFile,
1727
2439
  shell,
1728
2440
  createSpawnTool,