fix: keep async tool generators in one task (#10474)

* fix: add demand-driven task ownership for async generators

* fix: keep async tool generators in one task

* fix: publish generator cleanup errors without blocking

* style: format tool lifecycle regression tests
This commit is contained in:
エイカク
2026-10-09 20:10:37 +09:00
committed by GitHub
parent 28a10c17ab
commit 33ef43be3f
5 changed files with 842 additions and 99 deletions
@@ -46,6 +46,7 @@ from astrbot.core.provider.modalities import (
sanitize_contexts_by_modalities,
)
from astrbot.core.provider.provider import Provider
from astrbot.core.utils.async_generator import iterate_in_task
from ..context.compressor import ContextCompressor
from ..context.config import ContextConfig
@@ -1611,49 +1612,49 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self,
executor: T.AsyncGenerator[ToolExecutorResultT, None],
) -> T.AsyncGenerator[ToolExecutorResultT, None]:
executor = iterate_in_task(executor)
async def _next_executor_result() -> ToolExecutorResultT:
return await anext(executor)
while True:
if self._is_stop_requested():
await self._close_executor(executor)
raise _ToolExecutionInterrupted(
"Tool execution interrupted before reading the next tool result."
)
next_result_task = asyncio.create_task(_next_executor_result())
abort_task = asyncio.create_task(self._abort_signal.wait())
try:
done, _ = await asyncio.wait(
{next_result_task, abort_task},
return_when=asyncio.FIRST_COMPLETED,
)
if abort_task in done:
if not next_result_task.done():
next_result_task.cancel()
with suppress(asyncio.CancelledError, StopAsyncIteration):
await next_result_task
await self._close_executor(executor)
try:
while True:
if self._is_stop_requested():
raise _ToolExecutionInterrupted(
"Tool execution interrupted by a stop request."
"Tool execution interrupted before reading the next tool result."
)
next_result_task = asyncio.create_task(_next_executor_result())
abort_task = asyncio.create_task(self._abort_signal.wait())
try:
yield next_result_task.result()
except StopAsyncIteration:
return
except asyncio.CancelledError:
if not next_result_task.done():
next_result_task.cancel()
# The reader must finish before its async generator can be closed.
await asyncio.gather(next_result_task, return_exceptions=True)
await self._close_executor(executor)
raise
finally:
if not abort_task.done():
abort_task.cancel()
with suppress(asyncio.CancelledError):
await abort_task
done, _ = await asyncio.wait(
{next_result_task, abort_task},
return_when=asyncio.FIRST_COMPLETED,
)
if abort_task in done:
if not next_result_task.done():
next_result_task.cancel()
with suppress(asyncio.CancelledError, StopAsyncIteration):
await next_result_task
raise _ToolExecutionInterrupted(
"Tool execution interrupted by a stop request."
)
try:
yield next_result_task.result()
except StopAsyncIteration:
return
except asyncio.CancelledError:
if not next_result_task.done():
next_result_task.cancel()
# The reader must finish before its async generator can be closed.
await asyncio.gather(next_result_task, return_exceptions=True)
raise
finally:
if not abort_task.done():
abort_task.cancel()
with suppress(asyncio.CancelledError):
await abort_task
finally:
await executor.aclose()
+74 -60
View File
@@ -6,6 +6,7 @@ import typing as T
import uuid
from collections.abc import Sequence
from collections.abc import Set as AbstractSet
from contextlib import aclosing
import mcp
@@ -50,6 +51,7 @@ from astrbot.core.tools.computer_tools import (
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.utils.active_event_registry import active_event_registry
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.async_generator import iterate_in_task
from astrbot.core.utils.config_number import coerce_int_config
from astrbot.core.utils.history_saver import persist_agent_history
from astrbot.core.utils.image_ref_utils import is_supported_image_ref
@@ -145,18 +147,25 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
if isinstance(tool, HandoffTool):
is_bg = tool_args.pop("background_task", False)
if is_bg:
async for r in cls._execute_handoff_background(
tool, run_context, **tool_args
):
yield r
async with aclosing(
cls._execute_handoff_background(tool, run_context, **tool_args)
) as results:
async for r in results:
yield r
return
async for r in cls._execute_handoff(tool, run_context, **tool_args):
yield r
async with aclosing(
cls._execute_handoff(tool, run_context, **tool_args)
) as results:
async for r in results:
yield r
return
elif isinstance(tool, MCPTool):
async for r in cls._execute_mcp(tool, run_context, **tool_args):
yield r
async with aclosing(
cls._execute_mcp(tool, run_context, **tool_args)
) as results:
async for r in results:
yield r
return
elif tool.is_background_task:
@@ -189,8 +198,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
return
else:
async for r in cls._execute_local(tool, run_context, **tool_args):
yield r
async with aclosing(
cls._execute_local(tool, run_context, **tool_args)
) as results:
async for r in results:
yield r
return
@classmethod
@@ -724,46 +736,47 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
method_name=method_name,
**tool_args,
)
while True:
try:
resp = await asyncio.wait_for(
anext(wrapper),
timeout=effective_timeout,
)
if resp is not None:
if isinstance(resp, mcp.types.CallToolResult):
yield resp
async with aclosing(wrapper):
while True:
try:
resp = await asyncio.wait_for(
anext(wrapper),
timeout=effective_timeout,
)
if resp is not None:
if isinstance(resp, mcp.types.CallToolResult):
yield resp
else:
text_content = mcp.types.TextContent(
type="text",
text=str(resp),
)
yield mcp.types.CallToolResult(content=[text_content])
else:
text_content = mcp.types.TextContent(
type="text",
text=str(resp),
)
yield mcp.types.CallToolResult(content=[text_content])
else:
# NOTE: Tool 在这里直接请求发送消息给用户
# TODO: 是否需要判断 event.get_result() 是否为空?
# 如果为空,则说明没有发送消息给用户,并且返回值为空,将返回一个特殊的 TextContent,其内容如"工具没有返回内容"
if res := run_context.context.event.get_result():
if res.chain:
try:
await event.send(
MessageChain(
chain=res.chain,
type="tool_direct_result",
# NOTE: Tool 在这里直接请求发送消息给用户
# TODO: 是否需要判断 event.get_result() 是否为空?
# 如果为空,则说明没有发送消息给用户,并且返回值为空,将返回一个特殊的 TextContent,其内容如"工具没有返回内容"
if res := run_context.context.event.get_result():
if res.chain:
try:
await event.send(
MessageChain(
chain=res.chain,
type="tool_direct_result",
)
)
)
except Exception as e:
logger.error(
f"Tool 直接发送消息失败: {e}",
exc_info=True,
)
yield None
except asyncio.TimeoutError:
raise Exception(
f"tool {tool.name} execution timeout after {effective_timeout} seconds.",
)
except StopAsyncIteration:
break
except Exception as e:
logger.error(
f"Tool 直接发送消息失败: {e}",
exc_info=True,
)
yield None
except asyncio.TimeoutError:
raise Exception(
f"tool {tool.name} execution timeout after {effective_timeout} seconds.",
)
except StopAsyncIteration:
break
@classmethod
async def _execute_mcp(
@@ -847,18 +860,19 @@ async def call_local_llm_tool(
if inspect.isasyncgen(ready_to_call):
_has_yielded = False
try:
async for ret in ready_to_call:
# 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
# 返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, MessageEventResult | CommandResult):
# 如果返回值是 MessageEventResult, 设置结果并继续
event.set_result(ret)
yield
else:
# 如果返回值是 None, 则不设置结果并继续
# 继续执行后续阶段
yield ret
async with aclosing(iterate_in_task(ready_to_call)) as results:
async for ret in results:
# 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
# 返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, MessageEventResult | CommandResult):
# 如果返回值是 MessageEventResult, 设置结果并继续
event.set_result(ret)
yield
else:
# 如果返回值是 None, 则不设置结果并继续
# 继续执行后续阶段
yield ret
if not _has_yielded:
# 如果这个异步生成器没有执行到 yield 分支
yield
+92
View File
@@ -0,0 +1,92 @@
import asyncio
from collections.abc import AsyncGenerator
from contextlib import suppress
from typing import TypeVar, cast
T = TypeVar("T")
async def iterate_in_task(
generator: AsyncGenerator[T, None],
) -> AsyncGenerator[T, None]:
"""Advance and close a generator in one task, only when a caller requests it.
Args:
generator: Generator owned by this iterator until completion or close.
Yields:
The original generator's values without prefetching.
Raises:
BaseException: Original iteration or cleanup failures.
"""
advance = asyncio.Event()
results: asyncio.Queue[tuple[bool, T | BaseException]] = asyncio.Queue(maxsize=1)
finishing = False
stop_requested = False
terminal: BaseException | None = None
async def produce() -> None:
nonlocal finishing, terminal
try:
while True:
await advance.wait()
advance.clear()
try:
value = await anext(generator)
except asyncio.CancelledError as error:
if not stop_requested:
terminal = error
break
except BaseException as error:
terminal = error
break
if stop_requested:
return
await results.put((True, value))
finally:
finishing = True
try:
await generator.aclose()
except BaseException as error:
terminal = error
if terminal is not None:
# Closing consumers may leave a value queued; terminal is also read below.
with suppress(asyncio.QueueFull):
results.put_nowait((False, terminal))
owner = asyncio.create_task(produce())
try:
while True:
advance.set()
succeeded, value = await results.get()
if not succeeded:
if isinstance(value, StopAsyncIteration):
return
raise cast(BaseException, value)
yield cast(T, value)
finally:
interrupted = False
if not finishing:
stop_requested = True
owner.cancel()
while not owner.done():
try:
await asyncio.shield(owner)
except asyncio.CancelledError:
if not owner.done():
interrupted = True
with suppress(asyncio.CancelledError):
await owner
if (
not interrupted
and terminal is not None
and not isinstance(terminal, StopAsyncIteration)
):
raise cast(BaseException, terminal)
if not interrupted and not results.empty():
succeeded, value = results.get_nowait()
if not succeeded and not isinstance(value, StopAsyncIteration):
raise cast(BaseException, value)
if interrupted:
raise asyncio.CancelledError
+327
View File
@@ -0,0 +1,327 @@
"""Tests for demand-driven async generator task ownership."""
import asyncio
from contextvars import ContextVar
import anyio
import pytest
from astrbot.core.utils.async_generator import iterate_in_task
@pytest.mark.asyncio
@pytest.mark.parametrize("exit_mode", ["close", "exhaust", "error"])
async def test_owner_task_context_and_demand_are_preserved(exit_mode):
tasks = set(asyncio.all_tasks())
context = ContextVar("generator_context", default="caller")
owners = []
advanced = []
error = ValueError("controlled generator failure")
async def source():
owners.append(asyncio.current_task())
token = context.set("generator")
try:
with anyio.CancelScope():
yield None
advanced.append("next")
owners.append(asyncio.current_task())
if exit_mode == "error":
raise error
yield error
finally:
await asyncio.sleep(0)
owners.append(asyncio.current_task())
context.reset(token)
results = iterate_in_task(source())
try:
assert await anext(results) is None
await asyncio.sleep(0)
assert advanced == []
assert context.get() == "caller"
if exit_mode == "exhaust":
assert await anext(results) is error
with pytest.raises(StopAsyncIteration):
await anext(results)
elif exit_mode == "error":
with pytest.raises(ValueError) as caught:
await anext(results)
assert caught.value is error
finally:
await results.aclose()
assert all(owner is owners[0] for owner in owners)
assert len(owners) == (2 if exit_mode == "close" else 3)
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
async def test_cancel_pending_read_waits_for_cleanup():
tasks = set(asyncio.all_tasks())
started = asyncio.Event()
cleanup = []
async def source():
try:
started.set()
await asyncio.Future()
yield "unreachable"
finally:
await asyncio.sleep(0)
cleanup.append("closed")
results = iterate_in_task(source())
pending = asyncio.create_task(anext(results))
try:
await asyncio.wait_for(started.wait(), 1)
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await pending
assert cleanup == ["closed"]
finally:
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await results.aclose()
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
async def test_cleanup_failure_is_not_hidden():
error = OSError("controlled cleanup failure")
async def source():
try:
yield "result"
finally:
raise error
results = iterate_in_task(source())
assert await anext(results) == "result"
with pytest.raises(OSError) as caught:
await results.aclose()
assert caught.value is error
@pytest.mark.asyncio
async def test_exhaustion_waits_without_cancelling_cleanup():
cleanup_started = asyncio.Event()
release = asyncio.Event()
closed = []
async def source():
try:
yield "result"
finally:
cleanup_started.set()
await release.wait()
closed.append("closed")
results = iterate_in_task(source())
assert await anext(results) == "result"
pending = asyncio.create_task(anext(results))
try:
await asyncio.wait_for(cleanup_started.wait(), 1)
assert not pending.done()
release.set()
with pytest.raises(StopAsyncIteration):
await asyncio.wait_for(pending, 1)
assert closed == ["closed"]
finally:
release.set()
await asyncio.gather(pending, return_exceptions=True)
await results.aclose()
@pytest.mark.asyncio
async def test_close_waits_for_async_cleanup_in_owner_task():
owners = []
cleanup_started = asyncio.Event()
release = asyncio.Event()
async def source():
owners.append(asyncio.current_task())
try:
yield "result"
finally:
cleanup_started.set()
await release.wait()
owners.append(asyncio.current_task())
results = iterate_in_task(source())
assert await anext(results) == "result"
closing = asyncio.create_task(results.aclose())
try:
await asyncio.wait_for(cleanup_started.wait(), 1)
assert not closing.done()
release.set()
await asyncio.wait_for(closing, 1)
assert len(owners) == 2 and owners[0] is owners[1]
finally:
release.set()
await asyncio.gather(closing, return_exceptions=True)
@pytest.mark.asyncio
async def test_empty_generator_finishes_without_pending_task():
tasks = set(asyncio.all_tasks())
async def source():
if False:
yield
assert [value async for value in iterate_in_task(source())] == []
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
async def test_repeated_cancel_does_not_interrupt_owner_cleanup():
tasks = set(asyncio.all_tasks())
started = asyncio.Event()
cleanup_started = asyncio.Event()
release = asyncio.Event()
closed = []
async def source():
try:
started.set()
await asyncio.Future()
yield "unreachable"
finally:
cleanup_started.set()
await release.wait()
closed.append("closed")
results = iterate_in_task(source())
pending = asyncio.create_task(anext(results))
try:
await asyncio.wait_for(started.wait(), 1)
pending.cancel()
await asyncio.wait_for(cleanup_started.wait(), 1)
pending.cancel()
await asyncio.sleep(0)
assert not pending.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(pending, 1)
assert closed == ["closed"]
finally:
release.set()
await asyncio.gather(pending, return_exceptions=True)
await results.aclose()
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
async def test_generator_that_yields_after_cancel_is_still_closed():
tasks = set(asyncio.all_tasks())
started = asyncio.Event()
closed = []
async def source():
try:
started.set()
try:
await asyncio.Future()
except asyncio.CancelledError:
yield "late result"
finally:
closed.append("closed")
results = iterate_in_task(source())
pending = asyncio.create_task(anext(results))
try:
await asyncio.wait_for(started.wait(), 1)
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(pending, 0.1)
assert closed == ["closed"]
finally:
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await results.aclose()
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
async def test_source_cancellation_reaches_consumer():
error = asyncio.CancelledError("source cancelled itself")
closed = []
async def source():
try:
raise error
yield
finally:
closed.append("closed")
results = iterate_in_task(source())
try:
with pytest.raises(asyncio.CancelledError) as caught:
await asyncio.wait_for(anext(results), 0.1)
assert caught.value is error
assert closed == ["closed"]
finally:
await results.aclose()
@pytest.mark.asyncio
async def test_source_error_is_delivered_after_cleanup():
error = ValueError("source failure")
cleanup = []
async def source():
try:
raise error
yield
finally:
await asyncio.sleep(0)
cleanup.append("closed")
results = iterate_in_task(source())
try:
with pytest.raises(ValueError) as caught:
await anext(results)
assert caught.value is error
assert cleanup == ["closed"]
finally:
await results.aclose()
@pytest.mark.asyncio
async def test_cancel_with_queued_value_and_cleanup_error(monkeypatch):
original_queue = asyncio.Queue
pending = None
queued = asyncio.Event()
error = OSError("queued cleanup failure")
class CancelAfterPut(original_queue):
async def get(self):
await asyncio.Future()
async def put(self, item):
if item[0]:
pending.cancel()
queued.set()
await super().put(item)
monkeypatch.setattr(
"astrbot.core.utils.async_generator.asyncio.Queue", CancelAfterPut
)
async def source():
try:
yield "queued result"
finally:
raise error
results = iterate_in_task(source())
pending = asyncio.create_task(anext(results))
try:
await asyncio.wait_for(queued.wait(), 1)
with pytest.raises(OSError) as caught:
await asyncio.wait_for(pending, 0.1)
assert caught.value is error
finally:
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await results.aclose()
+309
View File
@@ -0,0 +1,309 @@
"""Real local tool lifecycle across wrappers and runner stop/cancellation."""
import asyncio
from types import SimpleNamespace
import anyio
import pytest
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.runners.tool_loop_agent_runner import (
ToolLoopAgentRunner,
_ToolExecutionInterrupted,
)
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor, call_local_llm_tool
@pytest.mark.asyncio
@pytest.mark.parametrize("layer", ["handler_wrapper", "local", "execute", "runner"])
@pytest.mark.parametrize("exit_mode", ["close", "exhaust", "error"])
async def test_real_handler_cleanup_and_output(layer, exit_mode):
tasks = set(asyncio.all_tasks())
cleanup = []
advanced = []
owners = []
error = ValueError("controlled tool error")
async def implementation():
token_task = asyncio.current_task()
with anyio.CancelScope():
try:
owners.append(token_task)
yield "first"
advanced.append("next")
owners.append(asyncio.current_task())
if exit_mode == "error":
raise error
yield "second"
finally:
await asyncio.sleep(0)
owners.append(asyncio.current_task())
cleanup.append("closed")
handler_generator = implementation()
def handler(event):
return handler_generator
run_context = ContextWrapper(context=SimpleNamespace(event=SimpleNamespace()))
tool = FunctionTool(
name="cleanup_probe",
description="controlled tool lifecycle probe",
parameters={"type": "object", "properties": {}},
handler=handler,
)
runner = ToolLoopAgentRunner()
runner._abort_signal = asyncio.Event()
executor = None
if layer == "handler_wrapper":
results = call_local_llm_tool(run_context, handler, "decorator_handler")
elif layer == "local":
results = FunctionToolExecutor._execute_local(tool, run_context)
else:
executor = FunctionToolExecutor.execute(tool, run_context)
results = (
runner._iter_tool_executor_results(executor)
if layer == "runner"
else executor
)
try:
first = await anext(results)
assert (
first if layer == "handler_wrapper" else first.content[0].text
) == "first"
await asyncio.sleep(0)
assert not advanced and not cleanup
if exit_mode == "exhaust":
second = await anext(results)
assert (
second if layer == "handler_wrapper" else second.content[0].text
) == "second"
with pytest.raises(StopAsyncIteration):
await anext(results)
elif exit_mode == "error":
with pytest.raises(ValueError) as caught:
await anext(results)
assert caught.value is error
await results.aclose()
assert cleanup == ["closed"]
assert all(owner is owners[0] for owner in owners)
finally:
await results.aclose()
if executor is not None:
await executor.aclose()
await handler_generator.aclose()
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["coroutine", "empty", "none"])
async def test_local_result_compatibility(kind):
sent = []
async def coroutine(event):
return "coroutine result"
async def generator(event):
if kind == "none":
yield None
event = SimpleNamespace(get_result=lambda: None)
run_context = ContextWrapper(context=SimpleNamespace(event=event))
tool = FunctionTool(
name="compatibility_probe",
description="controlled compatibility probe",
parameters={"type": "object", "properties": {}},
handler=coroutine if kind == "coroutine" else generator,
)
async for result in FunctionToolExecutor.execute(tool, run_context):
sent.append(result)
if kind == "coroutine":
assert len(sent) == 1 and sent[0].content[0].text == "coroutine result"
else:
assert sent == [None]
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["stop_yielded", "stop_pending", "cancel", "timeout"])
async def test_real_handler_stop_cancel_timeout_wait_for_cleanup(mode):
tasks = set(asyncio.all_tasks())
started = asyncio.Event()
closed = []
async def handler(event):
try:
if mode == "stop_yielded":
yield "first"
started.set()
await asyncio.Future()
yield "unreachable"
finally:
await asyncio.sleep(0)
closed.append("closed")
run_context = ContextWrapper(context=SimpleNamespace(event=SimpleNamespace()))
tool = FunctionTool(
name="blocked_probe",
description="controlled blocking tool",
parameters={"type": "object", "properties": {}},
handler=handler,
)
runner = ToolLoopAgentRunner()
runner._abort_signal = asyncio.Event()
executor = (
FunctionToolExecutor._execute_local(tool, run_context, tool_call_timeout=0.01)
if mode == "timeout"
else FunctionToolExecutor.execute(tool, run_context)
)
results = (
executor if mode == "timeout" else runner._iter_tool_executor_results(executor)
)
pending = None
try:
if mode == "stop_yielded":
assert (await anext(results)).content[0].text == "first"
runner.request_stop()
with pytest.raises(_ToolExecutionInterrupted):
await anext(results)
else:
pending = asyncio.create_task(anext(results))
await asyncio.wait_for(started.wait(), 1)
if mode == "cancel":
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(pending, 1)
elif mode == "timeout":
with pytest.raises(Exception, match="execution timeout"):
await asyncio.wait_for(pending, 1)
else:
runner.request_stop()
with pytest.raises(_ToolExecutionInterrupted):
await asyncio.wait_for(pending, 1)
assert closed == ["closed"]
finally:
if pending is not None:
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await results.aclose()
await executor.aclose()
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
async def test_runner_repeated_cancel_waits_for_real_cleanup():
tasks = set(asyncio.all_tasks())
started = asyncio.Event()
cleanup_started = asyncio.Event()
release = asyncio.Event()
closed = []
async def handler(event):
try:
started.set()
await asyncio.Future()
yield "unreachable"
finally:
cleanup_started.set()
await release.wait()
closed.append("closed")
context = ContextWrapper(context=SimpleNamespace(event=SimpleNamespace()))
tool = FunctionTool(
name="repeat_cancel_probe",
description="controlled repeated cancellation",
parameters={"type": "object", "properties": {}},
handler=handler,
)
runner = ToolLoopAgentRunner()
runner._abort_signal = asyncio.Event()
results = runner._iter_tool_executor_results(
FunctionToolExecutor.execute(tool, context)
)
pending = asyncio.create_task(anext(results))
try:
await asyncio.wait_for(started.wait(), 1)
pending.cancel()
await asyncio.wait_for(cleanup_started.wait(), 1)
pending.cancel()
await asyncio.sleep(0)
release.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(pending, 1)
assert closed == ["closed"]
finally:
release.set()
await asyncio.gather(pending, return_exceptions=True)
await results.aclose()
assert not (set(asyncio.all_tasks()) - tasks)
@pytest.mark.asyncio
async def test_runner_reports_real_handler_cleanup_error():
error = OSError("controlled tool cleanup failure")
async def handler(event):
try:
yield "first"
finally:
raise error
context = ContextWrapper(context=SimpleNamespace(event=SimpleNamespace()))
tool = FunctionTool(
name="cleanup_error_probe",
description="controlled cleanup failure",
parameters={"type": "object", "properties": {}},
handler=handler,
)
runner = ToolLoopAgentRunner()
runner._abort_signal = asyncio.Event()
results = runner._iter_tool_executor_results(
FunctionToolExecutor.execute(tool, context)
)
assert (await anext(results)).content[0].text == "first"
with pytest.raises(OSError) as caught:
await results.aclose()
assert caught.value is error
@pytest.mark.asyncio
async def test_background_real_generator_cancel_closes_before_return(monkeypatch):
started = asyncio.Event()
closed = []
wakeups = []
async def handler(event):
try:
yield "partial"
started.set()
await asyncio.Future()
finally:
await asyncio.sleep(0)
closed.append("closed")
async def wake(**kwargs):
wakeups.append(kwargs)
monkeypatch.setattr(
FunctionToolExecutor, "_wake_main_agent_for_background_result", wake
)
context = ContextWrapper(context=SimpleNamespace(event=SimpleNamespace()))
tool = FunctionTool(
name="background_cleanup_probe",
description="controlled background cleanup",
parameters={"type": "object", "properties": {}},
handler=handler,
)
pending = asyncio.create_task(
FunctionToolExecutor._execute_background(tool, context, "task")
)
try:
await asyncio.wait_for(started.wait(), 1)
pending.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(pending, 1)
assert closed == ["closed"]
assert wakeups == []
finally:
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)