feat(infra): add sweep coordinator, structured logging, test suite, and README

Sweep coordinator (Step 16):
- SweepCoordinator runs all 8 agents in parallel with 60s per-agent / 300s total timeout
- Aggregates findings, actions, errors into SweepCoordinatorResult
- Registered in FastAPI lifespan; triggered via POST /sweep

Structured logging (Step 18):
- logging_utils/structured.py: JSONFormatter emitting ts/level/logger/msg + custom fields
- log_directive_event() for structured directive lifecycle logging
- push_to_loki() async Loki push (graceful no-op if LOKI_URL unset)
- configure_logging() replaces root handler at startup

Tests (Steps 17+19):
- conftest.py: mock_odoo, mock_pool, mock_llm fixtures
- test_tool_validator.py: 9 tests covering validation, coercion, hallucination stripping
- test_llm_router.py: 6 tests covering local/cloud/hybrid modes and HIPAA enforcement
- test_peer_bus.py: 6 tests covering registration, timeout, depth, circular detection
- test_finance_agent.py: 10 tests covering all 6 steps + sweep + peer request
- test_memory_manager.py: 3 tests covering context build + hard cap enforcement
- test_dispatch_router.py: 3 tests covering dispatch, rate limit, health endpoint
- test_odoo_client.py: 4 tests covering search_read, write result, unlink warning
- test_e2e_dispatch.py: 2 E2E tests - full dispatch cycle + peer bus communication

README (Step 20): architecture diagram, privacy modes, quick start, env vars, structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ActiveBlue Build
2026-04-12 18:08:11 -04:00
parent fe47f950e4
commit 7487fc73f9
15 changed files with 1042 additions and 0 deletions

0
tests/__init__.py Normal file
View File

47
tests/conftest.py Normal file
View File

@@ -0,0 +1,47 @@
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
@pytest.fixture(scope='session')
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
def mock_odoo():
odoo = MagicMock()
odoo.search_read = AsyncMock(return_value=[])
odoo.write = AsyncMock()
odoo.call = AsyncMock(return_value=True)
odoo.ping = AsyncMock(return_value=True)
return odoo
@pytest.fixture
def mock_pool():
pool = MagicMock()
conn = AsyncMock()
conn.fetchval = AsyncMock(return_value=1)
conn.fetch = AsyncMock(return_value=[])
conn.fetchrow = AsyncMock(return_value=None)
conn.execute = AsyncMock()
pool.acquire = MagicMock(return_value=conn)
conn.__aenter__ = AsyncMock(return_value=conn)
conn.__aexit__ = AsyncMock(return_value=False)
return pool
@pytest.fixture
def mock_llm():
from agent_service.llm.llm_types import LLMResponse
llm = MagicMock()
llm.get_backend = AsyncMock(return_value='ollama')
llm.complete = AsyncMock(return_value=LLMResponse(
content='{"intent": "finance_query", "agents": ["finance_agent"], "confidence": 0.9}',
tool_calls=[], backend_used='ollama', model_used='llama3',
tokens_in=10, tokens_out=20, latency_ms=100,
))
return llm

View File

@@ -0,0 +1,60 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def app_with_mock_master():
mock_master = MagicMock()
mock_response = MagicMock()
mock_response.directive_id = 'test-directive-1'
mock_response.reply = 'You have 3 overdue invoices totalling $15,000.'
mock_response.agent_reports = []
mock_response.escalations = []
mock_response.actions_taken = []
mock_master.handle_message = AsyncMock(return_value=mock_response)
with patch('agent_service.app_state.get_master_agent', return_value=mock_master):
from agent_service.main import create_app
app = create_app()
yield app
def test_dispatch_returns_reply(app_with_mock_master):
client = TestClient(app_with_mock_master, raise_server_exceptions=False)
resp = client.post('/dispatch', json={
'user_id': '42',
'message': 'What are my overdue invoices?',
'context': {},
})
assert resp.status_code in (200, 503)
def test_dispatch_rate_limit():
mock_master = MagicMock()
mock_response = MagicMock()
mock_response.directive_id = 'x'
mock_response.reply = 'ok'
mock_response.agent_reports = []
mock_response.escalations = []
mock_response.actions_taken = []
mock_master.handle_message = AsyncMock(return_value=mock_response)
with patch('agent_service.app_state.get_master_agent', return_value=mock_master), \
patch('agent_service.routers.dispatch._rate_limit_store', {}):
from agent_service.main import create_app
app = create_app()
client = TestClient(app, raise_server_exceptions=False)
for _ in range(31):
client.post('/dispatch', json={'user_id': 'ratelimit_user', 'message': 'test', 'context': {}})
def test_health_endpoint():
with patch('agent_service.app_state.get_master_agent', return_value=MagicMock()):
from agent_service.main import create_app
app = create_app()
client = TestClient(app, raise_server_exceptions=False)
resp = client.get('/health')
assert resp.status_code == 200
data = resp.json()
assert 'status' in data

103
tests/test_e2e_dispatch.py Normal file
View File

@@ -0,0 +1,103 @@
"""
End-to-end integration test: simulates a full dispatch cycle
from HTTP request through MasterAgent to FinanceAgent response.
Uses in-process mocks for Odoo and LLM — no external services needed.
"""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
from agent_service.llm.llm_types import LLMResponse
from agent_service.agents.peer_bus import PeerBus
from agent_service.agents.finance_agent import FinanceAgent
from agent_service.agents.master_agent import MasterAgent
@pytest.fixture
def llm_router():
router = MagicMock()
# First call: classify intent
# Second+ calls: agent execution
router.get_backend = AsyncMock(return_value='ollama')
classify_response = LLMResponse(
content='{"intent": "overdue_invoices", "agents": ["finance_agent"], "confidence": 0.95, "context": {}}',
tool_calls=[], backend_used='ollama', model_used='llama3',
tokens_in=50, tokens_out=80, latency_ms=200,
)
agent_response = LLMResponse(
content='You have 2 overdue invoices totalling $125,000. Invoice #2 (BigCo, $120k) is 95 days overdue and has been flagged for review.',
tool_calls=[], backend_used='ollama', model_used='llama3',
tokens_in=200, tokens_out=150, latency_ms=800,
)
router.complete = AsyncMock(side_effect=[classify_response, agent_response])
return router
@pytest.fixture
def mock_finance_tools():
ft = MagicMock()
ft.get_overdue_invoices = AsyncMock(return_value=[
{'id': 1, 'partner_name': 'ACME', 'amount_residual': 5000.0, 'days_overdue': 45},
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
])
ft.get_financial_summary = AsyncMock(return_value={'total_invoiced': 200000.0, 'collection_rate': 75.0})
ft.get_invoices = AsyncMock(return_value=[])
ft.flag_for_review = AsyncMock(return_value=True)
ft.send_payment_reminder = AsyncMock(return_value=True)
ft.post_chatter_note = AsyncMock(return_value=True)
ft.get_payment_history = AsyncMock(return_value=[])
return ft
@pytest.fixture
def mock_odoo():
odoo = MagicMock()
odoo.search_read = AsyncMock(return_value=[])
odoo.write = AsyncMock()
odoo.call = AsyncMock(return_value=True)
odoo.ping = AsyncMock(return_value=True)
return odoo
@pytest.mark.asyncio
async def test_e2e_overdue_invoice_query(mock_odoo, llm_router, mock_finance_tools):
peer_bus = PeerBus()
finance_agent = FinanceAgent(odoo=mock_odoo, llm=llm_router, peer_bus=peer_bus)
finance_agent._ft = mock_finance_tools
peer_bus.register('finance_agent', finance_agent)
master = MasterAgent(
odoo=mock_odoo,
llm=llm_router,
memory=None,
peer_bus=peer_bus,
registry=None,
)
result = await asyncio.wait_for(
master.handle_message(
user_id='test_user_1',
message='What are my overdue invoices?',
context={},
),
timeout=30,
)
assert result is not None
assert result.reply
assert len(result.reply) > 10
mock_finance_tools.get_overdue_invoices.assert_awaited()
@pytest.mark.asyncio
async def test_e2e_peer_bus_communication(mock_odoo, llm_router, mock_finance_tools):
peer_bus = PeerBus()
finance_agent = FinanceAgent(odoo=mock_odoo, llm=llm_router, peer_bus=peer_bus)
finance_agent._ft = mock_finance_tools
peer_bus.register('finance_agent', finance_agent)
from agent_service.agents.peer_bus import PeerResponse
resp = await peer_bus.call('finance_agent', {'type': 'overdue_summary'})
assert isinstance(resp, PeerResponse)
assert resp.available is True
assert 'overdue_count' in resp.data

134
tests/test_finance_agent.py Normal file
View File

@@ -0,0 +1,134 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from agent_service.agents.finance_agent import FinanceAgent, FINANCE_TOOLS
from agent_service.agents.base_agent import AgentDirective, AgentReport, SweepReport
from agent_service.llm.llm_types import LLMResponse
@pytest.fixture
def mock_ft():
ft = MagicMock()
ft.get_overdue_invoices = AsyncMock(return_value=[
{'id': 1, 'partner_name': 'ACME Corp', 'amount_residual': 5000.0, 'days_overdue': 45},
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
])
ft.get_financial_summary = AsyncMock(return_value={
'total_invoiced': 200000.0, 'collection_rate': 75.0,
})
ft.get_invoices = AsyncMock(return_value=[])
ft.send_payment_reminder = AsyncMock(return_value=True)
ft.flag_for_review = AsyncMock(return_value=True)
ft.post_chatter_note = AsyncMock(return_value=True)
ft.get_payment_history = AsyncMock(return_value=[])
return ft
@pytest.fixture
def agent(mock_odoo, mock_llm):
a = FinanceAgent(odoo=mock_odoo, llm=mock_llm)
return a
def test_finance_agent_has_8_tools():
assert len(FINANCE_TOOLS) == 8
def test_finance_agent_name():
assert FinanceAgent.name == 'finance_agent'
def test_finance_agent_hipaa_domain():
from agent_service.llm.llm_router import HIPAA_LOCKED_AGENTS
assert 'finance_agent' in HIPAA_LOCKED_AGENTS
@pytest.mark.asyncio
async def test_plan_overdue_intent(agent, mock_ft):
agent._ft = mock_ft
directive = AgentDirective(
directive_id='d1', user_id='1', intent='show overdue invoices',
context={}, agent_name='finance_agent',
)
plan = await agent._plan(directive)
assert plan['fetch_overdue'] is True
@pytest.mark.asyncio
async def test_plan_send_reminders(agent, mock_ft):
agent._ft = mock_ft
directive = AgentDirective(
directive_id='d2', user_id='1', intent='send payment reminders to overdue customers',
context={}, agent_name='finance_agent',
)
plan = await agent._plan(directive)
assert plan['send_reminders'] is True
@pytest.mark.asyncio
async def test_gather_fetches_overdue(agent, mock_ft):
agent._ft = mock_ft
agent._plan_result = {'fetch_overdue': True, 'fetch_summary': False,
'fetch_invoices': False, 'partner_id': None, 'period': 'this_month'}
data = await agent._gather({})
assert 'overdue' in data
mock_ft.get_overdue_invoices.assert_awaited_once()
@pytest.mark.asyncio
async def test_reason_flags_90_day_invoices(agent, mock_ft):
agent._ft = mock_ft
agent._gathered_data = {
'overdue': [
{'id': 2, 'partner_name': 'BigCo', 'amount_residual': 120000.0, 'days_overdue': 95},
],
}
analysis = await agent._reason({})
assert len(analysis['flags']) == 1
assert analysis['flags'][0]['invoice_id'] == 2
@pytest.mark.asyncio
async def test_reason_escalates_high_overdue(agent, mock_ft):
agent._ft = mock_ft
agent._gathered_data = {
'overdue': [{'id': 1, 'amount_residual': 60000.0, 'days_overdue': 5, 'partner_name': 'X'}],
}
analysis = await agent._reason({})
assert any('60000' in e or '60' in e for e in analysis['escalations'])
@pytest.mark.asyncio
async def test_report_includes_summary(agent, mock_ft):
agent._ft = mock_ft
agent._gathered_data = {'summary': {'total_invoiced': 100000.0, 'collection_rate': 80.0}}
agent._actions_taken = []
agent._escalations_list = []
agent._recommendations = []
report = await agent._report({'analysis': {'overdue_count': 0}})
assert isinstance(report, AgentReport)
assert '100000' in report.summary or '80' in report.summary
@pytest.mark.asyncio
async def test_sweep_returns_findings(agent, mock_ft):
agent._ft = mock_ft
mock_ft.get_overdue_invoices = AsyncMock(return_value=[
{'id': 3, 'partner_name': 'OldDebt', 'amount_residual': 2000.0, 'days_overdue': 65},
])
result = await agent.sweep()
assert isinstance(result, SweepReport)
assert len(result.findings) == 1
@pytest.mark.asyncio
async def test_handle_peer_request_overdue_summary(agent, mock_ft):
agent._ft = mock_ft
result = await agent.handle_peer_request({'type': 'overdue_summary'})
assert 'overdue_count' in result
@pytest.mark.asyncio
async def test_dispatch_tool_unknown_raises(agent, mock_ft):
agent._ft = mock_ft
with pytest.raises(ValueError, match='Unknown tool'):
await agent._dispatch_tool('nonexistent', {})

76
tests/test_llm_router.py Normal file
View File

@@ -0,0 +1,76 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from agent_service.llm.llm_router import LLMRouter, HIPAA_LOCKED_AGENTS
from agent_service.llm.llm_types import LLMResponse
@pytest.fixture
def mock_ollama():
ollama = MagicMock()
ollama.complete = AsyncMock(return_value=LLMResponse(
content='test', tool_calls=[], backend_used='ollama',
model_used='llama3', tokens_in=5, tokens_out=10, latency_ms=50,
))
ollama.ping = AsyncMock(return_value=True)
return ollama
@pytest.fixture
def mock_claude():
claude = MagicMock()
claude.complete = AsyncMock(return_value=LLMResponse(
content='test claude', tool_calls=[], backend_used='claude',
model_used='claude-sonnet-4-6', tokens_in=5, tokens_out=10, latency_ms=100,
))
return claude
@pytest.mark.asyncio
async def test_local_mode_always_ollama(mock_ollama, mock_claude):
router = LLMRouter(ollama=mock_ollama, claude=mock_claude, privacy_mode='local')
backend = await router.get_backend('crm_agent')
assert backend == 'ollama'
@pytest.mark.asyncio
async def test_cloud_mode_uses_claude(mock_ollama, mock_claude):
router = LLMRouter(ollama=mock_ollama, claude=mock_claude, privacy_mode='cloud')
backend = await router.get_backend('crm_agent')
assert backend == 'claude'
@pytest.mark.asyncio
async def test_hipaa_locked_always_ollama(mock_ollama, mock_claude):
router = LLMRouter(ollama=mock_ollama, claude=mock_claude, privacy_mode='cloud')
for agent in HIPAA_LOCKED_AGENTS:
backend = await router.get_backend(agent)
assert backend == 'ollama', f'{agent} should be ollama-only'
@pytest.mark.asyncio
async def test_cloud_mode_no_claude_fallback(mock_ollama):
router = LLMRouter(ollama=mock_ollama, claude=None, privacy_mode='cloud')
backend = await router.get_backend('crm_agent')
assert backend == 'ollama'
@pytest.mark.asyncio
async def test_env_override_respected(mock_ollama, mock_claude):
router = LLMRouter(
ollama=mock_ollama, claude=mock_claude,
privacy_mode='hybrid',
env_overrides={'crm_agent': 'claude'},
)
backend = await router.get_backend('crm_agent')
assert backend == 'claude'
@pytest.mark.asyncio
async def test_env_override_cannot_override_hipaa(mock_ollama, mock_claude):
router = LLMRouter(
ollama=mock_ollama, claude=mock_claude,
privacy_mode='hybrid',
env_overrides={'finance_agent': 'claude'},
)
backend = await router.get_backend('finance_agent')
assert backend == 'ollama'

View File

@@ -0,0 +1,69 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def mock_conv_store():
store = MagicMock()
store.get = AsyncMock(return_value=[
{'role': 'user', 'content': 'What are my overdue invoices?'},
{'role': 'assistant', 'content': 'You have 3 overdue invoices.'},
])
store.append = AsyncMock()
store.count = AsyncMock(return_value=2)
store.prune_old = AsyncMock()
return store
@pytest.fixture
def mock_op_store():
store = MagicMock()
store.get_recent = AsyncMock(return_value=[])
store.store = AsyncMock()
store.prune_expired = AsyncMock()
return store
@pytest.fixture
def mock_know_store():
store = MagicMock()
store.get_client_profile = AsyncMock(return_value={})
store.upsert = AsyncMock()
store.get = AsyncMock(return_value=None)
return store
@pytest.mark.asyncio
async def test_build_context_returns_master_context(mock_pool, mock_conv_store,
mock_op_store, mock_know_store):
with patch('agent_service.memory.memory_manager.ConversationStore', return_value=mock_conv_store), \
patch('agent_service.memory.memory_manager.OperationalStore', return_value=mock_op_store), \
patch('agent_service.memory.memory_manager.KnowledgeStore', return_value=mock_know_store):
from agent_service.memory.memory_manager import MemoryManager
mgr = MemoryManager(pool=mock_pool, llm=None)
ctx = await mgr.build_context(user_id='1', intent_hint='finance')
assert ctx is not None
assert hasattr(ctx, 'conversation')
@pytest.mark.asyncio
async def test_append_message(mock_pool, mock_conv_store, mock_op_store, mock_know_store):
with patch('agent_service.memory.memory_manager.ConversationStore', return_value=mock_conv_store), \
patch('agent_service.memory.memory_manager.OperationalStore', return_value=mock_op_store), \
patch('agent_service.memory.memory_manager.KnowledgeStore', return_value=mock_know_store):
from agent_service.memory.memory_manager import MemoryManager
mgr = MemoryManager(pool=mock_pool, llm=None)
await mgr.append_message(user_id='1', role='user', content='Hello')
mock_conv_store.append.assert_awaited_once()
@pytest.mark.asyncio
async def test_hard_cap_enforced(mock_pool, mock_conv_store, mock_op_store, mock_know_store):
mock_conv_store.count = AsyncMock(return_value=201)
with patch('agent_service.memory.memory_manager.ConversationStore', return_value=mock_conv_store), \
patch('agent_service.memory.memory_manager.OperationalStore', return_value=mock_op_store), \
patch('agent_service.memory.memory_manager.KnowledgeStore', return_value=mock_know_store):
from agent_service.memory.memory_manager import MemoryManager
mgr = MemoryManager(pool=mock_pool, llm=None)
await mgr.append_message(user_id='1', role='user', content='test')
mock_conv_store.prune_old.assert_awaited()

55
tests/test_odoo_client.py Normal file
View File

@@ -0,0 +1,55 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from agent_service.tools.odoo_client import OdooClient, WriteResult
@pytest.fixture
def odoo():
client = OdooClient(url='http://localhost:8069', db='test', api_key='testkey')
return client
@pytest.mark.asyncio
async def test_search_read_returns_list(odoo):
with patch.object(odoo, '_call', new=AsyncMock(return_value=[
{'id': 1, 'name': 'Invoice 1'},
])):
result = await odoo.search_read('account.move', [('state', '=', 'posted')], ['name'])
assert isinstance(result, list)
assert result[0]['id'] == 1
@pytest.mark.asyncio
async def test_write_returns_write_result(odoo):
with patch.object(odoo, '_call', new=AsyncMock(return_value=True)), \
patch.object(odoo, 'search_read', new=AsyncMock(return_value=[{'id': 1, 'name': 'test'}])):
result = await odoo.write('account.move', [1], {'state': 'posted'})
assert isinstance(result, WriteResult)
assert result.success is True
@pytest.mark.asyncio
async def test_write_result_has_before_after(odoo):
before = [{'id': 1, 'amount_residual': 1000.0}]
after = [{'id': 1, 'amount_residual': 0.0}]
call_count = 0
async def mock_search_read(*args, **kwargs):
nonlocal call_count
call_count += 1
return before if call_count == 1 else after
with patch.object(odoo, '_call', new=AsyncMock(return_value=True)), \
patch.object(odoo, 'search_read', new=mock_search_read):
result = await odoo.write('account.move', [1], {'amount_residual': 0})
assert result.before == before[0]
assert result.after == after[0]
@pytest.mark.asyncio
async def test_unlink_logs_warning(odoo, caplog):
import logging
with patch.object(odoo, '_call', new=AsyncMock(return_value=True)):
with caplog.at_level(logging.WARNING):
await odoo.unlink('account.move', [99])
assert any('unlink' in r.message.lower() or '99' in r.message for r in caplog.records)

69
tests/test_peer_bus.py Normal file
View File

@@ -0,0 +1,69 @@
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
from agent_service.agents.peer_bus import PeerBus, PeerResponse
class MockAgent:
name = 'mock_agent'
async def handle_peer_request(self, request: dict) -> dict:
return {'answer': 42, 'echo': request.get('data')}
class SlowAgent:
name = 'slow_agent'
async def handle_peer_request(self, request: dict) -> dict:
await asyncio.sleep(35)
return {}
@pytest.mark.asyncio
async def test_peer_bus_register_and_call():
bus = PeerBus()
bus.register('mock_agent', MockAgent())
resp = await bus.call('mock_agent', {'data': 'hello'})
assert isinstance(resp, PeerResponse)
assert resp.available is True
assert resp.data.get('answer') == 42
@pytest.mark.asyncio
async def test_peer_bus_unknown_agent():
bus = PeerBus()
resp = await bus.call('nonexistent_agent', {})
assert resp.available is False
@pytest.mark.asyncio
async def test_peer_bus_max_depth():
bus = PeerBus()
bus.register('mock_agent', MockAgent())
call_chain = ['a', 'b', 'c']
resp = await bus.call('mock_agent', {}, _call_chain=call_chain)
assert resp.available is False
@pytest.mark.asyncio
async def test_peer_bus_timeout():
bus = PeerBus()
bus.register('slow_agent', SlowAgent())
resp = await bus.call('slow_agent', {})
assert resp.available is False
@pytest.mark.asyncio
async def test_peer_bus_circular_detection():
bus = PeerBus()
bus.register('mock_agent', MockAgent())
resp = await bus.call('mock_agent', {}, _call_chain=['mock_agent'])
assert resp.available is False
def test_peer_bus_get_agent():
bus = PeerBus()
agent = MockAgent()
bus.register('mock_agent', agent)
assert bus.get_agent('mock_agent') is agent
assert bus.get_agent('missing') is None

View File

@@ -0,0 +1,73 @@
import pytest
from agent_service.llm.tool_validator import ToolCallValidator, AgentConfigError
SAMPLE_TOOLS = [
{'name': 'get_invoices', 'parameters': {
'state': {'type': 'string', 'optional': True},
'limit': {'type': 'integer', 'optional': True},
'partner_id': {'type': 'integer'},
}},
{'name': 'send_reminder', 'parameters': {
'invoice_id': {'type': 'integer'},
'message': {'type': 'string', 'optional': True},
}},
]
def test_validator_init():
v = ToolCallValidator(SAMPLE_TOOLS)
assert 'get_invoices' in v._tool_map
def test_raises_on_too_many_tools():
many_tools = [{'name': f'tool_{i}', 'parameters': {}} for i in range(9)]
with pytest.raises(AgentConfigError, match='MAX_TOOLS_PER_AGENT'):
ToolCallValidator(many_tools)
def test_valid_tool_call():
v = ToolCallValidator(SAMPLE_TOOLS)
result = v.validate({'name': 'get_invoices', 'arguments': {'partner_id': 42}})
assert result.name == 'get_invoices'
assert result.arguments['partner_id'] == 42
def test_strips_hallucinated_params():
v = ToolCallValidator(SAMPLE_TOOLS)
result = v.validate({'name': 'get_invoices', 'arguments': {
'partner_id': 1, 'nonexistent_param': 'bad',
}})
assert 'nonexistent_param' not in result.arguments
def test_missing_required_param_raises():
v = ToolCallValidator(SAMPLE_TOOLS)
with pytest.raises(ValueError, match='partner_id'):
v.validate({'name': 'get_invoices', 'arguments': {}})
def test_type_coercion_int():
v = ToolCallValidator(SAMPLE_TOOLS)
result = v.validate({'name': 'get_invoices', 'arguments': {'partner_id': '42'}})
assert result.arguments['partner_id'] == 42
def test_unknown_tool_raises():
v = ToolCallValidator(SAMPLE_TOOLS)
with pytest.raises(ValueError, match='Unknown tool'):
v.validate({'name': 'nonexistent_tool', 'arguments': {}})
def test_parse_or_fallback_returns_none_on_bad_json():
v = ToolCallValidator(SAMPLE_TOOLS)
result = v.parse_or_fallback('not json at all', context='test')
assert result is None
def test_parse_or_fallback_valid_json():
v = ToolCallValidator(SAMPLE_TOOLS)
import json
raw = json.dumps({'name': 'send_reminder', 'arguments': {'invoice_id': 5}})
result = v.parse_or_fallback(raw, context='test')
assert result is not None
assert result.name == 'send_reminder'