yc1838 Claude Opus 4.7 commited on
Commit
96fa5bb
·
1 Parent(s): 9f0689c

fix: expand ~ in fs tools, accept absolute glob patterns, add find_files

Browse files

Spiral-fix triggered by 2026-04-27 GraphRecursionError(50): the agent
tried to locate a file under ~/code/Orbit-Daily, but read_file/ls/grep/
glob_files all passed tilde paths through unchanged, and Path.glob
rejects absolute patterns ("Non-relative patterns are unsupported"),
so the model burned its budget on manual ls tree-walking.

- expanduser on read_file, ls, grep, glob_files (write_file's sandbox
boundary intentionally still rejects absolute/escape paths).
- glob_files now uses stdlib glob.glob(recursive=True), which handles
absolute patterns and ** natively.
- New find_files tool shells out to unix `find -name` (30s timeout,
capped results) for fast deep search by filename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

src/lilith_agent/tools/__init__.py CHANGED
@@ -11,6 +11,7 @@ from lilith_agent.tools.files import (
11
  ls as _ls,
12
  grep as _grep,
13
  glob_files as _glob_files,
 
14
  write_file as _write_file
15
  )
16
  from lilith_agent.tools.todos import (
@@ -91,6 +92,11 @@ def build_tools(cfg: Config) -> list[BaseTool]:
91
  """Find files matching a glob pattern (e.g., '**/*.csv')."""
92
  return _glob_files(pattern)
93
 
 
 
 
 
 
94
  @tool
95
  def write_file(path: str, content: str) -> str:
96
  """Persist text to a file the user can open afterwards.
@@ -190,6 +196,7 @@ def build_tools(cfg: Config) -> list[BaseTool]:
190
  ls,
191
  grep,
192
  glob_files,
 
193
  write_file,
194
  write_todos,
195
  mark_todo_done,
 
11
  ls as _ls,
12
  grep as _grep,
13
  glob_files as _glob_files,
14
+ find_files as _find_files,
15
  write_file as _write_file
16
  )
17
  from lilith_agent.tools.todos import (
 
92
  """Find files matching a glob pattern (e.g., '**/*.csv')."""
93
  return _glob_files(pattern)
94
 
95
+ @tool
96
+ def find_files(name: str, root: str = ".", max_results: int = 200) -> str:
97
+ """Locate files by name under `root` using unix `find` (fast deep search). Use this instead of walking with `ls` when looking for a known filename in a large tree."""
98
+ return _find_files(name, root=root, max_results=max_results)
99
+
100
  @tool
101
  def write_file(path: str, content: str) -> str:
102
  """Persist text to a file the user can open afterwards.
 
196
  ls,
197
  grep,
198
  glob_files,
199
+ find_files,
200
  write_file,
201
  write_todos,
202
  mark_todo_done,
src/lilith_agent/tools/files.py CHANGED
@@ -1,4 +1,6 @@
1
- import fnmatch
 
 
2
  from pathlib import Path
3
  from typing import Optional
4
 
@@ -54,7 +56,7 @@ def read_file(
54
  Reads a file with optional line-based chunking.
55
  Used to prevent context overflow for large files.
56
  """
57
- p = Path(path)
58
  if not p.exists():
59
  return f"ERROR: File {path} does not exist."
60
  if not p.is_file():
@@ -102,7 +104,7 @@ def read_file(
102
 
103
  def ls(path: str = ".") -> str:
104
  """List contents of a directory."""
105
- p = Path(path)
106
  if not p.exists():
107
  return f"ERROR: Path {path} does not exist."
108
  if not p.is_dir():
@@ -120,7 +122,7 @@ def ls(path: str = ".") -> str:
120
 
121
  def grep(pattern: str, path: str, ignore_case: bool = True) -> str:
122
  """Search for a pattern in a file and return matching lines with line numbers."""
123
- p = Path(path)
124
  if not p.exists():
125
  return f"ERROR: File {path} does not exist."
126
 
@@ -144,21 +146,43 @@ def grep(pattern: str, path: str, ignore_case: bool = True) -> str:
144
 
145
  def glob_files(pattern: str, root_dir: str = ".") -> str:
146
  """Find files matching a glob pattern."""
147
- p = Path(root_dir)
 
148
  try:
149
- # Use rglob for recursive search if pattern starts with **/
150
- if pattern.startswith("**/"):
151
- matches = list(p.rglob(pattern[3:]))
152
  else:
153
- matches = list(p.glob(pattern))
154
-
155
  if not matches:
156
  return "No files found matching the pattern."
157
- return "\n".join(str(m) for m in sorted(matches))
158
  except Exception as e:
159
  return f"ERROR globbing {pattern}: {e}"
160
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  def write_file(path: str, content: str) -> str:
163
  """Write content to a file inside the sandboxed write root.
164
 
 
1
+ import glob as _glob
2
+ import os
3
+ import subprocess
4
  from pathlib import Path
5
  from typing import Optional
6
 
 
56
  Reads a file with optional line-based chunking.
57
  Used to prevent context overflow for large files.
58
  """
59
+ p = Path(os.path.expanduser(path))
60
  if not p.exists():
61
  return f"ERROR: File {path} does not exist."
62
  if not p.is_file():
 
104
 
105
  def ls(path: str = ".") -> str:
106
  """List contents of a directory."""
107
+ p = Path(os.path.expanduser(path))
108
  if not p.exists():
109
  return f"ERROR: Path {path} does not exist."
110
  if not p.is_dir():
 
122
 
123
  def grep(pattern: str, path: str, ignore_case: bool = True) -> str:
124
  """Search for a pattern in a file and return matching lines with line numbers."""
125
+ p = Path(os.path.expanduser(path))
126
  if not p.exists():
127
  return f"ERROR: File {path} does not exist."
128
 
 
146
 
147
  def glob_files(pattern: str, root_dir: str = ".") -> str:
148
  """Find files matching a glob pattern."""
149
+ pattern = os.path.expanduser(pattern)
150
+ root_dir = os.path.expanduser(root_dir)
151
  try:
152
+ if os.path.isabs(pattern):
153
+ matches = _glob.glob(pattern, recursive=True)
 
154
  else:
155
+ matches = _glob.glob(pattern, root_dir=root_dir, recursive=True)
156
+
157
  if not matches:
158
  return "No files found matching the pattern."
159
+ return "\n".join(sorted(matches))
160
  except Exception as e:
161
  return f"ERROR globbing {pattern}: {e}"
162
 
163
 
164
+ def find_files(name: str, root: str = ".", max_results: int = 200) -> str:
165
+ """Locate files by name under `root` using unix `find`. Fast deep search."""
166
+ root = os.path.expanduser(root)
167
+ if not Path(root).exists():
168
+ return f"ERROR: Path {root} does not exist."
169
+ try:
170
+ result = subprocess.run(
171
+ ["find", str(root), "-name", name],
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=30,
175
+ )
176
+ lines = [ln for ln in result.stdout.splitlines() if ln]
177
+ if not lines:
178
+ return "No files found."
179
+ return "\n".join(lines[:max_results])
180
+ except subprocess.TimeoutExpired:
181
+ return "ERROR: find timed out (>30s)."
182
+ except Exception as e:
183
+ return f"ERROR: {e}"
184
+
185
+
186
  def write_file(path: str, content: str) -> str:
187
  """Write content to a file inside the sandboxed write root.
188
 
tests/test_files.py CHANGED
@@ -1,10 +1,20 @@
1
  from __future__ import annotations
2
 
 
3
  from pathlib import Path
4
 
5
  import pytest
6
 
7
- from lilith_agent.tools.files import _resolve_safe_write_path, set_write_root, write_file
 
 
 
 
 
 
 
 
 
8
 
9
 
10
  def test_relative_path_inside_root_is_allowed(tmp_path: Path):
@@ -50,3 +60,65 @@ def test_write_file_outside_root_returns_error_without_writing(tmp_path: Path):
50
  result = write_file(str(target), "hello")
51
  assert result.startswith("ERROR")
52
  assert not target.exists()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import os
4
  from pathlib import Path
5
 
6
  import pytest
7
 
8
+ from lilith_agent.tools.files import (
9
+ _resolve_safe_write_path,
10
+ find_files,
11
+ glob_files,
12
+ grep,
13
+ ls,
14
+ read_file,
15
+ set_write_root,
16
+ write_file,
17
+ )
18
 
19
 
20
  def test_relative_path_inside_root_is_allowed(tmp_path: Path):
 
60
  result = write_file(str(target), "hello")
61
  assert result.startswith("ERROR")
62
  assert not target.exists()
63
+
64
+
65
+ def test_ls_expands_tilde(monkeypatch, tmp_path: Path):
66
+ monkeypatch.setenv("HOME", str(tmp_path))
67
+ (tmp_path / "marker.txt").write_text("x")
68
+ out = ls("~")
69
+ assert "marker.txt" in out
70
+
71
+
72
+ def test_read_file_expands_tilde(monkeypatch, tmp_path: Path):
73
+ monkeypatch.setenv("HOME", str(tmp_path))
74
+ (tmp_path / "note.md").write_text("hello goddess")
75
+ out = read_file("~/note.md")
76
+ assert "hello goddess" in out
77
+
78
+
79
+ def test_grep_expands_tilde(monkeypatch, tmp_path: Path):
80
+ monkeypatch.setenv("HOME", str(tmp_path))
81
+ (tmp_path / "lore.txt").write_text("The She\nDemiurge\n")
82
+ out = grep("She", "~/lore.txt")
83
+ assert "The She" in out
84
+
85
+
86
+ def test_glob_files_expands_tilde(monkeypatch, tmp_path: Path):
87
+ monkeypatch.setenv("HOME", str(tmp_path))
88
+ (tmp_path / "a.lol").write_text("")
89
+ (tmp_path / "sub").mkdir()
90
+ (tmp_path / "sub" / "b.lol").write_text("")
91
+ out = glob_files("~/**/*.lol")
92
+ assert "a.lol" in out
93
+ assert "b.lol" in out
94
+
95
+
96
+ def test_glob_files_accepts_absolute_pattern(tmp_path: Path):
97
+ (tmp_path / "莉莉丝女神.lol").write_text("")
98
+ (tmp_path / "deep").mkdir()
99
+ (tmp_path / "deep" / "莉莉丝女神.lol").write_text("")
100
+ out = glob_files(f"{tmp_path}/**/莉莉丝女神.lol")
101
+ assert "莉莉丝女神.lol" in out
102
+ assert "deep" in out
103
+
104
+
105
+ def test_find_files_locates_by_name(tmp_path: Path):
106
+ (tmp_path / "a").mkdir()
107
+ (tmp_path / "a" / "target.txt").write_text("")
108
+ (tmp_path / "b").mkdir()
109
+ (tmp_path / "b" / "other.md").write_text("")
110
+ out = find_files("target.txt", str(tmp_path))
111
+ assert "target.txt" in out
112
+ assert "other.md" not in out
113
+
114
+
115
+ def test_find_files_expands_tilde(monkeypatch, tmp_path: Path):
116
+ monkeypatch.setenv("HOME", str(tmp_path))
117
+ (tmp_path / "needle.lol").write_text("")
118
+ out = find_files("needle.lol", "~")
119
+ assert "needle.lol" in out
120
+
121
+
122
+ def test_find_files_missing_root_errors(tmp_path: Path):
123
+ out = find_files("anything", str(tmp_path / "does_not_exist"))
124
+ assert out.startswith("ERROR")