@gaunt-sloth/agent 2.0.0-alpha.23 → 2.0.0-alpha.25

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.
Files changed (56) hide show
  1. package/README.md +73 -24
  2. package/dist/core/GthDeepAgent.d.ts +3 -3
  3. package/dist/core/GthDeepAgent.js +82 -35
  4. package/dist/core/GthDeepAgent.js.map +1 -1
  5. package/dist/core/debugCapture.d.ts +1 -1
  6. package/dist/core/debugCapture.js.map +1 -1
  7. package/dist/core/subagentProfiles.d.ts +50 -0
  8. package/dist/core/subagentProfiles.js +81 -0
  9. package/dist/core/subagentProfiles.js.map +1 -0
  10. package/dist/middleware/binaryContentInjectionMiddleware.d.ts +8 -1
  11. package/dist/middleware/binaryContentInjectionMiddleware.js +11 -2
  12. package/dist/middleware/binaryContentInjectionMiddleware.js.map +1 -1
  13. package/dist/middleware/frontendImageInjectionMiddleware.d.ts +80 -0
  14. package/dist/middleware/frontendImageInjectionMiddleware.js +146 -0
  15. package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -0
  16. package/dist/middleware/registry.js +36 -1
  17. package/dist/middleware/registry.js.map +1 -1
  18. package/dist/middleware/types.d.ts +16 -2
  19. package/dist/modules/a2a/A2AClientWrapper.d.ts +15 -6
  20. package/dist/modules/a2a/A2AClientWrapper.js +93 -64
  21. package/dist/modules/a2a/A2AClientWrapper.js.map +1 -1
  22. package/dist/modules/apiAgUiModule.d.ts +68 -0
  23. package/dist/modules/apiAgUiModule.js +95 -4
  24. package/dist/modules/apiAgUiModule.js.map +1 -1
  25. package/dist/modules/interactiveSessionModule.js +199 -43
  26. package/dist/modules/interactiveSessionModule.js.map +1 -1
  27. package/dist/modules/slashCommands.d.ts +467 -0
  28. package/dist/modules/slashCommands.js +888 -0
  29. package/dist/modules/slashCommands.js.map +1 -0
  30. package/dist/resolvers.js +15 -0
  31. package/dist/resolvers.js.map +1 -1
  32. package/dist/tools/GthCustomToolkit.js +72 -3
  33. package/dist/tools/GthCustomToolkit.js.map +1 -1
  34. package/dist/tools/GthDevToolkit.d.ts +6 -3
  35. package/dist/tools/GthDevToolkit.js +31 -11
  36. package/dist/tools/GthDevToolkit.js.map +1 -1
  37. package/dist/tools/GthFileSystemToolkit.d.ts +16 -0
  38. package/dist/tools/GthFileSystemToolkit.js +218 -110
  39. package/dist/tools/GthFileSystemToolkit.js.map +1 -1
  40. package/dist/tools/McpResourceTool.d.ts +31 -0
  41. package/dist/tools/McpResourceTool.js +106 -0
  42. package/dist/tools/McpResourceTool.js.map +1 -0
  43. package/dist/tools/shell/env.js +1 -1
  44. package/dist/tools/shell/hardline.d.ts +33 -2
  45. package/dist/tools/shell/hardline.js +525 -40
  46. package/dist/tools/shell/hardline.js.map +1 -1
  47. package/package.json +8 -8
  48. package/dist/tools/shell/allowlist.d.ts +0 -11
  49. package/dist/tools/shell/allowlist.js +0 -12
  50. package/dist/tools/shell/allowlist.js.map +0 -1
  51. package/dist/tools/shell/arity.d.ts +0 -11
  52. package/dist/tools/shell/arity.js +0 -12
  53. package/dist/tools/shell/arity.js.map +0 -1
  54. package/dist/tools/shell/normalize.d.ts +0 -10
  55. package/dist/tools/shell/normalize.js +0 -11
  56. 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';
@@ -345,9 +344,13 @@ export default class GthFileSystemToolkit extends BaseToolkit {
345
344
  const occurrences = this.countOccurrences(modifiedContent, normalizedOld);
346
345
  if (occurrences > 0) {
347
346
  if (occurrences > 1 && edit.replaceAll !== true) {
348
- // Ambiguous match: refuse rather than silently editing the first occurrence.
349
- throw new Error(`Found ${occurrences} occurrences of the oldText in ${filePath}. ` +
350
- 'Provide more surrounding context to make it unique, or set replaceAll: true.\n' +
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' +
351
354
  edit.oldText);
352
355
  }
353
356
  // split/join replaces every occurrence without regex escaping and, unlike
@@ -387,7 +390,14 @@ export default class GthFileSystemToolkit extends BaseToolkit {
387
390
  }
388
391
  }
389
392
  if (!matchFound) {
390
- throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
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);
391
401
  }
392
402
  // Fuzzy (trim-based line) match applied: flag it so the model knows the edit
393
403
  // landed on a near-match, not an exact one.
@@ -616,12 +626,30 @@ export default class GthFileSystemToolkit extends BaseToolkit {
616
626
  }
617
627
  return result;
618
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
+ }
619
648
  createReadBinaryTool() {
620
649
  return createGthTool(async (args) => {
621
650
  if (!this.binaryFormats || !Array.isArray(this.binaryFormats)) {
622
651
  return 'Binary formats are not configured. Add binaryFormats to your config to enable this feature.';
623
652
  }
624
- displayInfo(`\n📁 Reading binary file: ${args.path}\n`);
625
653
  let validPath;
626
654
  try {
627
655
  validPath = await this.validatePath(args.path);
@@ -678,7 +706,9 @@ export default class GthFileSystemToolkit extends BaseToolkit {
678
706
  createTools() {
679
707
  const tools = [
680
708
  createGthTool(async (args) => {
681
- displayInfo(`\n📁 Reading file: ${args.path}\n`);
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.
682
712
  const hasWindow = args.offset !== undefined || args.limit !== undefined;
683
713
  const hasHeadTail = Boolean(args.head) || Boolean(args.tail);
684
714
  // Argument guard first (pure, no filesystem access): the line window (offset/limit) and
@@ -690,42 +720,50 @@ export default class GthFileSystemToolkit extends BaseToolkit {
690
720
  'Use offset/limit to read an arbitrary range (offset = 1-based first line, ' +
691
721
  'limit = number of lines); use head/tail for the first/last N lines.');
692
722
  }
693
- const validPath = await this.validatePath(args.path);
694
- // Line window (offset/limit). offset defaults to 1; limit defaults to and is hard-capped
695
- // at MAX_READ_LINES. windowFile returns from offset to EOF and capReadContent (maxLines =
696
- // limit) does the slicing, so a limit-bounded read that leaves more file behind is
697
- // *observable* and gets a resume notice. That notice fires only when the limit is the
698
- // hard envelope defaulted, or an explicit limit clamped down to MAX_READ_LINES so a
699
- // deliberately small explicit window (fully satisfied) stays quiet.
700
- if (hasWindow) {
701
- const offset = args.offset !== undefined ? Math.max(1, Math.trunc(args.offset)) : 1;
702
- const limitWasDefaulted = args.limit === undefined;
703
- const limitWasClamped = args.limit !== undefined && Math.trunc(args.limit) > MAX_READ_LINES;
704
- const limit = limitWasDefaulted
705
- ? MAX_READ_LINES
706
- : Math.min(Math.max(1, Math.trunc(args.limit)), MAX_READ_LINES);
707
- const lineCapIsHard = limitWasDefaulted || limitWasClamped;
708
- const windowed = await this.windowFile(validPath, offset);
709
- return this.capReadContent(windowed, limit, offset, lineCapIsHard);
710
- }
711
- // head/tail paths respect the explicit line count the model asked for (maxLines =
712
- // Infinity, so the line cap never fires — lineCapIsHard is moot), but still get the
713
- // per-line and byte safety envelope — e.g. a `tail 1` that returns one multi-MB minified
714
- // line is still truncated. No resume offset is emitted for these paths because the first
715
- // emitted line is not line 1 of the file.
716
- if (args.head && args.tail) {
717
- return this.capReadContent(await this.headAndTailFile(validPath, args.head, args.tail), Number.POSITIVE_INFINITY, undefined, false);
718
- }
719
- if (args.tail) {
720
- return this.capReadContent(await this.tailFile(validPath, args.tail), Number.POSITIVE_INFINITY, undefined, false);
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);
721
763
  }
722
- if (args.head) {
723
- return this.capReadContent(await this.headFile(validPath, args.head), Number.POSITIVE_INFINITY, undefined, false);
764
+ catch (error) {
765
+ return this.recoverableError(`reading file ${args.path}`, error);
724
766
  }
725
- // Default whole-file read: capped at MAX_READ_LINES / MAX_READ_BYTES with per-line
726
- // truncation (the line cap here IS the hard envelope). Files under the caps come back
727
- // byte-identical to the old uncapped read.
728
- return this.capReadContent(await fs.readFile(validPath, 'utf-8'), MAX_READ_LINES, 1, true);
729
767
  }, {
730
768
  name: 'read_file',
731
769
  description: 'Read the complete contents of a file from the file system. ' +
@@ -746,7 +784,6 @@ export default class GthFileSystemToolkit extends BaseToolkit {
746
784
  schema: ReadFileArgsSchema,
747
785
  }, 'read'),
748
786
  createGthTool(async (args) => {
749
- displayInfo(`\n📁 Reading ${args.paths.length} files\n`);
750
787
  const results = await Promise.all(args.paths.map(async (filePath) => {
751
788
  try {
752
789
  const validPath = await this.validatePath(filePath);
@@ -783,7 +820,8 @@ export default class GthFileSystemToolkit extends BaseToolkit {
783
820
  schema: ReadMultipleFilesArgsSchema,
784
821
  }, 'read'),
785
822
  createGthTool(async (args) => {
786
- displayInfo(`\n📁 Writing file: ${args.path}\n`);
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.
787
825
  try {
788
826
  const validPath = await this.validatePath(args.path);
789
827
  // Create any missing parent directories (mkdir -p) so a write into a
@@ -813,9 +851,20 @@ export default class GthFileSystemToolkit extends BaseToolkit {
813
851
  schema: WriteFileArgsSchema,
814
852
  }, 'write'),
815
853
  createGthTool(async (args) => {
816
- displayInfo(`\n📁 Editing file: ${args.path}\n`);
817
- const validPath = await this.validatePath(args.path);
818
- return await this.applyFileEdits(validPath, args.edits, args.dryRun);
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
+ }
819
868
  }, {
820
869
  name: 'edit_file',
821
870
  description: 'Make line-based edits to a text file. Each edit replaces exact line sequences ' +
@@ -829,10 +878,16 @@ export default class GthFileSystemToolkit extends BaseToolkit {
829
878
  schema: EditFileArgsSchema,
830
879
  }, 'write'),
831
880
  createGthTool(async (args) => {
832
- displayInfo(`\n📁 Creating directory: ${args.path}\n`);
833
- const validPath = await this.validatePath(args.path);
834
- await fs.mkdir(validPath, { recursive: true });
835
- return `Successfully created directory ${args.path}`;
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
+ }
836
891
  }, {
837
892
  name: 'create_directory',
838
893
  description: 'Create a new directory or ensure a directory exists. Can create multiple ' +
@@ -842,17 +897,23 @@ export default class GthFileSystemToolkit extends BaseToolkit {
842
897
  schema: CreateDirectoryArgsSchema,
843
898
  }, 'write'),
844
899
  createGthTool(async (args) => {
845
- displayInfo(`\n📁 Listing directory: ${args.path}\n`);
846
- const validPath = await this.validatePath(args.path);
847
- const entries = await fs.readdir(validPath, { withFileTypes: true });
848
- const aiignoreConfig = this.aiignoreConfig;
849
- const filteredEntries = entries.filter((entry) => {
850
- const fullPath = path.join(validPath, entry.name);
851
- return !shouldIgnoreFile(fullPath, getCurrentWorkDir(), aiignoreConfig?.patterns, aiignoreConfig?.enabled);
852
- });
853
- return filteredEntries
854
- .map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`)
855
- .join('\n');
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
+ }
856
917
  }, {
857
918
  name: 'list_directory',
858
919
  description: 'Get a detailed listing of all files and directories in a specified path. ' +
@@ -862,9 +923,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
862
923
  schema: ListDirectoryArgsSchema,
863
924
  }, 'read'),
864
925
  createGthTool(async (args) => {
865
- displayInfo(`\n📁 Listing directory with sizes: ${args.path}\n`);
866
- const validPath = await this.validatePath(args.path);
867
- const entries = await fs.readdir(validPath, { withFileTypes: true });
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
+ }
868
938
  const aiignoreConfig = this.aiignoreConfig;
869
939
  const filteredEntries = entries.filter((entry) => {
870
940
  const fullPath = path.join(validPath, entry.name);
@@ -915,7 +985,6 @@ export default class GthFileSystemToolkit extends BaseToolkit {
915
985
  schema: ListDirectoryWithSizesArgsSchema,
916
986
  }, 'read'),
917
987
  createGthTool(async (args) => {
918
- displayInfo(`\n📁 Building directory tree: ${args.path}\n`);
919
988
  const buildTree = async (currentPath) => {
920
989
  const validPath = await this.validatePath(currentPath);
921
990
  const entries = await fs.readdir(validPath, { withFileTypes: true });
@@ -943,8 +1012,16 @@ export default class GthFileSystemToolkit extends BaseToolkit {
943
1012
  }
944
1013
  return result;
945
1014
  };
946
- const treeData = await buildTree(args.path);
947
- return JSON.stringify(treeData, null, 2);
1015
+ // GS2-36: a missing directory, denied path, or a symlink-out encountered mid-walk
1016
+ // (buildTree calls validatePath on each level) becomes a recoverable errored result
1017
+ // rather than a fatal run abort.
1018
+ try {
1019
+ const treeData = await buildTree(args.path);
1020
+ return JSON.stringify(treeData, null, 2);
1021
+ }
1022
+ catch (error) {
1023
+ return this.recoverableError(`building directory tree for ${args.path}`, error);
1024
+ }
948
1025
  }, {
949
1026
  name: 'directory_tree',
950
1027
  description: 'Get a recursive tree view of files and directories as a JSON structure. ' +
@@ -954,11 +1031,17 @@ export default class GthFileSystemToolkit extends BaseToolkit {
954
1031
  schema: DirectoryTreeArgsSchema,
955
1032
  }, 'read'),
956
1033
  createGthTool(async (args) => {
957
- displayInfo(`\n📁 Moving ${args.source} to ${args.destination}\n`);
958
- const validSourcePath = await this.validatePath(args.source);
959
- const validDestPath = await this.validatePath(args.destination);
960
- await fs.rename(validSourcePath, validDestPath);
961
- return `Successfully moved ${args.source} to ${args.destination}`;
1034
+ // GS2-36: a missing source, an existing destination, or a denied / symlinked-out path
1035
+ // becomes a recoverable errored result rather than a fatal run abort.
1036
+ try {
1037
+ const validSourcePath = await this.validatePath(args.source);
1038
+ const validDestPath = await this.validatePath(args.destination);
1039
+ await fs.rename(validSourcePath, validDestPath);
1040
+ return `Successfully moved ${args.source} to ${args.destination}`;
1041
+ }
1042
+ catch (error) {
1043
+ return this.recoverableError(`moving ${args.source} to ${args.destination}`, error);
1044
+ }
962
1045
  }, {
963
1046
  name: 'move_file',
964
1047
  description: 'Move or rename files and directories. Can move files between directories ' +
@@ -968,10 +1051,16 @@ export default class GthFileSystemToolkit extends BaseToolkit {
968
1051
  schema: MoveFileArgsSchema,
969
1052
  }, 'write'),
970
1053
  createGthTool(async (args) => {
971
- displayInfo(`\n📁 Searching for '${args.pattern}' in ${args.path}\n`);
972
- const validPath = await this.validatePath(args.path);
973
- const results = await this.searchFiles(validPath, args.pattern, args.excludePatterns);
974
- return results.length > 0 ? results.join('\n') : 'No matches found';
1054
+ // GS2-36: a missing or denied root path becomes a recoverable errored result rather than a
1055
+ // fatal run abort (searchFiles already swallows per-entry errors internally).
1056
+ try {
1057
+ const validPath = await this.validatePath(args.path);
1058
+ const results = await this.searchFiles(validPath, args.pattern, args.excludePatterns);
1059
+ return results.length > 0 ? results.join('\n') : 'No matches found';
1060
+ }
1061
+ catch (error) {
1062
+ return this.recoverableError(`searching ${args.path}`, error);
1063
+ }
975
1064
  }, {
976
1065
  name: 'search_files',
977
1066
  description: 'Recursively search for files and directories matching a pattern. ' +
@@ -982,12 +1071,18 @@ export default class GthFileSystemToolkit extends BaseToolkit {
982
1071
  schema: SearchFilesArgsSchema,
983
1072
  }, 'read'),
984
1073
  createGthTool(async (args) => {
985
- displayInfo(`\n📁 Getting file info: ${args.path}\n`);
986
- const validPath = await this.validatePath(args.path);
987
- const info = await this.getFileStats(validPath);
988
- return Object.entries(info)
989
- .map(([key, value]) => `${key}: ${value}`)
990
- .join('\n');
1074
+ // GS2-36: a missing file or denied path becomes a recoverable errored result rather than a
1075
+ // fatal run abort.
1076
+ try {
1077
+ const validPath = await this.validatePath(args.path);
1078
+ const info = await this.getFileStats(validPath);
1079
+ return Object.entries(info)
1080
+ .map(([key, value]) => `${key}: ${value}`)
1081
+ .join('\n');
1082
+ }
1083
+ catch (error) {
1084
+ return this.recoverableError(`getting file info for ${args.path}`, error);
1085
+ }
991
1086
  }, {
992
1087
  name: 'get_file_info',
993
1088
  description: 'Retrieve detailed metadata about a file or directory. Returns comprehensive ' +
@@ -997,14 +1092,20 @@ export default class GthFileSystemToolkit extends BaseToolkit {
997
1092
  schema: GetFileInfoArgsSchema,
998
1093
  }, 'read'),
999
1094
  createGthTool(async (args) => {
1000
- displayInfo(`\n📁 Deleting file: ${args.path}\n`);
1001
- const validPath = await this.validatePath(args.path);
1002
- const stats = await fs.stat(validPath);
1003
- if (stats.isDirectory()) {
1004
- throw new Error(`Cannot delete directory: ${args.path}. Use rmdir or a recursive delete tool for directories.`);
1095
+ // GS2-36: a missing file, a denied path, or the "this is a directory" refusal becomes a
1096
+ // recoverable errored result the model can act on, rather than a fatal run abort.
1097
+ try {
1098
+ const validPath = await this.validatePath(args.path);
1099
+ const stats = await fs.stat(validPath);
1100
+ if (stats.isDirectory()) {
1101
+ throw new Error(`Cannot delete directory: ${args.path}. Use rmdir or a recursive delete tool for directories.`);
1102
+ }
1103
+ await fs.unlink(validPath);
1104
+ return `Successfully deleted file: ${args.path}`;
1105
+ }
1106
+ catch (error) {
1107
+ return this.recoverableError(`deleting file ${args.path}`, error);
1005
1108
  }
1006
- await fs.unlink(validPath);
1007
- return `Successfully deleted file: ${args.path}`;
1008
1109
  }, {
1009
1110
  name: 'delete_file',
1010
1111
  description: 'Delete a file from the filesystem. This operation cannot be undone. ' +
@@ -1013,28 +1114,35 @@ export default class GthFileSystemToolkit extends BaseToolkit {
1013
1114
  schema: DeleteFileArgsSchema,
1014
1115
  }, 'write'),
1015
1116
  createGthTool(async (args) => {
1016
- displayInfo(`\n📁 Deleting directory: ${args.path}${args.recursive ? ' (recursive)' : ''}\n`);
1017
- const validPath = await this.validatePath(args.path);
1018
- // Check if this is a protected directory
1019
- if (this.isProtectedDirectory(validPath)) {
1020
- throw new Error(`Cannot delete protected directory: ${args.path}. This is one of the allowed root directories.`);
1021
- }
1022
- const stats = await fs.stat(validPath);
1023
- if (!stats.isDirectory()) {
1024
- throw new Error(`Not a directory: ${args.path}. Use delete_file for files.`);
1025
- }
1026
- if (args.recursive) {
1027
- await fs.rm(validPath, { recursive: true, force: true });
1028
- return `Successfully deleted directory and all contents: ${args.path}`;
1029
- }
1030
- else {
1031
- // For non-recursive delete, check if directory is empty
1032
- const entries = await fs.readdir(validPath);
1033
- if (entries.length > 0) {
1034
- throw new Error(`Directory not empty: ${args.path}. Use recursive: true to delete non-empty directories.`);
1117
+ // GS2-36: a missing directory, a denied path, the protected-root / not-a-directory /
1118
+ // not-empty refusals all become recoverable errored results the model can act on, rather
1119
+ // than a fatal run abort. Each refusal message is already an action-oriented hint.
1120
+ try {
1121
+ const validPath = await this.validatePath(args.path);
1122
+ // Check if this is a protected directory
1123
+ if (this.isProtectedDirectory(validPath)) {
1124
+ throw new Error(`Cannot delete protected directory: ${args.path}. This is one of the allowed root directories.`);
1125
+ }
1126
+ const stats = await fs.stat(validPath);
1127
+ if (!stats.isDirectory()) {
1128
+ throw new Error(`Not a directory: ${args.path}. Use delete_file for files.`);
1129
+ }
1130
+ if (args.recursive) {
1131
+ await fs.rm(validPath, { recursive: true, force: true });
1132
+ return `Successfully deleted directory and all contents: ${args.path}`;
1035
1133
  }
1036
- await fs.rmdir(validPath);
1037
- return `Successfully deleted empty directory: ${args.path}`;
1134
+ else {
1135
+ // For non-recursive delete, check if directory is empty
1136
+ const entries = await fs.readdir(validPath);
1137
+ if (entries.length > 0) {
1138
+ throw new Error(`Directory not empty: ${args.path}. Use recursive: true to delete non-empty directories.`);
1139
+ }
1140
+ await fs.rmdir(validPath);
1141
+ return `Successfully deleted empty directory: ${args.path}`;
1142
+ }
1143
+ }
1144
+ catch (error) {
1145
+ return this.recoverableError(`deleting directory ${args.path}`, error);
1038
1146
  }
1039
1147
  }, {
1040
1148
  name: 'delete_directory',