diff --git a/src/agents/sandbox/capabilities/skills.py b/src/agents/sandbox/capabilities/skills.py index 5d58c6703f..1af7d826f6 100644 --- a/src/agents/sandbox/capabilities/skills.py +++ b/src/agents/sandbox/capabilities/skills.py @@ -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, " @@ -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: diff --git a/tests/test_lazy_skill_probe_unwrap.py b/tests/test_lazy_skill_probe_unwrap.py new file mode 100644 index 0000000000..334698d08a --- /dev/null +++ b/tests/test_lazy_skill_probe_unwrap.py @@ -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"}