"""Agent configuration resolver with active-variant override and TTL cache. Resolves runtime configuration for AI agents from the database, preferring the active variant's values when one exists. All three agent services (extractor, event classifier, thesis rewriter) share this module instead of duplicating resolution logic. Requirements: 4.3, 4.4, 9.1–9.5, 10.4–10.6 Design: Config Resolution Module """ from __future__ import annotations import logging import time from dataclasses import dataclass import asyncpg logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Resolved config dataclass # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class ResolvedAgentConfig: """Runtime configuration resolved from DB agent + optional active variant.""" agent_id: str variant_id: str | None model_provider: str model_name: str system_prompt: str user_prompt_template: str prompt_version: str temperature: float max_tokens: int context_window: int input_token_limit: int token_budget: int timeout_seconds: int max_retries: int # --------------------------------------------------------------------------- # SQL: resolve agent config, preferring active variant via COALESCE # --------------------------------------------------------------------------- _RESOLVE_SQL = """\ SELECT a.id AS agent_id, v.id AS variant_id, COALESCE(v.model_provider, a.model_provider) AS model_provider, COALESCE(v.model_name, a.model_name) AS model_name, COALESCE(v.system_prompt, a.system_prompt) AS system_prompt, COALESCE(v.user_prompt_template,a.user_prompt_template) AS user_prompt_template, COALESCE(v.prompt_version, a.prompt_version) AS prompt_version, COALESCE(v.temperature, a.temperature) AS temperature, COALESCE(v.max_tokens, a.max_tokens) AS max_tokens, COALESCE(v.context_window, 0) AS context_window, COALESCE(v.input_token_limit, 0) AS input_token_limit, COALESCE(v.token_budget, 0) AS token_budget, COALESCE(v.timeout_seconds, a.timeout_seconds) AS timeout_seconds, COALESCE(v.max_retries, a.max_retries) AS max_retries FROM ai_agents a LEFT JOIN agent_variants v ON v.agent_id = a.id AND v.is_active = TRUE WHERE a.slug = $1 AND a.active = TRUE """ # --------------------------------------------------------------------------- # Resolver with TTL-based in-memory cache # --------------------------------------------------------------------------- class AgentConfigResolver: """Resolves agent configuration from DB with active variant override and TTL cache. Usage:: resolver = AgentConfigResolver(pool, ttl_seconds=60) config = await resolver.resolve("document-extractor") if config is None: # fall back to env-var OllamaConfig defaults ... """ def __init__(self, pool: asyncpg.Pool, ttl_seconds: int = 60) -> None: self._pool = pool self._ttl = ttl_seconds self._cache: dict[str, tuple[float, ResolvedAgentConfig]] = {} async def resolve(self, agent_slug: str) -> ResolvedAgentConfig | None: """Resolve config for an agent slug, preferring active variant if present. Returns ``None`` and logs a warning when the agent slug is not found or the database query fails. Callers should fall back to env-var defaults in that case. """ now = time.monotonic() # Check cache cached = self._cache.get(agent_slug) if cached is not None: ts, config = cached if (now - ts) < self._ttl: return config # Expired — remove stale entry before re-querying del self._cache[agent_slug] # Query database try: row = await self._pool.fetchrow(_RESOLVE_SQL, agent_slug) except Exception: logger.warning( "Failed to resolve agent config for %s from database", agent_slug, exc_info=True, ) return None if row is None: logger.warning( "No active agent found for slug %r", agent_slug, ) return None config = ResolvedAgentConfig( agent_id=str(row["agent_id"]), variant_id=str(row["variant_id"]) if row["variant_id"] else None, model_provider=row["model_provider"], model_name=row["model_name"], system_prompt=row["system_prompt"], user_prompt_template=row["user_prompt_template"], prompt_version=row["prompt_version"], temperature=float(row["temperature"]), max_tokens=int(row["max_tokens"]), context_window=int(row["context_window"]), input_token_limit=int(row["input_token_limit"]), token_budget=int(row["token_budget"]), timeout_seconds=int(row["timeout_seconds"]), max_retries=int(row["max_retries"]), ) self._cache[agent_slug] = (now, config) return config def invalidate(self, agent_slug: str | None = None) -> None: """Drop cached entries. If *agent_slug* is given, only that entry is removed. If ``None``, the entire cache is cleared. """ if agent_slug is None: self._cache.clear() else: self._cache.pop(agent_slug, None)