Files
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

1296 lines
44 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=abstract-method
"""Unit tests for middleware system."""
from unittest.async_case import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, patch
from typing import Any, AsyncGenerator, Callable, Union
from utils import MockModel
from pydantic import BaseModel
from agentscope.event import AgentEvent
from agentscope.agent import Agent, ContextConfig
from agentscope.middleware import MiddlewareBase
from agentscope.model import ChatResponse
from agentscope.message import (
TextBlock,
HintBlock,
UserMsg,
SystemMsg,
Msg,
ToolCallBlock,
)
from agentscope.tool import Toolkit, ToolBase, ToolChunk
from agentscope.permission import (
PermissionContext,
PermissionDecision,
PermissionBehavior,
)
class TestMiddleware(IsolatedAsyncioTestCase):
"""Test cases for middleware system."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.mock_model = MockModel()
self.toolkit = Toolkit()
self.execution_log = []
async def test_on_reply_middleware_pre_post_yield(self) -> None:
"""Test on_reply middleware pre, post and yield positions."""
class ReplyMiddleware(MiddlewareBase):
"""Middleware for testing on_reply hook."""
def __init__(self, log: list, name: str) -> None:
"""Initialize the reply middleware.
Args:
log: The execution log list.
name: The middleware name.
"""
self.log = log
self.name = name
async def on_reply(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[[], AsyncGenerator],
) -> AsyncGenerator:
"""The on_reply middleware logic."""
self.log.append(f"{self.name}_pre")
async for item in next_handler():
if isinstance(item, AgentEvent):
self.log.append(f"{self.name}_{item.type}")
elif isinstance(item, Msg):
self.log.append(f"{self.name}_msg")
yield item
self.log.append(f"{self.name}_post")
middleware1 = ReplyMiddleware(self.execution_log, "mw1")
middleware2 = ReplyMiddleware(self.execution_log, "mw2")
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware1, middleware2],
)
await agent.reply(UserMsg("user", "test message"))
# Verify execution order
expected = [
"mw1_pre",
"mw2_pre",
"mw2_REPLY_START",
"mw1_REPLY_START",
"mw2_MODEL_CALL_START",
"mw1_MODEL_CALL_START",
"mw2_TEXT_BLOCK_START",
"mw1_TEXT_BLOCK_START",
"mw2_TEXT_BLOCK_DELTA",
"mw1_TEXT_BLOCK_DELTA",
"mw2_TEXT_BLOCK_END",
"mw1_TEXT_BLOCK_END",
"mw2_MODEL_CALL_END",
"mw1_MODEL_CALL_END",
"mw2_msg",
"mw1_msg",
"mw2_REPLY_END",
"mw1_REPLY_END",
"mw2_post",
"mw1_post",
]
self.assertListEqual(self.execution_log, expected)
async def test_on_reasoning_middleware_pre_yield(self) -> None:
"""Test on_reasoning middleware pre and yield positions."""
class ReasoningMiddleware(MiddlewareBase):
"""Middleware for testing on_reasoning hook."""
def __init__(self, log: list, name: str) -> None:
"""Initialize the reasoning middleware.
Args:
log: The execution log list.
name: The middleware name.
"""
self.log = log
self.name = name
async def on_reasoning(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[[], AsyncGenerator],
) -> AsyncGenerator:
"""The on_reasoning middleware logic."""
self.log.append(f"{self.name}_pre")
async for item in next_handler():
if isinstance(item, AgentEvent):
self.log.append(f"{self.name}_{item.type}")
elif isinstance(item, Msg):
self.log.append(f"{self.name}_msg")
yield item
self.log.append(f"{self.name}_post")
middleware1 = ReasoningMiddleware(self.execution_log, "mw1")
middleware2 = ReasoningMiddleware(self.execution_log, "mw2")
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware1, middleware2],
)
await agent.reply(UserMsg("user", "test message"))
# Verify execution order
expected = [
"mw1_pre",
"mw2_pre",
"mw2_MODEL_CALL_START",
"mw1_MODEL_CALL_START",
"mw2_TEXT_BLOCK_START",
"mw1_TEXT_BLOCK_START",
"mw2_TEXT_BLOCK_DELTA",
"mw1_TEXT_BLOCK_DELTA",
"mw2_TEXT_BLOCK_END",
"mw1_TEXT_BLOCK_END",
"mw2_MODEL_CALL_END",
"mw1_MODEL_CALL_END",
"mw2_msg",
"mw1_msg",
]
self.assertListEqual(self.execution_log, expected)
async def test_on_model_call_middleware_non_streaming(self) -> None:
"""Test on_model_call middleware for non-streaming model."""
class ModelCallMiddleware(MiddlewareBase):
"""Middleware for testing on_model_call hook."""
def __init__(self, log: list, name: str) -> None:
"""Initialize the model call middleware.
Args:
log: The execution log list.
name: The middleware name.
"""
self.log = log
self.name = name
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable,
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
"""The on_model_call middleware logic."""
self.log.append(f"{self.name}_pre")
result = await next_handler()
self.log.append(f"{self.name}_post")
return result
middleware1 = ModelCallMiddleware(self.execution_log, "mw1")
middleware2 = ModelCallMiddleware(self.execution_log, "mw2")
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware1, middleware2],
)
await agent.reply(UserMsg("user", "test message"))
# Verify execution order: mw1_pre -> mw2_pre -> mw2_post -> mw1_post
expected = ["mw1_pre", "mw2_pre", "mw2_post", "mw1_post"]
self.assertListEqual(self.execution_log, expected)
async def test_on_model_call_middleware_streaming(self) -> None:
"""Test on_model_call middleware for streaming model."""
class ModelCallMiddleware(MiddlewareBase):
"""Middleware for testing on_model_call hook with streaming."""
def __init__(self, log: list, name: str) -> None:
"""Initialize the model call middleware.
Args:
log: The execution log list.
name: The middleware name.
"""
self.log = log
self.name = name
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable,
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
"""The on_model_call middleware logic for streaming."""
self.log.append(f"{self.name}_pre")
result = await next_handler()
async def wrapped_generator() -> AsyncGenerator[
ChatResponse,
None,
]:
"""Wrap the generator to log yields."""
async for chunk in result:
self.log.append(f"{self.name}_chunk")
yield chunk
self.log.append(f"{self.name}_post")
return wrapped_generator()
middleware1 = ModelCallMiddleware(self.execution_log, "mw1")
middleware2 = ModelCallMiddleware(self.execution_log, "mw2")
self.mock_model.set_responses(
[
[
ChatResponse(
content=[TextBlock(text="chunk1")],
is_last=False,
),
ChatResponse(
content=[TextBlock(text="chunk2")],
is_last=False,
),
ChatResponse(
content=[TextBlock(text="chunk3")],
is_last=True,
),
],
],
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware1, middleware2],
)
await agent.reply(UserMsg("user", "test message"))
# Verify execution order
expected = [
"mw1_pre",
"mw2_pre",
"mw2_chunk",
"mw1_chunk",
"mw2_chunk",
"mw1_chunk",
"mw2_chunk",
"mw1_chunk",
"mw2_post",
"mw1_post",
]
self.assertListEqual(self.execution_log, expected)
async def test_on_system_prompt_middleware(self) -> None:
"""Test on_system_prompt middleware (transformer pattern)."""
class SystemPromptMiddleware(MiddlewareBase):
"""Middleware for testing on_system_prompt hook."""
def __init__(self, log: list, name: str, suffix: str) -> None:
"""Initialize the system prompt middleware.
Args:
log: The execution log list.
name: The middleware name.
suffix: The suffix to append to the prompt.
"""
self.log = log
self.name = name
self.suffix = suffix
async def on_system_prompt(
self,
agent: Agent,
current_prompt: str,
) -> str:
"""The on_system_prompt middleware logic."""
self.log.append(f"{self.name}_executed")
return f"{current_prompt} {self.suffix}"
middleware1 = SystemPromptMiddleware(
self.execution_log,
"mw1",
"[MW1]",
)
middleware2 = SystemPromptMiddleware(
self.execution_log,
"mw2",
"[MW2]",
)
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware1, middleware2],
)
await agent.reply(UserMsg("user", "test message"))
# Verify execution order: mw1 -> mw2 (sequential transformer pattern)
# Note: system_prompt is called twice (once for initial setup, once
# during reasoning)
expected = [
"mw1_executed",
"mw2_executed", # First call
"mw1_executed",
"mw2_executed", # Second call during reasoning
]
self.assertListEqual(self.execution_log, expected)
async def test_multiple_middleware_types(self) -> None:
"""Test multiple middleware types working together."""
class MultiMiddleware(MiddlewareBase):
"""Middleware implementing multiple hooks."""
def __init__(self, log: list, name: str) -> None:
"""Initialize the multi middleware.
Args:
log: The execution log list.
name: The middleware name.
"""
self.log = log
self.name = name
async def on_reply(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[[], AsyncGenerator],
) -> AsyncGenerator:
"""The on_reply middleware logic."""
self.log.append(f"{self.name}_reply_pre")
async for item in next_handler():
yield item
self.log.append(f"{self.name}_reply_post")
async def on_reasoning(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[[], AsyncGenerator],
) -> AsyncGenerator:
"""The on_reasoning middleware logic."""
self.log.append(f"{self.name}_reasoning_pre")
async for item in next_handler():
yield item
self.log.append(f"{self.name}_reasoning_post")
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable,
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
"""The on_model_call middleware logic."""
self.log.append(f"{self.name}_model_call_pre")
result = await next_handler()
self.log.append(f"{self.name}_model_call_post")
return result
async def on_system_prompt(
self,
agent: Agent,
current_prompt: str,
) -> str:
"""The on_system_prompt middleware logic."""
self.log.append(f"{self.name}_system_prompt")
return current_prompt
middleware = MultiMiddleware(self.execution_log, "multi")
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware],
)
await agent.reply(UserMsg("user", "test message"))
# Verify all middleware hooks were called
expected = [
"multi_reply_pre",
"multi_system_prompt",
"multi_reasoning_pre",
"multi_system_prompt",
"multi_model_call_pre",
"multi_model_call_post",
"multi_reply_post",
]
self.assertListEqual(self.execution_log, expected)
async def test_on_reply_middleware_modify_input(self) -> None:
"""Test that on_reply middleware can modify msgs input."""
class ModifyMsgsMiddleware(MiddlewareBase):
"""Middleware that modifies the msgs input."""
async def on_reply(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., AsyncGenerator],
) -> AsyncGenerator:
"""Modify inputs before passing to next handler."""
# Modify the message content
inputs = input_kwargs["inputs"]
if isinstance(inputs, Msg):
modified_msg = UserMsg(
name=inputs.name,
content="MODIFIED: " + inputs.get_text_content(),
)
async for item in next_handler(inputs=modified_msg):
yield item
else:
async for item in next_handler(**input_kwargs):
yield item
middleware = ModifyMsgsMiddleware()
# Track what message the model receives
received_messages = []
class TrackingModel(MockModel):
"""Model that tracks received messages."""
async def _call_api(
self,
*args: Any,
**kwargs: Any,
) -> ChatResponse:
"""Track the messages and return mock response."""
messages = kwargs.get("messages", [])
received_messages.extend(messages)
return await super()._call_api(*args, **kwargs)
tracking_model = TrackingModel()
tracking_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="response")],
is_last=True,
),
],
)
agent_instance = Agent(
name="test_agent",
system_prompt="test prompt",
model=tracking_model,
toolkit=self.toolkit,
middlewares=[middleware],
)
await agent_instance.reply(UserMsg("user", "original message"))
# Verify the model received the modified message
user_messages = [m for m in received_messages if m.role == "user"]
self.assertTrue(len(user_messages) > 0)
self.assertIn(
"MODIFIED: original message",
user_messages[-1].get_text_content(),
)
async def test_on_reply_keeps_outer_input_when_inner_omits_kwargs(
self,
) -> None:
"""Argumentless next_handler() keeps outer reply input changes."""
class ModifyInputMiddleware(MiddlewareBase):
"""Middleware that replaces the reply input."""
async def on_reply(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., AsyncGenerator],
) -> AsyncGenerator:
inputs = input_kwargs["inputs"]
modified = UserMsg(
name=inputs.name,
content="MODIFIED by outer middleware",
)
async for item in next_handler(inputs=modified):
yield item
class TransparentMiddleware(MiddlewareBase):
"""Middleware that calls next_handler() without passing kwargs."""
async def on_reply(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., AsyncGenerator],
) -> AsyncGenerator:
assert (
"MODIFIED by outer middleware"
in input_kwargs["inputs"].get_text_content()
)
async for item in next_handler():
yield item
received_messages = []
class TrackingModel(MockModel):
"""Model that tracks received messages."""
async def _call_api(
self,
*args: Any,
**kwargs: Any,
) -> ChatResponse:
received_messages.extend(kwargs.get("messages", []))
return await super()._call_api(*args, **kwargs)
tracking_model = TrackingModel()
tracking_model.set_responses(
[ChatResponse(content=[TextBlock(text="response")], is_last=True)],
)
agent_instance = Agent(
name="test_agent",
system_prompt="test prompt",
model=tracking_model,
toolkit=self.toolkit,
middlewares=[
ModifyInputMiddleware(),
TransparentMiddleware(),
],
)
await agent_instance.reply(UserMsg("user", "original message"))
user_messages = [m for m in received_messages if m.role == "user"]
self.assertIn(
"MODIFIED by outer middleware",
user_messages[-1].get_text_content(),
)
async def test_on_reasoning_middleware_modify_input(self) -> None:
"""Test that on_reasoning middleware can modify tool_choice input."""
class ModifyToolChoiceMiddleware(MiddlewareBase):
"""Middleware that modifies the tool_choice input."""
async def on_reasoning(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., AsyncGenerator],
) -> AsyncGenerator:
"""Force tool_choice to 'none' to prevent tool calls."""
# Override tool_choice to 'none'
async for item in next_handler(tool_choice="none"):
yield item
middleware = ModifyToolChoiceMiddleware()
# Track what tool_choice the model receives
received_tool_choices = []
class TrackingModel(MockModel):
"""Model that tracks received tool_choice."""
async def _call_api(
self,
*args: Any,
**kwargs: Any,
) -> ChatResponse:
"""Track the tool_choice and return mock response."""
tool_choice = kwargs.get("tool_choice")
received_tool_choices.append(tool_choice)
return await super()._call_api(*args, **kwargs)
tracking_model = TrackingModel()
tracking_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="response without tools")],
is_last=True,
),
],
)
agent_instance = Agent(
name="test_agent",
system_prompt="test prompt",
model=tracking_model,
toolkit=self.toolkit,
middlewares=[middleware],
)
await agent_instance.reply(UserMsg("user", "test message"))
# Verify the model received tool_choice='none'
self.assertIn("none", received_tool_choices)
async def test_on_acting_middleware_intercepts_tool_execution(
self,
) -> None:
"""Test that on_acting middleware intercepts raw tool execution.
After the refactor, ``on_acting`` wraps only ``_acting_impl``
(i.e. ``toolkit.call_tool``). Permission checking and context
writes are handled by ``_execute_tool_call`` *outside* the hook.
This test verifies that the middleware can observe and modify the
``tool_call`` passed to the actual tool function.
"""
# ------------------------------------------------------------------ #
# A minimal tool that records the raw input it receives. #
# ------------------------------------------------------------------ #
received_inputs: list[str] = []
class _EchoParams(BaseModel):
value: str
class EchoTool(ToolBase):
"""Tool that echoes its input and records it."""
name: str = "echo"
description: str = "Echo the value."
input_schema: dict = _EchoParams.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."""
return PermissionDecision(
behavior=PermissionBehavior.ALLOW,
message="allowed",
)
async def __call__(
self,
value: str,
) -> ToolChunk:
"""Record the value and return it."""
received_inputs.append(value)
return ToolChunk(
content=[TextBlock(text=f"echo:{value}")],
)
toolkit_with_tool = Toolkit(tools=[EchoTool()])
# ------------------------------------------------------------------ #
# Middleware that renames the tool_call.input before forwarding. #
# ------------------------------------------------------------------ #
intercepted_tool_calls: list[str] = []
class ObserveActingMiddleware(MiddlewareBase):
"""Middleware that records the tool_call seen at acting level."""
async def on_acting(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., AsyncGenerator],
) -> AsyncGenerator:
"""Record the tool_call and forward to next handler."""
tool_call = input_kwargs["tool_call"]
intercepted_tool_calls.append(tool_call.input)
# Modify the input before execution
import json
modified = ToolCallBlock(
id=tool_call.id,
name=tool_call.name,
input=json.dumps({"value": "MODIFIED"}),
state=tool_call.state,
)
async for chunk in next_handler(tool_call=modified):
yield chunk
middleware = ObserveActingMiddleware()
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="done")],
is_last=True,
),
],
)
agent_instance = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=toolkit_with_tool,
middlewares=[middleware],
)
# Call _execute_tool_call with a valid tool call.
tool_call = ToolCallBlock(
id="call_1",
name="echo",
input='{"value": "ORIGINAL"}',
)
events = []
# pylint: disable=protected-access
async for evt in agent_instance._execute_tool_call(tool_call):
events.append(evt)
# Middleware intercepted the tool call at execution level
self.assertEqual(len(intercepted_tool_calls), 1)
self.assertIn("ORIGINAL", intercepted_tool_calls[0])
# The tool actually received the MODIFIED value
self.assertEqual(len(received_inputs), 1)
self.assertEqual(received_inputs[0], "MODIFIED")
async def test_on_model_call_middleware_modify_input(self) -> None:
"""Test that on_model_call middleware can modify messages and model."""
class ModifyMessagesMiddleware(MiddlewareBase):
"""Middleware that modifies messages input."""
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
"""Prepend a system message to the messages list."""
messages = input_kwargs["messages"]
modified_messages = [
SystemMsg(
name="system",
content="INJECTED SYSTEM MESSAGE",
),
] + messages
# Pass modified messages to next handler
return await next_handler(
current_model=input_kwargs["current_model"],
messages=modified_messages,
tools=input_kwargs["tools"],
tool_choice=input_kwargs["tool_choice"],
)
middleware = ModifyMessagesMiddleware()
# Track what messages the model receives
received_messages = []
class TrackingModel(MockModel):
"""Model that tracks received messages."""
async def _call_api(
self,
*args: Any,
**kwargs: Any,
) -> ChatResponse:
"""Track the messages and return mock response."""
messages = kwargs.get("messages", [])
received_messages.extend(messages)
return await super()._call_api(*args, **kwargs)
tracking_model = TrackingModel()
tracking_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="response")],
is_last=True,
),
],
)
agent_instance = Agent(
name="test_agent",
system_prompt="test prompt",
model=tracking_model,
toolkit=self.toolkit,
middlewares=[middleware],
)
await agent_instance.reply(UserMsg("user", "test message"))
# Verify the injected system message is present
system_messages = [m for m in received_messages if m.role == "system"]
self.assertTrue(
any(
"INJECTED SYSTEM MESSAGE" in m.get_text_content()
for m in system_messages
),
)
async def test_on_model_call_keeps_outer_messages_when_inner_omits_kwargs(
self,
) -> None:
"""Transparent inner model-call middleware keeps outer messages."""
class ModifyMessagesMiddleware(MiddlewareBase):
"""Middleware that changes the messages."""
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
modified_messages = [
SystemMsg(
name="system",
content="INJECTED SYSTEM MESSAGE",
),
] + input_kwargs["messages"]
return await next_handler(messages=modified_messages)
class TransparentMiddleware(MiddlewareBase):
"""Middleware that calls next_handler() without passing kwargs."""
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
system_messages = [
m for m in input_kwargs["messages"] if m.role == "system"
]
assert any(
"INJECTED SYSTEM MESSAGE" in m.get_text_content()
for m in system_messages
)
return await next_handler()
received_messages = []
class TrackingModel(MockModel):
"""Model that tracks received messages."""
async def _call_api(
self,
*args: Any,
**kwargs: Any,
) -> ChatResponse:
received_messages.extend(kwargs.get("messages", []))
return await super()._call_api(*args, **kwargs)
tracking_model = TrackingModel()
tracking_model.set_responses(
[ChatResponse(content=[TextBlock(text="response")], is_last=True)],
)
agent_instance = Agent(
name="test_agent",
system_prompt="test prompt",
model=tracking_model,
toolkit=self.toolkit,
middlewares=[
ModifyMessagesMiddleware(),
TransparentMiddleware(),
],
)
await agent_instance.reply(UserMsg("user", "test message"))
system_messages = [m for m in received_messages if m.role == "system"]
self.assertTrue(
any(
"INJECTED SYSTEM MESSAGE" in m.get_text_content()
for m in system_messages
),
)
async def test_on_model_call_keeps_outer_messages_with_partial_kwargs(
self,
) -> None:
"""Partial next_handler kwargs keep outer model-call changes."""
injected_text = "INJECTED SYSTEM MESSAGE FROM OUTER MIDDLEWARE"
inner_saw_injected_message = False
class ModifyMessagesMiddleware(MiddlewareBase):
"""Middleware that changes the messages."""
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
modified_messages = [
SystemMsg(
name="system",
content=injected_text,
),
] + input_kwargs["messages"]
return await next_handler(messages=modified_messages)
class PartialForwardMiddleware(MiddlewareBase):
"""Middleware that forwards only one of the current kwargs."""
async def on_model_call(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> Union[ChatResponse, AsyncGenerator[ChatResponse, None]]:
nonlocal inner_saw_injected_message
inner_saw_injected_message = any(
injected_text in m.get_text_content()
for m in input_kwargs["messages"]
)
return await next_handler(
tool_choice=input_kwargs["tool_choice"],
)
received_messages = []
class TrackingModel(MockModel):
"""Model that tracks received messages."""
async def _call_api(
self,
*args: Any,
**kwargs: Any,
) -> ChatResponse:
received_messages.extend(kwargs.get("messages", []))
return await super()._call_api(*args, **kwargs)
tracking_model = TrackingModel()
tracking_model.set_responses(
[ChatResponse(content=[TextBlock(text="response")], is_last=True)],
)
agent_instance = Agent(
name="test_agent",
system_prompt="test prompt",
model=tracking_model,
toolkit=self.toolkit,
middlewares=[
ModifyMessagesMiddleware(),
PartialForwardMiddleware(),
],
)
await agent_instance.reply(UserMsg("user", "test message"))
self.assertTrue(inner_saw_injected_message)
self.assertTrue(
any(
injected_text in m.get_text_content()
for m in received_messages
),
)
async def test_on_compress_context_middleware(self) -> None:
"""Test on_compress_context middleware follows the onion chain pattern.
Verifies that:
- Multiple middlewares are chained in onion order (mw1 wraps mw2).
- ``input_kwargs`` carries the correct ``context_config`` and
``instructions``.
- The ``next_handler`` ultimately calls ``_compress_context_impl``.
- A middleware can short-circuit and skip the actual implementation.
"""
seen_instructions = []
# ------------------------------------------------------------------ #
# Middleware that records pre/post and forwards to next_handler. #
# ------------------------------------------------------------------ #
class CompressContextMiddleware(MiddlewareBase):
"""Middleware for testing on_compress_context hook."""
def __init__(self, log: list, name: str) -> None:
"""Initialize the compress context middleware.
Args:
log (`list`):
The execution log list.
name (`str`):
The middleware name.
"""
self.log = log
self.name = name
async def on_compress_context(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> None:
"""Forward to next handler, recording pre and post."""
self.log.append(f"{self.name}_pre")
seen_instructions.append(input_kwargs.get("instructions"))
await next_handler(**input_kwargs)
self.log.append(f"{self.name}_post")
middleware1 = CompressContextMiddleware(self.execution_log, "mw1")
middleware2 = CompressContextMiddleware(self.execution_log, "mw2")
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
context_config = ContextConfig(trigger_ratio=0.8, reserve_ratio=0.1)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware1, middleware2],
context_config=context_config,
)
# Patch _compress_context_impl to avoid real token counting.
instructions = HintBlock(
hint="Keep user requirements while compressing.",
source="user",
)
with patch.object(
agent,
"_compress_context_impl",
new_callable=AsyncMock,
) as mock_impl:
await agent.compress_context(
context_config=context_config,
instructions=instructions,
)
# _compress_context_impl must have been called exactly once.
mock_impl.assert_awaited_once_with(
context_config=context_config,
instructions=instructions,
)
# Verify onion execution order: mw1_pre -> mw2_pre -> mw2_post ->
# mw1_post
expected = ["mw1_pre", "mw2_pre", "mw2_post", "mw1_post"]
self.assertListEqual(self.execution_log, expected)
self.assertListEqual(seen_instructions, [instructions, instructions])
async def test_on_compress_context_middleware_modify_instructions(
self,
) -> None:
"""Test that middleware can replace compress_context instructions."""
class ReplaceInstructionsMiddleware(MiddlewareBase):
"""Middleware that replaces the compression instructions."""
def __init__(self, replacement: HintBlock) -> None:
"""Initialize the middleware with replacement instructions."""
self.replacement = replacement
async def on_compress_context(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> None:
"""Replace instructions before forwarding."""
input_kwargs["instructions"] = self.replacement
await next_handler(**input_kwargs)
original = HintBlock(
hint="Keep all requirements.",
source="user",
)
replacement = HintBlock(
hint="Keep only unresolved requirements.",
source="middleware",
)
context_config = ContextConfig(trigger_ratio=0.8, reserve_ratio=0.1)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[ReplaceInstructionsMiddleware(replacement)],
context_config=context_config,
)
with patch.object(
agent,
"_compress_context_impl",
new_callable=AsyncMock,
) as mock_impl:
await agent.compress_context(
context_config=context_config,
instructions=original,
)
mock_impl.assert_awaited_once_with(
context_config=context_config,
instructions=replacement,
)
async def test_on_compress_context_middleware_short_circuit(
self,
) -> None:
"""Test that a middleware can skip _compress_context_impl entirely."""
class SkipCompressMiddleware(MiddlewareBase):
"""Middleware that skips the actual compress_context call."""
def __init__(self, log: list) -> None:
"""Initialize the skip compress middleware.
Args:
log (`list`):
The execution log list.
"""
self.log = log
async def on_compress_context(
self,
agent: Agent,
input_kwargs: dict,
next_handler: Callable[..., Any],
) -> None:
"""Record the call and skip forwarding to next_handler."""
self.log.append("skipped")
# Intentionally NOT calling next_handler.
middleware = SkipCompressMiddleware(self.execution_log)
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
middlewares=[middleware],
)
with patch.object(
agent,
"_compress_context_impl",
new_callable=AsyncMock,
) as mock_impl:
await agent.compress_context()
# _compress_context_impl should NOT have been called.
mock_impl.assert_not_awaited()
self.assertListEqual(self.execution_log, ["skipped"])
async def test_on_compress_context_no_middleware(self) -> None:
"""Test that compress_context calls _compress_context_impl directly
when no middleware is registered."""
self.mock_model.set_responses(
[
ChatResponse(
content=[TextBlock(text="test response")],
is_last=True,
),
],
)
context_config = ContextConfig(trigger_ratio=0.8, reserve_ratio=0.1)
agent = Agent(
name="test_agent",
system_prompt="test prompt",
model=self.mock_model,
toolkit=self.toolkit,
# No middlewares registered.
context_config=context_config,
)
with patch.object(
agent,
"_compress_context_impl",
new_callable=AsyncMock,
) as mock_impl:
await agent.compress_context(context_config=context_config)
mock_impl.assert_awaited_once_with(
context_config=context_config,
instructions=None,
)
async def asyncTearDown(self) -> None:
"""Clean up test fixtures."""
self.execution_log.clear()