-
-
Notifications
You must be signed in to change notification settings - Fork 35
feat(council): role registry + member store (#1818 slice S1) #1833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"}] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """taOS Council slice S1: role registry + member store.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: Duplicate
Reply with |
||
| """ | ||
| 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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION:
Reply with |
||
| ) 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], | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: Two stores open the same
council.dbfile with separate connectionsBoth
council_role_registryandcouncil_member_storepoint atdata_dir / "council.db"as independentBaseStoreinstances, so two separate aiosqlite connections are opened against one file. This is inconsistent with the rest of the codebase (every other store gets its own.db), and under any concurrent read/write from the two connections SQLite can raise "database is locked". Consider giving each store its own file (e.g.council_roles.db/council_members.db) or merging them into a single store.Reply with
@kilocode-bot fix itto have Kilo Code address this issue.