diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py index 761c50269..0b81adca8 100644 --- a/astrbot/core/agent/handoff.py +++ b/astrbot/core/agent/handoff.py @@ -44,6 +44,14 @@ def default_parameters(self) -> dict: "type": "string", "description": "The input to be handed off to another agent. This should be a clear and concise request or task.", }, + "background_mission": { + "type": "boolean", + "description": ( + "If true, run this handoff as a background mission: " + "return immediately and notify the user when the subagent finishes. " + "Use this for long-running or non-urgent tasks. Defaults to false." + ), + }, }, } diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 230faaf1c..c453dfbbb 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -45,6 +45,13 @@ async def execute(cls, tool, run_context, **tool_args): """ if isinstance(tool, HandoffTool): + is_bg = tool_args.pop("background_mission", False) + if is_bg: + async for r in cls._execute_handoff_background( + tool, run_context, **tool_args + ): + yield r + return async for r in cls._execute_handoff(tool, run_context, **tool_args): yield r return @@ -146,6 +153,163 @@ async def _execute_handoff( content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)] ) + @classmethod + async def _execute_handoff_background( + cls, + tool: HandoffTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + """Execute a handoff as a background mission. + + Immediately yields a success response with a task_id, then runs + the subagent asynchronously. When the subagent finishes, a + ``CronMessageEvent`` is created so the main LLM can inform the + user of the result – the same pattern used by + ``_execute_background`` for regular background tasks. + """ + task_id = uuid.uuid4().hex + + async def _run_handoff_in_background() -> None: + try: + await cls._do_handoff_background( + tool=tool, + run_context=run_context, + task_id=task_id, + **tool_args, + ) + except Exception as e: # noqa: BLE001 + logger.error( + f"Background handoff {task_id} ({tool.name}) failed: {e!s}", + exc_info=True, + ) + + asyncio.create_task(_run_handoff_in_background()) + + text_content = mcp.types.TextContent( + type="text", + text=( + f"Background mission submitted. task_id={task_id}. " + f"The subagent '{tool.agent.name}' is working on the task asynchronously. " + f"You will be notified when it finishes." + ), + ) + yield mcp.types.CallToolResult(content=[text_content]) + + @classmethod + async def _do_handoff_background( + cls, + tool: HandoffTool, + run_context: ContextWrapper[AstrAgentContext], + task_id: str, + **tool_args, + ) -> None: + """Run the subagent handoff and, on completion, wake the main agent.""" + from astrbot.core.astr_main_agent import ( + MainAgentBuildConfig, + _get_session_conv, + build_main_agent, + ) + + # ---- 1. Execute the handoff (subagent) ---------------------------- + result_text = "" + try: + async for r in cls._execute_handoff(tool, run_context, **tool_args): + if isinstance(r, mcp.types.CallToolResult): + for content in r.content: + if isinstance(content, mcp.types.TextContent): + result_text += content.text + "\n" + except Exception as e: + result_text = ( + f"error: Background handoff execution failed, internal error: {e!s}" + ) + + # ---- 2. Build a CronMessageEvent to wake the main agent ----------- + event = run_context.context.event + ctx = run_context.context.context + + note = ( + event.get_extra("background_note") + or f"Background subagent mission '{tool.agent.name}' finished." + ) + extras = { + "background_task_result": { + "task_id": task_id, + "tool_name": tool.name, + "subagent_name": tool.agent.name, + "result": result_text or "", + "tool_args": tool_args, + } + } + session = MessageSession.from_str(event.unified_msg_origin) + cron_event = CronMessageEvent( + context=ctx, + session=session, + message=note, + extras=extras, + message_type=session.message_type, + ) + cron_event.role = event.role + config = MainAgentBuildConfig(tool_call_timeout=3600) + + req = ProviderRequest() + conv = await _get_session_conv(event=cron_event, plugin_context=ctx) + req.conversation = conv + context = json.loads(conv.history) + if context: + req.contexts = context + context_dump = req._print_friendly_context() + req.contexts = [] + req.system_prompt += ( + "\n\nBellow is you and user previous conversation history:\n" + f"{context_dump}" + ) + + bg = json.dumps(extras["background_task_result"], ensure_ascii=False) + req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format( + background_task_result=bg + ) + req.prompt = ( + "Proceed according to your system instructions. " + "Output using same language as previous conversation." + " After completing your task, summarize and output your actions and results." + ) + if not req.func_tool: + req.func_tool = ToolSet() + req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL) + + result = await build_main_agent( + event=cron_event, plugin_context=ctx, config=config, req=req + ) + if not result: + logger.error("Failed to build main agent for background handoff mission.") + return + + runner = result.agent_runner + async for _ in runner.step_until_done(30): + # agent will send message to user via using tools + pass + llm_resp = runner.get_final_llm_resp() + task_meta = extras.get("background_task_result", {}) + summary_note = ( + f"[BackgroundMission] {task_meta.get('subagent_name', tool.agent.name)} " + f"(task_id={task_meta.get('task_id', task_id)}) finished. " + f"Result: {task_meta.get('result') or result_text or 'no content'}" + ) + if llm_resp and llm_resp.completion_text: + summary_note += ( + f"I finished the task, here is the result: {llm_resp.completion_text}" + ) + await persist_agent_history( + ctx.conversation_manager, + event=cron_event, + req=req, + summary_note=summary_note, + ) + if not llm_resp: + logger.warning("background handoff mission agent got no response") + return + @classmethod async def _execute_background( cls,