From eb46dff3ad8a8a6276afe559e85272ebf7594fe8 Mon Sep 17 00:00:00 2001 From: Michelle Pavlukhin Date: Thu, 25 Jun 2026 14:44:31 +0200 Subject: [PATCH 1/2] fix: resolve model-emitted short path prefixes in tool calls The model emits paths prefixed with the last directory component of the workspace (e.g. /myrepo/backend/main.py instead of the full absolute path), causing every Grep, Glob, and Read call to fail the is_relative_to(cwd) permission check. The agent then returns an empty because all citations are filtered out. - add resolve_path() helper that rewrites the short prefix to the full absolute path, with a longest-existing-suffix fallback - apply it in Grep, Glob, Read, and citation validation - Read: return a clear error when the path is a directory - Glob: strip a path prefix from the pattern argument - add tests/test_path_resolution.py Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/fastcontext/agent/agent.py | 2 +- src/fastcontext/agent/tool/glob.py | 19 ++++++++++++- src/fastcontext/agent/tool/grep.py | 4 ++- src/fastcontext/agent/tool/read.py | 7 +++++ src/fastcontext/agent/tool/utils.py | 44 +++++++++++++++++++++++++++++ src/fastcontext/agent/utils.py | 16 +++++++---- tests/test_path_resolution.py | 32 +++++++++++++++++++++ 7 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 tests/test_path_resolution.py 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..9f7d618 100644 --- a/src/fastcontext/agent/utils.py +++ b/src/fastcontext/agent/utils.py @@ -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") From 0ddfc7ea09b58065730832f0b9a2fd12e878a98a Mon Sep 17 00:00:00 2001 From: Michelle Pavlukhin Date: Thu, 25 Jun 2026 20:11:16 +0200 Subject: [PATCH 2/2] fix: parse_citations returns list when no final_answer tag When the model omits tags, parse_citations returned a dict instead of a list, causing format_citations to iterate the dict's string keys and crash with 'TypeError: string indices must be integers'. Return [] so get_final_answer produces an empty block instead of crashing. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/fastcontext/agent/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcontext/agent/utils.py b/src/fastcontext/agent/utils.py index 9f7d618..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()