Files
AI_engine/core/registry.py

123 lines
5.6 KiB
Python

"""
The agent registry, as this engine sees it.
Settings an operator changes in the console (Settings → Skills & Tools) are
stored in the Doormile backend's agent registry. This module polls
``GET /api/v1/internal/ai/registry`` and answers, at the moment an agent needs
it, three questions:
registry.autonomous(agent_id, env_default) may this agent act on its own?
registry.model(agent_id) which Claude model, if pinned?
registry.skill_enabled(skill_id) is this behaviour switched on?
registry.threshold(skill_id, key, default) the tuned value of a knob
Precedence — deliberate, and the same for every setting:
registry loaded → the registry's value (it is the operator's decision)
not yet / never → the env default this engine always had
So a backend that is down, unreachable, or not yet deployed leaves the engine
exactly as it behaved before the registry existed. Once the registry has been
read, the last good copy is kept through later failures: a flapping backend
does not flip autonomy back to an env default mid-shift.
Polling uses the ETag the backend sends; an unchanged registry costs a 304
with no body. Before Phase 5 of the plan (krow_talent_app/docs/
agent-platform-plan.md) every one of these settings was a module constant read
from the environment at import time, so a change needed a redeploy.
"""
import asyncio
import os
from typing import Any, Dict, Optional
import aiohttp
from core.logger import logger
POLL_SECONDS = float(os.getenv("REGISTRY_POLL_SECONDS", "30"))
class RegistryClient:
def __init__(self):
self._agents: Dict[str, Dict[str, Any]] = {}
self._skills: Dict[str, Dict[str, Any]] = {}
self._etag: Optional[str] = None
self.loaded = False
# ── Reading (sync, cheap, safe to call on every event) ──────────────────
def autonomous(self, agent_id: str, env_default: bool) -> bool:
if not self.loaded or agent_id not in self._agents:
return env_default
return bool(self._agents[agent_id].get("autonomous", False))
def model(self, agent_id: str) -> Optional[str]:
"""A pinned model id, or None for the engine's own LLM_MODEL."""
if not self.loaded:
return None
return self._agents.get(agent_id, {}).get("model") or None
def skill_enabled(self, skill_id: str, default: bool = True) -> bool:
if not self.loaded or skill_id not in self._skills:
return default
return bool(self._skills[skill_id].get("enabled", default))
def threshold(self, skill_id: str, key: str, default: float) -> float:
if not self.loaded:
return default
value = (self._skills.get(skill_id, {}).get("thresholds") or {}).get(key)
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else default
# ── Loading ──────────────────────────────────────────────────────────────
def apply(self, snapshot: Dict[str, Any]) -> None:
"""Adopt a registry snapshot (the `data` of /internal/ai/registry)."""
agents = {a["agentid"]: a for a in snapshot.get("agents") or [] if a.get("agentid")}
skills = {s["skillid"]: s for s in snapshot.get("skills") or [] if s.get("skillid")}
self._agents, self._skills = agents, skills
if not self.loaded:
logger.info(f"Agent registry loaded: {len(agents)} agents, {len(skills)} skills")
self.loaded = True
async def fetch_once(self, session: aiohttp.ClientSession, base_url: str, api_key: str) -> str:
"""One poll. Returns 'updated', 'unchanged', 'unavailable' or 'refused'."""
headers = {"X-Internal-Key": api_key}
if self._etag:
headers["If-None-Match"] = self._etag
try:
async with session.get(f"{base_url}/api/v1/internal/ai/registry", headers=headers,
timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 304:
return "unchanged"
if resp.status in (401, 403):
return "refused"
if resp.status != 200:
return "unavailable"
body = await resp.json()
self.apply(body.get("data") or {})
self._etag = resp.headers.get("ETag")
return "updated"
except Exception as exc: # network, timeout, bad JSON — keep the last good copy
logger.debug(f"Agent registry poll failed: {exc}")
return "unavailable"
async def run(self, base_url: str, api_key: str, poll_seconds: float = POLL_SECONDS) -> None:
"""Poll forever. Never raises; the engine runs on env defaults meanwhile."""
if not api_key:
logger.warning("INTERNAL_API_KEY unset — agent registry not read; running on env defaults")
return
last = None
async with aiohttp.ClientSession() as session:
while True:
outcome = await self.fetch_once(session, base_url, api_key)
if outcome != last and outcome in ("refused", "unavailable"):
logger.warning(
f"Agent registry {outcome} at {base_url}; "
+ ("keeping the last loaded copy" if self.loaded else "running on env defaults")
)
last = outcome
await asyncio.sleep(poll_seconds)
registry = RegistryClient()