Skip to content

Commit 8a3a262

Browse files
committed
feat: add silent subagent handoff mode
1 parent 094c2de commit 8a3a262

14 files changed

Lines changed: 673 additions & 20 deletions

File tree

astrbot/core/agent/handoff.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,21 @@ def __init__(
3232
# Optional provider override for this subagent. When set, the handoff
3333
# execution will use this chat provider id instead of the global/default.
3434
self.provider_id: str | None = None
35+
self.default_handoff_mode = "normal"
3536
# Note: Must assign after super().__init__() to prevent parent class from overriding this attribute
3637
self.agent = agent
3738

39+
def set_default_handoff_mode(self, mode: str) -> None:
40+
self.default_handoff_mode = mode if mode in {"normal", "silent"} else "normal"
41+
mode_schema = self.parameters.get("properties", {}).get("mode")
42+
if not isinstance(mode_schema, dict):
43+
return
44+
mode_schema["description"] = (
45+
f"Defaults to {self.default_handoff_mode}. "
46+
"Use silent when the subagent should work privately: its result is returned only to the main agent for synthesis, "
47+
"without showing this handoff tool call or result to the user."
48+
)
49+
3850
def default_parameters(self) -> dict:
3951
return {
4052
"type": "object",
@@ -56,6 +68,15 @@ def default_parameters(self) -> dict:
5668
"Use false only for quick, immediate tasks."
5769
),
5870
},
71+
"mode": {
72+
"type": "string",
73+
"enum": ["normal", "silent"],
74+
"description": (
75+
"Defaults to normal. "
76+
"Use silent when the subagent should work privately: its result is returned only to the main agent for synthesis, "
77+
"without showing this handoff tool call or result to the user."
78+
),
79+
},
5980
},
6081
}
6182

astrbot/core/agent/message.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,27 @@ def bind_checkpoint_messages(history: list[dict]) -> list[Message]:
345345
def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
346346
"""Dump runtime messages and reinsert bound checkpoint segments."""
347347
dumped: list[dict] = []
348+
hidden_tool_call_ids = {
349+
message.tool_call_id
350+
for message in messages
351+
if message.role == "tool" and message._no_save and message.tool_call_id
352+
}
348353
for message in messages:
354+
if message._no_save:
355+
continue
349356
message_data = message.model_dump()
357+
if message_data.get("role") == "assistant" and message_data.get("tool_calls"):
358+
visible_tool_calls = [
359+
tool_call
360+
for tool_call in message_data["tool_calls"]
361+
if tool_call.get("id") not in hidden_tool_call_ids
362+
]
363+
if visible_tool_calls:
364+
message_data["tool_calls"] = visible_tool_calls
365+
else:
366+
message_data.pop("tool_calls", None)
367+
if message_data.get("content") is None:
368+
continue
350369
if isinstance(message.content, list):
351370
message_data["content"] = [
352371
part.model_dump()

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727

2828
from astrbot import logger
29+
from astrbot.core.agent.handoff import HandoffTool
2930
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
3031
from astrbot.core.agent.tool import FunctionTool, ToolSet
3132
from astrbot.core.agent.tool_image_cache import tool_image_cache
@@ -665,6 +666,20 @@ def _track_tool_call_streak(self, tool_name: str) -> int:
665666
self._same_tool_streak = 1
666667
return self._same_tool_streak
667668

669+
@staticmethod
670+
def _is_silent_handoff_tool_call(
671+
func_tool: FunctionTool | None,
672+
func_tool_args: T.Any,
673+
) -> bool:
674+
if not isinstance(func_tool, HandoffTool):
675+
return False
676+
if not isinstance(func_tool_args, dict):
677+
return False
678+
mode = func_tool_args.get("mode")
679+
if mode is None:
680+
mode = getattr(func_tool, "default_handoff_mode", "normal")
681+
return str(mode).strip().lower() == "silent"
682+
668683
def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str:
669684
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
670685
return ""
@@ -894,6 +909,10 @@ async def step(self):
894909
),
895910
tool_calls_result=tool_call_result_blocks,
896911
)
912+
if tool_call_result_blocks and all(
913+
message._no_save for message in tool_call_result_blocks
914+
):
915+
tool_calls_result.tool_calls_info._no_save = True
897916
# record the assistant message with tool calls
898917
self.run_context.messages.extend(
899918
tool_calls_result.to_openai_messages_model()
@@ -991,21 +1010,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
9911010
):
9921011
tool_result_blocks_start = len(tool_call_result_blocks)
9931012
tool_call_streak = self._track_tool_call_streak(func_tool_name)
994-
yield _HandleFunctionToolsResult.from_message_chain(
995-
MessageChain(
996-
type="tool_call",
997-
chain=[
998-
Json(
999-
data={
1000-
"id": func_tool_id,
1001-
"name": func_tool_name,
1002-
"args": func_tool_args,
1003-
"ts": time.time(),
1004-
}
1005-
)
1006-
],
1007-
)
1008-
)
1013+
is_silent_handoff = False
10091014
try:
10101015
if not req.func_tool:
10111016
return
@@ -1025,6 +1030,26 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
10251030
# Some API may return None for tools with no parameters
10261031
if func_tool_args is None:
10271032
func_tool_args = {}
1033+
is_silent_handoff = self._is_silent_handoff_tool_call(
1034+
func_tool,
1035+
func_tool_args,
1036+
)
1037+
if not is_silent_handoff:
1038+
yield _HandleFunctionToolsResult.from_message_chain(
1039+
MessageChain(
1040+
type="tool_call",
1041+
chain=[
1042+
Json(
1043+
data={
1044+
"id": func_tool_id,
1045+
"name": func_tool_name,
1046+
"args": func_tool_args,
1047+
"ts": time.time(),
1048+
}
1049+
)
1050+
],
1051+
)
1052+
)
10281053
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
10291054

10301055
if not func_tool:
@@ -1207,6 +1232,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
12071232
)
12081233

12091234
if len(tool_call_result_blocks) > tool_result_blocks_start:
1235+
if is_silent_handoff:
1236+
for block in tool_call_result_blocks[tool_result_blocks_start:]:
1237+
block._no_save = True
1238+
continue
12101239
tool_result_content = str(tool_call_result_blocks[-1].content)
12111240
yield _HandleFunctionToolsResult.from_message_chain(
12121241
MessageChain(

astrbot/core/astr_agent_tool_exec.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,25 @@ async def execute(cls, tool, run_context, **tool_args):
138138
139139
"""
140140
if isinstance(tool, HandoffTool):
141-
is_bg = tool_args.pop("background_task", False)
141+
raw_mode = tool_args.get("mode")
142+
mode = cls._resolve_handoff_mode(tool, raw_mode)
143+
is_silent = mode == "silent"
144+
mode_source = "explicit" if raw_mode is not None else "default"
145+
background_requested = bool(tool_args.get("background_task", False))
146+
is_bg = tool_args.pop("background_task", False) and not is_silent
147+
background_state = (
148+
"ignored_for_silent"
149+
if background_requested and is_silent
150+
else "enabled"
151+
if is_bg
152+
else "disabled"
153+
)
154+
logger.info(
155+
f"SubAgent handoff mode={mode} "
156+
f"(子代理静默调用={'开启' if is_silent else '未开启'}; source={mode_source}; "
157+
f"background_task={background_state}) "
158+
f"tool={tool.name}, agent={getattr(tool.agent, 'name', 'unknown')}"
159+
)
142160
if is_bg:
143161
async for r in cls._execute_handoff_background(
144162
tool, run_context, **tool_args
@@ -292,6 +310,47 @@ def _build_handoff_toolset(
292310
toolset.add_tool(tool_name_or_obj)
293311
return None if toolset.empty() else toolset
294312

313+
@staticmethod
314+
def _resolve_handoff_mode(tool: HandoffTool, mode: T.Any) -> str:
315+
if mode is not None:
316+
resolved = str(mode).strip().lower()
317+
else:
318+
resolved = str(getattr(tool, "default_handoff_mode", "normal")).strip()
319+
return resolved if resolved in {"normal", "silent"} else "normal"
320+
321+
@classmethod
322+
def _is_silent_handoff_mode(cls, tool: HandoffTool, mode: T.Any) -> bool:
323+
return cls._resolve_handoff_mode(tool, mode) == "silent"
324+
325+
@classmethod
326+
def _remove_user_visible_tools_for_silent_handoff(
327+
cls,
328+
toolset: ToolSet | None,
329+
) -> ToolSet | None:
330+
if toolset is None:
331+
return None
332+
toolset.remove_tool(SendMessageToUserTool.name)
333+
return None if toolset.empty() else toolset
334+
335+
@classmethod
336+
async def _format_handoff_response_text(
337+
cls,
338+
llm_resp,
339+
*,
340+
include_structured_chain: bool = False,
341+
) -> str:
342+
result_chain = getattr(llm_resp, "result_chain", None)
343+
if not include_structured_chain or not result_chain:
344+
return llm_resp.completion_text
345+
346+
payload = {
347+
"text": result_chain.get_plain_text(),
348+
"components": [
349+
await component.to_dict() for component in result_chain.chain
350+
],
351+
}
352+
return json.dumps(payload, ensure_ascii=False)
353+
295354
@classmethod
296355
async def _execute_handoff(
297356
cls,
@@ -303,6 +362,7 @@ async def _execute_handoff(
303362
):
304363
tool_args = dict(tool_args)
305364
input_ = tool_args.get("input")
365+
is_silent = cls._is_silent_handoff_mode(tool, tool_args.get("mode"))
306366
if image_urls_prepared:
307367
prepared_image_urls = tool_args.get("image_urls")
308368
if isinstance(prepared_image_urls, list):
@@ -322,6 +382,8 @@ async def _execute_handoff(
322382

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

326388
ctx = run_context.context.context
327389
event = run_context.context.event
@@ -363,8 +425,12 @@ async def _execute_handoff(
363425
tool_call_timeout=run_context.tool_call_timeout,
364426
stream=stream,
365427
)
428+
response_text = await cls._format_handoff_response_text(
429+
llm_resp,
430+
include_structured_chain=is_silent,
431+
)
366432
yield mcp.types.CallToolResult(
367-
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
433+
content=[mcp.types.TextContent(type="text", text=response_text)]
368434
)
369435

370436
@classmethod

astrbot/core/subagent_orchestrator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ async def reload_from_config(self, cfg: dict[str, Any]) -> None:
6060
provider_id = item.get("provider_id")
6161
if provider_id is not None:
6262
provider_id = str(provider_id).strip() or None
63+
default_handoff_mode = str(
64+
item.get("default_handoff_mode", "normal")
65+
).strip()
66+
if default_handoff_mode not in {"normal", "silent"}:
67+
default_handoff_mode = "normal"
6368
tools = item.get("tools", [])
6469
begin_dialogs = None
6570

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

96101
# Optional per-subagent chat provider override.
97102
handoff.provider_id = provider_id
103+
handoff.set_default_handoff_mode(default_handoff_mode)
98104

99105
handoffs.append(handoff)
100106

astrbot/dashboard/routes/subagent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ async def get_config(self):
5959
if isinstance(a, dict):
6060
a.setdefault("provider_id", None)
6161
a.setdefault("persona_id", None)
62+
if a.get("default_handoff_mode") not in ("normal", "silent"):
63+
a["default_handoff_mode"] = "normal"
6264
return jsonify(Response().ok(data=data).__dict__)
6365
except Exception as e:
6466
logger.error(traceback.format_exc())

dashboard/src/i18n/locales/en-US/features/subagent.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
"providerHint": "Leave empty to follow the global default provider.",
6161
"personaLabel": "Choose Persona",
6262
"personaHint": "The SubAgent inherits the selected Persona's system settings and tools.",
63+
"silentHandoffLabel": "Silent SubAgent handoff",
64+
"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.",
6365
"descriptionLabel": "Description for the main LLM (used to decide handoff)",
6466
"descriptionHint": "Shown to the main LLM as the transfer_to_* tool description—keep it short and clear."
6567
},

dashboard/src/i18n/locales/ru-RU/features/subagent.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
"providerHint": "Оставьте пустым, чтобы использовать глобальный провайдер по умолчанию.",
6161
"personaLabel": "Выберите персонажа",
6262
"personaHint": "SubAgent наследует системные настройки и инструменты выбранного персонажа.",
63+
"silentHandoffLabel": "Тихий вызов SubAgent",
64+
"silentHandoffHint": "SubAgent выполняет делегированную задачу в фоне, а основной агент читает результат и отвечает пользователю. Пользователь не увидит вызов инструмента SubAgent и сырой результат, если основной LLM явно не выберет normal mode.",
6365
"descriptionLabel": "Описание для основного LLM (используется для принятия решения о handoff)",
6466
"descriptionHint": "Отображается как описание инструмента transfer_to_* — будьте кратки и ясны."
6567
},

dashboard/src/i18n/locales/zh-CN/features/subagent.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"header": {
3-
"eyebrow": "Subagent Orchestration"
3+
"eyebrow": "SubAgent Orchestration"
44
},
55
"page": {
66
"title": "子代理编排",
@@ -32,7 +32,7 @@
3232
"dedupeHint": "从主代理中移除与子代理重复的工具"
3333
},
3434
"description": {
35-
"disabled": "未启动子代理编排功能",
35+
"disabled": "未启用子代理编排功能",
3636
"enabled": "子代理将作为工具放在主代理的工具集中,主代理会在适当的时机调用子代理完成任务。"
3737
},
3838
"section": {
@@ -59,8 +59,10 @@
5959
"providerLabel": "Chat Provider(可选)",
6060
"providerHint": "留空表示跟随全局默认 provider。",
6161
"personaLabel": "选择人格设定",
62-
"personaHint": "子代理 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
62+
"personaHint": "子代理将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
6363
"personaPreview": "人格预览",
64+
"silentHandoffLabel": "子代理静默调用",
65+
"silentHandoffHint": "开启后,子代理会在后台完成委派任务,主代理仍会读取结果并回复用户;除非主 LLM 显式使用 normal 模式,否则用户不会看到子代理的工具调用与原始结果。",
6466
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff)",
6567
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
6668
},

0 commit comments

Comments
 (0)