@gaunt-sloth/agent 2.0.0-alpha.3 ā 2.0.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -23
- package/dist/builtInToolsConfig.d.ts +17 -3
- package/dist/builtInToolsConfig.js +40 -41
- package/dist/builtInToolsConfig.js.map +1 -1
- package/dist/core/GthDeepAgent.d.ts +52 -32
- package/dist/core/GthDeepAgent.js +438 -198
- package/dist/core/GthDeepAgent.js.map +1 -1
- package/dist/core/debugCapture.d.ts +8 -49
- package/dist/core/debugCapture.js +1 -1
- package/dist/core/debugCapture.js.map +1 -1
- package/dist/core/deepAgentPermissions.d.ts +67 -2
- package/dist/core/deepAgentPermissions.js +151 -3
- package/dist/core/deepAgentPermissions.js.map +1 -1
- package/dist/core/gthAcpServer.js +11 -0
- package/dist/core/gthAcpServer.js.map +1 -1
- package/dist/core/gthDeepAgentFactory.d.ts +3 -0
- package/dist/core/gthDeepAgentFactory.js +9 -1
- package/dist/core/gthDeepAgentFactory.js.map +1 -1
- package/dist/core/resolveAgentFactory.d.ts +14 -0
- package/dist/core/resolveAgentFactory.js +18 -0
- package/dist/core/resolveAgentFactory.js.map +1 -0
- package/dist/core/subagentProfiles.d.ts +50 -0
- package/dist/core/subagentProfiles.js +76 -0
- package/dist/core/subagentProfiles.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/OAuthClientProviderImpl.js.map +1 -1
- package/dist/mcp/tlsTrust.d.ts +61 -0
- package/dist/mcp/tlsTrust.js +109 -0
- package/dist/mcp/tlsTrust.js.map +1 -0
- package/dist/middleware/binaryContentInjectionMiddleware.d.ts +8 -1
- package/dist/middleware/binaryContentInjectionMiddleware.js +11 -2
- package/dist/middleware/binaryContentInjectionMiddleware.js.map +1 -1
- package/dist/middleware/frontendImageInjectionMiddleware.d.ts +106 -0
- package/dist/middleware/frontendImageInjectionMiddleware.js +166 -0
- package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -0
- package/dist/middleware/registry.js +36 -1
- package/dist/middleware/registry.js.map +1 -1
- package/dist/middleware/types.d.ts +16 -2
- package/dist/modules/a2a/A2AClientWrapper.d.ts +56 -1
- package/dist/modules/a2a/A2AClientWrapper.js +131 -31
- package/dist/modules/a2a/A2AClientWrapper.js.map +1 -1
- package/dist/modules/acpModule.js +17 -1
- package/dist/modules/acpModule.js.map +1 -1
- package/dist/modules/apiAgUiModule.d.ts +87 -0
- package/dist/modules/apiAgUiModule.js +229 -28
- package/dist/modules/apiAgUiModule.js.map +1 -1
- package/dist/modules/interactiveSessionModule.js +501 -41
- package/dist/modules/interactiveSessionModule.js.map +1 -1
- package/dist/modules/slashCommands.d.ts +576 -0
- package/dist/modules/slashCommands.js +995 -0
- package/dist/modules/slashCommands.js.map +1 -0
- package/dist/resolvers.js +119 -9
- package/dist/resolvers.js.map +1 -1
- package/dist/tools/GthCustomToolkit.js +95 -11
- package/dist/tools/GthCustomToolkit.js.map +1 -1
- package/dist/tools/GthDevToolkit.d.ts +11 -3
- package/dist/tools/GthDevToolkit.js +120 -34
- package/dist/tools/GthDevToolkit.js.map +1 -1
- package/dist/tools/GthFileSystemToolkit.d.ts +63 -0
- package/dist/tools/GthFileSystemToolkit.js +506 -105
- package/dist/tools/GthFileSystemToolkit.js.map +1 -1
- package/dist/tools/McpResourceTool.d.ts +31 -0
- package/dist/tools/McpResourceTool.js +106 -0
- package/dist/tools/McpResourceTool.js.map +1 -0
- package/dist/tools/gthChecklistTool.d.ts +30 -0
- package/dist/tools/gthChecklistTool.js +81 -0
- package/dist/tools/gthChecklistTool.js.map +1 -0
- package/dist/tools/gthGrepTool.d.ts +54 -0
- package/dist/tools/gthGrepTool.js +482 -0
- package/dist/tools/gthGrepTool.js.map +1 -0
- package/dist/tools/shell/env.js +1 -1
- package/dist/tools/shell/env.js.map +1 -1
- package/dist/tools/shell/outputBuffer.js.map +1 -1
- package/dist/tools/shell/workDir.d.ts +11 -0
- package/dist/tools/shell/workDir.js +45 -0
- package/dist/tools/shell/workDir.js.map +1 -0
- package/dist/utils/mcpAuthError.d.ts +36 -0
- package/dist/utils/mcpAuthError.js +89 -0
- package/dist/utils/mcpAuthError.js.map +1 -0
- package/dist/utils/mcpUtils.js +16 -0
- package/dist/utils/mcpUtils.js.map +1 -1
- package/package.json +12 -11
- package/dist/tools/shell/allowlist.d.ts +0 -11
- package/dist/tools/shell/allowlist.js +0 -12
- package/dist/tools/shell/allowlist.js.map +0 -1
- package/dist/tools/shell/arity.d.ts +0 -11
- package/dist/tools/shell/arity.js +0 -12
- package/dist/tools/shell/arity.js.map +0 -1
- package/dist/tools/shell/hardline.d.ts +0 -15
- package/dist/tools/shell/hardline.js +0 -88
- package/dist/tools/shell/hardline.js.map +0 -1
- package/dist/tools/shell/normalize.d.ts +0 -10
- package/dist/tools/shell/normalize.js +0 -11
- package/dist/tools/shell/normalize.js.map +0 -1
|
@@ -4,7 +4,6 @@ import fs from 'fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import { createTwoFilesPatch } from 'diff';
|
|
7
|
-
import { displayInfo } from '@gaunt-sloth/core/utils/consoleUtils.js';
|
|
8
7
|
import { shouldIgnoreFile } from '@gaunt-sloth/core/utils/aiignoreUtils.js';
|
|
9
8
|
import { getCurrentWorkDir } from '@gaunt-sloth/core/utils/systemUtils.js';
|
|
10
9
|
import { getFormatForExtension, getMimeType, readBinaryFile } from '#src/tools/binaryUtils.js';
|
|
@@ -14,6 +13,17 @@ import { getFormatForExtension, getMimeType, readBinaryFile } from '#src/tools/b
|
|
|
14
13
|
*/
|
|
15
14
|
// TODO make it configurable
|
|
16
15
|
const IGNORED_DIRS = ['node_modules', '.git', '.idea', 'dist'];
|
|
16
|
+
// read_file safety envelope (GS2-39). A single large file ā or a minified bundle that is one
|
|
17
|
+
// multi-MB line ā must not consume the whole context window in one uncapped read. Defaults are
|
|
18
|
+
// adopted from opencode's read tool (packages/core/src/tool/read-filesystem.ts): at most
|
|
19
|
+
// MAX_READ_LINES lines or MAX_READ_BYTES bytes are returned, and any single line longer than
|
|
20
|
+
// MAX_LINE_LENGTH characters is truncated with LINE_TRUNCATED_SUFFIX. offset/limit paging is the
|
|
21
|
+
// escape hatch to fetch whatever the cap dropped. This bounds context, not process memory: like
|
|
22
|
+
// the existing head/tail helpers the file is still read whole and then trimmed.
|
|
23
|
+
const MAX_READ_LINES = 2000;
|
|
24
|
+
const MAX_READ_BYTES = 50 * 1024;
|
|
25
|
+
const MAX_LINE_LENGTH = 2000;
|
|
26
|
+
const LINE_TRUNCATED_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
|
|
17
27
|
// Helper function to create a tool with filesystem type
|
|
18
28
|
function createGthTool(fn, config, gthFileSystemType) {
|
|
19
29
|
const toolInstance = tool(fn, config);
|
|
@@ -24,8 +34,33 @@ function createGthTool(fn, config, gthFileSystemType) {
|
|
|
24
34
|
// Schema definitions
|
|
25
35
|
const ReadFileArgsSchema = z.object({
|
|
26
36
|
path: z.string(),
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
offset: z
|
|
38
|
+
.number()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Start of a 1-based line window: the first line to return (inclusive). Combine with ' +
|
|
41
|
+
'`limit` to page through a large file (e.g. offset 2001 continues past the default ' +
|
|
42
|
+
`${MAX_READ_LINES}-line cap). Mutually exclusive with head/tail ā pass a line window ` +
|
|
43
|
+
'(offset/limit) OR head/tail, not both.'),
|
|
44
|
+
limit: z
|
|
45
|
+
.number()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('Number of lines to return starting at `offset` (defaults to and is hard-capped at ' +
|
|
48
|
+
`${MAX_READ_LINES}). Use with \`offset\` to read an arbitrary window. Mutually ` +
|
|
49
|
+
'exclusive with head/tail.'),
|
|
50
|
+
tail: z
|
|
51
|
+
.number()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe('If provided, returns only the last N lines of the file. May be combined with head ' +
|
|
54
|
+
'(no longer mutually exclusive): head+tail returns the first head lines, a ' +
|
|
55
|
+
'`... [N lines skipped] ...` marker, then the last tail lines. Mutually exclusive ' +
|
|
56
|
+
'with the offset/limit line window.'),
|
|
57
|
+
head: z
|
|
58
|
+
.number()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('If provided, returns only the first N lines of the file. May be combined with tail ' +
|
|
61
|
+
'(no longer mutually exclusive): head+tail returns the first head lines, a ' +
|
|
62
|
+
'`... [N lines skipped] ...` marker, then the last tail lines. Mutually exclusive ' +
|
|
63
|
+
'with the offset/limit line window.'),
|
|
29
64
|
});
|
|
30
65
|
const ReadBinaryArgsSchema = z.object({
|
|
31
66
|
path: z.string().describe('Path to the binary file to read'),
|
|
@@ -44,6 +79,12 @@ const WriteFileArgsSchema = z.object({
|
|
|
44
79
|
const EditOperation = z.object({
|
|
45
80
|
oldText: z.string().describe('Text to search for - must match exactly'),
|
|
46
81
|
newText: z.string().describe('Text to replace with'),
|
|
82
|
+
replaceAll: z
|
|
83
|
+
.boolean()
|
|
84
|
+
.default(false)
|
|
85
|
+
.describe('Replace all occurrences of oldText. Default false requires a unique match: if oldText ' +
|
|
86
|
+
'appears more than once the edit fails so you can add surrounding context to disambiguate; ' +
|
|
87
|
+
'set true to replace every occurrence.'),
|
|
47
88
|
});
|
|
48
89
|
const EditFileArgsSchema = z.object({
|
|
49
90
|
path: z.string(),
|
|
@@ -274,14 +315,50 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
274
315
|
const normalizedNew = this.normalizeLineEndings(newContent);
|
|
275
316
|
return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified');
|
|
276
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Count non-overlapping exact occurrences of `search` in `content`.
|
|
320
|
+
* Uses an indexOf scan (no regex) so arbitrary text is matched literally.
|
|
321
|
+
*/
|
|
322
|
+
countOccurrences(content, search) {
|
|
323
|
+
if (search === '')
|
|
324
|
+
return 0;
|
|
325
|
+
let count = 0;
|
|
326
|
+
let offset = 0;
|
|
327
|
+
let idx = content.indexOf(search, offset);
|
|
328
|
+
while (idx !== -1) {
|
|
329
|
+
count++;
|
|
330
|
+
offset = idx + search.length;
|
|
331
|
+
idx = content.indexOf(search, offset);
|
|
332
|
+
}
|
|
333
|
+
return count;
|
|
334
|
+
}
|
|
277
335
|
async applyFileEdits(filePath, edits, dryRun = false) {
|
|
278
336
|
const content = this.normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
|
|
279
337
|
let modifiedContent = content;
|
|
338
|
+
const summaries = [];
|
|
339
|
+
let editIndex = 0;
|
|
280
340
|
for (const edit of edits) {
|
|
341
|
+
editIndex++;
|
|
281
342
|
const normalizedOld = this.normalizeLineEndings(edit.oldText);
|
|
282
343
|
const normalizedNew = this.normalizeLineEndings(edit.newText);
|
|
283
|
-
|
|
284
|
-
|
|
344
|
+
const occurrences = this.countOccurrences(modifiedContent, normalizedOld);
|
|
345
|
+
if (occurrences > 0) {
|
|
346
|
+
if (occurrences > 1 && edit.replaceAll !== true) {
|
|
347
|
+
// Ambiguous match: refuse rather than silently editing the first occurrence. Thrown here
|
|
348
|
+
// and caught at the tool boundary (GS2-36), which surfaces it as a recoverable errored
|
|
349
|
+
// result naming the path ā so the model can add surrounding context and retry instead of
|
|
350
|
+
// the run aborting. Phrased as an action-oriented recovery hint (EXT-34 hermes style).
|
|
351
|
+
throw new Error(`found ${occurrences} occurrences of the search text, so the edit is ambiguous and the ` +
|
|
352
|
+
'file was NOT modified. Add more surrounding context to make the match unique, or set ' +
|
|
353
|
+
'replaceAll: true to replace every occurrence.\n' +
|
|
354
|
+
edit.oldText);
|
|
355
|
+
}
|
|
356
|
+
// split/join replaces every occurrence without regex escaping and, unlike
|
|
357
|
+
// String.replace, does NOT interpret `$`-patterns ($&, $`, $', $$) in the
|
|
358
|
+
// replacement text; for a unique match (occurrences === 1) it replaces exactly
|
|
359
|
+
// that one occurrence.
|
|
360
|
+
modifiedContent = modifiedContent.split(normalizedOld).join(normalizedNew);
|
|
361
|
+
summaries.push(`edit ${editIndex}: replaced ${occurrences} occurrence(s)`);
|
|
285
362
|
continue;
|
|
286
363
|
}
|
|
287
364
|
const oldLines = normalizedOld.split('\n');
|
|
@@ -313,8 +390,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
313
390
|
}
|
|
314
391
|
}
|
|
315
392
|
if (!matchFound) {
|
|
316
|
-
|
|
393
|
+
// No occurrence of the search text (exact or fuzzy) ā thrown here and caught at the tool
|
|
394
|
+
// boundary (GS2-36) so it reaches the model as a recoverable errored result naming the
|
|
395
|
+
// path, not a fatal abort. Action-oriented recovery hint (EXT-34 hermes style): re-read to
|
|
396
|
+
// get the exact current text, or fall back to a whole-file write_file.
|
|
397
|
+
throw new Error('no occurrence of the search text was found, so the file was NOT modified. Re-read the ' +
|
|
398
|
+
'file to get its exact current text (whitespace and indentation must match), then retry ' +
|
|
399
|
+
'the edit, or use write_file to replace the whole file.\n' +
|
|
400
|
+
edit.oldText);
|
|
317
401
|
}
|
|
402
|
+
// Fuzzy (trim-based line) match applied: flag it so the model knows the edit
|
|
403
|
+
// landed on a near-match, not an exact one.
|
|
404
|
+
summaries.push(`edit ${editIndex}: applied via fuzzy line-match (no exact match)`);
|
|
318
405
|
}
|
|
319
406
|
const diff = this.createUnifiedDiff(content, modifiedContent, filePath);
|
|
320
407
|
let numBackticks = 3;
|
|
@@ -325,7 +412,9 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
325
412
|
if (!dryRun) {
|
|
326
413
|
await fs.writeFile(filePath, modifiedContent, 'utf-8');
|
|
327
414
|
}
|
|
328
|
-
|
|
415
|
+
// Per-edit machine-readable summary, prepended so the diff block stays intact.
|
|
416
|
+
const summaryBlock = summaries.length > 0 ? `${summaries.join('\n')}\n\n` : '';
|
|
417
|
+
return summaryBlock + formattedDiff;
|
|
329
418
|
}
|
|
330
419
|
formatSize(bytes) {
|
|
331
420
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
@@ -349,6 +438,7 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
349
438
|
let chunk = Buffer.alloc(CHUNK_SIZE);
|
|
350
439
|
let linesFound = 0;
|
|
351
440
|
let remainingText = '';
|
|
441
|
+
let firstChunk = true;
|
|
352
442
|
while (position > 0 && linesFound < numLines) {
|
|
353
443
|
const size = Math.min(CHUNK_SIZE, position);
|
|
354
444
|
position -= size;
|
|
@@ -357,7 +447,17 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
357
447
|
break;
|
|
358
448
|
const readData = chunk.slice(0, bytesRead).toString('utf-8');
|
|
359
449
|
const chunkText = readData + remainingText;
|
|
360
|
-
const
|
|
450
|
+
const normalizedChunk = this.normalizeLineEndings(chunkText);
|
|
451
|
+
const chunkLines = normalizedChunk.split('\n');
|
|
452
|
+
// We read backward from EOF, so the file's terminating newline (if any) is only
|
|
453
|
+
// ever in this first chunk. Its split yields a phantom trailing empty element that
|
|
454
|
+
// must not be counted as the last line. Drop exactly one here so tail counting
|
|
455
|
+
// matches head/full reads; a file without a trailing newline is untouched and a
|
|
456
|
+
// genuine blank last line ("...\n\n") is preserved (only one element removed).
|
|
457
|
+
if (firstChunk && normalizedChunk.endsWith('\n')) {
|
|
458
|
+
chunkLines.pop();
|
|
459
|
+
}
|
|
460
|
+
firstChunk = false;
|
|
361
461
|
if (position > 0) {
|
|
362
462
|
remainingText = chunkLines[0];
|
|
363
463
|
chunkLines.shift();
|
|
@@ -406,12 +506,150 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
406
506
|
await fileHandle.close();
|
|
407
507
|
}
|
|
408
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* Returns the first `head` lines and the last `tail` lines of a file, separated by a
|
|
511
|
+
* `... [N lines skipped] ...` marker. When the two windows cover or overlap the whole
|
|
512
|
+
* file (`head + tail >= total`) the entire file is returned unchanged (no marker, no
|
|
513
|
+
* duplicated lines).
|
|
514
|
+
*/
|
|
515
|
+
async headAndTailFile(filePath, head, tail) {
|
|
516
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
517
|
+
const normalized = this.normalizeLineEndings(content);
|
|
518
|
+
const lines = normalized.split('\n');
|
|
519
|
+
// A terminating newline yields a phantom trailing empty element that would be
|
|
520
|
+
// miscounted as a real line (inflating `total`, so the true last line gets pushed
|
|
521
|
+
// into the "skipped" gap and the tail comes back empty). Drop exactly one when the
|
|
522
|
+
// file ends in a newline so counting/slicing matches head/full reads. Files without a
|
|
523
|
+
// trailing newline are untouched, and a genuine blank last line ("...\n\n") is
|
|
524
|
+
// preserved because only a single element is removed.
|
|
525
|
+
if (normalized.endsWith('\n')) {
|
|
526
|
+
lines.pop();
|
|
527
|
+
}
|
|
528
|
+
const total = lines.length;
|
|
529
|
+
if (head + tail >= total) {
|
|
530
|
+
return content;
|
|
531
|
+
}
|
|
532
|
+
const skipped = total - head - tail;
|
|
533
|
+
const headContent = lines.slice(0, head).join('\n');
|
|
534
|
+
const tailContent = lines.slice(total - tail).join('\n');
|
|
535
|
+
return headContent + '\n... [' + skipped + ' lines skipped] ...\n' + tailContent;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Return the file's lines from the 1-based `offset` to EOF (GS2-39 paging). Deliberately does
|
|
539
|
+
* NOT apply `limit` here ā the caller hands the result to capReadContent with maxLines = limit so
|
|
540
|
+
* that a limit-bounded read is *observable* to the envelope (it can then tell "more file remains
|
|
541
|
+
* past this window" from "reached EOF" and emit the correct resume notice). Pre-slicing to
|
|
542
|
+
* exactly `limit` lines here would hide that signal and silently drop the continuation.
|
|
543
|
+
*
|
|
544
|
+
* Mirrors headAndTailFile's trailing-newline handling so line numbering matches head/tail/full
|
|
545
|
+
* reads: a terminating newline yields a phantom trailing empty element that must not be counted
|
|
546
|
+
* as a real line. Clamps rather than throwing: an offset past EOF returns a short recoverable note
|
|
547
|
+
* (not an empty string, which is indistinguishable from an empty file).
|
|
548
|
+
*/
|
|
549
|
+
async windowFile(filePath, offset) {
|
|
550
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
551
|
+
const normalized = this.normalizeLineEndings(content);
|
|
552
|
+
const lines = normalized.split('\n');
|
|
553
|
+
if (normalized.endsWith('\n')) {
|
|
554
|
+
lines.pop();
|
|
555
|
+
}
|
|
556
|
+
const total = lines.length;
|
|
557
|
+
if (total === 0) {
|
|
558
|
+
return '';
|
|
559
|
+
}
|
|
560
|
+
const start = offset - 1; // offset is >= 1 (clamped by the caller)
|
|
561
|
+
if (start >= total) {
|
|
562
|
+
return `[read_file: offset ${offset} is past end of file (${total} lines total)]`;
|
|
563
|
+
}
|
|
564
|
+
return lines.slice(start).join('\n');
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Apply the read safety envelope (GS2-39) to already-selected file content:
|
|
568
|
+
* - any single line longer than MAX_LINE_LENGTH is truncated with LINE_TRUNCATED_SUFFIX;
|
|
569
|
+
* - emission stops once `maxLines` lines OR MAX_READ_BYTES bytes have been produced.
|
|
570
|
+
*
|
|
571
|
+
* When nothing exceeds the caps the content is returned verbatim (byte-identical), so normal
|
|
572
|
+
* files behave exactly as before ā the change only bites on pathological / huge files.
|
|
573
|
+
*
|
|
574
|
+
* Three truncation signals are kept deliberately separate:
|
|
575
|
+
* - a per-line cut is marked only by the inline LINE_TRUNCATED_SUFFIX (no global notice);
|
|
576
|
+
* - the BYTE cap always appends the global "output truncated" notice, because it is the hard
|
|
577
|
+
* safety envelope regardless of who asked for the read;
|
|
578
|
+
* - the LINE cap appends the global notice only when `lineCapIsHard` is true ā i.e. `maxLines`
|
|
579
|
+
* is the hard MAX_READ_LINES envelope (a defaulted or clamped-down window, or the whole-file
|
|
580
|
+
* read), NOT a deliberate small explicit `limit`. This distinguishes a *capped continuation*
|
|
581
|
+
* (notify so the model can page) from a *satisfied explicit window* (stay quiet).
|
|
582
|
+
*
|
|
583
|
+
* `startLine` is the 1-based file line of the first line in `content`; it is used only to put a
|
|
584
|
+
* concrete resume `offset` in the global notice. Pass `undefined` (head/tail paths, where the
|
|
585
|
+
* first emitted line is not line `startLine` of the file) to emit a generic notice instead.
|
|
586
|
+
*/
|
|
587
|
+
capReadContent(content, maxLines, startLine, lineCapIsHard) {
|
|
588
|
+
const lines = content.split('\n');
|
|
589
|
+
const withinBytes = Buffer.byteLength(content, 'utf-8') <= MAX_READ_BYTES;
|
|
590
|
+
const withinLines = lines.length <= maxLines;
|
|
591
|
+
const noLongLine = lines.every((line) => line.length <= MAX_LINE_LENGTH);
|
|
592
|
+
if (withinBytes && withinLines && noLongLine) {
|
|
593
|
+
return content;
|
|
594
|
+
}
|
|
595
|
+
const out = [];
|
|
596
|
+
let bytes = 0;
|
|
597
|
+
let stoppedByLine = false;
|
|
598
|
+
let stoppedByByte = false;
|
|
599
|
+
for (const rawLine of lines) {
|
|
600
|
+
if (out.length >= maxLines) {
|
|
601
|
+
stoppedByLine = true;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
const line = rawLine.length > MAX_LINE_LENGTH
|
|
605
|
+
? rawLine.slice(0, MAX_LINE_LENGTH) + LINE_TRUNCATED_SUFFIX
|
|
606
|
+
: rawLine;
|
|
607
|
+
// +1 accounts for the '\n' that re-joins this line to the previous one. Always emit at
|
|
608
|
+
// least one (already per-line-truncated) line so the result is never empty.
|
|
609
|
+
const size = Buffer.byteLength(line, 'utf-8') + (out.length > 0 ? 1 : 0);
|
|
610
|
+
if (out.length > 0 && bytes + size > MAX_READ_BYTES) {
|
|
611
|
+
stoppedByByte = true;
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
out.push(line);
|
|
615
|
+
bytes += size;
|
|
616
|
+
}
|
|
617
|
+
let result = out.join('\n');
|
|
618
|
+
// The byte cap is a hard envelope and always warrants a notice; the line cap warrants one only
|
|
619
|
+
// when it represents the hard MAX_READ_LINES envelope, not a deliberate explicit `limit`.
|
|
620
|
+
if (stoppedByByte || (stoppedByLine && lineCapIsHard)) {
|
|
621
|
+
const nextOffset = startLine !== undefined ? startLine + out.length : undefined;
|
|
622
|
+
const resume = nextOffset !== undefined
|
|
623
|
+
? `resume with offset:${nextOffset} (and an optional limit)`
|
|
624
|
+
: 'use offset/limit to page through the rest';
|
|
625
|
+
result += `\n... [read_file: output truncated at the ${MAX_READ_LINES}-line / ${MAX_READ_BYTES / 1024} KB read cap ā ${resume}] ...`;
|
|
626
|
+
}
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* GS2-36 ā turn a fatal tool throw into a recoverable, action-oriented errored result.
|
|
631
|
+
*
|
|
632
|
+
* A filesystem tool that throws (a denied/symlinked-out path from validatePath, a missing file,
|
|
633
|
+
* EISDIR, permission-denied, a directory-not-empty refusal, ā¦) otherwise propagates to
|
|
634
|
+
* GthAgentRunner as a fatal `Stream processing failed` and crashes the whole turn. The house
|
|
635
|
+
* convention (AGENTS.md: a tool result must never abort a run; the write_file precedent in
|
|
636
|
+
* `5892801a`) is to return the failure to the model as a plain string it can act on. This wraps
|
|
637
|
+
* that string in a uniform `Error <action>: <message>` shape ā the underlying fs/validation
|
|
638
|
+
* message (ENOENT / EISDIR / EACCES / "Access denied - ā¦") is itself the actionable detail, and
|
|
639
|
+
* the deliberately-thrown logical messages (edit_file no-match/ambiguous, "Directory not empty",
|
|
640
|
+
* "Cannot delete protected directory") are already phrased as recovery hints. EXT-14 containment
|
|
641
|
+
* is preserved everywhere: validatePath runs first and throws on a symlink-out BEFORE any fs
|
|
642
|
+
* mutation, so the mutation never happens ā the denial is merely surfaced as a result here.
|
|
643
|
+
*/
|
|
644
|
+
recoverableError(action, error) {
|
|
645
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
646
|
+
return `Error ${action}: ${message}`;
|
|
647
|
+
}
|
|
409
648
|
createReadBinaryTool() {
|
|
410
649
|
return createGthTool(async (args) => {
|
|
411
650
|
if (!this.binaryFormats || !Array.isArray(this.binaryFormats)) {
|
|
412
651
|
return 'Binary formats are not configured. Add binaryFormats to your config to enable this feature.';
|
|
413
652
|
}
|
|
414
|
-
displayInfo(`\nš Reading binary file: ${args.path}\n`);
|
|
415
653
|
let validPath;
|
|
416
654
|
try {
|
|
417
655
|
validPath = await this.validatePath(args.path);
|
|
@@ -468,35 +706,97 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
468
706
|
createTools() {
|
|
469
707
|
const tools = [
|
|
470
708
|
createGthTool(async (args) => {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
709
|
+
// TUI-C32 residual b ā the legacy `š Reading file: ā¦` notice is dropped: the surface-
|
|
710
|
+
// agnostic tool-call block (`ā š read_file(path=ā¦)`, TUI-C30) now announces the op once
|
|
711
|
+
// on both surfaces, so the old notice was a DL-10 double announcement.
|
|
712
|
+
const hasWindow = args.offset !== undefined || args.limit !== undefined;
|
|
713
|
+
const hasHeadTail = Boolean(args.head) || Boolean(args.tail);
|
|
714
|
+
// Argument guard first (pure, no filesystem access): the line window (offset/limit) and
|
|
715
|
+
// head/tail are two different slice mechanisms, so combining them is ambiguous. Return a
|
|
716
|
+
// recoverable string the model can act on rather than throwing ā a thrown tool result
|
|
717
|
+
// aborts the whole agent run (the GS2-36 anti-pattern).
|
|
718
|
+
if (hasWindow && hasHeadTail) {
|
|
719
|
+
return ('read_file: pass a line window (offset/limit) OR head/tail, not both. ' +
|
|
720
|
+
'Use offset/limit to read an arbitrary range (offset = 1-based first line, ' +
|
|
721
|
+
'limit = number of lines); use head/tail for the first/last N lines.');
|
|
475
722
|
}
|
|
476
|
-
|
|
477
|
-
|
|
723
|
+
// GS2-36: a missing file, a denied / symlinked-out path (validatePath throws before any
|
|
724
|
+
// read), or a read failure (EISDIR, EACCES) becomes a recoverable errored result the
|
|
725
|
+
// model can act on, instead of a fatal `Stream processing failed` that aborts the run.
|
|
726
|
+
try {
|
|
727
|
+
const validPath = await this.validatePath(args.path);
|
|
728
|
+
// Line window (offset/limit). offset defaults to 1; limit defaults to and is hard-capped
|
|
729
|
+
// at MAX_READ_LINES. windowFile returns from offset to EOF and capReadContent (maxLines =
|
|
730
|
+
// limit) does the slicing, so a limit-bounded read that leaves more file behind is
|
|
731
|
+
// *observable* and gets a resume notice. That notice fires only when the limit is the
|
|
732
|
+
// hard envelope ā defaulted, or an explicit limit clamped down to MAX_READ_LINES ā so a
|
|
733
|
+
// deliberately small explicit window (fully satisfied) stays quiet.
|
|
734
|
+
if (hasWindow) {
|
|
735
|
+
const offset = args.offset !== undefined ? Math.max(1, Math.trunc(args.offset)) : 1;
|
|
736
|
+
const limitWasDefaulted = args.limit === undefined;
|
|
737
|
+
const limitWasClamped = args.limit !== undefined && Math.trunc(args.limit) > MAX_READ_LINES;
|
|
738
|
+
const limit = limitWasDefaulted
|
|
739
|
+
? MAX_READ_LINES
|
|
740
|
+
: Math.min(Math.max(1, Math.trunc(args.limit)), MAX_READ_LINES);
|
|
741
|
+
const lineCapIsHard = limitWasDefaulted || limitWasClamped;
|
|
742
|
+
const windowed = await this.windowFile(validPath, offset);
|
|
743
|
+
return this.capReadContent(windowed, limit, offset, lineCapIsHard);
|
|
744
|
+
}
|
|
745
|
+
// head/tail paths respect the explicit line count the model asked for (maxLines =
|
|
746
|
+
// Infinity, so the line cap never fires ā lineCapIsHard is moot), but still get the
|
|
747
|
+
// per-line and byte safety envelope ā e.g. a `tail 1` that returns one multi-MB minified
|
|
748
|
+
// line is still truncated. No resume offset is emitted for these paths because the first
|
|
749
|
+
// emitted line is not line 1 of the file.
|
|
750
|
+
if (args.head && args.tail) {
|
|
751
|
+
return this.capReadContent(await this.headAndTailFile(validPath, args.head, args.tail), Number.POSITIVE_INFINITY, undefined, false);
|
|
752
|
+
}
|
|
753
|
+
if (args.tail) {
|
|
754
|
+
return this.capReadContent(await this.tailFile(validPath, args.tail), Number.POSITIVE_INFINITY, undefined, false);
|
|
755
|
+
}
|
|
756
|
+
if (args.head) {
|
|
757
|
+
return this.capReadContent(await this.headFile(validPath, args.head), Number.POSITIVE_INFINITY, undefined, false);
|
|
758
|
+
}
|
|
759
|
+
// Default whole-file read: capped at MAX_READ_LINES / MAX_READ_BYTES with per-line
|
|
760
|
+
// truncation (the line cap here IS the hard envelope). Files under the caps come back
|
|
761
|
+
// byte-identical to the old uncapped read.
|
|
762
|
+
return this.capReadContent(await fs.readFile(validPath, 'utf-8'), MAX_READ_LINES, 1, true);
|
|
478
763
|
}
|
|
479
|
-
|
|
480
|
-
return
|
|
764
|
+
catch (error) {
|
|
765
|
+
return this.recoverableError(`reading file ${args.path}`, error);
|
|
481
766
|
}
|
|
482
|
-
return await fs.readFile(validPath, 'utf-8');
|
|
483
767
|
}, {
|
|
484
768
|
name: 'read_file',
|
|
485
769
|
description: 'Read the complete contents of a file from the file system. ' +
|
|
486
770
|
'Handles various text encodings and provides detailed error messages ' +
|
|
487
771
|
'if the file cannot be read. Use this tool when you need to examine ' +
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
772
|
+
'the contents of a single file. ' +
|
|
773
|
+
`Large reads are capped for safety: at most ${MAX_READ_LINES} lines or ` +
|
|
774
|
+
`${MAX_READ_BYTES / 1024} KB are returned, and any line longer than ${MAX_LINE_LENGTH} ` +
|
|
775
|
+
'characters is truncated with a `... (line truncated to N chars)` marker; when a read ' +
|
|
776
|
+
'is capped a `... [read_file: output truncated ...] ...` notice is appended. ' +
|
|
777
|
+
'To read past the cap or fetch an arbitrary range, use `offset` (1-based first line) ' +
|
|
778
|
+
'and `limit` (number of lines). ' +
|
|
779
|
+
"Alternatively use the 'head' parameter to read only the first N lines of a file, or " +
|
|
780
|
+
"the 'tail' parameter to read only the last N lines; 'head' and 'tail' may be combined " +
|
|
781
|
+
'to get the first N lines, a `... [N lines skipped] ...` marker, and the last M lines. ' +
|
|
782
|
+
'The offset/limit line window and head/tail are mutually exclusive ā pass one or the ' +
|
|
783
|
+
'other, not both. Only works within allowed directories.',
|
|
491
784
|
schema: ReadFileArgsSchema,
|
|
492
785
|
}, 'read'),
|
|
493
786
|
createGthTool(async (args) => {
|
|
494
|
-
displayInfo(`\nš Reading ${args.paths.length} files\n`);
|
|
495
787
|
const results = await Promise.all(args.paths.map(async (filePath) => {
|
|
496
788
|
try {
|
|
497
789
|
const validPath = await this.validatePath(filePath);
|
|
498
790
|
const content = await fs.readFile(validPath, 'utf-8');
|
|
499
|
-
|
|
791
|
+
// Apply the same whole-file read safety envelope as read_file's default path
|
|
792
|
+
// (GS2-39/GS2-52): a single oversized file ā or a one-line minified bundle ā in a
|
|
793
|
+
// multi-file read must not blow the context window. maxLines = MAX_READ_LINES,
|
|
794
|
+
// startLine = 1, lineCapIsHard = true mirror read_file's whole-file read, so a
|
|
795
|
+
// capped file carries the "resume with offset:N" notice; the model pages the rest
|
|
796
|
+
// with read_file on that path. Sub-cap files come back byte-identical (verbatim
|
|
797
|
+
// fast-path). There is no offset/limit here on purpose ā read_file is the pager.
|
|
798
|
+
const capped = this.capReadContent(content, MAX_READ_LINES, 1, true);
|
|
799
|
+
return `${filePath}:\n${capped}\n`;
|
|
500
800
|
}
|
|
501
801
|
catch (error) {
|
|
502
802
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -510,25 +810,61 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
510
810
|
'efficient than reading files one by one when you need to analyze ' +
|
|
511
811
|
"or compare multiple files. Each file's content is returned with its " +
|
|
512
812
|
"path as a reference. Failed reads for individual files won't stop " +
|
|
513
|
-
'the entire operation.
|
|
813
|
+
'the entire operation. ' +
|
|
814
|
+
`Each file is subject to the same per-file read cap as read_file: at most ${MAX_READ_LINES} ` +
|
|
815
|
+
`lines or ${MAX_READ_BYTES / 1024} KB are returned and any line longer than ` +
|
|
816
|
+
`${MAX_LINE_LENGTH} characters is truncated; when a file is capped a ` +
|
|
817
|
+
'`... [read_file: output truncated ...] ...` notice is appended ā page past it by ' +
|
|
818
|
+
'reading that file individually with read_file (offset/limit). ' +
|
|
819
|
+
'Only works within allowed directories.',
|
|
514
820
|
schema: ReadMultipleFilesArgsSchema,
|
|
515
821
|
}, 'read'),
|
|
516
822
|
createGthTool(async (args) => {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
823
|
+
// TUI-C32 residual b ā legacy `š Writing file: ā¦` notice dropped (see read_file above);
|
|
824
|
+
// the TUI-C30 tool-call block announces the write once on both surfaces.
|
|
825
|
+
try {
|
|
826
|
+
const validPath = await this.validatePath(args.path);
|
|
827
|
+
// Create any missing parent directories (mkdir -p) so a write into a
|
|
828
|
+
// not-yet-created subdirectory succeeds instead of throwing ENOENT. The
|
|
829
|
+
// model expects the near-universal agent-tool `mkdir -p` semantics and
|
|
830
|
+
// routinely writes a new file before creating its dir. validatePath has
|
|
831
|
+
// already confirmed the nearest existing ancestor is inside the sandbox,
|
|
832
|
+
// so the new directories are created within an allowed root.
|
|
833
|
+
await fs.mkdir(path.dirname(validPath), { recursive: true });
|
|
834
|
+
await fs.writeFile(validPath, args.content, 'utf-8');
|
|
835
|
+
return `Successfully wrote to ${args.path}`;
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
// Surface the failure to the model as a recoverable tool result rather
|
|
839
|
+
// than throwing, which would abort the entire agent run (GS2-36). The
|
|
840
|
+
// model can read the message and adjust (e.g. the path is a directory,
|
|
841
|
+
// or is denied by the sandbox) instead of the session dying.
|
|
842
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
843
|
+
return `Error writing file ${args.path}: ${message}`;
|
|
844
|
+
}
|
|
521
845
|
}, {
|
|
522
846
|
name: 'write_file',
|
|
523
847
|
description: 'Create a new file or completely overwrite an existing file with new content. ' +
|
|
848
|
+
'Missing parent directories are created automatically. ' +
|
|
524
849
|
'Use with caution as it will overwrite existing files without warning. ' +
|
|
525
850
|
'Handles text content with proper encoding. Only works within allowed directories.',
|
|
526
851
|
schema: WriteFileArgsSchema,
|
|
527
852
|
}, 'write'),
|
|
528
853
|
createGthTool(async (args) => {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
854
|
+
// TUI-C32 residual b ā legacy `š Editing file: ā¦` notice dropped (see read_file above);
|
|
855
|
+
// the TUI-C30 tool-call block announces the edit once on both surfaces.
|
|
856
|
+
// GS2-36: a no-match / ambiguous edit, a missing file, a denied or symlinked-out path
|
|
857
|
+
// (validatePath throws BEFORE applyFileEdits mutates anything ā EXT-14 containment
|
|
858
|
+
// preserved), or a write failure becomes a recoverable errored result the model can act
|
|
859
|
+
// on, instead of a fatal throw that aborts the whole run. applyFileEdits' no-match /
|
|
860
|
+
// ambiguous messages are already phrased as action-oriented recovery hints.
|
|
861
|
+
try {
|
|
862
|
+
const validPath = await this.validatePath(args.path);
|
|
863
|
+
return await this.applyFileEdits(validPath, args.edits, args.dryRun);
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
return this.recoverableError(`editing file ${args.path}`, error);
|
|
867
|
+
}
|
|
532
868
|
}, {
|
|
533
869
|
name: 'edit_file',
|
|
534
870
|
description: 'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
|
|
@@ -542,10 +878,16 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
542
878
|
schema: EditFileArgsSchema,
|
|
543
879
|
}, 'write'),
|
|
544
880
|
createGthTool(async (args) => {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
881
|
+
// GS2-36: a denied / symlinked-out path or an mkdir failure (e.g. a file already exists at
|
|
882
|
+
// the path) becomes a recoverable errored result rather than a fatal run abort.
|
|
883
|
+
try {
|
|
884
|
+
const validPath = await this.validatePath(args.path);
|
|
885
|
+
await fs.mkdir(validPath, { recursive: true });
|
|
886
|
+
return `Successfully created directory ${args.path}`;
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
return this.recoverableError(`creating directory ${args.path}`, error);
|
|
890
|
+
}
|
|
549
891
|
}, {
|
|
550
892
|
name: 'create_directory',
|
|
551
893
|
description: 'Create a new directory or ensure a directory exists. Can create multiple ' +
|
|
@@ -555,17 +897,23 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
555
897
|
schema: CreateDirectoryArgsSchema,
|
|
556
898
|
}, 'write'),
|
|
557
899
|
createGthTool(async (args) => {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
900
|
+
// GS2-36: a missing directory, a denied / symlinked-out path, or a not-a-directory
|
|
901
|
+
// (ENOTDIR) error becomes a recoverable errored result rather than a fatal run abort.
|
|
902
|
+
try {
|
|
903
|
+
const validPath = await this.validatePath(args.path);
|
|
904
|
+
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
|
905
|
+
const aiignoreConfig = this.aiignoreConfig;
|
|
906
|
+
const filteredEntries = entries.filter((entry) => {
|
|
907
|
+
const fullPath = path.join(validPath, entry.name);
|
|
908
|
+
return !shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled);
|
|
909
|
+
});
|
|
910
|
+
return filteredEntries
|
|
911
|
+
.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
|
|
912
|
+
.join('\n');
|
|
913
|
+
}
|
|
914
|
+
catch (error) {
|
|
915
|
+
return this.recoverableError(`listing directory ${args.path}`, error);
|
|
916
|
+
}
|
|
569
917
|
}, {
|
|
570
918
|
name: 'list_directory',
|
|
571
919
|
description: 'Get a detailed listing of all files and directories in a specified path. ' +
|
|
@@ -575,9 +923,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
575
923
|
schema: ListDirectoryArgsSchema,
|
|
576
924
|
}, 'read'),
|
|
577
925
|
createGthTool(async (args) => {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
926
|
+
// GS2-36: a missing directory or denied path becomes a recoverable errored result rather
|
|
927
|
+
// than a fatal run abort. Only validatePath + readdir can throw fatally here (the per-entry
|
|
928
|
+
// stat below already has its own try/catch), so guard just those.
|
|
929
|
+
let validPath;
|
|
930
|
+
let entries;
|
|
931
|
+
try {
|
|
932
|
+
validPath = await this.validatePath(args.path);
|
|
933
|
+
entries = (await fs.readdir(validPath, { withFileTypes: true }));
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
return this.recoverableError(`listing directory ${args.path}`, error);
|
|
937
|
+
}
|
|
581
938
|
const aiignoreConfig = this.aiignoreConfig;
|
|
582
939
|
const filteredEntries = entries.filter((entry) => {
|
|
583
940
|
const fullPath = path.join(validPath, entry.name);
|
|
@@ -628,50 +985,69 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
628
985
|
schema: ListDirectoryWithSizesArgsSchema,
|
|
629
986
|
}, 'read'),
|
|
630
987
|
createGthTool(async (args) => {
|
|
631
|
-
displayInfo(`\nš Building directory tree: ${args.path}\n`);
|
|
632
988
|
const buildTree = async (currentPath) => {
|
|
633
989
|
const validPath = await this.validatePath(currentPath);
|
|
634
990
|
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
|
635
991
|
const result = [];
|
|
636
992
|
const aiignoreConfig = this.aiignoreConfig;
|
|
637
993
|
for (const entry of entries) {
|
|
994
|
+
// `.aiignore` is a privacy boundary, not an annotation: an ignored entry is dropped
|
|
995
|
+
// outright, exactly as list_directory / list_directory_with_sizes / search_files drop
|
|
996
|
+
// it. Emitting the entry with a marker still hands the model ā and therefore the
|
|
997
|
+
// provider ā the very filename `.aiignore` exists to hide, and a name is often the
|
|
998
|
+
// whole payload. Tested first so an ignored directory is neither named nor descended.
|
|
999
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
1000
|
+
if (shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled)) {
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
638
1003
|
const entryData = {
|
|
639
1004
|
name: entry.name,
|
|
640
1005
|
type: entry.isDirectory() ? 'directory' : 'file',
|
|
641
1006
|
};
|
|
1007
|
+
// Noise directories are listed but never walked. `ignored` is what tells the model
|
|
1008
|
+
// why such a directory carries no `children` ā without it an unwalked directory reads
|
|
1009
|
+
// as an empty one ā and that is the flag's only remaining purpose.
|
|
642
1010
|
if (IGNORED_DIRS.indexOf(entry.name) >= 0) {
|
|
643
1011
|
entryData.ignored = true;
|
|
644
1012
|
}
|
|
645
|
-
// Check if file should be ignored by aiignore
|
|
646
|
-
const fullPath = path.join(currentPath, entry.name);
|
|
647
|
-
const shouldIgnore = shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled);
|
|
648
|
-
if (shouldIgnore) {
|
|
649
|
-
entryData.ignored = true;
|
|
650
|
-
}
|
|
651
1013
|
if (entry.isDirectory() && !entryData.ignored) {
|
|
652
|
-
|
|
653
|
-
entryData.children = await buildTree(subPath);
|
|
1014
|
+
entryData.children = await buildTree(fullPath);
|
|
654
1015
|
}
|
|
655
1016
|
result.push(entryData);
|
|
656
1017
|
}
|
|
657
1018
|
return result;
|
|
658
1019
|
};
|
|
659
|
-
|
|
660
|
-
|
|
1020
|
+
// GS2-36: a missing directory, denied path, or a symlink-out encountered mid-walk
|
|
1021
|
+
// (buildTree calls validatePath on each level) becomes a recoverable errored result
|
|
1022
|
+
// rather than a fatal run abort.
|
|
1023
|
+
try {
|
|
1024
|
+
const treeData = await buildTree(args.path);
|
|
1025
|
+
return JSON.stringify(treeData, null, 2);
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
return this.recoverableError(`building directory tree for ${args.path}`, error);
|
|
1029
|
+
}
|
|
661
1030
|
}, {
|
|
662
1031
|
name: 'directory_tree',
|
|
663
1032
|
description: 'Get a recursive tree view of files and directories as a JSON structure. ' +
|
|
664
1033
|
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
|
|
665
|
-
|
|
1034
|
+
"Files have no children array; a directory has one (possibly empty) unless it carries 'ignored': true, " +
|
|
1035
|
+
'which means the directory was listed but not walked, so its contents are unknown rather than absent. ' +
|
|
666
1036
|
'The output is formatted with 2-space indentation for readability. Only works within allowed directories.',
|
|
667
1037
|
schema: DirectoryTreeArgsSchema,
|
|
668
1038
|
}, 'read'),
|
|
669
1039
|
createGthTool(async (args) => {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1040
|
+
// GS2-36: a missing source, an existing destination, or a denied / symlinked-out path
|
|
1041
|
+
// becomes a recoverable errored result rather than a fatal run abort.
|
|
1042
|
+
try {
|
|
1043
|
+
const validSourcePath = await this.validatePath(args.source);
|
|
1044
|
+
const validDestPath = await this.validatePath(args.destination);
|
|
1045
|
+
await fs.rename(validSourcePath, validDestPath);
|
|
1046
|
+
return `Successfully moved ${args.source} to ${args.destination}`;
|
|
1047
|
+
}
|
|
1048
|
+
catch (error) {
|
|
1049
|
+
return this.recoverableError(`moving ${args.source} to ${args.destination}`, error);
|
|
1050
|
+
}
|
|
675
1051
|
}, {
|
|
676
1052
|
name: 'move_file',
|
|
677
1053
|
description: 'Move or rename files and directories. Can move files between directories ' +
|
|
@@ -681,10 +1057,16 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
681
1057
|
schema: MoveFileArgsSchema,
|
|
682
1058
|
}, 'write'),
|
|
683
1059
|
createGthTool(async (args) => {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
1060
|
+
// GS2-36: a missing or denied root path becomes a recoverable errored result rather than a
|
|
1061
|
+
// fatal run abort (searchFiles already swallows per-entry errors internally).
|
|
1062
|
+
try {
|
|
1063
|
+
const validPath = await this.validatePath(args.path);
|
|
1064
|
+
const results = await this.searchFiles(validPath, args.pattern, args.excludePatterns);
|
|
1065
|
+
return results.length > 0 ? results.join('\n') : 'No matches found';
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
return this.recoverableError(`searching ${args.path}`, error);
|
|
1069
|
+
}
|
|
688
1070
|
}, {
|
|
689
1071
|
name: 'search_files',
|
|
690
1072
|
description: 'Recursively search for files and directories matching a pattern. ' +
|
|
@@ -695,12 +1077,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
695
1077
|
schema: SearchFilesArgsSchema,
|
|
696
1078
|
}, 'read'),
|
|
697
1079
|
createGthTool(async (args) => {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
.
|
|
1080
|
+
// GS2-36: a missing file or denied path becomes a recoverable errored result rather than a
|
|
1081
|
+
// fatal run abort.
|
|
1082
|
+
try {
|
|
1083
|
+
const validPath = await this.validatePath(args.path);
|
|
1084
|
+
const info = await this.getFileStats(validPath);
|
|
1085
|
+
return Object.entries(info)
|
|
1086
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
1087
|
+
.join('\n');
|
|
1088
|
+
}
|
|
1089
|
+
catch (error) {
|
|
1090
|
+
return this.recoverableError(`getting file info for ${args.path}`, error);
|
|
1091
|
+
}
|
|
704
1092
|
}, {
|
|
705
1093
|
name: 'get_file_info',
|
|
706
1094
|
description: 'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
|
|
@@ -710,14 +1098,20 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
710
1098
|
schema: GetFileInfoArgsSchema,
|
|
711
1099
|
}, 'read'),
|
|
712
1100
|
createGthTool(async (args) => {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1101
|
+
// GS2-36: a missing file, a denied path, or the "this is a directory" refusal becomes a
|
|
1102
|
+
// recoverable errored result the model can act on, rather than a fatal run abort.
|
|
1103
|
+
try {
|
|
1104
|
+
const validPath = await this.validatePath(args.path);
|
|
1105
|
+
const stats = await fs.stat(validPath);
|
|
1106
|
+
if (stats.isDirectory()) {
|
|
1107
|
+
throw new Error(`Cannot delete directory: ${args.path}. Use rmdir or a recursive delete tool for directories.`);
|
|
1108
|
+
}
|
|
1109
|
+
await fs.unlink(validPath);
|
|
1110
|
+
return `Successfully deleted file: ${args.path}`;
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
return this.recoverableError(`deleting file ${args.path}`, error);
|
|
1114
|
+
}
|
|
721
1115
|
}, {
|
|
722
1116
|
name: 'delete_file',
|
|
723
1117
|
description: 'Delete a file from the filesystem. This operation cannot be undone. ' +
|
|
@@ -726,28 +1120,35 @@ export default class GthFileSystemToolkit extends BaseToolkit {
|
|
|
726
1120
|
schema: DeleteFileArgsSchema,
|
|
727
1121
|
}, 'write'),
|
|
728
1122
|
createGthTool(async (args) => {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
throw new Error(`Not a directory: ${args.path}. Use delete_file for files.`);
|
|
738
|
-
}
|
|
739
|
-
if (args.recursive) {
|
|
740
|
-
await fs.rm(validPath, { recursive: true, force: true });
|
|
741
|
-
return `Successfully deleted directory and all contents: ${args.path}`;
|
|
742
|
-
}
|
|
743
|
-
else {
|
|
744
|
-
// For non-recursive delete, check if directory is empty
|
|
745
|
-
const entries = await fs.readdir(validPath);
|
|
746
|
-
if (entries.length > 0) {
|
|
747
|
-
throw new Error(`Directory not empty: ${args.path}. Use recursive: true to delete non-empty directories.`);
|
|
1123
|
+
// GS2-36: a missing directory, a denied path, the protected-root / not-a-directory /
|
|
1124
|
+
// not-empty refusals all become recoverable errored results the model can act on, rather
|
|
1125
|
+
// than a fatal run abort. Each refusal message is already an action-oriented hint.
|
|
1126
|
+
try {
|
|
1127
|
+
const validPath = await this.validatePath(args.path);
|
|
1128
|
+
// Check if this is a protected directory
|
|
1129
|
+
if (this.isProtectedDirectory(validPath)) {
|
|
1130
|
+
throw new Error(`Cannot delete protected directory: ${args.path}. This is one of the allowed root directories.`);
|
|
748
1131
|
}
|
|
749
|
-
await fs.
|
|
750
|
-
|
|
1132
|
+
const stats = await fs.stat(validPath);
|
|
1133
|
+
if (!stats.isDirectory()) {
|
|
1134
|
+
throw new Error(`Not a directory: ${args.path}. Use delete_file for files.`);
|
|
1135
|
+
}
|
|
1136
|
+
if (args.recursive) {
|
|
1137
|
+
await fs.rm(validPath, { recursive: true, force: true });
|
|
1138
|
+
return `Successfully deleted directory and all contents: ${args.path}`;
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
// For non-recursive delete, check if directory is empty
|
|
1142
|
+
const entries = await fs.readdir(validPath);
|
|
1143
|
+
if (entries.length > 0) {
|
|
1144
|
+
throw new Error(`Directory not empty: ${args.path}. Use recursive: true to delete non-empty directories.`);
|
|
1145
|
+
}
|
|
1146
|
+
await fs.rmdir(validPath);
|
|
1147
|
+
return `Successfully deleted empty directory: ${args.path}`;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
return this.recoverableError(`deleting directory ${args.path}`, error);
|
|
751
1152
|
}
|
|
752
1153
|
}, {
|
|
753
1154
|
name: 'delete_directory',
|