diff --git a/astrbot/builtin_stars/astrbot/process_llm_request.py b/astrbot/builtin_stars/astrbot/process_llm_request.py index 3a1216e92..e5acae5d3 100644 --- a/astrbot/builtin_stars/astrbot/process_llm_request.py +++ b/astrbot/builtin_stars/astrbot/process_llm_request.py @@ -71,10 +71,13 @@ class ProcessLLMRequest: tmgr = self.ctx.get_llm_tool_manager() # SubAgent orchestrator mode: main LLM only sees handoff tools. - orch_cfg = cfg.get("subagent_orchestrator", {}) + # NOTE: subagent_orchestrator config lives at top-level now. + orch_cfg = self.ctx.get_config().get("subagent_orchestrator", {}) if orch_cfg.get("main_enable", False): toolset = ToolSet() for tool in tmgr.func_list: + # Prevent recursion / confusion: in handoff-only mode, the main LLM + # should only be able to call transfer_to_* tools. if isinstance(tool, HandoffTool) and tool.active: toolset.add_tool(tool) req.func_tool = toolset @@ -83,16 +86,12 @@ class ProcessLLMRequest: # Use the built-in default router prompt; user overrides are disabled for now. router_prompt = ( self.ctx.get_config() - .get("provider_settings", {}) .get("subagent_orchestrator", {}) .get("router_system_prompt", "") ).strip() if router_prompt: req.system_prompt += f"\n{router_prompt}\n" - logger.debug( - f"Subagent orchestrator enabled; main tool set (handoff_only): {toolset.names()}" - ) return # Default behavior: follow persona tool selection. diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py index 0e2d93435..5812766c8 100644 --- a/astrbot/core/agent/handoff.py +++ b/astrbot/core/agent/handoff.py @@ -12,6 +12,7 @@ class HandoffTool(FunctionTool, Generic[TContext]): self, agent: Agent[TContext], parameters: dict | None = None, + tool_description: str | None = None, **kwargs, ): self.agent = agent @@ -20,10 +21,9 @@ class HandoffTool(FunctionTool, Generic[TContext]): # Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs # to override what the main agent sees, while we also compute a default # description here. - description = kwargs.pop( - "description", - agent.instructions or self.default_description(agent.name), - ) + # `tool_description` is the public description shown to the main LLM. + # Keep a separate kwarg to avoid conflicting with FunctionTool's `description`. + description = tool_description or self.default_description(agent.name) super().__init__( name=f"transfer_to_{agent.name}", parameters=parameters or self.default_parameters(), diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 1b9daa3e0..388ea6cf3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -121,18 +121,18 @@ DEFAULT_CONFIG = { "shipyard_ttl": 3600, "shipyard_max_sessions": 10, }, - # SubAgent orchestrator mode: the main LLM only delegates tasks to subagents - # (via transfer_to_{agent} tools). Domain tools are mounted on subagents. - "subagent_orchestrator": { - "main_enable": False, - "main_tools_policy": "handoff_only", # reserved for future; main_enable implies handoff_only - "router_system_prompt": ( - "You are a task router. Your job is to chat naturally, recognize user intent, " - "and delegate work to the most suitable subagent using transfer_to_* tools. " - "Do not try to use domain tools yourself. If no subagent fits, respond directly." - ), - "agents": [], - }, + }, + # SubAgent orchestrator mode: the main LLM only delegates tasks to subagents + # (via transfer_to_{agent} tools). Domain tools are mounted on subagents. + "subagent_orchestrator": { + "main_enable": False, + "main_tools_policy": "handoff_only", # reserved for future; main_enable implies handoff_only + "router_system_prompt": ( + "You are a task router. Your job is to chat naturally, recognize user intent, " + "and delegate work to the most suitable subagent using transfer_to_* tools. " + "Do not try to use domain tools yourself. If no subagent fits, respond directly." + ), + "agents": [], }, "provider_stt_settings": { "enable": False, diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index ef1771277..6a4f57aa3 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -89,7 +89,7 @@ class AstrBotCoreLifecycle: self.provider_manager.llm_tools, ) self.subagent_orchestrator.reload_from_config( - self.astrbot_config.get("provider_settings", {}), + self.astrbot_config.get("subagent_orchestrator", {}), ) except Exception as e: logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True) diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 7aad86bdd..93990d87c 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -11,6 +11,7 @@ import aiohttp from astrbot import logger from astrbot.core import sp +from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPClient, MCPTool from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.utils.astrbot_path import get_astrbot_data_path @@ -179,6 +180,48 @@ class FunctionToolManager: tool_set = ToolSet(self.func_list.copy()) return tool_set + def sync_dynamic_handoff_tools( + self, + handoffs: list[HandoffTool], + *, + handler_module_path: str, + ) -> None: + """Sync dynamic transfer_to_* tools in-place. + + This removes any existing tools previously registered under the same + handler_module_path and then registers the provided HandoffTool list. + + NOTE: add_func() stores a FunctionTool wrapper; for handoff tools we + want to keep the real HandoffTool objects in func_list so other parts + of the system can inspect agent/provider_id metadata. + """ + + # Remove previously registered dynamic handoff tools. + self.func_list = [ + t for t in self.func_list if t.handler_module_path != handler_module_path + ] + + for handoff in handoffs: + handoff.handler_module_path = handler_module_path + + # Register tool (ensures the handler is reachable by name). + self.add_func( + name=handoff.name, + func_args=[ + { + "type": "string", + "name": "input", + "description": "Task input delegated from the main agent.", + } + ], + desc=handoff.description, + handler=handoff.handler, + ) + + # Replace wrapper with the actual HandoffTool instance. + self.remove_func(handoff.name) + self.func_list.append(handoff) + async def init_mcp_clients(self) -> None: """从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下: ``` diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py index 38f384145..5e53a309c 100644 --- a/astrbot/core/subagent_orchestrator.py +++ b/astrbot/core/subagent_orchestrator.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any from astrbot import logger @@ -10,19 +9,6 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.provider.func_tool_manager import FunctionToolManager -@dataclass(frozen=True) -class SubAgentConfig: - """Runtime representation of a configured subagent.""" - - name: str - # Instructions are used as the subagent's system prompt. - instructions: str - # Public description is what the main LLM sees for transfer_to_* tool description. - public_description: str - tools: list[str] - enabled: bool = True - - class SubAgentOrchestrator: """Loads subagent definitions from config and registers handoff tools. @@ -32,23 +18,16 @@ class SubAgentOrchestrator: def __init__(self, tool_mgr: FunctionToolManager): self._tool_mgr = tool_mgr - self._registered_handoff_names: set[str] = set() - def reload_from_config(self, provider_settings: dict[str, Any]) -> None: - cfg = provider_settings.get("subagent_orchestrator", {}) + def reload_from_config(self, cfg: dict[str, Any]) -> None: enabled = bool(cfg.get("main_enable", False)) - # Remove previously registered dynamic handoff tools. - if self._registered_handoff_names: - for name in list(self._registered_handoff_names): - try: - self._tool_mgr.remove_func(name) - except Exception: - # remove_func is best-effort; keep going. - pass - self._registered_handoff_names.clear() - if not enabled: + # Ensure any previous dynamic handoff tools are cleared. + self._tool_mgr.sync_dynamic_handoff_tools( + [], + handler_module_path="core.subagent_orchestrator", + ) return agents = cfg.get("agents", []) @@ -56,6 +35,7 @@ class SubAgentOrchestrator: logger.warning("subagent_orchestrator.agents must be a list") return + handoffs: list[HandoffTool] = [] for item in agents: if not isinstance(item, dict): continue @@ -83,32 +63,20 @@ class SubAgentOrchestrator: ) # The tool description should be a short description for the main LLM, # while the subagent system prompt can be longer/more specific. - handoff = HandoffTool(agent=agent, description=public_description or None) + handoff = HandoffTool( + agent=agent, + tool_description=public_description or None, + ) # Optional per-subagent chat provider override. handoff.provider_id = provider_id - # Mark as dynamic so we can replace/remove later. - handoff.handler_module_path = "core.subagent_orchestrator" + handoffs.append(handoff) - # Register tool (replaces if same name exists). - self._tool_mgr.add_func( - name=handoff.name, - func_args=[ - { - "type": "string", - "name": "input", - "description": "Task input delegated from the main agent.", - } - ], - desc=handoff.description, - handler=handoff.handler, - ) + self._tool_mgr.sync_dynamic_handoff_tools( + handoffs, + handler_module_path="core.subagent_orchestrator", + ) - # NOTE: add_func wraps handler into a FunctionTool; we want the actual HandoffTool. - # Therefore, directly append the HandoffTool to func_list (and remove any wrapper). - self._tool_mgr.remove_func(handoff.name) - self._tool_mgr.func_list.append(handoff) - - self._registered_handoff_names.add(handoff.name) + for handoff in handoffs: logger.info(f"Registered subagent handoff tool: {handoff.name}") diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index f2ea098f5..90a534301 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -3,6 +3,7 @@ import traceback from quart import jsonify, request from astrbot.core import logger +from astrbot.core.agent.handoff import HandoffTool from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from .route import Response, Route, RouteContext @@ -28,8 +29,7 @@ class SubAgentRoute(Route): async def get_config(self): try: cfg = self.core_lifecycle.astrbot_config - provider_settings = cfg.get("provider_settings", {}) - data = provider_settings.get("subagent_orchestrator") + data = cfg.get("subagent_orchestrator") # First-time access: return a sane default instead of erroring. if not isinstance(data, dict): @@ -70,9 +70,7 @@ class SubAgentRoute(Route): return jsonify(Response().error("配置必须为 JSON 对象").__dict__) cfg = self.core_lifecycle.astrbot_config - provider_settings = cfg.get("provider_settings", {}) - provider_settings["subagent_orchestrator"] = data - cfg["provider_settings"] = provider_settings + cfg["subagent_orchestrator"] = data # Persist to cmd_config.json # AstrBotConfigManager does not expose a `save()` method; persist via AstrBotConfig. @@ -81,7 +79,7 @@ class SubAgentRoute(Route): # Reload dynamic handoff tools if orchestrator exists orch = getattr(self.core_lifecycle, "subagent_orchestrator", None) if orch is not None: - orch.reload_from_config(provider_settings) + orch.reload_from_config(data) return jsonify(Response().ok(message="保存成功").__dict__) except Exception as e: @@ -97,6 +95,12 @@ class SubAgentRoute(Route): tool_mgr = self.core_lifecycle.provider_manager.llm_tools tools_dict = [] for tool in tool_mgr.func_list: + # Prevent recursive routing: subagents should not be able to select + # the handoff (transfer_to_*) tools as their own mounted tools. + if isinstance(tool, HandoffTool): + continue + if tool.handler_module_path == "core.subagent_orchestrator": + continue tools_dict.append( { "name": tool.name, diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 6f1386d09..5b5abdfeb 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -132,8 +132,7 @@ class AstrBotDashboard: r = jsonify(Response().error("未授权").__dict__) r.status_code = 401 return r - # Be tolerant of different header casing / formatting. - token = token.strip().removeprefix("Bearer ").strip() + token = token.removeprefix("Bearer ") try: payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"]) g.username = payload["username"]