diff --git a/tests/conftest.py b/tests/conftest.py index bca2322af..643193f27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -380,6 +380,14 @@ async def client(app, tmp_data_dir): if device_store._db is not None: await device_store.close() await device_store.init() + council_roles = app.state.council_roles + if council_roles._db is not None: + await council_roles.close() + await council_roles.init() + council_members = app.state.council_members + if council_members._db is not None: + await council_members.close() + await council_members.init() # BrowserApp v2 stores from tinyagentos.routes.desktop_browser.store import BrowserStore, BrowserCookieStore _browser_store = BrowserStore(tmp_data_dir / "browser.sqlite3") @@ -445,6 +453,8 @@ async def client(app, tmp_data_dir): await app.state.http_client.aclose() await _browser_store.close() await _browser_cookie_store.close() + await app.state.council_roles.close() + await app.state.council_members.close() def create_test_qmd_db(db_path): diff --git a/tests/test_council_routes.py b/tests/test_council_routes.py new file mode 100644 index 000000000..c7eafe6de --- /dev/null +++ b/tests/test_council_routes.py @@ -0,0 +1,52 @@ +import pytest + + +@pytest.mark.asyncio +class TestCouncilRoutes: + async def test_list_roles_returns_seeded_roles(self, client, app): + roles_resp = await client.get("/api/council/roles") + assert roles_resp.status_code == 200 + roles = roles_resp.json() + assert len(roles) == 10 + slugs = {r["slug"] for r in roles} + expected = { + "coder", "reviewer", "writer", "editor", "summarizer", + "translator", "researcher", "planner", "critic", "data_analyst", + } + assert slugs == expected + + async def test_list_roles_has_expected_fields(self, client, app): + roles_resp = await client.get("/api/council/roles") + assert roles_resp.status_code == 200 + roles = roles_resp.json() + for role in roles: + assert "slug" in role + assert "display_name" in role + assert "description" in role + assert "gauge_status" in role + assert "builtin" in role + + async def test_list_members_returns_empty_when_none(self, client, app): + members_resp = await client.get("/api/council/members") + assert members_resp.status_code == 200 + assert members_resp.json() == [] + + async def test_list_members_returns_added_members(self, client, app): + store = app.state.council_members + if store._db is not None: + await store.close() + await store.init() + await store.add_member( + canonical_id="agent-route-1", + model_id="kilo-auto/free", + provider="kilocode", + roles=[{"role": "coder", "score": 90, "source": "local"}], + autonomy={"coder": "propose"}, + ) + + members_resp = await client.get("/api/council/members") + assert members_resp.status_code == 200 + members = members_resp.json() + assert len(members) == 1 + assert members[0]["canonical_id"] == "agent-route-1" + assert members[0]["roles"] == [{"role": "coder", "score": 90, "source": "local"}] diff --git a/tests/test_council_stores.py b/tests/test_council_stores.py new file mode 100644 index 000000000..0b24f6e2e --- /dev/null +++ b/tests/test_council_stores.py @@ -0,0 +1,130 @@ +import pytest +import pytest_asyncio +from pathlib import Path + +from tinyagentos.council.member_store import MemberStore +from tinyagentos.council.role_registry import RoleRegistry + + +@pytest_asyncio.fixture +async def role_registry(tmp_path): + store = RoleRegistry(tmp_path / "council_roles.db") + await store.init() + yield store + await store.close() + + +@pytest_asyncio.fixture +async def member_store(tmp_path): + store = MemberStore(tmp_path / "council_members.db") + await store.init() + yield store + await store.close() + + +@pytest.mark.asyncio +class TestRoleRegistry: + async def test_seeds_ten_roles(self, role_registry): + roles = await role_registry.list_roles() + assert len(roles) == 10 + slugs = {r["slug"] for r in roles} + expected = { + "coder", "reviewer", "writer", "editor", "summarizer", + "translator", "researcher", "planner", "critic", "data_analyst", + } + assert slugs == expected + + async def test_get_existing_role(self, role_registry): + role = await role_registry.get_role("coder") + assert role is not None + assert role["display_name"] == "Coder" + assert role["gauge_status"] == "proven" + assert role["builtin"] is True + + async def test_get_missing_role_returns_none(self, role_registry): + role = await role_registry.get_role("nonexistent") + assert role is None + + async def test_roles_sorted_by_slug(self, role_registry): + roles = await role_registry.list_roles() + slugs = [r["slug"] for r in roles] + assert slugs == sorted(slugs) + + async def test_proven_roles_have_suite_version(self, role_registry): + roles = await role_registry.list_roles() + proven = [r for r in roles if r["gauge_status"] == "proven"] + for r in proven: + assert r["suite_version"] is not None + + async def test_provisional_roles_have_null_suite_version(self, role_registry): + roles = await role_registry.list_roles() + provisional = [r for r in roles if r["gauge_status"] == "provisional"] + for r in provisional: + assert r["suite_version"] is None + + +@pytest.mark.asyncio +class TestMemberStore: + async def test_add_and_list_member(self, member_store): + member = await member_store.add_member( + canonical_id="agent-001", + model_id="kilo-auto/free", + provider="kilocode", + roles=[{"role": "coder", "score": 85, "source": "local"}], + autonomy={"coder": "propose"}, + ) + assert member["status"] == "active" + assert member["id"] is not None + + members = await member_store.list_members() + assert len(members) == 1 + assert members[0]["canonical_id"] == "agent-001" + assert members[0]["model_id"] == "kilo-auto/free" + assert members[0]["roles"] == [{"role": "coder", "score": 85, "source": "local"}] + assert members[0]["autonomy"] == {"coder": "propose"} + + async def test_get_member(self, member_store): + created = await member_store.add_member( + canonical_id="agent-002", + model_id="stepflash:free", + provider="kilocode", + roles=[], + autonomy={}, + ) + fetched = await member_store.get_member(created["id"]) + assert fetched is not None + assert fetched["canonical_id"] == "agent-002" + assert fetched["provider"] == "kilocode" + + async def test_get_missing_returns_none(self, member_store): + assert await member_store.get_member("does-not-exist") is None + + async def test_list_empty(self, member_store): + assert await member_store.list_members() == [] + + async def test_duplicate_canonical_id_rejected(self, member_store): + await member_store.add_member( + canonical_id="agent-dup", + model_id="m1", + provider="p", + roles=[], + autonomy={}, + ) + with pytest.raises(Exception): + await member_store.add_member( + canonical_id="agent-dup", + model_id="m2", + provider="p", + roles=[], + autonomy={}, + ) + + async def test_default_status_is_active(self, member_store): + member = await member_store.add_member( + canonical_id="agent-003", + model_id="hy3:free", + provider="kilocode", + roles=[], + autonomy={}, + ) + assert member["status"] == "active" diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 68d76e7d6..a7ebbf0f1 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -295,6 +295,11 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> score_cache = ScoreCache(benchmark_store, poll_interval_seconds=15.0) scheduler_history_store = HistoryStore(data_dir / "scheduler_history.db") + from tinyagentos.council.role_registry import RoleRegistry + from tinyagentos.council.member_store import MemberStore + council_role_registry = RoleRegistry(data_dir / "council.db") + council_member_store = MemberStore(data_dir / "council.db") + async def _probe_backend(backend: dict) -> dict: return await check_backend_health(http_client, backend) @@ -631,6 +636,8 @@ async def lifespan(app: FastAPI): await benchmark_store.init() await scheduler_history_store.init() + await council_role_registry.init() + await council_member_store.init() app.state.config = config app.state.config_path = config_path app.state.data_dir = data_dir @@ -867,6 +874,8 @@ async def _browser_reap_loop(app: FastAPI) -> None: app.state.benchmark_store = benchmark_store app.state.score_cache = score_cache app.state.scheduler_history_store = scheduler_history_store + app.state.council_roles = council_role_registry + app.state.council_members = council_member_store orchestrator = RestartOrchestrator(app.state) app.state.orchestrator = orchestrator # Boot-time: check if a pending restart was applied successfully @@ -1637,6 +1646,8 @@ async def dispatch(self, request, call_next): app.state.license_acceptances = license_acceptances_store app.state.cluster_pairing = cluster_pairing_store app.state.capability_map = capability_map_store + app.state.council_roles = council_role_registry + app.state.council_members = council_member_store # Detect and set container runtime (eager, so tests work without lifespan) try: diff --git a/tinyagentos/council/__init__.py b/tinyagentos/council/__init__.py new file mode 100644 index 000000000..9f4d2348e --- /dev/null +++ b/tinyagentos/council/__init__.py @@ -0,0 +1 @@ +"""taOS Council slice S1: role registry + member store.""" diff --git a/tinyagentos/council/member_store.py b/tinyagentos/council/member_store.py new file mode 100644 index 000000000..1515b5c8b --- /dev/null +++ b/tinyagentos/council/member_store.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import logging +import time +import uuid +from pathlib import Path + +from tinyagentos.base_store import BaseStore + +logger = logging.getLogger(__name__) + +MEMBER_STORE_SCHEMA = """ +CREATE TABLE IF NOT EXISTS council_members ( + id TEXT PRIMARY KEY, + canonical_id TEXT NOT NULL UNIQUE, + model_id TEXT NOT NULL, + provider TEXT NOT NULL, + roles TEXT NOT NULL, + autonomy TEXT NOT NULL, + status TEXT NOT NULL, + added_at TEXT NOT NULL +); +""" + + +class MemberStore(BaseStore): + SCHEMA = MEMBER_STORE_SCHEMA + + async def add_member( + self, + canonical_id: str, + model_id: str, + provider: str, + roles: list[dict], + autonomy: dict, + status: str = "active", + ) -> dict: + member_id = uuid.uuid4().hex + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + await self._db.execute( + """ + INSERT INTO council_members (id, canonical_id, model_id, provider, roles, autonomy, status, added_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + member_id, + canonical_id, + model_id, + provider, + json.dumps(roles), + json.dumps(autonomy), + status, + now, + ), + ) + await self._db.commit() + return { + "id": member_id, + "canonical_id": canonical_id, + "model_id": model_id, + "provider": provider, + "roles": roles, + "autonomy": autonomy, + "status": status, + "added_at": now, + } + + async def list_members(self) -> list[dict]: + async with self._db.execute( + "SELECT id, canonical_id, model_id, provider, roles, autonomy, status, added_at FROM council_members ORDER BY added_at" + ) as cursor: + rows = await cursor.fetchall() + return [ + { + "id": r[0], + "canonical_id": r[1], + "model_id": r[2], + "provider": r[3], + "roles": json.loads(r[4]), + "autonomy": json.loads(r[5]), + "status": r[6], + "added_at": r[7], + } + for r in rows + ] + + async def get_member(self, member_id: str) -> dict | None: + async with self._db.execute( + "SELECT id, canonical_id, model_id, provider, roles, autonomy, status, added_at FROM council_members WHERE id = ?", + (member_id,), + ) as cursor: + row = await cursor.fetchone() + if row is None: + return None + return { + "id": row[0], + "canonical_id": row[1], + "model_id": row[2], + "provider": row[3], + "roles": json.loads(row[4]), + "autonomy": json.loads(row[5]), + "status": row[6], + "added_at": row[7], + } diff --git a/tinyagentos/council/role_registry.py b/tinyagentos/council/role_registry.py new file mode 100644 index 000000000..311437fd1 --- /dev/null +++ b/tinyagentos/council/role_registry.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from tinyagentos.base_store import BaseStore + +logger = logging.getLogger(__name__) + +ROLE_REGISTRY_SCHEMA = """ +CREATE TABLE IF NOT EXISTS council_roles ( + slug TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + gauge_status TEXT NOT NULL, + suite_version TEXT, + builtin INTEGER NOT NULL DEFAULT 1 +); +""" + +SEED_ROLES = [ + ("coder", "Coder", "Writes and modifies code within bounded repo tasks.", "proven", "1.0.0"), + ("reviewer", "Reviewer", "Reviews merged PRs against a ground-truth review.", "proven", "1.0.0"), + ("writer", "Writer", "Drafts content from briefs with rubric grading.", "designed", "1.0.0"), + ("editor", "Editor", "Edits flawed drafts for clarity and correctness.", "designed", "1.0.0"), + ("summarizer", "Summarizer", "Summarizes long documents with fact-recall grading.", "designed", "1.0.0"), + ("translator", "Translator", "Translates text with round-trip verification.", "designed", "1.0.0"), + ("researcher", "Researcher", "Answers questions with verifiable research.", "provisional", None), + ("planner", "Planner", "Breaks goals into actionable task plans.", "provisional", None), + ("critic", "Critic", "Critiques artifacts against known-flaw lists.", "provisional", None), + ("data_analyst", "Data analyst", "Answers questions from CSV datasets.", "provisional", None), +] + + +class RoleRegistry(BaseStore): + SCHEMA = ROLE_REGISTRY_SCHEMA + + async def _post_init(self) -> None: + await self._seed() + + async def _seed(self) -> None: + for slug, display_name, description, gauge_status, suite_version in SEED_ROLES: + await self._db.execute( + """ + INSERT OR IGNORE INTO council_roles (slug, display_name, description, gauge_status, suite_version) + VALUES (?, ?, ?, ?, ?) + """, + (slug, display_name, description, gauge_status, suite_version), + ) + await self._db.commit() + + async def list_roles(self) -> list[dict]: + async with self._db.execute( + "SELECT slug, display_name, description, gauge_status, suite_version, builtin FROM council_roles ORDER BY slug" + ) as cursor: + rows = await cursor.fetchall() + return [ + { + "slug": r[0], + "display_name": r[1], + "description": r[2], + "gauge_status": r[3], + "suite_version": r[4], + "builtin": bool(r[5]), + } + for r in rows + ] + + async def get_role(self, slug: str) -> dict | None: + async with self._db.execute( + "SELECT slug, display_name, description, gauge_status, suite_version, builtin FROM council_roles WHERE slug = ?", + (slug,), + ) as cursor: + row = await cursor.fetchone() + if row is None: + return None + return { + "slug": row[0], + "display_name": row[1], + "description": row[2], + "gauge_status": row[3], + "suite_version": row[4], + "builtin": bool(row[5]), + } diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index 2322ff856..a73745c51 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -385,3 +385,6 @@ def register_all_routers(app): from tinyagentos.routes.receipts import router as receipts_router app.include_router(receipts_router) + + from tinyagentos.routes.council import router as council_router + app.include_router(council_router) diff --git a/tinyagentos/routes/council.py b/tinyagentos/routes/council.py new file mode 100644 index 000000000..582a44e81 --- /dev/null +++ b/tinyagentos/routes/council.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from tinyagentos.council.member_store import MemberStore +from tinyagentos.council.role_registry import RoleRegistry + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/api/council/roles") +async def api_list_roles(request: Request): + registry = request.app.state.council_roles + roles = await registry.list_roles() + return roles + + +@router.get("/api/council/members") +async def api_list_members(request: Request): + store = request.app.state.council_members + members = await store.list_members() + return members