diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e66e2e --- /dev/null +++ b/README.md @@ -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 diff --git a/agent_service/agents/sweep_coordinator.py b/agent_service/agents/sweep_coordinator.py new file mode 100644 index 0000000..5773c2f --- /dev/null +++ b/agent_service/agents/sweep_coordinator.py @@ -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) diff --git a/agent_service/logging_utils/__init__.py b/agent_service/logging_utils/__init__.py new file mode 100644 index 0000000..2b80b26 --- /dev/null +++ b/agent_service/logging_utils/__init__.py @@ -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'] diff --git a/agent_service/logging_utils/structured.py b/agent_service/logging_utils/structured.py new file mode 100644 index 0000000..909cfe7 --- /dev/null +++ b/agent_service/logging_utils/structured.py @@ -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) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6de1441 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test +python_functions = test_ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c02af1b --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_dispatch_router.py b/tests/test_dispatch_router.py new file mode 100644 index 0000000..13e3b3f --- /dev/null +++ b/tests/test_dispatch_router.py @@ -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 diff --git a/tests/test_e2e_dispatch.py b/tests/test_e2e_dispatch.py new file mode 100644 index 0000000..a1223a1 --- /dev/null +++ b/tests/test_e2e_dispatch.py @@ -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 diff --git a/tests/test_finance_agent.py b/tests/test_finance_agent.py new file mode 100644 index 0000000..feb4a55 --- /dev/null +++ b/tests/test_finance_agent.py @@ -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', {}) diff --git a/tests/test_llm_router.py b/tests/test_llm_router.py new file mode 100644 index 0000000..51b4151 --- /dev/null +++ b/tests/test_llm_router.py @@ -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' diff --git a/tests/test_memory_manager.py b/tests/test_memory_manager.py new file mode 100644 index 0000000..6bb61f5 --- /dev/null +++ b/tests/test_memory_manager.py @@ -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() diff --git a/tests/test_odoo_client.py b/tests/test_odoo_client.py new file mode 100644 index 0000000..2464aa5 --- /dev/null +++ b/tests/test_odoo_client.py @@ -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) diff --git a/tests/test_peer_bus.py b/tests/test_peer_bus.py new file mode 100644 index 0000000..e6ffe65 --- /dev/null +++ b/tests/test_peer_bus.py @@ -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 diff --git a/tests/test_tool_validator.py b/tests/test_tool_validator.py new file mode 100644 index 0000000..116ad7b --- /dev/null +++ b/tests/test_tool_validator.py @@ -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'