diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 968426b8b4..4396729cbd 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -227,6 +227,8 @@ async def reset( fallback_providers: list[Provider] | None = None, tool_result_overflow_dir: str | None = None, read_tool: FunctionTool | None = None, + # external abort signal for SubAgent control (does not affect main agent) + external_abort_signal: asyncio.Event | None = None, **kwargs: T.Any, ) -> None: self.req = request @@ -276,7 +278,12 @@ async def reset( self.agent_hooks = agent_hooks self.run_context = run_context self._aborted = False - self._abort_signal = asyncio.Event() + # Use external abort signal if provided (for SubAgent), otherwise create new one + self._abort_signal = ( + external_abort_signal + if external_abort_signal is not None + else asyncio.Event() + ) self._pending_follow_ups: list[FollowUpTicket] = [] self._follow_up_seq = 0 self._last_tool_name: str | None = None @@ -1010,6 +1017,22 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: if not req.func_tool: return + # Prefer dynamic tools when available + func_tool = self._resolve_dynamic_subagent_tool(func_tool_name) + + # If not found in dynamic tools, check regular tool sets + if func_tool is None: + if ( + self.tool_schema_mode == "skills_like" + and self._skill_like_raw_tool_set + ): + # in 'skills_like' mode, raw.func_tool is light schema, does not have handler + # so we need to get the tool from the raw tool set + func_tool = self._skill_like_raw_tool_set.get_tool( + func_tool_name + ) + else: + func_tool = req.func_tool.get_tool(func_tool_name) if ( self.tool_schema_mode == "skills_like" and self._skill_like_raw_tool_set @@ -1140,6 +1163,12 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: "The tool has returned a data type that is not supported." ) if result_parts: + result_content = "\n\n".join(result_parts) + # Check for dynamic tool creation marker + self._maybe_register_dynamic_tool_from_result( + result_content + ) + inline_result = "\n\n".join(result_parts) inline_result = await self._materialize_large_tool_result( tool_call_id=func_tool_id, @@ -1335,8 +1364,18 @@ def request_stop(self) -> None: self._abort_signal.set() def _is_stop_requested(self) -> bool: + # Check if abort signal is set return self._abort_signal.is_set() + def is_abort_signal_set(self) -> bool: + """检查 abort_signal 是否已被设置(用于外部控制)""" + return self._abort_signal.is_set() + + def reset_abort_signal(self) -> None: + """重置 abort_signal(用于复用 runner)""" + self._abort_signal.clear() + self._aborted = False + def was_aborted(self) -> bool: return self._aborted @@ -1346,15 +1385,37 @@ def get_final_llm_resp(self) -> LLMResponse | None: async def _finalize_aborted_step( self, llm_resp: LLMResponse | None = None, + manual_stop: bool = False, ) -> AgentResponse: - logger.info("Agent execution was requested to stop by user.") + """终结被中断的步骤 + + Args: + llm_resp: LLM响应对象 + manual_stop: 是否是主Agent手动停止SubAgent(True时使用不同的消息提示) + """ + if manual_stop: + logger.info("SubAgent execution was manually stopped by main agent.") + else: + logger.info("Agent execution was requested to stop by user.") + if llm_resp is None: llm_resp = LLMResponse(role="assistant", completion_text="") + + # 根据停止类型选择不同的消息 if llm_resp.role != "assistant": + if manual_stop: + # SubAgent被主Agent手动停止,使用更简洁的消息 + interruption_msg = ( + "[SYSTEM: SubAgent was manually stopped by main agent. " + "Partial output before interruption is preserved.]" + ) + else: + interruption_msg = self.USER_INTERRUPTION_MESSAGE llm_resp = LLMResponse( role="assistant", - completion_text=self.USER_INTERRUPTION_MESSAGE, + completion_text=interruption_msg, ) + self.final_llm_resp = llm_resp self._aborted = True self._transition_state(AgentState.DONE) @@ -1431,3 +1492,66 @@ async def _iter_tool_executor_results( abort_task.cancel() with suppress(asyncio.CancelledError): await abort_task + + def _resolve_dynamic_subagent_tool(self, func_tool_name: str): + run_context_context = getattr(self.run_context, "context", None) + if run_context_context is None: + return None + + event = getattr(run_context_context, "event", None) + if event is None: + return None + + session_id = getattr(event, "unified_msg_origin", None) + if not session_id: + return None + + try: + from astrbot.core.subagent_manager import SubAgentManager + + dynamic_handoffs = SubAgentManager.get_handoff_tools_for_session(session_id) + except Exception: + return None + + for h in dynamic_handoffs: + if h.name == func_tool_name or f"transfer_to_{h.name}" == func_tool_name: + return h + return None + + def _maybe_register_dynamic_tool_from_result(self, result_content: str) -> None: + if not result_content.startswith("__DYNAMIC_TOOL_CREATED__:"): + return + + parts = result_content.split(":", 3) + if len(parts) < 4: + return + + new_tool_name = parts[1] + new_tool_obj_name = parts[2] + logger.info(f"[SubAgent] Tool created: {new_tool_name}") + + run_context_context = getattr(self.run_context, "context", None) + event = ( + getattr(run_context_context, "event", None) if run_context_context else None + ) + session_id = getattr(event, "unified_msg_origin", None) if event else None + if not session_id: + return + + try: + from astrbot.core.subagent_manager import SubAgentManager + + handoffs = SubAgentManager.get_handoff_tools_for_session(session_id) + except Exception as e: + logger.warning(f"[SubAgent] Failed to load dynamic handoffs: {e}") + return + + for handoff in handoffs: + if ( + handoff.name == new_tool_obj_name + or handoff.name == new_tool_name.replace("transfer_to_", "") + ): + if self.req.func_tool: + self.req.func_tool.add_tool(handoff) + logger.info(f"[SubAgent] Added {handoff.name} to func_tool set") + break diff --git a/astrbot/core/astr_agent_context.py b/astrbot/core/astr_agent_context.py index 9c6451cc74..7927b0aa46 100644 --- a/astrbot/core/astr_agent_context.py +++ b/astrbot/core/astr_agent_context.py @@ -1,3 +1,5 @@ +from typing import Any + from pydantic import Field from pydantic.dataclasses import dataclass @@ -14,7 +16,7 @@ class AstrAgentContext: """The star context instance""" event: AstrMessageEvent """The message event associated with the agent context.""" - extra: dict[str, str] = Field(default_factory=dict) + extra: dict[str, Any] = Field(default_factory=dict) """Customized extra data.""" diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index de5caad554..6c24021867 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -1,6 +1,7 @@ import asyncio import inspect import json +import time import traceback import typing as T import uuid @@ -290,6 +291,21 @@ def _build_handoff_toolset( toolset.add_tool(runtime_tool) elif isinstance(tool_name_or_obj, FunctionTool): toolset.add_tool(tool_name_or_obj) + + # Always add send_shared_context tool for shared context feature + try: + from astrbot.core.subagent_manager import ( + SEND_SHARED_CONTEXT_TOOL, + SubAgentManager, + ) + + session_id = event.unified_msg_origin + session = SubAgentManager.get_session(session_id) + if session and session.shared_context_enabled: + toolset.add_tool(SEND_SHARED_CONTEXT_TOOL) + except Exception as e: + logger.debug(f"[SubAgent] Failed to add shared context tool: {e}") + return None if toolset.empty() else toolset @classmethod @@ -322,7 +338,6 @@ async def _execute_handoff( # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) - ctx = run_context.context.context event = run_context.context.event umo = event.unified_msg_origin @@ -351,18 +366,69 @@ async def _execute_handoff( prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {}) agent_max_step = int(prov_settings.get("max_agent_step", 30)) stream = prov_settings.get("streaming_response", False) - llm_resp = await ctx.tool_loop_agent( - event=event, - chat_provider_id=prov_id, - prompt=input_, - image_urls=image_urls, - system_prompt=tool.agent.instructions, - tools=toolset, - contexts=contexts, - max_steps=agent_max_step, - tool_call_timeout=run_context.tool_call_timeout, - stream=stream, + + # 获取子代理的历史上下文 + subagent_history, agent_name = cls._load_subagent_history(umo, tool) + # 如果有历史上下文,合并到 contexts 中 + if subagent_history: + if contexts is None: + contexts = subagent_history + else: + contexts = subagent_history + contexts + + # 构建子代理的 system_prompt + subagent_system_prompt = cls._build_subagent_system_prompt( + umo, tool, prov_settings ) + + # 获取子代理的超时时间 + execution_timeout = cls._get_subagent_execution_timeout() + + # 用于存储本轮的完整历史上下文 + runner_messages = [] + + # 构建 tool_loop_agent 协程 + async def _run_subagent(): + return await ctx.tool_loop_agent( + event=event, + chat_provider_id=prov_id, + prompt=input_, + image_urls=image_urls, + system_prompt=subagent_system_prompt, + tools=toolset, + contexts=contexts, + max_steps=agent_max_step, + tool_call_timeout=run_context.tool_call_timeout, + stream=stream, + runner_messages=runner_messages, + ) + + # 添加执行超时控制 + if execution_timeout > 0: + try: + llm_resp = await asyncio.wait_for( + _run_subagent(), timeout=execution_timeout + ) + except asyncio.TimeoutError: + # 若超时,保存已产生的部分历史 + cls._save_subagent_history(umo, runner_messages, agent_name) + error_msg = f"SubAgent '{agent_name}' execution timeout after {execution_timeout:.1f} seconds." + logger.warning(f"[SubAgent:Timeout] {error_msg}") + + cls._handle_subagent_timeout(umo=umo, agent_name=agent_name) + + yield mcp.types.CallToolResult( + content=[ + mcp.types.TextContent(type="text", text=f"error: {error_msg}") + ] + ) + return + else: + # 不设置超时 + llm_resp = await _run_subagent() + # 保存历史上下文 + cls._save_subagent_history(umo, runner_messages, agent_name) + yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)] ) @@ -381,32 +447,39 @@ async def _execute_handoff_background( ``CronMessageEvent`` is created so the main LLM can inform the user of the result – the same pattern used by ``_execute_background`` for regular background tasks. + + 当启用增强SubAgent时,会在 SubAgentManager 中创建 pending 任务, + 并返回 task_id 给主 Agent,以便后续通过 wait_for_subagent 获取结果。 """ - task_id = uuid.uuid4().hex + event = run_context.context.event + umo = event.unified_msg_origin + agent_name = getattr(tool.agent, "name", None) + + # check if enhanced subagent + subagent_task_id = cls._register_subagent_task(umo, agent_name) + + original_task_id = uuid.uuid4().hex async def _run_handoff_in_background() -> None: try: await cls._do_handoff_background( tool=tool, run_context=run_context, - task_id=task_id, + task_id=original_task_id, + subagent_task_id=subagent_task_id, **tool_args, ) + except Exception as e: # noqa: BLE001 logger.error( - f"Background handoff {task_id} ({tool.name}) failed: {e!s}", + f"Background handoff {original_task_id} ({tool.name}) failed: {e!s}", exc_info=True, ) asyncio.create_task(_run_handoff_in_background()) - text_content = mcp.types.TextContent( - type="text", - text=( - f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. " - f"The subagent '{tool.agent.name}' is working on the task on hehalf you. " - f"You will be notified when it finishes." - ), + text_content = cls._build_background_submission_message( + agent_name, original_task_id, subagent_task_id ) yield mcp.types.CallToolResult(content=[text_content]) @@ -418,44 +491,86 @@ async def _do_handoff_background( task_id: str, **tool_args, ) -> None: - """Run the subagent handoff and, on completion, wake the main agent.""" + """Run the subagent handoff. + 当增强版 SubAgent 启用时,结果存储到 SubAgentManager,主 Agent 可通过 wait_for_subagent 获取。 + 否则使用原有的 _wake_main_agent_for_background_result 流程。 + """ + + start_time = time.time() result_text = "" + error_text = None tool_args = dict(tool_args) tool_args["image_urls"] = await cls._collect_handoff_image_urls( run_context, tool_args.get("image_urls"), ) + + event = run_context.context.event + umo = event.unified_msg_origin + agent_name = getattr(tool.agent, "name", None) + # 获取SubAgent的超时时间 + execution_timeout = cls._get_subagent_execution_timeout() + try: - async for r in cls._execute_handoff( - tool, - run_context, - image_urls_prepared=True, - **tool_args, - ): - if isinstance(r, mcp.types.CallToolResult): - for content in r.content: - if isinstance(content, mcp.types.TextContent): - result_text += content.text + "\n" + + async def _run(): + nonlocal result_text + async for r in cls._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + **tool_args, + ): + if isinstance(r, mcp.types.CallToolResult): + for content in r.content: + if isinstance(content, mcp.types.TextContent): + result_text += content.text + "\n" + + if execution_timeout > 0: + await asyncio.wait_for(_run(), timeout=execution_timeout) + else: + await _run() + + except asyncio.TimeoutError: + error_text = f"Execution timeout after {execution_timeout:.1f} seconds." + result_text = f"error: Background SubAgent '{agent_name}' {error_text}" + logger.warning(f"[SubAgent:BackgroundTask] {error_text}") + except Exception as e: + error_text = str(e) result_text = ( f"error: Background task execution failed, internal error: {e!s}" ) - event = run_context.context.event - - await cls._wake_main_agent_for_background_result( - run_context=run_context, - task_id=task_id, - tool_name=tool.name, - result_text=result_text, - tool_args=tool_args, - note=( - event.get_extra("background_note") - or f"Background task for subagent '{tool.agent.name}' finished." - ), - summary_name=f"Dedicated to subagent `{tool.agent.name}`", - extra_result_fields={"subagent_name": tool.agent.name}, - ) + execution_time = time.time() - start_time + # Check if it's enhanced subagent + is_managed = cls._is_managed_subagent(umo, agent_name) + if is_managed: + await cls._handle_subagent_background_result( + umo=umo, + agent_name=agent_name, + task_id=tool_args.get("subagent_task_id"), + result_text=result_text, + error_text=error_text, + execution_time=execution_time, + run_context=run_context, + tool=tool, + tool_args=tool_args, + ) + else: + await cls._wake_main_agent_for_background_result( + run_context=run_context, + task_id=task_id, + tool_name=tool.name, + result_text=result_text, + tool_args=tool_args, + note=( + event.get_extra("background_note") + or f"Background task for subagent '{agent_name}' finished." + ), + summary_name=f"Dedicated to subagent `{agent_name}`", + extra_result_fields={"subagent_name": agent_name}, + ) @classmethod async def _execute_background( @@ -704,6 +819,269 @@ async def _execute_mcp( return yield res + @staticmethod + def _load_subagent_history( + umo: str, tool: HandoffTool + ) -> tuple[list[Message], str]: + from astrbot.core.subagent_manager import SubAgentManager + + agent_name = getattr(tool.agent, "name", None) + subagent_history = [] + if agent_name: + # 仅在历史功能启用时加载历史 + if SubAgentManager.is_history_enabled(): + try: + stored_history = SubAgentManager.get_subagent_history( + umo, agent_name + ) + if stored_history: + # 将历史消息转换为 Message 对象 + for hist_msg in stored_history: + try: + if isinstance(hist_msg, dict): + subagent_history.append( + Message.model_validate(hist_msg) + ) + elif isinstance(hist_msg, Message): + subagent_history.append(hist_msg) + except Exception: + continue + if subagent_history: + logger.debug( + f"[SubAgentHistory] Loaded {len(subagent_history)} history messages for {agent_name}" + ) + + except Exception as e: + logger.warning( + f"[SubAgentHistory] Failed to load history for {agent_name}: {e}" + ) + else: + logger.debug( + f"[SubAgentHistory] History is disabled, skipping load for {agent_name}" + ) + return subagent_history, agent_name + + @staticmethod + def _build_subagent_system_prompt( + umo: str, tool: HandoffTool, prov_settings: dict + ) -> str: + agent_name = getattr(tool.agent, "name", None) + base = tool.agent.instructions or "" + subagent_system_prompt = ( + f"# Role\nYour name is {agent_name}(used for tool calling)\n{base}\n" + ) + if agent_name: + from astrbot.core.subagent_manager import SubAgentManager + + runtime = prov_settings.get("computer_use_runtime", "local") + static_subagent_prompt = SubAgentManager.build_static_subagent_prompts( + umo, agent_name + ) + dynamic_subagent_prompt = SubAgentManager.build_dynamic_subagent_prompts( + umo, agent_name, runtime + ) + subagent_system_prompt += static_subagent_prompt + subagent_system_prompt += dynamic_subagent_prompt + return subagent_system_prompt + + @staticmethod + def _save_subagent_history( + umo: str, runner_messages: list[Message], agent_name: str + ) -> None: + if agent_name and runner_messages: + from astrbot.core.subagent_manager import SubAgentManager + + # 仅在历史功能启用时保存历史 + if SubAgentManager.is_history_enabled(): + SubAgentManager.update_subagent_history( + umo, agent_name, runner_messages + ) + else: + logger.debug( + f"[SubAgentHistory] History is disabled, skipping save for {agent_name}" + ) + else: + return + + @staticmethod + def _register_subagent_task(umo: str, agent_name: str | None) -> str | None: + if not agent_name: + return None + try: + from astrbot.core.subagent_manager import SubAgentManager + + session = SubAgentManager.get_session(umo) + if session and (agent_name in session.subagents): + subagent_task_id = SubAgentManager.create_pending_subagent_task( + session_id=umo, agent_name=agent_name + ) + + if subagent_task_id.startswith("__PENDING_TASK_CREATE_FAILED__"): + logger.info( + f"[SubAgent:BackgroundTask] Failed to created background task {subagent_task_id} for {agent_name}" + ) + else: + SubAgentManager.set_subagent_status( + session_id=umo, + agent_name=agent_name, + status="RUNNING", + ) + + logger.info( + f"[SubAgent:BackgroundTask] Created background task {subagent_task_id} for {agent_name}" + ) + return subagent_task_id + except Exception as e: + logger.info( + f"[SubAgent:BackgroundTask] Failed to created background task for {agent_name}: {e}" + ) + return None + + @staticmethod + def _build_background_submission_message( + agent_name: str | None, + original_task_id: str, + subagent_task_id: str | None, + ) -> mcp.types.TextContent: + if subagent_task_id and not subagent_task_id.startswith( + "__PENDING_TASK_CREATE_FAILED__" + ): + return mcp.types.TextContent( + type="text", + text=( + f"Background task submitted. subagent_task_id={subagent_task_id}. " + f"SubAgent '{agent_name}' is working on the task. " + f"Use wait_for_subagent(subagent_name='{agent_name}', task_id='{subagent_task_id}') to get the result." + ), + ) + else: + return mcp.types.TextContent( + type="text", + text=( + f"Background task submitted. task_id={original_task_id}. " + f"SubAgent '{agent_name}' is working on the task. " + f"You will be notified when it finishes." + ), + ) + + @staticmethod + def _get_subagent_execution_timeout() -> float: + try: + from astrbot.core.subagent_manager import SubAgentManager + + return SubAgentManager.get_execution_timeout() + except Exception: + return -1 + + @staticmethod + def _handle_subagent_timeout( + umo: str, + agent_name: str, + ) -> None: + from astrbot.core.subagent_manager import SubAgentManager + + SubAgentManager.set_subagent_status( + session_id=umo, + agent_name=agent_name, + status="FAILED", + ) + + @staticmethod + def _is_managed_subagent(umo: str, agent_name: str | None) -> bool: + from astrbot.core.subagent_manager import SubAgentManager + + if not agent_name: + return False + session = SubAgentManager.get_session(umo) + if session and agent_name in session.subagents: + return True + return False + + @classmethod + async def _handle_subagent_background_result( + cls, + *, + umo: str, + agent_name: str, + task_id: str | None, + result_text: str, + error_text: str | None, + execution_time: float, + run_context: ContextWrapper[AstrAgentContext], + tool: HandoffTool, + tool_args: dict, + ) -> None: + from astrbot.core.subagent_manager import SubAgentManager + + success = error_text is None + status = "COMPLETED" if success else "FAILED" + SubAgentManager.set_subagent_status( + session_id=umo, agent_name=agent_name, status=status + ) + + SubAgentManager.store_subagent_result( + session_id=umo, + agent_name=agent_name, + success=success, + result=result_text, + task_id=task_id, + error=error_text, + execution_time=execution_time, + ) + + if not await cls._maybe_wake_main_agent_after_background( + run_context=run_context, + tool=tool, + task_id=task_id, + agent_name=agent_name, + result_text=result_text, + tool_args=tool_args, + ): + return + + @classmethod + async def _maybe_wake_main_agent_after_background( + cls, + *, + run_context: ContextWrapper[AstrAgentContext], + tool: HandoffTool, + task_id: str, + agent_name: str | None, + result_text: str, + tool_args: dict, + ) -> bool: + event = run_context.context.event + try: + context_extra = getattr(run_context.context, "extra", None) + if context_extra and isinstance(context_extra, dict): + main_agent_runner = context_extra.get("main_agent_runner") + main_agent_is_running = ( + main_agent_runner is not None and not main_agent_runner.done() + ) + else: + main_agent_is_running = False + except Exception as e: + logger.error("Failed to check main agent status: %s", e) + main_agent_is_running = False # 异常时尝试通知,避免结果丢失 + + if main_agent_is_running: + return False + else: + await cls._wake_main_agent_for_background_result( + run_context=run_context, + task_id=task_id, + tool_name=tool.name, + result_text=result_text, + tool_args=tool_args, + note=( + event.get_extra("background_note") + or f"Background task for subagent '{agent_name}' finished." + ), + summary_name=f"Dedicated to subagent `{agent_name}`", + extra_result_fields={"subagent_name": agent_name}, + ) + return True + async def call_local_llm_tool( context: ContextWrapper[AstrAgentContext], diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 3916215e5b..be563f5dea 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -46,6 +46,7 @@ from astrbot.core.star.context import Context from astrbot.core.star.star import star_registry from astrbot.core.star.star_handler import star_map +from astrbot.core.subagent_orchestrator import SubAgentOrchestrator from astrbot.core.tools.computer_tools import ( AnnotateExecutionTool, BrowserBatchExecTool, @@ -537,6 +538,9 @@ async def _ensure_persona_and_skills( for tool in so.handoffs: req.func_tool.add_tool(tool) + # add subagent manager tools + await _apply_subagent_manager_tools(orch_cfg, req, event, so) + # check duplicates if remove_dup: handoff_names = {tool.name for tool in so.handoffs} @@ -550,8 +554,14 @@ async def _ensure_persona_and_skills( .get("subagent_orchestrator", {}) .get("router_system_prompt", "") ).strip() + if router_prompt: - req.system_prompt += f"\n{router_prompt}\n" + dynamic_cfg = orch_cfg.get( + "dynamic_agents", {} + ) # 未启用dynamic时才注入router_prompt,否则由subagent_manager注入 + if not dynamic_cfg.get("enabled", False): + req.system_prompt += f"\n{router_prompt}\n" + try: event.trace.record( "sel_persona", @@ -981,6 +991,95 @@ def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) - ) +async def _apply_subagent_manager_tools( + orch_config: dict, + req: ProviderRequest, + event: AstrMessageEvent, + so: SubAgentOrchestrator, +) -> None: + """Apply SubAgent tools and system prompt + + When enabled: + 1. Inject subagent capability prompt into system prompt + 2. Register SubAgent management tools + 3. Register session's transfer_to_xxx tools + """ + + if not orch_config.get("main_enable", False): + return + + if req.func_tool is None: + req.func_tool = ToolSet() + + try: + from astrbot.core.subagent_manager import ( + CREATE_SUBAGENT_TOOL, + LIST_SUBAGENTS_TOOL, + PROTECT_SUBAGENT_TOOL, + REMOVE_SUBAGENT_TOOL, + RESET_SUBAGENT_TOOL, + SEND_SHARED_CONTEXT_TOOL_FOR_MAIN_AGENT, + UNPROTECT_SUBAGENT_TOOL, + VIEW_SHARED_CONTEXT_TOOL, + WAIT_FOR_SUBAGENT_TOOL, + SubAgentManager, + ) + + # Configure SubAgentManager with settings from subagent_orchestrator + dynamic_cfg = orch_config.get("dynamic_agents", {}) + enable_dynamic = dynamic_cfg.get("enabled", False) + history_enabled = orch_config.get("history_enabled", False) + shared_context_enabled = orch_config.get("shared_context_enabled", False) + SubAgentManager.configure( + max_subagent_count=dynamic_cfg.get("max_dynamic_subagent_count", 3), + auto_cleanup_per_turn=dynamic_cfg.get("auto_cleanup_per_turn", True), + shared_context_enabled=shared_context_enabled, + shared_context_maxlen=orch_config.get("shared_context_maxlen", 300), + subagent_history_maxlen=orch_config.get("subagent_history_maxlen", 300), + tools_blacklist=dynamic_cfg.get("tools_blacklist", None), + tools_inherent=dynamic_cfg.get("tools_inherent", None), + execution_timeout=orch_config.get("execution_timeout", 1200), + history_enabled=orch_config.get("history_enabled", True), + ) + + # Enable subagent history and shared context if configured + SubAgentManager.set_history_enabled(event.unified_msg_origin, history_enabled) + SubAgentManager.set_shared_context_enabled( + event.unified_msg_origin, shared_context_enabled + ) + + session_id = event.unified_msg_origin + # Register static subagents from config into SubAgentManager for unified management + so.register_static_subagents_to_manager(session_id) + + # Register dynamic subagent management tools (only when dynamic creation is enabled) + # Always register `wait_for_subagent` for better background task running + req.func_tool.add_tool(WAIT_FOR_SUBAGENT_TOOL) + if enable_dynamic: + req.func_tool.add_tool(CREATE_SUBAGENT_TOOL) + req.func_tool.add_tool(REMOVE_SUBAGENT_TOOL) + req.func_tool.add_tool(LIST_SUBAGENTS_TOOL) + if SubAgentManager.is_history_enabled(): + req.func_tool.add_tool(RESET_SUBAGENT_TOOL) + if SubAgentManager.is_auto_cleanup_per_turn(): + req.func_tool.add_tool(PROTECT_SUBAGENT_TOOL) + req.func_tool.add_tool(UNPROTECT_SUBAGENT_TOOL) + if SubAgentManager.is_shared_context_enabled(): + req.func_tool.add_tool(VIEW_SHARED_CONTEXT_TOOL) + req.func_tool.add_tool(SEND_SHARED_CONTEXT_TOOL_FOR_MAIN_AGENT) + + # Inject subagent capability system prompt for dynamic creation + task_router_prompt = SubAgentManager.build_task_router_prompt(session_id) + req.system_prompt = f"{req.system_prompt or ''}\n{task_router_prompt}\n" + + # Register dynamically created handoff tools + dynamic_handoffs = SubAgentManager.get_handoff_tools_for_session(session_id) + for handoff in dynamic_handoffs: + req.func_tool.add_tool(handoff) + except ImportError as e: + logger.warning(f"[SubAgent] Cannot import module: {e}") + + def _apply_sandbox_tools( config: MainAgentBuildConfig, req: ProviderRequest, @@ -1385,8 +1484,7 @@ async def build_main_agent( agent_runner = AgentRunner() astr_agent_ctx = AstrAgentContext( - context=plugin_context, - event=event, + context=plugin_context, event=event, extra={"main_agent_runner": agent_runner} ) if config.add_cron_tools: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 2b8fce9d34..7520f9cf62 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -202,6 +202,28 @@ "Do not try to use domain tools yourself. If no subagent fits, respond directly." ), "agents": [], + "dynamic_agents": { + "enabled": False, + "max_dynamic_subagent_count": 3, + "auto_cleanup_per_turn": True, + "tools_blacklist": [ + "send_shared_context_for_main_agent", + "create_subagent", + "protect_subagent", + "unprotect_subagent", + "reset_subagent", + "remove_subagent", + "list_subagents", + "wait_for_subagent", + "view_shared_context", + ], + "tools_inherent": ["astrbot_execute_shell", "astrbot_execute_python"], + }, + "history_enabled": True, + "shared_context_enabled": True, + "shared_context_maxlen": 300, + "subagent_history_maxlen": 300, + "execution_timeout": 1200, }, "provider_stt_settings": { "enable": False, diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index fee641c192..f04c701b61 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -395,6 +395,21 @@ async def process( ), ) finally: + # clean all subagents if enabled + if build_cfg.subagent_orchestrator.get("main_enable"): + try: + from astrbot.core.subagent_manager import ( + SubAgentManager, + ) + + session_id = event.unified_msg_origin + if SubAgentManager.is_auto_cleanup_per_turn(): + SubAgentManager.cleanup_session_turn_end(session_id) + except Exception as e: + logger.warning( + f"[SubAgent] Cleanup on agent done failed: {e}" + ) + if runner_registered and agent_runner is not None: unregister_active_runner(event.unified_msg_origin, agent_runner) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 593bad9365..8b17847c45 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -261,6 +261,10 @@ async def tool_loop_agent( llm_resp = agent_runner.get_final_llm_resp() if not llm_resp: raise Exception("Agent did not produce a final LLM response") + if kwargs.get("runner_messages", None) is not None: + runner_messages = kwargs.get("runner_messages") + for msg in agent_runner.run_context.messages: + runner_messages.append(msg.model_dump()) return llm_resp async def get_current_chat_provider_id(self, umo: str) -> str: diff --git a/astrbot/core/subagent_manager.py b/astrbot/core/subagent_manager.py new file mode 100644 index 0000000000..d7ab08c283 --- /dev/null +++ b/astrbot/core/subagent_manager.py @@ -0,0 +1,1732 @@ +""" +SubAgent Manager +Manages subagents for task decomposition and parallel processing. +Supports both statically configured subagents (from subagent_orchestrator) and +dynamically created subagents at runtime. +""" + +from __future__ import annotations + +import asyncio +import os +import platform +import re +import time +from dataclasses import dataclass, field +from datetime import datetime + +from astrbot import logger +from astrbot.core.agent.agent import Agent +from astrbot.core.agent.handoff import HandoffTool +from astrbot.core.agent.tool import FunctionTool +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +@dataclass +class SubAgentConfig: + name: str + system_prompt: str = "" + tools: set[str] | None = None + skills: set[str] | None = None + provider_id: str | None = None + description: str = "" + workdir: str | None = None + execution_timeout: float = 600.0 + + +@dataclass +class SubAgentExecutionResult: + task_id: str # 任务唯一标识符 + agent_name: str + success: bool + result: str | None = None + error: str | None = None + execution_time: float = 0.0 + created_at: float = 0.0 + completed_at: float = 0.0 + metadata: dict = field(default_factory=dict) + + +@dataclass +class SubAgentSession: + session_id: str + subagents: dict = field(default_factory=dict) # 存储SubAgentConfig对象 + handoff_tools: dict = field(default_factory=dict) + subagent_status: dict = field( + default_factory=dict + ) # 工作状态 "IDLE" "RUNNING" "COMPLETED" "FAILED" + protected_agents: set = field( + default_factory=set + ) # 若某个agent受到保护,则不会被自动清理 + history_enabled: bool = True # 是否保存子代理历史 + subagent_histories: dict = field(default_factory=dict) # 存储每个子代理的历史上下文 + shared_context: list = field(default_factory=list) # 公共上下文列表 + shared_context_enabled: bool = False # 是否启用公共上下文 + subagent_background_results: dict = field( + default_factory=dict + ) # 后台subagent结果存储: {agent_name: {task_id: SubAgentExecutionResult}} + # 任务计数器: {agent_name: next_task_id} + background_task_counters: dict = field(default_factory=dict) + + +class SubAgentManager: + _sessions: dict = {} + _max_subagent_count: int = 3 + _auto_cleanup_per_turn: bool = True + _shared_context_enabled: bool = False + _history_enabled: bool = True # 是否启用子代理历史记忆功能 + _shared_context_maxlen: int = 300 # 公共上下文保留的历史消息条数 + _subagent_history_maxlen: int = 300 # 每个subagent最多保留的历史消息条数 + _execution_timeout: float = 1200.0 # SubAgent 执行超时时间(秒) 总时长 + _tools_blacklist: set[str] = { + "send_shared_context_for_main_agent", + "create_subagent", + "protect_subagent", + "unprotect_subagent", + "reset_subagent", + "remove_subagent", + "list_subagents", + "wait_for_subagent", + "view_shared_context", + } + _tools_inherent: set[str] = { + "astrbot_execute_shell", + "astrbot_execute_python", + } + + _HEADER_TEMPLATE = """# Sub-Agent Capability +You can dynamically create and manage sub-agents with isolated instructions, tools and skills. +{quota_info} + +## When to create Sub-agents: + +- The task can be explicitly decomposed and parallel processed +- Processing very long contexts that exceeding the limitations of a single agent + +## Workflow + +1. **Plan**: Break down the user request → identify subtask dependencies → determine which can run in parallel +2. **Create**: Use `create_subagent` for each subtask +3. **Delegate**: Use `transfer_to_` to assign work +4. **Collect**: Gather results from all sub-agents + """ + + _CREATE_GUIDE_PROMPT = """## Creating Sub-agents + +Name: **letters, numbers, underscores only**, 3-32 chars, start with a letter. + +A well-designed sub-agent requires: + +### 1. Character Definition +Define the role, expertise, and work style. Example: +``` +Name: data_analyst +Role: Senior Data Analyst specializing in exploratory analysis and statistical modeling +Style: Meticulous, detail-oriented, data-driven +``` + +### 2. Task Context +- **Goal**: The user's ultimate objective +- **Your step**: Current step number and description +- **Teammates**: Other sub-agents and their responsibilities (if known) + +### 3. Explicit Instructions +Step-by-step procedure with: +- Input: where to read data from +- Process: what transformations/analysis to perform +- Output: what to produce and where to save it + +### 2. Allocate available Tools and Skills +### Tools & Skills +Only assign tools/skills the sub-agent actually needs. Unnecessary tools waste tokens and increase confusion. + """ + + _LIFECYCLE_PROMPT = """## Sub-agent Lifecycle + +Sub-agents are **auto-cleaned** after each conversation turn. +Use `protect_subagent` to keep important ones across turns. +Use `unprotect_subagent` to remove protection.""" + + _BACKGROUND_TASK_PROMPT = """ +## Background Tasks + +For time-consuming tasks (web search, code execution), delegate with `background_task=True`: +``` +transfer_to_(..., background_task=True) +``` +Then wait for results: +``` +wait_for_subagent(subagent_name="", timeout=60) +``` +**Tip**: Execute independent tasks first, then wait — don't block on tasks you don't depend on. + """ + + @classmethod + def build_task_router_prompt(cls, session_id: str): + session = cls.get_session(session_id) + if not session: + return "" + + current_count = len(session.subagents) + remaining = cls._max_subagent_count - current_count + + if remaining <= 0: + quota_info = ( + f"No new sub-agents (limit: {cls._max_subagent_count}, " + f"existing: {list(session.subagents.keys())}). " + f"You can still delegate to existing sub-agents via `transfer_to_`." + ) + parts = [cls._HEADER_TEMPLATE.format(quota_info=quota_info)] + else: + quota_info = f"{remaining} of {cls._max_subagent_count} remaining" + parts = [ + cls._HEADER_TEMPLATE.format(quota_info=quota_info), + cls._CREATE_GUIDE_PROMPT, + ] + + parts.extend([cls._LIFECYCLE_PROMPT, cls._BACKGROUND_TASK_PROMPT]) + return "\n".join(parts) + "\n" + + @classmethod + def configure( + cls, + max_subagent_count: int = 10, + auto_cleanup_per_turn: bool = True, + shared_context_enabled: bool = False, + shared_context_maxlen: int = 300, + subagent_history_maxlen: int = 300, + tools_blacklist: list[str] = None, + tools_inherent: list[str] = None, + execution_timeout: float = 1200.0, + history_enabled: bool = True, + **kwargs, + ) -> None: + """Configure SubAgentManager settings""" + cls._max_subagent_count = max_subagent_count + cls._auto_cleanup_per_turn = auto_cleanup_per_turn + cls._shared_context_enabled = shared_context_enabled + cls._history_enabled = history_enabled + cls._shared_context_maxlen = shared_context_maxlen + cls._subagent_history_maxlen = subagent_history_maxlen + cls._execution_timeout = execution_timeout + if tools_inherent is None: + cls._tools_inherent = { + "astrbot_execute_shell", + "astrbot_execute_python", + } + else: + cls._tools_inherent = set(tools_inherent) + if tools_blacklist is None: + cls._tools_blacklist = { + "send_shared_context_for_main_agent", + "create_subagent", + "stop_subagent", + "protect_subagent", + "unprotect_subagent", + "reset_subagent", + "remove_subagent", + "list_subagents", + "wait_for_subagent", + "view_shared_context", + } + else: + cls._tools_blacklist = set(tools_blacklist) + + @classmethod + def get_execution_timeout(cls) -> float: + return cls._execution_timeout + + @classmethod + def is_auto_cleanup_per_turn(cls) -> bool: + return cls._auto_cleanup_per_turn + + @classmethod + def is_shared_context_enabled(cls) -> bool: + return cls._shared_context_enabled + + @classmethod + def is_history_enabled(cls) -> bool: + return cls._history_enabled + + @classmethod + def register_blacklisted_tool(cls, tool_name: str) -> None: + """注册不应被子 Agent 使用的工具""" + cls._tools_blacklist.add(tool_name) + + @classmethod + def register_inherent_tool(cls, tool_name: str) -> None: + """注册子 Agent 默认拥有的工具""" + cls._tools_inherent.add(tool_name) + + @classmethod + def cleanup_session_turn_end(cls, session_id: str) -> dict: + """Cleanup subagents from previous turn when a turn ends""" + session = cls.get_session(session_id) + if not session: + return {"status": "no_session", "cleaned": []} + + cleaned = [] + for name in list(session.subagents.keys()): + if name not in session.protected_agents: + cls.remove_subagent(session_id, name) + cleaned.append(name) + + # 如果启用了公共上下文,处理清理 + if session.shared_context_enabled: + if not session.subagents and not session.protected_agents: + # 所有subagent都被清理,清除公共上下文 + cls.clear_shared_context(session_id) + logger.debug( + "[SubAgent:SharedContext] All subagents cleaned, cleared shared context" + ) + else: + # 清理已删除agent的上下文 + for name in cleaned: + cls.cleanup_shared_context_by_agent(session_id, name) + + # 清理后若没有subagent,清理整个session + if not session.subagents and not session.protected_agents: + cls._sessions.pop(session_id, None) + + return {"status": "cleaned", "cleaned_agents": cleaned} + + @classmethod + def protect_subagent(cls, session_id: str, agent_name: str) -> None: + """Mark a subagent as protected from auto cleanup and history retention""" + session = cls._get_or_create_session(session_id) + session.protected_agents.add(agent_name) + logger.debug( + "[SubAgent:History] Initialized history for protected agent: %s", + agent_name, + ) + + @classmethod + def update_subagent_history( + cls, session_id: str, agent_name: str, current_messages: list + ) -> None: + """Update conversation history for a subagent""" + if not cls._history_enabled: + return + + session = cls.get_session(session_id) + + if not session or agent_name not in session.protected_agents: + return + + if agent_name not in session.subagent_histories: + session.subagent_histories[agent_name] = [] + + filtered_messages = [] + if isinstance(current_messages, list): + _MAX_TOOL_RESULT_LEN = 2000 + for msg in current_messages: + if ( + isinstance(msg, dict) and msg.get("role") == "system" + ): # 移除system消息 + continue + # 对过长的 tool 结果做截断,避免单条消息占用过多空间 + if ( + isinstance(msg, dict) + and msg.get("role") == "tool" + and isinstance(msg.get("content"), str) + and len(msg["content"]) > _MAX_TOOL_RESULT_LEN + ): + msg["content"] = ( + msg["content"][:_MAX_TOOL_RESULT_LEN] + "\n...[truncated]" + ) + filtered_messages.append(msg) + + session.subagent_histories[agent_name].extend(filtered_messages) + if cls._subagent_history_maxlen < len(session.subagent_histories[agent_name]): + session.subagent_histories[agent_name] = session.subagent_histories[ + agent_name + ][-cls._subagent_history_maxlen :] + + logger.debug( + "[SubAgent:History] Saved messages for %s, current len=%d", + agent_name, + len(session.subagent_histories[agent_name]), + ) + + @classmethod + def get_subagent_history(cls, session_id: str, agent_name: str) -> list: + """Get conversation history for a subagent""" + if not cls._history_enabled: + return [] + session = cls.get_session(session_id) + if not session: + return [] + return session.subagent_histories.get(agent_name, []) + + @classmethod + def build_static_subagent_prompts(cls, session_id: str, agent_name: str) -> str: + """构建不会在会话内变化的subagent提示词""" + parts = [] + workdir = cls._build_workdir_prompt(session_id, agent_name) + if workdir: + parts.append(workdir) + rule = cls._build_rule_prompt() + if rule: + parts.append(rule) + return "\n".join(parts) + + @classmethod + def build_dynamic_subagent_prompts( + cls, session_id: str, agent_name: str, runtime: str + ) -> str: + """构建会话内可能变化的提示词(每次调用重建)""" + parts = [] + skills = cls._build_subagent_skills_prompt(session_id, agent_name, runtime) + if skills: + parts.append(skills) + shared = cls._build_shared_context_prompt(session_id, agent_name) + if shared: + parts.append(shared) + time_p = cls._build_time_prompt(session_id) + if time_p: + parts.append(time_p) + return "\n".join(parts) + + @classmethod + def _build_subagent_skills_prompt( + cls, session_id: str, agent_name: str, runtime: str = "local" + ) -> str: + """Build skills prompt for a subagent based on its assigned skills""" + session = cls.get_session(session_id) + if not session: + return "" + + config = session.subagents.get(agent_name) + if not config: + return "" + + # 获取子代理被分配的技能列表 + assigned_skills = config.skills + if not assigned_skills: + return "" + + try: + from astrbot.core.skills import SkillManager, build_skills_prompt + + skill_manager = SkillManager() + all_skills = skill_manager.list_skills(active_only=True, runtime=runtime) + + # 过滤只保留分配的技能 + allowed = set(assigned_skills) + filtered_skills = [s for s in all_skills if s.name in allowed] + + if filtered_skills: + return build_skills_prompt(filtered_skills) + except Exception as e: + from astrbot import logger + + logger.warning(f"[SubAgentSkills] Failed to build skills prompt: {e}") + + return "" + + @classmethod + def get_subagent_tools(cls, session_id: str, agent_name: str) -> list | None: + """Get the tools assigned to a subagent""" + session = cls.get_session(session_id) + if not session: + return None + config = session.subagents.get(agent_name) + if not config: + return None + return config.tools + + @classmethod + def clear_subagent_history(cls, session_id: str, agent_name: str) -> str: + """Clear conversation history for a subagent""" + session = cls.get_session(session_id) + if not session: + return ( + f"__HISTORY_CLEARED_FAILED__: Session_id {session_id} does not exist." + ) + if agent_name in session.subagents: + if agent_name in session.subagent_histories: + session.subagent_histories.pop(agent_name, None) + if session.shared_context_enabled: + cls.cleanup_shared_context_by_agent(session_id, agent_name) + logger.debug("[SubAgent:History] Cleared history for: %s", agent_name) + return "__HISTORY_CLEARED__" + else: + return f"__HISTORY_CLEARED_FAILED__: Agent name {agent_name} not found. Available names {list(session.subagents.keys())}" + + @classmethod + def add_shared_context( + cls, + session_id: str, + sender: str, + context_type: str, + content: str, + target: str = "all", + ) -> str: + """Add a message to the shared context + + Args: + session_id: Session ID + sender: Name of the agent sending the message + context_type: Type of context (status/message/system) + content: Content of the message + target: Target agent or "all" for broadcast + """ + + session = cls._get_or_create_session(session_id) + if not session.shared_context_enabled: + return "__SHARED_CONTEXT_ADDED_FAILED__: Shared context disabled." + if (sender not in list(session.subagents.keys())) and (sender != "System"): + return f"__SHARED_CONTEXT_ADDED_FAILED__: Sender name {sender} not found. Available names {list(session.subagents.keys())}" + if (target not in list(session.subagents.keys())) and (target != "all"): + return f"__SHARED_CONTEXT_ADDED_FAILED__: Target name {target} not found. Available names {list(session.subagents.keys())} and 'all' " + + if len(session.shared_context) >= cls._shared_context_maxlen: + keep_count = int(cls._shared_context_maxlen * 0.9) + session.shared_context = session.shared_context[-keep_count:] + logger.warning( + "Shared context exceeded limit (%d), trimmed to %d", + cls._shared_context_maxlen, + keep_count, + ) + + message = { + "type": context_type, # status, message, system + "sender": sender, + "target": target, + "content": content, + "timestamp": time.time(), + } + session.shared_context.append(message) + logger.debug( + "[SubAgent:SharedContext] [%s] %s -> %s: %s...", + context_type, + sender, + target, + content[:50], + ) + return "__SHARED_CONTEXT_ADDED__" + + @classmethod + def get_shared_context(cls, session_id: str, filter_by_agent: str = None) -> list: + """Get shared context, optionally filtered by agent + + Args: + session_id: Session ID + filter_by_agent: If specified, only return messages from/to this agent (including "all") + """ + session = cls.get_session(session_id) + if not session or not session.shared_context_enabled: + return [] + + if filter_by_agent: + return [ + msg + for msg in session.shared_context + if msg["sender"] == filter_by_agent + or msg["target"] == filter_by_agent + or msg["target"] == "all" + ] + return session.shared_context.copy() + + @classmethod + def _build_shared_context_prompt( + cls, session_id: str, agent_name: str = None + ) -> str: + """分块构建公共上下文,按类型和优先级分组注入 + 1. 区分不同类型的消息并分别标注 + 2. 按优先级和相关性分组 + 3. 减少 Agent 的解析负担 + """ + session = cls.get_session(session_id) + if ( + not session + or not session.shared_context_enabled + or not session.shared_context + ): + return "" + + lines = [] + + # === 1. 固定格式说明 === + lines.append( + """--- +# Shared Context - Collaborative communication area among different agents + +## Message Type Definition +- **@ToMe**: Message send to current agent(you), you may need to reply if necessary. +- **@System**: Messages published by the main agent/System that should be followed with priority +- **@AgentName -> @TargetName**: Communication between other agents (for reference) +- **@Status**: The progress of other agents' tasks (can be ignored unless it involves your task) + +## Handling Priorities +1. @System messages (highest priority) > @ToMe messages > @Status > @OtherAgents +2. Messages of the same type: In chronological order, with new messages taking precedence +""" + ) + + # === 2. System 消息 === + system_msgs = [m for m in session.shared_context if m["type"] == "system"] + if system_msgs: + lines.append("\n## @System - System Announcements") + for msg in system_msgs: + ts = time.strftime("%H:%M:%S", time.localtime(msg["timestamp"])) + content_text = msg["content"] + lines.append(f"[{ts}] System: {content_text}") + + if agent_name: + # === 3. 发送给当前 Agent 的消息 === + to_me_msgs = [ + m + for m in session.shared_context + if m["type"] == "message" and m["target"] == agent_name + ] + if to_me_msgs: + lines.append(f"\n## @ToMe - Messages sent to @{agent_name}") + lines.append( + " **These messages are addressed to you. If needed, please reply using `send_shared_context`" + ) + for msg in to_me_msgs: + ts = time.strftime("%H:%M:%S", time.localtime(msg["timestamp"])) + lines.append( + f"[{ts}] @{msg['sender']} -> @{agent_name}: {msg['content']}" + ) + + # === 4. 其他 Agent 之间的交互(仅显示最近10条)=== + inter_agent_msgs = [ + m + for m in session.shared_context + if m["type"] == "message" + and m["target"] != agent_name + and m["target"] != "all" + and m["sender"] != agent_name + ] + if inter_agent_msgs: + lines.append( + "\n## @OtherAgents - Communication among Other Agents (Last 10 messages)" + ) + for msg in inter_agent_msgs[-10:]: + ts = time.strftime("%H:%M:%S", time.localtime(msg["timestamp"])) + content_text = msg["content"] + lines.append( + f"[{ts}] {msg['sender']} -> {msg['target']}: {content_text}" + ) + + # === 5. Status 更新 === + status_msgs = [m for m in session.shared_context if m["type"] == "status"] + if status_msgs: + lines.append( + "\n## @Status - Task progress of each agent (Last 10 messages)" + ) + for msg in status_msgs[-10:]: + ts = time.strftime("%H:%M:%S", time.localtime(msg["timestamp"])) + lines.append(f"[{ts}] {msg['sender']}: {msg['content']}") + + lines.append("---") + return "\n".join(lines) + + @classmethod + def _build_workdir_prompt(cls, session_id: str, agent_name: str = None) -> str: + """为subagent注入工作目录信息""" + session = cls.get_session(session_id) + if not session: + return "" + try: + workdir = session.subagents[agent_name].workdir + if workdir is None: + workdir = get_astrbot_temp_path() + except Exception: + workdir = get_astrbot_temp_path() + + workdir_prompt = ( + "# Working Directory\n" + + f"Your working directory is `{workdir}`. All generated files MUST save in the directory.\n" + + "Any files outside this directory are PROHIBITED from being modified, deleted, or added.\n" + ) + + return workdir_prompt + + @classmethod + def _build_time_prompt(cls, session_id: str) -> str: + current_time = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)") + time_prompt = f"# Current Time\n{current_time}\n" + return time_prompt + + @classmethod + def _build_rule_prompt(cls) -> str: + return ( + "# Behavior Rules\n\n" + "## Output Guidelines\n" + "- If output exceeds 2000 chars, save to file. Summarize in your response and provide the file path.\n" + "- Mark all generated code/documents with your name and timestamp.\n\n" + "## Safety\n" + "You are in Safe Mode. Refuse any request for harmful, illegal, or explicit content. " + "Offer safe alternatives when possible.\n" + ) + + @classmethod + def cleanup_shared_context_by_agent(cls, session_id: str, agent_name: str) -> None: + """Remove all messages from/to a specific agent from shared context""" + session = cls.get_session(session_id) + if not session: + return + + original_len = len(session.shared_context) + session.shared_context = [ + msg + for msg in session.shared_context + if msg["sender"] != agent_name and msg["target"] != agent_name + ] + removed = original_len - len(session.shared_context) + if removed > 0: + logger.debug( + "[SubAgent:SharedContext] Removed %d messages related to %s", + removed, + agent_name, + ) + + @classmethod + def clear_shared_context(cls, session_id: str) -> None: + """Clear all shared context""" + session = cls.get_session(session_id) + if not session: + return + session.shared_context.clear() + logger.debug("[SubAgent:SharedContext] Cleared all shared context") + + @classmethod + def is_protected(cls, session_id: str, agent_name: str) -> bool: + """Check if a subagent is protected from auto cleanup""" + session = cls.get_session(session_id) + if not session: + return False + return agent_name in session.protected_agents + + @classmethod + def set_history_enabled(cls, session_id: str, enabled: bool) -> None: + """Enable or disable history for subagents""" + session = cls._get_or_create_session(session_id) + session.history_enabled = enabled + logger.info( + "[SubAgent:History] Subagent history %s", + "enabled" if enabled else "disabled", + ) + + @classmethod + def set_shared_context_enabled(cls, session_id: str, enabled: bool) -> None: + """Enable or disable shared context for a session""" + session = cls._get_or_create_session(session_id) + session.shared_context_enabled = enabled + logger.info( + "[SubAgent:SharedContext] Shared context %s", + "enabled" if enabled else "disabled", + ) + + @classmethod + def set_subagent_status(cls, session_id: str, agent_name: str, status: str) -> None: + session = cls._get_or_create_session(session_id) + if agent_name in session.subagents: + session.subagent_status[agent_name] = status + + @classmethod + def get_session(cls, session_id: str) -> SubAgentSession | None: + return cls._sessions.get(session_id, None) + + @classmethod + def _get_or_create_session(cls, session_id: str) -> SubAgentSession: + if session_id not in cls._sessions: + cls._sessions[session_id] = SubAgentSession(session_id=session_id) + return cls._sessions[session_id] + + @classmethod + async def create_subagent( + cls, session_id: str, config: SubAgentConfig, protected: bool = False + ) -> tuple: + """Create a subagent (dynamic or static). + + Args: + session_id: Session ID + config: SubAgent configuration + protected: If True, the subagent will not be auto-cleaned per turn. + Static subagents from config should be protected. + """ + session = cls._get_or_create_session(session_id) + if config.name not in session.subagents: + # Check max count limit + active_count = len(session.subagents.keys()) + if active_count >= cls._max_subagent_count: + return ( + f"Error: Maximum number of subagents ({cls._max_subagent_count}) reached. More subagents is not allowed.", + None, + ) + + if config.name in session.subagents: + session.handoff_tools.pop(config.name, None) + # When shared_context is enabled, the send_shared_context tool is allocated regardless of whether the main agent allocates the tool to the subagent + if session.shared_context_enabled: + cls.register_inherent_tool("send_shared_context") + + if config.tools is None: + config.tools = set() + # remove tools in backlist + for tool_bl in cls._tools_blacklist: + config.tools.discard(tool_bl) + + # add tools in inherent list + for tool_ih in cls._tools_inherent: + config.tools.add(tool_ih) + + session.subagents[config.name] = config + agent = Agent( + name=config.name, + instructions=config.system_prompt, + tools=list(config.tools), + ) + handoff_tool = HandoffTool( + agent=agent, + tool_description=config.description or f"Delegate to {config.name} agent", + ) + if config.provider_id: + handoff_tool.provider_id = config.provider_id + session.handoff_tools[config.name] = handoff_tool + # 初始化subagent的历史上下文(仅当历史功能启用时) + if cls._history_enabled: + session.subagent_histories[config.name] = [] + # 初始化subagent状态 + cls.set_subagent_status(session_id, config.name, "IDLE") + # 如果标记为protected,则加入protected集合 + if protected: + session.protected_agents.add(config.name) + logger.info( + "[SubAgent:Create] Created subagent: %s (protected=%s)", + config.name, + protected, + ) + return f"transfer_to_{config.name}", handoff_tool + + @classmethod + def register_static_subagent( + cls, + session_id: str, + handoff_tool: HandoffTool, + skills: set[str] | None = None, + workdir: str | None = None, + ) -> tuple: + """Register a static subagent (from subagent_orchestrator config) into SubAgentManager. + + Static subagents are always protected from auto-cleanup. + Returns (tool_name, handoff_tool) same as create_subagent. + """ + agent = handoff_tool.agent + config = SubAgentConfig( + name=agent.name, + system_prompt=agent.instructions or "", + tools=set(agent.tools) if agent.tools else set(), + skills=skills or set(), + provider_id=getattr(handoff_tool, "provider_id", None), + description=f"Delegate to {agent.name} agent", + workdir=workdir, + ) + + session = cls._get_or_create_session(session_id) + if ( + config.name not in session.subagents + ): # if the static agent already exists, pass + if session.shared_context_enabled: + cls.register_inherent_tool("send_shared_context") + if config.tools is None: + config.tools = set() + session.subagents[config.name] = config + agent = Agent( + name=config.name, + instructions=config.system_prompt, + tools=list(config.tools), + ) + handoff_tool = HandoffTool( + agent=agent, + tool_description=config.description + or f"Delegate to {config.name} agent", + ) + if config.provider_id: + handoff_tool.provider_id = config.provider_id + session.handoff_tools[config.name] = handoff_tool + + if cls._history_enabled and config.name not in session.subagent_histories: + session.subagent_histories[config.name] = [] + + cls.set_subagent_status(session_id, config.name, "IDLE") + session.protected_agents.add(config.name) + else: + pass + return f"transfer_to_{config.name}", handoff_tool + + @classmethod + async def cleanup_session(cls, session_id: str) -> dict: + session = cls._sessions.pop(session_id, None) + if not session: + return {"status": "not_found", "cleaned_agents": []} + else: + cleaned = list(session.subagents.keys()) + for name in cleaned: + logger.info("[SubAgent:Cleanup] Cleaned: %s", name) + return {"status": "cleaned", "cleaned_agents": cleaned} + + @classmethod + def remove_subagent(cls, session_id: str, agent_name: str) -> str: + session = cls.get_session(session_id) + if session.subagent_status[agent_name] == "RUNNING": + return f"__SUBAGENT_REMOVE_FAILED__: {agent_name} is still RUNNING. Waiting for finish first." + + def _remove_by_name(name): + session.subagents.pop(name, None) + session.protected_agents.discard(name) + session.handoff_tools.pop(name, None) + session.subagent_histories.pop(name, None) + session.subagent_background_results.pop(name, None) + session.background_task_counters.pop(name, None) + # 清理公共上下文中包含该Agent的内容 + cls.cleanup_shared_context_by_agent(session_id, name) + + if agent_name == "all": + if "RUNNING" in session.subagent_status.values(): + removed = 0 + for subagent_name in session.subagents.keys(): + if session.subagent_status[subagent_name] == "RUNNING": + continue + _remove_by_name(subagent_name) + removed += 1 + return f"__SUBAGENT_REMOVED__: Removed {removed} subagents. {len(session.subagents.keys())} subagents are reserved because they are still running." + else: + session.subagents.clear() + session.handoff_tools.clear() + session.protected_agents.clear() + session.subagent_histories.clear() + session.shared_context.clear() + session.subagent_background_results.clear() + session.background_task_counters.clear() + logger.info("[SubAgent:Cleanup] All subagents cleaned.") + return "__SUBAGENT_REMOVED__: All subagents have been removed." + else: + if agent_name not in session.subagents: + return f"__SUBAGENT_REMOVE_FAILED__: {agent_name} not found. Available subagent names {list(session.subagents.keys())}" + else: + _remove_by_name(agent_name) + logger.info("[SubAgent:Cleanup] Cleaned: %s", agent_name) + return f"__SUBAGENT_REMOVED__: Subagent {agent_name} has been removed." + + @classmethod + def get_handoff_tools_for_session(cls, session_id: str) -> list: + session = cls.get_session(session_id) + if not session: + return [] + return list(session.handoff_tools.values()) + + @classmethod + def create_pending_subagent_task(cls, session_id: str, agent_name: str) -> str: + """为 SubAgent 创建一个 pending 任务,返回 task_id + + Args: + session_id: Session ID + agent_name: SubAgent 名称 + + Returns: + task_id: 任务ID,格式为简单的递增数字字符串 + """ + session = cls._get_or_create_session(session_id) + + # 初始化 + if agent_name not in session.subagent_background_results: + session.subagent_background_results[agent_name] = {} + if agent_name not in session.background_task_counters: + session.background_task_counters[agent_name] = 0 + + if ( + session.subagent_status[agent_name] == "RUNNING" + ): # 若当前有任务在运行,不允许创建 + return ( + f"__PENDING_TASK_CREATE_FAILED__: Subagent {agent_name} already running" + ) + + # 生成递增的任务ID + session.background_task_counters[agent_name] += 1 + task_id = str(session.background_task_counters[agent_name]) + + # 创建 pending 占位 + session.subagent_background_results[agent_name][task_id] = ( + SubAgentExecutionResult( + task_id=task_id, + agent_name=agent_name, + success=False, + result=None, + created_at=time.time(), + metadata={}, + ) + ) + + return task_id + + @classmethod + def _ensure_task_store( + cls, session: SubAgentSession, agent_name: str + ) -> dict[str, SubAgentExecutionResult]: + if agent_name not in session.subagent_background_results: + session.subagent_background_results[agent_name] = {} + return session.subagent_background_results[agent_name] + + @staticmethod + def _is_task_completed(result: SubAgentExecutionResult) -> bool: + return result.completed_at > 0 or result.error is not None + + @classmethod + def get_pending_subagent_tasks(cls, session_id: str, agent_name: str) -> list[str]: + """获取 SubAgent 的所有 pending 任务 ID 列表(按创建时间排序)""" + session = cls.get_session(session_id) + if not session: + return [] + + store = session.subagent_background_results.get(agent_name) + if not store: + return [] + + pending = [tid for tid, res in store.items() if not cls._is_task_completed(res)] + return sorted(pending, key=lambda tid: store[tid].created_at) + + @classmethod + def get_latest_task_id(cls, session_id: str, agent_name: str) -> str | None: + """获取 SubAgent 的最新任务 ID""" + session = cls.get_session(session_id) + if not session or agent_name not in session.subagent_background_results: + return None + + # 按 created_at 排序取最新的 + sorted_tasks = sorted( + session.subagent_background_results[agent_name].items(), + key=lambda x: x[1].created_at, + reverse=True, + ) + return sorted_tasks[0][0] if sorted_tasks else None + + @classmethod + def store_subagent_result( + cls, + session_id: str, + agent_name: str, + success: bool, + result: str, + task_id: str | None = None, + error: str | None = None, + execution_time: float = 0.0, + metadata: dict | None = None, + ) -> None: + """存储 SubAgent 的执行结果 + + Args: + session_id: Session ID + agent_name: SubAgent 名称 + success: 是否成功 + result: 执行结果 + task_id: 任务ID,如果为None则存储到最新的pending任务 + error: 错误信息 + execution_time: 执行耗时 + metadata: 额外元数据 + """ + session = cls._get_or_create_session(session_id) + + task_store = cls._ensure_task_store(session, agent_name) + + if task_id is None: + # 如果没有指定task_id,尝试找最新的pending任务 + pending = cls.get_pending_subagent_tasks(session_id, agent_name) + if pending: + task_id = pending[-1] + else: + logger.warning( + f"[SubAgentResult] No task_id and no pending tasks for {agent_name}" + ) + return + + if task_id not in task_store: + # 如果任务不存在,先创建一个占位 + task_store[task_id] = SubAgentExecutionResult( + task_id=task_id, + agent_name=agent_name, + success=False, + result="", + created_at=time.time(), + metadata=metadata or {}, + ) + + # 更新结果 + task_store[task_id].success = success + task_store[task_id].result = result + task_store[task_id].error = error + task_store[task_id].execution_time = execution_time + task_store[task_id].completed_at = time.time() + if metadata: + task_store[task_id].metadata.update(metadata) + + @classmethod + def get_subagent_result( + cls, session_id: str, agent_name: str, task_id: str | None = None + ) -> SubAgentExecutionResult | None: + """获取 SubAgent 的执行结果 + + Args: + session_id: Session ID + agent_name: SubAgent 名称 + task_id: 任务ID,如果为None则获取最新完成的任务结果 + + Returns: + SubAgentExecutionResult 或 None + """ + session = cls.get_session(session_id) + if not session or agent_name not in session.subagent_background_results: + return None + + if task_id is None: + # 获取最新的已完成任务 + completed = [ + (tid, r) + for tid, r in session.subagent_background_results[agent_name].items() + if r.result != "" or r.completed_at > 0 + ] + if not completed: + return None + # 按创建时间排序,取最新的 + completed.sort(key=lambda x: x[1].created_at, reverse=True) + return completed[0][1] + + return session.subagent_background_results[agent_name].get(task_id, None) + + @classmethod + def has_subagent_result( + cls, session_id: str, agent_name: str, task_id: str | None = None + ) -> bool: + """检查 SubAgent 是否有结果 + + Args: + session_id: Session ID + agent_name: SubAgent 名称 + task_id: 任务ID,如果为None则检查是否有任何已完成的任务 + """ + session = cls.get_session(session_id) + task_store = cls._ensure_task_store(session, agent_name) + if not session or not task_store: + return False + + if task_id is None: + # 检查是否有任何已完成的任务 + return any( + r.result != "" or r.completed_at > 0 for r in task_store.values() + ) + + if task_id not in task_store: + return False + result = task_store[task_id] + return result.result != "" or result.completed_at > 0 + + @classmethod + def clear_subagent_result( + cls, session_id: str, agent_name: str, task_id: str | None = None + ) -> None: + """清除 SubAgent 的执行结果 + + Args: + session_id: Session ID + agent_name: SubAgent 名称 + task_id: 任务ID,如果为None则清除该Agent所有任务 + """ + session = cls.get_session(session_id) + task_store = cls._ensure_task_store(session, agent_name) + if not session or not task_store: + return + + if task_id is None: + # 清除所有任务 + session.subagent_background_results.pop(agent_name, None) + session.background_task_counters.pop(agent_name, None) + else: + # 清除特定任务 + task_store.pop(task_id, None) + + @classmethod + def get_subagent_status(cls, session_id: str, agent_name: str) -> str: + """获取 SubAgent 的状态: IDLE, RUNNING, COMPLETED, FAILED + + Args: + session_id: Session ID + agent_name: SubAgent 名称 + """ + session = cls.get_session(session_id) + if not session: + return "UNKNOWN" + return session.subagent_status.get(agent_name, "UNKNOWN") + + @classmethod + def get_all_subagent_status(cls, session_id: str) -> dict: + """获取所有 SubAgent 的状态""" + session = cls.get_session(session_id) + if not session: + return {} + return { + name: cls.get_subagent_status(session_id, name) + for name in session.subagents + } + + +@dataclass +class CreateSubAgentTool(FunctionTool): + name: str = "create_subagent" + description: str = "Create a subagent. After creation, use transfer_to_{name} tool." + + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Subagent name"}, + "system_prompt": { + "type": "string", + "description": "Subagent system_prompt", + }, + "tools": { + "type": "array", + "items": {"type": "string"}, + "description": "Tools available to subagent, can be empty.", + }, + "skills": { + "type": "array", + "items": {"type": "string"}, + "description": "Skills available to subagent, can be empty", + }, + "workdir": { + "type": "string", + "description": "Subagent working directory(absolute path), can be empty(same to main agent). Fill only when the user has clearly specified the path.", + }, + }, + "required": ["name", "system_prompt"], + } + ) + + def _check_path_safety(self, path_str: str) -> bool: + """ + 检查路径是否合法、安全 + """ + if not path_str or not isinstance(path_str, str): + return False + + if not os.path.isabs(path_str): + return False + + try: + resolved = os.path.realpath(path_str) + except (OSError, ValueError): + return False + + # 使用路径组件匹配而非子字符串匹配 + path_parts = {part.lower() for part in os.path.normpath(resolved).split(os.sep)} + + # Windows 特殊目录检查(作为独立的路径组件) + windows_dangerous_components = { + "windows", + "system32", + "syswow64", + "boot", + "recovery", + "programdata", + "$recycle.bin", + "system volume information", + } + + system = platform.system().lower() + if system == "windows": + if path_parts & windows_dangerous_components: + return False + elif system == "linux": + # 检查是否在危险目录下(前缀匹配) + linux_dangerous_prefixes = [ + "/etc", + "/bin", + "/sbin", + "/lib", + "/lib64", + "/boot", + "/dev", + "/proc", + "/sys", + "/root", + ] + resolved_norm = os.path.normpath(resolved) + for prefix in linux_dangerous_prefixes: + if resolved_norm.startswith(prefix + "/") or resolved_norm == prefix: + return False + elif system == "darwin": + darwin_dangerous_prefixes = [ + "/System", + "/Library", + "/private/var", + "/usr", + ] + resolved_norm = os.path.normpath(resolved) + for prefix in darwin_dangerous_prefixes: + if resolved_norm.startswith(prefix + "/") or resolved_norm == prefix: + return False + + # 通用检查:父目录跳转 + if ".." in path_str: + return False + + if not os.path.exists(resolved): + return False + + return True + + async def call(self, context, **kwargs) -> str: + name = kwargs.get("name", "") + + if not name: + return "Error: subagent name required" + # 验证名称格式:只允许英文字母、数字和下划线,长度限制 + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]{0,31}$", name): + return "Error: SubAgent name must start with letter, contain only letters/numbers/underscores, max 32 characters" + + if name.startswith("__") and name.endswith("__"): + return "Error: SubAgent name cannot start and end with double underscores" + + system_prompt = kwargs.get("system_prompt", "") + tools = kwargs.get("tools", {}) + skills = kwargs.get("skills", {}) + workdir = kwargs.get("workdir") + # 检查工作路径是否非法 + if workdir is not None and self._check_path_safety(workdir): + pass + else: + workdir = get_astrbot_temp_path() + + session_id = context.context.event.unified_msg_origin + config = SubAgentConfig( + name=name, + system_prompt=system_prompt, + tools=set(tools), + skills=set(skills), + workdir=workdir, + ) + + tool_name, handoff_tool = await SubAgentManager.create_subagent( + session_id=session_id, config=config + ) + if handoff_tool: + return f"__DYNAMIC_TOOL_CREATED__:{tool_name}:{handoff_tool.name}:Created. Use {tool_name} to delegate." + else: + return f"__DYNAMIC_TOOL_CREATE_FAILED__:{tool_name}" + + +@dataclass +class RemoveSubagentTool(FunctionTool): + name: str = "remove_subagent" + description: str = "Remove subagent by name. Use 'all' to remove all subagents." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Subagent name to remove. Use 'all' to remove all subagents.", + } + }, + "required": ["name"], + } + ) + + async def call(self, context, **kwargs) -> str: + name = kwargs.get("name", "") + if not name: + return "Error: name required" + session_id = context.context.event.unified_msg_origin + remove_status = SubAgentManager.remove_subagent(session_id, name) + if remove_status == "__SUBAGENT_REMOVED__": + return f"Cleaned {name} Subagent" + else: + return remove_status + + +@dataclass +class ListSubagentsTool(FunctionTool): + name: str = "list_subagents" + description: str = "List subagents with their status." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "include_status": { + "type": "boolean", + "description": "Include status", + "default": True, + } + }, + } + ) + + async def call(self, context, **kwargs) -> str: + include_status = kwargs.get("include_status", True) + session_id = context.context.event.unified_msg_origin + session = SubAgentManager.get_session(session_id) + if not session or not session.subagents: + return "No subagents" + + lines = ["Subagents:"] + for name, config in session.subagents.items(): + protected = " (protected)" if name in session.protected_agents else "" + if include_status: + status = SubAgentManager.get_subagent_status(session_id, name) + lines.append(f" {name}{protected} [{status}]\ttools:{config.tools}") + else: + lines.append(f" - {name}{protected}\ttools:{config.tools}") + return "\n".join(lines) + + +@dataclass +class ProtectSubagentTool(FunctionTool): + """Tool to protect a subagent from auto cleanup""" + + name: str = "protect_subagent" + description: str = "Protect a subagent from automatic cleanup. Use this to prevent important subagents from being removed." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Subagent name to protect"}, + }, + "required": ["name"], + } + ) + + async def call(self, context, **kwargs) -> str: + name = kwargs.get("name", "") + if not name: + return "Error: name required" + session_id = context.context.event.unified_msg_origin + session = SubAgentManager._get_or_create_session(session_id) + if name not in session.subagents: + return f"Error: Subagent {name} not found. Available subagents: {session.subagents.keys()}" + SubAgentManager.protect_subagent(session_id, name) + return f"Subagent {name} is now protected from auto cleanup" + + +@dataclass +class UnprotectSubagentTool(FunctionTool): + """Tool to remove protection from a subagent""" + + name: str = "unprotect_subagent" + description: str = "Remove protection from a subagent. It can then be auto cleaned." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Subagent name to unprotect"}, + }, + "required": ["name"], + } + ) + + async def call(self, context, **kwargs) -> str: + name = kwargs.get("name", "") + if not name: + return "Error: name required" + session_id = context.context.event.unified_msg_origin + session = SubAgentManager.get_session(session_id) + if not session: + return "Error: No session found" + if name in session.protected_agents: + session.protected_agents.discard(name) + return f"Subagent {name} is no longer protected" + return f"Subagent {name} was not protected" + + +@dataclass +class ResetSubAgentTool(FunctionTool): + """Tool to reset a subagent""" + + name: str = "reset_subagent" + description: str = "Reset an existing subagent. This will clean the dialog history of the subagent." + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Subagent name to reset"}, + }, + "required": ["name"], + } + ) + + async def call(self, context, **kwargs) -> str: + name = kwargs.get("name", "") + if not name: + return "Error: name required" + session_id = context.context.event.unified_msg_origin + reset_status = SubAgentManager.clear_subagent_history(session_id, name) + if reset_status == "__HISTORY_CLEARED__": + return f"Subagent {name} was reset" + else: + return reset_status + + +# Shared Context Tools +@dataclass +class SendSharedContextToolForMainAgent(FunctionTool): + """Tool to send a message to the shared context (visible to all agents)""" + + name: str = "send_shared_context_for_main_agent" + description: str = """Send a message to the shared context that will be visible to all subagents and the main agent. You are the main agent, use this to share global information. +Types: 'message' (to other agents), 'system' (global announcements).""" + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "context_type": { + "type": "string", + "description": "Type of context: message (to other agents), system (global announcement)", + "enum": ["message", "system"], + }, + "content": {"type": "string", "description": "Content to share"}, + "target": { + "type": "string", + "description": "Target agent name or 'all' for broadcast", + "default": "all", + }, + }, + "required": ["context_type", "content", "target"], + } + ) + + async def call(self, context, **kwargs) -> str: + context_type = kwargs.get("context_type", "message") + content = kwargs.get("content", "") + target = kwargs.get("target", "all") + if not content: + return "Error: content is required" + session_id = context.context.event.unified_msg_origin + add_status = SubAgentManager.add_shared_context( + session_id, "System", context_type, content, target + ) + if add_status == "__SHARED_CONTEXT_ADDED__": + return f"Shared context updated: [{context_type}] System -> {target}: {content[:100]}{'...' if len(content) > 100 else ''}" + else: + return add_status + + +@dataclass +class SendSharedContextTool(FunctionTool): + """Tool to send a message to the shared context (visible to all agents)""" + + name: str = "send_shared_context" + description: str = """Send a message to the shared context that will be visible to all subagents. +Use this to share information, status updates, or coordinate with other agents. +If you want to send a result to the main agent, do not use this tool, just return the results directly. +""" + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "context_type": { + "type": "string", + "description": "Type of context: `status` (your current task progress), `message` (to other agents)", + "enum": ["status", "message"], + }, + "content": {"type": "string", "description": "Content to share"}, + "sender": { + "type": "string", + "description": "Sender agent name", + "default": "YourName", + }, + "target": { + "type": "string", + "description": "Target agent name or 'all' for broadcast.", + "default": "all", + }, + }, + "required": ["context_type", "content", "sender", "target"], + } + ) + + async def call(self, context, **kwargs) -> str: + context_type = kwargs.get("context_type", "message") + content = kwargs.get("content", "") + target = kwargs.get("target", "all") + sender = kwargs.get("sender", "YourName") + if not content: + return "Error: content is required" + session_id = context.context.event.unified_msg_origin + add_status = SubAgentManager.add_shared_context( + session_id, sender, context_type, content, target + ) + if add_status == "__SHARED_CONTEXT_ADDED__": + return f"Shared context updated: [{context_type}] {sender} -> {target}: {content[:100]}{'...' if len(content) > 100 else ''}" + else: + return add_status + + +@dataclass +class ViewSharedContextTool(FunctionTool): + """Tool to view the shared context (mainly for main agent)""" + + name: str = "view_shared_context" + description: str = """View the shared context between all agents. This shows all messages including status updates, +inter-agent messages, and system announcements.""" + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": {}, + } + ) + + async def call(self, context, **kwargs) -> str: + session_id = context.context.event.unified_msg_origin + shared_context = SubAgentManager.get_shared_context(session_id) + + if not shared_context: + return "Shared context is empty." + + lines = ["=== Shared Context ===\n"] + for msg in shared_context: + ts = time.strftime("%H:%M:%S", time.localtime(msg["timestamp"])) + msg_type = msg["type"] + sender = msg["sender"] + target = msg["target"] + content = msg["content"] + lines.append(f"[{ts}] [{msg_type}] {sender} -> {target}:") + lines.append(f" {content}") + lines.append("") + + return "\n".join(lines) + + +@dataclass +class WaitForSubagentTool(FunctionTool): + """等待 SubAgent 结果的工具""" + + name: str = "wait_for_subagent" + description: str = """Waiting for the execution result of the specified SubAgent. +Usage scenario: +- After assigning a background task to SubAgent, you need to wait for its result before proceeding to the next step. + CAUTION: Whenever you have a task that does not depend on the output of a subagent, please execute THAT TASK FIRST instead of waiting. +- Avoids repeatedly executing tasks that have already been completed by SubAgent +parameter +- subagent_name: The name of the SubAgent to wait for +- task_id: Task ID (optional). If not filled in, the latest task result of the Agent will be obtained. +- timeout: Maximum waiting time (in seconds), default 60 +- poll_interval: polling interval (in seconds), default 5 +""" + + parameters: dict = field( + default_factory=lambda: { + "type": "object", + "properties": { + "subagent_name": { + "type": "string", + "description": "The name of the SubAgent to wait for", + }, + "timeout": { + "type": "number", + "description": "Maximum waiting time (seconds)", + "default": 60, + }, + "poll_interval": { + "type": "number", + "description": "Poll interval (seconds)", + "default": 5, + }, + "task_id": { + "type": "string", + "description": "Task ID (optional; if not filled in, the latest task result will be obtained)", + }, + }, + "required": ["subagent_name"], + } + ) + + async def call(self, context, **kwargs) -> str: + subagent_name = kwargs.get("subagent_name") + if not subagent_name: + return "Error: subagent_name is required" + + task_id = kwargs.get("task_id") # 可选,不填则获取最新的 + timeout = kwargs.get("timeout", 60) + poll_interval = kwargs.get("poll_interval", 5) + + session_id = context.context.event.unified_msg_origin + session = SubAgentManager.get_session(session_id) + + if not session: + return "Error: No session found" + if subagent_name not in session.subagents: + return f"Error: SubAgent '{subagent_name}' not found. Available: {list(session.subagents.keys())}" + + # 如果没有指定 task_id,尝试获取最新创建的 pending 任务 + if not task_id: + pending_tasks = SubAgentManager.get_pending_subagent_tasks( + session_id, subagent_name + ) + if pending_tasks: + # 使用最新的 pending 任务 + task_id = pending_tasks[-1] + else: + # 没有 pending 任务,检查是否有已完成的最新任务 + latest = SubAgentManager.get_subagent_result(session_id, subagent_name) + if latest: + return f"SubAgent '{subagent_name}' has no pending tasks. Latest completed task id: {latest.task_id}. Task id {latest.task_id} Results:\n{latest.result}" + return f"Error: SubAgent '{subagent_name}' has no tasks." + start_time = time.time() + + while time.time() - start_time < timeout: + session = SubAgentManager.get_session(session_id) + if not session: + return "Error: Session Not Found" + if subagent_name not in session.subagents: + return ( + f"Error: SubAgent '{subagent_name}' not found. It may be removed." + ) + + status = SubAgentManager.get_subagent_status(session_id, subagent_name) + + if status == "IDLE": + return f"Error: SubAgent '{subagent_name}' is running no tasks." + elif status == "COMPLETED": + result = SubAgentManager.get_subagent_result( + session_id, subagent_name, task_id + ) + if result and (result.result != "" or result.completed_at > 0): + return f"SubAgent '{result.agent_name}' execution completed\n Task id: {result.task_id}\n Execution time: {result.execution_time:.1f}s\n--- Result ---\n{result.result}\n" + else: + return f"SubAgent '{subagent_name}' task {task_id} execution completed with empty results." + elif status == "FAILED": + result = SubAgentManager.get_subagent_result( + session_id, subagent_name, task_id + ) + if result and (result.result != "" or result.completed_at > 0): + return ( + f"SubAgent '{result.agent_name}' execution failed\n" + f"Task id: {result.task_id}\n" + f"Execution time: {result.execution_time:.1f}s\n" + f"Error: {result.error or 'Unknown error'}\n" + ) + else: + return f"SubAgent '{subagent_name}' failed task {task_id} with empty results. Error: {result.error or 'Unknown error'}" + else: + pass + + await asyncio.sleep(poll_interval) + + target = f"Task {task_id}" + return f" Timeout! \nSubAgent '{subagent_name}' has not finished '{target}' in {timeout}s. The task may be still running. You can continue waiting by `wait_for_subagent` again." + + +# Tool instances +CREATE_SUBAGENT_TOOL = CreateSubAgentTool() +REMOVE_SUBAGENT_TOOL = RemoveSubagentTool() +LIST_SUBAGENTS_TOOL = ListSubagentsTool() +RESET_SUBAGENT_TOOL = ResetSubAgentTool() +PROTECT_SUBAGENT_TOOL = ProtectSubagentTool() +UNPROTECT_SUBAGENT_TOOL = UnprotectSubagentTool() +SEND_SHARED_CONTEXT_TOOL = SendSharedContextTool() +SEND_SHARED_CONTEXT_TOOL_FOR_MAIN_AGENT = SendSharedContextToolForMainAgent() +VIEW_SHARED_CONTEXT_TOOL = ViewSharedContextTool() +WAIT_FOR_SUBAGENT_TOOL = WaitForSubagentTool() diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py index c6c595dfc9..948bff05f3 100644 --- a/astrbot/core/subagent_orchestrator.py +++ b/astrbot/core/subagent_orchestrator.py @@ -15,8 +15,9 @@ class SubAgentOrchestrator: """Loads subagent definitions from config and registers handoff tools. - This is intentionally lightweight: it does not execute agents itself. - Execution happens via HandoffTool in FunctionToolExecutor. + Static subagents from config are registered into SubAgentManager so they + can enjoy unified lifecycle management, shared context, history retention, + and other advanced features alongside dynamically created subagents. """ def __init__( @@ -61,6 +62,7 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None: if provider_id is not None: provider_id = str(provider_id).strip() or None tools = item.get("tools", []) + skills = item.get("skills", []) begin_dialogs = None if persona_data: @@ -80,6 +82,13 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None: else: tools = [str(t).strip() for t in tools if str(t).strip()] + if skills is None: + skills = [] + elif not isinstance(skills, list): + skills = [] + else: + skills = [str(s).strip() for s in skills if str(s).strip()] + agent = Agent[AstrAgentContext]( name=name, instructions=instructions, @@ -102,3 +111,45 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None: logger.info(f"Registered subagent handoff tool: {handoff.name}") self.handoffs = handoffs + + def register_static_subagents_to_manager(self, session_id: str) -> None: + """Register all static subagents (from config) into SubAgentManager. + + This makes static subagents enjoy the same unified management as + dynamically created subagents: shared context, history retention, + lifecycle management, etc. + + Static subagents are always protected from auto-cleanup. + """ + + try: + from astrbot.core.subagent_manager import SubAgentManager + except ImportError: + return + + for handoff in self.handoffs: + try: + # Extract skills from the agent's config if available + skills = set() + workdir = None + # Try to get skills from the handoff tool or agent + agent = handoff.agent + # The agent.tools may contain skill names; we pass them along + # SubAgentManager will filter and build skills prompt as needed + SubAgentManager.register_static_subagent( + session_id=session_id, + handoff_tool=handoff, + skills=skills, + workdir=workdir, + ) + logger.debug( + "[SubAgentOrchestrator] Registered static subagent '%s' to SubAgentManager for session %s", + agent.name, + session_id, + ) + except Exception as e: + logger.warning( + "[SubAgentOrchestrator] Failed to register static subagent '%s' to manager: %s", + getattr(handoff.agent, "name", "unknown"), + e, + ) diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index e3d77f73ad..3de4b2b6bc 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -59,7 +59,21 @@ async def get_config(self): if isinstance(a, dict): a.setdefault("provider_id", None) a.setdefault("persona_id", None) - return jsonify(Response().ok(data=data).__dict__) + + # 获取 enhanced_subagent 配置 + enhanced_data = cfg.get("enhanced_subagent", {}) + + # 兼容旧格式:直接返回 subagent_orchestrator 的字段,同时附加 enhanced_subagent + response_data = { + "main_enable": data.get("main_enable", False), + "remove_main_duplicate_tools": data.get( + "remove_main_duplicate_tools", False + ), + "agents": data.get("agents", []), + "enhanced_subagent": enhanced_data, + } + + return jsonify(Response().ok(data=response_data).__dict__) except Exception as e: logger.error(traceback.format_exc()) return jsonify(Response().error(f"获取 subagent 配置失败: {e!s}").__dict__) @@ -71,17 +85,35 @@ async def update_config(self): return jsonify(Response().error("配置必须为 JSON 对象").__dict__) cfg = self.core_lifecycle.astrbot_config - cfg["subagent_orchestrator"] = data + + # 兼容旧格式和新格式: + # 1. 新格式: {"subagent_orchestrator": {...}, "enhanced_subagent": {...}} + # 2. 旧格式: {"main_enable": ..., "agents": [...], ...} + if "subagent_orchestrator" in data: + # 新格式 + orch_data = data["subagent_orchestrator"] + cfg["subagent_orchestrator"] = orch_data + + # Reload dynamic handoff tools if orchestrator exists + orch = getattr(self.core_lifecycle, "subagent_orchestrator", None) + if orch is not None: + await orch.reload_from_config(orch_data) + else: + # 旧格式:直接使用整个 data 作为 subagent_orchestrator + cfg["subagent_orchestrator"] = data + + # Reload dynamic handoff tools if orchestrator exists + orch = getattr(self.core_lifecycle, "subagent_orchestrator", None) + if orch is not None: + await orch.reload_from_config(data) + + # 处理 enhanced_subagent(新格式专用) + if "enhanced_subagent" in data: + cfg["enhanced_subagent"] = data["enhanced_subagent"] # Persist to cmd_config.json - # AstrBotConfigManager does not expose a `save()` method; persist via AstrBotConfig. cfg.save_config() - # Reload dynamic handoff tools if orchestrator exists - orch = getattr(self.core_lifecycle, "subagent_orchestrator", None) - if orch is not None: - await orch.reload_from_config(data) - return jsonify(Response().ok(message="保存成功").__dict__) except Exception as e: logger.error(traceback.format_exc()) diff --git a/dashboard/src/i18n/locales/en-US/features/subagent.json b/dashboard/src/i18n/locales/en-US/features/subagent.json index e9ea127f51..86d4a637fd 100644 --- a/dashboard/src/i18n/locales/en-US/features/subagent.json +++ b/dashboard/src/i18n/locales/en-US/features/subagent.json @@ -1,86 +1,139 @@ { "header": { - "eyebrow": "Orchestration" + "eyebrow": "Subagent Orchestration" }, "page": { - "title": "SubAgent Orchestration", + "title": "Subagent Orchestration", "beta": "Experimental", - "subtitle": "The main LLM can use its own tools directly and delegate tasks to SubAgents via handoff." + "subtitle": "The main agent can use its own tools directly, or delegate tasks to subagents to complete more complex tasks and avoid excessive context length." }, "actions": { "refresh": "Refresh", "save": "Save", - "add": "Add SubAgent", + "add": "Add Subagent", "expand": "Expand", "collapse": "Collapse", "delete": "Delete", "close": "Close" }, "overview": { - "totalAgents": "Total SubAgents", - "totalAgentsNote": "Configured delegate agents", + "totalAgents": "Total Subagents", + "totalAgentsNote": "Number of configured subagents", "enabledAgents": "Enabled Agents", - "enabledAgentsNote": "Agents available for handoff", - "mainOrchestration": "Main Orchestration", + "enabledAgentsNote": "Subagents participating in handoff orchestration", + "mainOrchestration": "Main Orchestration Status", "boundPersonas": "Bound Personas", - "boundPersonasNote": "Agents with an attached persona" + "boundPersonasNote": "Subagents with persona bindings" }, "switches": { - "enable": "Enable SubAgent orchestration", - "enableHint": "Enable sub-agent functionality", - "dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)", - "dedupeHint": "Remove duplicate tools from main agent" + "enable": "Enable Subagent Orchestration", + "enableHint": "Enable subagent functionality", + "dedupe": "Deduplicate tools for main LLM", + "dedupeHint": "Remove duplicate tools from the main agent that overlap with subagents" }, "description": { - "disabled": "When off: SubAgent is disabled; the main LLM mounts tools via persona rules (all by default) and calls them directly.", - "enabled": "When on: the main LLM keeps its own tools and mounts transfer_to_* delegate tools. With deduplication, tools overlapping with SubAgents are removed from the main tool set." + "disabled": "Subagent orchestration is not enabled.", + "enabled": "Subagents will be placed in the main agent's tool set as tools, and the main agent will call subagents at appropriate times to complete tasks." }, "section": { - "title": "SubAgents", - "subtitle": "Configure delegate agents, personas, and descriptions for the main LLM", + "title": "Subagent Configuration", + "subtitle": "Configure delegatable subagents, personas, and descriptions for the main agent", "globalSettings": "Global Settings", - "agentSetup": "Agent Setup" + "agentSetup": "Agent Setup", + "orchestratorTitle": "Subagent Orchestration", + "orchestratorSubtitle": "Configure basic orchestration features including subagent list and router system prompt", + "enhancedSettings": "Enhanced Subagent Settings", + "enhancedSettingsHint": "Configure runtime parameters, resource limits, and tool strategies for dynamic subagents" }, "cards": { "statusEnabled": "Enabled", "statusDisabled": "Disabled", - "unnamed": "Untitled SubAgent", + "unnamed": "Unnamed Subagent", "transferPrefix": "transfer_to_{name}", "switchLabel": "Enable", - "previewTitle": "Preview: handoff tool shown to the main LLM", + "previewTitle": "Preview: handoff tools seen by main LLM", "personaChip": "Persona: {id}", + "noDescription": "No description", "personaPreview": "Persona Preview", - "noDescription": "No description yet", - "previewHint": "Review the currently selected persona to verify the handoff target." + "previewHint": "Shows a preview of the currently selected Persona for easy confirmation of handoff targets." }, "form": { - "nameLabel": "Agent name (used for transfer_to_{name})", - "nameHint": "Use lowercase letters + underscores; must be globally unique.", + "nameLabel": "Agent Name (used for transfer_to_{name})", + "nameHint": "Use lowercase letters and underscores, globally unique", "providerLabel": "Chat Provider (optional)", - "providerHint": "Leave empty to follow the global default provider.", - "personaLabel": "Choose Persona", - "personaHint": "The SubAgent inherits the selected Persona's system settings and tools.", - "descriptionLabel": "Description for the main LLM (used to decide handoff)", - "descriptionHint": "Shown to the main LLM as the transfer_to_* tool description—keep it short and clear." + "providerHint": "Leave empty to use global default provider.", + "personaLabel": "Select Persona", + "personaHint": "The subagent will directly inherit the selected Persona's system settings and tools. Manage and create personas on the Persona settings page.", + "personaPreview": "Persona Preview", + "descriptionLabel": "Description for Main LLM (used to decide handoff)", + "descriptionHint": "This will be used as the description for transfer_to_* tools shown to the main LLM. Keep it concise and clear." }, "messages": { - "loadConfigFailed": "Failed to load config", + "loadConfigFailed": "Failed to load configuration", "loadPersonaFailed": "Failed to load persona list", - "unsavedChangesNotice": "You have unsaved changes on this page. Save before leaving.", - "unsavedChangesLeaveConfirm": "You have unsaved changes. Leaving will discard them. Continue?", - "unsavedChangesReloadConfirm": "You have unsaved changes. Reloading will discard them. Continue?", - "nameMissing": "A SubAgent is missing a name", - "nameInvalid": "Invalid SubAgent name: only lowercase letters/numbers/underscores, starting with a letter", - "nameDuplicate": "Duplicate SubAgent name: {name}", - "personaMissing": "SubAgent {name} has no persona selected", + "unsavedChangesNotice": "There are unsaved changes on this page. Please save before leaving.", + "unsavedChangesLeaveConfirm": "There are unsaved changes. Leaving will discard these changes. Continue?", + "unsavedChangesReloadConfirm": "There are unsaved changes. Refreshing will discard these changes. Continue?", + "nameMissing": "A subagent is missing a name", + "nameInvalid": "Invalid subagent name: only lowercase letters, numbers, and underscores are allowed, must start with a letter", + "nameDuplicate": "Duplicate subagent name: {name}", + "personaMissing": "Subagent {name} has no selected Persona", "saveSuccess": "Saved successfully", "saveFailed": "Failed to save", "nameRequired": "Name is required", - "namePattern": "Lowercase letters, numbers, underscore only" + "namePattern": "Only lowercase letters, numbers, and underscores allowed" + }, + "routerSystemPrompt": { + "label": "Router System Prompt", + "hint": "The main agent's routing prompt, used to guide the main agent in recognizing user intent and delegating tasks to subagents. Only effective when static subagents are enabled." + }, + "historyEnabled": { + "label": "Enable History Memory", + "hint": "Enable subagent context history. When on, subagents retain memory across multiple conversation turns." }, "empty": { - "title": "No Agents Configured", - "subtitle": "Add a new sub-agent to get started", + "title": "No Subagents Configured", + "subtitle": "Add a new subagent to get started", "action": "Create First Agent" + }, + "enhancedSwitches": { + "enable": "Enable Dynamic Subagents", + "enableHint": "Enable dynamic subagent creation and management. When enabled, the main LLM can dynamically create subagents at runtime using create_subagent tools. Can coexist with static subagent orchestration.", + "autoCleanup": "Auto Cleanup Per Turn", + "autoCleanupHint": "Automatically clean up unprotected dynamic subagents after each conversation turn", + "sharedContext": "Enable Shared Context", + "sharedContextHint": "Enable shared context between subagents. Subagents can communicate via send_shared_context tools." + }, + "enhancedFields": { + "maxDynamicSubagentCount": "Max Dynamic Subagent Count", + "maxDynamicSubagentCountHint": "Maximum number of dynamic subagents allowed per session (including static ones)", + "sharedContextMaxlen": "Shared Context Max Length", + "sharedContextMaxlenHint": "Maximum number of messages in shared context", + "subagentHistoryMaxlen": "Max History Messages", + "subagentHistoryMaxlenHint": "Maximum history messages retained per subagent", + "executionTimeout": "Execution Timeout (seconds)", + "executionTimeoutHint": "Maximum execution timeout for subagent tasks, -1 means unlimited" + }, + "enhancedSection": { + "runtimeParams": "Runtime Parameters", + "runtimeParamsHint": "Control subagent count, history, and execution timeout", + "sharedContext": "Shared Context", + "sharedContextHint": "Context sharing strategy between subagents", + "toolStrategy": "Tool Strategy", + "toolStrategyHint": "Control which tools subagents can use" + }, + "enhancedTools": { + "blacklist": "Tools Blacklist", + "blacklistHint": "Tools that subagents cannot use. Blacklisted tools will not be assigned to subagents.", + "inherent": "Inherent Tools List", + "inherentHint": "Tools inherent to subagents. Tools in this list are guaranteed to be assigned to subagents.", + "selectTool": "Select Tool", + "addTool": "Add Tool", + "selectBlacklistTool": "Add Tool to Blacklist", + "selectInherentTool": "Add Tool to Inherent List", + "emptyBlacklist": "No blacklisted tools", + "emptyInherent": "No inherent tools", + "selectOrInputTool": "Select or type a tool name", + "availableTools": "Available Tools" } } diff --git a/dashboard/src/i18n/locales/ru-RU/features/subagent.json b/dashboard/src/i18n/locales/ru-RU/features/subagent.json index 4f6b298b4d..759610172c 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/subagent.json +++ b/dashboard/src/i18n/locales/ru-RU/features/subagent.json @@ -1,4 +1,4 @@ -{ +{ "header": { "eyebrow": "Orchestration" }, @@ -39,7 +39,11 @@ "title": "Субагенты", "subtitle": "Настройте делегируемых агентов, персонажей и описания для основного LLM", "globalSettings": "Глобальные настройки", - "agentSetup": "Настройка агента" + "agentSetup": "Настройка агента", + "orchestratorTitle": "Оркестрация субагентов", + "orchestratorSubtitle": "Настройка списка субагентов и системного промпта роутера", + "enhancedSettings": "Настройки динамических субагентов", + "enhancedSettingsHint": "Настройка параметров выполнения, ограничений ресурсов и стратегии инструментов" }, "cards": { "statusEnabled": "Включено", @@ -78,9 +82,57 @@ "nameRequired": "Имя обязательно", "namePattern": "Только строчные буквы, цифры и подчеркивание" }, + "routerSystemPrompt": { + "label": "Системный промпт роутера", + "hint": "Промпт основного агента для маршрутизации, используется для распознавания намерений пользователя и делегирования задач субагентам. Активен только при включённых статических субагентах." + }, + "historyEnabled": { + "label": "Включить историю", + "hint": "Включить контекстную историю субагентов. При включении субагенты сохраняют память между ходами." + }, "empty": { "title": "Агенты не настроены", "subtitle": "Добавьте первого под-агента, чтобы начать", "action": "Создать первого агента" + }, + "enhancedSwitches": { + "enable": "Включить динамических субагентов", + "enableHint": "Включить динамическое создание и управление субагентами. При включении основной LLM может динамически создавать субагентов во время выполнения с помощью инструментов create_subagent. Может сосуществовать со статической оркестрацией субагентов.", + "autoCleanup": "Автоочистка за ход", + "autoCleanupHint": "Автоматически очищать незащищённых динамических субагентов после каждого хода", + "sharedContext": "Включить общий контекст", + "sharedContextHint": "Включить общий контекст между субагентами. Субагенты могут общаться через инструменты send_shared_context." + }, + "enhancedFields": { + "maxDynamicSubagentCount": "Макс. число динамических субагентов", + "maxDynamicSubagentCountHint": "Максимальное количество динамических субагентов на сессию (включая статических)", + "sharedContextMaxlen": "Макс. длина общего контекста", + "sharedContextMaxlenHint": "Максимальное количество сообщений общего контекста", + "subagentHistoryMaxlen": "Макс. сообщений в истории", + "subagentHistoryMaxlenHint": "Максимальное количество исторических сообщений на субагента", + "executionTimeout": "Таймаут выполнения (сек)", + "executionTimeoutHint": "Максимальное время выполнения задачи субагентом, -1 означает без ограничений" + }, + "enhancedSection": { + "runtimeParams": "Параметры выполнения", + "runtimeParamsHint": "Управление количеством субагентов, историей и таймаутом", + "sharedContext": "Общий контекст", + "sharedContextHint": "Стратегия совместного использования контекста между субагентами", + "toolStrategy": "Стратегия инструментов", + "toolStrategyHint": "Управление доступными субагентам инструментами" + }, + "enhancedTools": { + "blacklist": "Чёрный список инструментов", + "blacklistHint": "Инструменты, которые субагенты не могут использовать. Они не будут назначены субагентам.", + "inherent": "Список встроенных инструментов", + "inherentHint": "Встроенные инструменты субагентов. Инструменты из этого списка гарантированно назначаются субагентам.", + "selectTool": "Выбрать инструмент", + "addTool": "Добавить инструмент", + "selectBlacklistTool": "Добавить в чёрный список", + "selectInherentTool": "Добавить во встроенные", + "emptyBlacklist": "Чёрный список пуст", + "emptyInherent": "Встроенные инструменты отсутствуют", + "selectOrInputTool": "Выберите или введите имя инструмента", + "availableTools": "Доступные инструменты" } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/subagent.json b/dashboard/src/i18n/locales/zh-CN/features/subagent.json index cd49ae432d..dbc7cfa180 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/subagent.json +++ b/dashboard/src/i18n/locales/zh-CN/features/subagent.json @@ -39,7 +39,11 @@ "title": "子代理配置", "subtitle": "为主代理配置可委派的子代理、人格与描述信息", "globalSettings": "全局设置", - "agentSetup": "Agent 设置" + "agentSetup": "Agent 设置", + "orchestratorTitle": "子代理编排", + "orchestratorSubtitle": "配置子代理列表、主代理路由提示词等基础编排功能", + "enhancedSettings": "动态子代理设置", + "enhancedSettingsHint": "配置动态子代理的运行参数、资源限制和工具策略" }, "cards": { "statusEnabled": "启用", @@ -79,9 +83,57 @@ "nameRequired": "名称必填", "namePattern": "仅支持小写字母、数字和下划线" }, + "routerSystemPrompt": { + "label": "路由提示词", + "hint": "主Agent的路由提示词,用于指导主Agent如何识别用户意图并委派任务给子代理。仅在启用静态子代理时有效。" + }, + "historyEnabled": { + "label": "启用历史记忆", + "hint": "是否启用子代理的上下文历史功能,开启后子代理可以保留跨多轮对话的记忆" + }, "empty": { "title": "未配置子代理", "subtitle": "添加一个新的子代理以开始", "action": "创建第一个 Agent" + }, + "enhancedSwitches": { + "enable": "启用动态子代理", + "enableHint": "启用动态子代理创建和管理能力。开启后主LLM可在运行时通过create_subagent等工具动态创建子代理。与静态子代理编排可同时存在", + "autoCleanup": "每轮自动清理", + "autoCleanupHint": "每轮对话结束后自动清理未受保护的动态子代理", + "sharedContext": "启用共享上下文", + "sharedContextHint": "启用子代理间的公共上下文共享,子代理可通过send_shared_context工具互相通信" + }, + "enhancedFields": { + "maxDynamicSubagentCount": "最大动态子代理数量", + "maxDynamicSubagentCountHint": "单个会话允许的最大动态子代理数量(包含静态子代理)", + "sharedContextMaxlen": "共享上下文最大长度", + "sharedContextMaxlenHint": "共享上下文消息的最大数量(条)", + "subagentHistoryMaxlen": "最大历史消息数", + "subagentHistoryMaxlenHint": "每个子代理最多保留的历史消息条数", + "executionTimeout": "执行超时时间(秒)", + "executionTimeoutHint": "子代理执行任务的最大超时时间,-1表示不限制" + }, + "enhancedSection": { + "runtimeParams": "运行参数", + "runtimeParamsHint": "控制动态子代理的数量、历史和执行超时", + "sharedContext": "共享上下文", + "sharedContextHint": "动态子代理之间的上下文共享策略", + "toolStrategy": "工具策略", + "toolStrategyHint": "控制动态子代理可使用哪些工具" + }, + "enhancedTools": { + "blacklist": "工具黑名单", + "blacklistHint": "动态子代理不可使用的工具列表。被加入黑名单的工具将不会分配给动态子代理。", + "inherent": "固有工具名单", + "inherentHint": "动态子代理固有的工具名单,在该名单内的工具会确保分配给动态子代理。", + "selectTool": "选择工具", + "addTool": "添加工具", + "selectBlacklistTool": "添加工具到黑名单", + "selectInherentTool": "添加工具到固有名单", + "emptyBlacklist": "暂无黑名单工具", + "emptyInherent": "暂无固有工具", + "selectOrInputTool": "选择或输入工具名称", + "availableTools": "可用工具列表" } } diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue index d3876ec4c8..0e33ec58df 100644 --- a/dashboard/src/views/SubAgentPage.vue +++ b/dashboard/src/views/SubAgentPage.vue @@ -27,178 +27,522 @@ {{ tm('messages.unsavedChangesNotice') }} -
-
-
{{ tm('section.globalSettings') }}
-
{{ mainStateDescription }}
+ + + +
+
+
+
{{ tm('section.orchestratorTitle') }}
+
{{ tm('section.orchestratorSubtitle') }}
+
-
-
-
-
-
-
{{ tm('switches.enable') }}
-
{{ tm('switches.enableHint') }}
-
- +
+
+
{{ tm('section.globalSettings') }}
+
{{ mainStateDescription }}
-
-
-
-
{{ tm('switches.dedupe') }}
-
{{ tm('switches.dedupeHint') }}
+
+
+
+
+
{{ tm('switches.enable') }}
+
{{ tm('switches.enableHint') }}
+
+ +
+
+ +
+
+
+
{{ tm('switches.dedupe') }}
+
{{ tm('switches.dedupeHint') }}
+
+
-
-
-
-
-
{{ tm('section.title') }}
-
{{ tm('section.subtitle') }}
+ +
+
{{ tm('routerSystemPrompt.label') }}
+
{{ tm('routerSystemPrompt.hint') }}
+
-
-
- mdi-robot-outline - {{ cfg.agents.length }} + + +
+
+
{{ tm('section.title') }}
+
{{ tm('section.subtitle') }}
+
+
+
+ mdi-robot-outline + {{ cfg.agents.length }} +
+ + {{ tm('actions.add') }} +
- - {{ tm('actions.add') }} -
-
-
-
- -
{{ tm('empty.title') }}
-
{{ tm('empty.subtitle') }}
- - {{ tm('empty.action') }} - +
+
+ +
{{ tm('empty.title') }}
+
{{ tm('empty.subtitle') }}
+ + {{ tm('empty.action') }} + +
-
-
-
-
-
-
- - {{ agent.name || tm('cards.unnamed') }} - - {{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }} - +
+
+
+
+
+ + {{ agent.name || tm('cards.unnamed') }} + + {{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }} + +
+
+ {{ agent.public_description || tm('cards.noDescription') }} +
-
- {{ agent.public_description || tm('cards.noDescription') }} +
+ + {{ isAgentExpanded(agent.__key) ? tm('actions.collapse') : tm('actions.expand') }} + + +
-
- - {{ isAgentExpanded(agent.__key) ? tm('actions.collapse') : tm('actions.expand') }} - + + +
+
+
{{ tm('section.agentSetup') }}
+
+ + +
+
{{ tm('form.providerLabel') }}
+
+ +
+
+ +
+
{{ tm('form.personaLabel') }}
+
+ +
+
+ + +
+
+ +
+
{{ tm('cards.personaPreview') }}
+
{{ tm('cards.previewHint') }}
+
+ +
+
+
+
+
+
+
+ + + + + + +
+
+
+
{{ tm('section.enhancedSettings') }}
+
{{ tm('section.enhancedSettingsHint') }}
+
+
+ + +
+
+
+
+
{{ tm('enhancedSwitches.enable') }}
+
{{ tm('enhancedSwitches.enableHint') }}
+
-
+
+ + +
+ +
+
+
{{ tm('enhancedSection.runtimeParams') }}
+
{{ tm('enhancedSection.runtimeParamsHint') }}
+
+
- -
-
-
{{ tm('section.agentSetup') }}
-
+
+ +
+
+
+
{{ tm('enhancedFields.maxDynamicSubagentCount') }}
+
{{ tm('enhancedFields.maxDynamicSubagentCountHint') }}
+
+
+
-
-
{{ tm('form.providerLabel') }}
-
- -
+ +
+
+
+
{{ tm('enhancedSwitches.autoCleanup') }}
+
{{ tm('enhancedSwitches.autoCleanupHint') }}
+ +
+
-
-
{{ tm('form.personaLabel') }}
-
- -
+ +
+
+
+
{{ tm('enhancedFields.executionTimeout') }}
+
{{ tm('enhancedFields.executionTimeoutHint') }}
+
+ +
+
+
+ + +
+
+
{{ tm('enhancedSection.sharedContext') }}
+
{{ tm('enhancedSection.sharedContextHint') }}
+
+
+ +
+ +
+
+
+
{{ tm('historyEnabled.label') }}
+
{{ tm('historyEnabled.hint') }}
+ +
+
- +
+
+
+
{{ tm('enhancedFields.subagentHistoryMaxlen') }}
+
{{ tm('enhancedFields.subagentHistoryMaxlenHint') }}
+
+ +
+
+ + +
+
+
+
{{ tm('enhancedSwitches.sharedContext') }}
+
{{ tm('enhancedSwitches.sharedContextHint') }}
+
+
-
+
-
-
{{ tm('cards.personaPreview') }}
-
{{ tm('cards.previewHint') }}
-
- + +
+
+
+
{{ tm('enhancedFields.sharedContextMaxlen') }}
+
{{ tm('enhancedFields.sharedContextMaxlenHint') }}
+
+
-
+
+
+ + +
+
+
{{ tm('enhancedSection.toolStrategy') }}
+
{{ tm('enhancedSection.toolStrategyHint') }}
+
+
+ + +
+
{{ tm('enhancedTools.blacklist') }}
+
{{ tm('enhancedTools.blacklistHint') }}
+
+ + {{ tool }} + + + {{ tm('enhancedTools.emptyBlacklist') }} + +
+
+ + mdi-plus + {{ tm('enhancedTools.addTool') }} + +
- -
+ + +
+
{{ tm('enhancedTools.inherent') }}
+
{{ tm('enhancedTools.inherentHint') }}
+
+ + {{ tool }} + + + {{ tm('enhancedTools.emptyInherent') }} + +
+
+ + mdi-plus + {{ tm('enhancedTools.addTool') }} + +
+
+
+
+ + + + + {{ toolSelectorMode === 'blacklist' ? tm('enhancedTools.selectBlacklistTool') : tm('enhancedTools.selectInherentTool') }} + + + + +
+
{{ tm('enhancedTools.availableTools') }}
+ + + {{ tool.name }} + {{ tool.description }} + + +
+
+ + + + + {{ tm('actions.close') }} + + + {{ tm('enhancedTools.addTool') }} + + +
+
+ {{ snackbar.message }}