From 5bc4e0d3a87979e67ada3d01a0557fdb7fdcefe7 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 14:23:00 +0800 Subject: [PATCH 1/3] fix(search_file): return workspace-relative paths Align search_file output with existing path contract tests by returning workspace-relative matches instead of absolute filesystem paths.\n\nChanges:\n- Emit relative paths for both file and directory search branches in SearchFileTool.\n- Update tool description and output schema text to document workspace-relative output semantics.\n\nWhy:\n- Current behavior returned absolute paths while test and TODO baseline require relative paths.\n- Relative paths keep outputs deterministic across machines and CI temp directories.\n\nVerification:\n- .venv/bin/pytest -q tests/unit/test_v4_file_tools.py::test_search_file_finds_matching_paths => passed\n- .venv/bin/pytest -q tests/unit/test_v4_file_tools.py => 16 passed --- dare_framework/tool/_internal/tools/search_file.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/dare_framework/tool/_internal/tools/search_file.py b/dare_framework/tool/_internal/tools/search_file.py index 689fef02..d0463221 100644 --- a/dare_framework/tool/_internal/tools/search_file.py +++ b/dare_framework/tool/_internal/tools/search_file.py @@ -38,7 +38,7 @@ 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." @property def input_schema(self) -> dict[str, Any]: @@ -57,7 +57,7 @@ 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"}, "total_matches": {"type": "integer"}, "truncated": {"type": "boolean"}, }, @@ -145,7 +145,7 @@ def _execute_search_file(input: dict[str, Any], context: RunContext[Any]) -> Too 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("\\", "/")) + 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] @@ -154,7 +154,7 @@ def _execute_search_file(input: dict[str, Any], context: RunContext[Any]) -> Too rel_path = _normalized_relative_path(abs_path, root) if not _match_pattern(pattern, rel_path): continue - matches.append(str(abs_path).replace("\\", "/")) + matches.append(rel_path) if len(matches) >= max_results: truncated = True break @@ -200,4 +200,3 @@ def _error_result(error: ToolError) -> ToolResult: error=error.message, evidence=[], ) - From ce9f26b459ac711af5f5b92af908565cdc6da256 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 16:51:47 +0800 Subject: [PATCH 2/3] fix(search_file): preserve workspace root context for multi-root paths Problem:\n- search_file switched to pure workspace-relative output, which dropped root identity in multi-root runs\n- read_file resolves relative paths against workspace_roots[0], so matches from secondary roots could resolve to wrong files or fail\n\nChanges:\n- add @root[n]/ support in resolve_path for deterministic root-targeted resolution\n- emit @root[n]/ prefix from search_file when a match belongs to a non-primary workspace root\n- update search_file description/output schema to document the root-prefixed contract\n- add regression tests covering secondary-root search_file output and read_file consumption of prefixed paths\n\nWhy this shape:\n- keeps existing single-root behavior unchanged\n- preserves deterministic relative outputs while restoring root context needed for follow-up file tools --- dare_framework/tool/_internal/file_utils.py | 34 +++++++++++++++ .../tool/_internal/tools/search_file.py | 27 +++++++++--- tests/unit/test_v4_file_tools.py | 41 +++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/dare_framework/tool/_internal/file_utils.py b/dare_framework/tool/_internal/file_utils.py index cad06ad5..7d258850 100644 --- a/dare_framework/tool/_internal/file_utils.py +++ b/dare_framework/tool/_internal/file_utils.py @@ -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]: @@ -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() @@ -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: diff --git a/dare_framework/tool/_internal/tools/search_file.py b/dare_framework/tool/_internal/tools/search_file.py index d0463221..c1b22fe8 100644 --- a/dare_framework/tool/_internal/tools/search_file.py +++ b/dare_framework/tool/_internal/tools/search_file.py @@ -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 workspace-relative paths." + 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]: @@ -57,7 +60,11 @@ def output_schema(self) -> dict[str, Any]: return { "type": "object", "properties": { - "paths": {"type": "array", "items": {"type": "string"}, "description": "Workspace-relative paths"}, + "paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Workspace-relative paths. Non-primary roots are encoded as @root[n]/.", + }, "total_matches": {"type": "integer"}, "truncated": {"type": "boolean"}, }, @@ -143,7 +150,7 @@ 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(rel_path) else: @@ -151,7 +158,7 @@ def _execute_search_file(input: dict[str, Any], context: RunContext[Any]) -> Too 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): continue matches.append(rel_path) @@ -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: diff --git a/tests/unit/test_v4_file_tools.py b/tests/unit/test_v4_file_tools.py index 05963ab5..e673ab50 100644 --- a/tests/unit/test_v4_file_tools.py +++ b/tests/unit/test_v4_file_tools.py @@ -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" From 2bf92d2a3d0f7c36baf6e1041d04521dd9b2b269 Mon Sep 17 00:00:00 2001 From: mindfn Date: Wed, 4 Mar 2026 17:07:44 +0800 Subject: [PATCH 3/3] fix(search_file): match globs before root-prefix formatting Problem:\n- secondary-root matches are displayed as @root[n]/...\n- pattern matching was performed against that display string, which breaks directory-aware globs like pkg/*.py\n\nChanges:\n- split search_file path handling into two stages:\n 1) compute root-relative path for matching\n 2) format matched path for output with optional @root[n]/ prefix\n- keep prefixed output contract for non-primary roots, while restoring correct glob behavior\n- add regression test covering directory glob matching in a secondary workspace root\n\nVerification:\n- .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\n- .venv/bin/pytest -q tests/unit/test_v4_file_tools.py --- .../tool/_internal/tools/search_file.py | 19 +++++++++------- tests/unit/test_v4_file_tools.py | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/dare_framework/tool/_internal/tools/search_file.py b/dare_framework/tool/_internal/tools/search_file.py index c1b22fe8..3c0bcae6 100644 --- a/dare_framework/tool/_internal/tools/search_file.py +++ b/dare_framework/tool/_internal/tools/search_file.py @@ -150,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, roots) - if _match_pattern(pattern, rel_path): - matches.append(rel_path) + 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, roots) - 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(rel_path) + matches.append(_display_relative_path(relative_path, root, roots)) if len(matches) >= max_results: truncated = True break @@ -185,8 +185,11 @@ def _execute_search_file(input: dict[str, Any], context: RunContext[Any]) -> Too ) -def _normalized_relative_path(path: Path, root: Path, roots: list[Path]) -> str: - relative_path = relative_to_root(path, root).replace("\\", "/") +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: diff --git a/tests/unit/test_v4_file_tools.py b/tests/unit/test_v4_file_tools.py index e673ab50..e3abb612 100644 --- a/tests/unit/test_v4_file_tools.py +++ b/tests/unit/test_v4_file_tools.py @@ -324,6 +324,28 @@ async def test_search_file_prefixes_secondary_root_paths(tmp_path): 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"