862 lines
35 KiB
Python
862 lines
35 KiB
Python
"""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
|