354 lines
14 KiB
Python
354 lines
14 KiB
Python
"""Subagent run orchestration service.
|
|
|
|
This module owns parent/child agent-thread relationships. It decides whether a
|
|
task starts a new child thread or continues an existing one, records the
|
|
``SubagentThread`` relation and builds the subagent-only runtime payload.
|
|
|
|
It deliberately delegates durable run mechanics to ``agent_run_service``:
|
|
request id idempotency, active-run conflict checks, input message persistence,
|
|
AgentRun row creation and queue enqueueing all stay in the shared AgentRun
|
|
lifecycle boundary.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import yuxi.services.agent_run_service as agent_run_service
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from yuxi.repositories.agent_run_repository import AgentRunRepository
|
|
from yuxi.repositories.conversation_repository import ConversationRepository
|
|
from yuxi.repositories.subagent_thread_repository import SubagentThreadRepository
|
|
from yuxi.services.input_message_service import AgentRunInputMessage
|
|
from yuxi.storage.postgres.models_business import Agent, AgentRun, SubagentThread
|
|
from yuxi.utils.datetime_utils import utc_isoformat
|
|
from yuxi.utils.hash_utils import hash_id, subagent_child_thread_id
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubagentStartResult:
|
|
run: AgentRun
|
|
created: bool
|
|
continuing: bool
|
|
relation: SubagentThread
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubagentRunBusy(Exception):
|
|
thread_id: str
|
|
active_run_id: str | None
|
|
active_run_status: str | None
|
|
message: str | None
|
|
|
|
def to_payload(self) -> dict:
|
|
return {
|
|
"status": "busy",
|
|
"thread_id": self.thread_id,
|
|
"active_run_id": self.active_run_id,
|
|
"active_run_status": self.active_run_status,
|
|
"message": self.message,
|
|
}
|
|
|
|
|
|
def subagent_run_urls(run_id: str) -> dict[str, str]:
|
|
"""生成子智能体 run 对外暴露的事件流和结果查询 URL。"""
|
|
return {
|
|
"events_url": f"/api/agent/runs/{run_id}/events",
|
|
"result_url": f"/api/agent/runs/{run_id}/result",
|
|
}
|
|
|
|
|
|
def serialize_subagent_run_state(run: AgentRun) -> dict:
|
|
"""序列化给父智能体状态使用的子智能体 run 摘要。
|
|
|
|
任务描述不在此冗余存储:其唯一来源是父对话里 `task` 工具调用的入参,
|
|
前端面板按 tool_call_id 回填展示。
|
|
"""
|
|
payload = run.input_payload
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("subagent run 缺少 input_payload")
|
|
runtime = payload.get("runtime")
|
|
if not isinstance(runtime, dict):
|
|
raise ValueError("subagent run 缺少 runtime")
|
|
tool_call_id = str(runtime.get("tool_call_id") or "").strip()
|
|
if not tool_call_id:
|
|
raise ValueError("subagent run 缺少 tool_call_id")
|
|
|
|
state = {
|
|
"id": tool_call_id,
|
|
"run_id": run.id,
|
|
"subagent_slug": run.agent_slug,
|
|
"subagent_name": runtime.get("subagent_name"),
|
|
"child_thread_id": run.conversation_thread_id,
|
|
"status": run.status,
|
|
"created_at": utc_isoformat(run.created_at) if run.created_at else None,
|
|
"completed_at": utc_isoformat(run.finished_at) if run.finished_at else None,
|
|
"error": run.error_message,
|
|
**subagent_run_urls(run.id),
|
|
}
|
|
return {key: value for key, value in state.items() if value is not None}
|
|
|
|
|
|
class SubagentRunService:
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
self.run_repo = AgentRunRepository(db)
|
|
self.conv_repo = ConversationRepository(db)
|
|
self.thread_repo = SubagentThreadRepository(db)
|
|
|
|
async def start(
|
|
self,
|
|
*,
|
|
uid: str,
|
|
created_by_run_id: str,
|
|
agent_item: Agent,
|
|
input_message: AgentRunInputMessage,
|
|
tool_call_id: str,
|
|
requested_thread_id: str | None = None,
|
|
file_thread_id: str | None = None,
|
|
model_spec: str | None = None,
|
|
) -> SubagentStartResult:
|
|
"""启动或继续一个后台子智能体 run,并在新建时入队 worker。"""
|
|
|
|
creator_run = await self.run_repo.get_run_for_user(created_by_run_id, uid)
|
|
if not creator_run:
|
|
raise ValueError("父运行任务不存在")
|
|
if getattr(creator_run, "run_type", None) == "subagent":
|
|
raise ValueError("子智能体不能创建子智能体")
|
|
|
|
child_thread_id = str(requested_thread_id or "").strip()
|
|
continuing = bool(child_thread_id) # 表示是继续运行已有子智能体线程,而非新建子线程
|
|
if not child_thread_id:
|
|
child_thread_id = subagent_child_thread_id(
|
|
creator_run.conversation_thread_id,
|
|
agent_item.slug,
|
|
tool_call_id,
|
|
)
|
|
|
|
# 1. 确保子线程有对应 conversation,必要时创建 subagent 对话
|
|
# 2. 确保父子线程关系存在,必要时创建 SubagentThread 记录;relation 是后台子 run 的线程归属来源
|
|
relation = await self._ensure_thread_relation(
|
|
child_thread_id=child_thread_id,
|
|
uid=uid,
|
|
agent_item=agent_item,
|
|
creator_run=creator_run,
|
|
continuing=continuing,
|
|
)
|
|
|
|
# 创建数据库记录
|
|
request_id = hash_id("req:", f"{creator_run.id}:{child_thread_id}:{tool_call_id}")
|
|
try:
|
|
run, created = await self._create_run_record(
|
|
input_message=input_message,
|
|
request_id=request_id,
|
|
current_uid=uid,
|
|
model_spec=model_spec,
|
|
creator_run=creator_run,
|
|
relation=relation,
|
|
tool_call_id=tool_call_id,
|
|
file_thread_id=file_thread_id,
|
|
)
|
|
except HTTPException as exc:
|
|
detail = exc.detail
|
|
if exc.status_code == 409 and isinstance(detail, dict) and detail.get("code") == "run_busy":
|
|
raise SubagentRunBusy(
|
|
thread_id=str(detail.get("thread_id") or child_thread_id),
|
|
active_run_id=detail.get("active_run_id"),
|
|
active_run_status=detail.get("active_run_status"),
|
|
message=detail.get("message"),
|
|
) from exc
|
|
raise ValueError(detail if isinstance(detail, str) else json.dumps(detail, ensure_ascii=False)) from exc
|
|
|
|
# 创建成功后入队 worker 执行;幂等命中已有 run 时不重复入队。
|
|
if created:
|
|
await self.db.commit()
|
|
await agent_run_service.enqueue_agent_run(run.id)
|
|
|
|
return SubagentStartResult(
|
|
run=run,
|
|
created=created,
|
|
continuing=continuing,
|
|
relation=relation,
|
|
)
|
|
|
|
async def get_run_for_creator(self, *, uid: str, created_by_run_id: str, run_id: str) -> AgentRun:
|
|
"""在父 run 作用域内读取子智能体 run,防止工具访问其它对话的子任务。"""
|
|
run = await self.run_repo.get_subagent_run_for_creator(
|
|
uid=uid,
|
|
created_by_run_id=created_by_run_id,
|
|
run_id=run_id,
|
|
)
|
|
if not run:
|
|
raise ValueError("子智能体运行不存在或不属于当前父运行")
|
|
return run
|
|
|
|
async def _create_run_record(
|
|
self,
|
|
*,
|
|
input_message: AgentRunInputMessage,
|
|
request_id: str,
|
|
current_uid: str,
|
|
model_spec: str | None,
|
|
creator_run: AgentRun,
|
|
relation: SubagentThread,
|
|
tool_call_id: str,
|
|
file_thread_id: str | None,
|
|
) -> tuple[Any, bool]:
|
|
"""创建后台子智能体 run,并把规范化输入消息保存为该 run 的输入。"""
|
|
if not input_message.content:
|
|
raise HTTPException(status_code=422, detail="input_message 不能为空")
|
|
|
|
scope = await agent_run_service.prepare_agent_run_creation_scope(
|
|
agent_slug=relation.subagent_slug,
|
|
conversation_thread_id=relation.child_thread_id,
|
|
request_id=request_id,
|
|
current_uid=current_uid,
|
|
db=self.db,
|
|
run_type="subagent",
|
|
agent_kind="subagent",
|
|
created_by_run_id=creator_run.id,
|
|
subagent_thread_relation_id=relation.id,
|
|
)
|
|
if relation.child_conversation_id != scope.conversation.id:
|
|
raise HTTPException(status_code=409, detail="subagent thread relation 与本次运行不匹配")
|
|
if scope.existing_run:
|
|
return scope.existing_run, False
|
|
|
|
if creator_run.conversation_id != relation.parent_conversation_id:
|
|
raise HTTPException(status_code=409, detail="subagent thread relation 与本次运行不匹配")
|
|
|
|
resolved_model_spec = agent_run_service.resolve_agent_run_model_spec(
|
|
model_spec,
|
|
scope.agent_item,
|
|
scope.agent_backend,
|
|
)
|
|
runtime_payload = {
|
|
"tool_call_id": tool_call_id,
|
|
"subagent_name": scope.agent_item.name,
|
|
"parent_thread_id": creator_run.conversation_thread_id,
|
|
"file_thread_id": file_thread_id or creator_run.conversation_thread_id,
|
|
"skills_thread_id": relation.child_thread_id,
|
|
}
|
|
input_payload = {
|
|
"model_spec": resolved_model_spec,
|
|
"runtime": {key: value for key, value in runtime_payload.items() if value is not None},
|
|
}
|
|
subagent_input_message = input_message.with_metadata(
|
|
{
|
|
"request_id": request_id,
|
|
"source": "subagent",
|
|
"raw_message": input_message.raw_message(),
|
|
}
|
|
)
|
|
persisted_input_message = await agent_run_service.create_agent_run_input_message(
|
|
db=self.db,
|
|
conversation_id=scope.conversation.id,
|
|
request_id=request_id,
|
|
input_message=subagent_input_message,
|
|
)
|
|
return await agent_run_service.persist_agent_run_record(
|
|
agent_slug=relation.subagent_slug,
|
|
conversation_thread_id=relation.child_thread_id,
|
|
current_uid=current_uid,
|
|
db=self.db,
|
|
request_id=request_id,
|
|
conversation_id=scope.conversation.id,
|
|
run_type="subagent",
|
|
input_payload=input_payload,
|
|
persisted_input_message=persisted_input_message,
|
|
created_by_run_id=creator_run.id,
|
|
subagent_thread_relation_id=relation.id,
|
|
)
|
|
|
|
async def _ensure_child_conversation(
|
|
self,
|
|
*,
|
|
child_thread_id: str,
|
|
uid: str,
|
|
agent_item: Agent,
|
|
creator_run: AgentRun,
|
|
):
|
|
"""确保子线程有对应 conversation;新线程会创建标记为 subagent 的对话。"""
|
|
conversation = await self.conv_repo.get_conversation_by_thread_id(child_thread_id)
|
|
if conversation:
|
|
if conversation.uid != str(uid) or conversation.status == "deleted":
|
|
raise ValueError("子智能体线程不存在")
|
|
if conversation.status != "subagent":
|
|
raise ValueError(f"子智能体线程 {child_thread_id} 已被普通对话占用")
|
|
if conversation.agent_id != agent_item.slug:
|
|
raise ValueError(f"子智能体线程 {child_thread_id} 属于智能体 {conversation.agent_id}")
|
|
return conversation
|
|
|
|
conversation = await self.conv_repo.add_conversation(
|
|
uid=uid,
|
|
agent_id=agent_item.slug,
|
|
title=f"SubAgent: {agent_item.name}",
|
|
thread_id=child_thread_id,
|
|
metadata={
|
|
"source": "subagent",
|
|
"parent_thread_id": creator_run.conversation_thread_id,
|
|
"created_by_run_id": creator_run.id,
|
|
"parent_conversation_id": creator_run.conversation_id,
|
|
"subagent_slug": agent_item.slug,
|
|
},
|
|
)
|
|
conversation.status = "subagent"
|
|
await self.db.flush()
|
|
return conversation
|
|
|
|
def _validate_thread_relation(
|
|
self,
|
|
relation: SubagentThread,
|
|
*,
|
|
child_thread_id: str,
|
|
agent_item: Agent,
|
|
creator_run: AgentRun,
|
|
) -> None:
|
|
"""校验已有子线程关系仍属于当前父对话和子智能体。"""
|
|
if relation.parent_conversation_id != creator_run.conversation_id:
|
|
raise ValueError(f"子智能体线程 {child_thread_id}:线程不属于当前对话")
|
|
if relation.subagent_slug != agent_item.slug:
|
|
raise ValueError(f"子智能体线程 {child_thread_id} 属于子智能体 {relation.subagent_slug or '未知'}")
|
|
|
|
async def _ensure_thread_relation(
|
|
self,
|
|
*,
|
|
child_thread_id: str,
|
|
uid: str,
|
|
agent_item: Agent,
|
|
creator_run: AgentRun,
|
|
continuing: bool,
|
|
) -> SubagentThread:
|
|
"""读取或创建父子线程关系;relation 是后台子 run 的线程归属来源。"""
|
|
existing = await self.thread_repo.get_by_child_thread_for_user(child_thread_id, uid)
|
|
if existing:
|
|
self._validate_thread_relation(
|
|
existing,
|
|
child_thread_id=child_thread_id,
|
|
agent_item=agent_item,
|
|
creator_run=creator_run,
|
|
)
|
|
return existing
|
|
if continuing:
|
|
raise ValueError(f"无法继续子智能体线程 {child_thread_id}:当前对话中没有找到对应的运行记录")
|
|
if creator_run.conversation_id is None:
|
|
raise ValueError("父运行任务缺少 conversation_id,无法创建子智能体线程关系")
|
|
|
|
child_conversation = await self._ensure_child_conversation(
|
|
child_thread_id=child_thread_id,
|
|
uid=uid,
|
|
agent_item=agent_item,
|
|
creator_run=creator_run,
|
|
)
|
|
return await self.thread_repo.create(
|
|
uid=uid,
|
|
parent_conversation_id=creator_run.conversation_id,
|
|
child_conversation_id=child_conversation.id,
|
|
child_thread_id=child_thread_id,
|
|
subagent_slug=agent_item.slug,
|
|
created_by_run_id=creator_run.id,
|
|
)
|