Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions dare_framework/tool/_internal/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
DEFAULT_MAX_BYTES = 1_000_000
DEFAULT_MAX_RESULTS = 50
DEFAULT_IGNORE_DIRS = [".git", "node_modules", "__pycache__", ".venv", "venv"]
_ROOT_PREFIX = "@root["


def get_tool_config(context: RunContext[Any], tool_name: str) -> dict[str, Any]:
Expand Down Expand Up @@ -93,6 +94,9 @@ def resolve_path(path_value: Any, roots: list[Path]) -> tuple[Path, Path]:
"""Resolve a path value against workspace roots."""
if not isinstance(path_value, str) or not path_value.strip():
raise ToolError(code="INVALID_PATH", message="path is required", retryable=False)
prefixed_root = _parse_prefixed_root_path(path_value, roots)
if prefixed_root is not None:
return prefixed_root
candidate = Path(path_value).expanduser()
if candidate.is_absolute():
resolved = candidate.resolve()
Expand All @@ -107,6 +111,36 @@ def resolve_path(path_value: Any, roots: list[Path]) -> tuple[Path, Path]:
return resolved, root


def _parse_prefixed_root_path(path_value: str, roots: list[Path]) -> tuple[Path, Path] | None:
"""Parse @root[n]/relative/path references used by file search outputs."""
if not path_value.startswith(_ROOT_PREFIX):
return None

marker_end = path_value.find("]/")
if marker_end <= len(_ROOT_PREFIX):
raise ToolError(code="INVALID_PATH", message="invalid root-prefixed path", retryable=False)

root_index_raw = path_value[len(_ROOT_PREFIX):marker_end]
try:
root_index = int(root_index_raw)
except ValueError as exc:
raise ToolError(code="INVALID_PATH", message="invalid root index in path", retryable=False) from exc

if root_index < 0 or root_index >= len(roots):
raise ToolError(code="PATH_NOT_ALLOWED", message="root index is outside workspace roots", retryable=False)

relative_fragment = path_value[marker_end + 2:]
relative_candidate = Path(relative_fragment)
if relative_candidate.is_absolute():
raise ToolError(code="PATH_NOT_ALLOWED", message="path is outside workspace roots", retryable=False)

root = roots[root_index]
resolved = (root / relative_candidate).resolve()
if not _is_relative_to(resolved, root):
raise ToolError(code="PATH_NOT_ALLOWED", message="path is outside workspace roots", retryable=False)
return resolved, root


def relative_to_root(path: Path, root: Path) -> str:
"""Return a relative path string when possible."""
try:
Expand Down
37 changes: 27 additions & 10 deletions dare_framework/tool/_internal/tools/search_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ def name(self) -> str:

@property
def description(self) -> str:
return "Search file paths by glob pattern (e.g. *.py, src/**/*.ts). Returns paths as absolute paths. Use paths directly for read_file."
return (
"Search file paths by glob pattern (e.g. *.py, src/**/*.ts). "
"Returns workspace-relative paths; matches in non-primary roots are prefixed as @root[n]/."
)

@property
def input_schema(self) -> dict[str, Any]:
Expand All @@ -57,7 +60,11 @@ def output_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"paths": {"type": "array", "items": {"type": "string"}, "description": "Absolute paths; use directly for read_file"},
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "Workspace-relative paths. Non-primary roots are encoded as @root[n]/<path>.",
},
"total_matches": {"type": "integer"},
"truncated": {"type": "boolean"},
},
Expand Down Expand Up @@ -143,18 +150,18 @@ def _execute_search_file(input: dict[str, Any], context: RunContext[Any]) -> Too

if search_path.is_file():
abs_path = search_path.resolve()
rel_path = _normalized_relative_path(abs_path, root)
if _match_pattern(pattern, rel_path):
matches.append(str(abs_path).replace("\\", "/"))
relative_path = _relative_path_for_match(abs_path, root)
if _match_pattern(pattern, relative_path):
matches.append(_display_relative_path(relative_path, root, roots))
else:
for dirpath, dirs, files in os.walk(search_path, topdown=True, followlinks=False):
dirs[:] = [d for d in sorted(dirs) if d not in ignore_dirs]
for filename in sorted(files):
abs_path = (Path(dirpath) / filename).resolve()
rel_path = _normalized_relative_path(abs_path, root)
if not _match_pattern(pattern, rel_path):
relative_path = _relative_path_for_match(abs_path, root)
if not _match_pattern(pattern, relative_path):
continue
matches.append(str(abs_path).replace("\\", "/"))
matches.append(_display_relative_path(relative_path, root, roots))
if len(matches) >= max_results:
truncated = True
break
Expand All @@ -178,10 +185,21 @@ def _execute_search_file(input: dict[str, Any], context: RunContext[Any]) -> Too
)


def _normalized_relative_path(path: Path, root: Path) -> str:
def _relative_path_for_match(path: Path, root: Path) -> str:
return relative_to_root(path, root).replace("\\", "/")


def _display_relative_path(relative_path: str, root: Path, roots: list[Path]) -> str:
try:
root_index = roots.index(root)
except ValueError:
root_index = 0

if root_index <= 0:
return relative_path
return f"@root[{root_index}]/{relative_path}"


def _match_pattern(pattern: str, relative_path: str) -> bool:
file_name = Path(relative_path).name
if fnmatch.fnmatch(relative_path, pattern) or fnmatch.fnmatch(file_name, pattern):
Expand All @@ -200,4 +218,3 @@ def _error_result(error: ToolError) -> ToolResult:
error=error.message,
evidence=[],
)

63 changes: 63 additions & 0 deletions tests/unit/test_v4_file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,66 @@ async def test_search_file_finds_matching_paths(tmp_path):

assert result.success is True
assert result.output["paths"] == ["a.py", "pkg/c.py"]


@pytest.mark.asyncio
async def test_search_file_prefixes_secondary_root_paths(tmp_path):
primary = tmp_path / "primary"
secondary = tmp_path / "secondary"
primary.mkdir()
secondary.mkdir()
(secondary / "b.py").write_text("x\n")
ctx = RunContext(
deps=None,
run_id="run",
config={"workspace_roots": [str(primary), str(secondary)]},
)

tool = SearchFileTool()
result = await tool.execute(run_context=ctx, pattern="*.py", path=str(secondary))

assert result.success is True
assert result.output["paths"] == ["@root[1]/b.py"]


@pytest.mark.asyncio
async def test_search_file_matches_directory_glob_in_secondary_root(tmp_path):
primary = tmp_path / "primary"
secondary = tmp_path / "secondary"
primary.mkdir()
secondary.mkdir()
pkg = secondary / "pkg"
pkg.mkdir()
(pkg / "c.py").write_text("x\n")
ctx = RunContext(
deps=None,
run_id="run",
config={"workspace_roots": [str(primary), str(secondary)]},
)

tool = SearchFileTool()
result = await tool.execute(run_context=ctx, pattern="pkg/*.py", path=str(secondary))

assert result.success is True
assert result.output["paths"] == ["@root[1]/pkg/c.py"]


@pytest.mark.asyncio
async def test_read_file_accepts_secondary_root_prefixed_path(tmp_path):
primary = tmp_path / "primary"
secondary = tmp_path / "secondary"
primary.mkdir()
secondary.mkdir()
(secondary / "b.py").write_text("print('ok')\n")
ctx = RunContext(
deps=None,
run_id="run",
config={"workspace_roots": [str(primary), str(secondary)]},
)

tool = ReadFileTool()
result = await tool.execute(run_context=ctx, path="@root[1]/b.py")

assert result.success is True
assert result.output["content"] == "print('ok')\n"
assert result.output["path"] == "b.py"
Loading