Feature/tool group (#484)

* Tech spec for tool group

* Partial tool group implementation

* Tool group tests
This commit is contained in:
cybermaggedon 2025-09-03 23:39:49 +01:00 committed by GitHub
parent 672e358b2f
commit e74eb5d1ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1304 additions and 6 deletions

View file

@ -82,8 +82,8 @@ def sample_message_data():
},
"AgentRequest": {
"question": "What is machine learning?",
"plan": "",
"state": "",
"group": [],
"history": []
},
"AgentResponse": {

View file

@ -198,8 +198,8 @@ class TestAgentMessageContracts:
# Test required fields
request = AgentRequest(**request_data)
assert hasattr(request, 'question')
assert hasattr(request, 'plan')
assert hasattr(request, 'state')
assert hasattr(request, 'group')
assert hasattr(request, 'history')
def test_agent_response_schema_contract(self, sample_message_data):

View file

@ -0,0 +1,267 @@
"""
Integration tests for the tool group system.
Tests the complete workflow of tool filtering and execution logic.
"""
import pytest
import json
import sys
import os
from unittest.mock import Mock, AsyncMock, patch
# Add trustgraph paths for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'trustgraph-base'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'trustgraph-flow'))
from trustgraph.agent.tool_filter import filter_tools_by_group_and_state, get_next_state, validate_tool_config
@pytest.fixture
def sample_tools():
"""Sample tools with different groups and states for testing."""
return {
'knowledge_query': Mock(config={
'group': ['read-only', 'knowledge', 'basic'],
'state': 'analysis',
'applicable-states': ['undefined', 'research']
}),
'graph_update': Mock(config={
'group': ['write', 'knowledge', 'admin'],
'applicable-states': ['analysis', 'modification']
}),
'text_completion': Mock(config={
'group': ['read-only', 'text', 'basic'],
'state': 'undefined'
# No applicable-states = available in all states
}),
'complex_analysis': Mock(config={
'group': ['advanced', 'compute', 'expensive'],
'state': 'results',
'applicable-states': ['analysis']
})
}
class TestToolGroupFiltering:
"""Test tool group filtering integration scenarios."""
def test_basic_group_filtering(self, sample_tools):
"""Test that filtering only returns tools matching requested groups."""
# Filter for read-only and knowledge tools
filtered = filter_tools_by_group_and_state(
sample_tools,
['read-only', 'knowledge'],
'undefined'
)
# Should include tools with matching groups and correct state
assert 'knowledge_query' in filtered # Has read-only + knowledge, available in undefined
assert 'text_completion' in filtered # Has read-only, available in all states
assert 'graph_update' not in filtered # Has knowledge but no read-only
assert 'complex_analysis' not in filtered # Wrong groups and state
def test_state_based_filtering(self, sample_tools):
"""Test filtering based on current state."""
# Filter for analysis state with advanced tools
filtered = filter_tools_by_group_and_state(
sample_tools,
['advanced', 'compute'],
'analysis'
)
# Should only include tools available in analysis state
assert 'complex_analysis' in filtered # Available in analysis state
assert 'knowledge_query' not in filtered # Not available in analysis state
assert 'graph_update' not in filtered # Wrong group (no advanced/compute)
assert 'text_completion' not in filtered # Wrong group
def test_state_transition_handling(self, sample_tools):
"""Test state transitions after tool execution."""
# Get knowledge_query tool and test state transition
knowledge_tool = sample_tools['knowledge_query']
# Test state transition
next_state = get_next_state(knowledge_tool, 'undefined')
assert next_state == 'analysis' # knowledge_query should transition to analysis
# Test tool with no state transition
text_tool = sample_tools['text_completion']
next_state = get_next_state(text_tool, 'research')
assert next_state == 'undefined' # text_completion transitions to undefined
def test_wildcard_group_access(self, sample_tools):
"""Test wildcard group grants access to all tools."""
# Filter with wildcard group access
filtered = filter_tools_by_group_and_state(
sample_tools,
['*'], # Wildcard access
'undefined'
)
# Should include all tools that are available in undefined state
assert 'knowledge_query' in filtered # Available in undefined
assert 'text_completion' in filtered # Available in all states
assert 'graph_update' not in filtered # Not available in undefined
assert 'complex_analysis' not in filtered # Not available in undefined
def test_no_matching_tools(self, sample_tools):
"""Test behavior when no tools match the requested groups."""
# Filter with non-matching group
filtered = filter_tools_by_group_and_state(
sample_tools,
['nonexistent-group'],
'undefined'
)
# Should return empty dictionary
assert len(filtered) == 0
def test_default_group_behavior(self):
"""Test default group behavior when no group is specified."""
# Create tools with and without explicit groups
tools = {
'default_tool': Mock(config={}), # No group = default group
'admin_tool': Mock(config={'group': ['admin']})
}
# Filter with no group specified (should default to ["default"])
filtered = filter_tools_by_group_and_state(tools, None, 'undefined')
# Only default_tool should be available
assert 'default_tool' in filtered
assert 'admin_tool' not in filtered
class TestToolConfigurationValidation:
"""Test tool configuration validation with group metadata."""
def test_tool_config_validation_invalid(self):
"""Test that invalid tool configurations are rejected."""
# Test invalid group field (should be list)
invalid_config = {
"name": "invalid_tool",
"description": "Invalid tool",
"type": "text-completion",
"group": "not-a-list" # Should be list
}
# Should raise validation error
with pytest.raises(ValueError, match="'group' field must be a list"):
validate_tool_config(invalid_config)
def test_tool_config_validation_valid(self):
"""Test that valid tool configurations are accepted."""
valid_config = {
"name": "valid_tool",
"description": "Valid tool",
"type": "text-completion",
"group": ["read-only", "text"],
"state": "analysis",
"applicable-states": ["undefined", "research"]
}
# Should not raise any exception
validate_tool_config(valid_config)
def test_kebab_case_field_names(self):
"""Test that kebab-case field names are properly handled."""
config = {
"name": "test_tool",
"group": ["basic"],
"applicable-states": ["undefined", "analysis"] # kebab-case
}
# Should validate without error
validate_tool_config(config)
# Create mock tool and test filtering
tool = Mock(config=config)
# Test that kebab-case field is properly read
filtered = filter_tools_by_group_and_state(
{'test_tool': tool},
['basic'],
'analysis'
)
assert 'test_tool' in filtered
class TestCompleteWorkflow:
"""Test complete multi-step workflows with state transitions."""
def test_research_analysis_workflow(self, sample_tools):
"""Test complete research -> analysis -> results workflow."""
# Step 1: Initial research phase (undefined state)
step1_filtered = filter_tools_by_group_and_state(
sample_tools,
['read-only', 'knowledge'],
'undefined'
)
# Should have access to knowledge_query and text_completion
assert 'knowledge_query' in step1_filtered
assert 'text_completion' in step1_filtered
assert 'complex_analysis' not in step1_filtered # Not available in undefined
# Simulate executing knowledge_query tool
knowledge_tool = step1_filtered['knowledge_query']
next_state = get_next_state(knowledge_tool, 'undefined')
assert next_state == 'analysis' # Transition to analysis state
# Step 2: Analysis phase
step2_filtered = filter_tools_by_group_and_state(
sample_tools,
['advanced', 'compute', 'text'], # Include text for text_completion
'analysis'
)
# Should have access to complex_analysis and text_completion
assert 'complex_analysis' in step2_filtered
assert 'text_completion' in step2_filtered # Available in all states
assert 'knowledge_query' not in step2_filtered # Not available in analysis
# Simulate executing complex_analysis tool
analysis_tool = step2_filtered['complex_analysis']
final_state = get_next_state(analysis_tool, 'analysis')
assert final_state == 'results' # Transition to results state
def test_multi_tenant_scenario(self, sample_tools):
"""Test different users with different permissions."""
# User A: Read-only permissions in undefined state
user_a_tools = filter_tools_by_group_and_state(
sample_tools,
['read-only'],
'undefined'
)
# Should only have access to read-only tools in undefined state
assert 'knowledge_query' in user_a_tools # read-only + available in undefined
assert 'text_completion' in user_a_tools # read-only + available in all states
assert 'graph_update' not in user_a_tools # write permissions required
assert 'complex_analysis' not in user_a_tools # advanced permissions required
# User B: Admin permissions in analysis state
user_b_tools = filter_tools_by_group_and_state(
sample_tools,
['write', 'admin'],
'analysis'
)
# Should have access to admin tools available in analysis state
assert 'graph_update' in user_b_tools # admin + available in analysis
assert 'complex_analysis' not in user_b_tools # wrong group (needs advanced/compute)
assert 'knowledge_query' not in user_b_tools # not available in analysis state
assert 'text_completion' not in user_b_tools # wrong group (no admin)

View file

@ -0,0 +1,321 @@
"""
Unit tests for the tool filtering logic in the tool group system.
"""
import pytest
import sys
import os
from unittest.mock import Mock
# Add trustgraph-flow to path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'trustgraph-flow'))
from trustgraph.agent.tool_filter import (
filter_tools_by_group_and_state,
get_next_state,
validate_tool_config,
_is_tool_available
)
class TestToolFiltering:
"""Test tool filtering based on groups and states."""
def test_filter_tools_default_group(self):
"""Tools without groups should belong to 'default' group."""
tools = {
'tool1': Mock(config={}),
'tool2': Mock(config={'group': ['read-only']})
}
# Request default group (implicit)
filtered = filter_tools_by_group_and_state(tools, None, None)
# Only tool1 should be available (no group = default group)
assert 'tool1' in filtered
assert 'tool2' not in filtered
def test_filter_tools_explicit_groups(self):
"""Test filtering with explicit group membership."""
tools = {
'read_tool': Mock(config={'group': ['read-only', 'basic']}),
'write_tool': Mock(config={'group': ['write', 'admin']}),
'mixed_tool': Mock(config={'group': ['read-only', 'write']})
}
# Request read-only tools
filtered = filter_tools_by_group_and_state(tools, ['read-only'], None)
assert 'read_tool' in filtered
assert 'write_tool' not in filtered
assert 'mixed_tool' in filtered # Has read-only in its groups
def test_filter_tools_multiple_requested_groups(self):
"""Test filtering with multiple requested groups."""
tools = {
'tool1': Mock(config={'group': ['read-only']}),
'tool2': Mock(config={'group': ['write']}),
'tool3': Mock(config={'group': ['admin']})
}
# Request read-only and write tools
filtered = filter_tools_by_group_and_state(tools, ['read-only', 'write'], None)
assert 'tool1' in filtered
assert 'tool2' in filtered
assert 'tool3' not in filtered
def test_filter_tools_wildcard_group(self):
"""Test wildcard group grants access to all tools."""
tools = {
'tool1': Mock(config={'group': ['read-only']}),
'tool2': Mock(config={'group': ['admin']}),
'tool3': Mock(config={}) # default group
}
# Request wildcard access
filtered = filter_tools_by_group_and_state(tools, ['*'], None)
assert len(filtered) == 3
assert all(tool in filtered for tool in tools)
def test_filter_tools_by_state(self):
"""Test filtering based on applicable-states."""
tools = {
'init_tool': Mock(config={'applicable-states': ['undefined']}),
'analysis_tool': Mock(config={'applicable-states': ['analysis']}),
'any_state_tool': Mock(config={}) # available in all states
}
# Filter for 'analysis' state
filtered = filter_tools_by_group_and_state(tools, ['default'], 'analysis')
assert 'init_tool' not in filtered
assert 'analysis_tool' in filtered
assert 'any_state_tool' in filtered
def test_filter_tools_state_wildcard(self):
"""Test tools with '*' in applicable-states are always available."""
tools = {
'wildcard_tool': Mock(config={'applicable-states': ['*']}),
'specific_tool': Mock(config={'applicable-states': ['research']})
}
# Filter for 'analysis' state
filtered = filter_tools_by_group_and_state(tools, ['default'], 'analysis')
assert 'wildcard_tool' in filtered
assert 'specific_tool' not in filtered
def test_filter_tools_combined_group_and_state(self):
"""Test combined group and state filtering."""
tools = {
'valid_tool': Mock(config={
'group': ['read-only'],
'applicable-states': ['analysis']
}),
'wrong_group': Mock(config={
'group': ['admin'],
'applicable-states': ['analysis']
}),
'wrong_state': Mock(config={
'group': ['read-only'],
'applicable-states': ['research']
}),
'wrong_both': Mock(config={
'group': ['admin'],
'applicable-states': ['research']
})
}
filtered = filter_tools_by_group_and_state(
tools, ['read-only'], 'analysis'
)
assert 'valid_tool' in filtered
assert 'wrong_group' not in filtered
assert 'wrong_state' not in filtered
assert 'wrong_both' not in filtered
def test_filter_tools_empty_request_groups(self):
"""Test that empty group list results in no available tools."""
tools = {
'tool1': Mock(config={'group': ['read-only']}),
'tool2': Mock(config={})
}
filtered = filter_tools_by_group_and_state(tools, [], None)
assert len(filtered) == 0
class TestStateTransitions:
"""Test state transition logic."""
def test_get_next_state_with_transition(self):
"""Test state transition when tool defines next state."""
tool = Mock(config={'state': 'analysis'})
next_state = get_next_state(tool, 'undefined')
assert next_state == 'analysis'
def test_get_next_state_no_transition(self):
"""Test no state change when tool doesn't define next state."""
tool = Mock(config={})
next_state = get_next_state(tool, 'research')
assert next_state == 'research'
def test_get_next_state_empty_config(self):
"""Test with tool that has no config."""
tool = Mock(config=None)
tool.config = None
next_state = get_next_state(tool, 'initial')
assert next_state == 'initial'
class TestConfigValidation:
"""Test tool configuration validation."""
def test_validate_valid_config(self):
"""Test validation of valid configuration."""
config = {
'group': ['read-only', 'basic'],
'state': 'analysis',
'applicable-states': ['undefined', 'research']
}
# Should not raise an exception
validate_tool_config(config)
def test_validate_group_not_list(self):
"""Test validation fails when group is not a list."""
config = {'group': 'read-only'} # Should be list
with pytest.raises(ValueError, match="'group' field must be a list"):
validate_tool_config(config)
def test_validate_group_non_string_elements(self):
"""Test validation fails when group contains non-strings."""
config = {'group': ['read-only', 123]} # 123 is not string
with pytest.raises(ValueError, match="All group names must be strings"):
validate_tool_config(config)
def test_validate_state_not_string(self):
"""Test validation fails when state is not a string."""
config = {'state': 123} # Should be string
with pytest.raises(ValueError, match="'state' field must be a string"):
validate_tool_config(config)
def test_validate_applicable_states_not_list(self):
"""Test validation fails when applicable-states is not a list."""
config = {'applicable-states': 'undefined'} # Should be list
with pytest.raises(ValueError, match="'applicable-states' field must be a list"):
validate_tool_config(config)
def test_validate_applicable_states_non_string_elements(self):
"""Test validation fails when applicable-states contains non-strings."""
config = {'applicable-states': ['undefined', 123]}
with pytest.raises(ValueError, match="All state names must be strings"):
validate_tool_config(config)
def test_validate_minimal_config(self):
"""Test validation of minimal valid configuration."""
config = {'name': 'test', 'description': 'Test tool'}
# Should not raise an exception
validate_tool_config(config)
class TestToolAvailability:
"""Test the internal _is_tool_available function."""
def test_tool_available_default_groups_and_states(self):
"""Test tool with default groups and states."""
tool = Mock(config={})
# Default group request, default state
assert _is_tool_available(tool, ['default'], 'undefined')
# Non-default group request should fail
assert not _is_tool_available(tool, ['admin'], 'undefined')
def test_tool_available_string_group_conversion(self):
"""Test that single group string is converted to list."""
tool = Mock(config={'group': 'read-only'}) # Single string
assert _is_tool_available(tool, ['read-only'], 'undefined')
assert not _is_tool_available(tool, ['admin'], 'undefined')
def test_tool_available_string_state_conversion(self):
"""Test that single state string is converted to list."""
tool = Mock(config={'applicable-states': 'analysis'}) # Single string
assert _is_tool_available(tool, ['default'], 'analysis')
assert not _is_tool_available(tool, ['default'], 'research')
def test_tool_no_config_attribute(self):
"""Test tool without config attribute."""
tool = Mock()
del tool.config # Remove config attribute
# Should use defaults and be available for default group/state
assert _is_tool_available(tool, ['default'], 'undefined')
assert not _is_tool_available(tool, ['admin'], 'undefined')
class TestWorkflowScenarios:
"""Test complete workflow scenarios from the tech spec."""
def test_research_to_analysis_workflow(self):
"""Test the research -> analysis workflow from tech spec."""
tools = {
'knowledge_query': Mock(config={
'group': ['read-only', 'knowledge'],
'state': 'analysis',
'applicable-states': ['undefined', 'research']
}),
'complex_analysis': Mock(config={
'group': ['advanced', 'compute'],
'state': 'results',
'applicable-states': ['analysis']
}),
'text_completion': Mock(config={
'group': ['read-only', 'text', 'basic']
# No applicable-states = available in all states
})
}
# Phase 1: Initial research (undefined state)
phase1_filtered = filter_tools_by_group_and_state(
tools, ['read-only', 'knowledge'], 'undefined'
)
assert 'knowledge_query' in phase1_filtered
assert 'text_completion' in phase1_filtered
assert 'complex_analysis' not in phase1_filtered
# Simulate tool execution and state transition
executed_tool = phase1_filtered['knowledge_query']
next_state = get_next_state(executed_tool, 'undefined')
assert next_state == 'analysis'
# Phase 2: Analysis state (include basic group for text_completion)
phase2_filtered = filter_tools_by_group_and_state(
tools, ['advanced', 'compute', 'basic'], 'analysis'
)
assert 'knowledge_query' not in phase2_filtered # Not available in analysis
assert 'complex_analysis' in phase2_filtered
assert 'text_completion' in phase2_filtered # Always available
# Simulate complex analysis execution
executed_tool = phase2_filtered['complex_analysis']
final_state = get_next_state(executed_tool, 'analysis')
assert final_state == 'results'