Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

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,
)