diff --git a/src/fastcontext/agent/agent.py b/src/fastcontext/agent/agent.py index 478e93f..922bbcd 100644 --- a/src/fastcontext/agent/agent.py +++ b/src/fastcontext/agent/agent.py @@ -72,7 +72,7 @@ async def _agent_loop(self, prompt: str, max_turns: int, verbose: bool, citation await self.context.add(tools_result_msg) else: if citation: - return get_final_answer(step_msg.content) + return get_final_answer(step_msg.content, work_dir=self.work_dir) return step_msg.content async def run(self, prompt: str, max_turns: int = 4, verbose: bool = False, citation: bool = False) -> str: diff --git a/src/fastcontext/agent/tool/glob.py b/src/fastcontext/agent/tool/glob.py index 4506346..749ca05 100644 --- a/src/fastcontext/agent/tool/glob.py +++ b/src/fastcontext/agent/tool/glob.py @@ -3,7 +3,7 @@ from pathlib import Path from .tool import Tool -from .utils import RG_PATH +from .utils import RG_PATH, resolve_path def run(directory: str, pattern: str, cwd: str) -> str: @@ -46,6 +46,23 @@ async def call(self, parameters: str, **kwargs) -> str: directory = params.get("directory", cwd) pattern = params.get("pattern") + directory = resolve_path(directory, cwd) + + # The model sometimes puts a full path in the pattern parameter + # (e.g. /myrepo/backend/**/*.py instead of just **/*.py) + if pattern and pattern.startswith("/"): + cwd_name = Path(cwd).name + prefix = f"/{cwd_name}/" + if pattern.startswith(prefix): + pattern = pattern[len(prefix):] + else: + parts = pattern.split("/") + glob_chars = {"*", "?", "[", "{"} + for i, part in enumerate(parts): + if any(c in part for c in glob_chars): + pattern = "/".join(parts[i:]) + break + p = Path(directory) if not p.is_dir(): return f"Error: directory `{directory}` does not exist or is not a directory." diff --git a/src/fastcontext/agent/tool/grep.py b/src/fastcontext/agent/tool/grep.py index 4ee161f..47a69bf 100644 --- a/src/fastcontext/agent/tool/grep.py +++ b/src/fastcontext/agent/tool/grep.py @@ -2,7 +2,7 @@ from pathlib import Path from .tool import Tool -from .utils import RG_PATH +from .utils import RG_PATH, resolve_path class GrepTool(Tool): @@ -82,6 +82,8 @@ async def call(self, parameters: str, **kwargs) -> str: head_limit = params.get("head_limit") multiline = params.get("multiline") + path = resolve_path(path, cwd) + if not Path(path).resolve().is_relative_to(Path(cwd).resolve()): return f"Permission error: `{path}` is not within the working directory `{cwd}`" diff --git a/src/fastcontext/agent/tool/read.py b/src/fastcontext/agent/tool/read.py index a0caa76..34c7a62 100644 --- a/src/fastcontext/agent/tool/read.py +++ b/src/fastcontext/agent/tool/read.py @@ -4,6 +4,7 @@ import aiofiles from .tool import Tool +from .utils import resolve_path MAX_LINE = 2000 MAX_LINE_LENGTH = 2000 @@ -41,12 +42,18 @@ async def call(self, parameters: str, **kwargs) -> str: return "Error: file path is required" cwd = kwargs.get("cwd", Path.cwd().as_posix()) + + file_path = resolve_path(file_path, cwd) + if not Path(file_path).resolve().is_relative_to(Path(cwd).resolve()): return f"Permission error: `{file_path}` is not within the working directory `{cwd}`" if not Path(file_path).exists(): return f"Error: {file_path} does not exist" + if Path(file_path).is_dir(): + return f"Error: {file_path} is a directory, not a file. Use Glob to find files in this directory." + if not isinstance(offset, int) or offset <= 0: return "Error: offset must be a positive integer" diff --git a/src/fastcontext/agent/tool/utils.py b/src/fastcontext/agent/tool/utils.py index 8deaddb..8e44966 100644 --- a/src/fastcontext/agent/tool/utils.py +++ b/src/fastcontext/agent/tool/utils.py @@ -1,6 +1,7 @@ import os import platform import shutil +from pathlib import Path def _find_existing_rg() -> str | None: @@ -12,3 +13,46 @@ def _find_existing_rg() -> str | None: RG_PATH = _find_existing_rg() + + +def resolve_path(file_path: str, cwd: str) -> str: + """Resolve short-prefixed model path to full absolute path. + + Model prefix path with last cwd component: emit ``/myrepo/src`` not + ``/home/user/projects/myrepo/src``. Rewrite to full form so tool + permission check (``is_relative_to(cwd)``) pass. Already within cwd: + return unchanged. + """ + cwd_path = Path(cwd).resolve() + + try: + if Path(file_path).resolve().is_relative_to(cwd_path) and Path(file_path).exists(): + return file_path + except (OSError, ValueError): + pass + + # Model used last cwd component as prefix: + # cwd=/home/user/projects/myrepo, model says /myrepo/src/... + cwd_name = cwd_path.name + prefix = f"/{cwd_name}/" + if file_path.startswith(prefix): + candidate = cwd_path / file_path[len(prefix):] + return str(candidate.resolve()) + + if file_path == f"/{cwd_name}": + return str(cwd_path) + + if file_path.startswith("/"): + relative = file_path.lstrip("/") + candidate = cwd_path / relative + if candidate.exists(): + return str(candidate.resolve()) + + parts = Path(file_path).parts + for i in range(1, len(parts)): + suffix = Path(*parts[i:]) + candidate = cwd_path / suffix + if candidate.exists(): + return str(candidate.resolve()) + + return file_path diff --git a/src/fastcontext/agent/utils.py b/src/fastcontext/agent/utils.py index b43f921..e86109f 100644 --- a/src/fastcontext/agent/utils.py +++ b/src/fastcontext/agent/utils.py @@ -56,7 +56,7 @@ def load_system_prompt(work_dir: str) -> str: def parse_citations(text: str) -> list: final_answer = re.search(r"(.*?)", text, re.DOTALL) if final_answer is None: - return {"final_answer": text.strip(), "citations": []} + return [] entries = final_answer.group(1).strip().splitlines() @@ -87,14 +87,20 @@ def parse_citations(text: str) -> list: return citations -def format_citations(citations: list, validate: bool = True) -> str: +def format_citations(citations: list, validate: bool = True, work_dir: str = None) -> str: if validate: + from fastcontext.agent.tool.utils import resolve_path + validated_citations = [] for c in citations: - # if not file or not existing, skip this citation - if not os.path.isfile(c["path"]): + path = c["path"] + if work_dir: + path = resolve_path(path, work_dir) + if not os.path.isfile(path): continue + c = dict(c) + c["path"] = path validated_citations.append(c) citations = validated_citations @@ -108,9 +114,9 @@ def format_citations(citations: list, validate: bool = True) -> str: return "\n" + "\n".join(formatted) + "\n" -def get_final_answer(text: str) -> str: +def get_final_answer(text: str, work_dir: str = None) -> str: citations = parse_citations(text) - final_answer = format_citations(citations) + final_answer = format_citations(citations, work_dir=work_dir) return final_answer diff --git a/tests/test_path_resolution.py b/tests/test_path_resolution.py new file mode 100644 index 0000000..00c42e4 --- /dev/null +++ b/tests/test_path_resolution.py @@ -0,0 +1,32 @@ +"""Tests for the resolve_path helper.""" + +from pathlib import Path + +import pytest + +from fastcontext.agent.tool.utils import resolve_path + + +@pytest.fixture +def cwd(tmp_path): + repo = tmp_path / "projects" / "myrepo" + (repo / "backend").mkdir(parents=True) + (repo / "backend" / "main.py").write_text("print('hello')\n") + return str(repo) + + +class TestResolvePath: + def test_already_absolute_within_cwd(self, cwd): + full_path = str(Path(cwd) / "backend" / "main.py") + assert resolve_path(full_path, cwd) == full_path + + def test_short_prefix_resolution(self, cwd): + result = resolve_path("/myrepo/backend/main.py", cwd) + assert result == str(Path(cwd) / "backend" / "main.py") + + def test_unresolvable_path_returns_original(self, cwd): + assert resolve_path("/nope/file.py", cwd) == "/nope/file.py" + + def test_suffix_matching(self, cwd): + result = resolve_path("/some/random/prefix/backend/main.py", cwd) + assert result == str(Path(cwd) / "backend" / "main.py")