diff --git a/docs/guides/code_executors/code_executor/index.md b/docs/guides/code_executors/code_executor/index.md index 3ac9481fc5..35bd9ca01c 100644 --- a/docs/guides/code_executors/code_executor/index.md +++ b/docs/guides/code_executors/code_executor/index.md @@ -20,8 +20,8 @@ doing so as you. `BaseCodeExecutor` puts that whole decision behind one object. The agent hands it a code block and gets back standard output, standard error, and any files the code produced, while everything about where the code actually ran stays the -implementation's business. Six implementations ship in -`google.adk.code_executors`, and a seventh lives alongside its integration in +implementation's business. Seven implementations ship in +`google.adk.code_executors`, and an eighth lives alongside its integration in `google.adk.integrations.cloud_run`. What separates them is almost entirely how much isolation they give you. @@ -95,7 +95,7 @@ implementation: `optimize_data_file` makes the flow search the user's message for `text/csv` parts, parse them, and put them on `CodeExecutionInput.input_files`. The generated code can then load the dataset by filename, with no upload code of your -own anywhere. Only three implementations accept it, though. +own anywhere. Only four implementations accept it, though. `stateful` says whether a variable defined in one turn is still around in the next. A stateless executor starts a fresh process every time, so the model has to @@ -148,6 +148,10 @@ the model writes. * **`ContainerCodeExecutor`** starts a local or self-hosted Docker container with the network disabled and Linux capabilities dropped. +* **`SmolCodeExecutor`** creates and deletes a local microVM for each snippet, + with guest networking disabled by default. It needs Linux KVM or macOS + Hypervisor.framework, but no container daemon. Set `target="cloud"` to + run the same executor on Smol Cloud instead. * **`GkeCodeExecutor`** runs the snippet on a Kubernetes cluster in gVisor-sandboxed Pods, or through the Agent Sandbox client, so the code talks to a sandboxed kernel rather than the node's own. @@ -175,10 +179,10 @@ account, a quota, and code executing somewhere you do not administer. Once you have settled on a level of trust, two details can take a choice away from you again. -If your agent needs a variable to survive from one snippet to the next, or wants -a CSV attached for it, three of the seven are already out. -`UnsafeLocalCodeExecutor`, `ContainerCodeExecutor` and -`CloudRunSandboxCodeExecutor` reject `stateful` and `optimize_data_file`. +If your agent needs a variable to survive from one snippet to the next, +`SmolCodeExecutor` is also stateless: it creates a fresh VM per code block. +For attached CSV files, `UnsafeLocalCodeExecutor`, `ContainerCodeExecutor` +and `CloudRunSandboxCodeExecutor` reject `optimize_data_file`. If you pick `CloudRunSandboxCodeExecutor`, read its options before you configure it, because it is the one implementation that changes a base default. Its @@ -216,6 +220,29 @@ agent = LlmAgent( ) ``` +### Run in a local or Cloud microVM + +Install `pip install "google-adk[smol]"` and use a local VM without a Docker daemon: + +```python +from google.adk.agents import LlmAgent +from google.adk.code_executors import SmolCodeExecutor + +agent = LlmAgent( + name="microvm_code_agent", + code_executor=SmolCodeExecutor(timeout_seconds=60), +) +``` + +The executor creates a fresh VM per code block, uploads Python and any input +files into `/workspace`, runs the code with a bounded timeout, and deletes +the VM. Set `optimize_data_file=True` to attach CSV files from the request. Guest egress is off by default. To run on Smol Cloud, provide +`SMOL_CLOUD_TOKEN` and set `target="cloud"`; `network_enabled=True` explicitly +allows the guest to access the network. Cloud VMs also have an expiry time in +case the client process stops before deletion. Each code block starts with a +clean filesystem, so Python variables and generated files do not persist +between blocks. The SDK supports Linux and macOS hosts. + ### Run in a gVisor sandbox on Kubernetes `GkeCodeExecutor` creates one short-lived Job per execution on the gVisor @@ -252,11 +279,12 @@ agent = LlmAgent( `UnsafeLocalCodeExecutor`, `ContainerCodeExecutor` and `CloudRunSandboxCodeExecutor` raise `ValueError` at construction if you set either to `True`, with a message naming the class. -* **Four implementations need extra packages.** `VertexAiCodeExecutor`, +* **Five implementations need extra packages.** `VertexAiCodeExecutor`, `ContainerCodeExecutor`, `GkeCodeExecutor` and `AgentEngineSandboxCodeExecutor` arrive with `pip install "google-adk[extensions]"`. Without them the import still - succeeds, and construction is where it fails. + succeeds, and construction is where it fails. `SmolCodeExecutor` instead + uses `pip install "google-adk[smol]"`; its SDK is loaded on execution. ## Related samples diff --git a/pyproject.toml b/pyproject.toml index e28f601cf8..df30dcec20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,7 @@ optional-dependencies.all = [ "redis>=4.2", "rouge-score>=0.1.2", "slack-bolt>=1.22", + "smolmachines>=1.25.2,<2; sys_platform!='win32'", "sqlalchemy[asyncio]>=2,<3", "sqlalchemy-spanner>=1.14", "tabulate>=0.9", @@ -314,6 +315,9 @@ optional-dependencies.slack = [ "aiohttp!=3.14.2", "slack-bolt>=1.22", ] +optional-dependencies.smol = [ + "smolmachines>=1.25.2,<2; sys_platform!='win32'", +] optional-dependencies.test = [ "a2a-sdk>=0.3.4,<2", "anthropic>=0.78", # For anthropic model tests; 0.78 introduced ThinkingConfigAdaptiveParam (required for Claude Opus 4.7). diff --git a/src/google/adk/code_executors/__init__.py b/src/google/adk/code_executors/__init__.py index acf762ccb8..b4417fd9a4 100644 --- a/src/google/adk/code_executors/__init__.py +++ b/src/google/adk/code_executors/__init__.py @@ -26,6 +26,7 @@ from .code_executor_context import CodeExecutorContext from .container_code_executor import ContainerCodeExecutor from .gke_code_executor import GkeCodeExecutor + from .smol_code_executor import SmolCodeExecutor from .vertex_ai_code_executor import VertexAiCodeExecutor logger = logging.getLogger('google_adk.' + __name__) @@ -38,6 +39,7 @@ 'VertexAiCodeExecutor', 'ContainerCodeExecutor', 'GkeCodeExecutor', + 'SmolCodeExecutor', 'AgentEngineSandboxCodeExecutor', ] @@ -81,6 +83,10 @@ def __getattr__(name: str) -> object: 'GkeCodeExecutor requires additional dependencies. ' 'Please install with: pip install "google-adk[extensions]"' ) from e + elif name == 'SmolCodeExecutor': + from .smol_code_executor import SmolCodeExecutor + + return SmolCodeExecutor elif name == 'AgentEngineSandboxCodeExecutor': try: from .agent_engine_sandbox_code_executor import AgentEngineSandboxCodeExecutor diff --git a/src/google/adk/code_executors/smol_code_executor.py b/src/google/adk/code_executors/smol_code_executor.py new file mode 100644 index 0000000000..f5734b54a4 --- /dev/null +++ b/src/google/adk/code_executors/smol_code_executor.py @@ -0,0 +1,149 @@ +# Copyright 2026 Google LLC +# +# 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. + +"""Run model-generated Python in a disposable local or Cloud microVM.""" + +from __future__ import annotations + +import logging +from typing import Any +from typing import Literal +from typing import TYPE_CHECKING +import uuid + +from pydantic import Field +from pydantic import SecretStr +from typing_extensions import override + +from .base_code_executor import BaseCodeExecutor +from .code_execution_utils import CodeExecutionInput +from .code_execution_utils import CodeExecutionResult + +if TYPE_CHECKING: + from ..agents.invocation_context import InvocationContext + +logger = logging.getLogger('google_adk.' + __name__) + + +class SmolCodeExecutor(BaseCodeExecutor): + """Execute Python in a fresh Smol Machines microVM for each code block. + + A local VM needs Linux KVM or macOS Hypervisor.framework. Use ``target='cloud'`` + for Smol Cloud, with ``SMOL_CLOUD_TOKEN`` or a configured Smol CLI login. + Install the optional SDK with ``pip install 'google-adk[smol]'``. + + Guest network access is disabled by default. The VM is deleted after every + execution, including failed or timed-out executions; no Python variables or + files persist between blocks. Input files are available in ``/workspace``. + """ + + target: Literal['local', 'cloud'] = 'local' + image: str = 'python:3.12-slim' + network_enabled: bool = False + cpus: int = Field(default=2, gt=0) + memory_mb: int = Field(default=1024, gt=0) + api_key: SecretStr | None = Field(default=None, repr=False) + timeout_seconds: int = Field(default=300, gt=0) + stateful: bool = Field(default=False, frozen=True) + optimize_data_file: bool = False + + def __init__(self, **data: Any) -> None: + if data.get('stateful'): + raise ValueError('SmolCodeExecutor creates a new VM for each execution.') + super().__init__(**data) + + @override + def execute_code( + self, + invocation_context: InvocationContext, + code_execution_input: CodeExecutionInput, + ) -> CodeExecutionResult: + del invocation_context # A fresh VM has no session state to share. + for file in code_execution_input.input_files: + if ( + not file.name + or file.name in ('.', '..') + or '/' in file.name + or '\\' in file.name + or '\x00' in file.name + ): + raise ValueError(f'Invalid sandbox input file name: {file.name!r}') + try: + import smol + except ImportError as exc: + raise ImportError( + 'SmolCodeExecutor requires the Smol Machines SDK. ' + "Install with: pip install 'google-adk[smol]'" + ) from exc + + connection = smol.ConnectOptions( + target=self.target, + api_key=self.api_key.get_secret_value() if self.api_key else None, + ) + config = smol.MachineConfig( + image=self.image, + resources=smol.ResourceSpec( + cpus=self.cpus, + memory_mb=self.memory_mb, + network=self.network_enabled, + ), + persistent=False, + auto_stop_seconds=( + max(600, self.timeout_seconds + 60) + if self.target == 'cloud' + else None + ), + ttl_seconds=( + max(1200, 2 * self.timeout_seconds + 60) + if self.target == 'cloud' + else None + ), + ) + machine = smol.Machine.create(config, connection) + failed = False + try: + script_path = f'/workspace/adk-code-{uuid.uuid4().hex}.py' + for file in code_execution_input.input_files: + content = ( + file.content.encode('utf-8') + if isinstance(file.content, str) + else file.content + ) + machine.write_file(f'/workspace/{file.name}', content) + machine.write_file(script_path, code_execution_input.code) + result = machine.exec( + ['python3', script_path], + smol.ExecOptions( + workdir='/workspace', timeout=float(self.timeout_seconds) + ), + ) + stderr = result.stderr + if result.stdout_truncated or result.stderr_truncated: + stderr += '\nSmol VM output was truncated.' + return CodeExecutionResult( + stdout=result.stdout, + stderr=stderr, + exit_code=result.exit_code, + ) + except BaseException: + failed = True + raise + finally: + try: + machine.delete() + except Exception: + if failed: + logger.exception('Could not delete Smol VM after execution failure') + else: + raise diff --git a/tests/unittests/code_executors/test_smol_code_executor.py b/tests/unittests/code_executors/test_smol_code_executor.py new file mode 100644 index 0000000000..7ee2ad20ec --- /dev/null +++ b/tests/unittests/code_executors/test_smol_code_executor.py @@ -0,0 +1,204 @@ +# Copyright 2026 Google LLC +# +# 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. + +"""Contract tests for the Smol Machines code executor.""" + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +from google.adk.agents import LlmAgent +from google.adk.code_executors import SmolCodeExecutor +from google.adk.code_executors.code_execution_utils import CodeExecutionInput +from google.adk.code_executors.code_execution_utils import File +import pytest + +from tests.unittests import testing_utils + + +@pytest.fixture +def vm(monkeypatch): + machine = MagicMock() + machine.exec.return_value = SimpleNamespace( + stdout='42\n', + stderr='', + exit_code=0, + stdout_truncated=False, + stderr_truncated=False, + ) + create = MagicMock(return_value=machine) + + def options(**kwargs): + return SimpleNamespace(**kwargs) + + monkeypatch.setitem( + sys.modules, + 'smol', + SimpleNamespace( + Machine=SimpleNamespace(create=create), + ConnectOptions=options, + MachineConfig=options, + ResourceSpec=options, + ExecOptions=options, + ), + ) + return machine, create + + +def test_local_code_runs_without_guest_egress_and_deletes_vm(vm): + machine, create = vm + executor = SmolCodeExecutor(timeout_seconds=7) + + result = executor.execute_code(None, CodeExecutionInput(code='print(42)')) + + assert (result.stdout, result.exit_code) == ('42\n', 0) + config, conn = create.call_args.args + assert config.image == 'python:3.12-slim' + assert config.resources.network is False + assert config.persistent is False + assert conn.target == 'local' + script_path, source = machine.write_file.call_args.args + assert script_path.startswith('/workspace/adk-code-') + assert source == 'print(42)' + assert machine.exec.call_args.args[0] == ['python3', script_path] + assert machine.exec.call_args.args[1].timeout == 7.0 + machine.delete.assert_called_once_with() + + +def test_agent_runner_executes_code_and_returns_microvm_output(vm): + machine, create = vm + model = testing_utils.MockModel.create( + responses=['```python\nprint(42)\n```', 'The answer is 42.'] + ) + agent = LlmAgent( + name='smol_agent', model=model, code_executor=SmolCodeExecutor() + ) + + events = testing_utils.InMemoryRunner(root_agent=agent).run( + 'Calculate the answer with Python.' + ) + + assert any( + part.code_execution_result and '42' in str(part.code_execution_result) + for event in events + if event.content + for part in event.content.parts or [] + ) + assert events[-1].content.parts[0].text == 'The answer is 42.' + create.assert_called_once() + machine.delete.assert_called_once_with() + + +def test_cloud_key_is_redacted_and_vm_has_lifetime_limit(vm): + machine, create = vm + executor = SmolCodeExecutor(target='cloud', api_key='private-token') + + assert 'private-token' not in repr(executor) + assert 'private-token' not in executor.model_dump_json() + executor.execute_code(None, CodeExecutionInput(code='print(42)')) + + config, conn = create.call_args.args + assert conn.target == 'cloud' + assert conn.api_key == 'private-token' + assert config.ttl_seconds >= 2 * executor.timeout_seconds + assert config.auto_stop_seconds > executor.timeout_seconds + machine.delete.assert_called_once_with() + + +def test_input_files_are_uploaded_into_the_sandbox(vm): + machine, _ = vm + executor = SmolCodeExecutor(optimize_data_file=True) + executor.execute_code( + None, + CodeExecutionInput( + code="print(open('data.csv').read())", + input_files=[ + File(name='data.csv', content='one,two', mime_type='text/csv'), + File(name='file.bin', content=b'\x00\xff'), + ], + ), + ) + + assert machine.write_file.call_args_list[0].args == ( + '/workspace/data.csv', + b'one,two', + ) + assert machine.write_file.call_args_list[1].args == ( + '/workspace/file.bin', + b'\x00\xff', + ) + assert executor.optimize_data_file is True + machine.delete.assert_called_once_with() + + +@pytest.mark.parametrize('name', ['../secret', '/etc/passwd', 'a\\b', '.', '']) +def test_invalid_file_path_is_rejected_before_provisioning(vm, name): + _, create = vm + with pytest.raises(ValueError, match='Invalid sandbox input file name'): + SmolCodeExecutor().execute_code( + None, + CodeExecutionInput( + code='pass', input_files=[File(name=name, content='')] + ), + ) + create.assert_not_called() + + +def test_exec_failure_deletes_vm(vm): + machine, _ = vm + machine.exec.side_effect = RuntimeError('timed out') + with pytest.raises(RuntimeError, match='timed out'): + SmolCodeExecutor(timeout_seconds=1).execute_code( + None, CodeExecutionInput(code='while True: pass') + ) + machine.delete.assert_called_once_with() + + +def test_delete_failure_does_not_hide_execution_failure(vm, caplog): + machine, _ = vm + machine.exec.side_effect = RuntimeError('execution failed') + machine.delete.side_effect = RuntimeError('delete failed') + with pytest.raises(RuntimeError, match='execution failed'): + SmolCodeExecutor().execute_code(None, CodeExecutionInput(code='pass')) + assert 'Could not delete Smol VM' in caplog.text + + +def test_delete_failure_is_reported_after_success(vm): + machine, _ = vm + machine.delete.side_effect = RuntimeError('delete failed') + with pytest.raises(RuntimeError, match='delete failed'): + SmolCodeExecutor().execute_code(None, CodeExecutionInput(code='pass')) + + +def test_truncated_output_is_visible(vm): + machine, _ = vm + machine.exec.return_value.stdout_truncated = True + result = SmolCodeExecutor().execute_code( + None, CodeExecutionInput(code='print(42)') + ) + assert 'truncated' in result.stderr + + +@pytest.mark.parametrize( + 'options', [{'stateful': True}, {'timeout_seconds': 0}] +) +def test_unbounded_or_stateful_config_is_rejected(options): + with pytest.raises(ValueError): + SmolCodeExecutor(**options) + + +def test_missing_optional_sdk_gives_install_hint(monkeypatch): + monkeypatch.setitem(sys.modules, 'smol', None) + with pytest.raises(ImportError, match=r'google-adk\[smol\]'): + SmolCodeExecutor().execute_code(None, CodeExecutionInput(code='pass'))