trustgraph/tests/unit/test_agent/test_react_processor.py

477 lines
20 KiB
Python
Raw Normal View History

Release/v1.2 (#457) * Bump setup.py versions for 1.1 * PoC MCP server (#419) * Very initial MCP server PoC for TrustGraph * Put service on port 8000 * Add MCP container and packages to buildout * Update docs for API/CLI changes in 1.0 (#421) * Update some API basics for the 0.23/1.0 API change * Add MCP container push (#425) * Add command args to the MCP server (#426) * Host and port parameters * Added websocket arg * More docs * MCP client support (#427) - MCP client service - Tool request/response schema - API gateway support for mcp-tool - Message translation for tool request & response - Make mcp-tool using configuration service for information about where the MCP services are. * Feature/react call mcp (#428) Key Features - MCP Tool Integration: Added core MCP tool support with ToolClientSpec and ToolClient classes - API Enhancement: New mcp_tool method for flow-specific tool invocation - CLI Tooling: New tg-invoke-mcp-tool command for testing MCP integration - React Agent Enhancement: Fixed and improved multi-tool invocation capabilities - Tool Management: Enhanced CLI for tool configuration and management Changes - Added MCP tool invocation to API with flow-specific integration - Implemented ToolClientSpec and ToolClient for tool call handling - Updated agent-manager-react to invoke MCP tools with configurable types - Enhanced CLI with new commands and improved help text - Added comprehensive documentation for new CLI commands - Improved tool configuration management Testing - Added tg-invoke-mcp-tool CLI command for isolated MCP integration testing - Enhanced agent capability to invoke multiple tools simultaneously * Test suite executed from CI pipeline (#433) * Test strategy & test cases * Unit tests * Integration tests * Extending test coverage (#434) * Contract tests * Testing embeedings * Agent unit tests * Knowledge pipeline tests * Turn on contract tests * Increase storage test coverage (#435) * Fixing storage and adding tests * PR pipeline only runs quick tests * Empty configuration is returned as empty list, previously was not in response (#436) * Update config util to take files as well as command-line text (#437) * Updated CLI invocation and config model for tools and mcp (#438) * Updated CLI invocation and config model for tools and mcp * CLI anomalies * Tweaked the MCP tool implementation for new model * Update agent implementation to match the new model * Fix agent tools, now all tested * Fixed integration tests * Fix MCP delete tool params * Update Python deps to 1.2 * Update to enable knowledge extraction using the agent framework (#439) * Implement KG extraction agent (kg-extract-agent) * Using ReAct framework (agent-manager-react) * ReAct manager had an issue when emitting JSON, which conflicts which ReAct manager's own JSON messages, so refactored ReAct manager to use traditional ReAct messages, non-JSON structure. * Minor refactor to take the prompt template client out of prompt-template so it can be more readily used by other modules. kg-extract-agent uses this framework. * Migrate from setup.py to pyproject.toml (#440) * Converted setup.py to pyproject.toml * Modern package infrastructure as recommended by py docs * Install missing build deps (#441) * Install missing build deps (#442) * Implement logging strategy (#444) * Logging strategy and convert all prints() to logging invocations * Fix/startup failure (#445) * Fix loggin startup problems * Fix logging startup problems (#446) * Fix logging startup problems (#447) * Fixed Mistral OCR to use current API (#448) * Fixed Mistral OCR to use current API * Added PDF decoder tests * Fix Mistral OCR ident to be standard pdf-decoder (#450) * Fix Mistral OCR ident to be standard pdf-decoder * Correct test * Schema structure refactor (#451) * Write schema refactor spec * Implemented schema refactor spec * Structure data mvp (#452) * Structured data tech spec * Architecture principles * New schemas * Updated schemas and specs * Object extractor * Add .coveragerc * New tests * Cassandra object storage * Trying to object extraction working, issues exist * Validate librarian collection (#453) * Fix token chunker, broken API invocation (#454) * Fix token chunker, broken API invocation (#455) * Knowledge load utility CLI (#456) * Knowledge loader * More tests
2025-08-18 20:56:09 +01:00
"""
Unit tests for ReAct processor logic
Tests the core business logic for the ReAct (Reasoning and Acting) pattern
without relying on external LLM services, focusing on the Think-Act-Observe
cycle and tool coordination.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
import re
class TestReActProcessorLogic:
"""Test cases for ReAct processor business logic"""
def test_react_cycle_parsing(self):
"""Test parsing of ReAct cycle components from LLM output"""
# Arrange
llm_output = """Think: I need to find information about the capital of France.
Act: knowledge_search: capital of France
Observe: The search returned that Paris is the capital of France.
Think: I now have enough information to answer.
Answer: The capital of France is Paris."""
def parse_react_output(text):
"""Parse ReAct format output into structured steps"""
steps = []
lines = text.strip().split('\n')
for line in lines:
line = line.strip()
if line.startswith('Think:'):
steps.append({
'type': 'think',
'content': line[6:].strip()
})
elif line.startswith('Act:'):
act_content = line[4:].strip()
# Parse "tool_name: parameters" format
if ':' in act_content:
tool_name, params = act_content.split(':', 1)
steps.append({
'type': 'act',
'tool_name': tool_name.strip(),
'parameters': params.strip()
})
else:
steps.append({
'type': 'act',
'content': act_content
})
elif line.startswith('Observe:'):
steps.append({
'type': 'observe',
'content': line[8:].strip()
})
elif line.startswith('Answer:'):
steps.append({
'type': 'answer',
'content': line[7:].strip()
})
return steps
# Act
steps = parse_react_output(llm_output)
# Assert
assert len(steps) == 5
assert steps[0]['type'] == 'think'
assert steps[1]['type'] == 'act'
assert steps[1]['tool_name'] == 'knowledge_search'
assert steps[1]['parameters'] == 'capital of France'
assert steps[2]['type'] == 'observe'
assert steps[3]['type'] == 'think'
assert steps[4]['type'] == 'answer'
def test_tool_selection_logic(self):
"""Test tool selection based on question type and context"""
# Arrange
test_cases = [
("What is 2 + 2?", "calculator"),
("Who is the president of France?", "knowledge_search"),
("Tell me about the relationship between Paris and France", "graph_rag"),
("What time is it?", "knowledge_search") # Default to general search
]
available_tools = {
"calculator": {"description": "Perform mathematical calculations"},
"knowledge_search": {"description": "Search knowledge base for facts"},
"graph_rag": {"description": "Query knowledge graph for relationships"}
}
def select_tool(question, tools):
"""Select appropriate tool based on question content"""
question_lower = question.lower()
# Math keywords
if any(word in question_lower for word in ['+', '-', '*', '/', 'calculate', 'math']):
return "calculator"
# Relationship/graph keywords
if any(word in question_lower for word in ['relationship', 'between', 'connected', 'related']):
return "graph_rag"
# General knowledge keywords or default case
if any(word in question_lower for word in ['who', 'what', 'where', 'when', 'why', 'how', 'time']):
return "knowledge_search"
return None
# Act & Assert
for question, expected_tool in test_cases:
selected_tool = select_tool(question, available_tools)
assert selected_tool == expected_tool, f"Question '{question}' should select {expected_tool}"
def test_tool_execution_logic(self):
"""Test tool execution and result processing"""
# Arrange
def mock_knowledge_search(query):
if "capital" in query.lower() and "france" in query.lower():
return "Paris is the capital of France."
return "Information not found."
def mock_calculator(expression):
try:
# Simple expression evaluation
if '+' in expression:
parts = expression.split('+')
return str(sum(int(p.strip()) for p in parts))
return str(eval(expression))
except:
return "Error: Invalid expression"
tools = {
"knowledge_search": mock_knowledge_search,
"calculator": mock_calculator
}
def execute_tool(tool_name, parameters, available_tools):
"""Execute tool with given parameters"""
if tool_name not in available_tools:
return {"error": f"Tool {tool_name} not available"}
try:
tool_function = available_tools[tool_name]
result = tool_function(parameters)
return {"success": True, "result": result}
except Exception as e:
return {"error": str(e)}
# Act & Assert
test_cases = [
("knowledge_search", "capital of France", "Paris is the capital of France."),
("calculator", "2 + 2", "4"),
("calculator", "invalid expression", "Error: Invalid expression"),
("nonexistent_tool", "anything", None) # Error case
]
for tool_name, params, expected in test_cases:
result = execute_tool(tool_name, params, tools)
if expected is None:
assert "error" in result
else:
assert result.get("result") == expected
def test_conversation_context_integration(self):
"""Test integration of conversation history into ReAct reasoning"""
# Arrange
conversation_history = [
{"role": "user", "content": "What is 2 + 2?"},
{"role": "assistant", "content": "2 + 2 = 4"},
{"role": "user", "content": "What about 3 + 3?"}
]
def build_context_prompt(question, history, max_turns=3):
"""Build context prompt from conversation history"""
context_parts = []
# Include recent conversation turns
recent_history = history[-(max_turns*2):] if history else []
for turn in recent_history:
role = turn["role"]
content = turn["content"]
context_parts.append(f"{role}: {content}")
current_question = f"user: {question}"
context_parts.append(current_question)
return "\n".join(context_parts)
# Act
context_prompt = build_context_prompt("What about 3 + 3?", conversation_history)
# Assert
assert "2 + 2" in context_prompt
assert "2 + 2 = 4" in context_prompt
assert "3 + 3" in context_prompt
assert context_prompt.count("user:") == 3
assert context_prompt.count("assistant:") == 1
def test_react_cycle_validation(self):
"""Test validation of complete ReAct cycles"""
# Arrange
complete_cycle = [
{"type": "think", "content": "I need to solve this math problem"},
{"type": "act", "tool_name": "calculator", "parameters": "2 + 2"},
{"type": "observe", "content": "The calculator returned 4"},
{"type": "think", "content": "I can now provide the answer"},
{"type": "answer", "content": "2 + 2 = 4"}
]
incomplete_cycle = [
{"type": "think", "content": "I need to solve this"},
{"type": "act", "tool_name": "calculator", "parameters": "2 + 2"}
# Missing observe and answer steps
]
def validate_react_cycle(steps):
"""Validate that ReAct cycle is complete"""
step_types = [step.get("type") for step in steps]
# Must have at least one think, act, observe, and answer
required_types = ["think", "act", "observe", "answer"]
validation_results = {
"is_complete": all(req_type in step_types for req_type in required_types),
"has_reasoning": "think" in step_types,
"has_action": "act" in step_types,
"has_observation": "observe" in step_types,
"has_answer": "answer" in step_types,
"step_count": len(steps)
}
return validation_results
# Act & Assert
complete_validation = validate_react_cycle(complete_cycle)
assert complete_validation["is_complete"] is True
assert complete_validation["has_reasoning"] is True
assert complete_validation["has_action"] is True
assert complete_validation["has_observation"] is True
assert complete_validation["has_answer"] is True
incomplete_validation = validate_react_cycle(incomplete_cycle)
assert incomplete_validation["is_complete"] is False
assert incomplete_validation["has_reasoning"] is True
assert incomplete_validation["has_action"] is True
assert incomplete_validation["has_observation"] is False
assert incomplete_validation["has_answer"] is False
def test_multi_step_reasoning_logic(self):
"""Test multi-step reasoning chains"""
# Arrange
complex_question = "What is the population of the capital of France?"
def plan_reasoning_steps(question):
"""Plan the reasoning steps needed for complex questions"""
steps = []
question_lower = question.lower()
# Check if question requires multiple pieces of information
if "capital of" in question_lower and ("population" in question_lower or "how many" in question_lower):
steps.append({
"step": 1,
"action": "find_capital",
"description": "First find the capital city"
})
steps.append({
"step": 2,
"action": "find_population",
"description": "Then find the population of that city"
})
elif "capital of" in question_lower:
steps.append({
"step": 1,
"action": "find_capital",
"description": "Find the capital city"
})
elif "population" in question_lower:
steps.append({
"step": 1,
"action": "find_population",
"description": "Find the population"
})
else:
steps.append({
"step": 1,
"action": "general_search",
"description": "Search for relevant information"
})
return steps
# Act
reasoning_plan = plan_reasoning_steps(complex_question)
# Assert
assert len(reasoning_plan) == 2
assert reasoning_plan[0]["action"] == "find_capital"
assert reasoning_plan[1]["action"] == "find_population"
assert all("step" in step for step in reasoning_plan)
def test_error_handling_in_react_cycle(self):
"""Test error handling during ReAct execution"""
# Arrange
def execute_react_step_with_errors(step_type, content, tools=None):
"""Execute ReAct step with potential error handling"""
try:
if step_type == "think":
# Thinking step - validate reasoning
if not content or len(content.strip()) < 5:
return {"error": "Reasoning too brief"}
return {"success": True, "content": content}
elif step_type == "act":
# Action step - validate tool exists and execute
if not tools or not content:
return {"error": "No tools available or no action specified"}
# Parse tool and parameters
if ":" in content:
tool_name, params = content.split(":", 1)
tool_name = tool_name.strip()
params = params.strip()
if tool_name not in tools:
return {"error": f"Tool {tool_name} not available"}
# Execute tool
result = tools[tool_name](params)
return {"success": True, "tool_result": result}
else:
return {"error": "Invalid action format"}
elif step_type == "observe":
# Observation step - validate observation
if not content:
return {"error": "No observation provided"}
return {"success": True, "content": content}
else:
return {"error": f"Unknown step type: {step_type}"}
except Exception as e:
return {"error": f"Execution error: {str(e)}"}
# Test cases
mock_tools = {
"calculator": lambda x: str(eval(x)) if x.replace('+', '').replace('-', '').replace('*', '').replace('/', '').replace(' ', '').isdigit() else "Error"
}
test_cases = [
("think", "I need to calculate", {"success": True}),
("think", "", {"error": True}), # Empty reasoning
("act", "calculator: 2 + 2", {"success": True}),
("act", "nonexistent: something", {"error": True}), # Tool doesn't exist
("act", "invalid format", {"error": True}), # Invalid format
("observe", "The result is 4", {"success": True}),
("observe", "", {"error": True}), # Empty observation
("invalid_step", "content", {"error": True}) # Invalid step type
]
# Act & Assert
for step_type, content, expected in test_cases:
result = execute_react_step_with_errors(step_type, content, mock_tools)
if expected.get("error"):
assert "error" in result, f"Expected error for step {step_type}: {content}"
else:
assert "success" in result, f"Expected success for step {step_type}: {content}"
def test_response_synthesis_logic(self):
"""Test synthesis of final response from ReAct steps"""
# Arrange
react_steps = [
{"type": "think", "content": "I need to find the capital of France"},
{"type": "act", "tool_name": "knowledge_search", "tool_result": "Paris is the capital of France"},
{"type": "observe", "content": "The search confirmed Paris is the capital"},
{"type": "think", "content": "I have the information needed to answer"}
]
def synthesize_response(steps, original_question):
"""Synthesize final response from ReAct steps"""
# Extract key information from steps
tool_results = []
observations = []
reasoning = []
for step in steps:
if step["type"] == "think":
reasoning.append(step["content"])
elif step["type"] == "act" and "tool_result" in step:
tool_results.append(step["tool_result"])
elif step["type"] == "observe":
observations.append(step["content"])
# Build response based on available information
if tool_results:
# Use tool results as primary information source
primary_info = tool_results[0]
# Extract specific answer from tool result
if "capital" in original_question.lower() and "Paris" in primary_info:
return "The capital of France is Paris."
elif "+" in original_question and any(char.isdigit() for char in primary_info):
return f"The answer is {primary_info}."
else:
return primary_info
else:
# Fallback to reasoning if no tool results
return "I need more information to answer this question."
# Act
response = synthesize_response(react_steps, "What is the capital of France?")
# Assert
assert "Paris" in response
assert "capital of france" in response.lower()
assert len(response) > 10 # Should be a complete sentence
def test_tool_parameter_extraction(self):
"""Test extraction and validation of tool parameters"""
# Arrange
def extract_tool_parameters(action_content, tool_schema):
"""Extract and validate parameters for tool execution"""
# Parse action content for tool name and parameters
if ":" not in action_content:
return {"error": "Invalid action format - missing tool parameters"}
tool_name, params_str = action_content.split(":", 1)
tool_name = tool_name.strip()
params_str = params_str.strip()
if tool_name not in tool_schema:
return {"error": f"Unknown tool: {tool_name}"}
schema = tool_schema[tool_name]
required_params = schema.get("required_parameters", [])
# Simple parameter extraction (for more complex tools, this would be more sophisticated)
if len(required_params) == 1 and required_params[0] == "query":
# Single query parameter
return {"tool_name": tool_name, "parameters": {"query": params_str}}
elif len(required_params) == 1 and required_params[0] == "expression":
# Single expression parameter
return {"tool_name": tool_name, "parameters": {"expression": params_str}}
else:
# Multiple parameters would need more complex parsing
return {"tool_name": tool_name, "parameters": {"input": params_str}}
tool_schema = {
"knowledge_search": {"required_parameters": ["query"]},
"calculator": {"required_parameters": ["expression"]},
"graph_rag": {"required_parameters": ["query"]}
}
test_cases = [
("knowledge_search: capital of France", "knowledge_search", {"query": "capital of France"}),
("calculator: 2 + 2", "calculator", {"expression": "2 + 2"}),
("invalid format", None, None), # No colon
("unknown_tool: something", None, None) # Unknown tool
]
# Act & Assert
for action_content, expected_tool, expected_params in test_cases:
result = extract_tool_parameters(action_content, tool_schema)
if expected_tool is None:
assert "error" in result
else:
assert result["tool_name"] == expected_tool
assert result["parameters"] == expected_params