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
401 lines
13 KiB
Python
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)
|