Files
MoviePilot/app/agent/middleware/subagents.py
Aqr-K 7584d4b9fc fix: isolate agent sessions and retain runtime resource ownership (#7149)
* fix: arbitrate subscription identity and restore workflow state

* fix: isolate agent sessions and retain runtime resource ownership

* test: preserve legacy empty-owner subscription transactions

* test: handle reaped terminal descendants during shutdown polling

* fix: align shared atomic configuration method with lifecycle prerequisite

* test: cover subagent cancellation and completion lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:20:59 +08:00

1572 lines
57 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
MoviePilot 子代理中间件适配。
"""
import asyncio
import json
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime
from functools import lru_cache
from typing import Any, Literal, Optional, cast
from langchain.agents import create_agent
from langchain.agents.middleware.types import (
AgentMiddleware,
ContextT,
ModelRequest,
ModelResponse,
ResponseT,
ToolCallRequest,
)
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from app.agent.llm.helper import LLMHelper
from app.agent.middleware.guardrails import ToolGuardrailsMiddleware
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.middleware.summarization import (
ContextPreservingSummarizationMiddleware,
FinalRequestCompactionMiddleware,
)
from app.agent.middleware.terminal import SubAgentTerminalGrant, subagent_terminal_scope
from app.agent.middleware.utils import append_to_system_message
from app.agent.middleware.vision import VisionMiddleware
from app.agent.policy.contracts import (
AuthSource,
PrincipalType,
ToolOrigin,
ToolPolicyContext,
)
from app.agent.policy.sanitizer import (
sanitize_for_host,
summarize_error,
)
from app.agent.runtime import SubAgentDefinition, agent_runtime_manager
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
SUBAGENT_TASK_TOOL_NAME = "task"
SUBAGENT_CONTROL_TOOL_NAME = "subagent_task"
SUBAGENT_STREAM_MARKER_KEY = "ls_agent_type"
SUBAGENT_STREAM_MARKER_VALUE = "subagent"
SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS = 60000
SUBAGENT_MAX_WAIT_TIMEOUT_MS = 300000
SUBAGENT_CANCEL_GRACE_SECONDS = 5.0
SUBAGENT_MAX_ACTIVE_TASKS = 8
SUBAGENT_MAX_CONCURRENT_TASKS = 4
SUBAGENT_RESULT_MAX_CHARS = 12000
SUBAGENT_DESCRIPTION_MAX_CHARS = 500
SUBAGENT_PIPELINE_CONTEXT_MAX_CHARS = 12000
SUBAGENT_PARENT_PROMPT = """<subagents>
You may use subagent tools to delegate independent research, retrieval,
diagnosis, or planning work to built-in subagents.
Delegation modes:
- Use `task` for one blocking subtask when you need the result immediately.
- Use `subagent_task` for two or more independent subtasks. Start them first
with `action=start` and a `tasks` array, then use `action=status`,
`action=wait`, `action=update`, or `action=cancel` with the returned task IDs.
- Use `subagent_task` with `action=run` when you want to launch a bounded
batch and wait for the batch in one tool call.
- Use `subagent_task` with `action=pipeline` when later subtasks must use
previous subagent results. Pipeline steps run sequentially, and each step's
result is passed as private context to the next step.
Rules:
- Delegate when a task benefits from focused investigation, such as media identity checks, site/resource search, subscription analysis, download/transfer diagnosis, MoviePilot code/config exploration, or read-only system inspection.
- Subagent output is private context for your decision-making. Do not expose a subagent's process or final report verbatim to the user.
- Subagents must not send messages to the user, ask for interaction, or reveal their internal tool activity.
- Synthesize child evidence into your own progress updates and final answer, following the main agent's communication rules. Distinguish verified findings from child hypotheses; delegation does not justify silent work or premature completion.
- If a task requires configuration changes, deletion, adding downloads, adding subscriptions, or any high-impact action, the main agent must handle it directly under the confirmation policy.
- Child tools enforce read-only operations. Perform command launches, browser navigation/interactions, and external MCP calls in the main agent; pass the resulting evidence to a child for analysis when useful.
- To let a child inspect a parent terminal, declare `terminal_sessions=[{session_id, actions:["read","wait"]}]` on that task. Mentioning a handle in its description does not grant access. Share separately for each batch or pipeline task; process control remains with the parent.
</subagents>"""
SUBAGENT_TASK_DESCRIPTION = (
"将独立的 MoviePilot 调查或规划任务交给内置子智能体。"
"子智能体结果仅作为主智能体的私有上下文,不得原样转发给用户。"
)
SUBAGENT_CONTROL_DESCRIPTION = (
"异步启动和管理多个 MoviePilot 子任务。"
"使用 action=start 和 tasks=[{description}] 启动批次并立即取得任务标识。"
"action=status 查看状态;"
"action=wait 等待全部或任一结果;"
"action=update 携带 task_id 和新 description,在有界取消后替换任务;"
"action=cancel 停止任务;"
"action=run 在一次调用中启动并等待有界批次。"
"action=pipeline 顺序执行任务,并把每步结果作为下一步的私有上下文。"
)
SUBAGENT_BASE_PROMPT = """You are a silent subagent working for the MoviePilot main agent.
Requirements:
- Handle only the delegated subtask from the main agent. Do not converse with the user.
- Do not send messages, request user interaction, or output progress updates.
- Use tool results only for analysis, and return the final result only to the main agent.
- Limit yourself to the read-only operations permitted by the host. Command launches, browser navigation/interactions, external MCP calls, and writes must be handled by the main agent; request the resulting evidence instead of bypassing a denial.
- If user confirmation or a high-impact change is needed, explain why the main agent must confirm it instead of executing it yourself.
- Return a concise structured Chinese result with key evidence, judgment, and recommended next step.
"""
def _default_subagent_policy_context(tools: list[BaseTool]) -> ToolPolicyContext:
"""
从已注入工具继承会话归属,确保独立构造的子图也经过宿主策略。
"""
for tool in tools:
session_id = getattr(tool, "_session_id", None)
user_id = getattr(tool, "_user_id", None)
if not session_id and not user_id:
continue
agent_context = getattr(tool, "_agent_context", None)
if not isinstance(agent_context, dict):
agent_context = {}
return ToolPolicyContext(
session_id=str(session_id or "subagent"),
user_id=str(user_id or "subagent"),
origin=ToolOrigin.SUBAGENT,
principal_type=PrincipalType.SUBAGENT,
auth_source=AuthSource.INTERNAL,
agent_context=agent_context,
channel=getattr(tool, "_channel", None),
source=getattr(tool, "_source", None),
)
return ToolPolicyContext(
session_id="subagent",
user_id="subagent",
origin=ToolOrigin.SUBAGENT,
principal_type=PrincipalType.SUBAGENT,
auth_source=AuthSource.INTERNAL,
agent_context={},
)
@dataclass(frozen=True)
class _SubAgentProfile:
"""
子代理运行时定义。
:param name: 内置子智能体的可调用名称
:param description: 子智能体职责和适用任务摘要
:param prompt: 创建子智能体时注入的基础提示词
:param include_tags: 可进入子智能体工具目录的标签集合
:param exclude_tags: 必须排除的工具标签集合
"""
name: str
description: str
prompt: str
include_tags: frozenset[str]
exclude_tags: frozenset[str]
class _TaskToolInput(BaseModel):
"""
子代理任务工具输入。
"""
description: str = Field(..., description="需要子智能体处理的完整任务描述")
terminal_sessions: list[SubAgentTerminalGrant] = Field(
default_factory=list,
description="明确共享给此子智能体的父任务终端,仅用于只读检查",
)
class _SubAgentTaskSpec(BaseModel):
"""
异步子代理任务定义。
"""
description: str = Field(..., description="需要子智能体处理的完整任务描述")
terminal_sessions: list[SubAgentTerminalGrant] = Field(
default_factory=list,
description="仅对当前任务生效的父终端授权;同批其他任务不继承",
)
class _SubAgentControlInput(BaseModel):
"""
异步子代理管控工具输入。
"""
action: Literal["start", "status", "wait", "cancel", "update", "run", "pipeline"] = Field(
default="start",
description="任务动作:start、status、wait、cancel、update、run 或 pipeline,默认 start",
)
description: Optional[str] = Field(
default=None,
description="action=start 或 action=run 时的单任务描述",
)
terminal_sessions: list[SubAgentTerminalGrant] = Field(
default_factory=list,
description="单任务 description 使用的终端授权;使用 tasks 时须在各任务内分别指定",
)
tasks: Optional[list[_SubAgentTaskSpec]] = Field(
default=None,
description="action=start 或 action=run 时的批量任务定义,也用于 pipeline 的顺序步骤",
)
task_ids: Optional[list[str]] = Field(
default=None,
description="action=start 返回的任务标识;空列表表示所有已知任务",
)
task_id: Optional[str] = Field(
default=None,
description="status、wait、cancel 或 update 使用的单个任务标识",
)
wait_mode: Literal["all", "any"] = Field(
default="all",
description="action=wait 或 action=run 的等待方式:all 等待全部,any 等待任一任务",
)
timeout_ms: Optional[int] = Field(
default=SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS,
description=(
"action=wait、action=run 或 pipeline 每一步的最长等待毫秒数,默认 60000"
),
)
@dataclass
class _SubAgentRuntimeTask:
"""
运行中的异步子代理任务记录。
:param task_id: 宿主分配的子任务唯一标识
:param description: 派发给子智能体的完整任务描述
:param subagent_type: 执行该任务的内置子智能体类型
:param task: 负责执行子任务的异步任务对象
:param created_at: 任务登记创建的时间
:param started_at: 实际开始执行的时间;排队期间为 None
:param finished_at: 任务完成或取消后的结束时间;尚未结束时为 None
:param terminal_sessions: 仅对本子任务生效的父终端只读授权列表,其他子任务不继承
"""
task_id: str
description: str
subagent_type: str
task: asyncio.Task[Any]
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
terminal_sessions: list[SubAgentTerminalGrant] = field(default_factory=list)
def is_subagent_stream_metadata(metadata: Any) -> bool:
"""
判断流式 token 元数据是否来自子代理。
"""
if not isinstance(metadata, dict):
return False
if metadata.get(SUBAGENT_STREAM_MARKER_KEY) == SUBAGENT_STREAM_MARKER_VALUE:
return True
nested_metadata = metadata.get("metadata")
if isinstance(nested_metadata, dict) and nested_metadata.get(
SUBAGENT_STREAM_MARKER_KEY
) == SUBAGENT_STREAM_MARKER_VALUE:
return True
configurable = metadata.get("configurable")
if isinstance(configurable, dict) and configurable.get(
SUBAGENT_STREAM_MARKER_KEY
) == SUBAGENT_STREAM_MARKER_VALUE:
return True
return bool(
metadata.get("lc_agent_name")
in builtin_subagent_names(agent_runtime_manager.current_signature())
)
def builtin_subagent_names(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> frozenset[str]:
"""
返回内置子代理名称集合。
"""
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_names(runtime_signature)
@lru_cache(maxsize=8)
def _cached_builtin_subagent_names(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> frozenset[str]:
"""
按运行时签名缓存内置子代理名称集合。
"""
return frozenset(
profile.name
for profile in _builtin_subagent_profiles(runtime_signature)
)
def _builtin_subagent_profiles(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> tuple[_SubAgentProfile, ...]:
"""
从运行时配置目录加载 MoviePilot 子代理定义。
"""
runtime_signature = runtime_signature or agent_runtime_manager.current_signature()
return _cached_builtin_subagent_profiles(runtime_signature)
def _default_subagent_profiles(
runtime_signature: Optional[tuple[tuple[str, int, int], ...]] = None,
) -> tuple[_SubAgentProfile, ...]:
"""
返回生产主 Agent 默认可见的通用子代理目录。
"""
profiles = tuple(
profile
for profile in _builtin_subagent_profiles(runtime_signature)
if profile.name == "general-purpose"
)
if not profiles:
raise RuntimeError("运行时未定义 general-purpose 子代理")
return profiles
@lru_cache(maxsize=8)
def _cached_builtin_subagent_profiles(
runtime_signature: tuple[tuple[str, int, int], ...],
) -> tuple[_SubAgentProfile, ...]:
"""
按运行时签名缓存 MoviePilot 子代理定义。
"""
definitions = agent_runtime_manager.list_subagents()
profiles = tuple(
_profile_from_runtime_definition(definition)
for definition in definitions
)
if profiles:
return profiles
raise RuntimeError("运行时未加载任何子代理定义")
cast(Any, builtin_subagent_names).cache_clear = cast(Any, _cached_builtin_subagent_names).cache_clear
cast(Any, _builtin_subagent_profiles).cache_clear = cast(Any, _cached_builtin_subagent_profiles).cache_clear
def _profile_from_runtime_definition(
definition: SubAgentDefinition,
) -> _SubAgentProfile:
"""
把运行时子代理定义转换为中间件可用的 profile。
"""
prompt_parts = [SUBAGENT_BASE_PROMPT]
if definition.text.strip():
prompt_parts.append(definition.text.strip())
return _SubAgentProfile(
name=definition.subagent_id,
description=definition.description,
prompt="\n".join(prompt_parts),
include_tags=frozenset(definition.include_tags),
exclude_tags=frozenset(definition.exclude_tags),
)
def _tool_tag_values(tool: BaseTool) -> set[str]:
"""
读取工具实例上的标签集合。
"""
tags = getattr(tool, "tags", None) or []
if isinstance(tags, str):
return {tags}
return {str(tag) for tag in tags if tag}
def _select_tools(tools: list[BaseTool], profile: _SubAgentProfile) -> list[BaseTool]:
"""
根据工具标签筛选子代理可用工具。
"""
selected_tools = []
for tool in tools:
tags = _tool_tag_values(tool)
if ToolTag.Read.value not in tags:
continue
if profile.exclude_tags & tags:
continue
if profile.include_tags & tags:
selected_tools.append(tool)
return selected_tools
def _format_subagent_catalog(profiles: tuple[_SubAgentProfile, ...]) -> str:
"""
渲染子代理目录供任务工具描述使用。
"""
return "\n".join(
f"- {profile.name}: {profile.description}" for profile in profiles
)
def _extract_final_text(result: Any) -> str:
"""
从子代理执行结果中提取最后一条 AI 文本。
"""
if isinstance(result, dict):
messages = result.get("messages") or []
else:
messages = getattr(result, "messages", []) or []
for message in reversed(messages):
if isinstance(message, AIMessage) and message.content:
text = LLMHelper.extract_text_content(message.content).strip()
if text:
return text
return LLMHelper.extract_text_content(result, fallback_to_string=True).strip()
def _clip_text(text: Any, max_chars: int) -> tuple[str, bool]:
"""
裁剪过长文本,返回文本和是否被裁剪。
"""
normalized = "" if text is None else str(text)
if len(normalized) <= max_chars:
return normalized, False
return normalized[:max_chars], True
def _format_datetime(value: Optional[datetime]) -> Optional[str]:
"""
格式化任务时间。
"""
if not value:
return None
return value.strftime("%Y-%m-%d %H:%M:%S")
def _extract_tool_call_args(request: ToolCallRequest) -> dict[str, Any]:
"""
提取工具调用参数,并规整为字典。
"""
tool_call = request.tool_call or {}
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
return {}
return tool_args
def _record_subagent_tool_call(
*,
stream_handler: Any,
tool_name: str,
tool_args: dict[str, Any],
) -> str:
"""
按当前详细模式展示或汇总子代理工具调用。
"""
if not stream_handler or not getattr(stream_handler, "is_streaming", False):
return ""
safe_args = sanitize_for_host(tool_args)
if not isinstance(safe_args, dict):
safe_args = {}
if tool_name == SUBAGENT_TASK_TOOL_NAME:
tool_message = "调用子代理:general-purpose"
else:
tool_message = f"管理子代理任务:action={safe_args.get('action') or 'start'}"
return str(stream_handler.report_tool_call(
tool_name=tool_name,
tool_message=tool_message,
tool_kwargs=tool_args,
) or "")
class _SubAgentAgentProvider:
"""
子代理图懒加载与执行器。
"""
def __init__(
self,
*,
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""
初始化子代理执行器。
"""
self._model = model
self._profiles = {profile.name: profile for profile in profiles}
self._tools = tools
self._server_tools = server_tools or []
self._policy_context = policy_context or _default_subagent_policy_context(tools)
self._catalog = catalog
self._agents: dict[str, Any] = {}
self._guardrails: dict[str, ToolGuardrailsMiddleware] = {}
self._default_agent_name = "general-purpose"
def _resolve_profile(self, _agent_name: Optional[str] = None) -> _SubAgentProfile:
"""
解析唯一的通用子代理。
"""
return self._profiles[self._default_agent_name]
def get_agent(self, agent_name: Optional[str] = None) -> tuple[str, Any]:
"""
懒加载唯一通用子代理图。
"""
profile = self._resolve_profile(agent_name)
cached_agent = self._agents.get(profile.name)
if cached_agent:
return profile.name, cached_agent
subagent_tools = _select_tools(self._tools, profile)
subagent_catalog = (
self._catalog.select(subagent_tools) if self._catalog is not None else None
)
logger.info(
f"创建子代理图: subagent_type={profile.name}, tools={len(subagent_tools)}"
)
guardrails = ToolGuardrailsMiddleware(self._policy_context)
middlewares: list[AgentMiddleware[Any, Any, Any]] = [
AgentPolicyMiddleware(
context=self._policy_context,
catalog=subagent_catalog,
guardrails=guardrails,
),
guardrails,
FinalRequestCompactionMiddleware(
summarizer=ContextPreservingSummarizationMiddleware(
model=self._model,
keep=("messages", 20),
),
),
VisionMiddleware(),
]
agent = create_agent(
model=self._model,
tools=[*subagent_tools, *self._server_tools],
system_prompt=profile.prompt,
name=profile.name,
middleware=middlewares,
)
self._agents[profile.name] = agent
self._guardrails[profile.name] = guardrails
return profile.name, agent
async def run_task(
self,
*,
description: str,
subagent_type: Optional[str] = None,
task_id: Optional[str] = None,
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
) -> str:
"""
调用通用子代理并只返回供主代理读取的结果。
"""
del subagent_type
agent_name, agent = self.get_agent()
thread_suffix = task_id or uuid.uuid4().hex
log_task_id = task_id or "-"
logger.info(
f"开始调用子代理: subagent_type={agent_name}, task_id={log_task_id}"
)
try:
async with subagent_terminal_scope(
task_id=thread_suffix,
user_id=self._policy_context.user_id,
terminal_sessions=terminal_sessions,
) as terminal_context:
result = await agent.ainvoke(
{"messages": [HumanMessage(content=description + terminal_context)]},
config={
"configurable": {
"thread_id": f"subagent-{agent_name}-{thread_suffix}",
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
},
"metadata": {
"lc_agent_name": agent_name,
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
},
},
)
except Exception as err:
logger.error(
f"子代理调用失败: subagent_type={agent_name}, "
f"task_id={log_task_id}, error={summarize_error(err)}"
)
raise
finally:
guardrails = self._guardrails.get(agent_name)
if guardrails:
guardrails.release(f"subagent-{agent_name}-{thread_suffix}")
final_text = _extract_final_text(result)
logger.info(
f"子代理调用完成: subagent_type={agent_name}, "
f"task_id={log_task_id}, result_chars={len(final_text)}"
)
return final_text or "The subagent did not return a usable result."
class MoviePilotSubAgentMiddleware(AgentMiddleware):
"""
MoviePilot 本地子代理中间件兜底实现。
"""
def __init__(
self,
*,
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
system_prompt: str = SUBAGENT_PARENT_PROMPT,
task_description: str = SUBAGENT_TASK_DESCRIPTION,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""
初始化同步子代理中间件。
"""
self.system_prompt = system_prompt
self.stream_handler = stream_handler
self._provider = _SubAgentAgentProvider(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools,
policy_context=policy_context,
catalog=catalog,
)
self.tools = [
StructuredTool.from_function(
coroutine=self._run_task,
name=SUBAGENT_TASK_TOOL_NAME,
description=(
f"{task_description}\n\n可用子智能体:\n"
f"{_format_subagent_catalog(profiles)}"
),
args_schema=_TaskToolInput,
)
]
def _get_agent(self, agent_name: str) -> Any:
"""
懒加载指定名称的子代理图。
"""
return self._provider.get_agent(agent_name)[1]
async def _run_task(
self,
description: str,
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
) -> str:
"""
调用通用子代理并只返回供主代理读取的结果。
"""
return await self._provider.run_task(
description=description,
terminal_sessions=terminal_sessions,
)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
"""
在主代理模型调用前注入子代理使用说明。
"""
new_system_message = append_to_system_message(
request.system_message,
self.system_prompt,
)
return await handler(request.override(system_message=new_system_message))
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""
在 task 子代理工具执行时输出当前模式对应的执行信息。
"""
tool = request.tool
tool_name = getattr(tool, "name", None)
if tool_name != SUBAGENT_TASK_TOOL_NAME:
return await handler(request)
tool_args = _extract_tool_call_args(request)
logger.info(
f"开始执行子代理工具: tool_name={tool_name}, "
"subagent_type=general-purpose"
)
tool_call_id = _record_subagent_tool_call(
stream_handler=self.stream_handler,
tool_name=SUBAGENT_TASK_TOOL_NAME,
tool_args=tool_args,
)
try:
result = await handler(request)
except Exception as err:
if tool_call_id:
finish_tool_call = getattr(self.stream_handler, "tool_call_finished", None)
if callable(finish_tool_call):
finish_tool_call(tool_call_id, "error")
logger.error(
f"子代理工具执行失败: tool_name={tool_name}, "
f"error={summarize_error(err)}"
)
raise
if tool_call_id:
finish_tool_call = getattr(self.stream_handler, "tool_call_finished", None)
if callable(finish_tool_call):
finish_tool_call(tool_call_id, "done")
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
return result
class SubAgentTaskControlMiddleware(AgentMiddleware):
"""
提供异步子代理任务调度工具的中间件。
"""
def __init__(
self,
*,
model: BaseChatModel,
profiles: tuple[_SubAgentProfile, ...],
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
task_description: str = SUBAGENT_CONTROL_DESCRIPTION,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> None:
"""
初始化异步子代理调度中间件。
"""
self.stream_handler = stream_handler
self._provider = _SubAgentAgentProvider(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools,
policy_context=policy_context,
catalog=catalog,
)
self._semaphore = asyncio.Semaphore(SUBAGENT_MAX_CONCURRENT_TASKS)
self._tasks: dict[str, _SubAgentRuntimeTask] = {}
self._accepting_tasks = True
self._close_cancel_requested: set[asyncio.Task[Any]] = set()
self.tools = [
StructuredTool.from_function(
coroutine=self._control_task,
name=SUBAGENT_CONTROL_TOOL_NAME,
description=(
f"{task_description}\n\n可用子智能体:\n"
f"{_format_subagent_catalog(profiles)}"
),
args_schema=_SubAgentControlInput,
)
]
@staticmethod
def _json_response(payload: dict[str, Any]) -> str:
"""
将工具响应序列化为稳定 JSON,并为失败提供模型恢复路径。
"""
normalized = dict(payload)
if normalized.get("success") is False:
normalized.setdefault("execution_outcome", "failed")
normalized.setdefault(
"recovery",
"根据 error 或 tasks 中的状态修正任务描述或参数;不要重复启动已在途的子代理。",
)
return json.dumps(normalized, ensure_ascii=False, indent=2)
@staticmethod
def _normalize_timeout_ms(timeout_ms: Optional[int]) -> int:
"""
规范化等待超时时间。
"""
if timeout_ms is None:
return SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS
return max(0, min(int(timeout_ms), SUBAGENT_MAX_WAIT_TIMEOUT_MS))
@staticmethod
def _task_status(record: _SubAgentRuntimeTask) -> str:
"""
读取任务当前状态。
"""
task = record.task
if task.cancelled():
return "cancelled"
if not task.done():
return "running" if record.started_at else "pending"
if task.exception():
return "failed"
return "completed"
@staticmethod
def _task_output(record: _SubAgentRuntimeTask) -> dict[str, Any]:
"""
格式化单个任务状态和结果。
"""
description, description_truncated = _clip_text(
record.description,
SUBAGENT_DESCRIPTION_MAX_CHARS,
)
payload: dict[str, Any] = {
"task_id": record.task_id,
"subagent_type": record.subagent_type,
"status": SubAgentTaskControlMiddleware._task_status(record),
"description": description,
"description_truncated": description_truncated,
"created_at": _format_datetime(record.created_at),
"started_at": _format_datetime(record.started_at),
"finished_at": _format_datetime(record.finished_at),
}
if not record.task.done():
payload["execution_outcome"] = "pending"
payload["recovery"] = "使用 action=wait 或 action=status 继续观察,不要重复启动同一任务。"
return payload
if record.task.cancelled():
payload["execution_outcome"] = "failed"
payload["recovery"] = "任务已取消;检查其他任务结果,只有在需要时用新的 task_id 重新派发。"
return payload
error = record.task.exception()
if error:
payload["error"] = summarize_error(error)
payload["execution_outcome"] = "failed"
payload["recovery"] = "根据子代理错误修正任务描述或参数后重试;不要重复启动仍在途的任务。"
return payload
result, result_truncated = _clip_text(
record.task.result(),
SUBAGENT_RESULT_MAX_CHARS,
)
payload["result"] = result
payload["result_truncated"] = result_truncated
return payload
def _selected_records(
self,
*,
task_ids: Optional[list[str]] = None,
task_id: Optional[str] = None,
active_only: bool = False,
) -> tuple[list[_SubAgentRuntimeTask], list[str]]:
"""
根据任务 ID 选择记录。
"""
selected_ids = []
if task_id:
selected_ids.append(task_id)
selected_ids.extend(task_ids or [])
if not selected_ids:
records = list(self._tasks.values())
if active_only:
records = [record for record in records if not record.task.done()]
return records, []
records = []
missing_ids = []
seen_ids = set()
for selected_id in selected_ids:
if selected_id in seen_ids:
continue
seen_ids.add(selected_id)
record = self._tasks.get(selected_id)
if record:
records.append(record)
else:
missing_ids.append(selected_id)
return records, missing_ids
def _normalize_specs(
self,
*,
description: Optional[str],
tasks: Optional[list[_SubAgentTaskSpec]],
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
) -> tuple[list[_SubAgentTaskSpec], Optional[str]]:
"""
规范化单任务和批量任务输入。
"""
if tasks and terminal_sessions:
return [], "批量或管道任务请在每个 tasks 条目中单独声明 terminal_sessions。"
specs = []
for task in tasks or []:
if isinstance(task, dict):
task = _SubAgentTaskSpec(**task)
if task.description.strip():
specs.append(task)
if not specs and description and description.strip():
specs.append(
_SubAgentTaskSpec(
description=description,
terminal_sessions=terminal_sessions or [],
)
)
if not specs:
return [], "缺少可执行的子代理任务描述。"
if len(specs) > SUBAGENT_MAX_ACTIVE_TASKS:
return [], f"单次最多可提交 {SUBAGENT_MAX_ACTIVE_TASKS} 个子代理任务。"
active_count = sum(
1 for record in self._tasks.values() if not record.task.done()
)
if active_count + len(specs) > SUBAGENT_MAX_ACTIVE_TASKS:
return [], (
f"当前仍有 {active_count} 个子代理任务未完成,"
f"总并发上限为 {SUBAGENT_MAX_ACTIVE_TASKS}。"
)
return specs, None
async def _execute_managed_task(self, record: _SubAgentRuntimeTask) -> str:
"""
执行受调度器管理的子代理任务。
"""
async with self._semaphore:
record.started_at = datetime.now()
logger.info(
f"异步子代理任务开始执行: task_id={record.task_id}, "
f"subagent_type={record.subagent_type}"
)
try:
result = await self._provider.run_task(
description=record.description,
subagent_type=record.subagent_type,
task_id=record.task_id,
terminal_sessions=record.terminal_sessions,
)
logger.info(
f"异步子代理任务执行完成: task_id={record.task_id}, "
f"subagent_type={record.subagent_type}, result_chars={len(result)}"
)
return result
except asyncio.CancelledError:
logger.info(
f"异步子代理任务已取消: task_id={record.task_id}, "
f"subagent_type={record.subagent_type}"
)
raise
except Exception as err:
logger.error(
f"子代理任务执行失败: task_id={record.task_id}, "
f"error={summarize_error(err)}"
)
raise
def _mark_task_finished(self, task_id: str, task: asyncio.Task[Any]) -> None:
"""
记录任务完成时间并取出异常避免未读取告警。
"""
cancel_requested = getattr(self, "_close_cancel_requested", None)
if cancel_requested is not None:
cancel_requested.discard(task)
record = self._tasks.get(task_id)
if record and record.task is task:
record.finished_at = datetime.now()
if task.cancelled():
return
try:
task.exception()
except Exception:
return
def _start_tasks(self, specs: list[_SubAgentTaskSpec]) -> list[_SubAgentRuntimeTask]:
"""
启动一批异步子代理任务。
"""
records = []
for spec in specs:
task_id = f"subagent-{uuid.uuid4().hex[:12]}"
record = _SubAgentRuntimeTask(
task_id=task_id,
description=spec.description.strip(),
subagent_type="general-purpose",
task=cast(asyncio.Task[Any], None),
created_at=datetime.now(),
terminal_sessions=spec.terminal_sessions,
)
task = asyncio.create_task(
self._execute_managed_task(record),
name=task_id,
)
record.task = task
task.add_done_callback(self._task_finished_callback(task_id))
self._tasks[task_id] = record
records.append(record)
logger.info(
f"已启动子代理任务: task_id={task_id}, "
f"subagent_type={record.subagent_type}"
)
return records
async def _wait_records(
self,
*,
records: list[_SubAgentRuntimeTask],
wait_mode: str,
timeout_ms: Optional[int],
) -> None:
"""
按等待模式等待一组任务完成。
"""
pending_tasks = [record.task for record in records if not record.task.done()]
if not pending_tasks:
if records:
logger.info(f"子代理任务无需等待: tasks={len(records)}")
return
normalized_timeout_ms = self._normalize_timeout_ms(timeout_ms)
timeout = normalized_timeout_ms / 1000
if timeout <= 0:
logger.info(
f"子代理任务等待超时时间为 0,跳过等待: tasks={len(pending_tasks)}"
)
return
return_when = (
asyncio.FIRST_COMPLETED if wait_mode == "any" else asyncio.ALL_COMPLETED
)
logger.info(
f"开始等待子代理任务: tasks={len(pending_tasks)}, "
f"wait_mode={wait_mode}, timeout_ms={normalized_timeout_ms}"
)
await asyncio.wait(
pending_tasks,
timeout=timeout,
return_when=return_when,
)
finished_count = sum(1 for task in pending_tasks if task.done())
logger.info(
f"子代理任务等待结束: finished={finished_count}, "
f"pending={len(pending_tasks) - finished_count}"
)
@staticmethod
async def _cancel_records(
records: list[_SubAgentRuntimeTask],
*,
cancel_requested: Optional[set[asyncio.Task[Any]]] = None,
) -> list[_SubAgentRuntimeTask]:
"""
取消一组任务,并返回等待上限内仍未收敛的记录。
"""
cancellable_tasks = [
record.task for record in records if not record.task.done()
]
if cancellable_tasks:
logger.info(f"开始取消子代理任务: tasks={len(cancellable_tasks)}")
for task in cancellable_tasks:
if cancel_requested is not None and task in cancel_requested:
continue
task.cancel()
if cancel_requested is not None:
cancel_requested.add(task)
if not cancellable_tasks:
return []
done, pending = await asyncio.wait(
cancellable_tasks,
timeout=SUBAGENT_CANCEL_GRACE_SECONDS,
)
if done:
await asyncio.gather(*done, return_exceptions=True)
if pending:
logger.warning(
f"子代理任务取消等待超时: pending={len(pending)}, "
f"timeout={SUBAGENT_CANCEL_GRACE_SECONDS}s"
)
else:
logger.info(f"子代理任务取消完成: tasks={len(cancellable_tasks)}")
return [record for record in records if record.task in pending]
async def _update_record(
self,
record: _SubAgentRuntimeTask,
*,
description: str,
terminal_sessions: Optional[list[SubAgentTerminalGrant]],
) -> tuple[bool, Optional[str]]:
"""
有界取消旧任务后复用 task_id 启动更新后的子代理任务。
"""
if not self._accepting_tasks or self._tasks.get(record.task_id) is not record:
return False, "子代理任务控制器正在关闭或任务已被回收,更新被拒绝。"
if not record.task.done():
pending = await self._cancel_records([record])
if pending:
return False, "旧子代理任务未能在取消窗口内收敛,更新被拒绝。"
if not self._accepting_tasks or self._tasks.get(record.task_id) is not record:
return False, "子代理任务控制器正在关闭或任务已被回收,更新被拒绝。"
record.description = description.strip()
record.terminal_sessions = terminal_sessions or []
record.started_at = None
record.finished_at = None
record.task = asyncio.create_task(
self._execute_managed_task(record), name=record.task_id,
)
record.task.add_done_callback(self._task_finished_callback(record.task_id))
return True, None
def _task_finished_callback(
self, task_id: str,
) -> Callable[[asyncio.Task[Any]], None]:
"""
返回带稳定 task_id 的完成回调,供任务更新复用。
"""
return lambda finished_task: self._mark_task_finished(task_id, finished_task)
def seal(self) -> None:
"""
封住新的 detached 子代理提交,既有任务继续由记录表持有。
"""
self._accepting_tasks = False
async def close(self) -> bool:
"""
有限等待 detached 子代理;超时保留记录并返回 False。
"""
self.seal()
return await self._drain_tasks()
async def _drain_tasks(self) -> bool:
"""
清理本轮子任务,正常收敛后允许缓存图继续处理下一轮。
"""
if not hasattr(self, "_close_cancel_requested"):
self._close_cancel_requested = set()
unfinished_records = [
record for record in self._tasks.values() if not record.task.done()
]
if unfinished_records:
logger.info(
f"关闭子代理任务控制器,取消未完成任务: tasks={len(unfinished_records)}"
)
pending_records = await self._cancel_records(
unfinished_records,
cancel_requested=self._close_cancel_requested,
)
if pending_records:
# 未收敛的子任务仍由该 owner 持有,不能让下一轮叠加新任务。
self.seal()
self._tasks = {
record.task_id: record
for record in pending_records
}
return False
self._tasks.clear()
self._close_cancel_requested.clear()
return True
@staticmethod
def _pipeline_description(
*,
description: str,
previous_results: list[tuple[_SubAgentRuntimeTask, str]],
) -> str:
"""
追加上游子代理结果,生成当前管道步骤的任务描述。
"""
normalized_description = description.strip()
if not previous_results:
return normalized_description
context_parts = []
for step_index, (record, result) in enumerate(previous_results, start=1):
clipped_result, result_truncated = _clip_text(
result,
SUBAGENT_RESULT_MAX_CHARS,
)
truncated_note = "\n[Result truncated]" if result_truncated else ""
context_parts.append(
f"Step {step_index} ({record.subagent_type}) result:\n"
f"{clipped_result}{truncated_note}"
)
context_text, context_truncated = _clip_text(
"\n\n".join(context_parts),
SUBAGENT_PIPELINE_CONTEXT_MAX_CHARS,
)
truncated_note = "\n[Pipeline context truncated]" if context_truncated else ""
return (
f"{normalized_description}\n\n"
"<pipeline_context>\n"
"Previous subagent results are private context for this delegated "
"subtask. Use them to complete the current task, but do not expose "
"the prior reports verbatim.\n\n"
f"{context_text}{truncated_note}\n"
"</pipeline_context>"
)
async def _execute_pipeline_task(
self,
*,
record: _SubAgentRuntimeTask,
description: str,
) -> str:
"""
执行单个管道步骤,保留原始步骤描述用于状态展示。
"""
async with self._semaphore:
record.started_at = datetime.now()
logger.info(
f"管道子代理任务开始执行: task_id={record.task_id}, "
f"subagent_type={record.subagent_type}"
)
try:
result = await self._provider.run_task(
description=description,
subagent_type=record.subagent_type,
task_id=record.task_id,
terminal_sessions=record.terminal_sessions,
)
logger.info(
f"管道子代理任务执行完成: task_id={record.task_id}, "
f"subagent_type={record.subagent_type}, result_chars={len(result)}"
)
return result
except asyncio.CancelledError:
logger.info(
f"管道子代理任务已取消: task_id={record.task_id}, "
f"subagent_type={record.subagent_type}"
)
raise
except Exception as err:
logger.error(
f"管道子代理任务执行失败: task_id={record.task_id}, "
f"error={summarize_error(err)}"
)
raise
@staticmethod
def _create_pipeline_record(
spec: _SubAgentTaskSpec,
) -> _SubAgentRuntimeTask:
"""
创建一个管道步骤记录。
"""
task_id = f"subagent-{uuid.uuid4().hex[:12]}"
return _SubAgentRuntimeTask(
task_id=task_id,
description=spec.description.strip(),
subagent_type="general-purpose",
task=cast(asyncio.Task[Any], None),
created_at=datetime.now(),
terminal_sessions=spec.terminal_sessions,
)
def _track_pipeline_task(
self,
record: _SubAgentRuntimeTask,
task: asyncio.Task[Any],
) -> None:
"""
登记管道步骤任务,复用统一的状态和异常收口逻辑。
"""
record.task = task
task.add_done_callback(
lambda finished_task: self._mark_task_finished(
record.task_id,
finished_task,
)
)
self._tasks[record.task_id] = record
async def _run_pipeline(
self,
specs: list[_SubAgentTaskSpec],
timeout_ms: Optional[int],
) -> tuple[list[_SubAgentRuntimeTask], Optional[str]]:
"""
按顺序执行管道任务,并把每一步结果传给下一步。
"""
normalized_timeout_ms = self._normalize_timeout_ms(timeout_ms)
if normalized_timeout_ms <= 0:
return [], "管道任务需要大于 0 的等待时间。"
records: list[_SubAgentRuntimeTask] = []
previous_results: list[tuple[_SubAgentRuntimeTask, str]] = []
timeout = normalized_timeout_ms / 1000
for step_index, spec in enumerate(specs, start=1):
if not self._accepting_tasks:
return records, "子代理任务控制器正在关闭,不能再启动新任务。"
record = self._create_pipeline_record(spec)
records.append(record)
pipeline_description = self._pipeline_description(
description=record.description,
previous_results=previous_results,
)
task = asyncio.create_task(
self._execute_pipeline_task(
record=record,
description=pipeline_description,
),
name=record.task_id,
)
self._track_pipeline_task(record, task)
logger.info(
f"已启动管道子代理任务: step={step_index}, task_id={record.task_id}, "
f"subagent_type={record.subagent_type}"
)
done, pending = await asyncio.wait({task}, timeout=timeout)
if pending:
task.cancel()
error = f"第 {step_index} 个管道子代理任务等待超时。"
logger.info(
f"{error} task_id={record.task_id}, timeout_ms={normalized_timeout_ms}"
)
return records, error
try:
result = next(iter(done)).result()
except Exception as err:
error = (
f"第 {step_index} 个管道子代理任务执行失败: "
f"{summarize_error(err)}"
)
logger.info(
f"{error} task_id={record.task_id}"
)
return records, error
previous_results.append((record, result))
return records, None
async def _control_task(
self,
action: str = "start",
description: Optional[str] = None,
tasks: Optional[list[_SubAgentTaskSpec]] = None,
task_ids: Optional[list[str]] = None,
task_id: Optional[str] = None,
wait_mode: str = "all",
timeout_ms: Optional[int] = SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS,
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
) -> str:
"""
管理异步子代理任务。
"""
logger.info(f"收到子代理管控操作: action={action}")
if action == "update":
if not task_id or not description or not description.strip():
return self._json_response({
"success": False, "error": "update 必须提供 task_id 和新的 description。",
})
record = self._tasks.get(task_id)
if record is None:
return self._json_response({
"success": False, "error": f"未知子代理任务: {task_id}",
})
updated, error = await self._update_record(
record, description=description, terminal_sessions=terminal_sessions,
)
return self._json_response({
"success": updated, "action": action, "task_id": task_id,
"error": error, "tasks": [self._task_output(record)],
})
if action in {"start", "run", "pipeline"}:
if not self._accepting_tasks:
error = "子代理任务控制器正在关闭,不能再启动新任务。"
return self._json_response({"success": False, "error": error})
specs, error = self._normalize_specs(
description=description,
tasks=tasks,
terminal_sessions=terminal_sessions,
)
if error:
logger.info(
f"子代理管控操作未启动任务: action={action}, "
f"error={sanitize_for_host(error)}"
)
return self._json_response({"success": False, "error": error})
logger.info(f"准备启动子代理任务: action={action}, tasks={len(specs)}")
if action == "pipeline":
records, pipeline_error = await self._run_pipeline(
specs=specs,
timeout_ms=timeout_ms,
)
return self._json_response(
{
"success": pipeline_error is None,
"action": action,
"error": pipeline_error,
"tasks": [self._task_output(record) for record in records],
}
)
records = self._start_tasks(specs)
if action == "run":
await self._wait_records(
records=records,
wait_mode=wait_mode,
timeout_ms=timeout_ms,
)
return self._json_response(
{
"success": True,
"action": action,
"wait_mode": wait_mode if action == "run" else None,
"tasks": [self._task_output(record) for record in records],
}
)
records, missing_ids = self._selected_records(
task_ids=task_ids,
task_id=task_id,
active_only=action in {"wait", "cancel"} and not task_ids and not task_id,
)
cancellation_pending: list[_SubAgentRuntimeTask] = []
if action == "wait":
logger.info(
f"准备等待子代理任务: selected={len(records)}, missing={len(missing_ids)}"
)
await self._wait_records(
records=records,
wait_mode=wait_mode,
timeout_ms=timeout_ms,
)
elif action == "cancel":
logger.info(
f"准备取消子代理任务: selected={len(records)}, missing={len(missing_ids)}"
)
cancellation_pending = await self._cancel_records(records)
elif action == "status":
logger.info(
f"查询子代理任务状态: selected={len(records)}, missing={len(missing_ids)}"
)
response = {
"success": not cancellation_pending,
"action": action,
"wait_mode": wait_mode if action == "wait" else None,
"missing_task_ids": missing_ids,
"tasks": [self._task_output(record) for record in records],
}
if action == "cancel":
response["cancel_pending_task_ids"] = [
record.task_id for record in cancellation_pending
]
return self._json_response(response)
async def aafter_agent(self, state: Any, runtime: Any) -> None:
"""
本轮结束时回收子任务;永久关闭只由会话生命周期触发。
"""
await self._drain_tasks()
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""
在 subagent_task 子代理工具执行时输出当前模式对应的执行信息。
"""
tool = request.tool
tool_name = getattr(tool, "name", None)
if tool_name != SUBAGENT_CONTROL_TOOL_NAME:
return await handler(request)
tool_args = _extract_tool_call_args(request)
logged_args = sanitize_for_host(tool_args)
if not isinstance(logged_args, dict):
logged_args = {}
logger.info(
f"开始执行子代理工具: tool_name={tool_name}, "
f"action={logged_args.get('action') or '-'}, "
"subagent_type=general-purpose"
)
tool_call_id = _record_subagent_tool_call(
stream_handler=self.stream_handler,
tool_name=SUBAGENT_CONTROL_TOOL_NAME,
tool_args=tool_args,
)
try:
result = await handler(request)
except Exception as err:
if tool_call_id:
finish_tool_call = getattr(self.stream_handler, "tool_call_finished", None)
if callable(finish_tool_call):
finish_tool_call(tool_call_id, "error")
logger.error(
f"子代理工具执行失败: tool_name={tool_name}, "
f"error={summarize_error(err)}"
)
raise
if tool_call_id:
finish_tool_call = getattr(self.stream_handler, "tool_call_finished", None)
if callable(finish_tool_call):
finish_tool_call(tool_call_id, "done")
logger.info(f"子代理工具执行完成: tool_name={tool_name}")
return result
def create_subagent_middlewares(
*,
model: BaseChatModel,
tools: list[BaseTool],
server_tools: Optional[list[dict[str, Any]]] = None,
stream_handler: Any = None,
policy_context: Optional[ToolPolicyContext] = None,
catalog: Optional[ToolCatalogSnapshot] = None,
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
"""
创建子代理中间件列表和任务工具列表。
"""
runtime_signature = agent_runtime_manager.current_signature()
profiles = _default_subagent_profiles(runtime_signature)
subagent_middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
policy_context=policy_context,
catalog=catalog,
)
control_middleware = SubAgentTaskControlMiddleware(
model=model,
profiles=profiles,
tools=tools,
server_tools=server_tools or [],
stream_handler=stream_handler,
policy_context=policy_context,
catalog=catalog,
)
task_tools = [
*list(getattr(subagent_middleware, "tools", []) or []),
*list(getattr(control_middleware, "tools", []) or []),
]
return [
subagent_middleware,
control_middleware,
], task_tools
__all__ = [
"SUBAGENT_CONTROL_TOOL_NAME",
"SUBAGENT_TASK_TOOL_NAME",
"SubAgentTaskControlMiddleware",
"create_subagent_middlewares",
"is_subagent_stream_metadata",
]