feat: align agent/microdao model - add is_orchestrator, is_platform, hierarchy

This commit is contained in:
Apple
2025-11-28 08:34:14 -08:00
parent 66d2c019ff
commit 37e1c8abbe
5 changed files with 454 additions and 25 deletions

View File

@@ -0,0 +1,139 @@
# DAARION Agent & MicroDAO Model v1
## Overview
This document describes the unified data model for Agents and MicroDAOs in the DAARION ecosystem.
## Core Hierarchy
```
Node → Agent → MicroDAO
Platform (District)
```
- **Node**: Physical/virtual infrastructure where agents run (NODE1, NODE2)
- **Agent**: AI entity with identity, capabilities, and affiliations
- **MicroDAO**: Organization/community of agents with shared goals
- **Platform/District**: Top-level MicroDAO that acts as a category/district
## Agent Model
### Key Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | text | Unique identifier |
| `slug` | text | URL-friendly identifier |
| `display_name` | text | Human-readable name |
| `kind` | text | Type: orchestrator, security, marketing, etc. |
| `node_id` | text | Home node where agent runs |
| `is_public` | boolean | Visible in public Citizens catalog |
| `visibility_scope` | text | Access level: global, microdao, private |
| `is_orchestrator` | boolean | Can create/manage microDAOs |
| `primary_microdao_id` | text | Primary organization affiliation |
### Visibility Scope Values
- **global**: Visible to everyone in the city
- **microdao**: Visible only to MicroDAO members
- **private**: Visible only to owner/admin
### Agent Types by Kind
- `orchestrator`: MicroDAO leaders, can manage organizations
- `security`: Security and audit agents
- `marketing`: Marketing and communication agents
- `developer`: Development and technical agents
- `research`: Research and analysis agents
- `finance`: Financial management agents
- `system`: Infrastructure and monitoring agents
## MicroDAO Model
### Key Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | text | Unique identifier |
| `slug` | text | URL-friendly identifier |
| `name` | text | Display name |
| `is_public` | boolean | Visible in public services |
| `is_platform` | boolean | Is a platform/district (top-level) |
| `orchestrator_agent_id` | text | Main orchestrator agent |
| `parent_microdao_id` | text | Parent for hierarchy |
| `district` | text | District/category name |
### MicroDAO Types
- **Platform** (`is_platform = true`): Top-level organizational unit (district)
- **Regular** (`is_platform = false`): Standard MicroDAO under a platform
### Hierarchy
```
Platform (District)
├── MicroDAO 1
│ ├── Agent A (orchestrator)
│ ├── Agent B (member)
│ └── Agent C (member)
└── MicroDAO 2
├── Agent D (orchestrator)
└── Agent E (member)
```
## UI Mapping
### Agent Console (`/agents`)
- Technical view of all agents
- Shows: node_id, visibility_scope, is_orchestrator
- Filters: kind, node_id, microdao_id, is_public
### Citizens (`/citizens`)
- Public view of agents (`is_public = true`)
- Shows: display_name, title, tagline, skills
- Filters: district, kind, search
### MicroDAO Dashboard (`/microdao`)
- Organization management
- Shows: member_count, orchestrator, channels
- Filters: district, is_platform, is_public
## API Endpoints
### Agents
```
GET /city/agents
?kind=orchestrator
&node_id=node-1-hetzner-gex44
&microdao_id=dao_daarion
&is_public=true
&visibility_scope=global
&include_system=false
```
### MicroDAOs
```
GET /city/microdao
?district=Core
&is_public=true
&is_platform=false
&q=search
GET /city/microdao/{slug}
```
## Important Notes
1. **Citizen ≠ separate entity**: Citizens are just public agents (`is_public = true`)
2. **Every agent needs MicroDAO**: Active agents must belong to at least one MicroDAO
3. **Orchestrators**: Agents with `is_orchestrator = true` can manage MicroDAOs
4. **Soft delete**: Use `is_archived`, `is_test`, `deleted_at` instead of hard delete
## Related Files
- Models: `services/city-service/models_city.py`
- Repository: `services/city-service/repo_city.py`
- Routes: `services/city-service/routes_city.py`
- Migrations: `migrations/026_align_agent_microdao_model.sql`

View File

@@ -0,0 +1,91 @@
-- Migration: Align Agent/MicroDAO model
-- Purpose: Standardize fields for Agent Console, Citizens, MicroDAO Dashboard
-- Date: 2025-11-28
-- ============================================================================
-- AGENTS TABLE
-- ============================================================================
-- Add is_orchestrator flag (agent can create/manage microDAOs)
ALTER TABLE agents
ADD COLUMN IF NOT EXISTS is_orchestrator boolean NOT NULL DEFAULT false;
-- Create index for orchestrator lookup
CREATE INDEX IF NOT EXISTS idx_agents_is_orchestrator ON agents(is_orchestrator) WHERE is_orchestrator = true;
-- Update existing orchestrators based on kind
UPDATE agents
SET is_orchestrator = true
WHERE kind = 'orchestrator'
AND is_orchestrator = false;
-- ============================================================================
-- MICRODAOS TABLE
-- ============================================================================
-- Add is_platform flag (microDAO is a platform/district)
ALTER TABLE microdaos
ADD COLUMN IF NOT EXISTS is_platform boolean NOT NULL DEFAULT false;
-- Add orchestrator_agent_id as alias/copy of owner_agent_id for clarity
-- (keeping owner_agent_id for backward compatibility)
ALTER TABLE microdaos
ADD COLUMN IF NOT EXISTS orchestrator_agent_id text;
-- Copy owner_agent_id to orchestrator_agent_id where not set
UPDATE microdaos
SET orchestrator_agent_id = owner_agent_id
WHERE orchestrator_agent_id IS NULL
AND owner_agent_id IS NOT NULL;
-- Add parent_microdao_id for hierarchy (platform -> child microDAOs)
ALTER TABLE microdaos
ADD COLUMN IF NOT EXISTS parent_microdao_id text;
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_microdaos_is_platform ON microdaos(is_platform) WHERE is_platform = true;
CREATE INDEX IF NOT EXISTS idx_microdaos_orchestrator ON microdaos(orchestrator_agent_id);
CREATE INDEX IF NOT EXISTS idx_microdaos_parent ON microdaos(parent_microdao_id);
-- Add foreign key for parent_microdao_id (self-reference)
-- Note: Using DO block to handle if constraint already exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'microdaos_parent_fk'
) THEN
ALTER TABLE microdaos
ADD CONSTRAINT microdaos_parent_fk
FOREIGN KEY (parent_microdao_id)
REFERENCES microdaos(id)
ON DELETE SET NULL;
END IF;
END $$;
-- Add foreign key for orchestrator_agent_id
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'microdaos_orchestrator_agent_fk'
) THEN
ALTER TABLE microdaos
ADD CONSTRAINT microdaos_orchestrator_agent_fk
FOREIGN KEY (orchestrator_agent_id)
REFERENCES agents(id)
ON DELETE SET NULL;
END IF;
END $$;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON COLUMN agents.is_orchestrator IS 'Agent can create and manage microDAOs';
COMMENT ON COLUMN agents.visibility_scope IS 'Visibility: global (everyone), microdao (members only), private (owner only)';
COMMENT ON COLUMN agents.is_public IS 'Agent visible in public Citizens catalog';
COMMENT ON COLUMN agents.primary_microdao_id IS 'Primary microDAO affiliation';
COMMENT ON COLUMN microdaos.is_platform IS 'MicroDAO is a platform/district (top-level organization)';
COMMENT ON COLUMN microdaos.orchestrator_agent_id IS 'Main orchestrator agent for this microDAO';
COMMENT ON COLUMN microdaos.parent_microdao_id IS 'Parent microDAO for hierarchy (platform -> child)';

View File

@@ -233,6 +233,8 @@ class MicrodaoBadge(BaseModel):
name: str
slug: Optional[str] = None
role: Optional[str] = None # orchestrator, member, etc.
is_public: bool = True
is_platform: bool = False
class AgentSummary(BaseModel):
@@ -251,11 +253,12 @@ class AgentSummary(BaseModel):
node_label: Optional[str] = None # "НОДА1" / "НОДА2"
home_node: Optional[HomeNodeView] = None
# Visibility
visibility_scope: str = "city" # city, microdao, owner_only
# Visibility & roles
visibility_scope: str = "city" # global, microdao, private
is_listed_in_directory: bool = True
is_system: bool = False
is_public: bool = False # backward compatibility
is_public: bool = False
is_orchestrator: bool = False # Can create/manage microDAOs
# MicroDAO
primary_microdao_id: Optional[str] = None
@@ -354,12 +357,27 @@ class MicrodaoSummary(BaseModel):
name: str
description: Optional[str] = None
district: Optional[str] = None
# Visibility & type
is_public: bool = True
is_platform: bool = False # Is a platform/district
is_active: bool = True
# Orchestrator
orchestrator_agent_id: Optional[str] = None
is_active: bool
orchestrator_agent_name: Optional[str] = None
# Hierarchy
parent_microdao_id: Optional[str] = None
parent_microdao_slug: Optional[str] = None
# Stats
logo_url: Optional[str] = None
agents_count: int
rooms_count: int
channels_count: int
member_count: int = 0 # alias for agents_count
agents_count: int = 0 # backward compatibility
room_count: int = 0 # alias for rooms_count
rooms_count: int = 0 # backward compatibility
channels_count: int = 0
class MicrodaoChannelView(BaseModel):
@@ -385,13 +403,25 @@ class MicrodaoDetail(BaseModel):
name: str
description: Optional[str] = None
district: Optional[str] = None
# Visibility & type
is_public: bool = True
is_platform: bool = False
is_active: bool = True
# Orchestrator
orchestrator_agent_id: Optional[str] = None
orchestrator_display_name: Optional[str] = None
is_active: bool
is_public: bool
# Hierarchy
parent_microdao_id: Optional[str] = None
parent_microdao_slug: Optional[str] = None
child_microdaos: List["MicrodaoSummary"] = []
# Content
logo_url: Optional[str] = None
agents: List[MicrodaoAgentView]
channels: List[MicrodaoChannelView]
agents: List[MicrodaoAgentView] = []
channels: List[MicrodaoChannelView] = []
public_citizens: List[MicrodaoCitizenView] = []

View File

@@ -295,6 +295,8 @@ async def get_rooms_for_map() -> List[dict]:
async def list_agent_summaries(
*,
node_id: Optional[str] = None,
microdao_id: Optional[str] = None,
is_public: Optional[bool] = None,
visibility_scope: Optional[str] = None,
listed_only: Optional[bool] = None,
kinds: Optional[List[str]] = None,
@@ -322,6 +324,14 @@ async def list_agent_summaries(
params.append(node_id)
where_clauses.append(f"a.node_id = ${len(params)}")
if microdao_id:
params.append(microdao_id)
where_clauses.append(f"EXISTS (SELECT 1 FROM microdao_agents ma WHERE ma.agent_id = a.id AND ma.microdao_id = ${len(params)})")
if is_public is not None:
params.append(is_public)
where_clauses.append(f"COALESCE(a.is_public, false) = ${len(params)}")
if visibility_scope:
params.append(visibility_scope)
where_clauses.append(f"COALESCE(a.visibility_scope, 'city') = ${len(params)}")
@@ -359,6 +369,7 @@ async def list_agent_summaries(
COALESCE(a.is_listed_in_directory, true) AS is_listed_in_directory,
COALESCE(a.is_system, false) AS is_system,
COALESCE(a.is_public, false) AS is_public,
COALESCE(a.is_orchestrator, false) AS is_orchestrator,
a.primary_microdao_id,
pm.name AS primary_microdao_name,
pm.slug AS primary_microdao_slug,
@@ -1246,17 +1257,26 @@ async def get_microdaos(district: Optional[str] = None, q: Optional[str] = None,
m.name,
m.description,
m.district,
m.owner_agent_id as orchestrator_agent_id,
COALESCE(m.orchestrator_agent_id, m.owner_agent_id) as orchestrator_agent_id,
oa.display_name as orchestrator_agent_name,
m.is_active,
COALESCE(m.is_public, true) as is_public,
COALESCE(m.is_platform, false) as is_platform,
m.parent_microdao_id,
pm.slug as parent_microdao_slug,
m.logo_url,
COUNT(DISTINCT ma.agent_id) AS agents_count,
COUNT(DISTINCT ma.agent_id) AS member_count,
COUNT(DISTINCT mc.id) AS channels_count,
COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS rooms_count
COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS rooms_count,
COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS room_count
FROM microdaos m
LEFT JOIN microdao_agents ma ON ma.microdao_id = m.id
LEFT JOIN microdao_channels mc ON mc.microdao_id = m.id
LEFT JOIN agents oa ON COALESCE(m.orchestrator_agent_id, m.owner_agent_id) = oa.id
LEFT JOIN microdaos pm ON m.parent_microdao_id = pm.id
WHERE {where_sql}
GROUP BY m.id
GROUP BY m.id, oa.display_name, pm.slug
ORDER BY m.name
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
"""
@@ -1269,6 +1289,93 @@ async def get_microdaos(district: Optional[str] = None, q: Optional[str] = None,
return [dict(row) for row in rows]
async def list_microdao_summaries(
*,
is_public: Optional[bool] = None,
is_platform: Optional[bool] = None,
district: Optional[str] = None,
q: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[dict]:
"""
Unified method to list microDAOs.
Wraps get_microdaos with additional filtering.
"""
pool = await get_pool()
params = []
where_clauses = [
"COALESCE(m.is_archived, false) = false",
"COALESCE(m.is_test, false) = false",
"m.deleted_at IS NULL",
"m.is_active = true"
]
if is_public is not None:
params.append(is_public)
where_clauses.append(f"COALESCE(m.is_public, true) = ${len(params)}")
if is_platform is not None:
params.append(is_platform)
where_clauses.append(f"COALESCE(m.is_platform, false) = ${len(params)}")
if district:
params.append(district)
where_clauses.append(f"m.district = ${len(params)}")
if q:
params.append(f"%{q}%")
where_clauses.append(f"(m.name ILIKE ${len(params)} OR m.description ILIKE ${len(params)})")
where_sql = " AND ".join(where_clauses)
query = f"""
SELECT
m.id,
m.slug,
m.name,
m.description,
m.district,
COALESCE(m.orchestrator_agent_id, m.owner_agent_id) as orchestrator_agent_id,
oa.display_name as orchestrator_agent_name,
m.is_active,
COALESCE(m.is_public, true) as is_public,
COALESCE(m.is_platform, false) as is_platform,
m.parent_microdao_id,
pm.slug as parent_microdao_slug,
m.logo_url,
COUNT(DISTINCT ma.agent_id) AS agents_count,
COUNT(DISTINCT ma.agent_id) AS member_count,
COUNT(DISTINCT mc.id) AS channels_count,
COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS rooms_count,
COUNT(DISTINCT CASE WHEN mc.kind = 'city_room' THEN mc.id END) AS room_count
FROM microdaos m
LEFT JOIN microdao_agents ma ON ma.microdao_id = m.id
LEFT JOIN microdao_channels mc ON mc.microdao_id = m.id
LEFT JOIN agents oa ON COALESCE(m.orchestrator_agent_id, m.owner_agent_id) = oa.id
LEFT JOIN microdaos pm ON m.parent_microdao_id = pm.id
WHERE {where_sql}
GROUP BY m.id, oa.display_name, pm.slug
ORDER BY m.name
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
"""
params.append(limit)
params.append(offset)
rows = await pool.fetch(query, *params)
return [dict(row) for row in rows]
async def get_microdao_detail(slug: str) -> Optional[dict]:
"""
Get detailed microDAO info including agents, channels, children.
Alias for get_microdao_by_slug with clearer naming.
"""
return await get_microdao_by_slug(slug)
async def get_microdao_by_slug(slug: str) -> Optional[dict]:
"""Отримати детальну інформацію про MicroDAO"""
pool = await get_pool()
@@ -1281,14 +1388,21 @@ async def get_microdao_by_slug(slug: str) -> Optional[dict]:
m.name,
m.description,
m.district,
m.owner_agent_id as orchestrator_agent_id,
COALESCE(m.orchestrator_agent_id, m.owner_agent_id) as orchestrator_agent_id,
a.display_name as orchestrator_display_name,
m.is_active,
m.is_public,
m.logo_url,
a.display_name as orchestrator_display_name
COALESCE(m.is_public, true) as is_public,
COALESCE(m.is_platform, false) as is_platform,
m.parent_microdao_id,
pm.slug as parent_microdao_slug,
m.logo_url
FROM microdaos m
LEFT JOIN agents a ON m.owner_agent_id = a.id
WHERE m.slug = $1 AND COALESCE(m.is_archived, false) = false
LEFT JOIN agents a ON COALESCE(m.orchestrator_agent_id, m.owner_agent_id) = a.id
LEFT JOIN microdaos pm ON m.parent_microdao_id = pm.id
WHERE m.slug = $1
AND COALESCE(m.is_archived, false) = false
AND COALESCE(m.is_test, false) = false
AND m.deleted_at IS NULL
"""
dao_row = await pool.fetchrow(query_dao, slug)
@@ -1308,6 +1422,9 @@ async def get_microdao_by_slug(slug: str) -> Optional[dict]:
FROM microdao_agents ma
JOIN agents a ON ma.agent_id = a.id
WHERE ma.microdao_id = $1
AND COALESCE(a.is_archived, false) = false
AND COALESCE(a.is_test, false) = false
AND a.deleted_at IS NULL
ORDER BY ma.is_core DESC, ma.role
"""
agents_rows = await pool.fetch(query_agents, dao_id)
@@ -1327,6 +1444,20 @@ async def get_microdao_by_slug(slug: str) -> Optional[dict]:
channels_rows = await pool.fetch(query_channels, dao_id)
result["channels"] = [dict(row) for row in channels_rows]
# 4. Get child microDAOs
query_children = """
SELECT id, slug, name, COALESCE(is_public, true) as is_public,
COALESCE(is_platform, false) as is_platform
FROM microdaos
WHERE parent_microdao_id = $1
AND COALESCE(is_archived, false) = false
AND COALESCE(is_test, false) = false
AND deleted_at IS NULL
ORDER BY name
"""
children_rows = await pool.fetch(query_children, dao_id)
result["child_microdaos"] = [dict(row) for row in children_rows]
public_citizens = await get_microdao_public_citizens(dao_id)
result["public_citizens"] = public_citizens

View File

@@ -68,7 +68,9 @@ class MicrodaoMembershipPayload(BaseModel):
async def list_agents(
kind: Optional[str] = Query(None, description="Filter by agent kind"),
node_id: Optional[str] = Query(None, description="Filter by node_id"),
visibility_scope: Optional[str] = Query(None, description="Filter by visibility: city, microdao, owner_only"),
microdao_id: Optional[str] = Query(None, description="Filter by microDAO id"),
is_public: Optional[bool] = Query(None, description="Filter by public status"),
visibility_scope: Optional[str] = Query(None, description="Filter by visibility: global, microdao, private"),
include_system: bool = Query(True, description="Include system agents"),
limit: int = Query(100, le=200),
offset: int = Query(0, ge=0)
@@ -78,6 +80,8 @@ async def list_agents(
kinds_list = [kind] if kind else None
agents, total = await repo_city.list_agent_summaries(
node_id=node_id,
microdao_id=microdao_id,
is_public=is_public,
visibility_scope=visibility_scope,
kinds=kinds_list,
include_system=include_system,
@@ -126,6 +130,7 @@ async def list_agents(
is_listed_in_directory=agent.get("is_listed_in_directory", True),
is_system=agent.get("is_system", False),
is_public=agent.get("is_public", False),
is_orchestrator=agent.get("is_orchestrator", False),
primary_microdao_id=agent.get("primary_microdao_id"),
primary_microdao_name=agent.get("primary_microdao_name"),
primary_microdao_slug=agent.get("primary_microdao_slug"),
@@ -1320,6 +1325,8 @@ async def get_agents_presence_snapshot():
@router.get("/microdao", response_model=List[MicrodaoSummary])
async def get_microdaos(
district: Optional[str] = Query(None, description="Filter by district"),
is_public: Optional[bool] = Query(None, description="Filter by public status"),
is_platform: Optional[bool] = Query(None, description="Filter by platform status"),
q: Optional[str] = Query(None, description="Search by name/description"),
limit: int = Query(50, le=100),
offset: int = Query(0, ge=0)
@@ -1328,10 +1335,19 @@ async def get_microdaos(
Отримати список MicroDAOs.
- **district**: фільтр по дістрікту (Core, Energy, Green, Labs, etc.)
- **is_public**: фільтр по публічності
- **is_platform**: фільтр по типу (платформа/дістрікт)
- **q**: пошук по назві або опису
"""
try:
daos = await repo_city.get_microdaos(district=district, q=q, limit=limit, offset=offset)
daos = await repo_city.list_microdao_summaries(
district=district,
is_public=is_public,
is_platform=is_platform,
q=q,
limit=limit,
offset=offset
)
result = []
for dao in daos:
@@ -1341,10 +1357,17 @@ async def get_microdaos(
name=dao["name"],
description=dao.get("description"),
district=dao.get("district"),
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
is_public=dao.get("is_public", True),
is_platform=dao.get("is_platform", False),
is_active=dao.get("is_active", True),
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
orchestrator_agent_name=dao.get("orchestrator_agent_name"),
parent_microdao_id=dao.get("parent_microdao_id"),
parent_microdao_slug=dao.get("parent_microdao_slug"),
logo_url=dao.get("logo_url"),
member_count=dao.get("member_count", 0),
agents_count=dao.get("agents_count", 0),
room_count=dao.get("room_count", 0),
rooms_count=dao.get("rooms_count", 0),
channels_count=dao.get("channels_count", 0)
))
@@ -1405,16 +1428,31 @@ async def get_microdao_by_slug(slug: str):
primary_room_slug=citizen.get("public_primary_room_slug")
))
# Build child microDAOs list
child_microdaos = []
for child in dao.get("child_microdaos", []):
child_microdaos.append(MicrodaoSummary(
id=child["id"],
slug=child["slug"],
name=child["name"],
is_public=child.get("is_public", True),
is_platform=child.get("is_platform", False)
))
return MicrodaoDetail(
id=dao["id"],
slug=dao["slug"],
name=dao["name"],
description=dao.get("description"),
district=dao.get("district"),
is_public=dao.get("is_public", True),
is_platform=dao.get("is_platform", False),
is_active=dao.get("is_active", True),
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
orchestrator_display_name=dao.get("orchestrator_display_name"),
is_active=dao.get("is_active", True),
is_public=dao.get("is_public", True),
parent_microdao_id=dao.get("parent_microdao_id"),
parent_microdao_slug=dao.get("parent_microdao_slug"),
child_microdaos=child_microdaos,
logo_url=dao.get("logo_url"),
agents=agents,
channels=channels,