evalstone/evalscope/tests/agent/test_t2_environment.py
sora 13274243a0 Bump vendored EvalScope and add K3-ready DPV4 configs.
Keep K3 suite selection and report-schema scoring in bash, merge K3/vision dataset_args into dpv4 yamls, and pin EvalScope at 735d920ee911 with local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 07:30:48 +00:00

979 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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