fix: support bounded recursive glob across lessons

This commit is contained in:
Haoran
2026-08-23 11:28:14 +08:00
parent 1342a990a2
commit 61c3e64595
19 changed files with 189 additions and 83 deletions

View File

@@ -74,7 +74,12 @@ def run_edit(path, old_text, new_text):
def run_glob(pattern):
import glob as g
return "\n".join(g.glob(pattern, root_dir=WORKDIR))
matches = sorted(set(g.glob(
pattern, root_dir=WORKDIR, recursive=True)))
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown)
```
---

View File

@@ -74,7 +74,12 @@ def run_edit(path, old_text, new_text):
def run_glob(pattern):
import glob as g
return "\n".join(g.glob(pattern, root_dir=WORKDIR))
matches = sorted(set(g.glob(
pattern, root_dir=WORKDIR, recursive=True)))
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown)
```
---

View File

@@ -74,7 +74,12 @@ def run_edit(path, old_text, new_text):
def run_glob(pattern):
import glob as g
return "\n".join(g.glob(pattern, root_dir=WORKDIR))
matches = sorted(set(g.glob(
pattern, root_dir=WORKDIR, recursive=True)))
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown)
```
---

View File

@@ -110,11 +110,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
matches = sorted({
match for match in g.glob(
pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
@@ -130,7 +134,7 @@ TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]

View File

@@ -105,11 +105,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
matches = sorted({
match for match in g.glob(
pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
@@ -125,7 +129,7 @@ TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]

View File

@@ -91,11 +91,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
matches = sorted({
match for match in g.glob(
pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
@@ -108,7 +112,7 @@ TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]

View File

@@ -96,11 +96,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
matches = sorted({
match for match in g.glob(
pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
@@ -186,7 +190,7 @@ TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
# s05: new tool
{"name": "todo_write", "description": "Create and manage a task list for your current coding session.",

View File

@@ -101,11 +101,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
import glob
try:
matches = []
for match in glob.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
matches.append(match)
return "\n".join(matches) if matches else "(no matches)"
matches = sorted({
match for match in glob.glob(
pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
@@ -119,7 +123,7 @@ BASE_TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]

View File

@@ -185,11 +185,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
import glob
try:
matches = []
for match in glob.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
matches.append(match)
return "\n".join(matches) if matches else "(no matches)"
matches = sorted({
match for match in glob.glob(
pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
@@ -203,7 +207,7 @@ TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
{"name": "load_skill", "description": "Load the full SKILL.md content by skill name.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},

View File

@@ -118,11 +118,14 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
try:
matches = [
matches = sorted({
match for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
]
return "\n".join(matches) if matches else "(no matches)"
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as error:
return f"Error: {error}"
@@ -136,7 +139,7 @@ BASE_TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]
COMPACT_TOOL = {

View File

@@ -579,12 +579,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
try:
matches = [
matches = sorted({
match
for match in glob.glob(pattern, root_dir=WORKDIR)
for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
]
return "\n".join(matches) if matches else "(no matches)"
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as error:
return f"Error: {error}"
@@ -597,7 +600,7 @@ TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]

View File

@@ -325,12 +325,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
try:
matches = [
matches = sorted({
match
for match in glob.glob(pattern, root_dir=WORKDIR)
for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
]
return "\n".join(matches) if matches else "(no matches)"
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as error:
return f"Error: {error}"
@@ -392,7 +395,7 @@ TOOLS = [
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
{"name": "create_task", "description": "Create a task and return its runtime-generated ID.",
"input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"], "additionalProperties": False}},

View File

@@ -156,12 +156,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
try:
matches = [
matches = sorted({
match
for match in glob.glob(pattern, root_dir=WORKDIR)
for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
]
return "\n".join(matches) if matches else "(no matches)"
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as error:
return f"Error: {error}"
@@ -189,7 +192,7 @@ TOOLS = [
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},

View File

@@ -106,12 +106,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
try:
matches = [
matches = sorted({
match
for match in glob.glob(pattern, root_dir=WORKDIR)
for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
]
return "\n".join(matches) if matches else "(no matches)"
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as error:
return f"Error: {error}"
@@ -137,7 +140,7 @@ TOOLS = [
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},

View File

@@ -727,7 +727,10 @@ def run_glob(pattern: str, cwd: Path | None = None) -> str:
for path in sorted(base.glob(pattern))
if path.resolve().is_relative_to(base)
]
return "\n".join(matches[:200]) or "No files found"
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) or "No files found"
except Exception as exc:
return f"Error: {exc}"
@@ -1519,7 +1522,7 @@ BASE_TOOLS = [
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files by glob pattern.",
{"name": "glob", "description": "Find files by glob pattern; ** matches recursively.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},

View File

@@ -109,12 +109,15 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
def run_glob(pattern: str) -> str:
try:
matches = [
matches = sorted({
match
for match in glob.glob(pattern, root_dir=WORKDIR)
for match in glob.glob(pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR.resolve())
]
return "\n".join(matches[:200]) if matches else "(no matches)"
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as exc:
return f"Error: {exc}"
@@ -140,7 +143,7 @@ BASE_TOOLS = [
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files by glob pattern.",
{"name": "glob", "description": "Find files by glob pattern; ** matches recursively.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},

View File

@@ -972,11 +972,15 @@ def run_glob(pattern: str, cwd: Path | None = None) -> str:
import glob as g
try:
base = (cwd or WORKDIR).resolve()
results = []
for match in g.glob(pattern, root_dir=base):
if (base / match).resolve().is_relative_to(base):
results.append(match)
return "\n".join(results) if results else "(no matches)"
matches = sorted({
match for match in g.glob(
pattern, root_dir=base, recursive=True)
if (base / match).resolve().is_relative_to(base)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
@@ -1487,7 +1491,7 @@ def spawn_teammate_thread(name: str, role: str, prompt: str,
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files by glob pattern.",
{"name": "glob", "description": "Find files by glob pattern; ** matches recursively.",
"input_schema": {"type": "object",
"properties": {
"pattern": {"type": "string"}},
@@ -1849,7 +1853,7 @@ SUB_TOOLS = [
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},
@@ -2776,7 +2780,7 @@ BUILTIN_TOOLS = [
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},

View File

@@ -515,7 +515,7 @@ TOOLS = [
},
{
"name": "glob",
"description": "Find files matching a glob pattern.",
"description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {
"type": "object",
"properties": {"pattern": {"type": "string"}},
@@ -797,12 +797,16 @@ class AgentSession:
return f"Edited {path.relative_to(self.workdir)}"
if name == "glob":
matches = [
matches = sorted({
match
for match in glob.glob(str(arguments["pattern"]), root_dir=self.workdir)
for match in glob.glob(
str(arguments["pattern"]), root_dir=self.workdir, recursive=True)
if (self.workdir / match).resolve().is_relative_to(self.workdir)
]
return "\n".join(matches[:200]) if matches else "(no matches)"
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
raise GoalError(f"unknown tool '{name}'")

View File

@@ -30,6 +30,8 @@ LESSONS = tuple(
)
)
INTEGRATED_LESSON = ROOT / "s15_integrated_harness" / "code.py"
GOAL_LESSON = ROOT / "s17_goal_loop" / "code.py"
GLOB_LESSONS = (*LESSONS[1:], INTEGRATED_LESSON, GOAL_LESSON)
class FakeMessagesApi:
@@ -137,6 +139,42 @@ def bash_tool_call():
)
def run_glob_tool(lesson, workdir: Path, pattern: str) -> str:
if hasattr(lesson, "run_glob"):
return lesson.run_glob(pattern)
session = object.__new__(lesson.AgentSession)
session.workdir = workdir.resolve()
return session._run_tool("glob", {"pattern": pattern})
@pytest.mark.parametrize("lesson_path", GLOB_LESSONS,
ids=lambda path: path.parent.name)
def test_glob_double_star_matches_files_at_any_depth(
tmp_path: Path, lesson_path: Path):
(tmp_path / "root.py").write_text("")
(tmp_path / "one" / "two").mkdir(parents=True)
(tmp_path / "one" / "one.py").write_text("")
(tmp_path / "one" / "two" / "deep.py").write_text("")
lesson = load_lesson(tmp_path, lesson_path)
matches = set(run_glob_tool(lesson, tmp_path, "**/*.py").splitlines())
assert matches == {"root.py", "one/one.py", "one/two/deep.py"}
@pytest.mark.parametrize("lesson_path", GLOB_LESSONS,
ids=lambda path: path.parent.name)
def test_glob_caps_large_result_sets(tmp_path: Path, lesson_path: Path):
for index in range(205):
(tmp_path / f"file-{index:03}.txt").write_text("")
lesson = load_lesson(tmp_path, lesson_path)
lines = run_glob_tool(lesson, tmp_path, "*.txt").splitlines()
assert len(lines) == 201
assert lines[-1] == "... (more matches omitted; narrow the pattern)"
@pytest.mark.parametrize("lesson_path", LESSONS, ids=lambda path: path.parent.name)
@pytest.mark.parametrize("content", ([], None), ids=("empty-content", "empty-text"))
def test_parent_loop_does_not_append_an_empty_tool_result_turn(