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:
154
README.md
Normal file
154
README.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# ActiveBlue AI
|
||||||
|
|
||||||
|
Multi-agent AI system integrated with Odoo 18 Community Edition.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Odoo 18 (ai.activeblue.net)
|
||||||
|
└── activeblue_ai module
|
||||||
|
├── OWL2 systray brain icon + slide-in chat panel
|
||||||
|
├── Models: ab.ai.bot, ab.ai.directive, ab.ai.log, ab.ai.agent.registry
|
||||||
|
└── Controllers: /ai/chat, /ai/webhook/callback, /ai/health, /ai/approval/*
|
||||||
|
|
||||||
|
FastAPI Agent Service (192.168.2.47:8001)
|
||||||
|
├── POST /dispatch — route user message to MasterAgent
|
||||||
|
├── GET/POST /approval/* — human approval workflow
|
||||||
|
├── GET/POST /registry/* — agent registry + LLM backend overrides
|
||||||
|
├── POST /sweep — trigger proactive agent sweeps
|
||||||
|
└── GET /health — service health + Odoo/Ollama status
|
||||||
|
|
||||||
|
MasterAgent (singleton)
|
||||||
|
├── Classifies intent via LLM
|
||||||
|
├── Routes to specialist agents in parallel (asyncio.gather)
|
||||||
|
├── Manages 3-tier memory (conversation / operational / knowledge)
|
||||||
|
└── Synthesises responses
|
||||||
|
|
||||||
|
Specialist Agents (8, stateless):
|
||||||
|
finance_agent, accounting_agent, crm_agent, sales_agent,
|
||||||
|
project_agent, elearning_agent, expenses_agent, employees_agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Privacy Modes
|
||||||
|
|
||||||
|
| Mode | Behaviour |
|
||||||
|
|---------|--------------------------------------------|
|
||||||
|
| `local` | Ollama only for all agents |
|
||||||
|
| `hybrid`| Per-agent override (DB → env → fallback) |
|
||||||
|
| `cloud` | Claude for non-HIPAA agents |
|
||||||
|
|
||||||
|
**HIPAA-locked agents** (always Ollama, no exceptions):
|
||||||
|
`finance_agent`, `accounting_agent`, `employees_agent`, `expenses_agent`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone and configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://192.168.1.64:3000/tocmo0nlord/odoo-ai.git
|
||||||
|
cd odoo-ai
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env — set POSTGRES_PASSWORD, ODOO_API_KEY, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Odoo 18
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.odoo.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the Agent Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn agent_service.main:app --reload --port 8001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run database migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent_service/migrations
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Install Odoo module
|
||||||
|
|
||||||
|
In Odoo → Settings → Apps → search "ActiveBlue AI" → Install.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for the full list. Key variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `ODOO_URL` | Odoo base URL (e.g. `http://ai.activeblue.net`) |
|
||||||
|
| `ODOO_API_KEY` | Odoo user API key |
|
||||||
|
| `OLLAMA_URL` | Ollama API URL (e.g. `http://192.168.2.47:11434`) |
|
||||||
|
| `ANTHROPIC_API_KEY` | Required only if `LLM_PRIVACY_MODE=cloud` or `hybrid` |
|
||||||
|
| `LLM_PRIVACY_MODE` | `local` / `hybrid` / `cloud` (default: `local`) |
|
||||||
|
| `POSTGRES_PASSWORD` | Required — no default |
|
||||||
|
| `WEBHOOK_SECRET` | Shared secret between Odoo and agent service |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest pytest-asyncio
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
odoo-ai/
|
||||||
|
├── agent_service/
|
||||||
|
│ ├── agents/ # MasterAgent + 8 specialist agents + PeerBus + SweepCoordinator
|
||||||
|
│ ├── llm/ # OllamaBackend, ClaudeBackend, LLMRouter, ToolCallValidator
|
||||||
|
│ ├── memory/ # ConversationStore, OperationalStore, KnowledgeStore, MemoryManager
|
||||||
|
│ ├── tools/ # OdooClient + per-domain tools (finance, crm, sales, ...)
|
||||||
|
│ ├── routers/ # FastAPI routers (dispatch, approval, registry, sweep, health)
|
||||||
|
│ ├── prompts/ # System prompts for each agent
|
||||||
|
│ ├── migrations/ # Alembic migrations (7 tables)
|
||||||
|
│ ├── logging_utils/ # Structured JSON logging + Loki push
|
||||||
|
│ ├── config.py # pydantic-settings
|
||||||
|
│ ├── app_state.py # Global singletons
|
||||||
|
│ └── main.py # FastAPI app + lifespan startup
|
||||||
|
├── addons/
|
||||||
|
│ └── activeblue_ai/ # Odoo 18 module
|
||||||
|
│ ├── models/ # ab.ai.bot, ab.ai.directive, ab.ai.log, ab.ai.agent.registry
|
||||||
|
│ ├── controllers/ # webhook, health_proxy, approval + chat
|
||||||
|
│ ├── views/ # XML views + menus
|
||||||
|
│ ├── security/ # groups + ACL
|
||||||
|
│ ├── data/ # cron jobs
|
||||||
|
│ └── static/ # OWL2 JS + CSS + XML templates
|
||||||
|
├── research/ # Per-domain research notes
|
||||||
|
├── tests/ # pytest test suite
|
||||||
|
├── docker-compose.odoo.yml # Odoo 18 + PostgreSQL 15
|
||||||
|
├── docker-compose.yml # Agent service + PostgreSQL 15
|
||||||
|
├── Dockerfile
|
||||||
|
├── requirements.txt
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Tool Limits
|
||||||
|
|
||||||
|
Each specialist agent is capped at **8 tools** (`MAX_TOOLS_PER_AGENT`). The `ToolCallValidator` raises `AgentConfigError` at startup if exceeded.
|
||||||
|
|
||||||
|
## Memory Architecture
|
||||||
|
|
||||||
|
| Tier | Store | TTL | Scope |
|
||||||
|
|------|-------|-----|-------|
|
||||||
|
| Tier 1 | `ab_conversation_memory` | Hard cap: 200 rows/user | Per user |
|
||||||
|
| Tier 2 | `ab_operational_memory` | 90 days | Per agent+scope |
|
||||||
|
| Tier 3 | `ab_knowledge_store` | Permanent | Entity-keyed |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPL-3.0
|
||||||
100
agent_service/agents/sweep_coordinator.py
Normal file
100
agent_service/agents/sweep_coordinator.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALL_AGENT_NAMES = [
|
||||||
|
'finance_agent', 'accounting_agent', 'crm_agent', 'sales_agent',
|
||||||
|
'project_agent', 'elearning_agent', 'expenses_agent', 'employees_agent',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SweepCoordinatorResult:
|
||||||
|
total_agents: int = 0
|
||||||
|
completed: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
total_findings: int = 0
|
||||||
|
total_actions: int = 0
|
||||||
|
agent_results: list = field(default_factory=list)
|
||||||
|
errors: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SweepCoordinator:
|
||||||
|
def __init__(self, peer_bus=None):
|
||||||
|
self._peer_bus = peer_bus
|
||||||
|
|
||||||
|
async def run_sweep(self, agents: Optional[list[str]] = None) -> dict:
|
||||||
|
names = agents or ALL_AGENT_NAMES
|
||||||
|
result = SweepCoordinatorResult(total_agents=len(names))
|
||||||
|
logger.info('SweepCoordinator: starting sweep for %d agents', len(names))
|
||||||
|
|
||||||
|
tasks = {}
|
||||||
|
for name in names:
|
||||||
|
agent = self._get_agent(name)
|
||||||
|
if agent is None:
|
||||||
|
logger.debug('Sweep: agent %s not available, skipping', name)
|
||||||
|
result.failed += 1
|
||||||
|
result.errors[name] = 'agent not available'
|
||||||
|
continue
|
||||||
|
tasks[name] = asyncio.create_task(self._run_agent_sweep(name, agent))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
logger.warning('SweepCoordinator: no agents available for sweep')
|
||||||
|
return result.__dict__
|
||||||
|
|
||||||
|
done, _ = await asyncio.wait(list(tasks.values()), timeout=300)
|
||||||
|
|
||||||
|
for name, task in tasks.items():
|
||||||
|
if task not in done:
|
||||||
|
logger.warning('Sweep timeout for agent %s', name)
|
||||||
|
result.failed += 1
|
||||||
|
result.errors[name] = 'timeout'
|
||||||
|
task.cancel()
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sweep_report = task.result()
|
||||||
|
result.completed += 1
|
||||||
|
result.total_findings += len(sweep_report.findings)
|
||||||
|
result.total_actions += len(sweep_report.actions)
|
||||||
|
result.agent_results.append({
|
||||||
|
'agent': name,
|
||||||
|
'findings': sweep_report.findings,
|
||||||
|
'actions': sweep_report.actions,
|
||||||
|
'summary': sweep_report.summary,
|
||||||
|
'error': sweep_report.error,
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
'Sweep %s: %d findings, %d actions',
|
||||||
|
name, len(sweep_report.findings), len(sweep_report.actions),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Sweep result error for %s: %s', name, exc)
|
||||||
|
result.failed += 1
|
||||||
|
result.errors[name] = str(exc)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'SweepCoordinator done: %d completed, %d failed, %d findings, %d actions',
|
||||||
|
result.completed, result.failed, result.total_findings, result.total_actions,
|
||||||
|
)
|
||||||
|
return result.__dict__
|
||||||
|
|
||||||
|
async def _run_agent_sweep(self, name: str, agent):
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(agent.sweep(), timeout=60)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
from .base_agent import SweepReport
|
||||||
|
logger.warning('Agent sweep timed out: %s', name)
|
||||||
|
return SweepReport(agent=name, findings=[], actions=[], error='timeout')
|
||||||
|
except Exception as exc:
|
||||||
|
from .base_agent import SweepReport
|
||||||
|
logger.error('Agent sweep error %s: %s', name, exc)
|
||||||
|
return SweepReport(agent=name, findings=[], actions=[], error=str(exc))
|
||||||
|
|
||||||
|
def _get_agent(self, name: str):
|
||||||
|
if self._peer_bus is None:
|
||||||
|
return None
|
||||||
|
return self._peer_bus.get_agent(name)
|
||||||
3
agent_service/logging_utils/__init__.py
Normal file
3
agent_service/logging_utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .structured import configure_logging, get_logger, log_directive_event, push_to_loki
|
||||||
|
|
||||||
|
__all__ = ['configure_logging', 'get_logger', 'log_directive_event', 'push_to_loki']
|
||||||
93
agent_service/logging_utils/structured.py
Normal file
93
agent_service/logging_utils/structured.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
_loki_url: Optional[str] = None
|
||||||
|
_service_name = 'activeblue-ai'
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Emit log records as single-line JSON for Loki/structured log ingestion."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_entry: dict[str, Any] = {
|
||||||
|
'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(record.created)),
|
||||||
|
'level': record.levelname,
|
||||||
|
'logger': record.name,
|
||||||
|
'msg': record.getMessage(),
|
||||||
|
'service': _service_name,
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
log_entry['exception'] = self.formatException(record.exc_info)
|
||||||
|
for key in ('directive_id', 'agent', 'user_id', 'action', 'duration_ms'):
|
||||||
|
val = getattr(record, key, None)
|
||||||
|
if val is not None:
|
||||||
|
log_entry[key] = val
|
||||||
|
return json.dumps(log_entry, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = 'INFO', fmt: str = 'json', loki_url: str = '') -> None:
|
||||||
|
global _loki_url
|
||||||
|
_loki_url = loki_url or None
|
||||||
|
|
||||||
|
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(numeric_level)
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
if fmt == 'json':
|
||||||
|
handler.setFormatter(JSONFormatter())
|
||||||
|
else:
|
||||||
|
handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s %(name)-20s %(levelname)-8s %(message)s',
|
||||||
|
))
|
||||||
|
root.addHandler(handler)
|
||||||
|
|
||||||
|
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)
|
||||||
|
logging.getLogger('asyncpg').setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
|
||||||
|
class _DirectiveAdapter(logging.LoggerAdapter):
|
||||||
|
def process(self, msg, kwargs):
|
||||||
|
kwargs.setdefault('extra', {})
|
||||||
|
kwargs['extra'].update(self.extra)
|
||||||
|
return msg, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def log_directive_event(logger: logging.Logger, level: str, msg: str,
|
||||||
|
directive_id: str = '', agent: str = '',
|
||||||
|
user_id: str = '', action: str = '',
|
||||||
|
duration_ms: int = 0) -> None:
|
||||||
|
extra = {}
|
||||||
|
if directive_id:
|
||||||
|
extra['directive_id'] = directive_id
|
||||||
|
if agent:
|
||||||
|
extra['agent'] = agent
|
||||||
|
if user_id:
|
||||||
|
extra['user_id'] = user_id
|
||||||
|
if action:
|
||||||
|
extra['action'] = action
|
||||||
|
if duration_ms:
|
||||||
|
extra['duration_ms'] = duration_ms
|
||||||
|
getattr(logger, level.lower(), logger.info)(msg, extra=extra)
|
||||||
|
|
||||||
|
|
||||||
|
async def push_to_loki(labels: dict, entries: list[dict]) -> None:
|
||||||
|
if not _loki_url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
streams = [{'stream': labels, 'values': [[str(int(e['ts'] * 1e9)), e['line']] for e in entries]}]
|
||||||
|
payload = {'streams': streams}
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
await client.post(f'{_loki_url}/loki/api/v1/push', json=payload)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.getLogger(__name__).debug('Loki push failed: %s', exc)
|
||||||
6
pytest.ini
Normal file
6
pytest.ini
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test
|
||||||
|
python_functions = test_
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
47
tests/conftest.py
Normal file
47
tests/conftest.py
Normal 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
|
||||||
60
tests/test_dispatch_router.py
Normal file
60
tests/test_dispatch_router.py
Normal 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
103
tests/test_e2e_dispatch.py
Normal 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
134
tests/test_finance_agent.py
Normal 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
76
tests/test_llm_router.py
Normal 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'
|
||||||
69
tests/test_memory_manager.py
Normal file
69
tests/test_memory_manager.py
Normal 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
55
tests/test_odoo_client.py
Normal 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
69
tests/test_peer_bus.py
Normal 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
|
||||||
73
tests/test_tool_validator.py
Normal file
73
tests/test_tool_validator.py
Normal 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'
|
||||||
Reference in New Issue
Block a user