Files
EvoScientist-Multi/tests/test_subagent_timeout.py
T
m4 5d893c1dc6
Docker / build (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
[verified] fix: close 0.3.0 release security gaps
2026-09-03 10:42:46 +08:00

252 lines
8.1 KiB
Python

from __future__ import annotations
import asyncio
import math
import time
from unittest.mock import MagicMock
import pytest
from langchain_core.messages import ToolMessage
from EvoScientist.middleware.subagent_timeout import SubagentTimeoutMiddleware
def _request(name: str = "task"):
request = MagicMock()
request.tool_call = {"id": "call-1", "name": name, "args": {}}
return request
@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf])
@pytest.mark.parametrize("field", ["timeout_seconds", "cancellation_grace_seconds"])
def test_subagent_timeout_rejects_non_finite_bounds(field, value):
kwargs = {field: value}
with pytest.raises(ValueError, match="finite and positive"):
SubagentTimeoutMiddleware(**kwargs)
@pytest.mark.anyio
async def test_subagent_timeout_returns_stable_tool_error():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
async def handler(_request):
await asyncio.sleep(1)
return ToolMessage(content="late", tool_call_id="call-1", name="task")
result = await middleware.awrap_tool_call(_request(), handler)
assert isinstance(result, ToolMessage)
assert result.status == "error"
assert result.name == "task"
assert result.additional_kwargs["error_code"] == "SUBAGENT_TIMEOUT"
assert "SUBAGENT_TIMEOUT" in result.content
@pytest.mark.anyio
async def test_subagent_timeout_passes_success_through():
middleware = SubagentTimeoutMiddleware(timeout_seconds=1)
expected = ToolMessage(content="done", tool_call_id="call-1", name="task")
async def handler(_request):
return expected
assert await middleware.awrap_tool_call(_request(), handler) is expected
@pytest.mark.anyio
async def test_subagent_timeout_does_not_bound_other_tools():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
expected = ToolMessage(content="done", tool_call_id="call-1", name="read_file")
async def handler(_request):
await asyncio.sleep(0.02)
return expected
assert await middleware.awrap_tool_call(_request("read_file"), handler) is expected
@pytest.mark.anyio
async def test_subagent_internal_timeout_error_is_not_reclassified():
middleware = SubagentTimeoutMiddleware(timeout_seconds=1)
async def handler(_request):
raise TimeoutError("provider timed out immediately")
with pytest.raises(TimeoutError, match="provider timed out immediately"):
await middleware.awrap_tool_call(_request(), handler)
@pytest.mark.anyio
async def test_parent_cancellation_cancels_subagent_handler():
middleware = SubagentTimeoutMiddleware(timeout_seconds=10)
handler_cancelled = asyncio.Event()
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
finally:
handler_cancelled.set()
return ToolMessage(content="done", tool_call_id="call-1", name="task")
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
await asyncio.sleep(0)
invocation.cancel()
with pytest.raises(asyncio.CancelledError):
await invocation
assert handler_cancelled.is_set()
@pytest.mark.anyio
async def test_parent_cancellation_wins_over_handler_cleanup_error():
middleware = SubagentTimeoutMiddleware(timeout_seconds=10)
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
except asyncio.CancelledError as exc:
raise RuntimeError("cleanup failed") from exc
return ToolMessage(content="done", tool_call_id="call-1", name="task")
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
await asyncio.sleep(0)
invocation.cancel()
with pytest.raises(asyncio.CancelledError):
await invocation
@pytest.mark.anyio
async def test_deadline_wins_over_handler_cleanup_error():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
except asyncio.CancelledError as exc:
raise RuntimeError("cleanup failed") from exc
return ToolMessage(content="done", tool_call_id="call-1", name="task")
result = await middleware.awrap_tool_call(_request(), handler)
assert isinstance(result, ToolMessage)
assert result.additional_kwargs["error_code"] == "SUBAGENT_TIMEOUT"
@pytest.mark.anyio
async def test_parent_cancellation_during_deadline_cleanup_is_not_swallowed():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
cleanup_started = asyncio.Event()
release_cleanup = asyncio.Event()
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cleanup_started.set()
await release_cleanup.wait()
raise
return ToolMessage(content="done", tool_call_id="call-1", name="task")
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
await cleanup_started.wait()
invocation.cancel()
release_cleanup.set()
with pytest.raises(asyncio.CancelledError):
await invocation
@pytest.mark.anyio
async def test_parent_cancellation_after_cleanup_before_timeout_return_wins(monkeypatch):
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
async def handler(_request) -> ToolMessage:
await asyncio.Event().wait()
return ToolMessage(content="done", tool_call_id="call-1", name="task")
async def finish_cleanup_then_cancel_parent(_task):
current = asyncio.current_task()
assert current is not None
current.cancel()
monkeypatch.setattr(middleware, "_cancel_task", finish_cleanup_then_cancel_parent)
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
with pytest.raises(asyncio.CancelledError):
await invocation
@pytest.mark.anyio
async def test_deadline_detaches_handler_that_ignores_cancellation():
middleware = SubagentTimeoutMiddleware(
timeout_seconds=0.01, cancellation_grace_seconds=0.01
)
cleanup_started = asyncio.Event()
release_cleanup = asyncio.Event()
handler_done = asyncio.Event()
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cleanup_started.set()
await release_cleanup.wait()
finally:
handler_done.set()
return ToolMessage(content="late", tool_call_id="call-1", name="task")
started = time.monotonic()
result = await middleware.awrap_tool_call(_request(), handler)
elapsed = time.monotonic() - started
assert cleanup_started.is_set()
assert elapsed < 0.1
assert result.additional_kwargs["error_code"] == "SUBAGENT_TIMEOUT"
release_cleanup.set()
await asyncio.wait_for(handler_done.wait(), timeout=0.2)
@pytest.mark.anyio
async def test_parent_cancel_detaches_handler_that_ignores_cancellation():
middleware = SubagentTimeoutMiddleware(
timeout_seconds=10, cancellation_grace_seconds=0.01
)
handler_started = asyncio.Event()
cleanup_started = asyncio.Event()
release_cleanup = asyncio.Event()
handler_done = asyncio.Event()
async def handler(_request) -> ToolMessage:
handler_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cleanup_started.set()
await release_cleanup.wait()
finally:
handler_done.set()
return ToolMessage(content="late", tool_call_id="call-1", name="task")
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
await handler_started.wait()
started = time.monotonic()
invocation.cancel()
with pytest.raises(asyncio.CancelledError):
await invocation
elapsed = time.monotonic() - started
assert cleanup_started.is_set()
assert elapsed < 0.1
release_cleanup.set()
await asyncio.wait_for(handler_done.wait(), timeout=0.2)