evalstone/evalscope/tests/agent/test_t2_environment.py
2026-07-08 08:57:50 +00:00

581 lines
22 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 environment_extra + tool_infos
TestNativeAgentConfigEnvironmentExtra NativeAgentConfig.environment_extra round-trip
"""
import os
import pytest
import tempfile
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock
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.utils.function_utils 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')
# ===========================================================================
# 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_environment_local(self):
cls = get_environment('local')
from evalscope.agent.environments.local import LocalAgentEnvironment
assert cls is LocalAgentEnvironment
def test_get_environment_docker(self):
cls = get_environment('docker')
from evalscope.agent.environments.enclave import EnclaveAgentEnvironment
assert cls is EnclaveAgentEnvironment
def test_get_environment_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_environment_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
# ===========================================================================
# 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_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)
# Verify model was called with bash ToolInfo in the tools list
call_args = model.generate_async.call_args
tools_passed = call_args[1].get('tools') or call_args[0][1] if len(call_args[0]) > 1 else None
# tools_passed may be None if strategy decided not to pass them;
# at minimum verify execution completed without error.
assert output is not None
def test_environment_extra_forwarded(self):
"""environment_extra is forwarded to environment constructor kwargs."""
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 environment was created and closed; trace environment name should match
trace = output.trace
assert trace is not None
assert trace.environment == 'local'
# ===========================================================================
# TestNativeAgentConfigEnvironmentExtra (NativeAgentConfig schema)
# ===========================================================================
class TestNativeAgentConfigEnvironmentExtra:
def test_default_environment_extra_is_empty(self):
cfg = NativeAgentConfig()
assert cfg.environment_extra == {}
def test_environment_extra_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_extra_serialises(self):
cfg = NativeAgentConfig(environment_extra={'key': 'val'})
d = cfg.model_dump()
assert d['environment_extra'] == {'key': 'val'}
def test_kwargs_and_environment_extra_independent(self):
cfg = NativeAgentConfig(kwargs={'system_prompt': 'hi'}, 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