fix(backend/copilot): fix tool-result file read failures across turns

Three bugs caused "file not found" errors when the model tried to read
SDK tool-result files:

1. Path validation mismatch: is_allowed_local_path() expected
   tool-results directly under the project dir, but the SDK nests them
   under a conversation UUID subdirectory. Fixed to match any
   tool-results/ segment within the project dir.

2. Wrong tool fallback: when the model mistakenly called
   read_workspace_file (cloud storage) for SDK tool-result paths on
   local disk, it got "file not found". Added a fallback in
   ReadWorkspaceFileTool that detects allowed local paths and reads
   from disk instead.

3. Cross-turn cleanup: _cleanup_sdk_tool_results deleted the entire
   CLI project directory (including tool-results/) between turns.
   Subsequent turns referencing those paths via --resume transcript
   would fail. Removed the project dir cleanup — only the temp cwd
   is cleaned now.

Also added system prompt guidance telling the model to use read_file
(not read_workspace_file) for SDK tool-result paths.
This commit is contained in:
Zamil Majdy
2026-03-13 15:33:30 +07:00
parent 0cd9c0d87a
commit a4deae0f69
4 changed files with 76 additions and 15 deletions

View File

@@ -87,7 +87,10 @@ def is_allowed_local_path(path: str, sdk_cwd: str | None = None) -> bool:
Allowed:
- Files under *sdk_cwd* (``/tmp/copilot-<session>/``)
- Files under ``~/.claude/projects/<encoded-cwd>/tool-results/`` (SDK tool-results)
- Files under any ``tool-results/`` directory within
``~/.claude/projects/<encoded-cwd>/``. The SDK may nest tool-results
under a conversation UUID, e.g.
``<encoded-cwd>/<conversation-uuid>/tool-results/<file>``.
"""
if not path:
return False
@@ -106,10 +109,14 @@ def is_allowed_local_path(path: str, sdk_cwd: str | None = None) -> bool:
encoded = _current_project_dir.get("")
if encoded:
tool_results_dir = os.path.join(_SDK_PROJECTS_DIR, encoded, "tool-results")
if resolved == tool_results_dir or resolved.startswith(
tool_results_dir + os.sep
):
return True
project_dir = os.path.join(_SDK_PROJECTS_DIR, encoded)
# Allow any tool-results/ directory under the project dir, at any
# nesting depth (the SDK may insert a conversation UUID between the
# project dir and tool-results/).
if resolved.startswith(project_dir + os.sep):
relative = resolved[len(project_dir) + 1 :]
parts = relative.split(os.sep)
if "tool-results" in parts:
return True
return False

View File

@@ -120,6 +120,12 @@ def _build_storage_supplement(
### File persistence
Important files (code, configs, outputs) should be saved to workspace to ensure they persist.
### SDK tool-result files
When tool outputs are large, the SDK truncates them and saves the full output to
a local file under `~/.claude/projects/.../tool-results/`. To read these files,
always use `read_file` (NOT `read_workspace_file`). `read_workspace_file` reads
from cloud workspace storage, where SDK tool-results are NOT stored.
{_SHARED_TOOL_NOTES}"""

View File

@@ -75,7 +75,6 @@ from .tool_adapter import (
wait_for_stash,
)
from .transcript import (
cleanup_cli_project_dir,
download_transcript,
upload_transcript,
validate_transcript,
@@ -283,11 +282,13 @@ def _make_sdk_cwd(session_id: str) -> str:
def _cleanup_sdk_tool_results(cwd: str) -> None:
"""Remove SDK session artifacts for a specific working directory.
Cleans up:
- ``~/.claude/projects/<encoded-cwd>/`` — CLI session transcripts and
tool-result files. Each SDK turn uses a unique cwd, so this directory
is safe to remove entirely.
- ``/tmp/copilot-<session>/`` — the ephemeral working directory.
Cleans up the ephemeral working directory ``/tmp/copilot-<session>/``.
NOTE: The CLI project directory ``~/.claude/projects/<encoded-cwd>/``
is intentionally NOT cleaned up between turns. The SDK stores
tool-result files there that the model may reference in subsequent
turns via ``--resume``. Deleting them causes "file not found" errors
when the model tries to re-read truncated tool outputs.
Security: *cwd* MUST be created by ``_make_sdk_cwd()`` which sanitizes
the session_id.
@@ -297,9 +298,6 @@ def _cleanup_sdk_tool_results(cwd: str) -> None:
logger.warning(f"[SDK] Rejecting cleanup for path outside workspace: {cwd}")
return
# Clean the CLI's project directory (transcripts + tool-results).
cleanup_cli_project_dir(cwd)
# Clean up the temp cwd directory itself.
try:
shutil.rmtree(normalized, ignore_errors=True)

View File

@@ -10,6 +10,8 @@ from pydantic import BaseModel
from backend.copilot.context import (
E2B_WORKDIR,
get_current_sandbox,
get_sdk_cwd,
is_allowed_local_path,
resolve_sandbox_path,
)
from backend.copilot.model import ChatSession
@@ -281,6 +283,47 @@ class WorkspaceFileContentResponse(ToolResponseBase):
content_base64: str
def _read_local_tool_result(
path: str,
char_offset: int,
char_length: Optional[int],
session_id: str,
) -> ToolResponseBase:
"""Read an SDK tool-result file from local disk.
This is a fallback for when the model mistakenly calls
``read_workspace_file`` with an SDK tool-result path that only exists on
the host filesystem, not in cloud workspace storage.
"""
expanded = os.path.realpath(os.path.expanduser(path))
try:
with open(expanded, encoding="utf-8", errors="replace") as fh:
text = fh.read()
except FileNotFoundError:
return ErrorResponse(message=f"File not found: {path}", session_id=session_id)
except Exception as exc:
return ErrorResponse(
message=f"Error reading {path}: {exc}", session_id=session_id
)
total_chars = len(text)
end = char_offset + char_length if char_length is not None else total_chars
slice_text = text[char_offset:end]
return WorkspaceFileContentResponse(
file_id="local",
name=os.path.basename(path),
path=path,
mime_type="application/json",
content_base64=base64.b64encode(slice_text.encode("utf-8")).decode("utf-8"),
message=(
f"Read chars {char_offset}\u2013{char_offset + len(slice_text)} "
f"of {total_chars:,} total from local tool-result {os.path.basename(path)}"
),
session_id=session_id,
)
class WorkspaceFileMetadataResponse(ToolResponseBase):
"""Response containing workspace file metadata and download URL (prevents context bloat)."""
@@ -539,6 +582,13 @@ class ReadWorkspaceFileTool(BaseTool):
manager = await get_manager(user_id, session_id)
resolved = await _resolve_file(manager, file_id, path, session_id)
if isinstance(resolved, ErrorResponse):
# Fallback: if the path is an SDK tool-result on local disk,
# read it directly instead of failing. The model sometimes
# calls read_workspace_file for these paths by mistake.
if path and is_allowed_local_path(path, get_sdk_cwd()):
return _read_local_tool_result(
path, char_offset, char_length, session_id
)
return resolved
target_file_id, file_info = resolved