diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee830..ab88b2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: pi: ${{ steps.filter.outputs.pi }} opencode: ${{ steps.filter.outputs.opencode }} pydantic_ai: ${{ steps.filter.outputs.pydantic_ai }} + smolagents: ${{ steps.filter.outputs.smolagents }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -45,6 +46,8 @@ jobs: - 'packages/opencode-plugin/**' pydantic_ai: - 'packages/pydantic-ai-daytona/**' + smolagents: + - 'packages/smolagents-daytona/**' adk: needs: changes @@ -208,6 +211,45 @@ jobs: # NOTE: live tests (tests/integration_tests, real Daytona sandboxes) run via # the separate integration workflow, not per-PR CI. + smolagents: + needs: changes + if: needs.changes.outputs.smolagents == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + # Pinned: obstore (pulled via the daytona SDK) ships no wheels for the + # newest Python; 3.12 has wheels so `pip install` succeeds. + python-version: "3.12" + - name: Detect package + id: guard + run: | + test -f packages/smolagents-daytona/pyproject.toml && echo "ready=true" >> "$GITHUB_OUTPUT" || echo "ready=false" >> "$GITHUB_OUTPUT" + - name: Install + if: steps.guard.outputs.ready == 'true' + working-directory: packages/smolagents-daytona + # TEMPORARY (until smolagents>=1.27.0 is on PyPI): the pluggable executor + # contract this package builds on (huggingface/smolagents#2724) is not + # released yet, so smolagents is installed from the PR head SHA and the + # package itself with --no-deps to skip resolving the unreleased floor. + # Once smolagents 1.27.0 releases, replace all of this with: + # python -m pip install -e ".[dev]" + run: | + python -m pip install "smolagents @ git+https://github.com/huggingface/smolagents.git@5d49484a8c0959fd511c8904f632b60e3dc36b32" + python -m pip install "daytona>=0.211.0,<0.212.0" "pytest>=7.0.0" "black>=23.0.0" "ruff>=0.15,<0.17" "mypy>=1.0.0" + python -m pip install --no-deps -e . + - name: Lint (ruff) + if: steps.guard.outputs.ready == 'true' + working-directory: packages/smolagents-daytona + run: ruff check . + - name: Unit tests (offline) + if: steps.guard.outputs.ready == 'true' + working-directory: packages/smolagents-daytona + run: pytest + # NOTE: live tests (tests/integration_tests, real Daytona sandboxes) run via + # the separate integration workflow, not per-PR CI. + # Single, stable status check to require in branch protection. # It ALWAYS runs (if: always()) and fails only if a relevant job failed or # was cancelled. A "skipped" job (a package this PR didn't touch) is fine — @@ -215,7 +257,7 @@ jobs: # merges when a touched package's lint/test/build fails. ci-success: name: ci-success - needs: [changes, adk, langchain, n8n, pi, opencode, pydantic-ai] + needs: [changes, adk, langchain, n8n, pi, opencode, pydantic-ai, smolagents] if: always() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 03d0fd0..2a32b7d 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -36,6 +36,7 @@ on: - n8n - pi - pydantic-ai + - smolagents permissions: contents: read @@ -62,6 +63,7 @@ jobs: n8n: ${{ steps.decide.outputs.n8n }} pi: ${{ steps.decide.outputs.pi }} pydantic_ai: ${{ steps.decide.outputs.pydantic_ai }} + smolagents: ${{ steps.decide.outputs.smolagents }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -79,6 +81,8 @@ jobs: - 'packages/pi-extension/**' pydantic_ai: - 'packages/pydantic-ai-daytona/**' + smolagents: + - 'packages/smolagents-daytona/**' - name: Decide which packages run id: decide env: @@ -91,6 +95,7 @@ jobs: CH_N8N: ${{ steps.filter.outputs.n8n }} CH_PI: ${{ steps.filter.outputs.pi }} CH_PYDANTIC_AI: ${{ steps.filter.outputs.pydantic_ai }} + CH_SMOLAGENTS: ${{ steps.filter.outputs.smolagents }} run: | set -euo pipefail # Two lanes, one lane per PR type: same-repo PRs run on `pull_request`, @@ -118,6 +123,7 @@ jobs: echo "n8n=$(decide n8n "${CH_N8N:-false}")" echo "pi=$(decide pi "${CH_PI:-false}")" echo "pydantic_ai=$(decide pydantic-ai "${CH_PYDANTIC_AI:-false}")" + echo "smolagents=$(decide smolagents "${CH_SMOLAGENTS:-false}")" } >> "$GITHUB_OUTPUT" adk: @@ -248,6 +254,35 @@ jobs: DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} run: pytest tests/integration_tests + smolagents: + needs: changes + if: needs.changes.outputs.smolagents == 'true' + runs-on: ubuntu-latest + environment: integration-tests + timeout-minutes: 30 + steps: + # Fork lane checks out the PR head (see the adk job note). + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }} + - uses: actions/setup-python@v5 + with: + # Pinned to 3.12 for the same reason as ci.yml (obstore wheels). + python-version: "3.12" + - name: Install + working-directory: packages/smolagents-daytona + # TEMPORARY (until smolagents>=1.27.0 is on PyPI): see the ci.yml + # smolagents job for details; collapse to `pip install -e ".[dev]"` then. + run: | + python -m pip install "smolagents @ git+https://github.com/huggingface/smolagents.git@5d49484a8c0959fd511c8904f632b60e3dc36b32" + python -m pip install "daytona>=0.211.0,<0.212.0" "pytest>=7.0.0" + python -m pip install --no-deps -e . + - name: Integration tests (live Daytona) + working-directory: packages/smolagents-daytona + env: + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} + run: pytest tests/integration_tests + # Single, stable status check to require in branch protection -- the # integration counterpart of ci.yml's ci-success. Suites skipped because a PR # does not touch their package pass through; a failed, cancelled, or @@ -257,7 +292,7 @@ jobs: # required checks treat as satisfied, so the owning lane's verdict governs. integration-success: name: integration-success - needs: [changes, adk, langchain, n8n, pi, pydantic-ai] + needs: [changes, adk, langchain, n8n, pi, pydantic-ai, smolagents] if: >- always() && ( github.event_name == 'workflow_dispatch' || diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cebd086..b1303ea 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,5 +4,6 @@ "packages/n8n-nodes-daytona": "0.1.3", "packages/pi-extension": "0.191.0", "packages/opencode-plugin": "0.192.1", - "packages/pydantic-ai-daytona": "0.1.0" + "packages/pydantic-ai-daytona": "0.1.0", + "packages/smolagents-daytona": "0.1.0" } diff --git a/README.md b/README.md index d4d6db4..1fab25a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Packages published to **a package registry** live in [`packages/`](packages/): | [`pi-extension`](packages/pi-extension) — Pi coding-agent extension | npm · [`@daytona/pi`](https://www.npmjs.com/package/@daytona/pi) | | [`opencode-plugin`](packages/opencode-plugin) — OpenCode plugin | npm · [`@daytona/opencode`](https://www.npmjs.com/package/@daytona/opencode) | | [`pydantic-ai-daytona`](packages/pydantic-ai-daytona) — Pydantic AI sandbox capability | PyPI · [`pydantic-ai-daytona`](https://pypi.org/project/pydantic-ai-daytona/) | +| [`smolagents-daytona`](packages/smolagents-daytona) — smolagents sandbox code executor | PyPI · [`smolagents-daytona`](https://pypi.org/project/smolagents-daytona/) | ## Apps diff --git a/packages/smolagents-daytona/.gitignore b/packages/smolagents-daytona/.gitignore new file mode 100644 index 0000000..4025a70 --- /dev/null +++ b/packages/smolagents-daytona/.gitignore @@ -0,0 +1,76 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Daytona specific +daytona_config.json diff --git a/packages/smolagents-daytona/LICENSE b/packages/smolagents-daytona/LICENSE new file mode 100644 index 0000000..87d052a --- /dev/null +++ b/packages/smolagents-daytona/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Daytona Platforms Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/smolagents-daytona/README.md b/packages/smolagents-daytona/README.md new file mode 100644 index 0000000..729e683 --- /dev/null +++ b/packages/smolagents-daytona/README.md @@ -0,0 +1,106 @@ +# smolagents-daytona + +Daytona sandbox code executor for [smolagents](https://github.com/huggingface/smolagents) `CodeAgent`s. + +A smolagents `CodeAgent` writes its actions as Python code. Executing LLM-generated code is a +serious security concern, so it should run in a sandbox. This package runs each agent code step in +an isolated [Daytona](https://daytona.io) sandbox with a stateful interpreter context: variables, +imports, and tool definitions persist across steps, while your local environment stays untouched. + +The executor is registered under the `smolagents.executors` entry-point group, so installing the +package is all the setup smolagents needs: `CodeAgent(executor_type="daytona")` resolves to it +automatically. + +## Installation + +```bash +pip install smolagents-daytona +``` + +Requires `smolagents>=1.27.0` (the release that introduced pluggable executors) and Python 3.10+. + +## Quickstart + +1. Create a Daytona account and generate an API key from the + [Daytona Dashboard](https://app.daytona.io/dashboard/keys). +2. Set the `DAYTONA_API_KEY` environment variable. +3. Pass `executor_type="daytona"` when creating the agent: + +```python +from smolagents import CodeAgent, InferenceClientModel + +with CodeAgent(model=InferenceClientModel(), tools=[], executor_type="daytona") as agent: + agent.run("Give me the 100th Fibonacci number.") +``` + +Using the agent as a context manager ensures the Daytona sandbox is released when the agent is +done; alternatively, call `agent.cleanup()` explicitly. + +The agent's models are called from your local environment; only the generated code is sent to the +Daytona sandbox for execution, and only its output is returned. + +## Customizing the sandbox + +Everything in `executor_kwargs` is forwarded to +[`Daytona().create()`](https://www.daytona.io/docs/python-sdk/daytona/#daytonacreate): + +```python +from daytona import CreateSandboxFromSnapshotParams +from smolagents import CodeAgent, InferenceClientModel + +params = CreateSandboxFromSnapshotParams( + name="my-agent-sandbox", + env_vars={"DEBUG": "true"}, + auto_stop_interval=0, # Disable auto-stop +) + +with CodeAgent( + model=InferenceClientModel(), + tools=[], + executor_type="daytona", + executor_kwargs={"params": params, "timeout": 120}, +) as agent: + agent.run("Give me the 100th Fibonacci number.") +``` + +A custom Docker image works the same way with `CreateSandboxFromImageParams(image=...)`. + +Additional packages required by the agent (beyond what its tools declare) can be preinstalled in +the sandbox through the agent's `additional_authorized_imports`; the executor installs them at +startup. + +## Using the executor directly + +The executor can also be constructed and driven without an agent: + +```python +import io + +from rich.console import Console +from smolagents import AgentLogger, LogLevel + +from smolagents_daytona import DaytonaExecutor + +executor = DaytonaExecutor( + additional_imports=["numpy"], + logger=AgentLogger(LogLevel.INFO, Console(file=io.StringIO())), +) +try: + output = executor("import numpy as np; print(np.sqrt(2))") + print(output.logs) +finally: + executor.cleanup() +``` + +## Development + +```bash +pip install -e ".[dev]" +pytest # offline unit tests (Daytona SDK mocked) +DAYTONA_API_KEY=... pytest tests/integration_tests # live tests, real sandboxes +ruff check . +``` + +## License + +Apache-2.0 diff --git a/packages/smolagents-daytona/examples/basic_agent.py b/packages/smolagents-daytona/examples/basic_agent.py new file mode 100644 index 0000000..304b736 --- /dev/null +++ b/packages/smolagents-daytona/examples/basic_agent.py @@ -0,0 +1,23 @@ +"""Minimal CodeAgent executing its code steps in a Daytona sandbox. + +Requires DAYTONA_API_KEY and an LLM provider key (here HF_TOKEN for Inference +Providers). Installing smolagents-daytona is all the wiring needed: the +`executor_type="daytona"` string resolves through the `smolagents.executors` +entry-point group. +""" + +from smolagents import CodeAgent, InferenceClientModel + + +def main() -> None: + with CodeAgent( + model=InferenceClientModel(), + tools=[], + executor_type="daytona", + ) as agent: + result = agent.run("Give me the 100th Fibonacci number.") + print(result) + + +if __name__ == "__main__": + main() diff --git a/packages/smolagents-daytona/pyproject.toml b/packages/smolagents-daytona/pyproject.toml new file mode 100644 index 0000000..dcb83ef --- /dev/null +++ b/packages/smolagents-daytona/pyproject.toml @@ -0,0 +1,92 @@ +[build-system] +requires = ["setuptools>=77.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "smolagents-daytona" +version = "0.1.0" +description = "Daytona sandbox code executor for smolagents CodeAgents" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENSE"] +authors = [ + {name = "Daytona Platforms Inc.", email = "support@daytona.io"} +] +keywords = ["smolagents", "daytona", "sandbox", "executor", "agent", "code-execution"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + # Floor: the public executor contract (entry-point discovery, top-level + # `CodeOutput`/`RemotePythonExecutor`, `FINAL_ANSWER_EXCEPTION_BASE`, + # `deserialize_final_answer`, plain-Python `install_packages` default) + # ships in this release. See huggingface/smolagents#2722 and #2724. + "smolagents>=1.27.0", + # Floor: `Sandbox.code_interpreter` (stateful contexts via `create_context` + # and `run_code`) verified against this release line. + "daytona>=0.211.0,<0.212.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "black>=23.0.0", + "ruff>=0.15,<0.17", + "mypy>=1.0.0", +] + +# Registers the executor with smolagents: installing this package makes +# `CodeAgent(executor_type="daytona")` work with no further configuration. +[project.entry-points."smolagents.executors"] +daytona = "smolagents_daytona:DaytonaExecutor" + +[project.urls] +Homepage = "https://github.com/daytona/integrations/tree/main/packages/smolagents-daytona#readme" +Repository = "https://github.com/daytona/integrations" +Documentation = "https://github.com/daytona/integrations/tree/main/packages/smolagents-daytona#readme" +Issues = "https://github.com/daytona/integrations/issues" + +[tool.setuptools] +packages = ["smolagents_daytona"] + +[tool.setuptools.package-data] +smolagents_daytona = ["py.typed"] + +[tool.pytest.ini_options] +# Unit tests only by default; live tests live in tests/integration_tests and are +# invoked explicitly (see .github/workflows/integration.yml). +testpaths = ["tests/unit_tests"] + +[tool.black] +line-length = 100 +target-version = ['py310'] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +# Select rules explicitly: ruff's *default* set is not a stable contract (0.16.0 +# widened it from E4/E7/E9/F to also include I, UP, B, BLE, S, ... ), so relying +# on it lets a ruff release turn a green package red without a code change. +# This is the set this package was written and verified against. +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true + +# smolagents ships no `py.typed` marker, so mypy cannot follow its imports. +# Remove once upstream marks the package as typed. +[[tool.mypy.overrides]] +module = "smolagents.*" +ignore_missing_imports = true diff --git a/packages/smolagents-daytona/smolagents_daytona/__init__.py b/packages/smolagents-daytona/smolagents_daytona/__init__.py new file mode 100644 index 0000000..6432b90 --- /dev/null +++ b/packages/smolagents-daytona/smolagents_daytona/__init__.py @@ -0,0 +1,11 @@ +"""Daytona sandbox code executor for smolagents CodeAgents. + +`DaytonaExecutor` implements the smolagents remote-executor contract on top of +Daytona sandboxes. Installing this package registers it under the +``smolagents.executors`` entry-point group, so it can be selected with +``CodeAgent(executor_type="daytona")``. +""" + +from smolagents_daytona._executor import DaytonaExecutor + +__all__ = ["DaytonaExecutor"] diff --git a/packages/smolagents-daytona/smolagents_daytona/_executor.py b/packages/smolagents-daytona/smolagents_daytona/_executor.py new file mode 100644 index 0000000..3d1b2b2 --- /dev/null +++ b/packages/smolagents-daytona/smolagents_daytona/_executor.py @@ -0,0 +1,114 @@ +"""Daytona sandbox code executor for smolagents. + +Implements the public smolagents remote-executor contract on top of Daytona's +code interpreter: an isolated sandbox with a stateful interpreter context that +preserves variables and imports across execution steps. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from daytona import Daytona +from smolagents import AgentError, CodeOutput, LogLevel, RemotePythonExecutor + +if TYPE_CHECKING: + from smolagents import AgentLogger + + +class DaytonaExecutor(RemotePythonExecutor): + """Remote Python code executor running in a Daytona sandbox. + + Each executor owns one sandbox and one interpreter context, created at + construction time and released by :meth:`cleanup`. Python state (variables, + imports, tool definitions) persists across steps within the context. + + Registered under the ``smolagents.executors`` entry-point group as + ``daytona``, so ``CodeAgent(executor_type="daytona")`` resolves to this + class once the package is installed. + + Args: + additional_imports: Additional Python packages to install in the sandbox. + logger: Logger to use for output and errors. + allow_pickle: Whether to allow pickle fallback when serializing objects + that cannot be safely serialized to JSON. Keep the default ``False`` + unless you fully trust the execution environment. + **kwargs: Forwarded to ``Daytona().create()``, e.g. ``params`` + (``CreateSandboxFromSnapshotParams`` or ``CreateSandboxFromImageParams``) + and ``timeout``. + """ + + # Daytona's interpreter reports only `Exception` subclasses in its structured + # error output, so the final-answer control-flow exception generated by the + # base class must not derive from `BaseException`. + FINAL_ANSWER_EXCEPTION_BASE = "Exception" + + def __init__( + self, + additional_imports: list[str], + logger: "AgentLogger", + allow_pickle: bool = False, + **kwargs: Any, + ): + super().__init__(additional_imports, logger, allow_pickle) + self._daytona = Daytona() + self.sandbox = self._daytona.create(**kwargs) + try: + self.context = self.sandbox.code_interpreter.create_context() + self.installed_packages = self.install_packages(additional_imports) + except BaseException: + # The sandbox is a remote resource created by this constructor: if the + # rest of the initialization fails, the caller never receives the + # instance and could not release it, so release it here. + self.cleanup() + raise + self.logger.log("Daytona sandbox is running", level=LogLevel.INFO) + + def run_code_raise_errors(self, code: str) -> CodeOutput: + """Execute Python code in the sandbox and return the result. + + Args: + code: Python code to execute. + + Returns: + Code output containing the result, logs, and whether it is the final answer. + + Raises: + AgentError: If the executed code raised an error in the sandbox. + """ + result = self.sandbox.code_interpreter.run_code(code, context=self.context) + + logs = result.stdout + if result.stderr: + logs = f"{logs}\n{result.stderr}".strip() + + if result.error: + if result.error.name == self.FINAL_ANSWER_EXCEPTION: + final_answer = self.deserialize_final_answer(result.error.value, self.allow_pickle) + return CodeOutput(output=final_answer, logs=logs, is_final_answer=True) + + error_message = ( + f"{logs}\n" + f"Executing code yielded an error:\n" + f"{result.error.name}\n" + f"{result.error.value}\n" + f"{result.error.traceback}" + ) + raise AgentError(error_message, self.logger) + + return CodeOutput(output=result.stdout.strip() or None, logs=logs, is_final_answer=False) + + def cleanup(self) -> None: + """Release the Daytona sandbox. + + Idempotent and safe to call at any point, including after a partially + failed initialization, per the smolagents executor contract. + """ + try: + if hasattr(self, "sandbox"): + self.logger.log("Shutting down Daytona sandbox...", level=LogLevel.INFO) + self.sandbox.delete() + del self.sandbox + self.logger.log("Daytona sandbox cleanup completed", level=LogLevel.INFO) + except Exception as e: + self.logger.log_error(f"Error during Daytona cleanup: {e}") diff --git a/packages/smolagents-daytona/smolagents_daytona/py.typed b/packages/smolagents-daytona/smolagents_daytona/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/smolagents-daytona/tests/integration_tests/test_live_executor.py b/packages/smolagents-daytona/tests/integration_tests/test_live_executor.py new file mode 100644 index 0000000..0533b15 --- /dev/null +++ b/packages/smolagents-daytona/tests/integration_tests/test_live_executor.py @@ -0,0 +1,175 @@ +"""Live integration tests against a real Daytona backend (needs DAYTONA_API_KEY).""" + +import io +import os +from textwrap import dedent + +import pytest +from rich.console import Console +from smolagents import AgentError, AgentLogger, FinalAnswerTool, LogLevel + +from smolagents_daytona import DaytonaExecutor + +pytestmark = pytest.mark.skipif( + not os.environ.get("DAYTONA_API_KEY"), + reason="DAYTONA_API_KEY not set; these tests require a live Daytona backend", +) + + +def make_logger() -> AgentLogger: + return AgentLogger(LogLevel.INFO, Console(force_terminal=False, file=io.StringIO())) + + +# One sandbox is shared across this module's tests to keep the suite fast and cheap; +# `additional_imports` exercises the plain-Python `install_packages` default live. +# `emoji` is pure-Python with no dependencies and is not preinstalled in sandbox +# base images (unlike numpy, which may be), so its import proves the install ran. +@pytest.fixture(scope="module") +def executor(): + executor = DaytonaExecutor(additional_imports=["numpy", "emoji"], logger=make_logger()) + yield executor + executor.cleanup() + + +class TestLiveExecution: + def test_basic_execution(self, executor): + code_output = executor("a = 2 + 2; print(f'Result: {a}')") + assert "Result: 4" in code_output.logs + + def test_state_persists_between_executions(self, executor): + executor("import numpy as np; a = 2") + code_output = executor("print(np.sqrt(a))") + assert "1.41421" in code_output.logs + + def test_additional_imports_are_installed(self, executor): + assert executor.installed_packages == ["numpy", "emoji"] + code_output = executor("import emoji; print(emoji.emojize(':thumbs_up:'))") + assert code_output.logs.strip() + + def test_user_site_precedes_system_site_packages(self, executor): + """Agent-requested package versions must shadow preinstalled copies. + + Mirrors interpreter-startup ordering: `site.py` places user site before + system site-packages, so a version installed at executor startup wins over + one baked into the sandbox image. + """ + code_output = executor( + dedent(""" + import site + import sys + + user_site = site.getusersitepackages() + assert user_site in sys.path, sys.path + system_sites = [p for p in site.getsitepackages() if p in sys.path] + if system_sites: + first_system = min(sys.path.index(p) for p in system_sites) + assert sys.path.index(user_site) < first_system, sys.path + print("ordering ok") + """) + ) + assert "ordering ok" in code_output.logs + + def test_final_answer(self, executor): + executor.send_tools({"final_answer": FinalAnswerTool()}) + code_output = executor('final_answer("This is the final answer")') + assert code_output.is_final_answer is True + assert code_output.output == "This is the final answer" + + def test_runtime_error_raises_agent_error(self, executor): + with pytest.raises(AgentError) as excinfo: + executor("1/0") + assert "ZeroDivisionError" in str(excinfo.value) + + def test_syntax_error_raises_agent_error(self, executor): + with pytest.raises(AgentError) as excinfo: + executor('print("Missing parenthesis') + assert "SyntaxError" in str(excinfo.value) + + @pytest.mark.parametrize( + "code_action, expected_result", + [ + ( + dedent(''' + final_answer("""This is + a multiline + final answer""") + '''), + "This is\na multiline\nfinal answer", + ), + ( + dedent(""" + text = '''Text containing + final_answer(5) + ''' + final_answer(text) + """), + "Text containing\nfinal_answer(5)\n", + ), + ( + dedent(""" + num = 2 + if num == 1: + final_answer("One") + elif num == 2: + final_answer("Two") + """), + "Two", + ), + ], + ) + def test_final_answer_patterns(self, executor, code_action, expected_result): + executor.send_tools({"final_answer": FinalAnswerTool()}) + code_output = executor(code_action) + assert code_output.is_final_answer is True + assert code_output.output == expected_result + + def test_custom_final_answer_tool(self, executor): + class CustomFinalAnswerTool(FinalAnswerTool): + def forward(self, answer: str) -> str: + return "CUSTOM" + answer + + executor.send_tools({"final_answer": CustomFinalAnswerTool()}) + code_output = executor('final_answer(answer="_answer")') + assert code_output.is_final_answer is True + assert code_output.output == "CUSTOM_answer" + + def test_custom_final_answer_tool_with_custom_inputs(self, executor): + class CustomFinalAnswerToolWithCustomInputs(FinalAnswerTool): + inputs = { + "answer1": {"type": "string", "description": "First part of the answer."}, + "answer2": {"type": "string", "description": "Second part of the answer."}, + } + + def forward(self, answer1: str, answer2: str) -> str: + return answer1 + "CUSTOM" + answer2 + + executor.send_tools({"final_answer": CustomFinalAnswerToolWithCustomInputs()}) + code_output = executor( + dedent(""" + final_answer( + answer1="answer1_", + answer2="_answer2" + ) + """) + ) + assert code_output.is_final_answer is True + assert code_output.output == "answer1_CUSTOM_answer2" + + +class TestLiveEntryPointDiscovery: + def test_code_agent_runs_code_in_daytona_through_entry_point(self): + """End-to-end: entry-point discovery, real sandbox, final answer, cleanup. + + Uses the agent as a context manager, the exact usage documented in the + README: `CodeAgent.__exit__` calls `cleanup()`, which releases the sandbox. + """ + from unittest.mock import MagicMock + + from smolagents import CodeAgent + + with CodeAgent(tools=[], model=MagicMock(), executor_type="daytona") as agent: + assert isinstance(agent.python_executor, DaytonaExecutor) + agent.python_executor.send_tools({"final_answer": FinalAnswerTool()}) + code_output = agent.python_executor('final_answer(f"result: {6 * 7}")') + assert code_output.is_final_answer is True + assert code_output.output == "result: 42" diff --git a/packages/smolagents-daytona/tests/unit_tests/test_executor.py b/packages/smolagents-daytona/tests/unit_tests/test_executor.py new file mode 100644 index 0000000..52714f8 --- /dev/null +++ b/packages/smolagents-daytona/tests/unit_tests/test_executor.py @@ -0,0 +1,237 @@ +"""Offline unit tests for DaytonaExecutor (the Daytona SDK is mocked throughout).""" + +from unittest.mock import MagicMock, patch + +import pytest +from smolagents import AgentError, CodeOutput, FinalAnswerTool + +from smolagents_daytona import DaytonaExecutor + + +def make_executor(logger=None, **kwargs): + """Build a DaytonaExecutor with a fully mocked Daytona SDK. + + Returns the executor together with the SDK mocks so tests can assert on them. + """ + logger = logger if logger is not None else MagicMock() + with patch("smolagents_daytona._executor.Daytona") as daytona_cls: + daytona = MagicMock() + sandbox = MagicMock() + context = MagicMock() + daytona.create.return_value = sandbox + sandbox.code_interpreter.create_context.return_value = context + daytona_cls.return_value = daytona + executor = DaytonaExecutor(additional_imports=[], logger=logger, **kwargs) + return executor, daytona, sandbox, context + + +def make_run_result(stdout="", stderr="", error=None): + result = MagicMock() + result.stdout = stdout + result.stderr = stderr + result.error = error + return result + + +def make_error(name, value, traceback="Traceback ..."): + error = MagicMock() + error.name = name + error.value = value + error.traceback = traceback + return error + + +class TestInstantiation: + def test_wires_sandbox_and_context(self): + logger = MagicMock() + executor, daytona, sandbox, context = make_executor(logger=logger) + + assert executor.logger is logger + assert executor.sandbox is sandbox + assert executor.context is context + daytona.create.assert_called_once_with() + sandbox.code_interpreter.create_context.assert_called_once_with() + + def test_forwards_kwargs_to_sandbox_creation(self): + params = MagicMock() + executor, daytona, _, _ = make_executor(params=params, timeout=120) + + daytona.create.assert_called_once_with(params=params, timeout=120) + + def test_releases_sandbox_when_initialization_fails_after_creation(self): + """A failure after the sandbox exists must not leak the remote resource.""" + with patch("smolagents_daytona._executor.Daytona") as daytona_cls: + daytona = MagicMock() + sandbox = MagicMock() + daytona.create.return_value = sandbox + sandbox.code_interpreter.create_context.side_effect = RuntimeError("interpreter down") + daytona_cls.return_value = daytona + + with pytest.raises(RuntimeError, match="interpreter down"): + DaytonaExecutor(additional_imports=[], logger=MagicMock()) + + sandbox.delete.assert_called_once_with() + + +class TestRunCode: + def test_success_returns_output_and_logs(self): + executor, _, sandbox, context = make_executor() + sandbox.code_interpreter.run_code.return_value = make_run_result(stdout="hello world") + + output = executor.run_code_raise_errors("print('hello world')") + + assert output.output == "hello world" + assert output.logs == "hello world" + assert output.is_final_answer is False + sandbox.code_interpreter.run_code.assert_called_once_with( + "print('hello world')", context=context + ) + + def test_stderr_is_merged_into_logs(self): + executor, _, sandbox, _ = make_executor() + sandbox.code_interpreter.run_code.return_value = make_run_result( + stdout="out", stderr="warning: something" + ) + + output = executor.run_code_raise_errors("code") + + assert "out" in output.logs + assert "warning: something" in output.logs + + def test_execution_error_raises_agent_error(self): + executor, _, sandbox, _ = make_executor() + sandbox.code_interpreter.run_code.return_value = make_run_result( + error=make_error("ZeroDivisionError", "division by zero") + ) + + with pytest.raises(AgentError) as excinfo: + executor.run_code_raise_errors("1/0") + + assert "ZeroDivisionError" in str(excinfo.value) + assert "division by zero" in str(excinfo.value) + + def test_final_answer_exception_returns_final_answer(self): + executor, _, sandbox, _ = make_executor() + sandbox.code_interpreter.run_code.return_value = make_run_result( + error=make_error("FinalAnswerException", 'safe:"the answer"') + ) + + output = executor.run_code_raise_errors("final_answer('the answer')") + + assert output.is_final_answer is True + assert output.output == "the answer" + + +class TestCleanup: + def test_cleanup_deletes_sandbox(self): + executor, _, sandbox, _ = make_executor() + + executor.cleanup() + + sandbox.delete.assert_called_once_with() + assert not hasattr(executor, "sandbox") + + def test_cleanup_is_idempotent(self): + executor, _, sandbox, _ = make_executor() + + executor.cleanup() + executor.cleanup() + + sandbox.delete.assert_called_once_with() + + def test_cleanup_swallows_provider_errors(self): + executor, _, sandbox, _ = make_executor() + sandbox.delete.side_effect = RuntimeError("already gone") + + executor.cleanup() # must not raise + + +class TestSmolagentsContract: + """Validates this package against the public smolagents executor contract.""" + + def test_final_answer_exception_base_renders_exception_subclass(self): + """`FINAL_ANSWER_EXCEPTION_BASE = "Exception"` must reach the generated source. + + Daytona's interpreter only reports `Exception` subclasses in structured + errors, so the final-answer exception shipped to the sandbox must derive + from `Exception`, not `BaseException`. + """ + executor, _, _, _ = make_executor() + executor.run_code_raise_errors = MagicMock( + return_value=CodeOutput(output=None, logs="", is_final_answer=False) + ) + tool = FinalAnswerTool() + + executor.send_tools({"final_answer": tool}) + + assert "class FinalAnswerException(Exception):" in tool.forward.__source__ + sent_code = executor.run_code_raise_errors.call_args.args[0] + assert "class FinalAnswerException(Exception):" in sent_code + + def test_inherited_install_packages_repairs_user_site_visibility(self): + """The inherited smolagents default must keep user-site packages importable. + + Daytona sandboxes have a non-writable system site-packages: pip silently + falls back to a user-site install (exit code 0), and the long-running + interpreter does not have that directory on its `sys.path`. The base-class + default (contributed from this package, huggingface/smolagents#2724) runs a + plain pip install and then repairs user-site visibility with startup-like + ordering. This guards against regressions in the inherited behavior. + """ + executor, _, _, _ = make_executor() + executor.run_code_raise_errors = MagicMock( + return_value=CodeOutput(output=None, logs="installed", is_final_answer=False) + ) + + installed = executor.install_packages(["numpy", "emoji"]) + + assert installed == ["numpy", "emoji"] + sent_code = executor.run_code_raise_errors.call_args.args[0] + assert "sys.executable" in sent_code + assert "site.getusersitepackages()" in sent_code + # User site must be inserted before system site-packages (mirroring site.py + # startup ordering), not appended, so requested versions shadow preinstalled. + assert "sys.path.insert" in sent_code + assert "getsitepackages" in sent_code + assert "sys.path.append" not in sent_code + assert "importlib.invalidate_caches()" in sent_code + assert "!pip" not in sent_code + + def test_install_packages_propagates_agent_error(self): + executor, _, _, _ = make_executor() + executor.run_code_raise_errors = MagicMock( + side_effect=AgentError("installation failed", executor.logger) + ) + + with pytest.raises(AgentError, match="installation failed"): + executor.install_packages(["numpy"]) + + def test_entry_point_resolves_to_executor(self): + """The installed distribution must register the `daytona` executor type.""" + from importlib import metadata + + entry_points = [ + entry_point + for entry_point in metadata.entry_points(group="smolagents.executors") + if entry_point.name == "daytona" + ] + + assert len(entry_points) == 1 + assert entry_points[0].load() is DaytonaExecutor + + def test_code_agent_creates_executor_through_entry_point(self): + """End-to-end offline: `executor_type="daytona"` resolves through smolagents.""" + from smolagents import CodeAgent + + with patch("smolagents_daytona._executor.Daytona") as daytona_cls: + daytona = MagicMock() + sandbox = MagicMock() + daytona.create.return_value = sandbox + sandbox.code_interpreter.create_context.return_value = MagicMock() + daytona_cls.return_value = daytona + + agent = CodeAgent(tools=[], model=MagicMock(), executor_type="daytona") + + assert isinstance(agent.python_executor, DaytonaExecutor) + agent.cleanup() + sandbox.delete.assert_called_once_with() diff --git a/release-please-config.json b/release-please-config.json index ad073a5..715908d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -32,6 +32,11 @@ "release-type": "python", "package-name": "pydantic-ai-daytona", "component": "pydantic-ai-daytona" + }, + "packages/smolagents-daytona": { + "release-type": "python", + "package-name": "smolagents-daytona", + "component": "smolagents-daytona" } } }