"""Agent Store — dynamic character registry with flexible office assignment. Each agent is an individual character/person with appearance, office assignment, and a backing OPC role for execution. Persisted in SQLite. Tables: agents — active agent registry (cleared & rebuilt on preset switch) agent_appearances — visual identity cache (survives mode switches) server_state — persists exec_mode/company_profile across restarts """ from __future__ import annotations import json import time from typing import Any import aiosqlite # Default office assignment for preset models _CLASSIC_LAYOUT: list[dict[str, Any]] = [ {"role_id": "coordinator", "office_id": "office-0", "palette": 0, "seat_zone": "leaderOffice"}, {"role_id": "executor", "office_id": "office-1", "palette": 1, "seat_zone": "workspace"}, {"role_id": "reviewer", "office_id": "office-2", "palette": 2, "seat_zone": "workspace"}, ] class AgentStore: """Dynamic character registry with flexible office assignment. SQLite table: agents agent_id TEXT PK, name TEXT, description TEXT, opc_role_id TEXT, office_id TEXT, palette INT, hue_shift INT, seat_zone TEXT, specialties TEXT (JSON), status TEXT, created_at REAL """ def __init__(self, db: aiosqlite.Connection) -> None: self._db = db self._local_talent_cache: list[Any] | None = None async def initialize(self) -> None: """Create tables if not exists.""" await self._db.execute(""" CREATE TABLE IF NOT EXISTS agents ( agent_id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', opc_role_id TEXT NOT NULL, office_id TEXT DEFAULT 'office-0', palette INTEGER DEFAULT 0, hue_shift INTEGER DEFAULT 0, seat_zone TEXT DEFAULT 'workspace', desk_id TEXT DEFAULT NULL, specialties TEXT DEFAULT '[]', status TEXT DEFAULT 'idle', created_at REAL NOT NULL ) """) # Appearance memory: persists visual identity across mode switches await self._db.execute(""" CREATE TABLE IF NOT EXISTS agent_appearances ( role_id TEXT PRIMARY KEY, palette INTEGER DEFAULT 0, hue_shift INTEGER DEFAULT 0, seat_zone TEXT DEFAULT 'workspace', office_id TEXT DEFAULT 'office-0', desk_id TEXT DEFAULT NULL ) """) # Persistent server state (exec_mode, company_profile) await self._db.execute(""" CREATE TABLE IF NOT EXISTS server_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) """) # Migration: add desk_id column if missing (existing DBs) try: await self._db.execute("SELECT desk_id FROM agents LIMIT 1") except Exception: await self._db.execute("ALTER TABLE agents ADD COLUMN desk_id TEXT DEFAULT NULL") # Migration: add employee_id column if missing (existing DBs) try: await self._db.execute("SELECT employee_id FROM agents LIMIT 1") except Exception: await self._db.execute("ALTER TABLE agents ADD COLUMN employee_id TEXT DEFAULT NULL") # Shadow copy of custom-mode agents: survives non-custom preset switches. # Written on every custom-mode agent mutation; read when re-entering custom. await self._db.execute(""" CREATE TABLE IF NOT EXISTS custom_agents_shadow ( agent_id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT '', opc_role_id TEXT NOT NULL, office_id TEXT DEFAULT 'office-0', palette INTEGER DEFAULT 0, hue_shift INTEGER DEFAULT 0, seat_zone TEXT DEFAULT 'workspace', desk_id TEXT DEFAULT NULL, employee_id TEXT DEFAULT NULL, specialties TEXT DEFAULT '[]', created_at REAL NOT NULL ) """) # Migration: drop legacy table from earlier iteration await self._db.execute("DROP TABLE IF EXISTS custom_agents_backup") await self._db.commit() # ── Server state persistence ────────────────────────────────────── async def get_server_state(self, key: str, default: str = "") -> str: """Read a persisted server state value.""" cursor = await self._db.execute( "SELECT value FROM server_state WHERE key = ?", (key,) ) row = await cursor.fetchone() return row[0] if row else default async def set_server_state(self, key: str, value: str) -> None: """Write a persisted server state value.""" await self._db.execute( "INSERT OR REPLACE INTO server_state (key, value) VALUES (?, ?)", (key, value), ) await self._db.commit() # ── Appearance persistence ──────────────────────────────────────── async def _save_appearances(self) -> None: """Persist current agents' visual identity to agent_appearances table.""" cursor = await self._db.execute( "SELECT agent_id, palette, hue_shift, seat_zone, office_id, desk_id FROM agents" ) rows = await cursor.fetchall() for agent_id, palette, hue_shift, seat_zone, office_id, desk_id in rows: await self._db.execute( "INSERT OR REPLACE INTO agent_appearances " "(role_id, palette, hue_shift, seat_zone, office_id, desk_id) " "VALUES (?, ?, ?, ?, ?, ?)", (agent_id, palette, hue_shift, seat_zone, office_id, desk_id), ) await self._db.commit() async def _restore_appearance(self, agent_id: str) -> dict[str, Any] | None: """Look up saved appearance for a role_id. Returns dict or None.""" cursor = await self._db.execute( "SELECT palette, hue_shift, seat_zone, office_id, desk_id " "FROM agent_appearances WHERE role_id = ?", (agent_id,), ) row = await cursor.fetchone() if not row: return None return { "palette": row[0], "hue_shift": row[1], "seat_zone": row[2], "office_id": row[3], "desk_id": row[4], } # ── Custom-mode shadow copy ────────────────────────────────────── async def sync_custom_shadow(self) -> None: """Mirror the current agents table into custom_agents_shadow. Call this after any mutation to agents while in custom mode so the shadow is always up-to-date — even if the server crashes before a clean mode switch. """ cursor = await self._db.execute( "SELECT agent_id, name, description, opc_role_id, office_id, " "palette, hue_shift, seat_zone, desk_id, employee_id, specialties, created_at " "FROM agents" ) rows = await cursor.fetchall() await self._db.execute("DELETE FROM custom_agents_shadow") for row in rows: await self._db.execute( "INSERT INTO custom_agents_shadow " "(agent_id, name, description, opc_role_id, office_id, " "palette, hue_shift, seat_zone, desk_id, employee_id, specialties, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", row, ) await self._db.commit() async def _restore_custom_shadow(self) -> list[dict[str, Any]]: """Restore agents from the shadow table. Returns agents or [].""" cursor = await self._db.execute( "SELECT agent_id, name, description, opc_role_id, office_id, " "palette, hue_shift, seat_zone, desk_id, employee_id, specialties, created_at " "FROM custom_agents_shadow" ) rows = await cursor.fetchall() if not rows: return [] await self._db.execute("DELETE FROM agents") for row in rows: await self._db.execute( "INSERT OR REPLACE INTO agents " "(agent_id, name, description, opc_role_id, office_id, " "palette, hue_shift, seat_zone, desk_id, employee_id, specialties, status, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', ?)", row, ) await self._db.commit() return await self.get_all() # ── Preset loading ──────────────────────────────────────────────── async def load_preset(self, model_name: str, org_engine: Any) -> list[dict[str, Any]]: """Load agents from a preset runtime model. Returns list of agent dicts in UI AgentInfo format. Supported presets: corporate, single, custom. Custom mode: restore the saved custom team when available. If there is no saved custom team yet, clear whatever preset agents are active and start from a single custom leader. Non-custom: save appearances, clear, rebuild from preset definition. """ if model_name == "custom": # Shadow is authoritative when it exists because the agents # table may contain leftover preset agents from a non-custom mode. restored = await self._restore_custom_shadow() if restored: return restored # No saved custom team yet — never inherit preset agents. await self._save_appearances() await self._db.execute("DELETE FROM agents") await self._db.commit() starter = await self._load_custom_starter(org_engine) reconciled = await self.ensure_custom_role_agents(org_engine) if reconciled: return reconciled await self.sync_custom_shadow() return starter # Non-custom preset: save appearances, clear, rebuild. await self._save_appearances() await self._db.execute("DELETE FROM agents") # Single mode: only the executor if model_name == "single": return await self._load_single_agent(org_engine) if model_name == "corporate": try: from opc.layer2_organization.company_runtime_profiles import get_builtin_roles config = getattr(org_engine, "config", None) org = getattr(config, "org", None) configured_roles = getattr(org, "roles", None) role_configs = get_builtin_roles(model_name, configured_roles=configured_roles) roles = [ {"id": r.id, "name": r.name, "responsibility": r.responsibility or "", "tools": list(r.tools) if r.tools else [], "can_spawn": list(r.can_spawn) if r.can_spawn else [], "reports_to": r.reports_to or ""} for r in role_configs ] except ImportError: roles = [] else: roles = [] if not roles: # Use current org_engine roles as fallback roles_info = org_engine.list_agents() if hasattr(org_engine, "list_agents") else [] if not roles_info: # Fallback to hardcoded 3-agent layout return await self._load_classic_fallback(org_engine) return await self._load_from_role_infos(roles_info, model_name) return await self._load_from_role_dicts(roles, model_name) async def _load_single_agent(self, org_engine: Any) -> list[dict[str, Any]]: """Single-agent mode: only one executor in office-0.""" role = None if hasattr(org_engine, "get_agent"): role = org_engine.get_agent("executor") name = role.name if role else "Executor" description = role.responsibility if role else "" specialties = list(role.tools) if role and role.tools else [] agent = await self._insert_agent( agent_id="executor", name=name, description=description, opc_role_id="executor", office_id="office-0", palette=1, seat_zone="leaderOffice", specialties=specialties, ) return [agent] async def _load_custom_starter(self, org_engine: Any) -> list[dict[str, Any]]: """Custom mode first-time bootstrap: 1 agent in leader desk.""" role = None if hasattr(org_engine, "get_agent"): role = org_engine.get_agent("coordinator") if not role: role = org_engine.get_agent("executor") name = role.name if role else "My Agent" description = role.responsibility if role else "Custom agent" specialties = list(role.tools) if role and role.tools else [] agent = await self._insert_agent( agent_id="custom-leader", name=name, description=description, opc_role_id="coordinator", office_id="office-0", palette=0, seat_zone="leaderOffice", specialties=specialties, restore_appearance=False, ) return [agent] async def ensure_custom_role_agents(self, org_engine: Any) -> list[dict[str, Any]]: """Make custom-mode visual agents match the active org roles.""" if not org_engine or not hasattr(org_engine, "list_agents"): return await self.get_all() roles = list(org_engine.list_agents() or []) if not roles: return await self.get_all() role_ids = [self._role_id(role) for role in roles] role_ids = [role_id for role_id in role_ids if role_id] desired_role_ids = set(role_ids) if not desired_role_ids: return await self.get_all() selected_by_role: dict[str, Any] = {} for role_id in role_ids: employees = [] if hasattr(org_engine, "list_employees"): employees = list(org_engine.list_employees(role_id=role_id) or []) if not employees and hasattr(org_engine, "ensure_default_employee_for_role"): default_employee = org_engine.ensure_default_employee_for_role(role_id, persist=False) if default_employee is not None: employees = [default_employee] if employees: selected_by_role[role_id] = self._select_role_employee(employees) desired_employee_ids = { getattr(employee, "employee_id", "") for employee in selected_by_role.values() if getattr(employee, "employee_id", "") } existing_agents = await self.get_all() for agent in existing_agents: role_id = str(agent.get("opc_role_id") or agent.get("agent_id") or "") employee_id = str(agent.get("employee_id") or "") if role_id not in desired_role_ids: await self.remove_agent(str(agent.get("agent_id") or "")) continue selected = selected_by_role.get(role_id) selected_employee_id = str(getattr(selected, "employee_id", "") or "") if selected_employee_id and employee_id and employee_id != selected_employee_id: await self.remove_agent(str(agent.get("agent_id") or "")) elif selected_employee_id and not employee_id: await self.remove_agent(str(agent.get("agent_id") or "")) offices = ["office-0", "office-1", "office-2"] worker_idx = 0 for index, role in enumerate(roles): role_id = self._role_id(role) employee = selected_by_role.get(role_id) employee_id = str(getattr(employee, "employee_id", "") or "") if not employee_id: continue if await self._find_by_employee_id(employee_id): continue is_leader = self._is_leader_role(role, index) office_id = "office-0" if is_leader else offices[1 + (worker_idx % 2)] seat_zone = "leaderOffice" if is_leader else "workspace" if not is_leader: worker_idx += 1 await self.create_agent_from_employee( self._employee_to_dict(employee), office_id=office_id, seat_zone=seat_zone, ) for agent in await self.get_all(): employee_id = str(agent.get("employee_id") or "") if employee_id and employee_id not in desired_employee_ids: await self.remove_agent(str(agent.get("agent_id") or "")) await self.sync_custom_shadow() return await self.get_all() @staticmethod def _role_id(role: Any) -> str: return str(getattr(role, "role_id", "") or getattr(role, "id", "") or "").strip() @staticmethod def _is_leader_role(role: Any, index: int) -> bool: can_spawn = getattr(role, "can_spawn", None) or [] reports_to = getattr(role, "reports_to", None) or "" return index == 0 or (bool(can_spawn) and reports_to in ("", "owner")) @staticmethod def _select_role_employee(employees: list[Any]) -> Any: for employee in employees: metadata = dict(getattr(employee, "metadata", {}) or {}) if not metadata.get("is_default_employee"): return employee return employees[0] @staticmethod def _employee_to_dict(employee: Any) -> dict[str, Any]: return { "employee_id": getattr(employee, "employee_id", ""), "name": getattr(employee, "name", ""), "role_id": getattr(employee, "role_id", ""), "category": getattr(employee, "category", ""), "domains": list(getattr(employee, "domains", []) or []), "tags": list(getattr(employee, "tags", []) or []), } async def _load_classic_fallback(self, org_engine: Any) -> list[dict[str, Any]]: """Fallback: create classic agents from hardcoded layout.""" agents = [] for layout in _CLASSIC_LAYOUT: role_id = layout["role_id"] # Try to get role info from org_engine role = None if hasattr(org_engine, "get_agent"): role = org_engine.get_agent(role_id) name = role.name if role else role_id.capitalize() description = role.responsibility if role else "" specialties = list(role.tools) if role and role.tools else [] agent = await self._insert_agent( agent_id=role_id, name=name, description=description, opc_role_id=role_id, office_id=layout["office_id"], palette=layout["palette"], seat_zone=layout["seat_zone"], specialties=specialties, ) agents.append(agent) return agents async def _load_from_role_infos(self, roles: list[Any], model_name: str) -> list[dict[str, Any]]: """Load from OPC AgentInfo objects. Groups agents into offices: leaders in office-0, workers spread across office-1/2. """ agents = [] offices = ["office-0", "office-1", "office-2"] worker_idx = 0 for i, role in enumerate(roles): role_id = role.role_id if hasattr(role, "role_id") else str(role) name = role.name if hasattr(role, "name") else role_id description = role.responsibility if hasattr(role, "responsibility") else "" specialties = list(role.tools) if hasattr(role, "tools") and role.tools else [] # Leaders go to office-0: first agent, or agents that report to # "owner"/nobody (top of hierarchy) can_spawn = getattr(role, "can_spawn", None) or [] reports_to = getattr(role, "reports_to", None) or "" is_leader = i == 0 or (bool(can_spawn) and reports_to in ("", "owner")) if is_leader: office_id = "office-0" seat_zone = "leaderOffice" else: # Workers spread across office-1 and office-2 office_id = offices[1 + (worker_idx % 2)] seat_zone = "workspace" worker_idx += 1 agent = await self._insert_agent( agent_id=role_id, name=name, description=description, opc_role_id=role_id, office_id=office_id, palette=i % 6, seat_zone=seat_zone, specialties=specialties, ) agents.append(agent) return agents # Office layout maps for preset profiles _OFFICE_MAPS: dict[str, dict[str, str]] = { "corporate": { "ceo": "office-0", "cto": "office-1", "senior_engineer": "office-1", "devops_engineer": "office-1", "cmo": "office-2", "coo": "office-2", "content_specialist": "office-2", "designer": "office-2", "qa_analyst": "office-2", }, } _LEADER_SETS: dict[str, set[str]] = { "corporate": {"ceo"}, } async def _load_from_role_dicts(self, roles: list[dict[str, Any]], model_name: str) -> list[dict[str, Any]]: """Load from role config dicts. Groups agents into offices using profile-specific layout maps: corporate: office-0 CEO, office-1 Engineering, office-2 Business/Ops """ office_map = self._OFFICE_MAPS.get(model_name, {}) leaders = self._LEADER_SETS.get(model_name, set()) offices = ["office-0", "office-1", "office-2"] agents = [] for i, role_dict in enumerate(roles): role_id = role_dict.get("id", f"role-{i}") name = role_dict.get("name", role_id) description = role_dict.get("responsibility", "") specialties = role_dict.get("tools", []) office_id = office_map.get(role_id, offices[i % len(offices)]) seat_zone = "leaderOffice" if role_id in leaders else "workspace" agent = await self._insert_agent( agent_id=role_id, name=name, description=description, opc_role_id=role_id, office_id=office_id, palette=i % 6, seat_zone=seat_zone, specialties=specialties, ) agents.append(agent) return agents async def _insert_agent( self, agent_id: str, name: str, description: str, opc_role_id: str, office_id: str, palette: int, seat_zone: str, specialties: list[str] | None = None, hue_shift: int = 0, desk_id: str | None = None, employee_id: str | None = None, restore_appearance: bool = True, ) -> dict[str, Any]: """Insert a single agent and return its UI AgentInfo dict. If restore_appearance is True, checks agent_appearances table for previously saved visual identity and uses it instead of defaults. """ # Restore saved appearance if available if restore_appearance: saved = await self._restore_appearance(agent_id) if saved: palette = saved["palette"] hue_shift = saved["hue_shift"] seat_zone = saved["seat_zone"] office_id = saved["office_id"] desk_id = saved["desk_id"] now = time.time() specs_json = json.dumps(specialties or []) await self._db.execute( "INSERT OR REPLACE INTO agents " "(agent_id, name, description, opc_role_id, office_id, palette, hue_shift, seat_zone, desk_id, employee_id, specialties, status, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'idle', ?)", (agent_id, name, description, opc_role_id, office_id, palette, hue_shift, seat_zone, desk_id, employee_id, specs_json, now), ) await self._db.commit() return self._to_ui_format( agent_id, name, description, specialties or [], "idle", palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id ) async def create_agent( self, name: str, opc_role_id: str, office_id: str, org_engine: Any = None, description: str = "", specialties: list[str] | None = None, tools: list[str] | None = None, palette: int | None = None, seat_zone: str = "workspace", employee_id: str | None = None, ) -> dict[str, Any]: """User creates a new character in the UI. Accepts optional custom fields. Falls back to org_engine role info for anything not explicitly provided. """ # Merge with org_engine role info (use provided values first) if org_engine and hasattr(org_engine, "get_agent"): role = org_engine.get_agent(opc_role_id) if role: if not description: description = role.responsibility or "" if not specialties and not tools: specialties = list(role.tools) if role.tools else [] # Merge tools into specialties for display final_specialties = list(specialties or []) if tools: for t in tools: if t not in final_specialties: final_specialties.append(t) # Auto-assign palette if not specified if palette is None: count = await self._count() palette = count % 6 # Generate unique agent_id agent_id = f"{opc_role_id}-{name.lower().replace(' ', '-')}" # Ensure uniqueness existing = await self._get_one(agent_id) if existing: agent_id = f"{agent_id}-{int(time.time()) % 10000}" return await self._insert_agent( agent_id=agent_id, name=name, description=description, opc_role_id=opc_role_id, office_id=office_id, palette=palette, seat_zone=seat_zone, specialties=final_specialties, employee_id=employee_id, restore_appearance=False, # User-created agents: use explicit values ) async def remove_agent(self, agent_id: str) -> dict[str, Any] | None: """Remove a character. Returns the agent dict for broadcasting.""" agent = await self._get_one(agent_id) if not agent: return None await self._db.execute("DELETE FROM agents WHERE agent_id = ?", (agent_id,)) await self._db.commit() return agent async def move_agent(self, agent_id: str, office_id: str, seat_zone: str | None = None, desk_id: str | None = None) -> dict[str, Any] | None: """Move character to different office/desk. Purely visual.""" fields = ["office_id = ?"] params: list[Any] = [office_id] if seat_zone: fields.append("seat_zone = ?") params.append(seat_zone) if desk_id is not None: fields.append("desk_id = ?") params.append(desk_id) params.append(agent_id) await self._db.execute( f"UPDATE agents SET {', '.join(fields)} WHERE agent_id = ?", tuple(params), ) await self._db.commit() return await self._get_one(agent_id) async def update_status(self, agent_id: str, status: str) -> None: """Update character status from OPC events.""" if not agent_id: return await self._db.execute( "UPDATE agents SET status = ? WHERE agent_id = ?", (status, agent_id), ) await self._db.commit() async def get_all(self) -> list[dict[str, Any]]: """Return all agents in the EXACT format UI AgentInfo expects.""" cursor = await self._db.execute( "SELECT agent_id, name, description, specialties, status, " "palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id FROM agents ORDER BY created_at" ) rows = await cursor.fetchall() agents = [] for row in rows: agent_id, name, description, specs_json, status, palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id = row specialties = json.loads(specs_json) if specs_json else [] agents.append(self._to_ui_format( agent_id, name, description, specialties, status, palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id )) return agents async def get_templates(self, org_engine: Any = None) -> list[dict[str, Any]]: """Generate agent templates for UI create-agent dialog. Reads from OPC org_engine to get available roles. """ if not org_engine or not hasattr(org_engine, "list_agents"): return [] templates = [] icon_map = { "coordinator": "compass", "executor": "hammer", "reviewer": "magnifying-glass", } roles = org_engine.list_agents() for role in roles: role_id = role.role_id if hasattr(role, "role_id") else str(role) name = role.name if hasattr(role, "name") else role_id desc = role.responsibility if hasattr(role, "responsibility") else "" tools = list(role.tools) if hasattr(role, "tools") and role.tools else [] templates.append({ "id": role_id, "label": name, "icon": icon_map.get(role_id, "person"), "desc": desc[:60], "tools": tools, }) return templates # ── Employee → Agent bridge ────────────────────────────────────────── async def create_agent_from_employee( self, employee: dict[str, Any], office_id: str = "office-0", seat_zone: str = "workspace", ) -> dict[str, Any]: """Create a visual agent linked to an org employee (idempotent).""" emp_id = employee.get("employee_id", "") existing = await self._find_by_employee_id(emp_id) if existing: return existing agent_id = f"emp-{emp_id}" name = employee.get("name", emp_id) description = employee.get("category", "") opc_role_id = employee.get("role_id", "executor") domains = list(employee.get("domains", [])) tags = list(employee.get("tags", [])) specialties = domains + [t for t in tags if t not in domains] count = await self._count() palette = count % 6 return await self._insert_agent( agent_id=agent_id, name=name, description=description, opc_role_id=opc_role_id, office_id=office_id, palette=palette, seat_zone=seat_zone, specialties=specialties, employee_id=emp_id, restore_appearance=False, ) async def _find_by_employee_id(self, employee_id: str) -> dict[str, Any] | None: """Find an agent linked to a given employee_id.""" cursor = await self._db.execute( "SELECT agent_id, name, description, specialties, status, " "palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id " "FROM agents WHERE employee_id = ?", (employee_id,), ) row = await cursor.fetchone() if not row: return None agent_id, name, description, specs_json, status, palette, hue_shift, seat_zone, office_id, desk_id, eid, opc_role_id = row specialties = json.loads(specs_json) if specs_json else [] return self._to_ui_format(agent_id, name, description, specialties, status, palette, hue_shift, seat_zone, office_id, desk_id, eid, opc_role_id) async def get_employee_agent_map(self) -> dict[str, str]: """Return {employee_id: agent_id} for all employee-linked agents.""" cursor = await self._db.execute( "SELECT employee_id, agent_id FROM agents WHERE employee_id IS NOT NULL" ) rows = await cursor.fetchall() return {row[0]: row[1] for row in rows} async def get_role_agent_map(self) -> dict[str, str]: """Return {opc_role_id: agent_id} for all agents. Used by EventAdapter to resolve OPC role_id → UI agent_id. If multiple agents share a role, last one wins (acceptable for status routing). """ cursor = await self._db.execute( "SELECT opc_role_id, agent_id FROM agents" ) rows = await cursor.fetchall() return {row[0]: row[1] for row in rows} async def update_status_by_role(self, role_id: str, status: str) -> None: """Update status for all agents with the given opc_role_id.""" if not role_id: return await self._db.execute( "UPDATE agents SET status = ? WHERE opc_role_id = ?", (status, role_id), ) await self._db.commit() # ── Private helpers ──────────────────────────────────────────────────── async def _get_one(self, agent_id: str) -> dict[str, Any] | None: cursor = await self._db.execute( "SELECT agent_id, name, description, specialties, status, " "palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id FROM agents WHERE agent_id = ?", (agent_id,), ) row = await cursor.fetchone() if not row: return None agent_id, name, description, specs_json, status, palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id = row specialties = json.loads(specs_json) if specs_json else [] return self._to_ui_format(agent_id, name, description, specialties, status, palette, hue_shift, seat_zone, office_id, desk_id, employee_id, opc_role_id) async def _count(self) -> int: cursor = await self._db.execute("SELECT COUNT(*) FROM agents") row = await cursor.fetchone() return row[0] if row else 0 @staticmethod def _to_ui_format( agent_id: str, name: str, description: str, specialties: list[str], status: str, palette: int, hue_shift: int, seat_zone: str, office_id: str = "office-0", desk_id: str | None = None, employee_id: str | None = None, opc_role_id: str | None = None, ) -> dict[str, Any]: """Convert to the EXACT format frontend AgentInfo expects.""" appearance: dict[str, Any] = { "palette": palette, "hue_shift": hue_shift, "seat_zone": seat_zone, } if desk_id: appearance["desk_id"] = desk_id result: dict[str, Any] = { "agent_id": agent_id, "name": name, "description": description, "specialties": specialties, "status": status, "office_id": office_id, "appearance": appearance, "opc_role_id": opc_role_id or agent_id, } if employee_id: result["employee_id"] = employee_id return result