mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-18 10:00:40 +08:00
fix(core): interrupt subagent tool waits on stop (#5850)
* fix(core): interrupt subagent tool waits on stop * test: relax subagent handoff timeout * test: cover stop-aware tool interruption * refactor: unify runner stop state * refactor: simplify tool executor interruption * fix: preserve tool interruption propagation * refactor: tighten interruption helpers --------- Co-authored-by: idiotsj <idiotsj@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
@@ -7,10 +9,13 @@ import pytest
|
||||
# 将项目根目录添加到 sys.path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from astrbot.core.agent.agent import Agent
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, TokenUsage
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
@@ -127,6 +132,31 @@ class MockAbortableStreamProvider(MockProvider):
|
||||
)
|
||||
|
||||
|
||||
class MockToolCallProvider(MockProvider):
|
||||
def __init__(self, tool_name: str, tool_args: dict[str, str] | None = None):
|
||||
super().__init__()
|
||||
self.tool_name = tool_name
|
||||
self.tool_args = tool_args or {}
|
||||
self.abort_signal = None
|
||||
|
||||
async def text_chat(self, **kwargs) -> LLMResponse:
|
||||
self.call_count += 1
|
||||
self.abort_signal = kwargs.get("abort_signal")
|
||||
return LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="",
|
||||
tools_call_name=[self.tool_name],
|
||||
tools_call_args=[self.tool_args],
|
||||
tools_call_ids=[f"call_{self.tool_name}"],
|
||||
usage=TokenUsage(input_other=10, output=5),
|
||||
)
|
||||
|
||||
|
||||
class MockHandoffProvider(MockToolCallProvider):
|
||||
def __init__(self, handoff_tool_name: str):
|
||||
super().__init__(handoff_tool_name, {"input": "delegate this task"})
|
||||
|
||||
|
||||
class MockHooks(BaseAgentRunHooks):
|
||||
"""模拟钩子函数"""
|
||||
|
||||
@@ -163,6 +193,41 @@ class MockAgentContext:
|
||||
self.event = event
|
||||
|
||||
|
||||
class BlockingSubagentContext:
|
||||
def __init__(self):
|
||||
self.started = asyncio.Event()
|
||||
self.cancelled = False
|
||||
|
||||
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.started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled = True
|
||||
raise
|
||||
|
||||
|
||||
class BlockingToolState:
|
||||
def __init__(self):
|
||||
self.started = asyncio.Event()
|
||||
self.cancelled = False
|
||||
|
||||
async def handler(self, event, query: str = ""):
|
||||
del event, query
|
||||
self.started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled = True
|
||||
raise
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
return MockProvider()
|
||||
@@ -466,6 +531,104 @@ async def test_stop_signal_returns_aborted_and_persists_partial_message(
|
||||
assert runner.run_context.messages[-1].role == "assistant"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_interrupts_pending_subagent_handoff(mock_hooks):
|
||||
subagent_context = BlockingSubagentContext()
|
||||
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 = MockHandoffProvider(handoff_tool.name)
|
||||
request = ProviderRequest(
|
||||
prompt="delegate",
|
||||
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,
|
||||
)
|
||||
|
||||
step_iter = runner.step()
|
||||
first_resp = await step_iter.__anext__()
|
||||
assert first_resp.type == "tool_call"
|
||||
assert provider.abort_signal is not None
|
||||
assert provider.abort_signal.is_set() is False
|
||||
|
||||
pending_resp = asyncio.create_task(step_iter.__anext__())
|
||||
await asyncio.wait_for(subagent_context.started.wait(), timeout=5)
|
||||
|
||||
runner.request_stop()
|
||||
assert provider.abort_signal.is_set() is True
|
||||
|
||||
aborted_resp = await asyncio.wait_for(pending_resp, timeout=1)
|
||||
assert aborted_resp.type == "aborted"
|
||||
assert runner.was_aborted() is True
|
||||
assert subagent_context.cancelled is True
|
||||
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await step_iter.__anext__()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_interrupts_pending_regular_tool(mock_hooks):
|
||||
tool_state = BlockingToolState()
|
||||
event = MockEvent("webchat:FriendMessage:webchat!user!session", "user")
|
||||
tool = FunctionTool(
|
||||
name="long_tool",
|
||||
description="A long-running test tool",
|
||||
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
handler=tool_state.handler,
|
||||
)
|
||||
provider = MockToolCallProvider(tool.name, {"query": "slow"})
|
||||
request = ProviderRequest(
|
||||
prompt="run a slow tool",
|
||||
func_tool=ToolSet(tools=[tool]),
|
||||
contexts=[],
|
||||
)
|
||||
runner = ToolLoopAgentRunner()
|
||||
|
||||
await runner.reset(
|
||||
provider=provider,
|
||||
request=request,
|
||||
run_context=ContextWrapper(
|
||||
context=SimpleNamespace(event=event, context=SimpleNamespace())
|
||||
),
|
||||
tool_executor=FunctionToolExecutor(),
|
||||
agent_hooks=mock_hooks,
|
||||
streaming=False,
|
||||
)
|
||||
|
||||
step_iter = runner.step()
|
||||
first_resp = await step_iter.__anext__()
|
||||
assert first_resp.type == "tool_call"
|
||||
assert provider.abort_signal is not None
|
||||
assert provider.abort_signal.is_set() is False
|
||||
|
||||
pending_resp = asyncio.create_task(step_iter.__anext__())
|
||||
await asyncio.wait_for(tool_state.started.wait(), timeout=5)
|
||||
|
||||
runner.request_stop()
|
||||
assert provider.abort_signal.is_set() is True
|
||||
|
||||
aborted_resp = await asyncio.wait_for(pending_resp, timeout=5)
|
||||
assert aborted_resp.type == "aborted"
|
||||
assert runner.was_aborted() is True
|
||||
assert tool_state.cancelled is True
|
||||
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await step_iter.__anext__()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_injects_follow_up_notice(
|
||||
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
|
||||
|
||||
Reference in New Issue
Block a user