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:
File diff suppressed because it is too large
Load Diff
@@ -324,6 +324,25 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
"""
|
||||
|
||||
|
||||
COORDINATOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
|
||||
|
||||
## 你的职责:
|
||||
- 先判断当前请求是否真的需要拆解;不需要时应明确建议继续走 direct
|
||||
- 只有在明显多步骤、跨领域、需要多角色配合时,才拆成 2~4 个子任务
|
||||
- 每个子任务必须清晰写出 `title`、`role`、`goal`、`expected_evidence`
|
||||
- 角色建议只能来自现有 top-level agent:`schedule_planner`、`librarian`、`analyst`、`executor`
|
||||
- 汇总时基于子任务结果回收,不依赖单点硬编码拼接
|
||||
|
||||
## 边界:
|
||||
- 禁止无限递归拆分
|
||||
- 禁止创建新的 runtime agent / worker
|
||||
- 禁止把一个简单请求硬拆成多个空泛步骤
|
||||
- 如果证据不足、子任务未闭环,必须把风险明确暴露出来
|
||||
"""
|
||||
|
||||
|
||||
VERIFIER_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||
|
||||
你是 Jarvis 的验证官,负责对执行结果做最小但明确的核验。
|
||||
|
||||
@@ -57,6 +57,19 @@ TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
||||
),
|
||||
}
|
||||
|
||||
TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
|
||||
AgentRole.MASTER.value: (
|
||||
AgentRole.SCHEDULE_PLANNER.value,
|
||||
AgentRole.EXECUTOR.value,
|
||||
AgentRole.LIBRARIAN.value,
|
||||
AgentRole.ANALYST.value,
|
||||
),
|
||||
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
|
||||
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
|
||||
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
|
||||
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
|
||||
}
|
||||
|
||||
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
||||
"schedule_analysis": AgentRole.SCHEDULE_PLANNER.value,
|
||||
"schedule_planning": AgentRole.SCHEDULE_PLANNER.value,
|
||||
@@ -77,6 +90,8 @@ BUILTIN_AGENT_MANIFESTS: tuple[AgentManifest, ...] = tuple(
|
||||
system_prompt_key=role.value,
|
||||
routing_hints=list(TOP_LEVEL_AGENT_ROUTING_HINTS[role.value]),
|
||||
default_sub_commanders=list(TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS[role.value]),
|
||||
can_spawn_children=bool(TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES[role.value]),
|
||||
allowed_spawn_role_values=list(TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES[role.value]),
|
||||
skill_context_key=role.value.replace("agent_", ""),
|
||||
)
|
||||
for role in AgentRole
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.agents.registry.models import (
|
||||
@dataclass(frozen=True)
|
||||
class RegistryIndexes:
|
||||
agent_by_id: Mapping[str, AgentManifest]
|
||||
agent_by_role_value: Mapping[str, AgentManifest]
|
||||
sub_commander_by_id: Mapping[str, SubCommanderManifest]
|
||||
capability_by_id: Mapping[str, CapabilityManifest]
|
||||
specialist_template_by_id: Mapping[str, SpecialistTemplateManifest]
|
||||
@@ -24,6 +25,7 @@ class RegistryIndexes:
|
||||
skill_context_key_by_agent_id: Mapping[str, str]
|
||||
capability_id_by_tool_name: Mapping[str, str]
|
||||
capability_ids_by_sub_commander_id: Mapping[str, tuple[str, ...]]
|
||||
spawnable_role_values_by_agent_id: Mapping[str, tuple[str, ...]]
|
||||
|
||||
|
||||
def summarize_registry_indexes(indexes: RegistryIndexes) -> dict[str, int]:
|
||||
@@ -50,6 +52,9 @@ def build_registry_indexes(bundle: RegistryBundle) -> RegistryIndexes:
|
||||
|
||||
return RegistryIndexes(
|
||||
agent_by_id=MappingProxyType(agent_by_id),
|
||||
agent_by_role_value=MappingProxyType({
|
||||
agent.role_value: agent for agent in bundle.agents
|
||||
}),
|
||||
sub_commander_by_id=MappingProxyType(sub_commander_by_id),
|
||||
capability_by_id=MappingProxyType(capability_by_id),
|
||||
specialist_template_by_id=MappingProxyType(specialist_template_by_id),
|
||||
@@ -73,4 +78,9 @@ def build_registry_indexes(bundle: RegistryBundle) -> RegistryIndexes:
|
||||
sub_commander.sub_commander_id: tuple(sub_commander.capability_ids)
|
||||
for sub_commander in bundle.sub_commanders
|
||||
}),
|
||||
spawnable_role_values_by_agent_id=MappingProxyType({
|
||||
agent.agent_id: tuple(agent.allowed_spawn_role_values)
|
||||
for agent in bundle.agents
|
||||
if agent.can_spawn_children and agent.allowed_spawn_role_values
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PermissionClass(str, Enum):
|
||||
@@ -23,6 +23,8 @@ class AgentManifest(BaseModel):
|
||||
system_prompt_key: str
|
||||
routing_hints: list[str]
|
||||
default_sub_commanders: list[str]
|
||||
can_spawn_children: bool = False
|
||||
allowed_spawn_role_values: list[str] = Field(default_factory=list)
|
||||
skill_context_key: str | None = None
|
||||
continuity_policy: str | None = None
|
||||
clarification_policy: str | None = None
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
from app.agents.schemas.event import AgentEvent
|
||||
from app.agents.schemas.task import AgentTask, TaskResult, TaskLifecycleStatus, VerificationStatus
|
||||
from app.agents.schemas.message import AgentMessage
|
||||
from app.agents.schemas.task import (
|
||||
AgentTask,
|
||||
CollaborationBudget,
|
||||
InterruptRecord,
|
||||
RecoveryRecord,
|
||||
TaskLifecycleStatus,
|
||||
TaskResult,
|
||||
TaskResultStatus,
|
||||
VerificationStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"AgentMessage",
|
||||
"AgentTask",
|
||||
"CollaborationBudget",
|
||||
"InterruptRecord",
|
||||
"RecoveryRecord",
|
||||
"TaskLifecycleStatus",
|
||||
"TaskResult",
|
||||
"TaskResultStatus",
|
||||
"VerificationStatus",
|
||||
]
|
||||
|
||||
@@ -11,6 +11,18 @@ AgentEventType = Literal[
|
||||
"agent.tool.result",
|
||||
"agent.verify.started",
|
||||
"agent.verify.completed",
|
||||
"agent.created",
|
||||
"agent.spawn.blocked",
|
||||
"agent.message.sent",
|
||||
"agent.message.received",
|
||||
"agent.interrupt.requested",
|
||||
"agent.interrupt.completed",
|
||||
"agent.recovery.started",
|
||||
"agent.recovery.completed",
|
||||
"agent.task.interrupted",
|
||||
"agent.task.recovered",
|
||||
"agent.task.reassigned",
|
||||
"agent.collaboration.budget.updated",
|
||||
"agent.error",
|
||||
]
|
||||
AgentEventSeverity = Literal["info", "warning", "error"]
|
||||
@@ -24,5 +36,11 @@ class AgentEvent(BaseModel):
|
||||
agent_id: str | None = None
|
||||
sub_commander_id: str | None = None
|
||||
task_id: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
child_task_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
message_id: str | None = None
|
||||
interrupt_id: str | None = None
|
||||
recovery_id: str | None = None
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
severity: AgentEventSeverity = "info"
|
||||
|
||||
29
backend/app/agents/schemas/message.py
Normal file
29
backend/app/agents/schemas/message.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
AgentMessageType = Literal[
|
||||
"task_request",
|
||||
"task_update",
|
||||
"handoff",
|
||||
"verification_request",
|
||||
"verification_feedback",
|
||||
"interrupt_notice",
|
||||
]
|
||||
|
||||
|
||||
class AgentMessage(BaseModel):
|
||||
message_id: str
|
||||
thread_id: str
|
||||
from_agent_id: str
|
||||
to_agent_id: str
|
||||
task_id: str | None = None
|
||||
reply_to_message_id: str | None = None
|
||||
message_type: AgentMessageType = "task_update"
|
||||
content_summary: str
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -8,6 +8,41 @@ from pydantic import BaseModel, Field
|
||||
|
||||
TaskLifecycleStatus = Literal["pending", "in_progress", "completed", "failed", "blocked"]
|
||||
VerificationStatus = Literal["passed", "failed", "skipped"]
|
||||
TaskResultStatus = Literal["completed", "failed", "blocked", "passed", "skipped"]
|
||||
InterruptStatus = Literal["requested", "acknowledged", "resolved"]
|
||||
BudgetMode = Literal["direct", "collaboration"]
|
||||
|
||||
|
||||
class InterruptRecord(BaseModel):
|
||||
interrupt_id: str
|
||||
reason: str
|
||||
status: InterruptStatus = "requested"
|
||||
requested_by: str | None = None
|
||||
source_event_id: str | None = None
|
||||
requested_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RecoveryRecord(BaseModel):
|
||||
recovery_id: str
|
||||
source_interrupt_id: str | None = None
|
||||
strategy: str | None = None
|
||||
resumed_from_task_id: str | None = None
|
||||
resumed_from_thread_id: str | None = None
|
||||
recovered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CollaborationBudget(BaseModel):
|
||||
mode: BudgetMode = "direct"
|
||||
max_parallel_tasks: int | None = None
|
||||
remaining_parallel_tasks: int | None = None
|
||||
max_tool_calls: int | None = None
|
||||
remaining_tool_calls: int | None = None
|
||||
max_iterations: int | None = None
|
||||
remaining_iterations: int | None = None
|
||||
escalation_threshold: int | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentTask(BaseModel):
|
||||
@@ -17,8 +52,16 @@ class AgentTask(BaseModel):
|
||||
owner_agent_id: str | None = None
|
||||
role: str | None = None
|
||||
goal: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
child_task_ids: list[str] = Field(default_factory=list)
|
||||
thread_id: str | None = None
|
||||
message_id: str | None = None
|
||||
message_index: int | None = None
|
||||
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
interrupt_records: list[InterruptRecord | dict[str, Any]] = Field(default_factory=list)
|
||||
recovery_records: list[RecoveryRecord | dict[str, Any]] = Field(default_factory=list)
|
||||
collaboration_budget: CollaborationBudget | dict[str, Any] | None = None
|
||||
result_summary: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -26,7 +69,17 @@ class AgentTask(BaseModel):
|
||||
|
||||
class TaskResult(BaseModel):
|
||||
task_id: str
|
||||
status: VerificationStatus
|
||||
status: TaskResultStatus
|
||||
summary: str | None = None
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
owner_agent_id: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
child_task_ids: list[str] = Field(default_factory=list)
|
||||
thread_id: str | None = None
|
||||
message_id: str | None = None
|
||||
message_index: int | None = None
|
||||
interrupt_records: list[InterruptRecord | dict[str, Any]] = Field(default_factory=list)
|
||||
recovery_records: list[RecoveryRecord | dict[str, Any]] = Field(default_factory=list)
|
||||
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
|
||||
next_action: str | None = None
|
||||
output_data: dict[str, Any] | None = None
|
||||
|
||||
@@ -3,8 +3,9 @@ from enum import Enum
|
||||
from typing import Annotated, Any, Literal, TypedDict
|
||||
|
||||
from app.agents.schemas.event import AgentEvent
|
||||
from app.agents.schemas.task import AgentTask, TaskResult, VerificationStatus
|
||||
from langchain_core.messages import BaseMessage
|
||||
from app.agents.schemas.message import AgentMessage
|
||||
from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRecord, RecoveryRecord, TaskResult, VerificationStatus
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
|
||||
@@ -24,12 +25,27 @@ class ConversationTurn:
|
||||
model: str | None = None
|
||||
|
||||
|
||||
def turn_to_message(turn: ConversationTurn) -> BaseMessage:
|
||||
if turn.role == "user":
|
||||
return HumanMessage(content=turn.content)
|
||||
return AIMessage(content=turn.content)
|
||||
|
||||
|
||||
class AgentState(TypedDict):
|
||||
messages: Annotated[list[BaseMessage], add_messages]
|
||||
user_id: str
|
||||
conversation_id: str
|
||||
parent_conversation_id: str | None
|
||||
thread_id: str | None
|
||||
last_message_id: str | None
|
||||
message_sequence: int
|
||||
agent_id: str | None
|
||||
parent_agent_id: str | None
|
||||
root_agent_id: str | None
|
||||
collaboration_depth: int
|
||||
spawned_agent_ids: list[str]
|
||||
|
||||
execution_mode: Literal["direct", "delegated", "verified"]
|
||||
execution_mode: Literal["direct", "collaboration", "delegated", "verified"]
|
||||
current_agent: str | None
|
||||
next_step: str | None
|
||||
active_agents: list[AgentRole]
|
||||
@@ -38,11 +54,16 @@ class AgentState(TypedDict):
|
||||
sub_commander_trace: list[dict[str, Any]]
|
||||
agent_trace: list[str]
|
||||
event_trace: list[AgentEvent | dict[str, Any]]
|
||||
message_trace: list[AgentMessage | dict[str, Any]]
|
||||
|
||||
pending_tasks: list[dict[str, Any]]
|
||||
completed_tasks: list[dict[str, Any]]
|
||||
active_tasks: list[AgentTask | dict[str, Any]]
|
||||
task_results: list[TaskResult | dict[str, Any]]
|
||||
task_hierarchy: dict[str, list[str]]
|
||||
interrupted_tasks: list[InterruptRecord | dict[str, Any]]
|
||||
recovery_trace: list[RecoveryRecord | dict[str, Any]]
|
||||
recovery_points: list[dict[str, Any]]
|
||||
tool_calls: list[dict[str, Any]]
|
||||
last_tool_result: str | None
|
||||
action_results: list[dict[str, Any]]
|
||||
@@ -54,7 +75,8 @@ class AgentState(TypedDict):
|
||||
verification_status: VerificationStatus | None
|
||||
verification_summary: str | None
|
||||
verification_evidence: list[dict[str, Any]]
|
||||
budget_state: dict[str, Any] | None
|
||||
budget_state: CollaborationBudget | dict[str, Any] | None
|
||||
collaboration_budget_history: list[CollaborationBudget | dict[str, Any]]
|
||||
|
||||
tool_strategy_used: str | None
|
||||
tool_round_count: int
|
||||
@@ -102,6 +124,15 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
messages=[],
|
||||
user_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
parent_conversation_id=None,
|
||||
thread_id=None,
|
||||
last_message_id=None,
|
||||
message_sequence=0,
|
||||
agent_id=AgentRole.MASTER.value,
|
||||
parent_agent_id=None,
|
||||
root_agent_id=AgentRole.MASTER.value,
|
||||
collaboration_depth=0,
|
||||
spawned_agent_ids=[],
|
||||
execution_mode="direct",
|
||||
current_agent=AgentRole.MASTER.value,
|
||||
next_step=None,
|
||||
@@ -111,10 +142,15 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
sub_commander_trace=[],
|
||||
agent_trace=[AgentRole.MASTER.value],
|
||||
event_trace=[],
|
||||
message_trace=[],
|
||||
pending_tasks=[],
|
||||
completed_tasks=[],
|
||||
active_tasks=[],
|
||||
task_results=[],
|
||||
task_hierarchy={},
|
||||
interrupted_tasks=[],
|
||||
recovery_trace=[],
|
||||
recovery_points=[],
|
||||
tool_calls=[],
|
||||
last_tool_result=None,
|
||||
action_results=[],
|
||||
@@ -126,6 +162,7 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
verification_summary=None,
|
||||
verification_evidence=[],
|
||||
budget_state=None,
|
||||
collaboration_budget_history=[],
|
||||
tool_strategy_used=None,
|
||||
tool_round_count=0,
|
||||
max_tool_rounds=2,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.agents.schemas.task import AgentTask, TaskResult, VerificationStatus
|
||||
from app.agents.schemas.task import AgentTask, TaskResult, TaskResultStatus, VerificationStatus
|
||||
from app.agents.state import AgentState
|
||||
|
||||
|
||||
@@ -14,6 +14,34 @@ class VerificationVerdict(BaseModel):
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
def normalize_task_result(
|
||||
task_result: TaskResult | dict[str, Any],
|
||||
*,
|
||||
default_task_id: str | None = None,
|
||||
) -> TaskResult:
|
||||
payload = task_result.model_dump(mode="json") if isinstance(task_result, TaskResult) else dict(task_result or {})
|
||||
normalized_status = payload.get("status")
|
||||
if normalized_status not in {"completed", "failed", "blocked", "passed", "skipped"}:
|
||||
normalized_status = "failed"
|
||||
return TaskResult(
|
||||
task_id=str(payload.get("task_id") or default_task_id or "unknown-task"),
|
||||
status=cast(TaskResultStatus, normalized_status),
|
||||
summary=payload.get("summary"),
|
||||
evidence=list(payload.get("evidence") or []),
|
||||
owner_agent_id=payload.get("owner_agent_id"),
|
||||
parent_task_id=payload.get("parent_task_id"),
|
||||
child_task_ids=list(payload.get("child_task_ids") or []),
|
||||
thread_id=payload.get("thread_id"),
|
||||
message_id=payload.get("message_id"),
|
||||
message_index=payload.get("message_index") if isinstance(payload.get("message_index"), int) else None,
|
||||
interrupt_records=list(payload.get("interrupt_records") or []),
|
||||
recovery_records=list(payload.get("recovery_records") or []),
|
||||
budget_snapshot=payload.get("budget_snapshot") if isinstance(payload.get("budget_snapshot"), dict) else None,
|
||||
next_action=payload.get("next_action"),
|
||||
output_data=payload.get("output_data") if isinstance(payload.get("output_data"), dict) else None,
|
||||
)
|
||||
|
||||
|
||||
def verify_task_result(
|
||||
*,
|
||||
task: AgentTask | dict[str, Any] | None = None,
|
||||
@@ -30,8 +58,13 @@ def verify_task_result(
|
||||
if status is not None:
|
||||
return VerificationVerdict(status=status, summary=normalized_summary, evidence=normalized_evidence)
|
||||
|
||||
if normalized_result.get("status") in {"passed", "failed", "skipped"}:
|
||||
inferred_status = normalized_result["status"]
|
||||
normalized_status = normalized_result.get("status")
|
||||
if normalized_status in {"passed", "failed", "skipped"}:
|
||||
inferred_status = normalized_status
|
||||
elif normalized_status == "completed":
|
||||
inferred_status = "passed"
|
||||
elif normalized_status == "blocked":
|
||||
inferred_status = "skipped"
|
||||
elif normalized_result.get("success") is True:
|
||||
inferred_status = "passed"
|
||||
elif normalized_result.get("success") is False:
|
||||
@@ -57,4 +90,4 @@ def apply_verification_verdict(state: AgentState, verdict: VerificationVerdict)
|
||||
return AgentState(**next_state)
|
||||
|
||||
|
||||
__all__ = ["VerificationVerdict", "apply_verification_verdict", "verify_task_result"]
|
||||
__all__ = ["VerificationVerdict", "apply_verification_verdict", "normalize_task_result", "verify_task_result"]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import settings
|
||||
from collections.abc import AsyncGenerator
|
||||
import os
|
||||
import re
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
os.makedirs(settings.DATA_DIR, exist_ok=True)
|
||||
|
||||
engine = create_async_engine(
|
||||
@@ -24,12 +27,9 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
@@ -37,6 +37,7 @@ async def init_db():
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await ensure_log_columns(conn)
|
||||
await ensure_message_columns(conn)
|
||||
await ensure_conversation_columns(conn)
|
||||
await ensure_document_columns(conn)
|
||||
await ensure_user_columns(conn)
|
||||
await ensure_forum_columns(conn)
|
||||
@@ -79,6 +80,20 @@ async def ensure_message_columns(conn):
|
||||
await conn.execute(text(ddl))
|
||||
|
||||
|
||||
async def ensure_conversation_columns(conn):
|
||||
rows = await _get_table_info(conn, 'conversations')
|
||||
if not rows:
|
||||
return
|
||||
|
||||
columns = {row[1] for row in rows}
|
||||
required_columns = {
|
||||
'agent_state': "ALTER TABLE conversations ADD COLUMN agent_state JSON",
|
||||
}
|
||||
for column, ddl in required_columns.items():
|
||||
if column not in columns:
|
||||
await conn.execute(text(ddl))
|
||||
|
||||
|
||||
async def ensure_document_columns(conn):
|
||||
result = await conn.execute(text("PRAGMA table_info(documents)"))
|
||||
rows = result.fetchall()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
@@ -6,6 +9,7 @@ class AgentCreate(BaseModel):
|
||||
role: str
|
||||
description: str | None = None
|
||||
system_prompt: str
|
||||
spawn_permission: bool = False
|
||||
|
||||
|
||||
class AgentOut(BaseModel):
|
||||
@@ -55,3 +59,93 @@ class AgentConfigOut(BaseModel):
|
||||
selected_skill_ids: list[str]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AgentVisibilityEventOut(BaseModel):
|
||||
event_id: str
|
||||
event_type: str
|
||||
timestamp: datetime
|
||||
conversation_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
sub_commander_id: str | None = None
|
||||
task_id: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
child_task_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
message_id: str | None = None
|
||||
interrupt_id: str | None = None
|
||||
recovery_id: str | None = None
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
severity: str = "info"
|
||||
|
||||
|
||||
class AgentVisibilityEventsResponse(BaseModel):
|
||||
conversation_id: str
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
items: list[AgentVisibilityEventOut]
|
||||
|
||||
|
||||
class AgentVisibilityTaskSummaryOut(BaseModel):
|
||||
task_id: str
|
||||
role: str | None = None
|
||||
owner_agent_id: str | None = None
|
||||
status: str | None = None
|
||||
summary: str | None = None
|
||||
evidence_count: int = 0
|
||||
|
||||
|
||||
class AgentVisibilityTopologyNodeOut(BaseModel):
|
||||
agent_id: str
|
||||
role: str | None = None
|
||||
parent_agent_id: str | None = None
|
||||
source: str
|
||||
task_count: int = 0
|
||||
completed_task_count: int = 0
|
||||
|
||||
|
||||
class AgentVisibilityTopologyOut(BaseModel):
|
||||
conversation_id: str
|
||||
root_agent_id: str | None = None
|
||||
current_agent: str | None = None
|
||||
nodes: list[AgentVisibilityTopologyNodeOut]
|
||||
edges: list[dict[str, str]]
|
||||
tasks: list[AgentVisibilityTaskSummaryOut]
|
||||
task_hierarchy: dict[str, list[str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentVisibilityEvidenceOut(BaseModel):
|
||||
conversation_id: str
|
||||
task_id: str
|
||||
task: dict[str, Any] | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
tool_outcomes: list[dict[str, Any]] = Field(default_factory=list)
|
||||
verifier: dict[str, Any]
|
||||
|
||||
|
||||
class AgentVisibilityThreadMessageOut(BaseModel):
|
||||
message_id: str
|
||||
thread_id: str
|
||||
from_agent_id: str
|
||||
to_agent_id: str
|
||||
task_id: str | None = None
|
||||
reply_to_message_id: str | None = None
|
||||
message_type: str
|
||||
content_summary: str
|
||||
created_at: datetime
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentVisibilityThreadOut(BaseModel):
|
||||
conversation_id: str
|
||||
thread_id: str
|
||||
total: int
|
||||
items: list[AgentVisibilityThreadMessageOut]
|
||||
|
||||
|
||||
class AgentVisibilityVerifierOut(BaseModel):
|
||||
conversation_id: str
|
||||
status: str | None = None
|
||||
summary: str | None = None
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
@@ -134,6 +134,27 @@ _CONTINUITY_SNAPSHOT_FIELDS = (
|
||||
"current_agent",
|
||||
"next_step",
|
||||
"agent_trace",
|
||||
"agent_id",
|
||||
"parent_agent_id",
|
||||
"root_agent_id",
|
||||
"collaboration_depth",
|
||||
"thread_id",
|
||||
"last_message_id",
|
||||
"message_sequence",
|
||||
"spawned_agent_ids",
|
||||
"current_sub_commander",
|
||||
"active_sub_commanders",
|
||||
"sub_commander_trace",
|
||||
"event_trace",
|
||||
"message_trace",
|
||||
"active_tasks",
|
||||
"task_results",
|
||||
"task_hierarchy",
|
||||
"verification_status",
|
||||
"verification_summary",
|
||||
"verification_evidence",
|
||||
"budget_state",
|
||||
"collaboration_budget_history",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user