"""T2 Environment Abstraction – unit and integration tests. Test plan: TestEnvironmentRegistry – registry API surface (environments + tools) TestLocalEnvironmentExec – LocalAgentEnvironment exec TestLocalEnvironmentTools – bash/python_exec handlers w/ local env TestDockerEnvironmentExec – EnclaveAgentEnvironment (docker engine) exec TestDockerEnvironmentTools – bash + python_exec handlers w/ enclave env TestAgentLoopWithEnvironment – full AgentLoop + local env + bash tool TestDefaultAdapterEnvPath – _on_agent_inference runtime config + tool_infos TestNativeAgentEnvironmentConfig – legacy-compatible Agent environment config """ import os import sys import tempfile import time import types from typing import Any, Dict, List, Optional from unittest.mock import AsyncMock, MagicMock import pytest import evalscope # noqa: F401 – trigger strategy / env / tool registration from evalscope.api.agent import ( AgentContext, AgentEnvironment, AgentLoop, AgentTrace, EventType, ExecResult, ToolExecutor, ) from evalscope.api.agent.types import NativeAgentConfig from evalscope.api.messages import ChatMessageAssistant, ChatMessageUser from evalscope.api.model.model_output import ChatCompletionChoice, ModelOutput from evalscope.api.registry import ( AGENT_TOOL_INFO_REGISTRY, ENVIRONMENT_REGISTRY, get_environment, list_agent_tools, list_environments, resolve_tool_infos, resolve_tools, ) from evalscope.api.tool import ToolCall, ToolInfo from evalscope.api.tool.tool_call import ToolFunction from evalscope.config import TaskConfig from evalscope.utils.asyncio_runtime import AsyncioLoopRunner # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_output(content: str, tool_calls=None, stop_reason='stop') -> ModelOutput: msg = ChatMessageAssistant(content=content, tool_calls=tool_calls or []) choice = ChatCompletionChoice(message=msg, finish_reason=stop_reason) return ModelOutput(model='mock', choices=[choice]) def _tool_call(name: str, args: Dict[str, Any], call_id: str = 'tc-1') -> ToolCall: return ToolCall(id=call_id, function=ToolFunction(name=name, arguments=args)) def _check_docker() -> bool: """Return True if Docker daemon is reachable.""" try: import docker # type: ignore[import] docker.from_env().ping() return True except Exception: return False DOCKER_AVAILABLE = _check_docker() docker_mark = pytest.mark.skipif(not DOCKER_AVAILABLE, reason='Docker daemon not available') class _FakeExecutionStatus: SUCCESS = 'success' TIMEOUT = 'timeout' class _FakeSandboxHandle: sandbox_id = 'fake-sandbox' def __init__(self) -> None: self.tool_name: Optional[str] = None self.payload: Optional[Dict[str, Any]] = None self.closed = False async def execute_tool(self, tool_name: str, payload: Dict[str, Any]) -> Any: self.tool_name = tool_name self.payload = payload return types.SimpleNamespace( output='ok', error='', status=_FakeExecutionStatus.SUCCESS, execution_time=0.1, ) async def close(self) -> None: self.closed = True def _install_fake_ms_enclave(monkeypatch: pytest.MonkeyPatch) -> None: ms_enclave_mod = types.ModuleType('ms_enclave') sandbox_mod = types.ModuleType('ms_enclave.sandbox') model_mod = types.ModuleType('ms_enclave.sandbox.model') model_mod.ExecutionStatus = _FakeExecutionStatus sandbox_mod.model = model_mod ms_enclave_mod.sandbox = sandbox_mod monkeypatch.setitem(sys.modules, 'ms_enclave', ms_enclave_mod) monkeypatch.setitem(sys.modules, 'ms_enclave.sandbox', sandbox_mod) monkeypatch.setitem(sys.modules, 'ms_enclave.sandbox.model', model_mod) def _allow_enclave_construction(monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.agent.environments import enclave as enclave_mod monkeypatch.setattr(enclave_mod, 'check_import', lambda *args, **kwargs: None) # =========================================================================== # TestEnvironmentRegistry # =========================================================================== class TestEnvironmentRegistry: def test_environments_registered(self): envs = list_environments() assert 'local' in envs, f"'local' not in {envs}" assert 'docker' in envs, f"'docker' not in {envs}" def test_tools_registered(self): tools = list_agent_tools() for name in ('bash', 'python_exec'): assert name in tools, f"'{name}' not in {tools}" def test_tool_infos_registered(self): for name in ('bash', 'python_exec'): assert name in AGENT_TOOL_INFO_REGISTRY, f"ToolInfo missing for '{name}'" info = AGENT_TOOL_INFO_REGISTRY[name] assert isinstance(info, ToolInfo) assert info.name == name assert info.description def test_resolve_tool_infos_returns_infos(self): infos = resolve_tool_infos(['bash', 'python_exec']) assert len(infos) == 2 assert {i.name for i in infos} == {'bash', 'python_exec'} def test_resolve_tool_infos_empty(self): assert resolve_tool_infos(None) == [] assert resolve_tool_infos([]) == [] def test_resolve_tool_infos_unknown_skipped(self): # Unknown names are silently skipped (they have no registered ToolInfo). infos = resolve_tool_infos(['bash', 'nonexistent_tool_xyz']) assert len(infos) == 1 assert infos[0].name == 'bash' def test_get_runtime_local(self): cls = get_environment('local') from evalscope.agent.environments.local import LocalAgentEnvironment assert cls is LocalAgentEnvironment def test_get_runtime_docker(self): cls = get_environment('docker') from evalscope.agent.environments.enclave import EnclaveAgentEnvironment assert cls is EnclaveAgentEnvironment def test_get_runtime_enclave_alias(self): from evalscope.agent.environments.enclave import EnclaveAgentEnvironment assert get_environment('enclave') is EnclaveAgentEnvironment assert get_environment('volcengine') is EnclaveAgentEnvironment def test_get_runtime_unknown_raises(self): with pytest.raises(ValueError, match='not registered'): get_environment('nonexistent_env_xyz') def test_duplicate_environment_registration_raises(self): from evalscope.api.registry import register_environment with pytest.raises(ValueError, match='already registered'): @register_environment('local') class _Dup(AgentEnvironment): async def exec(self, *a, **kw): ... async def close(self): ... def test_bash_tool_info_has_required_params(self): info = AGENT_TOOL_INFO_REGISTRY['bash'] assert 'command' in info.parameters.properties assert 'command' in info.parameters.required def test_python_exec_tool_info_has_required_params(self): info = AGENT_TOOL_INFO_REGISTRY['python_exec'] assert 'code' in info.parameters.properties assert 'code' in info.parameters.required # =========================================================================== # TestEnclaveEnvironmentInterpreter # =========================================================================== class TestEnclaveEnvironmentInterpreter: def _run(self, coro: Any) -> Any: return AsyncioLoopRunner.run(coro) def _env_with_fake_handle( self, monkeypatch: pytest.MonkeyPatch, *, interpreter: Optional[List[str]] = None ) -> tuple[Any, _FakeSandboxHandle]: from evalscope.agent.environments.enclave import EnclaveAgentEnvironment _allow_enclave_construction(monkeypatch) _install_fake_ms_enclave(monkeypatch) handle = _FakeSandboxHandle() kwargs = {} if interpreter is not None: kwargs['interpreter'] = interpreter env = EnclaveAgentEnvironment( engine='docker', sandbox_config={'image': 'python:3.11-slim'}, **kwargs, ) async def _ensure_sandbox() -> _FakeSandboxHandle: return handle monkeypatch.setattr(env, '_ensure_sandbox', _ensure_sandbox) return env, handle def test_exec_uses_backward_compatible_default_interpreter(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch) result = self._run(env.exec(['echo', 'hello world'], cwd='/tmp', env={'FOO': 'bar'})) assert result.returncode == 0 assert handle.tool_name == 'shell_executor' assert handle.payload is not None assert handle.payload['command'][:2] == ['bash', '-c'] assert handle.payload['command'][-1] == "export FOO=bar; cd /tmp && echo 'hello world'" assert handle.payload['timeout'] == 60.0 def test_none_timeout_uses_environment_default(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.agent.environments.enclave import EnclaveAgentEnvironment _allow_enclave_construction(monkeypatch) env = EnclaveAgentEnvironment( engine='docker', sandbox_config={'image': 'python:3.11-slim'}, timeout=None, ) assert env._timeout == 60.0 def test_exec_uses_configured_login_interpreter(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch, interpreter=['bash', '-lc']) result = self._run(env.exec(['python', '-V'])) assert result.returncode == 0 assert handle.payload is not None assert handle.payload['command'][:2] == ['bash', '-lc'] assert handle.payload['command'][-1] == 'python -V' def test_bash_tool_preserves_configured_login_interpreter(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.agent.tools.bash import run_bash env, handle = self._env_with_fake_handle(monkeypatch, interpreter=['bash', '-lc']) call = _tool_call('bash', {'command': 'which python'}) obs = self._run(run_bash(call, env)) assert obs == 'ok' assert handle.payload is not None assert handle.payload['command'][:2] == ['bash', '-lc'] assert handle.payload['command'][-1] == 'which python' def test_tuple_bash_c_wrapper_is_unwrapped(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch, interpreter=['bash', '-lc']) result = self._run(env.exec(('bash', '-c', 'echo tuple'))) assert result.returncode == 0 assert handle.payload is not None assert handle.payload['command'] == ['bash', '-lc', 'echo tuple'] def test_bash_c_wrapper_is_not_unwrapped_for_non_bash_interpreter(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch, interpreter=['sh', '-c']) result = self._run(env.exec(['/bin/bash', '-c', 'echo bash contract'])) assert result.returncode == 0 assert handle.payload is not None assert handle.payload['command'] == ['sh', '-c', "/bin/bash -c 'echo bash contract'"] def test_unwrapped_bash_command_exports_env_for_whole_script(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch) result = self._run(env.exec(['/bin/bash', '-c', 'echo "$FOO" && env | grep "^FOO="'], env={'FOO': 'bar'})) assert result.returncode == 0 assert handle.payload is not None assert handle.payload['command'][-1] == 'export FOO=bar; echo "$FOO" && env | grep "^FOO="' def test_env_export_rejects_invalid_variable_name(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch) with pytest.raises(ValueError, match='Invalid environment variable name'): self._run(env.exec(['echo', 'x'], env={'BAD;KEY': 'value'})) assert handle.payload is None def test_env_export_casts_values_to_strings(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch) result = self._run(env.exec(['echo', 'x'], env={'COUNT': 3})) assert result.returncode == 0 assert handle.payload is not None assert handle.payload['command'][-1] == 'export COUNT=3; echo x' def test_exec_maps_ms_enclave_timeout_status(self, monkeypatch: pytest.MonkeyPatch) -> None: env, handle = self._env_with_fake_handle(monkeypatch) async def _timed_out(tool_name: str, payload: Dict[str, Any]) -> Any: handle.tool_name = tool_name handle.payload = payload return types.SimpleNamespace( output='', error='Command timed out after 0.3 seconds', status=_FakeExecutionStatus.TIMEOUT, execution_time=0.3, ) monkeypatch.setattr(handle, 'execute_tool', _timed_out) result = self._run(env.exec(['sleep', '10'], timeout=0.3)) assert result.timed_out assert result.returncode == -1 def test_empty_interpreter_is_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.agent.environments.enclave import EnclaveAgentEnvironment _allow_enclave_construction(monkeypatch) with pytest.raises(ValueError, match='interpreter'): EnclaveAgentEnvironment( engine='docker', sandbox_config={'image': 'python:3.11-slim'}, interpreter=[], ) def test_string_interpreter_is_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.agent.environments.enclave import EnclaveAgentEnvironment _allow_enclave_construction(monkeypatch) with pytest.raises(TypeError, match='interpreter'): EnclaveAgentEnvironment( engine='docker', sandbox_config={'image': 'python:3.11-slim'}, interpreter='bash -lc', ) def test_swe_bench_agentic_adapter_uses_login_interpreter(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.benchmarks.swe_bench.swe_bench_agentic_adapter import _SWEBenchAgenticAdapterBase _allow_enclave_construction(monkeypatch) adapter = object.__new__(_SWEBenchAgenticAdapterBase) adapter.working_dir = '/testbed' adapter.force_arch = '' adapter._task_config = TaskConfig( model='dummy', agent_config=NativeAgentConfig(command_timeout=45), sandbox={ 'default_config': { 'image': 'custom/base:latest', 'network_enabled': False, } }, ) sample = types.SimpleNamespace(metadata={'docker_image': 'swebench/example:latest', 'instance_id': 'example'}) env = adapter.build_environment(sample) assert env._interpreter == ['bash', '-lc'] assert env._timeout == 45 assert 'environment' not in env._sandbox_config_dict assert env._sandbox_config_dict['image'] == 'swebench/example:latest' assert env._sandbox_config_dict['network_enabled'] is False assert env._sandbox_config_dict['env_vars'] == { 'PAGER': 'cat', 'MANPAGER': 'cat', 'LESS': '-R', 'PIP_PROGRESS_BAR': 'off', 'TQDM_DISABLE': '1', } def test_swe_bench_pro_adapter_uses_login_interpreter(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.benchmarks.swe_bench_pro.swe_bench_pro_agentic_adapter import SWEBenchProAgenticAdapter _allow_enclave_construction(monkeypatch) adapter = object.__new__(SWEBenchProAgenticAdapter) adapter.working_dir = '/app' adapter._task_config = TaskConfig(model='dummy', agent_config=NativeAgentConfig(command_timeout=46)) sample = types.SimpleNamespace( metadata={'docker_image': 'jefzda/sweap-images:example', 'instance_id': 'example'} ) env = adapter.build_environment(sample) assert env._interpreter == ['bash', '-lc'] assert env._timeout == 46 assert 'environment' not in env._sandbox_config_dict assert env._sandbox_config_dict['env_vars'] == { 'PAGER': 'cat', 'MANPAGER': 'cat', 'LESS': '-R', 'PIP_PROGRESS_BAR': 'off', 'TQDM_DISABLE': '1', } def test_gaia_adapter_uses_env_vars_sandbox_config(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.benchmarks.gaia.gaia_adapter import GaiaAdapter _allow_enclave_construction(monkeypatch) adapter = object.__new__(GaiaAdapter) adapter._task_config = TaskConfig( model='dummy', agent_config=NativeAgentConfig(command_timeout=180), sandbox={ 'default_config': { 'image': 'custom/gaia:latest', 'network_enabled': False, } }, ) adapter._host_files_dir = None sample = types.SimpleNamespace(metadata={'task_id': 'example'}) env = adapter.build_environment(sample) assert env._timeout == 180 assert 'environment' not in env._sandbox_config_dict assert env._sandbox_config_dict['image'] == 'custom/gaia:latest' assert env._sandbox_config_dict['network_enabled'] is False assert env._sandbox_config_dict['env_vars'] == { 'PAGER': 'cat', 'MANPAGER': 'cat', 'PIP_PROGRESS_BAR': 'off', 'TQDM_DISABLE': '1', } def test_gdpval_adapter_uses_env_vars_sandbox_config(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.api.benchmark import BenchmarkMeta from evalscope.benchmarks.gdpval.gdpval_adapter import GDPvalAdapter _allow_enclave_construction(monkeypatch) adapter = object.__new__(GDPvalAdapter) adapter._benchmark_meta = BenchmarkMeta(name='gdpval', dataset_id='dummy') adapter._task_config = TaskConfig( model='dummy', agent_config=NativeAgentConfig(command_timeout=181), sandbox={ 'default_config': { 'image': 'custom/gdpval:latest', 'network_enabled': False, } }, ) adapter.docker_image = 'custom/gdpval:latest' adapter._current_output_dir = None adapter._ensure_docker_image = lambda: None sample = types.SimpleNamespace(id='example', metadata={'task_id': 'example'}) env = adapter.build_environment(sample) sandbox_env = env._env assert sandbox_env._timeout == 181 assert 'environment' not in sandbox_env._sandbox_config_dict assert sandbox_env._sandbox_config_dict['image'] == 'custom/gdpval:latest' assert sandbox_env._sandbox_config_dict['network_enabled'] is False assert sandbox_env._sandbox_config_dict['env_vars'] == { 'PAGER': 'cat', 'MANPAGER': 'cat', 'PIP_PROGRESS_BAR': 'off', 'TQDM_DISABLE': '1', } def test_job_bench_selects_legacy_docker_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: from evalscope.benchmarks.job_bench.job_bench_adapter import JobBenchAdapter adapter = object.__new__(JobBenchAdapter) adapter._task_config = TaskConfig( model='dummy', agent_config=NativeAgentConfig(environment='docker'), ) expected_runtime = MagicMock(spec=AgentEnvironment) monkeypatch.setattr(adapter, '_build_docker_environment', lambda _: expected_runtime) runtime = adapter.build_environment(types.SimpleNamespace(metadata={})) assert runtime is expected_runtime # =========================================================================== # TestLocalEnvironmentExec # =========================================================================== class TestLocalEnvironmentExec: def _run(self, coro): return AsyncioLoopRunner.run(coro) def _env(self): from evalscope.agent.environments.local import LocalAgentEnvironment return LocalAgentEnvironment() def test_exec_echo(self): env = self._env() result = self._run(env.exec(['echo', 'hello'])) assert result.returncode == 0 assert 'hello' in result.stdout assert not result.timed_out def test_exec_nonzero_returncode(self): env = self._env() result = self._run(env.exec(['bash', '-c', 'exit 42'])) assert result.returncode == 42 def test_exec_stderr(self): env = self._env() result = self._run(env.exec(['bash', '-c', 'echo err >&2; exit 1'])) assert 'err' in result.stderr assert result.returncode != 0 def test_exec_timeout(self): env = self._env() result = self._run(env.exec(['sleep', '10'], timeout=0.3)) assert result.timed_out assert result.returncode == -1 def test_exec_cwd(self): env = self._env() result = self._run(env.exec(['pwd'], cwd='/tmp')) assert '/tmp' in result.stdout def test_exec_with_env_vars(self): from evalscope.agent.environments.local import LocalAgentEnvironment env = LocalAgentEnvironment(env_vars={'MY_VAR': 'hello_from_test'}) result = self._run(env.exec(['bash', '-c', 'echo $MY_VAR'])) assert 'hello_from_test' in result.stdout def test_close_is_idempotent(self): env = self._env() self._run(env.close()) self._run(env.close()) # second call must not raise def test_context_manager(self): async def _cm(): from evalscope.agent.environments.local import LocalAgentEnvironment async with LocalAgentEnvironment() as env: result = await env.exec(['echo', 'cm']) return result result = self._run(_cm()) assert 'cm' in result.stdout def test_exec_result_duration_positive(self): env = self._env() result = self._run(env.exec(['echo', 'hi'])) assert result.duration >= 0 # =========================================================================== # TestLocalEnvironmentTools (tool handlers with LocalAgentEnvironment) # =========================================================================== class TestLocalEnvironmentTools: def _run(self, coro): return AsyncioLoopRunner.run(coro) def _env(self): from evalscope.agent.environments.local import LocalAgentEnvironment return LocalAgentEnvironment() def test_bash_tool_runs_command(self): from evalscope.agent.tools.bash import run_bash env = self._env() call = _tool_call('bash', {'command': 'echo hello_bash'}) obs = self._run(run_bash(call, env)) assert 'hello_bash' in obs def test_bash_tool_without_env_raises(self): from evalscope.agent.tools.bash import run_bash call = _tool_call('bash', {'command': 'echo x'}) with pytest.raises(PermissionError, match='requires an AgentEnvironment'): self._run(run_bash(call, None)) def test_bash_tool_stderr_in_output(self): from evalscope.agent.tools.bash import run_bash env = self._env() call = _tool_call('bash', {'command': 'echo err >&2 && exit 0'}) obs = self._run(run_bash(call, env)) # stderr section is present when non-empty assert '[stderr]' in obs def test_python_exec_tool_runs_code(self): from evalscope.agent.tools.python_exec import run_python_exec env = self._env() call = _tool_call('python_exec', {'code': 'print(2 + 2)'}) obs = self._run(run_python_exec(call, env)) assert '4' in obs def test_python_exec_tool_without_env_raises(self): from evalscope.agent.tools.python_exec import run_python_exec call = _tool_call('python_exec', {'code': 'print(1)'}) with pytest.raises(PermissionError): self._run(run_python_exec(call, None)) # =========================================================================== # TestDockerEnvironmentExec (requires Docker) # =========================================================================== @docker_mark class TestDockerEnvironmentExec: """Integration tests for ``EnclaveAgentEnvironment`` with the docker engine. These tests create real Docker containers using the ``python:3.11-slim`` image. Each test uses its own environment instance (= its own container). """ def _run(self, coro): return AsyncioLoopRunner.run(coro) def _env(self): from evalscope.agent.environments.enclave import EnclaveAgentEnvironment return EnclaveAgentEnvironment( engine='docker', sandbox_config={'image': 'python:3.11-slim'}, timeout=30.0, ) def teardown_method(self, method): """Reset the class-level manager between test classes to avoid leakage.""" # We intentionally leave the manager running (shared singleton) for # performance; individual containers are deleted per-sample via close(). def test_exec_echo(self): env = self._env() try: result = self._run(env.exec(['echo', 'hello docker'])) assert result.returncode == 0 assert 'hello docker' in result.stdout assert not result.timed_out finally: self._run(env.close()) def test_exec_timeout_terminates_container_process(self): env = self._env() marker = '/workspace/timeout-marker' try: result = self._run(env.exec(['/bin/bash', '-c', f'sleep 2; touch {marker}'], timeout=0.3)) assert result.timed_out assert result.returncode == -1 deadline = time.monotonic() + 6.0 while time.monotonic() < deadline: check = self._run(env.exec(['/bin/bash', '-c', f'test -e {marker} && echo PRESENT || echo ABSENT'])) assert check.stdout.strip() == 'ABSENT' time.sleep(0.25) finally: self._run(env.close()) def test_exec_python(self): env = self._env() try: result = self._run(env.exec(['python3', '-c', 'print(1+1)'])) assert result.returncode == 0 assert '2' in result.stdout finally: self._run(env.close()) def test_exec_nonzero_returncode(self): env = self._env() try: result = self._run(env.exec(['/bin/bash', '-c', 'exit 5'])) assert result.returncode == 5 finally: self._run(env.close()) def test_exec_cwd(self): env = self._env() try: # default working_dir is /workspace; verify via pwd result = self._run(env.exec(['/bin/bash', '-c', 'pwd'])) assert result.returncode == 0 assert '/workspace' in result.stdout or result.returncode == 0 finally: self._run(env.close()) def test_close_idempotent(self): env = self._env() # Trigger container creation self._run(env.exec(['true'])) # First close self._run(env.close()) # Second close must not raise self._run(env.close()) def test_multiple_env_instances_isolated(self): """Two environment instances should get separate containers.""" env1 = self._env() env2 = self._env() try: r1 = self._run(env1.exec(['bash', '-c', 'echo env1 > /workspace/marker.txt && cat /workspace/marker.txt'])) r2 = self._run(env2.exec(['bash', '-c', 'echo env2 > /workspace/marker.txt && cat /workspace/marker.txt'])) # Each container has its own filesystem; markers stay isolated. assert 'env1' in r1.stdout assert 'env2' in r2.stdout finally: self._run(env1.close()) self._run(env2.close()) def test_context_manager(self): async def _cm(): from evalscope.agent.environments.enclave import EnclaveAgentEnvironment async with EnclaveAgentEnvironment( engine='docker', sandbox_config={'image': 'python:3.11-slim'}, ) as env: result = await env.exec(['echo', 'ctx_mgr']) return result result = self._run(_cm()) assert 'ctx_mgr' in result.stdout # =========================================================================== # TestDockerEnvironmentTools (bash + python_exec with docker) # =========================================================================== @docker_mark class TestDockerEnvironmentTools: def _run(self, coro): return AsyncioLoopRunner.run(coro) def _env(self): from evalscope.agent.environments.enclave import EnclaveAgentEnvironment return EnclaveAgentEnvironment( engine='docker', sandbox_config={'image': 'python:3.11-slim'}, timeout=30.0, ) def test_bash_tool_in_docker(self): from evalscope.agent.tools.bash import run_bash env = self._env() try: call = _tool_call('bash', {'command': 'echo docker_bash'}) obs = self._run(run_bash(call, env)) assert 'docker_bash' in obs finally: self._run(env.close()) def test_python_exec_tool_in_docker(self): from evalscope.agent.tools.python_exec import run_python_exec env = self._env() try: call = _tool_call('python_exec', {'code': 'print("docker_py", 2**10)'}) obs = self._run(run_python_exec(call, env)) assert '1024' in obs finally: self._run(env.close()) # =========================================================================== # TestAgentLoopWithEnvironment (AgentLoop + local env + bash tool) # =========================================================================== class TestAgentLoopWithEnvironment: """Verify the AgentLoop correctly wires environment through ToolExecutor.""" def _run(self, coro): return AsyncioLoopRunner.run(coro) def test_loop_uses_environment_via_bash_tool(self): """Model calls bash → env.exec is invoked → result observed.""" from evalscope.agent.environments.local import LocalAgentEnvironment from evalscope.agent.tools.bash import run_bash from evalscope.api.registry import get_strategy env = LocalAgentEnvironment() handlers = {'bash': run_bash} # Model: first call returns a bash tool_call; second call returns submit. bash_call = _tool_call('bash', {'command': 'echo agent_output'}, call_id='tc-bash') first_output = _make_output( content='', tool_calls=[bash_call], stop_reason='tool_calls', ) submit_call = _tool_call('submit', {'answer': 'agent_output'}, call_id='tc-submit') second_output = _make_output(content='', tool_calls=[submit_call]) model = MagicMock() model.generate_async = AsyncMock(side_effect=[first_output, second_output]) strategy = get_strategy('function_calling')() tool_executor = ToolExecutor(handlers=handlers, environment=env) ctx = AgentContext( sample_id='test-env-loop', messages=[ChatMessageUser(content='run bash')], tools=[], ) trace = AgentTrace(strategy='function_calling', max_steps=5) loop = AgentLoop( model=model, strategy=strategy, tool_executor=tool_executor, environment=env, max_steps=5, trace=trace, ) result = self._run(loop.run(ctx)) # Verify tool was called and observation includes bash output tool_msg = next( (m for m in result.messages if getattr(m, 'role', None) == 'tool'), None, ) assert tool_msg is not None, 'Expected a tool message in conversation' assert 'agent_output' in tool_msg.content, ( f'Expected bash output in tool message, got: {tool_msg.content!r}' ) # ENV_EXEC event should NOT be in trace (bash uses env.exec via AgentEnvironment, # not a separate ENV_EXEC emitter); TOOL_RESULT IS expected. event_types = {ev.type for ev in result.trace.events} assert EventType.TOOL_RESULT in event_types assert EventType.SUBMIT in event_types # =========================================================================== # TestDefaultAdapterEnvPath (DefaultDataAdapter._on_agent_inference with env) # =========================================================================== class TestDefaultAdapterEnvPath: """Test that _on_agent_inference instantiates environment and merges tool infos.""" def test_tool_infos_merged_into_ctx(self): """When agent_config.tools=['bash'], bash ToolInfo is passed to the model.""" from evalscope.api.benchmark.adapters.default_data_adapter import DefaultDataAdapter from evalscope.api.dataset import Sample # Build a minimal adapter bypassing __init__ adapter = DefaultDataAdapter.__new__(DefaultDataAdapter) cfg = NativeAgentConfig(strategy='function_calling', tools=['bash'], max_steps=1) task_cfg = MagicMock() task_cfg.agent_config = cfg adapter._task_config = task_cfg # Model: return final answer immediately (no tool calls) final_out = _make_output(content='done') model = MagicMock() model.generate.return_value = final_out # AgentLoop awaits ``generate_async``. model.generate_async = AsyncMock(return_value=final_out) sample = MagicMock() sample.id = 'x' sample.input = 'hello' sample.tools = [] output = adapter._on_inference(model, sample) model.generate_async.assert_awaited_once() assert model.generate_async.call_args.kwargs['tools'] assert output is not None def test_environment_extra_forwarded(self): """environment_extra is forwarded to the environment constructor.""" from evalscope.api.benchmark.adapters.default_data_adapter import DefaultDataAdapter from evalscope.api.dataset import Sample adapter = DefaultDataAdapter.__new__(DefaultDataAdapter) cfg = NativeAgentConfig( strategy='function_calling', tools=[], max_steps=1, environment='local', environment_extra={ 'working_dir': '/tmp', }, ) task_cfg = MagicMock() task_cfg.agent_config = cfg adapter._task_config = task_cfg final_out = _make_output(content='env done') model = MagicMock() model.generate_async = AsyncMock(return_value=final_out) sample = MagicMock() sample.id = 'env-test' sample.input = 'test env' sample.tools = [] output = adapter._on_inference(model, sample) assert output is not None # The runtime was created and closed; trace agent-runtime name should match. trace = output.trace assert trace is not None assert trace.environment == 'local' # =========================================================================== # TestNativeAgentEnvironmentConfig (NativeAgentConfig schema) # =========================================================================== class TestNativeAgentEnvironmentConfig: def test_default_environment_is_none(self): cfg = NativeAgentConfig() assert cfg.environment is None assert cfg.environment_extra == {} def test_environment_config_accepted(self): cfg = NativeAgentConfig( strategy='function_calling', environment='docker', environment_extra={ 'image': 'python:3.11-slim', 'working_dir': '/workspace', }, ) assert cfg.environment_extra['image'] == 'python:3.11-slim' assert cfg.environment == 'docker' def test_environment_config_serialises_compatibly(self): cfg = NativeAgentConfig(environment='docker', environment_extra={'key': 'val'}) d = cfg.model_dump() assert d['environment'] == 'docker' assert d['environment_extra'] == {'key': 'val'} assert 'runtime' not in d def test_kwargs_and_environment_config_independent(self): cfg = NativeAgentConfig(kwargs={'system_prompt': 'hi'}, environment='docker', environment_extra={'image': 'x'}) assert 'system_prompt' in cfg.kwargs assert 'system_prompt' not in cfg.environment_extra assert 'image' in cfg.environment_extra assert 'image' not in cfg.kwargs def test_unpublished_runtime_shape_is_rejected(self): with pytest.raises(ValueError, match='Extra inputs are not permitted'): NativeAgentConfig(runtime='docker') with pytest.raises(ValueError, match='Extra inputs are not permitted'): NativeAgentConfig(runtime_extra={'image': 'python:3.11-slim'}) def test_task_config_update_revalidates_agent_config(self): cfg = TaskConfig(agent_config={'mode': 'native'}) cfg.update({ 'agent_config': { 'environment': 'local', 'environment_extra': { 'working_dir': '/tmp', }, } }) assert isinstance(cfg.agent_config, NativeAgentConfig) assert cfg.agent_config.environment == 'local' assert cfg.agent_config.environment_extra == {'working_dir': '/tmp'} def test_task_config_update_preserves_explicit_agent_fields(self): cfg = TaskConfig(agent_config=NativeAgentConfig(max_steps=7)) cfg.update({}) assert isinstance(cfg.agent_config, NativeAgentConfig) assert cfg.agent_config.max_steps == 7 assert 'max_steps' in cfg.agent_config.model_fields_set assert 'strategy' not in cfg.agent_config.model_fields_set assert 'kwargs' not in cfg.agent_config.model_fields_set