fix(backend/copilot): narrow sandbox exception handling, add missing tests, improve docs

- Replace bare `except Exception` in sandbox file read with specific
  exception types (FileNotFoundError, OSError, UnicodeDecodeError) plus
  E2B SandboxException, re-raising unexpected exceptions as real bugs.
- Expand __init__.py docstring explaining why lazy imports (PEP 562) are
  needed: tool_adapter uses TOOL_REGISTRY at module level, making the
  circular import cycle non-trivial to restructure.
- Add docstring to _is_tabular explaining why isinstance checks are
  appropriate (structural type guard on opaque Any, not duck typing).
- Add integration tests: bare ref binary format with line range (ignored),
  bare ref TOML parsing.
- Add pyarrow skipif markers to file_ref_test.py binary format tests.
This commit is contained in:
Zamil Majdy
2026-03-17 00:30:38 +07:00
parent 981891aebe
commit 4f7bab3375
4 changed files with 106 additions and 4 deletions

View File

@@ -4,8 +4,19 @@ This module provides the integration layer between the Claude Agent SDK
and the existing CoPilot tool system, enabling drop-in replacement of
the current LLM orchestration with the battle-tested Claude Agent SDK.
Submodule imports are deferred (PEP 562) to avoid circular imports:
tools → sdk → service → prompting → tools.
Submodule imports are deferred via PEP 562 ``__getattr__`` to break a
circular import cycle::
sdk/__init__ → tool_adapter → copilot.tools (TOOL_REGISTRY)
copilot.tools → run_block → sdk.file_ref (no cycle here, but…)
sdk/__init__ → service → copilot.prompting → copilot.tools (cycle!)
``tool_adapter`` uses ``TOOL_REGISTRY`` at **module level** to build the
static ``COPILOT_TOOL_NAMES`` list, so the import cannot be deferred to
function scope without a larger refactor (moving tool-name registration
to a separate lightweight module). The lazy-import pattern here is the
least invasive way to break the cycle while keeping module-level constants
intact.
"""
from typing import Any

View File

@@ -201,8 +201,25 @@ async def read_file_bytes(
) from exc
try:
data = bytes(await sandbox.files.read(remote, format="bytes"))
except Exception as exc:
except (FileNotFoundError, OSError, UnicodeDecodeError) as exc:
raise ValueError(f"Failed to read from sandbox: {plain}: {exc}") from exc
except Exception as exc:
# E2B SDK raises SandboxException subclasses (NotFoundException,
# TimeoutException, NotEnoughSpaceException, etc.) which don't
# inherit from standard exceptions. Import lazily to avoid a
# hard dependency on e2b at module level.
try:
from e2b.exceptions import SandboxException # noqa: PLC0415
if isinstance(exc, SandboxException):
raise ValueError(
f"Failed to read from sandbox: {plain}: {exc}"
) from exc
except ImportError:
pass
# Re-raise unexpected exceptions (TypeError, AttributeError, etc.)
# so they surface as real bugs rather than being silently masked.
raise
# NOTE: E2B sandbox API does not support pre-read size checks;
# the full file is loaded before the size guard below.
if len(data) > _MAX_BARE_REF_BYTES:
@@ -380,7 +397,12 @@ async def _infer_format_from_workspace(
def _is_tabular(parsed: Any) -> bool:
"""Check if parsed data is in tabular format: [[header], [row1], ...]."""
"""Check if parsed data is in tabular format: [[header], [row1], ...].
Uses isinstance checks because this is a structural type guard on
opaque parser output (Any), not duck typing. A Protocol wouldn't
help here — we need to verify exact list-of-lists shape.
"""
if not isinstance(parsed, list) or len(parsed) < 2:
return False
header = parsed[0]

View File

@@ -308,6 +308,66 @@ async def test_bare_ref_yaml_returns_parsed_dict():
assert result["config"] == {"name": "test", "count": 42}
@pytest.mark.asyncio
async def test_bare_ref_binary_with_line_range_ignores_range():
"""Bare ref to a binary file (.parquet) with line range parses the full file.
Binary formats (parquet, xlsx) ignore line ranges — the full content is
parsed and the range is silently dropped with a log warning.
"""
try:
import pandas as pd
except ImportError:
pytest.skip("pandas not installed")
try:
import pyarrow # noqa: F401 # pyright: ignore[reportMissingImports]
except ImportError:
pytest.skip("pyarrow not installed")
with tempfile.TemporaryDirectory() as sdk_cwd:
parquet_file = os.path.join(sdk_cwd, "data.parquet")
import io as _io
df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
buf = _io.BytesIO()
df.to_parquet(buf, index=False)
with open(parquet_file, "wb") as f:
f.write(buf.getvalue())
with patch("backend.copilot.context._current_sdk_cwd") as mock_cwd_var:
mock_cwd_var.get.return_value = sdk_cwd
# Line range [1-2] should be silently ignored for binary formats.
result = await expand_file_refs_in_args(
{"data": f"@@agptfile:{parquet_file}[1-2]"},
user_id="u1",
session=_make_session(),
)
# Full file is returned despite the line range.
assert result["data"] == [["A", "B"], [1, 4], [2, 5], [3, 6]]
@pytest.mark.asyncio
async def test_bare_ref_toml_returns_parsed_dict():
"""Bare ref to a .toml file returns parsed dict."""
with tempfile.TemporaryDirectory() as sdk_cwd:
toml_file = os.path.join(sdk_cwd, "config.toml")
with open(toml_file, "w") as f:
f.write('name = "test"\ncount = 42\n')
with patch("backend.copilot.context._current_sdk_cwd") as mock_cwd_var:
mock_cwd_var.get.return_value = sdk_cwd
result = await expand_file_refs_in_args(
{"config": f"@@agptfile:{toml_file}"},
user_id="u1",
session=_make_session(),
)
assert result["config"] == {"name": "test", "count": 42}
# ---------------------------------------------------------------------------
# _read_file_handler — extended to accept workspace:// and local paths
# ---------------------------------------------------------------------------

View File

@@ -8,6 +8,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pydantic
import pytest
try:
import pyarrow as _pa # noqa: F401 # pyright: ignore[reportMissingImports]
_has_pyarrow = True
except ImportError:
_has_pyarrow = False
from backend.copilot.sdk.file_ref import (
_MAX_BARE_REF_BYTES,
_MAX_EXPAND_CHARS,
@@ -1905,6 +1912,7 @@ async def test_adapt_dict_to_list_str_target_not_wrapped():
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not _has_pyarrow, reason="pyarrow not installed")
@pytest.mark.asyncio
async def test_bare_ref_binary_format_ignores_line_range():
"""Binary bare refs (parquet/xlsx) silently ignore line ranges and
@@ -1937,6 +1945,7 @@ async def test_bare_ref_binary_format_ignores_line_range():
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not _has_pyarrow, reason="pyarrow not installed")
@pytest.mark.asyncio
async def test_bare_ref_parquet_nan_replaced_with_none():
"""NaN values in Parquet bare refs must become None for JSON serializability."""