# Framework Integrations Design

**Date:** 2026-03-30
**Status:** Draft
**Scope:** Phase 1 — LangChain/LangGraph + OpenAI Agents SDK

---

## 1. Goal

Add native toolset integrations for Python AI agent frameworks beyond Pydantic AI. Phase 1 covers LangChain/LangGraph and OpenAI Agents SDK. Phase 2 (future) covers CrewAI, Google ADK, Claude Agent SDK, and AutoGen/Semantic Kernel.

Each integration exposes the same 3 tools (`run_command`, `write_file`, `read_file`) mediated by agentsh policy, using the framework's native tool registration API.

---

## 2. Architecture

### 2.1 Shared base

Extract tool metadata and execution logic from the Pydantic AI toolset into a framework-agnostic base class.

```
src/agentsh_secure_sandbox/
    toolset/
        __init__.py              # Empty — imports go through top-level __init__.py
        _base.py                 # NEW — shared tool defs + call logic
        pydantic_ai.py           # REFACTORED — delegates to _base
        langchain.py             # NEW — LangChain/LangGraph integration
        openai_agents.py         # NEW — OpenAI Agents SDK integration
```

### 2.2 `_base.py` — `SecureSandboxTools`

Framework-agnostic class that owns the adapter-to-sandbox lifecycle and provides tool execution. This is an internal class (not exported in `__all__`) — framework-specific wrappers are the public API.

**Concurrency:** Not concurrency-safe. Do not share a single instance across concurrent agent runs. Each concurrent run needs its own instance.

```python
from __future__ import annotations
from typing import Any
from typing_extensions import Self

from ..core.types import SecuredSandbox, SandboxAdapter, SecureConfig
from .._api import secure_sandbox


# Tool metadata shared by all framework integrations.
TOOL_DEFS: dict[str, dict[str, Any]] = {
    "run_command": {
        "name": "run_command",
        "description": (
            "Execute a shell command in the secure sandbox. "
            "All commands are mediated by security policy. "
            "Dangerous operations (reading secrets, network exfiltration, "
            "privilege escalation) will be blocked."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "Shell command to execute",
                },
                "cwd": {
                    "type": "string",
                    "description": "Working directory (default: /workspace)",
                },
            },
            "required": ["command"],
            "additionalProperties": False,
        },
    },
    "write_file": {
        "name": "write_file",
        "description": (
            "Write content to a file in the secure sandbox. "
            "Writes to sensitive paths (secrets, config files) "
            "will be blocked by policy."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "File path to write",
                },
                "content": {
                    "type": "string",
                    "description": "File content",
                },
            },
            "required": ["path", "content"],
            "additionalProperties": False,
        },
    },
    "read_file": {
        "name": "read_file",
        "description": (
            "Read a file from the secure sandbox. "
            "Reads from sensitive paths (secrets, credentials) "
            "will be blocked by policy."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "File path to read",
                },
            },
            "required": ["path"],
            "additionalProperties": False,
        },
    },
}


class SecureSandboxTools:
    """Framework-agnostic secure sandbox tool provider.

    Owns the adapter -> SecuredSandbox lifecycle.
    Subclassed or composed by framework-specific integrations.
    """

    def __init__(
        self,
        adapter: SandboxAdapter,
        config: SecureConfig | None = None,
    ) -> None:
        self._adapter = adapter
        self._config = config or SecureConfig()
        self._sandbox: SecuredSandbox | None = None

    async def __aenter__(self) -> Self:
        self._sandbox = await secure_sandbox(self._adapter, self._config)
        return self

    async def __aexit__(self, *args: Any) -> None:
        if self._sandbox:
            await self._sandbox.stop()
            self._sandbox = None

    @property
    def sandbox(self) -> SecuredSandbox:
        if self._sandbox is None:
            raise RuntimeError(
                "Sandbox not provisioned. Use 'async with' to enter the context."
            )
        return self._sandbox

    async def call_tool(self, name: str, args: dict[str, Any]) -> str:
        """Execute a tool call against the secured sandbox.

        Returns a string result suitable for returning to the LLM.
        """
        sb = self.sandbox

        if name == "run_command":
            result = await sb.exec(
                args["command"],
                cwd=args.get("cwd"),
            )
            if result.exit_code != 0:
                parts = [f"Command failed (exit {result.exit_code}):"]
                if result.stdout:
                    parts.append(f"stdout:\n{result.stdout}")
                if result.stderr:
                    parts.append(f"stderr:\n{result.stderr}")
                return "\n".join(parts)
            return result.stdout

        if name == "write_file":
            write_result = await sb.write_file(args["path"], args["content"])
            if write_result.success:
                return f"Written to {write_result.path}"
            return f"Write denied: {write_result.error}"

        if name == "read_file":
            read_result = await sb.read_file(args["path"])
            if read_result.success:
                return read_result.content
            return f"Read denied: {read_result.error}"

        raise ValueError(f"Unknown tool: {name}")
```

### 2.3 Pydantic AI refactor

`pydantic_ai.py` is refactored to delegate to `SecureSandboxTools`:

- `__init__` creates a `SecureSandboxTools` instance internally
- `__aenter__` / `__aexit__` delegate to it
- `call_tool` delegates to `self._tools.call_tool(name, tool_args)`
- Tool definitions are built from `TOOL_DEFS`, mapping `TOOL_DEFS[name]["parameters"]` to `ToolDefinition.parameters_json_schema`
- Public API is unchanged — no breaking changes

---

## 3. LangChain/LangGraph Integration

### 3.1 Module: `toolset/langchain.py`

Exports `SecureSandboxToolkit` — follows LangChain's toolkit pattern.

### 3.2 Usage

```python
from langchain_anthropic import ChatAnthropic
from agentsh_secure_sandbox.toolset.langchain import SecureSandboxToolkit
from agentsh_secure_sandbox.adapters import daytona
from langgraph.prebuilt import create_react_agent

raw = await Daytona().create()
toolkit = SecureSandboxToolkit(daytona(raw))
# Optional: SecureSandboxToolkit(daytona(raw), config=SecureConfig(...))

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

### 3.3 Implementation approach

Three `BaseTool` subclasses (`_RunCommandTool`, `_WriteFileTool`, `_ReadFileTool`), each holding a reference to the shared `SecureSandboxTools` instance. They implement `_arun` (native async). The sync `_run` raises `NotImplementedError` directing users to use async execution, since the underlying sandbox operations are async-only and `asyncio.run` fails inside already-running event loops (Jupyter, async LangGraph agents).

`SecureSandboxToolkit` is a context manager that:
- On `__aenter__`: provisions the sandbox via `SecureSandboxTools`
- `get_tools()`: returns the 3 `BaseTool` instances
- On `__aexit__`: stops the sandbox

Each `BaseTool` uses a Pydantic `args_schema` for input validation, matching the JSON schemas in `TOOL_DEFS`.

### 3.4 Dependencies

Optional: `langchain-core>=0.3` (provides `BaseTool`, `BaseModel` for schemas).

---

## 4. OpenAI Agents SDK Integration

### 4.1 Module: `toolset/openai_agents.py`

Exports `SecureSandboxAgentTools`.

### 4.2 Usage

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

raw = await AsyncSandbox.create()
sandbox_tools = SecureSandboxAgentTools(e2b(raw))
# Optional: SecureSandboxAgentTools(e2b(raw), config=SecureConfig(...))

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)
```

### 4.3 Implementation approach

Three `FunctionTool` instances, each with an `on_invoke_tool` callback that delegates to `SecureSandboxTools.call_tool()`. The callback receives `(ctx: RunContextWrapper[Any], args_json: str)` (using the publicly exported `RunContextWrapper` type, not the internal `ToolContext`), parses the JSON string via `json.loads(args_json)` into a dict, and calls the shared `call_tool` logic.

`SecureSandboxAgentTools` is a context manager that:
- On `__aenter__`: provisions the sandbox via `SecureSandboxTools`
- `get_tools()`: returns the 3 `FunctionTool` instances
- On `__aexit__`: stops the sandbox

### 4.4 Dependencies

Optional: `openai-agents>=0.1` (provides `FunctionTool`, `RunContextWrapper`).

---

## 5. Package Changes

### 5.1 pyproject.toml

```toml
[project.optional-dependencies]
# Existing
pydantic-ai = ["pydantic-ai>=0.2"]
# New
langchain = ["langchain-core>=0.3"]
openai-agents = ["openai-agents>=0.1"]
```

### 5.2 `__init__.py` exports

Same lazy-import pattern as the existing Pydantic AI toolset:

```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, **_kw):
            raise ImportError(
                "SecureSandboxToolkit requires the langchain extra. "
                "Install with: pip install agentsh-secure-sandbox[langchain]"
            )
        SecureSandboxToolkit = _missing_toolkit
    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, **_kw):
            raise ImportError(
                "SecureSandboxAgentTools requires the openai-agents extra. "
                "Install with: pip install agentsh-secure-sandbox[openai-agents]"
            )
        SecureSandboxAgentTools = _missing_agent_tools
    else:
        raise
```

### 5.3 `__all__` update

Add `"SecureSandboxToolkit"` and `"SecureSandboxAgentTools"`. `SecureSandboxTools` (the base class) is internal and not exported — framework wrappers are the public API. `toolset/__init__.py` stays empty.

---

## 6. Testing

### 6.1 Unit tests

New file: `tests/test_toolsets.py` (or extend `tests/test_toolset.py`).

**Shared base tests:**
- `test_call_tool_run_command` — mocked sandbox, verify exec call and return
- `test_call_tool_run_command_failure` — nonzero exit code formatting
- `test_call_tool_write_file` — success and denied paths
- `test_call_tool_read_file` — success and denied paths
- `test_call_tool_unknown` — raises ValueError
- `test_lifecycle` — __aenter__ provisions, __aexit__ stops
- `test_sandbox_not_provisioned` — raises RuntimeError

**LangChain tests** (mocked `langchain_core` not required — test tool instantiation and call routing):
- `test_toolkit_get_tools_returns_three`
- `test_run_command_tool_arun`
- `test_write_file_tool_arun`
- `test_read_file_tool_arun`

**OpenAI Agents SDK tests** (mock `agents` module):
- `test_agent_tools_get_tools_returns_three`
- `test_run_command_invoke`
- `test_write_file_invoke`
- `test_read_file_invoke`

### 6.2 No e2e tests needed

Framework SDKs are mocked in unit tests. The underlying sandbox execution is already covered by adapter e2e tests.

---

## 7. Phase 2 (Future)

After Phase 1 ships, add integrations for:

| Framework | Module | Estimated effort |
|-----------|--------|-----------------|
| CrewAI | `toolset/crewai.py` | ~60 lines (uses `BaseTool` similar to LangChain) |
| Google ADK | `toolset/google_adk.py` | ~60 lines (function-based tools) |
| Claude Agent SDK | `toolset/claude_agent.py` | ~60 lines (function tools) |
| AutoGen / Semantic Kernel | `toolset/autogen.py` | ~80 lines (function registration) |

Each follows the same pattern: compose `SecureSandboxTools`, convert `TOOL_DEFS` to the framework's native format, implement lifecycle via context manager.

---

## 8. File summary

| File | Action | Lines (est.) |
|------|--------|-------------|
| `toolset/_base.py` | Create | ~130 |
| `toolset/langchain.py` | Create | ~100 |
| `toolset/openai_agents.py` | Create | ~80 |
| `toolset/pydantic_ai.py` | Refactor | ~80 (down from ~210) |
| `__init__.py` | Update | +30 |
| `pyproject.toml` | Update | +2 |
| `tests/test_toolsets.py` | Create | ~200 |
| README.md | Update | +30 |
| docs/spec.md | Update | +40 |
