Skip to content
Closed
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
11 changes: 8 additions & 3 deletions src/agents/sandbox/capabilities/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

from ...tool import FunctionTool, Tool
from ..entries import BaseEntry, Dir, File, LocalDir, LocalFile
from ..errors import LocalDirReadError, SkillsConfigError
from ..errors import LocalDirReadError, SkillsConfigError, WorkspaceReadNotFoundError
from ..manifest import Manifest
from ..session.base_sandbox_session import BaseSandboxSession
from ..session.sandbox_session import expected_sandbox_error
from ..types import User
from ..workspace_paths import (
SandboxPathGrant,
Expand Down Expand Up @@ -236,8 +237,12 @@ async def load_skill(
skill_dest = workspace_root / metadata.path
skill_md_path = skill_dest / "SKILL.md"
try:
handle = await session.read(skill_md_path, user=user)
except Exception:
with (
expected_sandbox_error(FileNotFoundError),
expected_sandbox_error(WorkspaceReadNotFoundError),
):
handle = await session.read(skill_md_path, user=user)
except (FileNotFoundError, WorkspaceReadNotFoundError):
handle = None
if handle is not None:
handle.close()
Expand Down
33 changes: 27 additions & 6 deletions src/agents/sandbox/session/sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import ipaddress
import time
import uuid
from collections.abc import Callable, Coroutine
from contextlib import nullcontext
from collections.abc import Callable, Coroutine, Iterator
from contextlib import contextmanager, nullcontext
from contextvars import ContextVar
from functools import wraps
from pathlib import Path
from typing import Any, TypeVar, cast
Expand All @@ -28,6 +29,25 @@

T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Coroutine[object, object, object]])
_expected_sandbox_errors: ContextVar[tuple[type[BaseException], ...]] = ContextVar(
"expected_sandbox_errors",
default=(),
)


@contextmanager
def expected_sandbox_error(error_type: type[BaseException]) -> Iterator[None]:
"""Mark one sandbox operation error as expected by its caller."""

token = _expected_sandbox_errors.set((*_expected_sandbox_errors.get(), error_type))
try:
yield
finally:
_expected_sandbox_errors.reset(token)


def _is_expected_sandbox_error(error: BaseException) -> bool:
return isinstance(error, _expected_sandbox_errors.get())


def instrumented_op(
Expand Down Expand Up @@ -403,21 +423,22 @@ async def _annotate(
value = await run()
except Exception as e:
duration_ms = (time.monotonic() - t0) * 1000.0
expected_error = _is_expected_sandbox_error(e)
self._apply_trace_finish_data(
span=trace_span,
op=op,
ok=False,
ok=expected_error,
data=start_data,
exc=e,
exc=None if expected_error else e,
)
await self._emit_finish_event(
op=op,
span_id=span_id,
parent_span_id=parent_span_id,
trace_id=trace_id,
duration_ms=duration_ms,
ok=False,
exc=e,
ok=expected_error,
exc=None if expected_error else e,
data=start_data,
stdout=None,
stderr=None,
Expand Down
30 changes: 30 additions & 0 deletions tests/sandbox/test_session_sinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
WorkspaceJsonlSink,
)
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.session.sandbox_session import expected_sandbox_error
from agents.sandbox.snapshot import LocalSnapshot
from agents.tracing import custom_span, trace
from tests.testing_processor import fetch_normalized_spans, fetch_ordered_spans
Expand Down Expand Up @@ -455,6 +456,35 @@ async def test_sandbox_session_error_events_and_traces_include_retryability(
assert error_payload["error_retryable"] is False


@pytest.mark.asyncio
async def test_sandbox_session_expected_read_not_found_is_not_an_error_event(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[SandboxSessionEvent] = []
instrumentation = Instrumentation(
sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")]
)
inner = _build_unix_local_session(tmp_path)

async def raw_missing_read(path: Path, *, user: object = None) -> io.IOBase:
_ = (path, user)
raise FileNotFoundError("missing.txt")

monkeypatch.setattr(inner, "read", raw_missing_read)

async with SandboxSession(inner, instrumentation=instrumentation) as session:
with expected_sandbox_error(FileNotFoundError):
with pytest.raises(FileNotFoundError):
await session.read(Path("missing.txt"))

read_finish = [event for event in events if event.op == "read" and event.phase == "finish"][0]
assert isinstance(read_finish, SandboxSessionFinishEvent)
assert read_finish.ok is True
assert read_finish.error_type is None
assert read_finish.error_code is None


@pytest.mark.asyncio
async def test_sandbox_session_ops_nest_under_sdk_trace_and_events_carry_trace_ids(
tmp_path: Path,
Expand Down