feat(service): add FastAPI agent service with 5 routers and Docker setup
- config.py: pydantic-settings with all env vars, privacy mode, per-agent overrides - app_state.py: global singletons (pool, master agent, registry, llm_router, sweep) - main.py: FastAPI lifespan startup — DB pool, LLM router, Odoo client, agents, master - routers/dispatch.py: POST /dispatch with rate limiting and webhook secret auth - routers/approval.py: GET /approval/pending, POST /approval/respond - routers/registry.py: GET/POST /registry/agents, POST /registry/backend overrides - routers/sweep.py: POST /sweep trigger, GET /sweep/status - routers/health.py: GET /health, GET /health/detailed (DB/Odoo/Ollama checks) - requirements.txt: pinned deps (fastapi, uvicorn, asyncpg, anthropic, alembic) - Dockerfile: python:3.11-slim, single uvicorn worker - docker-compose.yml: agent-service + postgres:15, bound to 192.168.2.47:8001 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="ActiveBlue AI Agent Service"
|
||||||
|
LABEL org.opencontainers.image.version="0.1.0"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY agent_service/ ./agent_service/
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
CMD ["uvicorn", "agent_service.main:app", "--host", "0.0.0.0", "--port", "8001", "--workers", "1"]
|
||||||
57
agent_service/app_state.py
Normal file
57
agent_service/app_state.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Global application state — singletons accessed by routers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
_db_pool: Optional['asyncpg.Pool'] = None
|
||||||
|
_master_agent = None
|
||||||
|
_agent_registry = None
|
||||||
|
_llm_router = None
|
||||||
|
_sweep_coordinator = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_db_pool(pool) -> None:
|
||||||
|
global _db_pool
|
||||||
|
_db_pool = pool
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_pool():
|
||||||
|
return _db_pool
|
||||||
|
|
||||||
|
|
||||||
|
def set_master_agent(agent) -> None:
|
||||||
|
global _master_agent
|
||||||
|
_master_agent = agent
|
||||||
|
|
||||||
|
|
||||||
|
def get_master_agent():
|
||||||
|
return _master_agent
|
||||||
|
|
||||||
|
|
||||||
|
def set_agent_registry(registry) -> None:
|
||||||
|
global _agent_registry
|
||||||
|
_agent_registry = registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_registry():
|
||||||
|
return _agent_registry
|
||||||
|
|
||||||
|
|
||||||
|
def set_llm_router(router) -> None:
|
||||||
|
global _llm_router
|
||||||
|
_llm_router = router
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_router():
|
||||||
|
return _llm_router
|
||||||
|
|
||||||
|
|
||||||
|
def set_sweep_coordinator(coordinator) -> None:
|
||||||
|
global _sweep_coordinator
|
||||||
|
_sweep_coordinator = coordinator
|
||||||
|
|
||||||
|
|
||||||
|
def get_sweep_coordinator():
|
||||||
|
return _sweep_coordinator
|
||||||
92
agent_service/config.py
Normal file
92
agent_service/config.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Odoo
|
||||||
|
odoo_url: str = 'http://localhost:8069'
|
||||||
|
odoo_db: str = 'odoo'
|
||||||
|
odoo_api_key: str = ''
|
||||||
|
|
||||||
|
# Ollama
|
||||||
|
ollama_url: str = 'http://localhost:11434'
|
||||||
|
ollama_model: str = 'llama3'
|
||||||
|
ollama_timeout: int = 120
|
||||||
|
|
||||||
|
# Anthropic / Claude
|
||||||
|
anthropic_api_key: str = ''
|
||||||
|
claude_model: str = 'claude-sonnet-4-6'
|
||||||
|
|
||||||
|
# Privacy
|
||||||
|
llm_privacy_mode: str = 'local' # local | hybrid | cloud
|
||||||
|
|
||||||
|
# Per-agent backend overrides (env: AGENT_BACKEND_FINANCE=claude)
|
||||||
|
agent_backend_finance: str = ''
|
||||||
|
agent_backend_accounting: str = ''
|
||||||
|
agent_backend_crm: str = ''
|
||||||
|
agent_backend_sales: str = ''
|
||||||
|
agent_backend_project: str = ''
|
||||||
|
agent_backend_elearning: str = ''
|
||||||
|
agent_backend_expenses: str = ''
|
||||||
|
agent_backend_employees: str = ''
|
||||||
|
|
||||||
|
# Service
|
||||||
|
agent_service_port: int = 8001
|
||||||
|
webhook_secret: str = ''
|
||||||
|
allowed_callback_ip: str = ''
|
||||||
|
|
||||||
|
# Postgres
|
||||||
|
postgres_host: str = 'localhost'
|
||||||
|
postgres_port: int = 5432
|
||||||
|
postgres_db: str = 'activeblue_ai'
|
||||||
|
postgres_user: str = 'activeblue'
|
||||||
|
postgres_password: str = ''
|
||||||
|
postgres_min_connections: int = 2
|
||||||
|
postgres_max_connections: int = 10
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
dispatch_rate_limit_per_user: int = 30 # requests per minute
|
||||||
|
directive_timeout_minutes: int = 10
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: str = 'INFO'
|
||||||
|
log_format: str = 'json'
|
||||||
|
loki_url: str = ''
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = '.env'
|
||||||
|
env_file_encoding = 'utf-8'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def postgres_dsn(self) -> str:
|
||||||
|
return (
|
||||||
|
f'postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}'
|
||||||
|
f'@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}'
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def postgres_asyncpg_dsn(self) -> str:
|
||||||
|
return (
|
||||||
|
f'asyncpg://{self.postgres_user}:{self.postgres_password}'
|
||||||
|
f'@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def agent_backend_override(self, agent_name: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
'finance_agent': self.agent_backend_finance,
|
||||||
|
'accounting_agent': self.agent_backend_accounting,
|
||||||
|
'crm_agent': self.agent_backend_crm,
|
||||||
|
'sales_agent': self.agent_backend_sales,
|
||||||
|
'project_agent': self.agent_backend_project,
|
||||||
|
'elearning_agent': self.agent_backend_elearning,
|
||||||
|
'expenses_agent': self.agent_backend_expenses,
|
||||||
|
'employees_agent': self.agent_backend_employees,
|
||||||
|
}
|
||||||
|
return mapping.get(agent_name, '')
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
238
agent_service/main.py
Normal file
238
agent_service/main.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from . import app_state
|
||||||
|
from .routers import dispatch, approval, registry, sweep, health
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_db(settings) -> asyncpg.Pool:
|
||||||
|
pool = await asyncpg.create_pool(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
database=settings.postgres_db,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
min_size=settings.postgres_min_connections,
|
||||||
|
max_size=settings.postgres_max_connections,
|
||||||
|
max_inactive_connection_lifetime=300,
|
||||||
|
)
|
||||||
|
logger.info('DB pool created (min=%d max=%d)', settings.postgres_min_connections, settings.postgres_max_connections)
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_health_loop(pool: asyncpg.Pool) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
try:
|
||||||
|
async with pool.acquire(timeout=5) as conn:
|
||||||
|
await conn.fetchval('SELECT 1')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('DB health check failed: %s', exc)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
settings = get_settings()
|
||||||
|
_configure_logging(settings)
|
||||||
|
|
||||||
|
# 1. Database
|
||||||
|
try:
|
||||||
|
pool = await _init_db(settings)
|
||||||
|
app_state.set_db_pool(pool)
|
||||||
|
asyncio.create_task(_db_health_loop(pool))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to connect to database: %s', exc)
|
||||||
|
pool = None
|
||||||
|
|
||||||
|
# 2. LLM Router
|
||||||
|
try:
|
||||||
|
from .llm.ollama_backend import OllamaBackend
|
||||||
|
from .llm.llm_config_store import LLMConfigStore
|
||||||
|
from .llm.llm_router import LLMRouter
|
||||||
|
|
||||||
|
ollama = OllamaBackend(
|
||||||
|
base_url=settings.ollama_url,
|
||||||
|
model=settings.ollama_model,
|
||||||
|
timeout=settings.ollama_timeout,
|
||||||
|
)
|
||||||
|
config_store = LLMConfigStore(pool) if pool else None
|
||||||
|
claude = None
|
||||||
|
if settings.llm_privacy_mode != 'local' and settings.anthropic_api_key:
|
||||||
|
from .llm.claude_backend import ClaudeBackend
|
||||||
|
claude = ClaudeBackend(api_key=settings.anthropic_api_key, model=settings.claude_model)
|
||||||
|
|
||||||
|
llm_router = LLMRouter(
|
||||||
|
ollama=ollama,
|
||||||
|
claude=claude,
|
||||||
|
config_store=config_store,
|
||||||
|
privacy_mode=settings.llm_privacy_mode,
|
||||||
|
env_overrides={
|
||||||
|
name: settings.agent_backend_override(name)
|
||||||
|
for name in [
|
||||||
|
'finance_agent', 'accounting_agent', 'crm_agent', 'sales_agent',
|
||||||
|
'project_agent', 'elearning_agent', 'expenses_agent', 'employees_agent',
|
||||||
|
]
|
||||||
|
if settings.agent_backend_override(name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app_state.set_llm_router(llm_router)
|
||||||
|
logger.info('LLM router ready (mode=%s)', settings.llm_privacy_mode)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to init LLM router: %s', exc)
|
||||||
|
llm_router = None
|
||||||
|
|
||||||
|
# 3. Odoo client
|
||||||
|
try:
|
||||||
|
from .tools.odoo_client import OdooClient
|
||||||
|
odoo = OdooClient(
|
||||||
|
url=settings.odoo_url,
|
||||||
|
db=settings.odoo_db,
|
||||||
|
api_key=settings.odoo_api_key,
|
||||||
|
)
|
||||||
|
logger.info('Odoo client initialised (%s)', settings.odoo_url)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to init Odoo client: %s', exc)
|
||||||
|
odoo = None
|
||||||
|
|
||||||
|
# 4. Agent registry
|
||||||
|
try:
|
||||||
|
from .agents.registry import AgentRegistry
|
||||||
|
agent_registry = AgentRegistry(odoo=odoo, pool=pool)
|
||||||
|
app_state.set_agent_registry(agent_registry)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to init agent registry: %s', exc)
|
||||||
|
agent_registry = None
|
||||||
|
|
||||||
|
# 5. Memory manager
|
||||||
|
try:
|
||||||
|
from .memory.memory_manager import MemoryManager
|
||||||
|
memory_mgr = MemoryManager(pool=pool, llm=llm_router) if pool else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to init memory manager: %s', exc)
|
||||||
|
memory_mgr = None
|
||||||
|
|
||||||
|
# 6. Peer bus + specialist agents
|
||||||
|
try:
|
||||||
|
from .agents.peer_bus import PeerBus
|
||||||
|
peer_bus = PeerBus()
|
||||||
|
_register_specialist_agents(peer_bus, odoo, llm_router)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to init peer bus / specialist agents: %s', exc)
|
||||||
|
peer_bus = None
|
||||||
|
|
||||||
|
# 7. Master agent
|
||||||
|
try:
|
||||||
|
from .agents.master_agent import MasterAgent
|
||||||
|
master = MasterAgent(
|
||||||
|
odoo=odoo,
|
||||||
|
llm=llm_router,
|
||||||
|
memory=memory_mgr,
|
||||||
|
peer_bus=peer_bus,
|
||||||
|
registry=agent_registry,
|
||||||
|
)
|
||||||
|
app_state.set_master_agent(master)
|
||||||
|
logger.info('MasterAgent ready')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to init MasterAgent: %s', exc)
|
||||||
|
|
||||||
|
# 8. Sweep coordinator (lazy import — defined in Step 16)
|
||||||
|
try:
|
||||||
|
from .agents.sweep_coordinator import SweepCoordinator
|
||||||
|
sweep_coord = SweepCoordinator(peer_bus=peer_bus)
|
||||||
|
app_state.set_sweep_coordinator(sweep_coord)
|
||||||
|
except ImportError:
|
||||||
|
pass # not yet implemented
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Sweep coordinator not available: %s', exc)
|
||||||
|
|
||||||
|
logger.info('ActiveBlue AI agent service started on port %d', settings.agent_service_port)
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
if pool:
|
||||||
|
await pool.close()
|
||||||
|
logger.info('Agent service shut down')
|
||||||
|
|
||||||
|
|
||||||
|
def _register_specialist_agents(peer_bus, odoo, llm_router) -> None:
|
||||||
|
try:
|
||||||
|
from .agents.finance_agent import FinanceAgent
|
||||||
|
peer_bus.register('finance_agent', FinanceAgent(odoo=odoo, llm=llm_router, peer_bus=peer_bus))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Could not register finance_agent: %s', exc)
|
||||||
|
|
||||||
|
specialist_map = {
|
||||||
|
'accounting_agent': 'AccountingAgent',
|
||||||
|
'crm_agent': 'CrmAgent',
|
||||||
|
'sales_agent': 'SalesAgent',
|
||||||
|
'project_agent': 'ProjectAgent',
|
||||||
|
'elearning_agent': 'ElearningAgent',
|
||||||
|
'expenses_agent': 'ExpensesAgent',
|
||||||
|
'employees_agent': 'EmployeesAgent',
|
||||||
|
}
|
||||||
|
for agent_name, class_name in specialist_map.items():
|
||||||
|
module_name = agent_name.replace('_agent', '_agent')
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
mod = importlib.import_module(f'.agents.{agent_name}', package='agent_service')
|
||||||
|
cls = getattr(mod, class_name)
|
||||||
|
peer_bus.register(agent_name, cls(odoo=odoo, llm=llm_router, peer_bus=peer_bus))
|
||||||
|
except ImportError:
|
||||||
|
logger.debug('%s module not yet implemented, skipping', agent_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Could not register %s: %s', agent_name, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_logging(settings) -> None:
|
||||||
|
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||||
|
if settings.log_format == 'json':
|
||||||
|
try:
|
||||||
|
import json_log_formatter
|
||||||
|
formatter = json_log_formatter.JSONFormatter()
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logging.root.handlers = [handler]
|
||||||
|
except ImportError:
|
||||||
|
logging.basicConfig(level=level, stream=sys.stdout)
|
||||||
|
else:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
stream=sys.stdout,
|
||||||
|
format='%(asctime)s %(name)s %(levelname)s %(message)s',
|
||||||
|
)
|
||||||
|
logging.root.setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title='ActiveBlue AI Agent Service',
|
||||||
|
version='0.1.0',
|
||||||
|
docs_url='/docs',
|
||||||
|
redoc_url=None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=['*'],
|
||||||
|
allow_methods=['GET', 'POST', 'DELETE'],
|
||||||
|
allow_headers=['*'],
|
||||||
|
)
|
||||||
|
app.include_router(dispatch.router)
|
||||||
|
app.include_router(approval.router)
|
||||||
|
app.include_router(registry.router)
|
||||||
|
app.include_router(sweep.router)
|
||||||
|
app.include_router(health.router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
0
agent_service/routers/__init__.py
Normal file
0
agent_service/routers/__init__.py
Normal file
89
agent_service/routers/approval.py
Normal file
89
agent_service/routers/approval.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix='/approval', tags=['approval'])
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequest(BaseModel):
|
||||||
|
directive_id: str
|
||||||
|
approved: bool
|
||||||
|
approver_id: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PendingApproval(BaseModel):
|
||||||
|
directive_id: str
|
||||||
|
agent: str
|
||||||
|
action: str
|
||||||
|
description: str
|
||||||
|
created_at: str
|
||||||
|
context: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/pending', response_model=list[PendingApproval])
|
||||||
|
async def list_pending():
|
||||||
|
from ..app_state import get_db_pool
|
||||||
|
pool = get_db_pool()
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='DB not ready')
|
||||||
|
async with pool.acquire(timeout=10) as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
'SELECT directive_id, agent_name, action_type, description, created_at, context_data '
|
||||||
|
'FROM ab_directive_log WHERE status = $1 ORDER BY created_at ASC',
|
||||||
|
'pending_approval',
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
PendingApproval(
|
||||||
|
directive_id=str(r['directive_id']),
|
||||||
|
agent=r['agent_name'] or '',
|
||||||
|
action=r['action_type'] or '',
|
||||||
|
description=r['description'] or '',
|
||||||
|
created_at=str(r['created_at']),
|
||||||
|
context=r['context_data'] or {},
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/respond', status_code=status.HTTP_200_OK)
|
||||||
|
async def respond_approval(req: ApprovalRequest):
|
||||||
|
from ..app_state import get_db_pool, get_master_agent
|
||||||
|
pool = get_db_pool()
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='DB not ready')
|
||||||
|
|
||||||
|
async with pool.acquire(timeout=10) as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT directive_id, status FROM ab_directive_log WHERE directive_id = $1',
|
||||||
|
req.directive_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Directive not found')
|
||||||
|
if row['status'] != 'pending_approval':
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f'Directive is not pending approval (status={row["status"]})',
|
||||||
|
)
|
||||||
|
new_status = 'approved' if req.approved else 'rejected'
|
||||||
|
await conn.execute(
|
||||||
|
'UPDATE ab_directive_log SET status=$1, approver_id=$2, approval_note=$3, updated_at=NOW() '
|
||||||
|
'WHERE directive_id=$4',
|
||||||
|
new_status, req.approver_id, req.note, req.directive_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('Directive %s %s by %s', req.directive_id, new_status, req.approver_id)
|
||||||
|
|
||||||
|
if req.approved:
|
||||||
|
master = get_master_agent()
|
||||||
|
if master:
|
||||||
|
try:
|
||||||
|
await master.resume_directive(req.directive_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('resume_directive failed %s: %s', req.directive_id, exc)
|
||||||
|
|
||||||
|
return {'directive_id': req.directive_id, 'status': new_status}
|
||||||
102
agent_service/routers/dispatch.py
Normal file
102
agent_service/routers/dispatch.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix='/dispatch', tags=['dispatch'])
|
||||||
|
|
||||||
|
# In-memory rate limit store: {user_id: [timestamp, ...]}
|
||||||
|
_rate_limit_store: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchRequest(BaseModel):
|
||||||
|
user_id: str = Field(..., description='Odoo user ID or session identifier')
|
||||||
|
message: str = Field(..., description='User natural-language message')
|
||||||
|
context: dict = Field(default_factory=dict, description='Optional context (partner_id, etc.)')
|
||||||
|
session_id: Optional[str] = Field(None, description='Conversation session ID')
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchResponse(BaseModel):
|
||||||
|
directive_id: str
|
||||||
|
reply: str
|
||||||
|
agent_reports: list[dict] = []
|
||||||
|
escalations: list[str] = []
|
||||||
|
actions_taken: list[dict] = []
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_webhook_secret(request: Request) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
secret = settings.webhook_secret
|
||||||
|
if not secret:
|
||||||
|
return
|
||||||
|
sig = request.headers.get('X-ActiveBlue-Signature', '')
|
||||||
|
if not sig:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Missing signature')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(user_id: str) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
limit = settings.dispatch_rate_limit_per_user
|
||||||
|
now = time.monotonic()
|
||||||
|
window = 60.0
|
||||||
|
timestamps = _rate_limit_store.get(user_id, [])
|
||||||
|
timestamps = [t for t in timestamps if now - t < window]
|
||||||
|
if len(timestamps) >= limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f'Rate limit exceeded: {limit} requests/minute',
|
||||||
|
)
|
||||||
|
timestamps.append(now)
|
||||||
|
_rate_limit_store[user_id] = timestamps
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('', response_model=DispatchResponse)
|
||||||
|
async def dispatch(req: DispatchRequest, request: Request):
|
||||||
|
_verify_webhook_secret(request)
|
||||||
|
_check_rate_limit(req.user_id)
|
||||||
|
|
||||||
|
from ..app_state import get_master_agent
|
||||||
|
master = get_master_agent()
|
||||||
|
if master is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Agent service not ready')
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
timeout = settings.directive_timeout_minutes * 60
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
master.handle_message(
|
||||||
|
user_id=req.user_id,
|
||||||
|
message=req.message,
|
||||||
|
context=req.context,
|
||||||
|
session_id=req.session_id,
|
||||||
|
),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f'Directive timed out after {settings.directive_timeout_minutes}m',
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception('dispatch error user=%s: %s', req.user_id, exc)
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||||
|
|
||||||
|
return DispatchResponse(
|
||||||
|
directive_id=response.directive_id,
|
||||||
|
reply=response.reply,
|
||||||
|
agent_reports=[r.dict() if hasattr(r, 'dict') else r for r in response.agent_reports],
|
||||||
|
escalations=response.escalations,
|
||||||
|
actions_taken=response.actions_taken,
|
||||||
|
session_id=req.session_id,
|
||||||
|
)
|
||||||
85
agent_service/routers/health.py
Normal file
85
agent_service/routers/health.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix='/health', tags=['health'])
|
||||||
|
|
||||||
|
_start_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
uptime_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
class DetailedHealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
uptime_seconds: float
|
||||||
|
db: str
|
||||||
|
odoo: str
|
||||||
|
ollama: str
|
||||||
|
master_agent: str
|
||||||
|
privacy_mode: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('', response_model=HealthResponse)
|
||||||
|
async def health():
|
||||||
|
return HealthResponse(status='ok', uptime_seconds=round(time.time() - _start_time, 1))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/detailed', response_model=DetailedHealthResponse)
|
||||||
|
async def health_detailed():
|
||||||
|
from ..app_state import get_db_pool, get_master_agent, get_llm_router
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
uptime = round(time.time() - _start_time, 1)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# DB check
|
||||||
|
db_status = 'unavailable'
|
||||||
|
pool = get_db_pool()
|
||||||
|
if pool:
|
||||||
|
try:
|
||||||
|
async with pool.acquire(timeout=5) as conn:
|
||||||
|
await conn.fetchval('SELECT 1')
|
||||||
|
db_status = 'ok'
|
||||||
|
except Exception as exc:
|
||||||
|
db_status = f'error: {exc}'
|
||||||
|
|
||||||
|
# Odoo check
|
||||||
|
odoo_status = 'unavailable'
|
||||||
|
master = get_master_agent()
|
||||||
|
if master and hasattr(master, '_odoo'):
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(master._odoo.ping(), timeout=5)
|
||||||
|
odoo_status = 'ok'
|
||||||
|
except Exception as exc:
|
||||||
|
odoo_status = f'error: {exc}'
|
||||||
|
|
||||||
|
# Ollama check
|
||||||
|
ollama_status = 'unavailable'
|
||||||
|
llm_router = get_llm_router()
|
||||||
|
if llm_router and hasattr(llm_router, '_ollama'):
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(llm_router._ollama.ping(), timeout=5)
|
||||||
|
ollama_status = 'ok'
|
||||||
|
except Exception as exc:
|
||||||
|
ollama_status = f'error: {exc}'
|
||||||
|
|
||||||
|
master_status = 'ok' if master is not None else 'unavailable'
|
||||||
|
overall = 'ok' if all(s == 'ok' for s in [db_status, master_status]) else 'degraded'
|
||||||
|
|
||||||
|
return DetailedHealthResponse(
|
||||||
|
status=overall,
|
||||||
|
uptime_seconds=uptime,
|
||||||
|
db=db_status,
|
||||||
|
odoo=odoo_status,
|
||||||
|
ollama=ollama_status,
|
||||||
|
master_agent=master_status,
|
||||||
|
privacy_mode=settings.llm_privacy_mode,
|
||||||
|
)
|
||||||
98
agent_service/routers/registry.py
Normal file
98
agent_service/routers/registry.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix='/registry', tags=['registry'])
|
||||||
|
|
||||||
|
|
||||||
|
class AgentInfo(BaseModel):
|
||||||
|
name: str
|
||||||
|
domain: str
|
||||||
|
active: bool
|
||||||
|
backend: str = 'ollama'
|
||||||
|
last_seen: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackendOverride(BaseModel):
|
||||||
|
agent_name: str
|
||||||
|
backend: str # ollama | claude
|
||||||
|
set_by: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/agents', response_model=list[AgentInfo])
|
||||||
|
async def list_agents():
|
||||||
|
from ..app_state import get_agent_registry, get_llm_router
|
||||||
|
registry = get_agent_registry()
|
||||||
|
llm_router = get_llm_router()
|
||||||
|
if registry is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Registry not ready')
|
||||||
|
agents = registry.get_all()
|
||||||
|
result = []
|
||||||
|
for agent in agents:
|
||||||
|
backend = 'ollama'
|
||||||
|
if llm_router:
|
||||||
|
try:
|
||||||
|
backend = await llm_router.get_backend(agent['name'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result.append(AgentInfo(
|
||||||
|
name=agent['name'],
|
||||||
|
domain=agent.get('domain', ''),
|
||||||
|
active=agent.get('active', True),
|
||||||
|
backend=backend,
|
||||||
|
last_seen=agent.get('last_seen'),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/sync', status_code=status.HTTP_200_OK)
|
||||||
|
async def sync_registry():
|
||||||
|
from ..app_state import get_agent_registry
|
||||||
|
registry = get_agent_registry()
|
||||||
|
if registry is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Registry not ready')
|
||||||
|
try:
|
||||||
|
count = await registry.sync()
|
||||||
|
return {'synced': count}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('registry sync failed: %s', exc)
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/backend', status_code=status.HTTP_200_OK)
|
||||||
|
async def set_backend_override(req: BackendOverride):
|
||||||
|
from ..app_state import get_llm_router
|
||||||
|
llm_router = get_llm_router()
|
||||||
|
if llm_router is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='LLM router not ready')
|
||||||
|
if req.backend not in ('ollama', 'claude'):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='backend must be ollama or claude')
|
||||||
|
try:
|
||||||
|
await llm_router.set_backend_override(
|
||||||
|
caller=req.agent_name,
|
||||||
|
backend=req.backend,
|
||||||
|
set_by=req.set_by,
|
||||||
|
note=req.note,
|
||||||
|
)
|
||||||
|
return {'agent': req.agent_name, 'backend': req.backend}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('set_backend_override failed: %s', exc)
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete('/backend/{agent_name}', status_code=status.HTTP_200_OK)
|
||||||
|
async def reset_backend_override(agent_name: str):
|
||||||
|
from ..app_state import get_llm_router
|
||||||
|
llm_router = get_llm_router()
|
||||||
|
if llm_router is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='LLM router not ready')
|
||||||
|
try:
|
||||||
|
await llm_router.reset_backend_override(caller=agent_name)
|
||||||
|
return {'agent': agent_name, 'reset': True}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
|
||||||
55
agent_service/routers/sweep.py
Normal file
55
agent_service/routers/sweep.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix='/sweep', tags=['sweep'])
|
||||||
|
|
||||||
|
# Track running sweep task
|
||||||
|
_sweep_task: Optional[asyncio.Task] = None
|
||||||
|
_last_sweep_result: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SweepRequest(BaseModel):
|
||||||
|
agents: list[str] = [] # empty = all active agents
|
||||||
|
|
||||||
|
|
||||||
|
class SweepStatusResponse(BaseModel):
|
||||||
|
running: bool
|
||||||
|
last_result: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('', status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def trigger_sweep(req: SweepRequest):
|
||||||
|
global _sweep_task
|
||||||
|
if _sweep_task and not _sweep_task.done():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail='Sweep already running',
|
||||||
|
)
|
||||||
|
from ..app_state import get_sweep_coordinator
|
||||||
|
coordinator = get_sweep_coordinator()
|
||||||
|
if coordinator is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Sweep coordinator not ready')
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
global _last_sweep_result
|
||||||
|
try:
|
||||||
|
result = await coordinator.run_sweep(agents=req.agents or None)
|
||||||
|
_last_sweep_result = result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('sweep run error: %s', exc)
|
||||||
|
_last_sweep_result = {'error': str(exc)}
|
||||||
|
|
||||||
|
_sweep_task = asyncio.create_task(_run())
|
||||||
|
return {'status': 'accepted', 'agents': req.agents or 'all'}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/status', response_model=SweepStatusResponse)
|
||||||
|
async def sweep_status():
|
||||||
|
running = _sweep_task is not None and not _sweep_task.done()
|
||||||
|
return SweepStatusResponse(running=running, last_result=_last_sweep_result)
|
||||||
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
agent-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: activeblue-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- '192.168.2.47:8001:8001'
|
||||||
|
depends_on:
|
||||||
|
agent-db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- activeblue-net
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'curl', '-f', 'http://localhost:8001/health']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: '50m'
|
||||||
|
max-file: '5'
|
||||||
|
|
||||||
|
agent-db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: activeblue-agent-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-activeblue_ai}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-activeblue}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- agent-db-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- activeblue-net
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-activeblue} -d ${POSTGRES_DB:-activeblue_ai}']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: '20m'
|
||||||
|
max-file: '3'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
agent-db-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
activeblue-net:
|
||||||
|
name: activeblue-net
|
||||||
|
external: false
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
asyncpg==0.29.0
|
||||||
|
anthropic==0.34.2
|
||||||
|
httpx==0.27.2
|
||||||
|
alembic==1.13.3
|
||||||
|
sqlalchemy[asyncio]==2.0.35
|
||||||
|
json-log-formatter==0.5.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
Reference in New Issue
Block a user