Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
32 changes: 23 additions & 9 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)
rel_path = _normalized_relative_path(abs_path, root, roots)
if _match_pattern(pattern, rel_path):
matches.append(str(abs_path).replace("\\", "/"))
matches.append(rel_path)
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)
rel_path = _normalized_relative_path(abs_path, root, roots)
if not _match_pattern(pattern, rel_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match globs before adding root prefix

Here rel_path is already rewritten as @root[n]/... for secondary roots, so directory-aware patterns like pkg/*.py or src/**/*.ts no longer match even when files exist under the searched secondary root. I confirmed this by exercising SearchFileTool with two workspace roots: searching the secondary root with pattern pkg/*.py returns zero results because matching is done against the prefixed display path instead of the root-relative path. This is a regression introduced by the prefixing change and causes false negatives for multi-root searches.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2bf92d2 by separating match-path semantics from display-path formatting.

What changed:

  • search_file now performs glob matching against the root-relative path (for example pkg/c.py).
  • After a match is found, output formatting is applied (@root[n]/... for non-primary roots).
  • Added regression coverage for this case:
    • test_search_file_matches_directory_glob_in_secondary_root

Verification:

  • .venv/bin/pytest -q tests/unit/test_v4_file_tools.py::test_search_file_prefixes_secondary_root_paths tests/unit/test_v4_file_tools.py::test_search_file_matches_directory_glob_in_secondary_root tests/unit/test_v4_file_tools.py::test_read_file_accepts_secondary_root_prefixed_path
  • .venv/bin/pytest -q tests/unit/test_v4_file_tools.py

continue
matches.append(str(abs_path).replace("\\", "/"))
matches.append(rel_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve root context in returned search_file paths

Appending rel_path here drops which workspace root a match came from, which breaks multi-root setups introduced by this change from absolute paths. If search_file is run against a non-primary root (for example by passing an absolute path in the second workspace root), the returned relative name is later resolved by read_file via resolve_path against roots[0], so the same path can resolve to the wrong file or fail with FILE_NOT_FOUND even though search_file reported it as a match.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ce9f26b by preserving root context in search outputs and teaching path resolution to consume it.

What changed:

  • search_file now prefixes non-primary workspace roots as @root[n]/... (primary root remains unchanged).
  • resolve_path now parses @root[n]/... and resolves against the selected workspace root.
  • Added regression tests for both behaviors:
    • test_search_file_prefixes_secondary_root_paths
    • test_read_file_accepts_secondary_root_prefixed_path

Verification:

  • .venv/bin/pytest -q tests/unit/test_v4_file_tools.py::test_search_file_prefixes_secondary_root_paths tests/unit/test_v4_file_tools.py::test_read_file_accepts_secondary_root_prefixed_path
  • .venv/bin/pytest -q tests/unit/test_v4_file_tools.py

if len(matches) >= max_results:
truncated = True
break
Expand All @@ -178,8 +185,16 @@ def _execute_search_file(input: dict[str, Any], context: RunContext[Any]) -> Too
)


def _normalized_relative_path(path: Path, root: Path) -> str:
return relative_to_root(path, root).replace("\\", "/")
def _normalized_relative_path(path: Path, root: Path, roots: list[Path]) -> str:
relative_path = relative_to_root(path, root).replace("\\", "/")
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:
Expand All @@ -200,4 +215,3 @@ def _error_result(error: ToolError) -> ToolResult:
error=error.message,
evidence=[],
)

41 changes: 41 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,44 @@ 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_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"