diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py index aebcdcb5d1..1f483ccfbc 100644 --- a/astrbot/core/agent/handoff.py +++ b/astrbot/core/agent/handoff.py @@ -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", @@ -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." + ), + }, }, } diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index 4292f4c04e..2d0c8dbf90 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -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() diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 1b9f5a5929..f47e556957 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -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 @@ -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 "" @@ -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() @@ -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 @@ -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: @@ -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( diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index de5caad554..754c68aa5a 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -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 @@ -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, @@ -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): @@ -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 @@ -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 diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py index c6c595dfc9..ca40330286 100644 --- a/astrbot/core/subagent_orchestrator.py +++ b/astrbot/core/subagent_orchestrator.py @@ -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 @@ -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) diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index e3d77f73ad..e8e7fcbe89 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -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()) diff --git a/dashboard/src/i18n/locales/en-US/features/subagent.json b/dashboard/src/i18n/locales/en-US/features/subagent.json index e9ea127f51..3d69db20cd 100644 --- a/dashboard/src/i18n/locales/en-US/features/subagent.json +++ b/dashboard/src/i18n/locales/en-US/features/subagent.json @@ -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." }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/subagent.json b/dashboard/src/i18n/locales/ru-RU/features/subagent.json index 4f6b298b4d..965c956c90 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/subagent.json +++ b/dashboard/src/i18n/locales/ru-RU/features/subagent.json @@ -60,6 +60,8 @@ "providerHint": "Оставьте пустым, чтобы использовать глобальный провайдер по умолчанию.", "personaLabel": "Выберите персонажа", "personaHint": "SubAgent наследует системные настройки и инструменты выбранного персонажа.", + "silentHandoffLabel": "Тихий вызов SubAgent", + "silentHandoffHint": "SubAgent выполняет делегированную задачу в фоне, а основной агент читает результат и отвечает пользователю. Пользователь не увидит вызов инструмента SubAgent и сырой результат, если основной LLM явно не выберет normal mode.", "descriptionLabel": "Описание для основного LLM (используется для принятия решения о handoff)", "descriptionHint": "Отображается как описание инструмента transfer_to_* — будьте кратки и ясны." }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/subagent.json b/dashboard/src/i18n/locales/zh-CN/features/subagent.json index cd49ae432d..e18e9f5a6a 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/subagent.json +++ b/dashboard/src/i18n/locales/zh-CN/features/subagent.json @@ -1,6 +1,6 @@ { "header": { - "eyebrow": "Subagent Orchestration" + "eyebrow": "SubAgent Orchestration" }, "page": { "title": "子代理编排", @@ -32,7 +32,7 @@ "dedupeHint": "从主代理中移除与子代理重复的工具" }, "description": { - "disabled": "未启动子代理编排功能。", + "disabled": "未启用子代理编排功能。", "enabled": "子代理将作为工具放在主代理的工具集中,主代理会在适当的时机调用子代理完成任务。" }, "section": { @@ -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 看,建议简短明确。" }, diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue index d3876ec4c8..a08a2de701 100644 --- a/dashboard/src/views/SubAgentPage.vue +++ b/dashboard/src/views/SubAgentPage.vue @@ -176,6 +176,24 @@ +
+
+ {{ tm('form.silentHandoffLabel') }} +
+ +
+ {{ tm('form.silentHandoffHint') }} +
+
+ { background: rgba(var(--v-theme-primary), 0.02); } +.setting-card--compact { + padding: 16px; +} + +.silent-handoff-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 16px; +} + +.silent-handoff-title { + min-width: 0; + color: var(--dashboard-text); + font-size: 15px; + font-weight: 700; + line-height: 1.45; +} + +.silent-handoff-switch { + justify-self: end; + align-self: center; +} + +.silent-handoff-hint { + grid-column: 1 / -1; + max-width: 680px; + margin-top: 0; +} + .setting-card-head { display: flex; justify-content: space-between; diff --git a/tests/test_conversation_checkpoint.py b/tests/test_conversation_checkpoint.py index 40eae46ff9..1c7745a895 100644 --- a/tests/test_conversation_checkpoint.py +++ b/tests/test_conversation_checkpoint.py @@ -1,10 +1,13 @@ import pytest from astrbot.core.agent.message import ( + AssistantMessageSegment, CheckpointData, CheckpointMessageSegment, Message, TextPart, + ToolCall, + ToolCallMessageSegment, bind_checkpoint_messages, dump_messages_with_checkpoints, get_checkpoint_id, @@ -101,6 +104,70 @@ def test_dump_messages_filters_temp_content_parts(): ] +def test_dump_messages_filters_temp_messages(): + temp_message = Message(role="tool", content="internal", tool_call_id="call_1") + temp_message._no_save = True + messages = [ + Message(role="user", content="hello"), + temp_message, + Message(role="assistant", content="ok"), + ] + + assert dump_messages_with_checkpoints(messages) == [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "ok"}, + ] + + +def test_dump_messages_filters_temp_tool_calls_from_mixed_assistant_message(): + assistant = AssistantMessageSegment( + content=None, + tool_calls=[ + ToolCall( + id="call_hidden", + function=ToolCall.FunctionBody( + name="transfer_to_subagent", + arguments="{}", + ), + ), + ToolCall( + id="call_visible", + function=ToolCall.FunctionBody( + name="visible_tool", + arguments="{}", + ), + ), + ], + ) + hidden_tool = ToolCallMessageSegment( + content="private", + tool_call_id="call_hidden", + ) + hidden_tool._no_save = True + visible_tool = ToolCallMessageSegment( + content="public", + tool_call_id="call_visible", + ) + + assert dump_messages_with_checkpoints([assistant, hidden_tool, visible_tool]) == [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "id": "call_visible", + "function": { + "name": "visible_tool", + "arguments": "{}", + }, + } + ], + }, + {"role": "tool", "content": "public", "tool_call_id": "call_visible"}, + ] + + def test_content_part_no_save_round_trip_from_dict(): message = Message.model_validate( { diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 74d0691085..98836c4b7a 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -1,4 +1,5 @@ import asyncio +import copy import os import sys from pathlib import Path @@ -291,6 +292,51 @@ def __init__(self, handoff_tool_name: str): super().__init__(handoff_tool_name, {"input": "delegate this task"}) +class SilentHandoffThenFinalProvider(MockProvider): + def __init__(self, handoff_tool_name: str, include_mode: bool = True): + super().__init__() + self.handoff_tool_name = handoff_tool_name + self.include_mode = include_mode + self.received_contexts = [] + + async def text_chat(self, **kwargs) -> LLMResponse: + self.call_count += 1 + self.received_contexts.append(copy.deepcopy(kwargs.get("contexts"))) + if self.call_count == 1: + tool_args = {"input": "delegate this task"} + if self.include_mode: + tool_args["mode"] = "silent" + return LLMResponse( + role="assistant", + completion_text="", + tools_call_name=[self.handoff_tool_name], + tools_call_args=[tool_args], + tools_call_ids=["call_silent_handoff"], + usage=TokenUsage(input_other=10, output=5), + ) + + return LLMResponse( + role="assistant", + completion_text="main final answer", + usage=TokenUsage(input_other=10, output=5), + ) + + +class ImmediateSubagentContext: + def __init__(self): + self.tool_loop_agent_calls = [] + + async def get_current_chat_provider_id(self, _umo: str) -> str: + return "provider-id" + + def get_config(self, **_kwargs): + return {"provider_settings": {}} + + async def tool_loop_agent(self, **kwargs): + self.tool_loop_agent_calls.append(kwargs) + return LLMResponse(role="assistant", completion_text="subagent private result") + + class MockHooks(BaseAgentRunHooks): """模拟钩子函数""" @@ -1072,6 +1118,106 @@ async def test_stop_interrupts_pending_subagent_handoff(mock_hooks): await step_iter.__anext__() +@pytest.mark.asyncio +async def test_silent_handoff_returns_result_to_main_agent_without_visible_tool_events( + mock_hooks, +): + subagent_context = ImmediateSubagentContext() + event = MockEvent("webchat:FriendMessage:webchat!user!session", "user") + handoff_tool = HandoffTool( + Agent(name="subagent", instructions="subagent-instructions", tools=[]), + tool_description="Delegate tasks to the subagent.", + ) + provider = SilentHandoffThenFinalProvider(handoff_tool.name) + request = ProviderRequest( + prompt="delegate privately", + func_tool=ToolSet(tools=[handoff_tool]), + contexts=[], + ) + runner = ToolLoopAgentRunner() + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper( + context=SimpleNamespace(event=event, context=subagent_context) + ), + tool_executor=FunctionToolExecutor(), + agent_hooks=mock_hooks, + streaming=False, + ) + + responses = [] + async for response in runner.step_until_done(5): + responses.append(response) + + assert subagent_context.tool_loop_agent_calls + assert provider.call_count == 2 + assert runner.done() is True + assert runner.get_final_llm_resp().completion_text == "main final answer" + assert [response.type for response in responses] == ["llm_result"] + + tool_messages = [ + message + for message in runner.run_context.messages + if getattr(message, "role", None) == "tool" + ] + tool_call_messages = [ + message + for message in runner.run_context.messages + if getattr(message, "tool_calls", None) + ] + assert len(tool_messages) == 1 + assert tool_messages[0]._no_save is True + assert len(tool_call_messages) == 1 + assert tool_call_messages[0]._no_save is True + assert tool_messages[0].content == "subagent private result" + assert provider.received_contexts[1][-1].content == "subagent private result" + + +@pytest.mark.asyncio +async def test_default_silent_handoff_mode_hides_tool_events_when_mode_omitted( + mock_hooks, +): + subagent_context = ImmediateSubagentContext() + event = MockEvent("webchat:FriendMessage:webchat!user!session", "user") + handoff_tool = HandoffTool( + Agent(name="subagent", instructions="subagent-instructions", tools=[]), + tool_description="Delegate tasks to the subagent.", + ) + handoff_tool.default_handoff_mode = "silent" + provider = SilentHandoffThenFinalProvider(handoff_tool.name, include_mode=False) + request = ProviderRequest( + prompt="delegate privately by default", + func_tool=ToolSet(tools=[handoff_tool]), + contexts=[], + ) + runner = ToolLoopAgentRunner() + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper( + context=SimpleNamespace(event=event, context=subagent_context) + ), + tool_executor=FunctionToolExecutor(), + agent_hooks=mock_hooks, + streaming=False, + ) + + responses = [] + async for response in runner.step_until_done(5): + responses.append(response) + + assert provider.call_count == 2 + assert runner.get_final_llm_resp().completion_text == "main final answer" + assert [response.type for response in responses] == ["llm_result"] + assert any( + getattr(message, "role", None) == "tool" and message._no_save + for message in runner.run_context.messages + ) + + @pytest.mark.asyncio async def test_stop_interrupts_pending_regular_tool(mock_hooks): tool_state = BlockingToolState() diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 5fab9fe0a2..8d192d2097 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -1,11 +1,16 @@ +import json from types import SimpleNamespace import mcp import pytest +from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor from astrbot.core.message.components import Image +from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.tools.message_tools import SendMessageToUserTool class _DummyEvent: @@ -321,6 +326,208 @@ async def _fake_tool_loop_agent(**kwargs): assert captured["tool_call_timeout"] == 120 +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("default_mode", "mode_arg", "expected_mode", "expected_state", "expected_source"), + [ + ("silent", None, "silent", "子代理静默调用=开启", "source=default"), + ("silent", "normal", "normal", "子代理静默调用=未开启", "source=explicit"), + ], +) +async def test_execute_handoff_logs_silent_mode_state( + monkeypatch: pytest.MonkeyPatch, + default_mode: str, + mode_arg: str | None, + expected_mode: str, + expected_state: str, + expected_source: str, +): + logs: list[str] = [] + + async def _fake_execute_handoff(cls, tool, run_context, **tool_args): + yield mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text="ok")] + ) + + def _fake_info(message, *args, **kwargs): + logs.append(message % args if args else str(message)) + + monkeypatch.setattr( + FunctionToolExecutor, + "_execute_handoff", + classmethod(_fake_execute_handoff), + ) + monkeypatch.setattr("astrbot.core.astr_agent_tool_exec.logger.info", _fake_info) + + tool = HandoffTool( + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ) + ) + tool.set_default_handoff_mode(default_mode) + run_context = _build_run_context() + + tool_args = {"input": "hello"} + if mode_arg is not None: + tool_args["mode"] = mode_arg + + results = [] + async for result in FunctionToolExecutor.execute(tool, run_context, **tool_args): + results.append(result) + + assert len(results) == 1 + assert any( + f"mode={expected_mode}" in log + and expected_state in log + and expected_source in log + for log in logs + ) + + +@pytest.mark.asyncio +async def test_execute_handoff_silent_mode_removes_send_message_tool( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict = {} + send_tool = SendMessageToUserTool() + helper_tool = FunctionTool( + name="helper_tool", + description="helper", + parameters={"type": "object", "properties": {}}, + handler=None, + ) + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: {"provider_settings": {}}, + ) + event = _DummyEvent([]) + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[send_tool, helper_tool], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=[], + mode="silent", + ): + results.append(result) + + assert len(results) == 1 + assert isinstance(captured["tools"], ToolSet) + assert captured["tools"].names() == ["helper_tool"] + + +@pytest.mark.asyncio +async def test_execute_handoff_uses_tool_default_silent_mode( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict = {} + send_tool = SendMessageToUserTool() + helper_tool = FunctionTool( + name="helper_tool", + description="helper", + parameters={"type": "object", "properties": {}}, + handler=None, + ) + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: {"provider_settings": {}}, + ) + event = _DummyEvent([]) + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + default_handoff_mode="silent", + agent=SimpleNamespace( + name="subagent", + tools=[send_tool, helper_tool], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=[], + ): + results.append(result) + + assert len(results) == 1 + assert isinstance(captured["tools"], ToolSet) + assert captured["tools"].names() == ["helper_tool"] + + +@pytest.mark.asyncio +async def test_format_handoff_response_text_includes_structured_chain_for_silent_mode(): + llm_resp = SimpleNamespace( + completion_text="look at this", + result_chain=MessageChain() + .message("look at this") + .url_image("https://example.com/image.png"), + ) + + result = await FunctionToolExecutor._format_handoff_response_text( + llm_resp, + include_structured_chain=True, + ) + + assert json.loads(result) == { + "text": "look at this", + "components": [ + {"type": "text", "data": {"text": "look at this"}}, + { + "type": "image", + "data": { + "file": "https://example.com/image.png", + "url": "", + "path": "", + }, + }, + ], + } + + @pytest.mark.asyncio async def test_collect_handoff_image_urls_filters_extensionless_file_outside_temp_root( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_subagent_orchestrator.py b/tests/unit/test_subagent_orchestrator.py index 9befac8872..b4d10468de 100644 --- a/tests/unit/test_subagent_orchestrator.py +++ b/tests/unit/test_subagent_orchestrator.py @@ -108,3 +108,34 @@ async def test_reload_from_config_tool_normalization(raw_tools, expected_tools): handoff = orchestrator.handoffs[0] assert handoff.agent.tools == expected_tools + + +@pytest.mark.asyncio +async def test_reload_from_config_sets_default_handoff_mode(): + tool_mgr = MagicMock() + persona_mgr = MagicMock() + persona_mgr.get_persona_v3_by_id.return_value = None + orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr) + + await orchestrator.reload_from_config( + _build_cfg({"default_handoff_mode": "silent"}) + ) + + handoff = orchestrator.handoffs[0] + assert handoff.default_handoff_mode == "silent" + assert ( + "Defaults to silent." in handoff.parameters["properties"]["mode"]["description"] + ) + + +@pytest.mark.asyncio +async def test_reload_from_config_normalizes_invalid_default_handoff_mode(): + tool_mgr = MagicMock() + persona_mgr = MagicMock() + persona_mgr.get_persona_v3_by_id.return_value = None + orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr) + + await orchestrator.reload_from_config(_build_cfg({"default_handoff_mode": "loud"})) + + handoff = orchestrator.handoffs[0] + assert handoff.default_handoff_mode == "normal"