Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions astrbot/builtin_stars/astrbot/process_llm_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from astrbot.api.event import AstrMessageEvent
from astrbot.api.message_components import Image, Reply
from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.message import TextPart
from astrbot.core.pipeline.process_stage.utils import (
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
Expand Down Expand Up @@ -68,6 +69,32 @@ async def _ensure_persona(

# tools select
tmgr = self.ctx.get_llm_tool_manager()

# SubAgent orchestrator mode: main LLM only sees handoff tools.
# 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

# Encourage the model to delegate to subagents.
# Use the built-in default router prompt; user overrides are disabled for now.
router_prompt = (
self.ctx.get_config()
.get("subagent_orchestrator", {})
.get("router_system_prompt", "")
).strip()
if router_prompt:
req.system_prompt += f"\n{router_prompt}\n"

return

# Default behavior: follow persona tool selection.
if (persona and persona.get("tools") is None) or not persona:
# select all
toolset = tmgr.get_full_tool_set()
Expand Down
15 changes: 14 additions & 1 deletion astrbot/core/agent/handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,29 @@ def __init__(
self,
agent: Agent[TContext],
parameters: dict | None = None,
tool_description: str | None = None,
**kwargs,
):
self.agent = agent

# Avoid passing duplicate `description` to the FunctionTool dataclass.
# 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.
# `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(),
description=agent.instructions or self.default_description(agent.name),
description=description,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里直接这样改吧:

  1. 新增可为None的关键字参数,"tool_description",然后这里就 tool_description or self.default_description(agent.name)

**kwargs,
)

# Optional provider override for this subagent. When set, the handoff
# execution will use this chat provider id instead of the global/default.
self.provider_id: str | None = None

def default_parameters(self) -> dict:
return {
"type": "object",
Expand Down
7 changes: 6 additions & 1 deletion astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ async def _execute_handoff(
ctx = run_context.context.context
event = run_context.context.event
umo = event.unified_msg_origin
prov_id = await ctx.get_current_chat_provider_id(umo)

# Use per-subagent provider override if configured; otherwise fall back
# to the current/default provider resolution.
prov_id = getattr(
tool, "provider_id", None
) or await ctx.get_current_chat_provider_id(umo)
llm_resp = await ctx.tool_loop_agent(
event=event,
chat_provider_id=prov_id,
Expand Down
12 changes: 12 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@
"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": [],
},
"provider_stt_settings": {
"enable": False,
"provider_id": "",
Expand Down
24 changes: 24 additions & 0 deletions astrbot/core/core_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from astrbot.core.star import PluginManager
from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
Expand All @@ -53,6 +54,10 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None:
self.astrbot_config = astrbot_config # 初始化配置
self.db = db # 初始化数据库

# Optional orchestrator that registers dynamic handoff tools (transfer_to_*)
# from provider_settings.subagent_orchestrator.
self.subagent_orchestrator: SubAgentOrchestrator | None = None

# 设置代理
proxy_config = self.astrbot_config.get("http_proxy", "")
if proxy_config != "":
Expand All @@ -72,6 +77,23 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None:
del os.environ["no_proxy"]
logger.debug("HTTP proxy cleared")

def _init_or_reload_subagent_orchestrator(self) -> None:
"""Create (if needed) and reload the subagent orchestrator from config.

This keeps lifecycle wiring in one place while allowing the orchestrator
to manage enable/disable and tool registration details.
"""
try:
if self.subagent_orchestrator is None:
self.subagent_orchestrator = SubAgentOrchestrator(
self.provider_manager.llm_tools,
)
self.subagent_orchestrator.reload_from_config(
self.astrbot_config.get("subagent_orchestrator", {}),
)
except Exception as e:
logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True)

async def initialize(self) -> None:
"""初始化 AstrBot 核心生命周期管理类.

Expand Down Expand Up @@ -175,6 +197,8 @@ async def initialize(self) -> None:
self.astrbot_config_mgr,
)

# Dynamic subagents (handoff tools) from config.
self._init_or_reload_subagent_orchestrator()
# 记录启动时间
self.start_time = int(time.time())

Expand Down
43 changes: 43 additions & 0 deletions astrbot/core/provider/func_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

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
Expand Down Expand Up @@ -179,6 +180,48 @@ def get_full_tool_set(self) -> ToolSet:
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 服务列表。文件格式如下:
```
Expand Down
82 changes: 82 additions & 0 deletions astrbot/core/subagent_orchestrator.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感觉相关逻辑可以直接放到 tool_manager 里面,用一个方法代替。

Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

from typing import Any

from astrbot import logger
from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.provider.func_tool_manager import FunctionToolManager


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.
"""

def __init__(self, tool_mgr: FunctionToolManager):
self._tool_mgr = tool_mgr

def reload_from_config(self, cfg: dict[str, Any]) -> None:
enabled = bool(cfg.get("main_enable", False))

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", [])
if not isinstance(agents, list):
logger.warning("subagent_orchestrator.agents must be a list")
return

handoffs: list[HandoffTool] = []
for item in agents:
if not isinstance(item, dict):
continue
if not item.get("enabled", True):
continue

name = str(item.get("name", "")).strip()
if not name:
continue

instructions = str(item.get("system_prompt", "")).strip()
public_description = str(item.get("public_description", "")).strip()
provider_id = item.get("provider_id")
if provider_id is not None:
provider_id = str(provider_id).strip() or None
tools = item.get("tools", [])
if not isinstance(tools, list):
tools = []
tools = [str(t).strip() for t in tools if str(t).strip()]

agent = Agent[AstrAgentContext](
name=name,
instructions=instructions,
tools=tools,
)
# 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,
tool_description=public_description or None,
)

# Optional per-subagent chat provider override.
handoff.provider_id = provider_id

handoffs.append(handoff)

self._tool_mgr.sync_dynamic_handoff_tools(
handoffs,
handler_module_path="core.subagent_orchestrator",
)

for handoff in handoffs:
logger.info(f"Registered subagent handoff tool: {handoff.name}")
2 changes: 2 additions & 0 deletions astrbot/dashboard/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .session_management import SessionManagementRoute
from .stat import StatRoute
from .static_file import StaticFileRoute
from .subagent import SubAgentRoute
from .tools import ToolsRoute
from .update import UpdateRoute

Expand All @@ -34,6 +35,7 @@
"SessionManagementRoute",
"StatRoute",
"StaticFileRoute",
"SubAgentRoute",
"ToolsRoute",
"UpdateRoute",
]
Loading