Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/fastcontext/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion src/fastcontext/agent/tool/glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"<system-reminder>Error: directory `{directory}` does not exist or is not a directory.</system-reminder>"
Expand Down
4 changes: 3 additions & 1 deletion src/fastcontext/agent/tool/grep.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"<system-reminder>Permission error: `{path}` is not within the working directory `{cwd}`</system-reminder>"

Expand Down
7 changes: 7 additions & 0 deletions src/fastcontext/agent/tool/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import aiofiles

from .tool import Tool
from .utils import resolve_path

MAX_LINE = 2000
MAX_LINE_LENGTH = 2000
Expand Down Expand Up @@ -41,12 +42,18 @@ async def call(self, parameters: str, **kwargs) -> str:
return "<system-reminder>Error: file path is required</system-reminder>"

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"<system-reminder>Permission error: `{file_path}` is not within the working directory `{cwd}`</system-reminder>"

if not Path(file_path).exists():
return f"<system-reminder>Error: {file_path} does not exist</system-reminder>"

if Path(file_path).is_dir():
return f"<system-reminder>Error: {file_path} is a directory, not a file. Use Glob to find files in this directory.</system-reminder>"

if not isinstance(offset, int) or offset <= 0:
return "<system-reminder>Error: offset must be a positive integer</system-reminder>"

Expand Down
44 changes: 44 additions & 0 deletions src/fastcontext/agent/tool/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import platform
import shutil
from pathlib import Path


def _find_existing_rg() -> str | None:
Expand All @@ -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
18 changes: 12 additions & 6 deletions src/fastcontext/agent/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def load_system_prompt(work_dir: str) -> str:
def parse_citations(text: str) -> list:
final_answer = re.search(r"<final_answer>(.*?)</final_answer>", text, re.DOTALL)
if final_answer is None:
return {"final_answer": text.strip(), "citations": []}
return []

entries = final_answer.group(1).strip().splitlines()

Expand Down Expand Up @@ -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
Expand All @@ -108,9 +114,9 @@ def format_citations(citations: list, validate: bool = True) -> str:
return "<final_answer>\n" + "\n".join(formatted) + "\n</final_answer>"


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


Expand Down
32 changes: 32 additions & 0 deletions tests/test_path_resolution.py
Original file line number Diff line number Diff line change
@@ -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")