chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,502 @@
|
||||
# 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.
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.code_executors.agent_engine_sandbox_code_executor import AgentEngineSandboxCodeExecutor
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionInput
|
||||
from google.adk.code_executors.code_execution_utils import File
|
||||
from google.adk.sessions.session import Session
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_invocation_context() -> InvocationContext:
|
||||
"""Fixture for a mock InvocationContext."""
|
||||
mock = MagicMock(spec=InvocationContext)
|
||||
mock.invocation_id = "test-invocation-123"
|
||||
session = MagicMock(spec=Session)
|
||||
mock.session = session
|
||||
session.state = {}
|
||||
|
||||
return mock
|
||||
|
||||
|
||||
class TestAgentEngineSandboxCodeExecutor:
|
||||
"""Unit tests for the AgentEngineSandboxCodeExecutor."""
|
||||
|
||||
def test_init_with_sandbox_overrides(self):
|
||||
"""Tests that class attributes can be overridden at instantiation."""
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
sandbox_resource_name="projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789",
|
||||
)
|
||||
assert executor.sandbox_resource_name == (
|
||||
"projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789"
|
||||
)
|
||||
|
||||
def test_init_with_sandbox_overrides_throws_error(self):
|
||||
"""Tests that class attributes can be overridden at instantiation."""
|
||||
with pytest.raises(ValueError):
|
||||
AgentEngineSandboxCodeExecutor(
|
||||
sandbox_resource_name="projects/123/locations/us-central1/reasoningEngines/456/sandboxes/789",
|
||||
)
|
||||
|
||||
def test_init_with_agent_engine_overrides_throws_error(self):
|
||||
"""Tests that class attributes can be overridden at instantiation."""
|
||||
with pytest.raises(ValueError):
|
||||
AgentEngineSandboxCodeExecutor(
|
||||
agent_engine_resource_name=(
|
||||
"projects/123/locations/us-central1/reason/456"
|
||||
),
|
||||
)
|
||||
|
||||
@patch("vertexai.Client")
|
||||
def test_execute_code_success(
|
||||
self,
|
||||
mock_vertexai_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
# Setup Mocks
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
mock_response = MagicMock()
|
||||
mock_json_output = MagicMock()
|
||||
mock_json_output.mime_type = "application/json"
|
||||
mock_json_output.data = json.dumps(
|
||||
{"msg_out": "hello world", "msg_err": ""}
|
||||
).encode("utf-8")
|
||||
mock_json_output.metadata = None
|
||||
|
||||
mock_file_output = MagicMock()
|
||||
mock_file_output.mime_type = "text/plain"
|
||||
mock_file_output.data = b"file content"
|
||||
mock_file_output.metadata = MagicMock()
|
||||
mock_file_output.metadata.attributes = {"file_name": b"file.txt"}
|
||||
|
||||
mock_png_file_output = MagicMock()
|
||||
mock_png_file_output.mime_type = "image/png"
|
||||
sample_png_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||
mock_png_file_output.data = sample_png_bytes
|
||||
mock_png_file_output.metadata = MagicMock()
|
||||
mock_png_file_output.metadata.attributes = {"file_name": b"file.png"}
|
||||
|
||||
mock_response.outputs = [
|
||||
mock_json_output,
|
||||
mock_file_output,
|
||||
mock_png_file_output,
|
||||
]
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
# Execute
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
sandbox_resource_name="projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789"
|
||||
)
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assert
|
||||
assert result.stdout == "hello world"
|
||||
assert not result.stderr
|
||||
assert result.output_files[0].mime_type == "text/plain"
|
||||
assert result.output_files[0].content == b"file content"
|
||||
|
||||
assert result.output_files[0].name == "file.txt"
|
||||
assert result.output_files[1].mime_type == "image/png"
|
||||
assert result.output_files[1].name == "file.png"
|
||||
assert result.output_files[1].content == sample_png_bytes
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.assert_called_once_with(
|
||||
name="projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789",
|
||||
input_data={"code": 'print("hello world")'},
|
||||
)
|
||||
|
||||
@patch("vertexai.Client")
|
||||
def test_execute_code_sends_input_files_with_content_key(
|
||||
self,
|
||||
mock_vertexai_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Input files must be sent under the 'content' key the SDK expects."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
mock_response = MagicMock()
|
||||
mock_response.outputs = []
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
sandbox_resource_name="projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789"
|
||||
)
|
||||
code_input = CodeExecutionInput(
|
||||
code='print("hi")',
|
||||
input_files=[
|
||||
File(name="data.csv", content="a,b,c", mime_type="text/csv")
|
||||
],
|
||||
)
|
||||
executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
_, call_kwargs = (
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.call_args
|
||||
)
|
||||
sent_files = call_kwargs["input_data"]["files"]
|
||||
assert sent_files == [
|
||||
{"name": "data.csv", "content": "a,b,c", "mime_type": "text/csv"}
|
||||
]
|
||||
|
||||
@patch("vertexai.Client")
|
||||
def test_execute_code_recreates_sandbox_when_get_returns_none(
|
||||
self,
|
||||
mock_vertexai_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
# Setup Mocks
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
|
||||
# Existing sandbox name stored in session, but get() will return None
|
||||
existing_sandbox_name = "projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/old"
|
||||
mock_invocation_context.session.state = {
|
||||
"sandbox_name": existing_sandbox_name
|
||||
}
|
||||
|
||||
# Mock get to return None (simulating missing/expired sandbox)
|
||||
mock_api_client.agent_engines.sandboxes.get.return_value = None
|
||||
|
||||
# Mock create operation to return a new sandbox resource name
|
||||
operation_mock = MagicMock()
|
||||
created_sandbox_name = "projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789"
|
||||
operation_mock.response.name = created_sandbox_name
|
||||
mock_api_client.agent_engines.sandboxes.create.return_value = operation_mock
|
||||
|
||||
# Mock execute_code response
|
||||
mock_response = MagicMock()
|
||||
mock_json_output = MagicMock()
|
||||
mock_json_output.mime_type = "application/json"
|
||||
mock_json_output.data = json.dumps(
|
||||
{"msg_out": "recreated sandbox run", "msg_err": ""}
|
||||
).encode("utf-8")
|
||||
mock_json_output.metadata = None
|
||||
mock_response.outputs = [mock_json_output]
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
# Execute using agent_engine_resource_name so a sandbox can be created
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
agent_engine_resource_name=(
|
||||
"projects/123/locations/us-central1/reasoningEngines/456"
|
||||
)
|
||||
)
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assert get was called for the existing sandbox
|
||||
mock_api_client.agent_engines.sandboxes.get.assert_called_once_with(
|
||||
name=existing_sandbox_name
|
||||
)
|
||||
|
||||
# Assert create was called and session updated with new sandbox
|
||||
mock_api_client.agent_engines.sandboxes.create.assert_called_once()
|
||||
assert (
|
||||
mock_invocation_context.session.state["sandbox_name"]
|
||||
== created_sandbox_name
|
||||
)
|
||||
|
||||
# Assert execute_code used the created sandbox name
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.assert_called_once_with(
|
||||
name=created_sandbox_name,
|
||||
input_data={"code": 'print("hello world")'},
|
||||
)
|
||||
|
||||
@patch("vertexai.Client")
|
||||
def test_execute_code_recreates_sandbox_when_get_raises_client_error(
|
||||
self,
|
||||
mock_vertexai_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
# Setup Mocks
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
|
||||
# Existing sandbox name stored in session
|
||||
existing_sandbox_name = "projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/old"
|
||||
mock_invocation_context.session.state = {
|
||||
"sandbox_name": existing_sandbox_name
|
||||
}
|
||||
|
||||
# Mock get to raise ClientError with code 404
|
||||
from google.genai.errors import ClientError
|
||||
|
||||
mock_api_client.agent_engines.sandboxes.get.side_effect = ClientError(
|
||||
code=404, response_json={"message": "Not Found"}
|
||||
)
|
||||
|
||||
# Mock create operation to return a new sandbox resource name
|
||||
operation_mock = MagicMock()
|
||||
created_sandbox_name = "projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789"
|
||||
operation_mock.response.name = created_sandbox_name
|
||||
mock_api_client.agent_engines.sandboxes.create.return_value = operation_mock
|
||||
|
||||
# Mock execute_code response
|
||||
mock_response = MagicMock()
|
||||
mock_json_output = MagicMock()
|
||||
mock_json_output.mime_type = "application/json"
|
||||
mock_json_output.data = json.dumps(
|
||||
{"msg_out": "recreated sandbox run", "msg_err": ""}
|
||||
).encode("utf-8")
|
||||
mock_json_output.metadata = None
|
||||
mock_response.outputs = [mock_json_output]
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
# Execute using agent_engine_resource_name so a sandbox can be created
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
agent_engine_resource_name=(
|
||||
"projects/123/locations/us-central1/reasoningEngines/456"
|
||||
)
|
||||
)
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assert get was called for the existing sandbox
|
||||
mock_api_client.agent_engines.sandboxes.get.assert_called_once_with(
|
||||
name=existing_sandbox_name
|
||||
)
|
||||
|
||||
# Assert create was called and session updated with new sandbox
|
||||
mock_api_client.agent_engines.sandboxes.create.assert_called_once()
|
||||
assert (
|
||||
mock_invocation_context.session.state["sandbox_name"]
|
||||
== created_sandbox_name
|
||||
)
|
||||
|
||||
# Assert execute_code used the created sandbox name
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.assert_called_once_with(
|
||||
name=created_sandbox_name,
|
||||
input_data={"code": 'print("hello world")'},
|
||||
)
|
||||
|
||||
@patch("vertexai.Client")
|
||||
def test_execute_code_creates_sandbox_if_missing(
|
||||
self,
|
||||
mock_vertexai_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
# Setup Mocks
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
|
||||
# Mock create operation to return a sandbox resource name
|
||||
operation_mock = MagicMock()
|
||||
created_sandbox_name = "projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789"
|
||||
operation_mock.response.name = created_sandbox_name
|
||||
mock_api_client.agent_engines.sandboxes.create.return_value = operation_mock
|
||||
|
||||
# Mock execute_code response
|
||||
mock_response = MagicMock()
|
||||
mock_json_output = MagicMock()
|
||||
mock_json_output.mime_type = "application/json"
|
||||
mock_json_output.data = json.dumps(
|
||||
{"stdout": "created sandbox run", "stderr": ""}
|
||||
).encode("utf-8")
|
||||
mock_json_output.metadata = None
|
||||
mock_response.outputs = [mock_json_output]
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
# Ensure session.state behaves like a dict for storing sandbox_name
|
||||
mock_invocation_context.session.state = {}
|
||||
|
||||
# Execute using agent_engine_resource_name so a sandbox will be created
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
agent_engine_resource_name=(
|
||||
"projects/123/locations/us-central1/reasoningEngines/456"
|
||||
),
|
||||
sandbox_resource_name=None,
|
||||
)
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assert sandbox creation was called and session state updated
|
||||
mock_api_client.agent_engines.sandboxes.create.assert_called_once()
|
||||
create_call_kwargs = (
|
||||
mock_api_client.agent_engines.sandboxes.create.call_args.kwargs
|
||||
)
|
||||
assert create_call_kwargs["name"] == (
|
||||
"projects/123/locations/us-central1/reasoningEngines/456"
|
||||
)
|
||||
assert (
|
||||
mock_invocation_context.session.state["sandbox_name"]
|
||||
== created_sandbox_name
|
||||
)
|
||||
|
||||
# Assert execute_code used the created sandbox name
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.assert_called_once_with(
|
||||
name=created_sandbox_name,
|
||||
input_data={"code": 'print("hello world")'},
|
||||
)
|
||||
|
||||
@patch("vertexai.Client")
|
||||
def test_execute_code_sends_correct_field_names_for_input_files(
|
||||
self,
|
||||
mock_vertexai_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Input files are sent with 'content' and 'mime_type' keys (not 'contents'/'mimeType')."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_json_output = MagicMock()
|
||||
mock_json_output.mime_type = "application/json"
|
||||
mock_json_output.data = json.dumps({"msg_out": "", "msg_err": ""}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
mock_json_output.metadata = None
|
||||
mock_response.outputs = [mock_json_output]
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
sandbox_resource_name="projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789"
|
||||
)
|
||||
code_input = CodeExecutionInput(
|
||||
code="import pandas as pd; df = pd.read_csv('data.csv')",
|
||||
input_files=[
|
||||
File(
|
||||
name="data.csv", content=b"col1,col2\n1,2", mime_type="text/csv"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.assert_called_once_with(
|
||||
name="projects/123/locations/us-central1/reasoningEngines/456/sandboxEnvironments/789",
|
||||
input_data={
|
||||
"code": "import pandas as pd; df = pd.read_csv('data.csv')",
|
||||
"files": [{
|
||||
"name": "data.csv",
|
||||
"content": b"col1,col2\n1,2",
|
||||
"mime_type": "text/csv",
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
def test_init_with_agent_engine_resource_name(self):
|
||||
"""Tests init when only agent_engine_resource_name is provided."""
|
||||
agent_engine_name = (
|
||||
"projects/123/locations/us-central1/reasoningEngines/456"
|
||||
)
|
||||
|
||||
executor = AgentEngineSandboxCodeExecutor(
|
||||
agent_engine_resource_name=agent_engine_name
|
||||
)
|
||||
|
||||
# Verify the engine name is set, and sandbox remains None.
|
||||
assert executor.agent_engine_resource_name == agent_engine_name
|
||||
assert executor.sandbox_resource_name is None
|
||||
assert executor._project_id == "123"
|
||||
assert executor._location == "us-central1"
|
||||
|
||||
@patch("vertexai.Client")
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GOOGLE_CLOUD_PROJECT": "test-project-456",
|
||||
"GOOGLE_CLOUD_LOCATION": "us-central1",
|
||||
},
|
||||
)
|
||||
def test_execute_code_with_auto_create_agent_engine(
|
||||
self, mock_vertexai_client, mock_invocation_context
|
||||
):
|
||||
"""Tests that Agent Engine is created lazily in execute_code."""
|
||||
# Setup Mocks
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
|
||||
# Mock Engine Creation
|
||||
mock_created_engine = MagicMock()
|
||||
mock_created_engine.api_resource.name = "projects/test-project-456/locations/us-central1/reasoningEngines/auto-created-ae-1"
|
||||
mock_api_client.agent_engines.create.return_value = mock_created_engine
|
||||
|
||||
# Mock create operation to return a sandbox resource name
|
||||
operation_mock = MagicMock()
|
||||
created_sandbox_name = "projects/test-project-456/locations/us-central1/reasoningEngines/auto-created-ae-1/sandboxEnvironments/789"
|
||||
operation_mock.response.name = created_sandbox_name
|
||||
mock_api_client.agent_engines.sandboxes.create.return_value = operation_mock
|
||||
|
||||
# Mock execute_code response
|
||||
mock_response = MagicMock()
|
||||
mock_json_output = MagicMock()
|
||||
mock_json_output.mime_type = "application/json"
|
||||
mock_json_output.data = json.dumps(
|
||||
{"stdout": "created sandbox run", "stderr": ""}
|
||||
).encode("utf-8")
|
||||
mock_json_output.metadata = None
|
||||
mock_response.outputs = [mock_json_output]
|
||||
mock_api_client.agent_engines.sandboxes.execute_code.return_value = (
|
||||
mock_response
|
||||
)
|
||||
|
||||
# Execute
|
||||
executor = AgentEngineSandboxCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assert
|
||||
mock_api_client.agent_engines.create.assert_called_once()
|
||||
assert (
|
||||
executor.agent_engine_resource_name
|
||||
== "projects/test-project-456/locations/us-central1/reasoningEngines/auto-created-ae-1"
|
||||
)
|
||||
assert executor.sandbox_resource_name is None
|
||||
mock_api_client.agent_engines.sandboxes.create.assert_called_once()
|
||||
assert (
|
||||
mock_invocation_context.session.state["sandbox_name"]
|
||||
== created_sandbox_name
|
||||
)
|
||||
|
||||
@patch("vertexai.Client")
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GOOGLE_CLOUD_PROJECT": "test-project-456",
|
||||
"GOOGLE_CLOUD_LOCATION": "us-central1",
|
||||
},
|
||||
)
|
||||
def test_execute_code_auto_create_agent_engine_fails(
|
||||
self, mock_vertexai_client, mock_invocation_context
|
||||
):
|
||||
"""Tests error handling when auto-creating Agent Engine fails."""
|
||||
mock_api_client = MagicMock()
|
||||
mock_vertexai_client.return_value = mock_api_client
|
||||
mock_api_client.agent_engines.create.side_effect = Exception(
|
||||
"Failed to auto-create Agent Engine"
|
||||
)
|
||||
|
||||
executor = AgentEngineSandboxCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
|
||||
with pytest.raises(Exception, match="Failed to auto-create Agent Engine"):
|
||||
executor.execute_code(mock_invocation_context, code_input)
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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.
|
||||
|
||||
from google.adk.code_executors.built_in_code_executor import BuiltInCodeExecutor
|
||||
from google.adk.models.llm_request import LlmRequest
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def built_in_executor() -> BuiltInCodeExecutor:
|
||||
return BuiltInCodeExecutor()
|
||||
|
||||
|
||||
def test_process_llm_request_gemini_2_model_config_none(
|
||||
built_in_executor: BuiltInCodeExecutor,
|
||||
):
|
||||
"""Tests processing when llm_request.config is None for Gemini 2."""
|
||||
llm_request = LlmRequest(model="gemini-2.5-flash")
|
||||
built_in_executor.process_llm_request(llm_request)
|
||||
assert llm_request.config is not None
|
||||
assert llm_request.config.tools == [
|
||||
types.Tool(code_execution=types.ToolCodeExecution())
|
||||
]
|
||||
|
||||
|
||||
def test_process_llm_request_gemini_2_model_tools_none(
|
||||
built_in_executor: BuiltInCodeExecutor,
|
||||
):
|
||||
"""Tests processing when llm_request.config.tools is None for Gemini 2."""
|
||||
llm_request = LlmRequest(
|
||||
model="gemini-2.5-pro", config=types.GenerateContentConfig()
|
||||
)
|
||||
built_in_executor.process_llm_request(llm_request)
|
||||
assert llm_request.config.tools == [
|
||||
types.Tool(code_execution=types.ToolCodeExecution())
|
||||
]
|
||||
|
||||
|
||||
def test_process_llm_request_gemini_2_model_tools_empty(
|
||||
built_in_executor: BuiltInCodeExecutor,
|
||||
):
|
||||
"""Tests processing when llm_request.config.tools is empty for Gemini 2."""
|
||||
llm_request = LlmRequest(
|
||||
model="gemini-2.5-pro",
|
||||
config=types.GenerateContentConfig(tools=[]),
|
||||
)
|
||||
built_in_executor.process_llm_request(llm_request)
|
||||
assert llm_request.config.tools == [
|
||||
types.Tool(code_execution=types.ToolCodeExecution())
|
||||
]
|
||||
|
||||
|
||||
def test_process_llm_request_gemini_2_model_with_existing_tools(
|
||||
built_in_executor: BuiltInCodeExecutor,
|
||||
):
|
||||
"""Tests processing when llm_request.config.tools already has tools for Gemini 2."""
|
||||
existing_tool = types.Tool(
|
||||
function_declarations=[
|
||||
types.FunctionDeclaration(name="test_func", description="A test func")
|
||||
]
|
||||
)
|
||||
llm_request = LlmRequest(
|
||||
model="gemini-2.5-flash",
|
||||
config=types.GenerateContentConfig(tools=[existing_tool]),
|
||||
)
|
||||
built_in_executor.process_llm_request(llm_request)
|
||||
assert len(llm_request.config.tools) == 2
|
||||
assert existing_tool in llm_request.config.tools
|
||||
assert (
|
||||
types.Tool(code_execution=types.ToolCodeExecution())
|
||||
in llm_request.config.tools
|
||||
)
|
||||
|
||||
|
||||
def test_process_llm_request_non_gemini_2_model(
|
||||
built_in_executor: BuiltInCodeExecutor,
|
||||
):
|
||||
"""Tests that a ValueError is raised for non-Gemini 2 models."""
|
||||
llm_request = LlmRequest(model="gemini-1.5-flash")
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
built_in_executor.process_llm_request(llm_request)
|
||||
assert (
|
||||
"Gemini code execution tool is not supported for model gemini-1.5-flash"
|
||||
in str(excinfo.value)
|
||||
)
|
||||
|
||||
|
||||
def test_process_llm_request_non_gemini_2_model_with_disabled_check(
|
||||
built_in_executor: BuiltInCodeExecutor,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Tests non-Gemini models pass when model-id check is disabled."""
|
||||
monkeypatch.setenv("ADK_DISABLE_GEMINI_MODEL_ID_CHECK", "true")
|
||||
llm_request = LlmRequest(model="internal-model-v1")
|
||||
|
||||
built_in_executor.process_llm_request(llm_request)
|
||||
|
||||
assert llm_request.config is not None
|
||||
assert llm_request.config.tools == [
|
||||
types.Tool(code_execution=types.ToolCodeExecution())
|
||||
]
|
||||
|
||||
|
||||
def test_process_llm_request_no_model_name(
|
||||
built_in_executor: BuiltInCodeExecutor,
|
||||
):
|
||||
"""Tests that a ValueError is raised if model name is not set."""
|
||||
llm_request = LlmRequest() # Model name defaults to None
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
built_in_executor.process_llm_request(llm_request)
|
||||
assert "Gemini code execution tool is not supported for model None" in str(
|
||||
excinfo.value
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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.
|
||||
|
||||
import signal
|
||||
|
||||
from google.adk.code_executors import code_execution_utils
|
||||
from google.genai import types
|
||||
|
||||
|
||||
def test_extract_code_and_truncate_content_basic():
|
||||
"""Tests basic code extraction and content truncation."""
|
||||
content = types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part(
|
||||
text=(
|
||||
"Here is some code:\n```python\nx = 1\n```\nAnd some text"
|
||||
" after."
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
delimiters = [("```python\n", "\n```")]
|
||||
code = (
|
||||
code_execution_utils.CodeExecutionUtils.extract_code_and_truncate_content(
|
||||
content, delimiters
|
||||
)
|
||||
)
|
||||
assert code == "x = 1"
|
||||
assert len(content.parts) == 2
|
||||
assert content.parts[0].text == "Here is some code:\n"
|
||||
assert content.parts[1].executable_code.code == "x = 1"
|
||||
|
||||
|
||||
def test_extract_code_and_truncate_content_multiple_blocks():
|
||||
"""Tests that the first code block is extracted when multiple exist."""
|
||||
content = types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part(
|
||||
text=(
|
||||
"First:\n"
|
||||
"```python\n"
|
||||
"x = 1\n"
|
||||
"```\n"
|
||||
"Second:\n"
|
||||
"```python\n"
|
||||
"y = 2\n"
|
||||
"```"
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
delimiters = [("```python\n", "\n```")]
|
||||
code = (
|
||||
code_execution_utils.CodeExecutionUtils.extract_code_and_truncate_content(
|
||||
content, delimiters
|
||||
)
|
||||
)
|
||||
assert code == "x = 1"
|
||||
assert len(content.parts) == 2
|
||||
assert content.parts[0].text == "First:\n"
|
||||
assert content.parts[1].executable_code.code == "x = 1"
|
||||
|
||||
|
||||
def test_extract_code_and_truncate_content_no_delimiter():
|
||||
"""Tests when no delimiters are found in the content."""
|
||||
content = types.Content(
|
||||
role="model",
|
||||
parts=[types.Part(text="Just plain text without code.")],
|
||||
)
|
||||
delimiters = [("```python\n", "\n```")]
|
||||
code = (
|
||||
code_execution_utils.CodeExecutionUtils.extract_code_and_truncate_content(
|
||||
content, delimiters
|
||||
)
|
||||
)
|
||||
assert code is None
|
||||
# Content should be unmodified.
|
||||
assert len(content.parts) == 1
|
||||
assert content.parts[0].text == "Just plain text without code."
|
||||
|
||||
|
||||
def test_extract_code_and_truncate_content_redos_vulnerability():
|
||||
"""Tests that a string that would cause ReDoS behaves reasonably."""
|
||||
# Construct a long string that contains repeating patterns without matching delimiters.
|
||||
# The old regex pattern would backtrack exponentially.
|
||||
ticks = "`" * 3
|
||||
long_invalid_payload = ticks + "python\n" + "x = 1\n" * 5000 + "not_matching"
|
||||
content = types.Content(
|
||||
role="model",
|
||||
parts=[types.Part(text=long_invalid_payload)],
|
||||
)
|
||||
delimiters = [(ticks + "python\n", "\n" + ticks)]
|
||||
|
||||
def handler(_signum, _frame):
|
||||
raise TimeoutError("Test timed out (possible ReDoS regression)")
|
||||
|
||||
signal.signal(signal.SIGALRM, handler)
|
||||
signal.alarm(2)
|
||||
try:
|
||||
# If ReDoS vulnerability exists, this call will hang or take a very long time.
|
||||
code = code_execution_utils.CodeExecutionUtils.extract_code_and_truncate_content(
|
||||
content, delimiters
|
||||
)
|
||||
finally:
|
||||
signal.alarm(0)
|
||||
assert code is None
|
||||
|
||||
|
||||
def test_extract_code_and_truncate_content_multiple_delimiter_pairs():
|
||||
"""Tests code extraction when multiple different delimiter pairs are provided."""
|
||||
ticks = "`" * 3
|
||||
# Case 1: First delimiter pair matches first
|
||||
content = types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part(
|
||||
text="Here is tool code:\n"
|
||||
+ ticks
|
||||
+ "tool_code\nx = 1\n"
|
||||
+ ticks
|
||||
+ "\nAnd python code:\n"
|
||||
+ ticks
|
||||
+ "python\ny = 2\n"
|
||||
+ ticks
|
||||
)
|
||||
],
|
||||
)
|
||||
delimiters = [
|
||||
(ticks + "tool_code\n", "\n" + ticks),
|
||||
(ticks + "python\n", "\n" + ticks),
|
||||
]
|
||||
code = (
|
||||
code_execution_utils.CodeExecutionUtils.extract_code_and_truncate_content(
|
||||
content, delimiters
|
||||
)
|
||||
)
|
||||
assert code == "x = 1"
|
||||
assert len(content.parts) == 2
|
||||
assert content.parts[0].text == "Here is tool code:\n"
|
||||
assert content.parts[1].executable_code.code == "x = 1"
|
||||
|
||||
# Case 2: Second delimiter pair matches first
|
||||
content = types.Content(
|
||||
role="model",
|
||||
parts=[
|
||||
types.Part(
|
||||
text="Here is python code:\n"
|
||||
+ ticks
|
||||
+ "python\ny = 2\n"
|
||||
+ ticks
|
||||
+ "\nAnd tool code:\n"
|
||||
+ ticks
|
||||
+ "tool_code\nx = 1\n"
|
||||
+ ticks
|
||||
)
|
||||
],
|
||||
)
|
||||
code = (
|
||||
code_execution_utils.CodeExecutionUtils.extract_code_and_truncate_content(
|
||||
content, delimiters
|
||||
)
|
||||
)
|
||||
assert code == "y = 2"
|
||||
assert len(content.parts) == 2
|
||||
assert content.parts[0].text == "Here is python code:\n"
|
||||
assert content.parts[1].executable_code.code == "y = 2"
|
||||
@@ -0,0 +1,277 @@
|
||||
# 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.
|
||||
|
||||
from google.adk.code_executors.code_execution_utils import File
|
||||
from google.adk.code_executors.code_executor_context import CodeExecutorContext
|
||||
from google.adk.sessions.state import State
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_state() -> State:
|
||||
"""Fixture for an empty session state."""
|
||||
return State({}, {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context_with_data() -> CodeExecutorContext:
|
||||
"""Fixture for a CodeExecutorContext with some prepopulated data."""
|
||||
state_data = {
|
||||
"_code_execution_context": {
|
||||
"execution_session_id": "session123",
|
||||
"processed_input_files": ["file1.csv", "file2.txt"],
|
||||
},
|
||||
"_code_executor_input_files": [
|
||||
{"name": "input1.txt", "content": "YQ==", "mime_type": "text/plain"}
|
||||
],
|
||||
"_code_executor_error_counts": {"invocationA": 2},
|
||||
}
|
||||
state = State(state_data, {})
|
||||
return CodeExecutorContext(state)
|
||||
|
||||
|
||||
def test_init_empty_state(empty_state: State):
|
||||
"""Test initialization with an empty state."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
assert ctx._context == {}
|
||||
assert ctx._session_state is empty_state
|
||||
|
||||
|
||||
def test_get_state_delta_empty(empty_state: State):
|
||||
"""Test get_state_delta when context is empty."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
delta = ctx.get_state_delta()
|
||||
assert delta == {"_code_execution_context": {}}
|
||||
|
||||
|
||||
def test_get_state_delta_with_data(context_with_data: CodeExecutorContext):
|
||||
"""Test get_state_delta with existing context data."""
|
||||
delta = context_with_data.get_state_delta()
|
||||
expected_context = {
|
||||
"execution_session_id": "session123",
|
||||
"processed_input_files": ["file1.csv", "file2.txt"],
|
||||
}
|
||||
assert delta == {"_code_execution_context": expected_context}
|
||||
|
||||
|
||||
def test_get_execution_id_exists(context_with_data: CodeExecutorContext):
|
||||
"""Test getting an existing execution ID."""
|
||||
assert context_with_data.get_execution_id() == "session123"
|
||||
|
||||
|
||||
def test_get_execution_id_not_exists(empty_state: State):
|
||||
"""Test getting execution ID when it doesn't exist."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
assert ctx.get_execution_id() is None
|
||||
|
||||
|
||||
def test_set_execution_id(empty_state: State):
|
||||
"""Test setting an execution ID."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
ctx.set_execution_id("new_session_id")
|
||||
assert ctx._context["execution_session_id"] == "new_session_id"
|
||||
assert ctx.get_execution_id() == "new_session_id"
|
||||
|
||||
|
||||
def test_get_processed_file_names_exists(
|
||||
context_with_data: CodeExecutorContext,
|
||||
):
|
||||
"""Test getting existing processed file names."""
|
||||
assert context_with_data.get_processed_file_names() == [
|
||||
"file1.csv",
|
||||
"file2.txt",
|
||||
]
|
||||
|
||||
|
||||
def test_get_processed_file_names_not_exists(empty_state: State):
|
||||
"""Test getting processed file names when none exist."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
assert ctx.get_processed_file_names() == []
|
||||
|
||||
|
||||
def test_add_processed_file_names_new(empty_state: State):
|
||||
"""Test adding processed file names to an empty context."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
ctx.add_processed_file_names(["new_file.py"])
|
||||
assert ctx._context["processed_input_files"] == ["new_file.py"]
|
||||
|
||||
|
||||
def test_add_processed_file_names_append(
|
||||
context_with_data: CodeExecutorContext,
|
||||
):
|
||||
"""Test appending to existing processed file names."""
|
||||
context_with_data.add_processed_file_names(["another_file.md"])
|
||||
assert context_with_data.get_processed_file_names() == [
|
||||
"file1.csv",
|
||||
"file2.txt",
|
||||
"another_file.md",
|
||||
]
|
||||
|
||||
|
||||
def test_get_input_files_exists(context_with_data: CodeExecutorContext):
|
||||
"""Test getting existing input files."""
|
||||
files = context_with_data.get_input_files()
|
||||
assert len(files) == 1
|
||||
assert files[0].name == "input1.txt"
|
||||
assert files[0].content == "YQ=="
|
||||
assert files[0].mime_type == "text/plain"
|
||||
|
||||
|
||||
def test_get_input_files_not_exists(empty_state: State):
|
||||
"""Test getting input files when none exist."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
assert ctx.get_input_files() == []
|
||||
|
||||
|
||||
def test_add_input_files_new(empty_state: State):
|
||||
"""Test adding input files to an empty session state."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
new_files = [
|
||||
File(name="new.dat", content="Yg==", mime_type="application/octet-stream")
|
||||
]
|
||||
ctx.add_input_files(new_files)
|
||||
assert empty_state["_code_executor_input_files"] == [{
|
||||
"name": "new.dat",
|
||||
"content": "Yg==",
|
||||
"mime_type": "application/octet-stream",
|
||||
}]
|
||||
|
||||
|
||||
def test_add_input_files_append(context_with_data: CodeExecutorContext):
|
||||
"""Test appending to existing input files."""
|
||||
new_file = File(name="input2.log", content="Yw==", mime_type="text/x-log")
|
||||
context_with_data.add_input_files([new_file])
|
||||
expected_files_data = [
|
||||
{"name": "input1.txt", "content": "YQ==", "mime_type": "text/plain"},
|
||||
{"name": "input2.log", "content": "Yw==", "mime_type": "text/x-log"},
|
||||
]
|
||||
assert (
|
||||
context_with_data._session_state["_code_executor_input_files"]
|
||||
== expected_files_data
|
||||
)
|
||||
|
||||
|
||||
def test_clear_input_files(context_with_data: CodeExecutorContext):
|
||||
"""Test clearing input files and processed file names."""
|
||||
context_with_data.clear_input_files()
|
||||
assert context_with_data._session_state["_code_executor_input_files"] == []
|
||||
assert context_with_data._context["processed_input_files"] == []
|
||||
|
||||
|
||||
def test_clear_input_files_when_not_exist(empty_state: State):
|
||||
"""Test clearing input files when they don't exist initially."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
ctx.clear_input_files() # Should not raise error
|
||||
assert "_code_executor_input_files" not in empty_state # Or assert it's empty
|
||||
assert "_code_execution_context" not in empty_state or not empty_state[
|
||||
"_code_execution_context"
|
||||
].get("processed_input_files")
|
||||
|
||||
|
||||
def test_get_error_count_exists(context_with_data: CodeExecutorContext):
|
||||
"""Test getting an existing error count."""
|
||||
assert context_with_data.get_error_count("invocationA") == 2
|
||||
|
||||
|
||||
def test_get_error_count_invocation_not_exists(
|
||||
context_with_data: CodeExecutorContext,
|
||||
):
|
||||
"""Test getting error count for an unknown invocation ID."""
|
||||
assert context_with_data.get_error_count("invocationB") == 0
|
||||
|
||||
|
||||
def test_get_error_count_no_error_key(empty_state: State):
|
||||
"""Test getting error count when the error key itself doesn't exist."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
assert ctx.get_error_count("any_invocation") == 0
|
||||
|
||||
|
||||
def test_increment_error_count_new_invocation(empty_state: State):
|
||||
"""Test incrementing error count for a new invocation ID."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
ctx.increment_error_count("invocationNew")
|
||||
assert empty_state["_code_executor_error_counts"]["invocationNew"] == 1
|
||||
|
||||
|
||||
def test_increment_error_count_existing_invocation(
|
||||
context_with_data: CodeExecutorContext,
|
||||
):
|
||||
"""Test incrementing error count for an existing invocation ID."""
|
||||
context_with_data.increment_error_count("invocationA")
|
||||
assert (
|
||||
context_with_data._session_state["_code_executor_error_counts"][
|
||||
"invocationA"
|
||||
]
|
||||
== 3
|
||||
)
|
||||
|
||||
|
||||
def test_reset_error_count_exists(context_with_data: CodeExecutorContext):
|
||||
"""Test resetting an existing error count."""
|
||||
context_with_data.reset_error_count("invocationA")
|
||||
assert "invocationA" not in (
|
||||
context_with_data._session_state["_code_executor_error_counts"]
|
||||
)
|
||||
|
||||
|
||||
def test_reset_error_count_not_exists(context_with_data: CodeExecutorContext):
|
||||
"""Test resetting an error count that doesn't exist."""
|
||||
context_with_data.reset_error_count("invocationB") # Should not raise
|
||||
assert "invocationB" not in (
|
||||
context_with_data._session_state["_code_executor_error_counts"]
|
||||
)
|
||||
|
||||
|
||||
def test_reset_error_count_no_error_key(empty_state: State):
|
||||
"""Test resetting when the error key itself doesn't exist."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
ctx.reset_error_count("any_invocation") # Should not raise
|
||||
assert "_code_executor_error_counts" not in empty_state
|
||||
|
||||
|
||||
def test_update_code_execution_result_new_invocation(empty_state: State):
|
||||
"""Test updating code execution result for a new invocation."""
|
||||
ctx = CodeExecutorContext(empty_state)
|
||||
ctx.update_code_execution_result("inv1", "print('hi')", "hi", "")
|
||||
results = empty_state["_code_execution_results"]["inv1"]
|
||||
assert len(results) == 1
|
||||
assert results[0]["code"] == "print('hi')"
|
||||
assert results[0]["result_stdout"] == "hi"
|
||||
assert results[0]["result_stderr"] == ""
|
||||
assert "timestamp" in results[0]
|
||||
|
||||
|
||||
def test_update_code_execution_result_append(
|
||||
context_with_data: CodeExecutorContext,
|
||||
):
|
||||
"""Test appending to existing code execution results for an invocation."""
|
||||
# First, let's add an initial result for a new invocation to the existing state
|
||||
context_with_data._session_state["_code_execution_results"] = {
|
||||
"invocationX": [{
|
||||
"code": "old_code",
|
||||
"result_stdout": "old_out",
|
||||
"result_stderr": "old_err",
|
||||
"timestamp": 123,
|
||||
}]
|
||||
}
|
||||
context_with_data.update_code_execution_result(
|
||||
"invocationX", "new_code", "new_out", "new_err"
|
||||
)
|
||||
results = context_with_data._session_state["_code_execution_results"][
|
||||
"invocationX"
|
||||
]
|
||||
assert len(results) == 2
|
||||
assert results[1]["code"] == "new_code"
|
||||
assert results[1]["result_stdout"] == "new_out"
|
||||
assert results[1]["result_stderr"] == "new_err"
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for the ContainerCodeExecutor container hardening defaults."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.code_executors.container_code_executor import ContainerCodeExecutor
|
||||
|
||||
|
||||
def _mock_docker_client():
|
||||
"""Returns a mock Docker client whose container passes python verification."""
|
||||
client = mock.MagicMock()
|
||||
container = mock.MagicMock()
|
||||
# `_verify_python_installation` runs `exec_run(['which', 'python3'])` and
|
||||
# checks `exit_code == 0`.
|
||||
container.exec_run.return_value = mock.MagicMock(exit_code=0)
|
||||
client.containers.run.return_value = container
|
||||
return client
|
||||
|
||||
|
||||
@mock.patch('google.adk.code_executors.container_code_executor.docker')
|
||||
def test_container_is_hardened_by_default(mock_docker):
|
||||
"""Networking is disabled and privileges are dropped by default."""
|
||||
client = _mock_docker_client()
|
||||
mock_docker.from_env.return_value = client
|
||||
|
||||
ContainerCodeExecutor(image='test-image')
|
||||
|
||||
_, kwargs = client.containers.run.call_args
|
||||
# Untrusted model-generated code must not be able to reach the network
|
||||
# (e.g. the cloud metadata endpoint) or escalate privileges by default.
|
||||
assert kwargs['network_disabled']
|
||||
assert kwargs['cap_drop'] == ['ALL']
|
||||
assert kwargs['security_opt'] == ['no-new-privileges']
|
||||
|
||||
|
||||
@mock.patch('google.adk.code_executors.container_code_executor.docker')
|
||||
def test_container_network_can_be_explicitly_enabled(mock_docker):
|
||||
"""Networking is left enabled when the caller opts in."""
|
||||
client = _mock_docker_client()
|
||||
mock_docker.from_env.return_value = client
|
||||
|
||||
ContainerCodeExecutor(image='test-image', network_enabled=True)
|
||||
|
||||
_, kwargs = client.containers.run.call_args
|
||||
assert not kwargs['network_disabled']
|
||||
@@ -0,0 +1,449 @@
|
||||
# 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.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionInput
|
||||
from google.adk.code_executors.gke_code_executor import GkeCodeExecutor
|
||||
from kubernetes import client
|
||||
from kubernetes import config
|
||||
from kubernetes.client.rest import ApiException
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_invocation_context() -> InvocationContext:
|
||||
"""Fixture for a mock InvocationContext."""
|
||||
mock = MagicMock(spec=InvocationContext)
|
||||
mock.invocation_id = "test-invocation-123"
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_k8s_config():
|
||||
"""Fixture for auto-mocking Kubernetes config loading."""
|
||||
with patch(
|
||||
"google.adk.code_executors.gke_code_executor.config"
|
||||
) as mock_config:
|
||||
# Simulate fallback from in-cluster to kubeconfig
|
||||
mock_config.ConfigException = config.ConfigException
|
||||
mock_config.load_incluster_config.side_effect = config.ConfigException
|
||||
yield mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_k8s_clients():
|
||||
"""Fixture for mock Kubernetes API clients."""
|
||||
with patch(
|
||||
"google.adk.code_executors.gke_code_executor.client"
|
||||
) as mock_client_class:
|
||||
mock_batch_v1 = MagicMock(spec=client.BatchV1Api)
|
||||
mock_core_v1 = MagicMock(spec=client.CoreV1Api)
|
||||
mock_client_class.BatchV1Api.return_value = mock_batch_v1
|
||||
mock_client_class.CoreV1Api.return_value = mock_core_v1
|
||||
yield {
|
||||
"batch_v1": mock_batch_v1,
|
||||
"core_v1": mock_core_v1,
|
||||
}
|
||||
|
||||
|
||||
class TestGkeCodeExecutor:
|
||||
"""Unit tests for the GkeCodeExecutor."""
|
||||
|
||||
def test_init_defaults(self):
|
||||
"""Tests that the executor initializes with correct default values."""
|
||||
executor = GkeCodeExecutor()
|
||||
assert executor.namespace == "default"
|
||||
assert executor.image == "python:3.11-slim"
|
||||
assert executor.timeout_seconds == 300
|
||||
assert executor.cpu_requested == "200m"
|
||||
assert executor.mem_limit == "512Mi"
|
||||
assert executor.executor_type == "job"
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.SandboxClient")
|
||||
def test_init_with_overrides(self, mock_sandbox_client):
|
||||
"""Tests that class attributes can be overridden at instantiation."""
|
||||
executor = GkeCodeExecutor(
|
||||
namespace="test-ns",
|
||||
image="custom-python:latest",
|
||||
timeout_seconds=60,
|
||||
cpu_limit="1000m",
|
||||
executor_type="sandbox",
|
||||
)
|
||||
assert executor.namespace == "test-ns"
|
||||
assert executor.image == "custom-python:latest"
|
||||
assert executor.timeout_seconds == 60
|
||||
assert executor.cpu_limit == "1000m"
|
||||
assert executor.executor_type == "sandbox"
|
||||
assert executor.sandbox_template == "python-sandbox-template"
|
||||
|
||||
def test_init_backward_compatibility(self):
|
||||
"""Tests that the executor can be initialized with positional arguments."""
|
||||
executor = GkeCodeExecutor(
|
||||
"/path/to/kubeconfig",
|
||||
"test-context",
|
||||
namespace="test-ns",
|
||||
image="test-image",
|
||||
timeout_seconds=100,
|
||||
executor_type="job",
|
||||
cpu_requested="100m",
|
||||
mem_requested="128Mi",
|
||||
cpu_limit="200m",
|
||||
mem_limit="256Mi",
|
||||
)
|
||||
assert executor.namespace == "test-ns"
|
||||
assert executor.image == "test-image"
|
||||
assert executor.timeout_seconds == 100
|
||||
assert executor.executor_type == "job"
|
||||
assert executor.cpu_requested == "100m"
|
||||
assert executor.mem_requested == "128Mi"
|
||||
assert executor.cpu_limit == "200m"
|
||||
assert executor.mem_limit == "256Mi"
|
||||
assert executor.kubeconfig_path == "/path/to/kubeconfig"
|
||||
assert executor.kubeconfig_context == "test-context"
|
||||
|
||||
def test_init_partial_positional_args(self):
|
||||
"""Tests initialization with partial positional arguments."""
|
||||
executor = GkeCodeExecutor("/path/to/kubeconfig")
|
||||
assert executor.kubeconfig_path == "/path/to/kubeconfig"
|
||||
assert executor.kubeconfig_context is None
|
||||
|
||||
def test_init_mixed_args(self):
|
||||
"""Tests initialization with mixed positional and keyword arguments."""
|
||||
executor = GkeCodeExecutor(
|
||||
"/path/to/kubeconfig",
|
||||
kubeconfig_context="test-context",
|
||||
namespace="test-ns",
|
||||
)
|
||||
assert executor.kubeconfig_path == "/path/to/kubeconfig"
|
||||
|
||||
def test_init_sandbox_missing_dependency(self):
|
||||
"""Tests that init raises ImportError if k8s-agent-sandbox is missing."""
|
||||
with patch(
|
||||
"google.adk.code_executors.gke_code_executor.SandboxClient", None
|
||||
):
|
||||
with pytest.raises(ImportError, match="k8s-agent-sandbox not found"):
|
||||
GkeCodeExecutor(executor_type="sandbox")
|
||||
|
||||
GkeCodeExecutor(executor_type="sandbox")
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.Watch")
|
||||
def test_execute_code_success(
|
||||
self,
|
||||
mock_watch,
|
||||
mock_k8s_clients,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Tests the happy path for successful code execution."""
|
||||
# Setup Mocks
|
||||
mock_job = MagicMock()
|
||||
mock_job.status.succeeded = True
|
||||
mock_job.status.failed = None
|
||||
mock_watch.return_value.stream.return_value = [{"object": mock_job}]
|
||||
|
||||
mock_pod_list = MagicMock()
|
||||
mock_pod_list.items = [MagicMock()]
|
||||
mock_pod_list.items[0].metadata.name = "test-pod-name"
|
||||
mock_k8s_clients["core_v1"].list_namespaced_pod.return_value = mock_pod_list
|
||||
mock_k8s_clients["core_v1"].read_namespaced_pod_log.return_value = (
|
||||
"hello world"
|
||||
)
|
||||
|
||||
# Execute
|
||||
executor = GkeCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assert
|
||||
assert result.stdout == "hello world"
|
||||
assert result.stderr == ""
|
||||
mock_k8s_clients[
|
||||
"core_v1"
|
||||
].create_namespaced_config_map.assert_called_once()
|
||||
mock_k8s_clients["batch_v1"].create_namespaced_job.assert_called_once()
|
||||
mock_k8s_clients["core_v1"].patch_namespaced_config_map.assert_called_once()
|
||||
mock_k8s_clients["core_v1"].read_namespaced_pod_log.assert_called_once()
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.Watch")
|
||||
def test_execute_code_job_failed(
|
||||
self,
|
||||
mock_watch,
|
||||
mock_k8s_clients,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Tests the path where the Kubernetes Job fails."""
|
||||
mock_job = MagicMock()
|
||||
mock_job.status.succeeded = None
|
||||
mock_job.status.failed = True
|
||||
mock_watch.return_value.stream.return_value = [{"object": mock_job}]
|
||||
mock_k8s_clients["core_v1"].read_namespaced_pod_log.return_value = (
|
||||
"Traceback...\nValueError: failure"
|
||||
)
|
||||
|
||||
executor = GkeCodeExecutor()
|
||||
result = executor.execute_code(
|
||||
mock_invocation_context, CodeExecutionInput(code="fail")
|
||||
)
|
||||
|
||||
assert result.stdout == ""
|
||||
assert "Job failed. Logs:" in result.stderr
|
||||
assert "ValueError: failure" in result.stderr
|
||||
|
||||
def test_execute_code_api_exception(
|
||||
self, mock_k8s_clients, mock_invocation_context
|
||||
):
|
||||
"""Tests handling of an ApiException from the K8s client."""
|
||||
mock_k8s_clients["core_v1"].create_namespaced_config_map.side_effect = (
|
||||
ApiException(reason="Test API Error")
|
||||
)
|
||||
executor = GkeCodeExecutor()
|
||||
result = executor.execute_code(
|
||||
mock_invocation_context, CodeExecutionInput(code="...")
|
||||
)
|
||||
|
||||
assert result.stdout == ""
|
||||
assert "Kubernetes API error: Test API Error" in result.stderr
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.Watch")
|
||||
def test_execute_code_timeout(
|
||||
self,
|
||||
mock_watch,
|
||||
mock_k8s_clients,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Tests the case where the job watch times out."""
|
||||
mock_watch.return_value.stream.return_value = (
|
||||
[]
|
||||
) # Empty stream simulates timeout
|
||||
mock_k8s_clients["core_v1"].read_namespaced_pod_log.return_value = (
|
||||
"Still running..."
|
||||
)
|
||||
|
||||
executor = GkeCodeExecutor(timeout_seconds=1)
|
||||
result = executor.execute_code(
|
||||
mock_invocation_context, CodeExecutionInput(code="...")
|
||||
)
|
||||
|
||||
assert result.stdout == ""
|
||||
assert "Executor timed out" in result.stderr
|
||||
assert "did not complete within 1s" in result.stderr
|
||||
assert "Pod Logs:\nStill running..." in result.stderr
|
||||
|
||||
def test_create_job_manifest_structure(self, mock_invocation_context):
|
||||
"""Tests the correctness of the generated Job manifest."""
|
||||
executor = GkeCodeExecutor(namespace="test-ns", image="test-img:v1")
|
||||
job = executor._create_job_manifest(
|
||||
"test-job", "test-cm", mock_invocation_context
|
||||
)
|
||||
|
||||
# Check top-level properties
|
||||
assert isinstance(job, client.V1Job)
|
||||
assert job.api_version == "batch/v1"
|
||||
assert job.kind == "Job"
|
||||
assert job.metadata.name == "test-job"
|
||||
assert job.spec.backoff_limit == 0
|
||||
assert job.spec.ttl_seconds_after_finished == 600
|
||||
|
||||
# Check pod template properties
|
||||
pod_spec = job.spec.template.spec
|
||||
assert pod_spec.restart_policy == "Never"
|
||||
assert pod_spec.runtime_class_name == "gvisor"
|
||||
assert len(pod_spec.tolerations) == 1
|
||||
assert pod_spec.tolerations[0].value == "gvisor"
|
||||
assert len(pod_spec.volumes) == 1
|
||||
assert pod_spec.volumes[0].name == "code-volume"
|
||||
assert pod_spec.volumes[0].config_map.name == "test-cm"
|
||||
|
||||
# Check container properties
|
||||
container = pod_spec.containers[0]
|
||||
assert container.name == "code-runner"
|
||||
assert container.image == "test-img:v1"
|
||||
assert container.command == ["python3", "/app/code.py"]
|
||||
|
||||
# Check security context
|
||||
sec_context = container.security_context
|
||||
assert sec_context.run_as_non_root is True
|
||||
assert sec_context.run_as_user == 1001
|
||||
assert sec_context.allow_privilege_escalation is False
|
||||
assert sec_context.read_only_root_filesystem is True
|
||||
assert sec_context.capabilities.drop == ["ALL"]
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.SandboxClient")
|
||||
def test_execute_code_forks_to_sandbox(
|
||||
self,
|
||||
mock_sandbox_client,
|
||||
mock_invocation_context,
|
||||
mock_k8s_clients,
|
||||
):
|
||||
"""Tests execute_code with executor_type='sandbox'.
|
||||
|
||||
Verifies that execute_code uses SandboxClient when executor_type is set to
|
||||
'sandbox'.
|
||||
"""
|
||||
# Setup Sandbox mock
|
||||
mock_sandbox_instance = (
|
||||
mock_sandbox_client.return_value.__enter__.return_value
|
||||
)
|
||||
mock_run_result = MagicMock()
|
||||
mock_run_result.stdout = "sandbox stdout"
|
||||
mock_run_result.stderr = None
|
||||
mock_sandbox_instance.run.return_value = mock_run_result
|
||||
|
||||
# Instantiate with sandbox type
|
||||
executor = GkeCodeExecutor(executor_type="sandbox")
|
||||
code_input = CodeExecutionInput(code='print("sandbox")')
|
||||
|
||||
# Execute
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assertions
|
||||
assert result.stdout == "sandbox stdout"
|
||||
|
||||
# Verify SandboxClient was used
|
||||
mock_sandbox_client.assert_called_once()
|
||||
mock_sandbox_instance.run.assert_called_once()
|
||||
|
||||
# Verify Job path was NOT taken
|
||||
mock_k8s_clients["batch_v1"].create_namespaced_job.assert_not_called()
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.SandboxClient")
|
||||
def test_execute_code_sandbox_connection_error(
|
||||
self,
|
||||
mock_sandbox_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Tests handling of exceptions from SandboxClient."""
|
||||
# Setup Sandbox mock to raise exception
|
||||
mock_sandbox_client.return_value.__enter__.side_effect = Exception(
|
||||
"Connection failed"
|
||||
)
|
||||
|
||||
# Instantiate with sandbox type
|
||||
executor = GkeCodeExecutor(executor_type="sandbox")
|
||||
code_input = CodeExecutionInput(code='print("sandbox")')
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(Exception, match="Connection failed"):
|
||||
executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.SandboxClient")
|
||||
def test_execute_code_sandbox_runtime_error(
|
||||
self,
|
||||
mock_sandbox_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Tests handling of RuntimeError from SandboxClient."""
|
||||
mock_sandbox_client.return_value.__enter__.side_effect = RuntimeError(
|
||||
"Gateway not found"
|
||||
)
|
||||
|
||||
executor = GkeCodeExecutor(executor_type="sandbox")
|
||||
code_input = CodeExecutionInput(code='print("sandbox")')
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Sandbox infrastructure error: Gateway not found"
|
||||
):
|
||||
executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.SandboxClient")
|
||||
def test_execute_code_sandbox_timeout_error(
|
||||
self,
|
||||
mock_sandbox_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Tests handling of TimeoutError from SandboxClient."""
|
||||
mock_sandbox_client.return_value.__enter__.side_effect = TimeoutError(
|
||||
"Execution timed out"
|
||||
)
|
||||
|
||||
executor = GkeCodeExecutor(executor_type="sandbox")
|
||||
code_input = CodeExecutionInput(code='print("sandbox")')
|
||||
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stdout == ""
|
||||
assert "Sandbox timed out: Execution timed out" in result.stderr
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.SandboxClient")
|
||||
@patch("google.adk.code_executors.gke_code_executor.Watch")
|
||||
def test_execute_code_forks_to_job(
|
||||
self,
|
||||
mock_watch,
|
||||
mock_sandbox_client,
|
||||
mock_invocation_context,
|
||||
mock_k8s_clients,
|
||||
):
|
||||
"""Tests that execute_code uses K8s Job when executor_type='job'."""
|
||||
# Setup K8s Job mocks (success path)
|
||||
mock_job = MagicMock()
|
||||
mock_job.status.succeeded = True
|
||||
mock_watch.return_value.stream.return_value = [{"object": mock_job}]
|
||||
|
||||
mock_pod = MagicMock()
|
||||
mock_pod.metadata.name = "pod-1"
|
||||
mock_k8s_clients["core_v1"].list_namespaced_pod.return_value.items = [
|
||||
mock_pod
|
||||
]
|
||||
mock_k8s_clients["core_v1"].read_namespaced_pod_log.return_value = (
|
||||
"job stdout"
|
||||
)
|
||||
|
||||
# Instantiate with job type
|
||||
executor = GkeCodeExecutor(executor_type="job")
|
||||
code_input = CodeExecutionInput(code='print("job")')
|
||||
|
||||
# Execute
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assertions
|
||||
assert result.stdout == "job stdout"
|
||||
|
||||
# Verify Job path WAS taken
|
||||
mock_k8s_clients["batch_v1"].create_namespaced_job.assert_called_once()
|
||||
|
||||
# Verify SandboxClient was NOT used
|
||||
mock_sandbox_client.assert_not_called()
|
||||
|
||||
@patch("google.adk.code_executors.gke_code_executor.SandboxClient")
|
||||
def test_execute_in_sandbox_returns_stderr(
|
||||
self,
|
||||
mock_sandbox_client,
|
||||
mock_invocation_context,
|
||||
):
|
||||
"""Tests that stderr from the sandbox run is propagated to the result."""
|
||||
# Setup Sandbox mock
|
||||
mock_sandbox_instance = (
|
||||
mock_sandbox_client.return_value.__enter__.return_value
|
||||
)
|
||||
mock_run_result = MagicMock()
|
||||
mock_run_result.stdout = ""
|
||||
mock_run_result.stderr = "oops\n"
|
||||
mock_sandbox_instance.run.return_value = mock_run_result
|
||||
|
||||
# Instantiate with sandbox type
|
||||
executor = GkeCodeExecutor(executor_type="sandbox")
|
||||
code_input = CodeExecutionInput(
|
||||
code="import sys; print('oops', file=sys.stderr)"
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
# Assertions
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == "oops\n"
|
||||
mock_sandbox_instance.write.assert_called_with("script.py", code_input.code)
|
||||
mock_sandbox_instance.run.assert_called_with("python3 script.py")
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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.
|
||||
|
||||
import textwrap
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionInput
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionResult
|
||||
from google.adk.code_executors.unsafe_local_code_executor import UnsafeLocalCodeExecutor
|
||||
from google.adk.sessions.base_session_service import BaseSessionService
|
||||
from google.adk.sessions.session import Session
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_invocation_context() -> InvocationContext:
|
||||
"""Provides a mock InvocationContext."""
|
||||
mock_agent = MagicMock(spec=BaseAgent)
|
||||
mock_session = MagicMock(spec=Session)
|
||||
mock_session_service = MagicMock(spec=BaseSessionService)
|
||||
return InvocationContext(
|
||||
invocation_id="test_invocation",
|
||||
agent=mock_agent,
|
||||
session=mock_session,
|
||||
session_service=mock_session_service,
|
||||
)
|
||||
|
||||
|
||||
class TestUnsafeLocalCodeExecutor:
|
||||
|
||||
def test_init_default(self):
|
||||
executor = UnsafeLocalCodeExecutor()
|
||||
assert not executor.stateful
|
||||
assert not executor.optimize_data_file
|
||||
|
||||
def test_init_stateful_raises_error(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Cannot set `stateful=True` in UnsafeLocalCodeExecutor.",
|
||||
):
|
||||
UnsafeLocalCodeExecutor(stateful=True)
|
||||
|
||||
def test_init_optimize_data_file_raises_error(self):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Cannot set `optimize_data_file=True` in UnsafeLocalCodeExecutor."
|
||||
),
|
||||
):
|
||||
UnsafeLocalCodeExecutor(optimize_data_file=True)
|
||||
|
||||
def test_execute_code_simple_print(
|
||||
self, mock_invocation_context: InvocationContext
|
||||
):
|
||||
executor = UnsafeLocalCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='print("hello world")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert isinstance(result, CodeExecutionResult)
|
||||
assert result.stdout == "hello world\n"
|
||||
assert result.stderr == ""
|
||||
assert result.output_files == []
|
||||
|
||||
def test_execute_code_with_error(
|
||||
self, mock_invocation_context: InvocationContext
|
||||
):
|
||||
executor = UnsafeLocalCodeExecutor()
|
||||
code_input = CodeExecutionInput(code='raise ValueError("Test error")')
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert isinstance(result, CodeExecutionResult)
|
||||
assert result.stdout == ""
|
||||
assert "Test error" in result.stderr
|
||||
assert result.output_files == []
|
||||
|
||||
def test_execute_code_variable_assignment(
|
||||
self, mock_invocation_context: InvocationContext
|
||||
):
|
||||
executor = UnsafeLocalCodeExecutor()
|
||||
code_input = CodeExecutionInput(code="x = 10\nprint(x * 2)")
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stdout == "20\n"
|
||||
assert result.stderr == ""
|
||||
|
||||
def test_execute_code_empty(self, mock_invocation_context: InvocationContext):
|
||||
executor = UnsafeLocalCodeExecutor()
|
||||
code_input = CodeExecutionInput(code="")
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == ""
|
||||
|
||||
def test_execute_code_nested_function_call(
|
||||
self, mock_invocation_context: InvocationContext
|
||||
):
|
||||
executor = UnsafeLocalCodeExecutor()
|
||||
code_input = CodeExecutionInput(code=(textwrap.dedent("""
|
||||
def helper(name):
|
||||
return f'hi {name}'
|
||||
|
||||
def run():
|
||||
print(helper('ada'))
|
||||
|
||||
run()
|
||||
""")))
|
||||
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stderr == ""
|
||||
assert result.stdout == "hi ada\n"
|
||||
|
||||
def test_execute_code_timeout(
|
||||
self, mock_invocation_context: InvocationContext
|
||||
):
|
||||
executor = UnsafeLocalCodeExecutor(timeout_seconds=1)
|
||||
code_input = CodeExecutionInput(code="import time\ntime.sleep(2)")
|
||||
result = executor.execute_code(mock_invocation_context, code_input)
|
||||
|
||||
assert result.stdout == ""
|
||||
assert "Code execution timed out after 1 seconds." in result.stderr
|
||||
Reference in New Issue
Block a user