| """ |
| Tests for PostProcessAgent |
| |
| Tests the dual extraction agent that generates both direct extraction and code. |
| """ |
| import pytest |
| import json |
| from unittest.mock import AsyncMock, Mock, patch |
| from mcpuniverse.extensions.mcpplus.agent.react_postprocess_agent import ( |
| PostProcessAgent, |
| PostProcessAgentConfig |
| ) |
| from mcpuniverse.extensions.mcpplus.utils.safe_executor import SafeCodeExecutor |
|
|
|
|
| class MockLLM: |
| """Mock LLM for testing.""" |
|
|
| def __init__(self, responses=None): |
| """Initialize with preset responses.""" |
| self.responses = responses or [] |
| self.call_count = 0 |
| self.config = Mock() |
| self.config.model_name = "gpt-4o-mini" |
|
|
| async def generate_async(self, messages, tracer=None, timeout=None): |
| """Mock async generate.""" |
| if self.call_count < len(self.responses): |
| response = self.responses[self.call_count] |
| self.call_count += 1 |
| return response |
| raise ValueError("No more mock responses available") |
|
|
| def dump_config(self): |
| """Mock dump_config method.""" |
| return {"type": "mock", "model_name": "gpt-4o-mini"} |
|
|
|
|
| class TestPostProcessAgentConfig: |
| """Test suite for PostProcessAgentConfig.""" |
|
|
| def test_default_config(self): |
| """Test default configuration values.""" |
| config = PostProcessAgentConfig() |
| assert config.max_iterations == 3 |
| assert config.llm_timeout == 500 |
| assert config.skip_iteration_on_size_failure is False |
|
|
| def test_custom_config(self): |
| """Test custom configuration values.""" |
| config = PostProcessAgentConfig( |
| max_iterations=5, |
| llm_timeout=600, |
| skip_iteration_on_size_failure=True |
| ) |
| assert config.max_iterations == 5 |
| assert config.llm_timeout == 600 |
| assert config.skip_iteration_on_size_failure is True |
|
|
|
|
| class TestPostProcessAgentInitialization: |
| """Test suite for PostProcessAgent initialization.""" |
|
|
| def test_init_with_dict_config(self): |
| """Test initialization with dictionary config.""" |
| llm = MockLLM() |
| executor = SafeCodeExecutor() |
| config = {"max_iterations": 5, "llm_timeout": 600} |
|
|
| agent = PostProcessAgent(llm=llm, safe_executor=executor, config=config) |
| assert agent._config.max_iterations == 5 |
| assert agent._config.llm_timeout == 600 |
|
|
| def test_init_with_config_object(self): |
| """Test initialization with PostProcessAgentConfig object.""" |
| llm = MockLLM() |
| executor = SafeCodeExecutor() |
| config = PostProcessAgentConfig(max_iterations=2) |
|
|
| agent = PostProcessAgent(llm=llm, safe_executor=executor, config=config) |
| assert agent._config.max_iterations == 2 |
|
|
| def test_init_with_no_config(self): |
| """Test initialization with no config (defaults).""" |
| llm = MockLLM() |
| executor = SafeCodeExecutor() |
|
|
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
| assert agent._config.max_iterations == 3 |
|
|
|
|
| @pytest.mark.asyncio |
| class TestPostProcessAgentExecution: |
| """Test suite for PostProcessAgent execution.""" |
|
|
| async def test_successful_dual_extraction(self): |
| """Test successful extraction with both methods.""" |
| |
| llm_response = json.dumps({ |
| "direct_extraction": "The weather is sunny and 75 degrees.", |
| "code": "result = data.split('Temperature:')[1].strip().split()[0]" |
| }) |
|
|
| llm = MockLLM(responses=[llm_response]) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| |
| input_message = json.dumps({ |
| "tool_name": "get_weather", |
| "tool_description": "Gets current weather", |
| "tool_output": "Weather Report\nTemperature: 75F\nConditions: Sunny\nHumidity: 60%", |
| "expected_info": "current temperature" |
| }) |
|
|
| |
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| |
| assert response.response is not None |
| response_data = json.loads(response.response) |
| assert "filtered_output" in response_data |
| assert "DIRECT EXTRACTION" in response_data["filtered_output"] |
| assert "CODE-BASED EXTRACTION" in response_data["filtered_output"] |
| assert response_data["stats"]["success"] is True |
| assert response_data["stats"]["postprocessor_iterations"] == 1 |
|
|
| async def test_invalid_json_input(self): |
| """Test handling of invalid JSON input.""" |
| llm = MockLLM() |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| |
| await agent.initialize() |
| response = await agent.execute(message="not json") |
|
|
| assert "ERROR" in response.response |
|
|
| async def test_empty_direct_extraction(self): |
| """Test when direct extraction is empty but code succeeds.""" |
| |
| |
| responses = [ |
| json.dumps({ |
| "direct_extraction": "", |
| "code": "result = 'extracted'" |
| }), |
| json.dumps({ |
| "direct_extraction": "Direct result", |
| "code": "result = 'code result'" |
| }) |
| ] |
|
|
| llm = MockLLM(responses=responses) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "test data", |
| "expected_info": "test info" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| |
| assert response_data["stats"]["postprocessor_iterations"] in [2, 3] |
|
|
| async def test_code_execution_failure(self): |
| """Test when code execution fails.""" |
| |
| |
| responses = [ |
| json.dumps({ |
| "direct_extraction": "Direct result", |
| "code": "result = undefined_variable" |
| }), |
| json.dumps({ |
| "direct_extraction": "Direct result", |
| "code": "result = 'success'" |
| }) |
| ] |
|
|
| llm = MockLLM(responses=responses) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "test data", |
| "expected_info": "test info" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| |
| assert response_data["stats"]["postprocessor_iterations"] == 2 |
|
|
| async def test_invalid_llm_json_response(self): |
| """Test handling of invalid JSON from LLM.""" |
| |
| |
| responses = [ |
| "not valid json", |
| json.dumps({ |
| "direct_extraction": "Result", |
| "code": "result = 'success'" |
| }) |
| ] |
|
|
| llm = MockLLM(responses=responses) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "test data", |
| "expected_info": "test info" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| |
| assert response_data["stats"]["postprocessor_iterations"] == 2 |
|
|
| async def test_max_iterations_exhausted(self): |
| """Test when all iterations are exhausted.""" |
| |
| responses = [ |
| json.dumps({"direct_extraction": "", "code": ""}), |
| json.dumps({"direct_extraction": "", "code": ""}), |
| json.dumps({"direct_extraction": "", "code": ""}) |
| ] |
|
|
| llm = MockLLM(responses=responses) |
| executor = SafeCodeExecutor() |
| config = PostProcessAgentConfig(max_iterations=3) |
| agent = PostProcessAgent(llm=llm, safe_executor=executor, config=config) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "original output", |
| "expected_info": "test info" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| |
| assert "original output" in response_data["filtered_output"] |
| assert response_data["stats"]["postprocessor_iterations"] == 3 |
|
|
| async def test_markdown_code_block_stripping(self): |
| """Test that markdown code blocks are stripped from LLM response.""" |
| |
| llm_response = """```json |
| { |
| "direct_extraction": "Result", |
| "code": "result = 'test'" |
| } |
| ```""" |
|
|
| llm = MockLLM(responses=[llm_response]) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "test data", |
| "expected_info": "test info" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| assert response_data["stats"]["success"] is True |
|
|
| async def test_output_size_check_both_too_large(self): |
| """Test when both outputs are too large (> 50% of input).""" |
| |
| large_input = "x" * 10000 |
|
|
| |
| llm_response = json.dumps({ |
| "direct_extraction": "y" * 6000, |
| "code": "result = 'z' * 6000" |
| }) |
|
|
| llm = MockLLM(responses=[llm_response]) |
| executor = SafeCodeExecutor() |
| config = PostProcessAgentConfig(skip_iteration_on_size_failure=True) |
| agent = PostProcessAgent(llm=llm, safe_executor=executor, config=config) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": large_input, |
| "expected_info": "test info" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| |
| assert large_input in response_data["filtered_output"] |
|
|
| async def test_partial_success_direct_only(self): |
| """Test when only direct extraction succeeds.""" |
| |
| responses = [ |
| json.dumps({ |
| "direct_extraction": "Direct result works", |
| "code": "" |
| }), |
| json.dumps({ |
| "direct_extraction": "Direct result works again", |
| "code": "" |
| }), |
| json.dumps({ |
| "direct_extraction": "Direct result final", |
| "code": "" |
| }) |
| ] |
|
|
| llm = MockLLM(responses=responses) |
| executor = SafeCodeExecutor() |
| config = PostProcessAgentConfig(max_iterations=3) |
| agent = PostProcessAgent(llm=llm, safe_executor=executor, config=config) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "test data", |
| "expected_info": "test info" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| |
| assert "Direct result final" in response_data["filtered_output"] |
| assert response_data["stats"]["postprocessor_iterations"] == 3 |
|
|
| async def test_stats_tracking(self): |
| """Test that statistics are properly tracked.""" |
| llm_response = json.dumps({ |
| "direct_extraction": "Short result", |
| "code": "result = 'Short code result'" |
| }) |
|
|
| llm = MockLLM(responses=[llm_response]) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| long_output = "This is a long tool output that will be compressed. " * 50 |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "Test tool", |
| "tool_output": long_output, |
| "expected_info": "short summary" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| stats = response_data["stats"] |
|
|
| |
| assert "postprocessor_iterations" in stats |
| assert "original_chars" in stats |
| assert "filtered_chars" in stats |
| assert "chars_reduced" in stats |
| assert "original_tokens" in stats |
| assert "filtered_tokens" in stats |
| assert "tokens_reduced" in stats |
| assert "success" in stats |
|
|
| |
| assert stats["original_tokens"] > stats["filtered_tokens"] |
| assert stats["tokens_reduced"] > 0 |
| assert stats["success"] is True |
|
|
| async def test_list_message_input(self): |
| """Test handling of list-format message input.""" |
| llm_response = json.dumps({ |
| "direct_extraction": "Result", |
| "code": "result = 'test'" |
| }) |
|
|
| llm = MockLLM(responses=[llm_response]) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| |
| input_message = [json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "test data", |
| "expected_info": "test info" |
| })] |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| assert response_data["stats"]["success"] is True |
|
|
| async def test_thought_field_in_response(self): |
| """Test that optional 'thought' field is handled.""" |
| llm_response = json.dumps({ |
| "thought": "I will extract the temperature from the data", |
| "direct_extraction": "Temperature is 75F", |
| "code": "result = data.split('Temperature:')[1].split()[0]" |
| }) |
|
|
| llm = MockLLM(responses=[llm_response]) |
| executor = SafeCodeExecutor() |
| agent = PostProcessAgent(llm=llm, safe_executor=executor) |
|
|
| input_message = json.dumps({ |
| "tool_name": "test_tool", |
| "tool_description": "", |
| "tool_output": "Temperature: 75F", |
| "expected_info": "temperature" |
| }) |
|
|
| await agent.initialize() |
| response = await agent.execute(message=input_message) |
|
|
| response_data = json.loads(response.response) |
| assert response_data["stats"]["success"] is True |
|
|