feat: add agent visibility APIs and harden runtime verification

Add Day 4 visibility endpoints and response models, strengthen collaboration/task verification behavior, and patch conversation schema startup migration for agent_state compatibility. Extend backend regression coverage for runtime schemas, verifier behavior, visibility APIs, router auth, and legacy conversation list loading.
This commit is contained in:
2026-04-04 00:56:03 +08:00
parent aa0ef0fbea
commit a7b6b5eb90
24 changed files with 2986 additions and 111 deletions

View File

@@ -1,12 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.models.skill import Skill
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.agent import AgentCreate, AgentOut, AgentStats, AgentConfigUpdate, AgentConfigOut
from app.schemas.agent import (
AgentConfigOut,
AgentConfigUpdate,
AgentCreate,
AgentOut,
AgentStats,
AgentVisibilityEvidenceOut,
AgentVisibilityEventsResponse,
AgentVisibilityEventOut,
AgentVisibilityTaskSummaryOut,
AgentVisibilityThreadMessageOut,
AgentVisibilityThreadOut,
AgentVisibilityTopologyNodeOut,
AgentVisibilityTopologyOut,
AgentVisibilityVerifierOut,
)
from app.services.agent_service import _extract_continuity_snapshot
router = APIRouter(prefix="/api/agents", tags=["Agent"])
@@ -21,6 +42,147 @@ SUB_COMMANDERS_BY_ROLE = {
"librarian": ["librarian_retrieval", "librarian_graph"],
"analyst": ["analyst_progress", "analyst_insights"],
}
ALLOWED_AGENT_ROLES = set(DEFAULT_AGENT_ROLES) | {
role
for sub_roles in SUB_COMMANDERS_BY_ROLE.values()
for role in sub_roles
}
def _parse_visibility_datetime(value: str | None) -> datetime | None:
if value is None:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="时间参数必须是 ISO 8601 格式") from exc
async def _get_visibility_state(
conversation_id: str,
*,
current_user: User,
db: AsyncSession,
) -> dict[str, Any]:
result = await db.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == current_user.id,
)
)
conversation = result.scalar_one_or_none()
if conversation is None:
raise HTTPException(status_code=404, detail="对话不存在")
snapshot = _extract_continuity_snapshot(conversation.agent_state)
if snapshot is None:
raise HTTPException(status_code=404, detail="当前会话暂无可视化运行时数据")
return snapshot
def _coerce_event_payload(event: dict[str, Any]) -> AgentVisibilityEventOut:
return AgentVisibilityEventOut.model_validate(event)
def _filter_events(
events: list[dict[str, Any]],
*,
agent_id: str | None,
thread_id: str | None,
event_type: str | None,
started_after: datetime | None,
ended_before: datetime | None,
) -> list[dict[str, Any]]:
filtered: list[dict[str, Any]] = []
for event in events:
if agent_id and event.get("agent_id") != agent_id:
continue
if thread_id and event.get("thread_id") != thread_id:
continue
if event_type and event.get("event_type") != event_type:
continue
timestamp_raw = event.get("timestamp")
timestamp = None
if isinstance(timestamp_raw, str):
try:
timestamp = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00"))
except ValueError:
timestamp = None
if started_after and timestamp and timestamp < started_after:
continue
if ended_before and timestamp and timestamp > ended_before:
continue
filtered.append(event)
return filtered
def _summarize_tasks(tasks: list[dict[str, Any]], task_results: list[dict[str, Any]]) -> list[AgentVisibilityTaskSummaryOut]:
result_by_task_id = {item.get("task_id"): item for item in task_results}
summaries: list[AgentVisibilityTaskSummaryOut] = []
for task in tasks:
task_id = str(task.get("task_id") or "")
result = result_by_task_id.get(task_id) or {}
evidence = result.get("evidence") or task.get("evidence") or []
summaries.append(
AgentVisibilityTaskSummaryOut(
task_id=task_id,
role=task.get("role"),
owner_agent_id=task.get("owner_agent_id") or result.get("owner_agent_id"),
status=result.get("status") or task.get("status"),
summary=result.get("summary") or task.get("result_summary"),
evidence_count=len(evidence),
)
)
return summaries
def _build_topology_nodes(
state: dict[str, Any],
tasks: list[dict[str, Any]],
task_results: list[dict[str, Any]],
) -> list[AgentVisibilityTopologyNodeOut]:
task_counts: dict[str, int] = {}
completed_counts: dict[str, int] = {}
for task in tasks:
owner = str(task.get("owner_agent_id") or "")
if owner:
task_counts[owner] = task_counts.get(owner, 0) + 1
for result in task_results:
owner = str(result.get("owner_agent_id") or "")
if owner and result.get("status") == "completed":
completed_counts[owner] = completed_counts.get(owner, 0) + 1
root_agent_id = str(state.get("root_agent_id") or state.get("agent_id") or "") or None
current_agent = str(state.get("current_agent") or "") or None
nodes: dict[str, AgentVisibilityTopologyNodeOut] = {}
if root_agent_id:
nodes[root_agent_id] = AgentVisibilityTopologyNodeOut(
agent_id=root_agent_id,
role=root_agent_id.split("-")[0],
parent_agent_id=None,
source="root",
task_count=task_counts.get(root_agent_id, 0),
completed_task_count=completed_counts.get(root_agent_id, 0),
)
for agent_id in state.get("spawned_agent_ids") or []:
agent_id = str(agent_id)
nodes[agent_id] = AgentVisibilityTopologyNodeOut(
agent_id=agent_id,
role=agent_id.split("-")[0],
parent_agent_id=root_agent_id,
source="spawned",
task_count=task_counts.get(agent_id, 0),
completed_task_count=completed_counts.get(agent_id, 0),
)
if current_agent and current_agent not in nodes:
nodes[current_agent] = AgentVisibilityTopologyNodeOut(
agent_id=current_agent,
role=current_agent.split("-")[0],
parent_agent_id=None if current_agent == root_agent_id else root_agent_id,
source="current",
task_count=task_counts.get(current_agent, 0),
completed_task_count=completed_counts.get(current_agent, 0),
)
return list(nodes.values())
def record_agent_call(agent_id: str):
@@ -83,6 +245,7 @@ async def get_agent_hierarchy_stats(
@router.get("/config/{agent_id}", response_model=AgentConfigOut)
async def get_agent_config(
agent_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Agent).where(Agent.role == agent_id))
@@ -172,12 +335,159 @@ async def update_agent_config(
)
@router.get("/visibility/events", response_model=AgentVisibilityEventsResponse)
async def get_visibility_events(
conversation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
agent_id: str | None = None,
thread_id: str | None = None,
event_type: str | None = None,
started_after: str | None = None,
ended_before: str | None = None,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
):
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
events = [dict(item) for item in state.get("event_trace") or []]
filtered = _filter_events(
events,
agent_id=agent_id,
thread_id=thread_id,
event_type=event_type,
started_after=_parse_visibility_datetime(started_after),
ended_before=_parse_visibility_datetime(ended_before),
)
paged = filtered[offset:offset + limit]
return AgentVisibilityEventsResponse(
conversation_id=conversation_id,
total=len(filtered),
limit=limit,
offset=offset,
items=[_coerce_event_payload(item) for item in paged],
)
@router.get("/visibility/topology", response_model=AgentVisibilityTopologyOut)
async def get_visibility_topology(
conversation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
tasks = [dict(item) for item in state.get("active_tasks") or []]
task_results = [dict(item) for item in state.get("task_results") or []]
nodes = _build_topology_nodes(state, tasks, task_results)
root_agent_id = str(state.get("root_agent_id") or state.get("agent_id") or "") or None
edges = [
{"parent_agent_id": root_agent_id, "child_agent_id": node.agent_id}
for node in nodes
if node.parent_agent_id and root_agent_id and node.agent_id != root_agent_id
]
return AgentVisibilityTopologyOut(
conversation_id=conversation_id,
root_agent_id=root_agent_id,
current_agent=str(state.get("current_agent") or "") or None,
nodes=nodes,
edges=edges,
tasks=_summarize_tasks(tasks, task_results),
task_hierarchy=dict(state.get("task_hierarchy") or {}),
)
@router.get("/visibility/tasks/{task_id}/evidence", response_model=AgentVisibilityEvidenceOut)
async def get_visibility_task_evidence(
task_id: str,
conversation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
tasks = [dict(item) for item in state.get("active_tasks") or []]
task = next((item for item in tasks if item.get("task_id") == task_id), None)
task_results = [dict(item) for item in state.get("task_results") or []]
result = next((item for item in task_results if item.get("task_id") == task_id), None)
if task is None and result is None:
raise HTTPException(status_code=404, detail="任务不存在")
tool_outcomes = [
dict(evidence)
for evidence in (result or {}).get("evidence") or []
if isinstance(evidence, dict) and evidence.get("tool_name")
]
verification_entry = next(
(
dict(evidence)
for evidence in (result or {}).get("evidence") or []
if isinstance(evidence, dict) and evidence.get("type") == "verification"
),
None,
)
verifier = {
"status": (verification_entry or {}).get("status"),
"summary": (verification_entry or {}).get("summary"),
"evidence": [dict(item) for item in state.get("verification_evidence") or [] if item.get("task_id") == task_id],
}
return AgentVisibilityEvidenceOut(
conversation_id=conversation_id,
task_id=task_id,
task=task,
result=result,
tool_outcomes=tool_outcomes,
verifier=verifier,
)
@router.get("/visibility/threads/{thread_id}/messages", response_model=AgentVisibilityThreadOut)
async def get_visibility_thread_messages(
thread_id: str,
conversation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
items = [
AgentVisibilityThreadMessageOut.model_validate(item)
for item in state.get("message_trace") or []
if isinstance(item, dict) and item.get("thread_id") == thread_id
]
if not items:
raise HTTPException(status_code=404, detail="线程不存在")
return AgentVisibilityThreadOut(
conversation_id=conversation_id,
thread_id=thread_id,
total=len(items),
items=items,
)
@router.get("/visibility/verifier", response_model=AgentVisibilityVerifierOut)
async def get_visibility_verifier(
conversation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
return AgentVisibilityVerifierOut(
conversation_id=conversation_id,
status=state.get("verification_status"),
summary=state.get("verification_summary"),
evidence=list(state.get("verification_evidence") or []),
)
@router.post("", response_model=AgentOut, status_code=201)
async def create_agent(
data: AgentCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail="仅管理员可创建 Agent")
if not data.spawn_permission:
raise HTTPException(status_code=400, detail="缺少 spawn_permission禁止直接创建 runtime agent")
if data.role not in ALLOWED_AGENT_ROLES:
raise HTTPException(status_code=400, detail="不支持的 Agent 角色")
agent = Agent(
name=data.name,
role=data.role,
@@ -193,6 +503,7 @@ async def create_agent(
@router.get("/{agent_id}", response_model=AgentOut)
async def get_agent(
agent_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Agent).where(Agent.id == agent_id))