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:
SJ
2026-03-21 00:59:52 +08:00
committed by GitHub
parent d2e0bc778a
commit b816f26fe6
2 changed files with 306 additions and 65 deletions

View File

@@ -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