Prefer external search roots
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
da964525825c4be99a97fb3a450828410547016e- Parents
-
873539b - Tree
f2f194e
da96452
da964525825c4be99a97fb3a450828410547016e873539b
f2f194e| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/hooks.py
|
62 | 0 |
| M |
tests/test_permissions.py
|
44 | 0 |
src/loader/runtime/hooks.pymodified@@ -273,6 +273,7 @@ class RelativePathContextHook(BaseToolHook): | ||
| 273 | 273 | resolved = self._resolve_recent_context_path( |
| 274 | 274 | raw_path, |
| 275 | 275 | require_existing=require_existing, |
| 276 | + prefer_external_ancestor=context.tool_call.name in self._SEARCH_TOOLS, | |
| 276 | 277 | ) |
| 277 | 278 | if resolved is None: |
| 278 | 279 | return HookResult() |
@@ -296,9 +297,17 @@ class RelativePathContextHook(BaseToolHook): | ||
| 296 | 297 | raw_path: str, |
| 297 | 298 | *, |
| 298 | 299 | require_existing: bool, |
| 300 | + prefer_external_ancestor: bool, | |
| 299 | 301 | ) -> str | None: |
| 300 | 302 | workspace_candidate = (self.workspace_root / raw_path).expanduser() |
| 301 | 303 | if workspace_candidate.exists(): |
| 304 | + if prefer_external_ancestor: | |
| 305 | + anchored = self._resolve_recent_context_ancestor( | |
| 306 | + raw_path, | |
| 307 | + require_existing=require_existing, | |
| 308 | + ) | |
| 309 | + if anchored is not None: | |
| 310 | + return anchored | |
| 302 | 311 | return None |
| 303 | 312 | |
| 304 | 313 | for base_dir in self.action_tracker.recent_path_contexts(): |
@@ -309,6 +318,59 @@ class RelativePathContextHook(BaseToolHook): | ||
| 309 | 318 | continue |
| 310 | 319 | if candidate.exists() or candidate.parent.exists(): |
| 311 | 320 | return str(candidate) |
| 321 | + if prefer_external_ancestor: | |
| 322 | + return self._resolve_recent_context_ancestor( | |
| 323 | + raw_path, | |
| 324 | + require_existing=require_existing, | |
| 325 | + ) | |
| 326 | + return None | |
| 327 | + | |
| 328 | + def _resolve_recent_context_ancestor( | |
| 329 | + self, | |
| 330 | + raw_path: str, | |
| 331 | + *, | |
| 332 | + require_existing: bool, | |
| 333 | + ) -> str | None: | |
| 334 | + raw_parts = tuple(part for part in Path(raw_path).parts if part not in {"."}) | |
| 335 | + if not raw_parts: | |
| 336 | + return None | |
| 337 | + | |
| 338 | + for base_dir in self.action_tracker.recent_path_contexts(): | |
| 339 | + base_path = Path(base_dir).expanduser() | |
| 340 | + try: | |
| 341 | + resolved_base = base_path.resolve(strict=False) | |
| 342 | + except Exception: | |
| 343 | + resolved_base = base_path | |
| 344 | + if resolved_base == self.workspace_root: | |
| 345 | + continue | |
| 346 | + try: | |
| 347 | + resolved_base.relative_to(self.workspace_root) | |
| 348 | + continue | |
| 349 | + except ValueError: | |
| 350 | + pass | |
| 351 | + | |
| 352 | + matched = self._match_recent_context_ancestor( | |
| 353 | + resolved_base, | |
| 354 | + raw_parts, | |
| 355 | + ) | |
| 356 | + if matched is None: | |
| 357 | + continue | |
| 358 | + if require_existing and not matched.exists(): | |
| 359 | + continue | |
| 360 | + return str(matched) | |
| 361 | + return None | |
| 362 | + | |
| 363 | + def _match_recent_context_ancestor( | |
| 364 | + self, | |
| 365 | + base_path: Path, | |
| 366 | + raw_parts: tuple[str, ...], | |
| 367 | + ) -> Path | None: | |
| 368 | + candidates = [base_path, *base_path.parents] | |
| 369 | + for candidate in candidates: | |
| 370 | + if len(candidate.parts) < len(raw_parts): | |
| 371 | + continue | |
| 372 | + if candidate.parts[-len(raw_parts) :] == raw_parts: | |
| 373 | + return candidate | |
| 312 | 374 | return None |
| 313 | 375 | |
| 314 | 376 | def _resolve_workspace_mirror_path( |
tests/test_permissions.pymodified@@ -539,6 +539,50 @@ async def test_relative_path_context_hook_remaps_workspace_mirror_of_external_ro | ||
| 539 | 539 | ] |
| 540 | 540 | |
| 541 | 541 | |
| 542 | +@pytest.mark.asyncio | |
| 543 | +async def test_relative_path_context_hook_prefers_external_search_ancestor_over_workspace_match( | |
| 544 | + temp_dir: Path, | |
| 545 | +) -> None: | |
| 546 | + workspace_root = temp_dir / "workspace" | |
| 547 | + (workspace_root / "guides").mkdir(parents=True) | |
| 548 | + external_root = temp_dir / "external-home" | |
| 549 | + external_fortran = external_root / "Loader" / "guides" / "fortran" | |
| 550 | + external_fortran.mkdir(parents=True) | |
| 551 | + (external_fortran / "index.html").write_text("<html></html>\n") | |
| 552 | + | |
| 553 | + registry = create_default_registry(workspace_root) | |
| 554 | + policy = build_permission_policy( | |
| 555 | + active_mode=PermissionMode.WORKSPACE_WRITE, | |
| 556 | + workspace_root=workspace_root, | |
| 557 | + tool_requirements=registry.get_tool_requirements(), | |
| 558 | + ) | |
| 559 | + action_tracker = ActionTracker() | |
| 560 | + action_tracker.record_tool_call( | |
| 561 | + "read", | |
| 562 | + {"file_path": str(external_fortran / "index.html")}, | |
| 563 | + ) | |
| 564 | + hook = RelativePathContextHook(action_tracker, workspace_root) | |
| 565 | + | |
| 566 | + result = await hook.pre_tool_use( | |
| 567 | + HookContext( | |
| 568 | + tool_call=ToolCall( | |
| 569 | + id="glob-ancestor-1", | |
| 570 | + name="glob", | |
| 571 | + arguments={"path": "guides", "pattern": "**"}, | |
| 572 | + ), | |
| 573 | + tool=registry.get("glob"), | |
| 574 | + registry=registry, | |
| 575 | + permission_policy=policy, | |
| 576 | + source="native", | |
| 577 | + ) | |
| 578 | + ) | |
| 579 | + | |
| 580 | + assert result.updated_arguments is not None | |
| 581 | + assert Path(result.updated_arguments["path"]).resolve() == ( | |
| 582 | + external_root / "Loader" / "guides" | |
| 583 | + ).resolve() | |
| 584 | + | |
| 585 | + | |
| 542 | 586 | class FakeSession: |
| 543 | 587 | def __init__(self, *, active_dod_path: str, messages: list[Message]) -> None: |
| 544 | 588 | self.active_dod_path = active_dod_path |