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
26 changes: 25 additions & 1 deletion src/agents/sandbox/capabilities/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@
)
from .capability import Capability


def _unwrap_session_wrapper(session: BaseSandboxSession) -> BaseSandboxSession:
"""
If `session` is the SDK SandboxSession wrapper that instruments operations,
return the underlying unwrapped session (`_inner`) so callers can perform
a raw operation without creating instrumented spans. Defensive: do not
import the wrapper type to avoid dependency cycles; detect by name/module.
"""
cls = type(session)
if not (
cls.__name__ == "SandboxSession"
and cls.__module__ == "agents.sandbox.session.sandbox_session"
):
return session
inner = getattr(session, "_inner", None)
return inner if isinstance(inner, BaseSandboxSession) else session


_SKILLS_SECTION_INTRO = (
"A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. "
"Below is the list of skills that can be used. Each entry includes a name, description, "
Expand Down Expand Up @@ -235,8 +253,14 @@ async def load_skill(
workspace_root = Path(session.state.manifest.root)
skill_dest = workspace_root / metadata.path
skill_md_path = skill_dest / "SKILL.md"
# Probe for an existing SKILL.md. On first lazy load this is expected
# to raise a not-found error. Using the instrumented SandboxSession.read()
# records that as a failed trace span even though we handle the exception.
# Unwrap to the inner session implementation (when available) to avoid
# creating a noisy failed span for the expected probe.
probe_session = _unwrap_session_wrapper(session)
try:
handle = await session.read(skill_md_path, user=user)
handle = await probe_session.read(skill_md_path, user=user)
except Exception:
handle = None
if handle is not None:
Expand Down
84 changes: 84 additions & 0 deletions tests/test_lazy_skill_probe_unwrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from pathlib import Path

import pytest

from agents.sandbox.capabilities.skills import LocalDirLazySkillSource
from agents.sandbox.entries import LocalDir
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.workspace_paths import SandboxPathGrant


@pytest.mark.asyncio
async def test_lazy_skill_probe_unwrap_and_materialize(monkeypatch, tmp_path):
# Prepare a simple host skill layout: skills_root/example-skill/SKILL.md
skills_root = tmp_path / "skills"
skill_dir = skills_root / "example-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: example-skill\n---\n\n# Example", encoding="utf-8"
)

# Build the lazy source pointing at the host directory
source = LocalDir(src=skills_root)
lazy = LocalDirLazySkillSource(source=source)

# Minimal inner session stub (subclass BaseSandboxSession) whose read() raises FileNotFoundError
class MinimalInner(BaseSandboxSession):
async def read(self, path, *, user=None):
raise FileNotFoundError("simulated not found")

async def write(self, path, data, *, user=None):
return None

async def running(self):
return True

async def persist_workspace(self):
raise NotImplementedError

async def hydrate_workspace(self, data):
return None

async def _exec_internal(self, *command, timeout=None):
raise NotImplementedError

inner = MinimalInner()
# Create a wrapper-like object whose class name/module matches the detection logic,
# and expose the _inner attribute so _unwrap_session_wrapper() will return inner.
WrapperClass = type(
"SandboxSession",
(),
{"__module__": "agents.sandbox.session.sandbox_session"},
)
wrapper = WrapperClass()
wrapper._inner = inner

# Monkeypatch LocalDir.apply to a no-op so load_skill can complete without a real session.
async def _noop_apply(self, session, dest, base_dir=None, user=None):
return None

monkeypatch.setattr(LocalDir, "apply", _noop_apply)

# Provide .state.manifest.root and .extra_path_grants used by load_skill (minimal stand-in).
# Grant the host skills directory so the absolute source path resolves outside cwd.
class DummyManifest:
def __init__(self, root, extra_path_grants):
self.root = root
self.extra_path_grants = extra_path_grants

class DummyState:
def __init__(self, manifest):
self.manifest = manifest

dummy_root = Path("/workspace")
dummy_manifest = DummyManifest(
root=str(dummy_root),
extra_path_grants=(SandboxPathGrant(path=skills_root),),
)
wrapper.state = DummyState(manifest=dummy_manifest)
inner.state = dummy_manifest # harmless placeholder

result = await lazy.load_skill(
skill_name="example-skill", session=wrapper, skills_path=".agents"
)
assert result["status"] in {"loaded", "already_loaded"}