feat: unified Agent/Citizen model with visibility controls

- Add visibility_scope, is_listed_in_directory, is_system, primary_microdao_id to agents
- Create unified list_agent_summaries method
- Add PUT /city/agents/{id}/visibility endpoint
- Add AgentVisibilityCard component
- Update AgentSummary types for frontend
This commit is contained in:
Apple
2025-11-28 07:56:33 -08:00
parent 6f4270aa64
commit 15714fb170
9 changed files with 637 additions and 22 deletions

View File

@@ -227,19 +227,47 @@ class UsageStats(BaseModel):
last_active: Optional[str] = None
class AgentSummary(BaseModel):
"""Agent summary for Agent Console"""
class MicrodaoBadge(BaseModel):
"""MicroDAO badge for agent display"""
id: str
name: str
slug: Optional[str] = None
role: Optional[str] = None # orchestrator, member, etc.
class AgentSummary(BaseModel):
"""Unified Agent summary for Agent Console and Citizens"""
id: str
slug: Optional[str] = None
display_name: str
title: Optional[str] = None # public_title
tagline: Optional[str] = None # public_tagline
kind: str = "assistant"
avatar_url: Optional[str] = None
status: str = "offline"
is_public: bool = False
public_slug: Optional[str] = None
public_title: Optional[str] = None
district: Optional[str] = None
# Node info
node_id: Optional[str] = None
node_label: Optional[str] = None # "НОДА1" / "НОДА2"
home_node: Optional[HomeNodeView] = None
microdao_memberships: List[Dict[str, Any]] = []
# Visibility
visibility_scope: str = "city" # city, microdao, owner_only
is_listed_in_directory: bool = True
is_system: bool = False
is_public: bool = False # backward compatibility
# MicroDAO
primary_microdao_id: Optional[str] = None
primary_microdao_name: Optional[str] = None
primary_microdao_slug: Optional[str] = None
district: Optional[str] = None
microdaos: List[MicrodaoBadge] = []
microdao_memberships: List[Dict[str, Any]] = [] # backward compatibility
# Skills
public_skills: List[str] = []
# Future: model bindings and usage stats
model_bindings: Optional[ModelBindings] = None
usage_stats: Optional[UsageStats] = None

View File

@@ -292,8 +292,137 @@ async def get_rooms_for_map() -> List[dict]:
# Agents Repository
# =============================================================================
async def list_agent_summaries(
*,
node_id: Optional[str] = None,
visibility_scope: Optional[str] = None,
listed_only: Optional[bool] = None,
kinds: Optional[List[str]] = None,
include_system: bool = True,
include_archived: bool = False,
limit: int = 200,
offset: int = 0
) -> Tuple[List[dict], int]:
"""
Unified method to list agents with all necessary data.
Used by both Agent Console and Citizens page.
"""
pool = await get_pool()
params: List[Any] = []
where_clauses = []
# Always filter archived unless explicitly included
if not include_archived:
where_clauses.append("COALESCE(a.is_archived, false) = false")
if node_id:
params.append(node_id)
where_clauses.append(f"a.node_id = ${len(params)}")
if visibility_scope:
params.append(visibility_scope)
where_clauses.append(f"COALESCE(a.visibility_scope, 'city') = ${len(params)}")
if listed_only is True:
where_clauses.append("COALESCE(a.is_listed_in_directory, true) = true")
elif listed_only is False:
where_clauses.append("COALESCE(a.is_listed_in_directory, true) = false")
if kinds:
params.append(kinds)
where_clauses.append(f"a.kind = ANY(${len(params)})")
if not include_system:
where_clauses.append("COALESCE(a.is_system, false) = false")
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
query = f"""
SELECT
a.id,
COALESCE(a.slug, a.public_slug, LOWER(REPLACE(a.display_name, ' ', '-'))) AS slug,
a.display_name,
COALESCE(a.public_title, '') AS title,
COALESCE(a.public_tagline, '') AS tagline,
a.kind,
a.avatar_url,
COALESCE(a.status, 'offline') AS status,
a.node_id,
nc.node_name AS node_label,
nc.hostname AS node_hostname,
nc.roles AS node_roles,
nc.environment AS node_environment,
COALESCE(a.visibility_scope, 'city') AS visibility_scope,
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,
a.primary_microdao_id,
pm.name AS primary_microdao_name,
pm.slug AS primary_microdao_slug,
pm.district AS district,
COALESCE(a.public_skills, ARRAY[]::text[]) AS public_skills,
COUNT(*) OVER() AS total_count
FROM agents a
LEFT JOIN node_cache nc ON a.node_id = nc.node_id
LEFT JOIN microdaos pm ON a.primary_microdao_id = pm.id
WHERE {where_sql}
ORDER BY a.display_name
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
"""
params.append(limit)
params.append(offset)
rows = await pool.fetch(query, *params)
if not rows:
return [], 0
total = rows[0]["total_count"]
items = []
for row in rows:
data = dict(row)
data.pop("total_count", None)
# Build home_node object
if data.get("node_id"):
data["home_node"] = {
"id": data.get("node_id"),
"name": data.get("node_label"),
"hostname": data.get("node_hostname"),
"roles": list(data.get("node_roles") or []),
"environment": data.get("node_environment")
}
else:
data["home_node"] = None
# Clean up intermediate fields
for key in ["node_hostname", "node_roles", "node_environment"]:
data.pop(key, None)
# Get MicroDAO memberships
memberships = await get_agent_microdao_memberships(data["id"])
data["microdaos"] = [
{
"id": m.get("microdao_id", ""),
"name": m.get("name", ""),
"slug": m.get("slug"),
"role": m.get("role")
}
for m in memberships
]
data["microdao_memberships"] = memberships # backward compatibility
data["public_skills"] = list(data.get("public_skills") or [])
items.append(data)
return items, total
async def get_all_agents() -> List[dict]:
"""Отримати всіх агентів (non-archived)"""
"""Отримати всіх агентів (non-archived) - legacy method"""
pool = await get_pool()
query = """
@@ -308,6 +437,29 @@ async def get_all_agents() -> List[dict]:
return [dict(row) for row in rows]
async def update_agent_visibility(
agent_id: str,
visibility_scope: str,
is_listed_in_directory: bool
) -> bool:
"""Оновити налаштування видимості агента"""
pool = await get_pool()
query = """
UPDATE agents
SET visibility_scope = $2,
is_listed_in_directory = $3,
is_public = $3,
updated_at = NOW()
WHERE id = $1
AND COALESCE(is_archived, false) = false
RETURNING id
"""
result = await pool.fetchrow(query, agent_id, visibility_scope, is_listed_in_directory)
return result is not None
async def get_agents_with_home_node(
kind: Optional[str] = None,
node_id: Optional[str] = None,

View File

@@ -22,6 +22,7 @@ from models_city import (
AgentRead,
AgentPresence,
AgentSummary,
MicrodaoBadge,
HomeNodeView,
NodeProfile,
PublicCitizenSummary,
@@ -67,14 +68,19 @@ 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"),
include_system: bool = Query(True, description="Include system agents"),
limit: int = Query(100, le=200),
offset: int = Query(0, ge=0)
):
"""Список всіх агентів для Agent Console"""
"""Список всіх агентів для Agent Console (unified API)"""
try:
agents, total = await repo_city.get_agents_with_home_node(
kind=kind,
kinds_list = [kind] if kind else None
agents, total = await repo_city.list_agent_summaries(
node_id=node_id,
visibility_scope=visibility_scope,
kinds=kinds_list,
include_system=include_system,
limit=limit,
offset=offset
)
@@ -93,21 +99,40 @@ async def list_agents(
environment=home_node_data.get("environment")
)
# Get microdao memberships
memberships = await repo_city.get_agent_microdao_memberships(agent["id"])
# Build microdao badges
microdaos = [
MicrodaoBadge(
id=m.get("id", ""),
name=m.get("name", ""),
slug=m.get("slug"),
role=m.get("role")
)
for m in agent.get("microdaos", [])
]
items.append(AgentSummary(
id=agent["id"],
slug=agent.get("slug"),
display_name=agent["display_name"],
title=agent.get("title"),
tagline=agent.get("tagline"),
kind=agent.get("kind", "assistant"),
avatar_url=agent.get("avatar_url"),
status=agent.get("status", "offline"),
is_public=agent.get("is_public", False),
public_slug=agent.get("public_slug"),
public_title=agent.get("public_title"),
district=agent.get("public_district"),
node_id=agent.get("node_id"),
node_label=agent.get("node_label"),
home_node=home_node,
microdao_memberships=memberships
visibility_scope=agent.get("visibility_scope", "city"),
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),
primary_microdao_id=agent.get("primary_microdao_id"),
primary_microdao_name=agent.get("primary_microdao_name"),
primary_microdao_slug=agent.get("primary_microdao_slug"),
district=agent.get("district"),
microdaos=microdaos,
microdao_memberships=agent.get("microdao_memberships", []),
public_skills=agent.get("public_skills", [])
))
return {"items": items, "total": total}
@@ -116,6 +141,43 @@ async def list_agents(
raise HTTPException(status_code=500, detail="Failed to list agents")
class AgentVisibilityPayload(BaseModel):
visibility_scope: str # city, microdao, owner_only
is_listed_in_directory: bool = True
@router.put("/agents/{agent_id}/visibility")
async def update_agent_visibility(
agent_id: str,
payload: AgentVisibilityPayload
):
"""Оновити налаштування видимості агента"""
try:
# Validate visibility_scope
if payload.visibility_scope not in ("city", "microdao", "owner_only"):
raise HTTPException(
status_code=400,
detail="visibility_scope must be one of: city, microdao, owner_only"
)
# Update in database
success = await repo_city.update_agent_visibility(
agent_id=agent_id,
visibility_scope=payload.visibility_scope,
is_listed_in_directory=payload.is_listed_in_directory
)
if not success:
raise HTTPException(status_code=404, detail="Agent not found")
return {"status": "ok", "agent_id": agent_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update agent visibility: {e}")
raise HTTPException(status_code=500, detail="Failed to update visibility")
# =============================================================================
# Nodes API (for Node Directory)
# =============================================================================