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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user