# Framework Integrations Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add LangChain/LangGraph and OpenAI Agents SDK toolset integrations sharing a common base with the existing Pydantic AI toolset.

**Architecture:** Extract shared tool metadata and execution logic into `_base.py`, refactor Pydantic AI to delegate to it, then add thin LangChain and OpenAI Agents SDK wrappers. Each framework file is ~80-100 lines of glue.

**Tech Stack:** Python 3.11+, Pydantic v2, langchain-core, openai-agents, pytest, pytest-asyncio

---

### Task 1: Create shared base (`_base.py`)

**Files:**
- Create: `src/agentsh_secure_sandbox/toolset/_base.py`
- Test: `tests/test_toolset_base.py`

- [ ] **Step 1: Write failing tests for `SecureSandboxTools`**

Create `tests/test_toolset_base.py`:

```python
# tests/test_toolset_base.py
from __future__ import annotations

import pytest
from unittest.mock import AsyncMock, MagicMock

from agentsh_secure_sandbox.toolset._base import SecureSandboxTools, TOOL_DEFS


# --- TOOL_DEFS ---

def test_tool_defs_has_three_tools():
    assert set(TOOL_DEFS.keys()) == {"run_command", "write_file", "read_file"}


def test_tool_defs_have_required_fields():
    for name, defn in TOOL_DEFS.items():
        assert "name" in defn
        assert "description" in defn
        assert "parameters" in defn
        assert defn["name"] == name


# --- Lifecycle ---

async def test_sandbox_not_provisioned():
    adapter = MagicMock()
    tools = SecureSandboxTools(adapter)
    with pytest.raises(RuntimeError, match="not provisioned"):
        _ = tools.sandbox


async def test_lifecycle_provisions_and_stops(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    adapter = MagicMock()
    tools = SecureSandboxTools(adapter)
    async with tools:
        assert tools.sandbox is mock_sandbox
    mock_sandbox.stop.assert_awaited_once()


# --- call_tool: run_command ---

async def test_call_tool_run_command(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.exec = AsyncMock(return_value=MagicMock(
        exit_code=0, stdout="hello\n", stderr="",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        result = await tools.call_tool("run_command", {"command": "echo hello"})
    assert result == "hello\n"


async def test_call_tool_run_command_with_cwd(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.exec = AsyncMock(return_value=MagicMock(
        exit_code=0, stdout="/tmp\n", stderr="",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        await tools.call_tool("run_command", {"command": "pwd", "cwd": "/tmp"})
    mock_sandbox.exec.assert_awaited_once_with("pwd", cwd="/tmp")


async def test_call_tool_run_command_failure(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.exec = AsyncMock(return_value=MagicMock(
        exit_code=1, stdout="", stderr="not found",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        result = await tools.call_tool("run_command", {"command": "bad_cmd"})
    assert "Command failed (exit 1):" in result
    assert "not found" in result


# --- call_tool: write_file ---

async def test_call_tool_write_file_success(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.write_file = AsyncMock(return_value=MagicMock(
        success=True, path="/workspace/test.txt",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        result = await tools.call_tool("write_file", {"path": "/workspace/test.txt", "content": "hello"})
    assert "Written to /workspace/test.txt" in result


async def test_call_tool_write_file_denied(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.write_file = AsyncMock(return_value=MagicMock(
        success=False, path="/root/.env", error="denied by policy",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        result = await tools.call_tool("write_file", {"path": "/root/.env", "content": "SECRET=x"})
    assert "Write denied" in result


# --- call_tool: read_file ---

async def test_call_tool_read_file_success(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.read_file = AsyncMock(return_value=MagicMock(
        success=True, content="file content",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        result = await tools.call_tool("read_file", {"path": "/workspace/test.txt"})
    assert result == "file content"


async def test_call_tool_read_file_denied(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.read_file = AsyncMock(return_value=MagicMock(
        success=False, error="denied by policy",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        result = await tools.call_tool("read_file", {"path": "/root/.ssh/id_rsa"})
    assert "Read denied" in result


# --- call_tool: unknown ---

async def test_call_tool_unknown(monkeypatch):
    mock_secure = AsyncMock(return_value=AsyncMock())
    monkeypatch.setattr("agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure)

    tools = SecureSandboxTools(MagicMock())
    async with tools:
        with pytest.raises(ValueError, match="Unknown tool"):
            await tools.call_tool("delete_file", {"path": "/tmp/x"})
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_toolset_base.py -v`
Expected: FAIL — `ModuleNotFoundError` because `_base.py` doesn't exist yet.

- [ ] **Step 3: Implement `_base.py`**

Create `src/agentsh_secure_sandbox/toolset/_base.py` with the code from the spec Section 2.2 verbatim.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_toolset_base.py -v`
Expected: All 12 tests PASS.

- [ ] **Step 5: Lint and type check**

Run: `ruff check src/agentsh_secure_sandbox/toolset/_base.py && mypy src/agentsh_secure_sandbox/toolset/_base.py`
Expected: Clean.

- [ ] **Step 6: Commit**

```bash
git add src/agentsh_secure_sandbox/toolset/_base.py tests/test_toolset_base.py
git commit -m "feat: extract shared SecureSandboxTools base for framework integrations"
```

---

### Task 2: Refactor Pydantic AI toolset to delegate to `_base.py`

**Files:**
- Modify: `src/agentsh_secure_sandbox/toolset/pydantic_ai.py`
- Verify: `tests/test_toolset.py` (existing tests must still pass)

- [ ] **Step 1: Run existing tests to establish baseline**

Run: `pytest tests/test_toolset.py -v`
Expected: All 3 tests PASS.

- [ ] **Step 2: Update existing test to match new error message**

The base class uses `"not provisioned"` instead of `"not initialized"`. Update `tests/test_toolset.py` line 14:

```python
# Change:
with pytest.raises(RuntimeError, match="not initialized"):
# To:
with pytest.raises(RuntimeError, match="not provisioned"):
```

- [ ] **Step 3: Refactor `pydantic_ai.py` to use `SecureSandboxTools`**

Key changes:
- Replace `_TOOL_DEFS` dict with construction from `TOOL_DEFS` imported from `_base`
- Replace `__init__` to create a `SecureSandboxTools` internally
- Delegate `__aenter__`/`__aexit__` to `self._tools`
- Delegate `call_tool` to `self._tools.call_tool(name, tool_args)`
- `sandbox` property delegates to `self._tools.sandbox`
- Keep `id`, `get_tools`, and the `AbstractToolset` interface unchanged

```python
# src/agentsh_secure_sandbox/toolset/pydantic_ai.py
from __future__ import annotations

from typing import Any

from typing_extensions import Self

from pydantic_ai import RunContext
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import AbstractToolset, ToolsetTool
from pydantic_core import SchemaValidator, core_schema

from ..core.types import SecuredSandbox, SandboxAdapter, SecureConfig
from ._base import SecureSandboxTools, TOOL_DEFS

AgentDepsT = Any


class SecureSandboxToolset(AbstractToolset[AgentDepsT]):
    """Pydantic AI Toolset that wraps a sandbox provider with agentsh
    security enforcement.

    Lifecycle:
    - One sandbox per toolset instance (1:1 ownership).
    - Not concurrency-safe. Do not share across concurrent agent runs.
    - After stop(), the instance is dead. Create a new one for a new run.
    - Provisioning happens eagerly in __aenter__, not lazily per tool call.
    """

    _TOOL_DEFS: dict[str, ToolDefinition] = {
        name: ToolDefinition(
            name=defn["name"],
            description=defn["description"],
            parameters_json_schema=defn["parameters"],
        )
        for name, defn in TOOL_DEFS.items()
    }

    def __init__(
        self,
        adapter: SandboxAdapter,
        config: SecureConfig | None = None,
        *,
        toolset_id: str = "agentsh-sandbox",
    ) -> None:
        self._tools = SecureSandboxTools(adapter, config)
        self._toolset_id = toolset_id

    @property
    def id(self) -> str:
        return self._toolset_id

    async def __aenter__(self) -> Self:
        await self._tools.__aenter__()
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self._tools.__aexit__(*args)

    @property
    def sandbox(self) -> SecuredSandbox:
        return self._tools.sandbox

    _ARGS_VALIDATOR = SchemaValidator(core_schema.dict_schema())

    async def get_tools(
        self,
        ctx: RunContext[AgentDepsT],
    ) -> dict[str, ToolsetTool[AgentDepsT]]:
        return {
            name: ToolsetTool(
                tool_def=defn,
                toolset=self,
                max_retries=0,
                args_validator=self._ARGS_VALIDATOR,
            )
            for name, defn in self._TOOL_DEFS.items()
        }

    async def call_tool(
        self,
        name: str,
        tool_args: dict[str, Any],
        ctx: RunContext[AgentDepsT],
        tool: ToolsetTool[AgentDepsT],
    ) -> Any:
        return await self._tools.call_tool(name, tool_args)
```

- [ ] **Step 4: Run existing tests to verify no regression**

Run: `pytest tests/test_toolset.py -v`
Expected: All 3 tests PASS.

- [ ] **Step 5: Run full test suite**

Run: `pytest tests/ -v --ignore=tests/e2e`
Expected: All tests PASS.

- [ ] **Step 6: Lint and type check**

Run: `ruff check src/agentsh_secure_sandbox/toolset/pydantic_ai.py && mypy src/agentsh_secure_sandbox/toolset/pydantic_ai.py`
Expected: Clean.

- [ ] **Step 7: Commit**

```bash
git add src/agentsh_secure_sandbox/toolset/pydantic_ai.py tests/test_toolset.py
git commit -m "refactor: delegate Pydantic AI toolset to shared SecureSandboxTools base"
```

---

### Task 3: Add LangChain/LangGraph integration

**Files:**
- Create: `src/agentsh_secure_sandbox/toolset/langchain.py`
- Create: `tests/test_toolset_langchain.py`

- [ ] **Step 1: Write failing tests**

Create `tests/test_toolset_langchain.py`:

```python
# tests/test_toolset_langchain.py
from __future__ import annotations

import pytest

langchain_core = pytest.importorskip("langchain_core")

from unittest.mock import AsyncMock, MagicMock  # noqa: E402

from agentsh_secure_sandbox.toolset.langchain import SecureSandboxToolkit  # noqa: E402


async def test_toolkit_get_tools_returns_three(monkeypatch):
    mock_secure = AsyncMock(return_value=AsyncMock())
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    toolkit = SecureSandboxToolkit(MagicMock())
    async with toolkit:
        tools = toolkit.get_tools()
    assert len(tools) == 3
    names = {t.name for t in tools}
    assert names == {"run_command", "write_file", "read_file"}


async def test_toolkit_tools_are_base_tool(monkeypatch):
    from langchain_core.tools import BaseTool

    mock_secure = AsyncMock(return_value=AsyncMock())
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    toolkit = SecureSandboxToolkit(MagicMock())
    async with toolkit:
        for tool in toolkit.get_tools():
            assert isinstance(tool, BaseTool)


async def test_run_command_arun(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.exec = AsyncMock(return_value=MagicMock(
        exit_code=0, stdout="hello\n", stderr="",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    toolkit = SecureSandboxToolkit(MagicMock())
    async with toolkit:
        tools = {t.name: t for t in toolkit.get_tools()}
        result = await tools["run_command"].ainvoke({"command": "echo hello"})
    assert result == "hello\n"


async def test_write_file_arun(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.write_file = AsyncMock(return_value=MagicMock(
        success=True, path="/workspace/test.txt",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    toolkit = SecureSandboxToolkit(MagicMock())
    async with toolkit:
        tools = {t.name: t for t in toolkit.get_tools()}
        result = await tools["write_file"].ainvoke({"path": "/workspace/test.txt", "content": "hi"})
    assert "Written to" in result


async def test_read_file_arun(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.read_file = AsyncMock(return_value=MagicMock(
        success=True, content="file content",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    toolkit = SecureSandboxToolkit(MagicMock())
    async with toolkit:
        tools = {t.name: t for t in toolkit.get_tools()}
        result = await tools["read_file"].ainvoke({"path": "/workspace/test.txt"})
    assert result == "file content"


async def test_sync_run_raises(monkeypatch):
    mock_secure = AsyncMock(return_value=AsyncMock())
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    toolkit = SecureSandboxToolkit(MagicMock())
    async with toolkit:
        tools = {t.name: t for t in toolkit.get_tools()}
        with pytest.raises(NotImplementedError):
            tools["run_command"]._run(command="echo hello")


def test_toolkit_sandbox_not_provisioned():
    toolkit = SecureSandboxToolkit(MagicMock())
    with pytest.raises(RuntimeError, match="not provisioned"):
        _ = toolkit.sandbox
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_toolset_langchain.py -v`
Expected: FAIL — `ModuleNotFoundError` for `langchain.py`. If `langchain-core` is not installed, tests are skipped (that's OK).

- [ ] **Step 3: Install langchain-core dev dependency**

Run: `pip install langchain-core>=0.3`

- [ ] **Step 4: Implement `langchain.py`**

Create `src/agentsh_secure_sandbox/toolset/langchain.py`:

```python
# src/agentsh_secure_sandbox/toolset/langchain.py
"""LangChain/LangGraph integration — SecureSandboxToolkit.

Exposes agentsh-secured sandbox tools as LangChain BaseTool instances
for use with create_react_agent, ToolNode, or any LangChain agent.
"""
from __future__ import annotations

from typing import Any, Type

from typing_extensions import Self

from pydantic import BaseModel, Field, PrivateAttr
from langchain_core.tools import BaseTool

from ..core.types import SecuredSandbox, SandboxAdapter, SecureConfig
from ._base import SecureSandboxTools, TOOL_DEFS


# --- Pydantic input schemas for BaseTool args_schema ---


class RunCommandInput(BaseModel):
    command: str = Field(description="Shell command to execute")
    cwd: str | None = Field(default=None, description="Working directory (default: /workspace)")


class WriteFileInput(BaseModel):
    path: str = Field(description="File path to write")
    content: str = Field(description="File content")


class ReadFileInput(BaseModel):
    path: str = Field(description="File path to read")


# --- BaseTool subclasses ---


class _RunCommandTool(BaseTool):
    name: str = "run_command"
    description: str = TOOL_DEFS["run_command"]["description"]
    args_schema: Type[BaseModel] = RunCommandInput
    _tools: SecureSandboxTools = PrivateAttr()

    def __init__(self, tools: SecureSandboxTools, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self._tools = tools

    def _run(self, **kwargs: Any) -> str:
        raise NotImplementedError(
            "SecureSandboxToolkit tools are async-only. "
            "Use ainvoke() or an async LangGraph agent."
        )

    async def _arun(self, command: str, cwd: str | None = None, **kwargs: Any) -> str:
        return await self._tools.call_tool("run_command", {"command": command, "cwd": cwd})


class _WriteFileTool(BaseTool):
    name: str = "write_file"
    description: str = TOOL_DEFS["write_file"]["description"]
    args_schema: Type[BaseModel] = WriteFileInput
    _tools: SecureSandboxTools = PrivateAttr()

    def __init__(self, tools: SecureSandboxTools, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self._tools = tools

    def _run(self, **kwargs: Any) -> str:
        raise NotImplementedError(
            "SecureSandboxToolkit tools are async-only. "
            "Use ainvoke() or an async LangGraph agent."
        )

    async def _arun(self, path: str, content: str, **kwargs: Any) -> str:
        return await self._tools.call_tool("write_file", {"path": path, "content": content})


class _ReadFileTool(BaseTool):
    name: str = "read_file"
    description: str = TOOL_DEFS["read_file"]["description"]
    args_schema: Type[BaseModel] = ReadFileInput
    _tools: SecureSandboxTools = PrivateAttr()

    def __init__(self, tools: SecureSandboxTools, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self._tools = tools

    def _run(self, **kwargs: Any) -> str:
        raise NotImplementedError(
            "SecureSandboxToolkit tools are async-only. "
            "Use ainvoke() or an async LangGraph agent."
        )

    async def _arun(self, path: str, **kwargs: Any) -> str:
        return await self._tools.call_tool("read_file", {"path": path})


# --- Toolkit ---


class SecureSandboxToolkit:
    """LangChain toolkit providing agentsh-secured sandbox tools.

    Not concurrency-safe. Do not share across concurrent agent runs.

    Usage::

        toolkit = SecureSandboxToolkit(daytona(raw))
        async with toolkit:
            agent = create_react_agent(model, tools=toolkit.get_tools())
            result = agent.invoke({"messages": [...]})
    """

    def __init__(
        self,
        adapter: SandboxAdapter,
        config: SecureConfig | None = None,
    ) -> None:
        self._tools = SecureSandboxTools(adapter, config)

    async def __aenter__(self) -> Self:
        await self._tools.__aenter__()
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self._tools.__aexit__(*args)

    @property
    def sandbox(self) -> SecuredSandbox:
        return self._tools.sandbox

    def get_tools(self) -> list[BaseTool]:
        return [
            _RunCommandTool(self._tools),
            _WriteFileTool(self._tools),
            _ReadFileTool(self._tools),
        ]
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `pytest tests/test_toolset_langchain.py -v`
Expected: All 7 tests PASS.

- [ ] **Step 6: Lint and type check**

Run: `ruff check src/agentsh_secure_sandbox/toolset/langchain.py && mypy src/agentsh_secure_sandbox/toolset/langchain.py`
Expected: Clean (may need minor adjustments for BaseTool's Pydantic model internals).

- [ ] **Step 7: Commit**

```bash
git add src/agentsh_secure_sandbox/toolset/langchain.py tests/test_toolset_langchain.py
git commit -m "feat: add LangChain/LangGraph SecureSandboxToolkit integration"
```

---

### Task 4: Add OpenAI Agents SDK integration

**Files:**
- Create: `src/agentsh_secure_sandbox/toolset/openai_agents.py`
- Create: `tests/test_toolset_openai_agents.py`

- [ ] **Step 1: Write failing tests**

Create `tests/test_toolset_openai_agents.py`:

```python
# tests/test_toolset_openai_agents.py
from __future__ import annotations

import json

import pytest

agents_mod = pytest.importorskip("agents")

from unittest.mock import AsyncMock, MagicMock  # noqa: E402

from agentsh_secure_sandbox.toolset.openai_agents import SecureSandboxAgentTools  # noqa: E402


async def test_agent_tools_get_tools_returns_three(monkeypatch):
    mock_secure = AsyncMock(return_value=AsyncMock())
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    tools = SecureSandboxAgentTools(MagicMock())
    async with tools:
        tool_list = tools.get_tools()
    assert len(tool_list) == 3
    names = {t.name for t in tool_list}
    assert names == {"run_command", "write_file", "read_file"}


async def test_agent_tools_are_function_tool(monkeypatch):
    from agents import FunctionTool

    mock_secure = AsyncMock(return_value=AsyncMock())
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    tools = SecureSandboxAgentTools(MagicMock())
    async with tools:
        for tool in tools.get_tools():
            assert isinstance(tool, FunctionTool)


async def test_run_command_invoke(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.exec = AsyncMock(return_value=MagicMock(
        exit_code=0, stdout="hello\n", stderr="",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    tools = SecureSandboxAgentTools(MagicMock())
    async with tools:
        tool_map = {t.name: t for t in tools.get_tools()}
        ctx = MagicMock()
        result = await tool_map["run_command"].on_invoke_tool(
            ctx, json.dumps({"command": "echo hello"}),
        )
    assert result == "hello\n"


async def test_write_file_invoke(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.write_file = AsyncMock(return_value=MagicMock(
        success=True, path="/workspace/test.txt",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    tools = SecureSandboxAgentTools(MagicMock())
    async with tools:
        tool_map = {t.name: t for t in tools.get_tools()}
        ctx = MagicMock()
        result = await tool_map["write_file"].on_invoke_tool(
            ctx, json.dumps({"path": "/workspace/test.txt", "content": "hi"}),
        )
    assert "Written to" in result


async def test_read_file_invoke(monkeypatch):
    mock_sandbox = AsyncMock()
    mock_sandbox.read_file = AsyncMock(return_value=MagicMock(
        success=True, content="file content",
    ))
    mock_secure = AsyncMock(return_value=mock_sandbox)
    monkeypatch.setattr(
        "agentsh_secure_sandbox.toolset._base.secure_sandbox", mock_secure,
    )
    tools = SecureSandboxAgentTools(MagicMock())
    async with tools:
        tool_map = {t.name: t for t in tools.get_tools()}
        ctx = MagicMock()
        result = await tool_map["read_file"].on_invoke_tool(
            ctx, json.dumps({"path": "/workspace/test.txt"}),
        )
    assert result == "file content"


def test_agent_tools_sandbox_not_provisioned():
    tools = SecureSandboxAgentTools(MagicMock())
    with pytest.raises(RuntimeError, match="not provisioned"):
        _ = tools.sandbox
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_toolset_openai_agents.py -v`
Expected: FAIL or SKIP (if `openai-agents` not installed).

- [ ] **Step 3: Install openai-agents dev dependency**

Run: `pip install openai-agents>=0.1`

- [ ] **Step 4: Implement `openai_agents.py`**

Create `src/agentsh_secure_sandbox/toolset/openai_agents.py`:

```python
# src/agentsh_secure_sandbox/toolset/openai_agents.py
"""OpenAI Agents SDK integration — SecureSandboxAgentTools.

Exposes agentsh-secured sandbox tools as OpenAI FunctionTool instances
for use with Agent and Runner.
"""
from __future__ import annotations

import json
from typing import Any

from typing_extensions import Self

from agents import FunctionTool, RunContextWrapper

from ..core.types import SecuredSandbox, SandboxAdapter, SecureConfig
from ._base import SecureSandboxTools, TOOL_DEFS


class SecureSandboxAgentTools:
    """OpenAI Agents SDK tools providing agentsh-secured sandbox access.

    Not concurrency-safe. Do not share across concurrent agent runs.

    Usage::

        sandbox_tools = SecureSandboxAgentTools(e2b(raw))
        async with sandbox_tools:
            agent = Agent(name="Coder", tools=sandbox_tools.get_tools())
            result = await Runner.run(agent, "Create a server")
    """

    def __init__(
        self,
        adapter: SandboxAdapter,
        config: SecureConfig | None = None,
    ) -> None:
        self._tools = SecureSandboxTools(adapter, config)

    async def __aenter__(self) -> Self:
        await self._tools.__aenter__()
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self._tools.__aexit__(*args)

    @property
    def sandbox(self) -> SecuredSandbox:
        return self._tools.sandbox

    def get_tools(self) -> list[FunctionTool]:
        return [
            self._make_tool("run_command"),
            self._make_tool("write_file"),
            self._make_tool("read_file"),
        ]

    def _make_tool(self, name: str) -> FunctionTool:
        defn = TOOL_DEFS[name]
        tools_ref = self._tools

        async def on_invoke(ctx: RunContextWrapper[Any], args_json: str) -> str:
            args = json.loads(args_json)
            return await tools_ref.call_tool(name, args)

        return FunctionTool(
            name=defn["name"],
            description=defn["description"],
            params_json_schema=defn["parameters"],
            on_invoke_tool=on_invoke,
        )
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `pytest tests/test_toolset_openai_agents.py -v`
Expected: All 6 tests PASS.

- [ ] **Step 6: Lint and type check**

Run: `ruff check src/agentsh_secure_sandbox/toolset/openai_agents.py && mypy src/agentsh_secure_sandbox/toolset/openai_agents.py`
Expected: Clean.

- [ ] **Step 7: Commit**

```bash
git add src/agentsh_secure_sandbox/toolset/openai_agents.py tests/test_toolset_openai_agents.py
git commit -m "feat: add OpenAI Agents SDK SecureSandboxAgentTools integration"
```

---

### Task 5: Package wiring (exports, extras, __init__.py)

**Files:**
- Modify: `src/agentsh_secure_sandbox/__init__.py`
- Modify: `pyproject.toml`

- [ ] **Step 1: Add optional dependencies to `pyproject.toml`**

Add after the existing `pydantic-ai` line (grouped with framework integration extras):

```toml
langchain = ["langchain-core>=0.3"]
openai-agents = ["openai-agents>=0.1"]
```

Also add `langchain-core` and `openai-agents` to the `dev` extras so tests run in CI:

```toml
dev = [
    ...existing deps...,
    "langchain-core>=0.3",
    "openai-agents>=0.1",
]
```

- [ ] **Step 2: Add lazy imports to `__init__.py`**

After the existing `SecureSandboxToolset` try/except block, add:

```python
try:
    from .toolset.langchain import SecureSandboxToolkit
except ModuleNotFoundError as _exc:
    if _exc.name and _exc.name.split(".")[0] in ("langchain", "langchain_core"):

        def _missing_toolkit(*_a: object, **_kw: object) -> object:
            raise ImportError(
                "SecureSandboxToolkit requires the langchain extra. "
                "Install it with: pip install agentsh-secure-sandbox[langchain]"
            )

        SecureSandboxToolkit = _missing_toolkit  # type: ignore[assignment,misc]
    else:
        raise

try:
    from .toolset.openai_agents import SecureSandboxAgentTools
except ModuleNotFoundError as _exc:
    if _exc.name and _exc.name.split(".")[0] in ("agents", "openai_agents"):

        def _missing_agent_tools(*_a: object, **_kw: object) -> object:
            raise ImportError(
                "SecureSandboxAgentTools requires the openai-agents extra. "
                "Install it with: pip install agentsh-secure-sandbox[openai-agents]"
            )

        SecureSandboxAgentTools = _missing_agent_tools  # type: ignore[assignment,misc]
    else:
        raise
```

- [ ] **Step 3: Add to `__all__`**

Add `"SecureSandboxToolkit"` and `"SecureSandboxAgentTools"` to the `__all__` list.

- [ ] **Step 4: Run full test suite**

Run: `pytest tests/ -v --ignore=tests/e2e`
Expected: All tests PASS. Existing tests unaffected.

- [ ] **Step 5: Lint and type check**

Run: `ruff check src/agentsh_secure_sandbox/__init__.py && mypy src/agentsh_secure_sandbox/__init__.py`
Expected: Clean.

- [ ] **Step 6: Commit**

```bash
git add src/agentsh_secure_sandbox/__init__.py pyproject.toml
git commit -m "feat: add langchain and openai-agents optional extras and exports"
```

---

### Task 6: Update documentation

**Files:**
- Modify: `README.md`
- Modify: `docs/spec.md`

- [ ] **Step 1: Update README.md**

After the existing "Pydantic AI Integration" section, add a "Framework Integrations" section:

```markdown
## Framework Integrations

Beyond Pydantic AI, `agentsh-secure-sandbox` provides native integrations for popular AI agent frameworks. Each integration exposes the same three tools (`run_command`, `write_file`, `read_file`) mediated by agentsh policy.

### LangChain / LangGraph

```bash
pip install agentsh-secure-sandbox[langchain,daytona]
```

```python
from langchain_anthropic import ChatAnthropic
from agentsh_secure_sandbox import SecureSandboxToolkit
from agentsh_secure_sandbox.adapters import daytona
from langgraph.prebuilt import create_react_agent
from daytona_sdk import Daytona

raw = await Daytona().create()
toolkit = SecureSandboxToolkit(daytona(raw))

async with toolkit:
    agent = create_react_agent(
        ChatAnthropic(model="claude-sonnet-4-6"),
        tools=toolkit.get_tools(),
    )
    result = agent.invoke({
        "messages": [{"role": "user", "content": "List files in /workspace"}]
    })
```

### OpenAI Agents SDK

```bash
pip install agentsh-secure-sandbox[openai-agents,e2b]
```

```python
from agents import Agent, Runner
from agentsh_secure_sandbox import SecureSandboxAgentTools
from agentsh_secure_sandbox.adapters import e2b
from e2b import AsyncSandbox

raw = await AsyncSandbox.create()
sandbox_tools = SecureSandboxAgentTools(e2b(raw))

async with sandbox_tools:
    agent = Agent(
        name="Coder",
        instructions="You are a coding assistant.",
        tools=sandbox_tools.get_tools(),
    )
    result = await Runner.run(agent, "Create a hello world Express server")
    print(result.final_output)
```
```

Also update the opening description line to include the new frameworks:
```
Built for [Pydantic AI](https://ai.pydantic.dev/), [LangChain](https://python.langchain.com/), and the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/).
```

- [ ] **Step 2: Update docs/spec.md**

In the package structure tree (Section 4), add the new toolset files:

```
    toolset/
        __init__.py
        _base.py          # Shared tool defs + call logic
        pydantic_ai.py    # Pydantic AI toolset (delegates to _base)
        langchain.py      # LangChain/LangGraph integration
        openai_agents.py  # OpenAI Agents SDK integration
```

Add a "Framework Integrations" subsection documenting:
- The shared `SecureSandboxTools` base class pattern
- `TOOL_DEFS` dictionary structure
- `call_tool()` dispatch method
- Optional dependency extras (`langchain`, `openai-agents`)

- [ ] **Step 3: Commit**

```bash
git add README.md docs/spec.md
git commit -m "docs: add LangChain and OpenAI Agents SDK integration examples"
```

---

### Task 7: Final validation

- [ ] **Step 1: Run full test suite with coverage**

Run: `pytest tests/ -v --ignore=tests/e2e`
Expected: All tests PASS.

- [ ] **Step 2: Run linter on all changed files**

Run: `ruff check src/agentsh_secure_sandbox/toolset/ src/agentsh_secure_sandbox/__init__.py`
Expected: Clean.

- [ ] **Step 3: Run type checker**

Run: `mypy src/agentsh_secure_sandbox/toolset/ src/agentsh_secure_sandbox/__init__.py`
Expected: Clean.

- [ ] **Step 4: Verify imports work with deps installed**

```bash
python -c "from agentsh_secure_sandbox import SecureSandboxToolkit; print('OK: SecureSandboxToolkit imported')"
python -c "from agentsh_secure_sandbox import SecureSandboxAgentTools; print('OK: SecureSandboxAgentTools imported')"
```

Expected: Both print OK (deps are in dev extras). If either fails, the lazy import guard or the module itself has an error.

- [ ] **Step 5: Push and open PR**

```bash
git push origin HEAD
gh pr create --title "feat: LangChain + OpenAI Agents SDK integrations" --body "..."
```
