Spaces:
Paused
Paused
| """ | |
| Tests for environments/agent_loop.py — HermesAgentLoop. | |
| Tests the multi-turn agent engine using mocked servers, without needing | |
| real API keys or running servers. | |
| """ | |
| import asyncio | |
| import json | |
| import sys | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| from unittest.mock import MagicMock | |
| import pytest | |
| # Ensure repo root is importable | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) | |
| try: | |
| from environments.agent_loop import ( | |
| AgentResult, | |
| HermesAgentLoop, | |
| ToolError, | |
| _extract_reasoning_from_message, | |
| resize_tool_pool, | |
| ) | |
| except ImportError: | |
| pytest.skip("atroposlib not installed", allow_module_level=True) | |
| # ─── Mock server infrastructure ───────────────────────────────────────── | |
| class MockFunction: | |
| name: str | |
| arguments: str | |
| class MockToolCall: | |
| id: str | |
| function: MockFunction | |
| type: str = "function" | |
| class MockMessage: | |
| content: Optional[str] | |
| role: str = "assistant" | |
| tool_calls: Optional[List[MockToolCall]] = None | |
| reasoning_content: Optional[str] = None | |
| reasoning: Optional[str] = None | |
| reasoning_details: Optional[list] = None | |
| class MockChoice: | |
| message: MockMessage | |
| finish_reason: str = "stop" | |
| index: int = 0 | |
| class MockChatCompletion: | |
| choices: List[MockChoice] | |
| id: str = "chatcmpl-mock" | |
| model: str = "mock-model" | |
| class MockServer: | |
| """ | |
| Mock server that returns pre-configured responses in sequence. | |
| Mimics the chat_completion() interface. | |
| """ | |
| def __init__(self, responses: List[MockChatCompletion]): | |
| self.responses = responses | |
| self.call_count = 0 | |
| self.call_history: List[Dict[str, Any]] = [] | |
| async def chat_completion(self, **kwargs) -> MockChatCompletion: | |
| self.call_history.append(kwargs) | |
| if self.call_count >= len(self.responses): | |
| # Return a simple text response if we run out | |
| return MockChatCompletion( | |
| choices=[MockChoice(message=MockMessage(content="Done."))] | |
| ) | |
| resp = self.responses[self.call_count] | |
| self.call_count += 1 | |
| return resp | |
| def make_text_response(content: str) -> MockChatCompletion: | |
| """Create a simple text-only response (no tool calls).""" | |
| return MockChatCompletion( | |
| choices=[MockChoice(message=MockMessage(content=content))] | |
| ) | |
| def make_tool_response( | |
| tool_name: str, | |
| arguments: dict, | |
| content: str = "", | |
| tool_call_id: str = "call_001", | |
| ) -> MockChatCompletion: | |
| """Create a response with a single tool call.""" | |
| return MockChatCompletion( | |
| choices=[ | |
| MockChoice( | |
| message=MockMessage( | |
| content=content, | |
| tool_calls=[ | |
| MockToolCall( | |
| id=tool_call_id, | |
| function=MockFunction( | |
| name=tool_name, | |
| arguments=json.dumps(arguments), | |
| ), | |
| ) | |
| ], | |
| ), | |
| finish_reason="tool_calls", | |
| ) | |
| ] | |
| ) | |
| # ─── Tests ─────────────────────────────────────────────────────────────── | |
| class TestAgentResult: | |
| def test_defaults(self): | |
| result = AgentResult(messages=[]) | |
| assert result.messages == [] | |
| assert result.managed_state is None | |
| assert result.turns_used == 0 | |
| assert result.finished_naturally is False | |
| assert result.reasoning_per_turn == [] | |
| assert result.tool_errors == [] | |
| class TestExtractReasoning: | |
| def test_reasoning_content_field(self): | |
| msg = MockMessage(content="hello", reasoning_content="I think...") | |
| assert _extract_reasoning_from_message(msg) == "I think..." | |
| def test_reasoning_field(self): | |
| msg = MockMessage(content="hello", reasoning="Let me consider...") | |
| assert _extract_reasoning_from_message(msg) == "Let me consider..." | |
| def test_reasoning_details(self): | |
| detail = MagicMock() | |
| detail.text = "Detail reasoning" | |
| msg = MockMessage(content="hello", reasoning_details=[detail]) | |
| assert _extract_reasoning_from_message(msg) == "Detail reasoning" | |
| def test_reasoning_details_dict_format(self): | |
| msg = MockMessage( | |
| content="hello", | |
| reasoning_details=[{"text": "Dict reasoning"}], | |
| ) | |
| assert _extract_reasoning_from_message(msg) == "Dict reasoning" | |
| def test_no_reasoning(self): | |
| msg = MockMessage(content="hello") | |
| assert _extract_reasoning_from_message(msg) is None | |
| def test_reasoning_content_takes_priority(self): | |
| msg = MockMessage( | |
| content="hello", | |
| reasoning_content="First", | |
| reasoning="Second", | |
| ) | |
| assert _extract_reasoning_from_message(msg) == "First" | |
| class TestHermesAgentLoop: | |
| """Test the agent loop with mock servers.""" | |
| def basic_tools(self): | |
| """Minimal tool schema for testing.""" | |
| return [ | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "terminal", | |
| "description": "Run a command", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "command": { | |
| "type": "string", | |
| "description": "Command to run", | |
| } | |
| }, | |
| "required": ["command"], | |
| }, | |
| }, | |
| }, | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": "read_file", | |
| "description": "Read a file", | |
| "parameters": { | |
| "type": "object", | |
| "properties": { | |
| "path": {"type": "string"}, | |
| }, | |
| "required": ["path"], | |
| }, | |
| }, | |
| }, | |
| ] | |
| def valid_names(self): | |
| return {"terminal", "read_file", "todo"} | |
| async def test_simple_text_response(self, basic_tools, valid_names): | |
| """Model responds with text only, no tool calls.""" | |
| server = MockServer([make_text_response("Hello! How can I help?")]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Hi"}] | |
| result = await agent.run(messages) | |
| assert result.finished_naturally is True | |
| assert result.turns_used == 1 | |
| assert len(result.messages) >= 2 # user + assistant | |
| assert result.messages[-1]["role"] == "assistant" | |
| assert result.messages[-1]["content"] == "Hello! How can I help?" | |
| async def test_tool_call_then_text(self, basic_tools, valid_names): | |
| """Model calls a tool, then responds with text.""" | |
| server = MockServer([ | |
| make_tool_response("todo", {"todos": [{"id": "1", "content": "test", "status": "pending"}]}), | |
| make_text_response("I created a todo for you."), | |
| ]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Create a todo"}] | |
| result = await agent.run(messages) | |
| assert result.finished_naturally is True | |
| assert result.turns_used == 2 | |
| # Should have: user, assistant (tool_call), tool (result), assistant (text) | |
| roles = [m["role"] for m in result.messages] | |
| assert roles == ["user", "assistant", "tool", "assistant"] | |
| async def test_max_turns_reached(self, basic_tools, valid_names): | |
| """Model keeps calling tools until max_turns is hit.""" | |
| # Create responses that always call a tool | |
| responses = [ | |
| make_tool_response("todo", {"todos": [{"id": str(i), "content": f"task {i}", "status": "pending"}]}, tool_call_id=f"call_{i}") | |
| for i in range(10) | |
| ] | |
| server = MockServer(responses) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=3, | |
| ) | |
| messages = [{"role": "user", "content": "Keep going"}] | |
| result = await agent.run(messages) | |
| assert result.finished_naturally is False | |
| assert result.turns_used == 3 | |
| async def test_unknown_tool_name(self, basic_tools, valid_names): | |
| """Model calls a tool not in valid_tool_names.""" | |
| server = MockServer([ | |
| make_tool_response("nonexistent_tool", {"arg": "val"}), | |
| make_text_response("OK, that didn't work."), | |
| ]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Call something weird"}] | |
| result = await agent.run(messages) | |
| # Should record a tool error | |
| assert len(result.tool_errors) >= 1 | |
| assert result.tool_errors[0].tool_name == "nonexistent_tool" | |
| async def test_empty_response(self, basic_tools, valid_names): | |
| """Server returns empty response.""" | |
| server = MockServer([MockChatCompletion(choices=[])]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Hi"}] | |
| result = await agent.run(messages) | |
| assert result.finished_naturally is False | |
| assert result.turns_used == 1 | |
| async def test_api_error_handling(self, basic_tools, valid_names): | |
| """Server raises an exception.""" | |
| class FailingServer: | |
| async def chat_completion(self, **kwargs): | |
| raise ConnectionError("Server unreachable") | |
| agent = HermesAgentLoop( | |
| server=FailingServer(), | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Hi"}] | |
| result = await agent.run(messages) | |
| assert result.finished_naturally is False | |
| assert result.turns_used == 1 | |
| async def test_tools_passed_to_server(self, basic_tools, valid_names): | |
| """Verify tools are passed in the chat_completion kwargs.""" | |
| server = MockServer([make_text_response("OK")]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Hi"}] | |
| await agent.run(messages) | |
| assert len(server.call_history) == 1 | |
| assert "tools" in server.call_history[0] | |
| assert server.call_history[0]["tools"] == basic_tools | |
| async def test_extra_body_forwarded(self, basic_tools, valid_names): | |
| """extra_body should be forwarded to server.""" | |
| extra = {"provider": {"ignore": ["DeepInfra"]}} | |
| server = MockServer([make_text_response("OK")]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| extra_body=extra, | |
| ) | |
| messages = [{"role": "user", "content": "Hi"}] | |
| await agent.run(messages) | |
| assert server.call_history[0].get("extra_body") == extra | |
| async def test_managed_state_returned(self, basic_tools, valid_names): | |
| """If server has get_state(), result should include managed_state.""" | |
| server = MockServer([make_text_response("OK")]) | |
| server.get_state = lambda: {"nodes": [{"test": True}]} | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Hi"}] | |
| result = await agent.run(messages) | |
| assert result.managed_state is not None | |
| assert "nodes" in result.managed_state | |
| async def test_no_managed_state_without_get_state(self, basic_tools, valid_names): | |
| """Regular server without get_state() should return None managed_state.""" | |
| server = MockServer([make_text_response("OK")]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Hi"}] | |
| result = await agent.run(messages) | |
| assert result.managed_state is None | |
| async def test_memory_tool_blocked(self, basic_tools): | |
| """Memory tool should return error in RL environments.""" | |
| valid = {"terminal", "read_file", "todo", "memory"} | |
| server = MockServer([ | |
| make_tool_response("memory", {"action": "add", "target": "user", "content": "test"}), | |
| make_text_response("Done"), | |
| ]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Remember this"}] | |
| result = await agent.run(messages) | |
| # Find the tool response | |
| tool_msgs = [m for m in result.messages if m["role"] == "tool"] | |
| assert len(tool_msgs) >= 1 | |
| tool_result = json.loads(tool_msgs[0]["content"]) | |
| assert "error" in tool_result | |
| assert "not available" in tool_result["error"].lower() | |
| async def test_session_search_blocked(self, basic_tools): | |
| """session_search should return error in RL environments.""" | |
| valid = {"terminal", "read_file", "todo", "session_search"} | |
| server = MockServer([ | |
| make_tool_response("session_search", {"query": "test"}), | |
| make_text_response("Done"), | |
| ]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "Search sessions"}] | |
| result = await agent.run(messages) | |
| tool_msgs = [m for m in result.messages if m["role"] == "tool"] | |
| assert len(tool_msgs) >= 1 | |
| tool_result = json.loads(tool_msgs[0]["content"]) | |
| assert "error" in tool_result | |
| async def test_reasoning_content_preserved(self, basic_tools, valid_names): | |
| """Reasoning content should be extracted and preserved.""" | |
| resp = MockChatCompletion( | |
| choices=[ | |
| MockChoice( | |
| message=MockMessage( | |
| content="The answer is 42.", | |
| reasoning_content="Let me think about this step by step...", | |
| ) | |
| ) | |
| ] | |
| ) | |
| server = MockServer([resp]) | |
| agent = HermesAgentLoop( | |
| server=server, | |
| tool_schemas=basic_tools, | |
| valid_tool_names=valid_names, | |
| max_turns=10, | |
| ) | |
| messages = [{"role": "user", "content": "What is the meaning of life?"}] | |
| result = await agent.run(messages) | |
| assert len(result.reasoning_per_turn) == 1 | |
| assert result.reasoning_per_turn[0] == "Let me think about this step by step..." | |
| class TestResizeToolPool: | |
| def test_resize_works(self): | |
| """resize_tool_pool should not raise.""" | |
| resize_tool_pool(16) # Small pool for testing | |
| resize_tool_pool(128) # Restore default | |
| def test_resize_shuts_down_previous_executor(self, monkeypatch): | |
| """Replacing the global tool executor should shut down the old pool.""" | |
| import environments.agent_loop as agent_loop_module | |
| old_executor = MagicMock() | |
| new_executor = MagicMock() | |
| monkeypatch.setattr(agent_loop_module, "_tool_executor", old_executor) | |
| monkeypatch.setattr( | |
| agent_loop_module.concurrent.futures, | |
| "ThreadPoolExecutor", | |
| MagicMock(return_value=new_executor), | |
| ) | |
| resize_tool_pool(16) | |
| old_executor.shutdown.assert_called_once_with(wait=False) | |
| assert agent_loop_module._tool_executor is new_executor | |