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")