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