Files
agentscope-ai--agentscope/tests/tool_offload_middleware_test.py
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Waiting to run
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Python Unittest Coverage / test (macos-15, 3.11) (push) Waiting to run
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Waiting to run
Python Unittest Coverage / test (windows-latest, 3.11) (push) Waiting to run
Web UI / check (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

401 lines
13 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=protected-access
"""Unit tests for ToolOffloadMiddleware."""
import asyncio
import json
from typing import Any
from unittest.async_case import IsolatedAsyncioTestCase
from unittest.mock import MagicMock
from pydantic import BaseModel
from utils import AnyString, MockModel
from agentscope.agent import Agent
from agentscope.app.message_bus import MessageBus, MessageBusKeys
from agentscope.app.middleware import ToolOffloadMiddleware
from agentscope.app._manager import BackgroundTaskManager
from agentscope.message import TextBlock, ToolCallBlock
from agentscope.permission import (
PermissionContext,
PermissionDecision,
PermissionBehavior,
)
from agentscope.tool import ToolBase, ToolChunk, Toolkit, ToolResponse
class _SlowToolParams(BaseModel):
"""Parameters for the slow test tool."""
delay: float
class SlowTool(ToolBase):
"""A tool that sleeps for ``delay`` seconds before returning."""
name: str = "slow_tool"
description: str = "A slow tool for testing background offload."
input_schema: dict = _SlowToolParams.model_json_schema()
is_concurrency_safe: bool = True
is_read_only: bool = True
is_state_injected: bool = False
is_external_tool: bool = False
is_mcp: bool = False
mcp_name: str | None = None
async def check_permissions(
self,
tool_input: dict[str, Any],
context: PermissionContext,
) -> PermissionDecision:
"""Always allow.
Args:
tool_input (`dict[str, Any]`):
The tool input parameters.
context (`PermissionContext`):
The permission context.
Returns:
`PermissionDecision`:
Always ALLOW.
"""
return PermissionDecision(
behavior=PermissionBehavior.ALLOW,
message="allowed",
)
async def __call__( # type: ignore[override]
self,
delay: float,
) -> ToolChunk:
"""Sleep for *delay* seconds then return a result.
Args:
delay (`float`):
Seconds to sleep.
Returns:
`ToolChunk`:
A chunk containing the result text.
"""
await asyncio.sleep(delay)
return ToolChunk(
content=[TextBlock(text=f"SlowTool finished after {delay}s")],
)
class _FastToolParams(BaseModel):
"""Parameters for the fast test tool."""
value: str
class FastTool(ToolBase):
"""A tool that returns immediately."""
name: str = "fast_tool"
description: str = "A fast tool for testing normal execution."
input_schema: dict = _FastToolParams.model_json_schema()
is_concurrency_safe: bool = True
is_read_only: bool = True
is_state_injected: bool = False
is_external_tool: bool = False
is_mcp: bool = False
mcp_name: str | None = None
async def check_permissions(
self,
tool_input: dict[str, Any],
context: PermissionContext,
) -> PermissionDecision:
"""Always allow.
Args:
tool_input (`dict[str, Any]`):
The tool input parameters.
context (`PermissionContext`):
The permission context.
Returns:
`PermissionDecision`:
Always ALLOW.
"""
return PermissionDecision(
behavior=PermissionBehavior.ALLOW,
message="allowed",
)
async def __call__( # type: ignore[override]
self,
value: str,
) -> ToolChunk:
"""Return a chunk with *value*.
Args:
value (`str`):
The value to echo.
Returns:
`ToolChunk`:
A chunk containing the value.
"""
return ToolChunk(
content=[TextBlock(text=f"FastTool: {value}")],
)
class ToolOffloadMiddlewareTest(IsolatedAsyncioTestCase):
"""Test cases for the ToolOffloadMiddleware."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.mock_model = MockModel()
self.bg_manager = BackgroundTaskManager(
message_bus=MagicMock(spec=MessageBus),
)
# ------------------------------------------------------------------
# Helper
# ------------------------------------------------------------------
def _make_agent(
self,
toolkit: Toolkit,
timeout_secs: float,
) -> tuple[Agent, ToolOffloadMiddleware]:
"""Create an agent with ToolOffloadMiddleware attached.
Args:
toolkit (`Toolkit`):
The toolkit to attach to the agent.
timeout_secs (`float`):
The middleware timeout.
Returns:
`tuple[Agent, ToolOffloadMiddleware]`:
The configured agent and the middleware instance.
"""
middleware = ToolOffloadMiddleware(
bg_manager=self.bg_manager,
message_bus=MagicMock(spec=MessageBus),
user_id="u",
agent_id="a",
timeout_secs=timeout_secs,
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=toolkit,
middlewares=[middleware],
)
return agent, middleware
# ------------------------------------------------------------------
# Tests
# ------------------------------------------------------------------
async def test_fast_tool_completes_normally(self) -> None:
"""A tool that finishes within the timeout yields its real result."""
toolkit = Toolkit(tools=[FastTool()])
agent, _ = self._make_agent(toolkit, timeout_secs=5.0)
tool_call = ToolCallBlock(
id="call_fast",
name="fast_tool",
input=json.dumps({"value": "hello"}),
)
results: list = []
# pylint: disable=protected-access
async for item in agent._acting(tool_call):
results.append(item)
# Should yield real ToolResponse (not synthetic)
responses = [r for r in results if isinstance(r, ToolResponse)]
self.assertEqual(len(responses), 1)
text = responses[0].content[0].text # type: ignore[union-attr]
self.assertIn("FastTool: hello", text)
# No background tasks registered
self.assertEqual(len(self.bg_manager.tasks), 0)
async def test_slow_tool_offloaded_to_background(self) -> None:
"""A tool that exceeds timeout returns a synthetic result."""
toolkit = Toolkit(tools=[SlowTool()])
# Set a very short timeout so the 0.5s tool is always offloaded
agent, _ = self._make_agent(toolkit, timeout_secs=0.05)
tool_call = ToolCallBlock(
id="call_slow",
name="slow_tool",
input=json.dumps({"delay": 0.5}),
)
results: list = []
# pylint: disable=protected-access
async for item in agent._acting(tool_call):
results.append(item)
# Should yield a synthetic ToolResponse immediately
responses = [r for r in results if isinstance(r, ToolResponse)]
self.assertEqual(len(responses), 1)
self.assertDictEqual(
responses[0].model_dump(),
{
"content": [
{
"type": "text",
"text": AnyString(),
"id": AnyString(),
},
],
"state": "success",
"metadata": {},
"id": "call_slow",
},
)
text = responses[0].content[0].text # type: ignore[union-attr]
self.assertIn("background", text)
self.assertIn("id=", text)
# Background task should be registered
self.assertEqual(len(self.bg_manager.tasks), 1)
async def test_background_task_result_injected_into_context(
self,
) -> None:
"""After the background tool finishes, the result is pushed to the
session inbox on the message bus as a serialised HintBlock."""
toolkit = Toolkit(tools=[SlowTool()])
agent, middleware = self._make_agent(toolkit, timeout_secs=0.05)
tool_call = ToolCallBlock(
id="call_bg",
name="slow_tool",
input=json.dumps({"delay": 0.2}),
)
# Trigger offload
# pylint: disable=protected-access
async for _ in agent._acting(tool_call):
pass
# Wait long enough for the background tool (0.2s) to finish
await asyncio.sleep(0.4)
# The completed result should now have been pushed to the message bus
# inbox as a model-dumped HintBlock.
mock_bus = middleware._message_bus
# The middleware uses queue_push(inbox_key, payload) rather
# than the deprecated inbox_push(session_id, payload).
inbox_calls = [
c
for c in mock_bus.queue_push.call_args_list
if c.args[0] == MessageBusKeys.inbox(agent.state.session_id)
]
self.assertEqual(len(inbox_calls), 1)
_, hint_dict = inbox_calls[0].args
session_id_called = agent.state.session_id
self.assertEqual(session_id_called, agent.state.session_id)
self.maxDiff = None
self.assertDictEqual(
hint_dict,
{
"type": "hint",
"id": AnyString(),
"source": '{"label": "tool_output", "sublabel": "slow_tool · '
'call_bg"}',
"hint": [
{
"type": "text",
"text": AnyString(),
"id": AnyString(),
},
],
},
)
hint_text = hint_dict["hint"][0]["text"]
self.assertIn("SlowTool finished", hint_text)
self.assertIn("<system-notification>", hint_text)
async def test_background_task_triggers_wakeup_on_completion(
self,
) -> None:
"""After a background tool finishes, a wakeup is enqueued on the
message bus so that an idle session can be restarted automatically."""
toolkit = Toolkit(tools=[SlowTool()])
agent, middleware = self._make_agent(toolkit, timeout_secs=0.05)
tool_call = ToolCallBlock(
id="call_wakeup",
name="slow_tool",
input=json.dumps({"delay": 0.2}),
)
# Trigger offload
# pylint: disable=protected-access
async for _ in agent._acting(tool_call):
pass
# Wait long enough for the background tool (0.2s) to finish
await asyncio.sleep(0.4)
# enqueue_wakeup must be called exactly once with the correct ids so
# WakeupDispatcher can re-invoke ChatService.run for this session.
mock_bus = middleware._message_bus
# enqueue_run_trigger is a standalone function that calls
# bus.queue_push + bus.publish under the hood.
wakeup_calls = [
c
for c in mock_bus.queue_push.call_args_list
if c.args[0] == MessageBusKeys.wakeup_queue()
]
self.assertEqual(len(wakeup_calls), 1)
payload = wakeup_calls[0].args[1]
self.assertEqual(payload["user_id"], "u")
self.assertEqual(payload["session_id"], agent.state.session_id)
self.assertEqual(payload["agent_id"], "a")
async def test_tool_stop_cancels_background_task(self) -> None:
"""ToolStop tool cancels the running background asyncio task."""
toolkit = Toolkit(tools=[SlowTool()])
agent, _ = self._make_agent(toolkit, timeout_secs=0.05)
tool_call = ToolCallBlock(
id="call_cancel",
name="slow_tool",
input=json.dumps({"delay": 10.0}),
)
# Offload the slow tool
# pylint: disable=protected-access
async for _ in agent._acting(tool_call):
pass
self.assertEqual(len(self.bg_manager.tasks), 1)
task_id = next(iter(self.bg_manager.tasks))
bg_task = self.bg_manager.tasks[task_id]
asyncio_task = bg_task.asyncio_task
# Call ToolStop bound to the same session as the registered
# background task, so the local cancel path matches.
tool_stop_tools = await self.bg_manager.list_tools(
session_id=bg_task.session_id,
)
tool_stop = tool_stop_tools[0]
result = await tool_stop(task_id=task_id)
text = result.content[0].text # type: ignore[union-attr]
self.assertIn("stopped successfully", text)
# The asyncio task should be cancelling
self.assertTrue(asyncio_task.cancelled() or asyncio_task.cancelling())
# Removed from manager
self.assertEqual(len(self.bg_manager.tasks), 0)