diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index 683e8bc47b..98992b242a 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -93,6 +93,7 @@ For provider-specific setup notes and links for the checked-in extension example | `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) | | `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | | `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) | +| `DeclawSandboxClient` | `openai-agents[declaw]` | [Declaw runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/declaw_runner.py) ([docs](https://docs.declaw.ai)) | | `E2BSandboxClient` | `openai-agents[e2b]` | [E2B runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) | | `ModalSandboxClient` | `openai-agents[modal]` | [Modal runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) | | `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) | diff --git a/examples/sandbox/extensions/declaw_runner.py b/examples/sandbox/extensions/declaw_runner.py new file mode 100644 index 0000000000..e79d22eaae --- /dev/null +++ b/examples/sandbox/extensions/declaw_runner.py @@ -0,0 +1,121 @@ +"""Minimal declaw-backed sandbox example for manual validation. + +Creates a tiny workspace, lets the agent inspect it through the shell +tool, and prints a short answer. Exercises the ``declaw`` backend end +to end via the ``[declaw]`` repo extra. + +Credentials (env): + DECLAW_API_KEY your declaw API key + DECLAW_DOMAIN e.g. ``api.declaw.ai`` + OPENAI_API_KEY for the OpenAI model + +Install: + uv sync --extra declaw --extra dev +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from pathlib import Path + +from agents import Runner, set_tracing_disabled +from agents.run import RunConfig +from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig + +if __package__ is None or __package__ == "": + sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from examples.sandbox.misc.example_support import text_manifest + +try: + from agents.extensions.sandbox import ( + DeclawSandboxClient, + DeclawSandboxClientOptions, + ) +except Exception as exc: # pragma: no cover - depends on optional extras + raise SystemExit( + "Declaw sandbox examples require the optional repo extra.\n" + "Install it with: uv sync --extra declaw" + ) from exc + + +DEFAULT_QUESTION = "Summarize the files in this workspace in 2 sentences." +DEFAULT_TEMPLATE = "base" + + +def _build_manifest() -> Manifest: + return text_manifest( + { + "README.md": ( + "# Notes\n\nSmall workspace used to validate the declaw sandbox backend.\n" + ), + "todo.md": ( + "# Todo\n\n" + "- Confirm the agent can read files.\n" + "- Confirm the shell tool round-trips cleanly.\n" + ), + } + ) + + +def _require_env(name: str) -> None: + if not os.environ.get(name): + raise SystemExit(f"{name} must be set before running this example.") + + +async def _run(*, question: str, template: str, timeout: int) -> None: + # Tracing goes to OpenAI's trace endpoint by default. ZDR-enabled + # orgs get a 403 on every upload — silence the noise. + set_tracing_disabled(True) + + for env in ("DECLAW_API_KEY", "DECLAW_DOMAIN", "OPENAI_API_KEY"): + _require_env(env) + + client = DeclawSandboxClient() + options = DeclawSandboxClientOptions(template=template, timeout=timeout) + + session = await client.create(options=options, manifest=_build_manifest()) + + try: + agent = SandboxAgent( + name="declaw-runner", + model="gpt-5-mini", + instructions=( + "You are running inside a declaw sandbox. Use the shell " + "tool to list files under /workspace and read anything " + "relevant. Keep the final answer to two sentences." + ), + ) + + result = await Runner.run( + agent, + question, + run_config=RunConfig(sandbox=SandboxRunConfig(session=session)), + max_turns=15, + ) + print(result.final_output) + finally: + await client.delete(session=session) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--question", default=DEFAULT_QUESTION) + parser.add_argument("--template", default=DEFAULT_TEMPLATE) + parser.add_argument("--timeout", type=int, default=300) + args = parser.parse_args() + + asyncio.run( + _run( + question=args.question, + template=args.template, + timeout=args.timeout, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 10fede884c..34d5a8e444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ mongodb = ["pymongo>=4.14"] docker = ["docker>=6.1"] blaxel = ["blaxel>=0.2.50", "aiohttp>=3.12,<4"] daytona = ["daytona>=0.155.0"] +declaw = ["declaw>=1.0.4"] cloudflare = ["aiohttp>=3.12,<4"] e2b = ["e2b==2.20.0", "e2b-code-interpreter==2.4.1"] modal = ["modal==1.3.5"] diff --git a/src/agents/extensions/sandbox/__init__.py b/src/agents/extensions/sandbox/__init__.py index d7b082ba1f..3b83f34635 100644 --- a/src/agents/extensions/sandbox/__init__.py +++ b/src/agents/extensions/sandbox/__init__.py @@ -42,6 +42,21 @@ except Exception: # pragma: no cover _HAS_DAYTONA = False +try: + from .declaw import ( + DeclawCloudBucketMountStrategy as DeclawCloudBucketMountStrategy, + DeclawSandboxClient as DeclawSandboxClient, + DeclawSandboxClientOptions as DeclawSandboxClientOptions, + DeclawSandboxSession as DeclawSandboxSession, + DeclawSandboxSessionState as DeclawSandboxSessionState, + DeclawSandboxTimeouts as DeclawSandboxTimeouts, + DeclawSandboxType as DeclawSandboxType, + ) + + _HAS_DECLAW = True +except Exception: # pragma: no cover + _HAS_DECLAW = False + try: from .blaxel import ( DEFAULT_BLAXEL_WORKSPACE_ROOT as DEFAULT_BLAXEL_WORKSPACE_ROOT, @@ -149,6 +164,19 @@ ] ) +if _HAS_DECLAW: + __all__.extend( + [ + "DeclawCloudBucketMountStrategy", + "DeclawSandboxClient", + "DeclawSandboxClientOptions", + "DeclawSandboxSession", + "DeclawSandboxSessionState", + "DeclawSandboxTimeouts", + "DeclawSandboxType", + ] + ) + if _HAS_BLAXEL: __all__.extend( [ diff --git a/src/agents/extensions/sandbox/declaw/__init__.py b/src/agents/extensions/sandbox/declaw/__init__.py new file mode 100644 index 0000000000..0d464a8839 --- /dev/null +++ b/src/agents/extensions/sandbox/declaw/__init__.py @@ -0,0 +1,38 @@ +"""Declaw sandbox backend for the OpenAI Agents SDK. + +Install: + pip install "openai-agents[declaw]" + +Credentials (env): + DECLAW_API_KEY your declaw API key + DECLAW_DOMAIN e.g. ``api.declaw.ai`` + +Docs: + https://docs.declaw.ai + +This module is a thin re-export of the adapter that lives in the +``declaw`` PyPI package. Declaw owns the adapter code; breaking +changes bump the declaw pin, not this package. +""" + +from __future__ import annotations + +from declaw.openai import ( # type: ignore[import-untyped] + DeclawCloudBucketMountStrategy as DeclawCloudBucketMountStrategy, + DeclawSandboxClient as DeclawSandboxClient, + DeclawSandboxClientOptions as DeclawSandboxClientOptions, + DeclawSandboxSession as DeclawSandboxSession, + DeclawSandboxSessionState as DeclawSandboxSessionState, + DeclawSandboxTimeouts as DeclawSandboxTimeouts, + DeclawSandboxType as DeclawSandboxType, +) + +__all__ = [ + "DeclawCloudBucketMountStrategy", + "DeclawSandboxClient", + "DeclawSandboxClientOptions", + "DeclawSandboxSession", + "DeclawSandboxSessionState", + "DeclawSandboxTimeouts", + "DeclawSandboxType", +] diff --git a/tests/extensions/test_sandbox_declaw.py b/tests/extensions/test_sandbox_declaw.py new file mode 100644 index 0000000000..10c27252c6 --- /dev/null +++ b/tests/extensions/test_sandbox_declaw.py @@ -0,0 +1,64 @@ +"""Re-export / registration smoke tests for the declaw sandbox extension. + +100% of the adapter code lives in the declaw package; this file only +verifies that the thin re-export wiring works and that the parent +``agents.extensions.sandbox`` namespace exposes declaw the same way it +exposes the other backends. +""" + +from agents.extensions.sandbox import ( + DeclawSandboxClient, + DeclawSandboxClientOptions, + DeclawSandboxSession, + DeclawSandboxSessionState, + DeclawSandboxType, +) +from agents.extensions.sandbox.declaw import ( + DeclawSandboxClient as BackendClient, +) +from agents.sandbox.manifest import Manifest +from agents.sandbox.session.base_sandbox_session import BaseSandboxSession +from agents.sandbox.session.sandbox_client import ( + BaseSandboxClient, + BaseSandboxClientOptions, +) +from agents.sandbox.session.sandbox_session_state import SandboxSessionState +from agents.sandbox.snapshot import NoopSnapshot + + +def test_declaw_backend_id() -> None: + assert DeclawSandboxClient.backend_id == "declaw" + + +def test_declaw_client_is_base_subclass() -> None: + assert issubclass(DeclawSandboxClient, BaseSandboxClient) + + +def test_declaw_session_is_base_subclass() -> None: + assert issubclass(DeclawSandboxSession, BaseSandboxSession) + + +def test_declaw_options_discriminator() -> None: + opts = DeclawSandboxClientOptions(template="base") + assert opts.type == "declaw" + assert isinstance(opts, BaseSandboxClientOptions) + + +def test_declaw_state_discriminator() -> None: + state = DeclawSandboxSessionState( + sandbox_id="sbx-test", + snapshot=NoopSnapshot(id="snap-test"), + manifest=Manifest(), + ) + assert state.type == "declaw" + assert isinstance(state, SandboxSessionState) + + +def test_parent_and_backend_module_point_at_same_class() -> None: + """The parent-level re-export and the backend submodule must + expose the same object, not two independent copies.""" + assert DeclawSandboxClient is BackendClient + + +def test_sandbox_type_enum_default() -> None: + assert DeclawSandboxType.DEFAULT.value == "default" diff --git a/uv.lock b/uv.lock index 489c9aaa23..4230c85b23 100644 --- a/uv.lock +++ b/uv.lock @@ -830,6 +830,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/45/e6dd0c6c740c67c07474f2eb5175bb5656598488db444c4abd2a4e948393/daytona_toolbox_api_client_async-0.155.0-py3-none-any.whl", hash = "sha256:6ecf6351a31686d8e33ff054db69e279c45b574018b6c9a1cae15a7940412951", size = 176355, upload-time = "2026-03-24T14:47:36.327Z" }, ] +[[package]] +name = "declaw" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/6f/a0fac605f492c1768d3d944971d094e68efa94ad410024db4433ee724680/declaw-1.0.4.tar.gz", hash = "sha256:f63a7a85829fe0f89450fa8af602aeff96605d0f95bec329f8808783547e9ffe", size = 49046, upload-time = "2026-04-18T23:18:38.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/f2539a003de32325298a74983c1cfeefbf3d553083ed9788497a5b39d8db/declaw-1.0.4-py3-none-any.whl", hash = "sha256:2e007a59434d70bc0c83f29f46b04383b8cc489f1c5bfde9bfcd701e2e96ea05", size = 72846, upload-time = "2026-04-18T23:18:36.573Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -2459,6 +2473,9 @@ dapr = [ daytona = [ { name = "daytona" }, ] +declaw = [ + { name = "declaw" }, +] docker = [ { name = "docker" }, ] @@ -2555,6 +2572,7 @@ requires-dist = [ { name = "cryptography", marker = "extra == 'encrypt'", specifier = ">=45.0,<46" }, { name = "dapr", marker = "extra == 'dapr'", specifier = ">=1.16.0" }, { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.155.0" }, + { name = "declaw", marker = "extra == 'declaw'", specifier = ">=1.0.4" }, { name = "docker", marker = "extra == 'docker'", specifier = ">=6.1" }, { name = "e2b", marker = "extra == 'e2b'", specifier = "==2.20.0" }, { name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = "==2.4.1" }, @@ -2581,7 +2599,7 @@ requires-dist = [ { name = "websockets", marker = "extra == 'realtime'", specifier = ">=15.0,<16" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] -provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] +provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "declaw", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] [package.metadata.requires-dev] dev = [