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
21 changes: 21 additions & 0 deletions astrbot/core/agent/handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,21 @@ def __init__(
# 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
self.default_handoff_mode = "normal"
# Note: Must assign after super().__init__() to prevent parent class from overriding this attribute
self.agent = agent

def set_default_handoff_mode(self, mode: str) -> None:
self.default_handoff_mode = mode if mode in {"normal", "silent"} else "normal"
mode_schema = self.parameters.get("properties", {}).get("mode")
if not isinstance(mode_schema, dict):
return
mode_schema["description"] = (
f"Defaults to {self.default_handoff_mode}. "
"Use silent when the subagent should work privately: its result is returned only to the main agent for synthesis, "
"without showing this handoff tool call or result to the user."
)

def default_parameters(self) -> dict:
return {
"type": "object",
Expand All @@ -56,6 +68,15 @@ def default_parameters(self) -> dict:
"Use false only for quick, immediate tasks."
),
},
"mode": {
"type": "string",
"enum": ["normal", "silent"],
"description": (
"Defaults to normal. "
"Use silent when the subagent should work privately: its result is returned only to the main agent for synthesis, "
"without showing this handoff tool call or result to the user."
),
},
},
}

Expand Down
19 changes: 19 additions & 0 deletions astrbot/core/agent/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,27 @@ def bind_checkpoint_messages(history: list[dict]) -> list[Message]:
def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
"""Dump runtime messages and reinsert bound checkpoint segments."""
dumped: list[dict] = []
hidden_tool_call_ids = {
message.tool_call_id
for message in messages
if message.role == "tool" and message._no_save and message.tool_call_id
}
for message in messages:
if message._no_save:
continue
message_data = message.model_dump()
if message_data.get("role") == "assistant" and message_data.get("tool_calls"):
visible_tool_calls = [
tool_call
for tool_call in message_data["tool_calls"]
if tool_call.get("id") not in hidden_tool_call_ids
]
if visible_tool_calls:
message_data["tool_calls"] = visible_tool_calls
else:
message_data.pop("tool_calls", None)
if message_data.get("content") is None:
continue
if isinstance(message.content, list):
message_data["content"] = [
part.model_dump()
Expand Down
59 changes: 44 additions & 15 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)

from astrbot import logger
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
Expand Down Expand Up @@ -665,6 +666,20 @@ def _track_tool_call_streak(self, tool_name: str) -> int:
self._same_tool_streak = 1
return self._same_tool_streak

@staticmethod
def _is_silent_handoff_tool_call(
func_tool: FunctionTool | None,
func_tool_args: T.Any,
) -> bool:
if not isinstance(func_tool, HandoffTool):
return False
if not isinstance(func_tool_args, dict):
return False
mode = func_tool_args.get("mode")
if mode is None:
mode = getattr(func_tool, "default_handoff_mode", "normal")
return str(mode).strip().lower() == "silent"

def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str:
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
return ""
Expand Down Expand Up @@ -894,6 +909,10 @@ async def step(self):
),
tool_calls_result=tool_call_result_blocks,
)
if tool_call_result_blocks and all(
message._no_save for message in tool_call_result_blocks
):
tool_calls_result.tool_calls_info._no_save = True
# record the assistant message with tool calls
self.run_context.messages.extend(
tool_calls_result.to_openai_messages_model()
Expand Down Expand Up @@ -991,21 +1010,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
):
tool_result_blocks_start = len(tool_call_result_blocks)
tool_call_streak = self._track_tool_call_streak(func_tool_name)
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
)
)
is_silent_handoff = False
try:
if not req.func_tool:
return
Expand All @@ -1025,6 +1030,26 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
# Some API may return None for tools with no parameters
if func_tool_args is None:
func_tool_args = {}
is_silent_handoff = self._is_silent_handoff_tool_call(
func_tool,
func_tool_args,
)
if not is_silent_handoff:
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
)
)
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")

if not func_tool:
Expand Down Expand Up @@ -1207,6 +1232,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
)

if len(tool_call_result_blocks) > tool_result_blocks_start:
if is_silent_handoff:
for block in tool_call_result_blocks[tool_result_blocks_start:]:
block._no_save = True
continue
tool_result_content = str(tool_call_result_blocks[-1].content)
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
Expand Down
70 changes: 68 additions & 2 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,25 @@ async def execute(cls, tool, run_context, **tool_args):

"""
if isinstance(tool, HandoffTool):
is_bg = tool_args.pop("background_task", False)
raw_mode = tool_args.get("mode")
mode = cls._resolve_handoff_mode(tool, raw_mode)
is_silent = mode == "silent"
mode_source = "explicit" if raw_mode is not None else "default"
background_requested = bool(tool_args.get("background_task", False))
is_bg = tool_args.pop("background_task", False) and not is_silent
background_state = (
"ignored_for_silent"
if background_requested and is_silent
else "enabled"
if is_bg
else "disabled"
)
logger.info(
f"SubAgent handoff mode={mode} "
f"(子代理静默调用={'开启' if is_silent else '未开启'}; source={mode_source}; "
f"background_task={background_state}) "
f"tool={tool.name}, agent={getattr(tool.agent, 'name', 'unknown')}"
)
if is_bg:
async for r in cls._execute_handoff_background(
tool, run_context, **tool_args
Expand Down Expand Up @@ -292,6 +310,47 @@ def _build_handoff_toolset(
toolset.add_tool(tool_name_or_obj)
return None if toolset.empty() else toolset

@staticmethod
def _resolve_handoff_mode(tool: HandoffTool, mode: T.Any) -> str:
if mode is not None:
resolved = str(mode).strip().lower()
else:
resolved = str(getattr(tool, "default_handoff_mode", "normal")).strip()
return resolved if resolved in {"normal", "silent"} else "normal"

@classmethod
def _is_silent_handoff_mode(cls, tool: HandoffTool, mode: T.Any) -> bool:
return cls._resolve_handoff_mode(tool, mode) == "silent"

@classmethod
def _remove_user_visible_tools_for_silent_handoff(
cls,
toolset: ToolSet | None,
) -> ToolSet | None:
if toolset is None:
return None
toolset.remove_tool(SendMessageToUserTool.name)
return None if toolset.empty() else toolset

@classmethod
async def _format_handoff_response_text(
cls,
llm_resp,
*,
include_structured_chain: bool = False,
) -> str:
result_chain = getattr(llm_resp, "result_chain", None)
if not include_structured_chain or not result_chain:
return llm_resp.completion_text

payload = {
"text": result_chain.get_plain_text(),
"components": [
await component.to_dict() for component in result_chain.chain
],
}
return json.dumps(payload, ensure_ascii=False)

@classmethod
async def _execute_handoff(
cls,
Expand All @@ -303,6 +362,7 @@ async def _execute_handoff(
):
tool_args = dict(tool_args)
input_ = tool_args.get("input")
is_silent = cls._is_silent_handoff_mode(tool, tool_args.get("mode"))
if image_urls_prepared:
prepared_image_urls = tool_args.get("image_urls")
if isinstance(prepared_image_urls, list):
Expand All @@ -322,6 +382,8 @@ async def _execute_handoff(

# Build handoff toolset from registered tools plus runtime computer tools.
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
if is_silent:
toolset = cls._remove_user_visible_tools_for_silent_handoff(toolset)

ctx = run_context.context.context
event = run_context.context.event
Expand Down Expand Up @@ -363,8 +425,12 @@ async def _execute_handoff(
tool_call_timeout=run_context.tool_call_timeout,
stream=stream,
)
response_text = await cls._format_handoff_response_text(
llm_resp,
include_structured_chain=is_silent,
)
yield mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
content=[mcp.types.TextContent(type="text", text=response_text)]
)

@classmethod
Expand Down
6 changes: 6 additions & 0 deletions astrbot/core/subagent_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
provider_id = item.get("provider_id")
if provider_id is not None:
provider_id = str(provider_id).strip() or None
default_handoff_mode = str(
item.get("default_handoff_mode", "normal")
).strip()
if default_handoff_mode not in {"normal", "silent"}:
default_handoff_mode = "normal"
tools = item.get("tools", [])
begin_dialogs = None

Expand Down Expand Up @@ -95,6 +100,7 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:

# Optional per-subagent chat provider override.
handoff.provider_id = provider_id
handoff.set_default_handoff_mode(default_handoff_mode)

handoffs.append(handoff)

Expand Down
2 changes: 2 additions & 0 deletions astrbot/dashboard/routes/subagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ async def get_config(self):
if isinstance(a, dict):
a.setdefault("provider_id", None)
a.setdefault("persona_id", None)
if a.get("default_handoff_mode") not in ("normal", "silent"):
a["default_handoff_mode"] = "normal"
return jsonify(Response().ok(data=data).__dict__)
except Exception as e:
logger.error(traceback.format_exc())
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/subagent.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
"providerHint": "Leave empty to follow the global default provider.",
"personaLabel": "Choose Persona",
"personaHint": "The SubAgent inherits the selected Persona's system settings and tools.",
"silentHandoffLabel": "Silent SubAgent handoff",
"silentHandoffHint": "Let this SubAgent complete delegated tasks in the background while the main agent still reads the result and replies to users. Users will not see the SubAgent tool call or raw result unless the main LLM explicitly uses normal mode.",
"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."
},
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/subagent.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
"providerHint": "Оставьте пустым, чтобы использовать глобальный провайдер по умолчанию.",
"personaLabel": "Выберите персонажа",
"personaHint": "SubAgent наследует системные настройки и инструменты выбранного персонажа.",
"silentHandoffLabel": "Тихий вызов SubAgent",
"silentHandoffHint": "SubAgent выполняет делегированную задачу в фоне, а основной агент читает результат и отвечает пользователю. Пользователь не увидит вызов инструмента SubAgent и сырой результат, если основной LLM явно не выберет normal mode.",
"descriptionLabel": "Описание для основного LLM (используется для принятия решения о handoff)",
"descriptionHint": "Отображается как описание инструмента transfer_to_* — будьте кратки и ясны."
},
Expand Down
8 changes: 5 additions & 3 deletions dashboard/src/i18n/locales/zh-CN/features/subagent.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"header": {
"eyebrow": "Subagent Orchestration"
"eyebrow": "SubAgent Orchestration"
},
"page": {
"title": "子代理编排",
Expand Down Expand Up @@ -32,7 +32,7 @@
"dedupeHint": "从主代理中移除与子代理重复的工具"
},
"description": {
"disabled": "未启动子代理编排功能。",
"disabled": "未启用子代理编排功能。",
"enabled": "子代理将作为工具放在主代理的工具集中,主代理会在适当的时机调用子代理完成任务。"
},
"section": {
Expand All @@ -59,8 +59,10 @@
"providerLabel": "Chat Provider(可选)",
"providerHint": "留空表示跟随全局默认 provider。",
"personaLabel": "选择人格设定",
"personaHint": "子代理 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
"personaHint": "子代理将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
"personaPreview": "人格预览",
"silentHandoffLabel": "子代理静默调用",
"silentHandoffHint": "开启后,子代理会在后台完成委派任务,主代理仍会读取结果并回复用户;除非主 LLM 显式使用 normal 模式,否则用户不会看到子代理的工具调用与原始结果。",
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff)",
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
},
Expand Down
Loading