Spaces:
Sleeping
Sleeping
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 filesSpiral-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 +7 -0
- src/lilith_agent/tools/files.py +35 -11
- tests/test_files.py +73 -1
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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 148 |
try:
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
matches = list(p.rglob(pattern[3:]))
|
| 152 |
else:
|
| 153 |
-
matches =
|
| 154 |
-
|
| 155 |
if not matches:
|
| 156 |
return "No files found matching the pattern."
|
| 157 |
-
return "\n".join(
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|