Files
2026-07-13 12:39:17 +08:00

636 lines
23 KiB
Python

"""Tests for ToolOutputTrimmer — the built-in call_model_input_filter for trimming
large tool outputs from older conversation turns.
"""
from __future__ import annotations
import copy
import json
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from agents.extensions.tool_output_trimmer import ToolOutputTrimmer
from agents.run_config import CallModelData, ModelInputData
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _user(text: str = "hello") -> dict[str, Any]:
return {"role": "user", "content": text}
def _assistant(text: str = "response") -> dict[str, Any]:
return {"role": "assistant", "content": text}
def _func_call(call_id: str, name: str, *, namespace: str | None = None) -> dict[str, Any]:
item = {"type": "function_call", "call_id": call_id, "name": name, "arguments": "{}"}
if namespace is not None:
item["namespace"] = namespace
return item
def _func_output(call_id: str, output: str) -> dict[str, Any]:
return {"type": "function_call_output", "call_id": call_id, "output": output}
def _make_data(items: list[Any]) -> CallModelData[Any]:
model_data = ModelInputData(input=items, instructions="You are helpful.")
return CallModelData(model_data=model_data, agent=MagicMock(), context=None)
def _output(result: ModelInputData, idx: int) -> Any:
"""Extract the ``output`` field from a result item (untyped for test convenience)."""
item: Any = result.input[idx]
return item["output"]
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
class TestDefaults:
def test_default_values(self) -> None:
trimmer = ToolOutputTrimmer()
assert trimmer.recent_turns == 2
assert trimmer.max_output_chars == 500
assert trimmer.preview_chars == 200
assert trimmer.trimmable_tools is None
def test_trimmable_tools_coerced_to_frozenset(self) -> None:
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"a", "b"}))
assert isinstance(trimmer.trimmable_tools, frozenset)
assert trimmer.trimmable_tools == frozenset({"a", "b"})
def test_trimmable_tools_from_list(self) -> None:
trimmer = ToolOutputTrimmer(trimmable_tools=["search", "run_code"])
assert isinstance(trimmer.trimmable_tools, frozenset)
assert "search" in trimmer.trimmable_tools
assert "run_code" in trimmer.trimmable_tools
def test_trimmable_tools_from_string(self) -> None:
trimmer = ToolOutputTrimmer(trimmable_tools="search")
assert isinstance(trimmer.trimmable_tools, frozenset)
assert trimmer.trimmable_tools == frozenset({"search"})
# ---------------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------------
class TestValidation:
def test_recent_turns_zero_raises(self) -> None:
with pytest.raises(ValueError, match="recent_turns must be >= 1"):
ToolOutputTrimmer(recent_turns=0)
def test_recent_turns_negative_raises(self) -> None:
with pytest.raises(ValueError, match="recent_turns must be >= 1"):
ToolOutputTrimmer(recent_turns=-1)
def test_max_output_chars_zero_raises(self) -> None:
with pytest.raises(ValueError, match="max_output_chars must be >= 1"):
ToolOutputTrimmer(max_output_chars=0)
def test_preview_chars_negative_raises(self) -> None:
with pytest.raises(ValueError, match="preview_chars must be >= 0"):
ToolOutputTrimmer(preview_chars=-1)
def test_preview_chars_zero_allowed(self) -> None:
trimmer = ToolOutputTrimmer(preview_chars=0)
assert trimmer.preview_chars == 0
def test_trimmable_tools_bytes_raises(self) -> None:
with pytest.raises(ValueError, match="trimmable_tools must be a string or iterable"):
ToolOutputTrimmer(trimmable_tools=b"search") # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Boundary detection
# ---------------------------------------------------------------------------
class TestRecentBoundary:
def test_empty_items(self) -> None:
trimmer = ToolOutputTrimmer()
assert trimmer._find_recent_boundary([]) == 0
def test_single_user_message(self) -> None:
trimmer = ToolOutputTrimmer()
assert trimmer._find_recent_boundary([_user()]) == 0
def test_two_user_messages_boundary_at_first(self) -> None:
items = [_user("q1"), _assistant("a1"), _user("q2"), _assistant("a2")]
trimmer = ToolOutputTrimmer(recent_turns=2)
assert trimmer._find_recent_boundary(items) == 0
def test_three_user_messages(self) -> None:
items = [
_user("q1"),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(recent_turns=2)
assert trimmer._find_recent_boundary(items) == 2
def test_custom_recent_turns(self) -> None:
items = [
_user("q1"),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
_user("q4"),
_assistant("a4"),
]
trimmer = ToolOutputTrimmer(recent_turns=3)
# q4 at 6 (count=1), q3 at 4 (count=2), q2 at 2 (count=3) -> boundary=2
assert trimmer._find_recent_boundary(items) == 2
# ---------------------------------------------------------------------------
# Trimming behavior
# ---------------------------------------------------------------------------
class TestTrimming:
def test_empty_input(self) -> None:
trimmer = ToolOutputTrimmer()
data = _make_data([])
result = trimmer(data)
assert result.input == []
def test_no_trimming_when_all_recent(self) -> None:
"""With only 1 user message, everything is recent."""
large = "x" * 1000
items = [
_user("q"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert _output(result, 2) == large
def test_trims_large_old_output(self) -> None:
"""Large output in an old turn should be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
trimmed = _output(result, 2)
assert "[Trimmed:" in trimmed
assert "search" in trimmed
assert "1000 chars" in trimmed
assert len(trimmed) < len(large)
def test_preserves_small_old_output(self) -> None:
"""Small outputs should never be trimmed."""
small = "x" * 100
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", small),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(max_output_chars=500)
result = trimmer(_make_data(items))
assert _output(result, 2) == small
def test_respects_trimmable_tools_allowlist(self) -> None:
"""Only outputs from tools in trimmable_tools should be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_func_call("c2", "resolve_entity"),
_func_output("c2", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"search"}))
result = trimmer(_make_data(items))
# search output trimmed
assert "[Trimmed:" in _output(result, 2)
# resolve_entity output preserved
assert _output(result, 4) == large
def test_string_trimmable_tools_allowlist_matches_single_tool_name(self) -> None:
"""A string trimmable_tools value should match one tool name, not characters."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_func_call("c2", "s"),
_func_output("c2", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools="search")
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert _output(result, 4) == large
def test_respects_qualified_tool_names_allowlist(self) -> None:
"""Qualified allowlist entries should match namespaced function tools."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "lookup_account", namespace="billing"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"billing.lookup_account"}))
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "billing.lookup_account" in _output(result, 2)
def test_namespaced_tools_still_match_bare_allowlist_entries(self) -> None:
"""Bare allowlist entries remain valid for namespaced tools."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "lookup_account", namespace="billing"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"lookup_account"}))
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "billing.lookup_account" in _output(result, 2)
def test_synthetic_same_name_namespace_uses_bare_display_name(self) -> None:
"""Deferred synthetic namespaces should not display as `name.name`."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "get_weather", namespace="get_weather"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"get_weather"}))
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "get_weather.get_weather" not in _output(result, 2)
assert "get_weather" in _output(result, 2)
def test_trims_tool_search_output_tool_definitions(self) -> None:
"""Large tool_search_output tool definitions should be structurally trimmed."""
verbose_schema = {
"type": "object",
"description": "schema " * 200,
"properties": {
"customer_id": {
"type": "string",
"description": "customer id " * 200,
"default": "cust_123",
}
},
"required": ["customer_id"],
}
items = [
_user("q1"),
{"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "profile"}},
{
"type": "tool_search_output",
"call_id": "ts1",
"tools": [
{
"type": "function",
"name": "lookup_account",
"description": "tool description " * 200,
"parameters": verbose_schema,
}
],
},
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
original_len = len(json.dumps(items[2]["tools"], sort_keys=True))
trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=60)
result = trimmer(_make_data(items))
trimmed_item_dict = cast(dict[str, Any], result.input[2])
assert trimmed_item_dict["type"] == "tool_search_output"
trimmed_tools = list(trimmed_item_dict["tools"])
assert trimmed_tools[0]["name"] == "lookup_account"
assert "description" not in trimmed_tools[0]["parameters"]
assert trimmed_tools[0]["parameters"]["properties"]["customer_id"]["default"] == "cust_123"
assert len(json.dumps(trimmed_tools, sort_keys=True)) < original_len
def test_trims_legacy_tool_search_output_results(self) -> None:
"""Legacy tool_search_output snapshots with free-text results should still trim."""
large = "x" * 2000
items = [
_user("q1"),
{"type": "tool_search_call", "call_id": "ts1", "arguments": {"query": "profile"}},
{
"type": "tool_search_output",
"call_id": "ts1",
"results": [{"text": large}],
},
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(max_output_chars=400, preview_chars=80)
result = trimmer(_make_data(items))
trimmed_item = cast(dict[str, Any], result.input[2])
assert trimmed_item["type"] == "tool_search_output"
assert "[Trimmed: tool_search output" in trimmed_item["results"][0]["text"]
def test_trims_all_tools_when_allowlist_is_none(self) -> None:
"""When trimmable_tools is None, all tools are eligible."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "any_tool"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=None)
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
def test_preserves_recent_large_output(self) -> None:
"""Large outputs in recent turns should never be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_assistant("a1"),
_user("q2"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert _output(result, 4) == large
def test_does_not_mutate_original_items(self) -> None:
"""The filter must not mutate the original input items."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
original = copy.deepcopy(items)
trimmer = ToolOutputTrimmer()
trimmer(_make_data(items))
assert items == original
def test_preserves_instructions(self) -> None:
"""The instructions field should pass through unchanged."""
items: list[Any] = [_user("hi")]
model_data = ModelInputData(input=items, instructions="Custom prompt")
data: CallModelData[Any] = CallModelData(
model_data=model_data, agent=MagicMock(), context=None
)
trimmer = ToolOutputTrimmer()
result = trimmer(data)
assert result.instructions == "Custom prompt"
def test_multiple_old_outputs_trimmed(self) -> None:
"""Multiple large outputs in old turns should all be trimmed."""
large1 = "a" * 1000
large2 = "b" * 2000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large1),
_func_call("c2", "execute"),
_func_output("c2", large2),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert "[Trimmed:" in _output(result, 2)
assert "[Trimmed:" in _output(result, 4)
assert "search" in _output(result, 2)
assert "execute" in _output(result, 4)
def test_custom_preview_chars(self) -> None:
"""Preview length should respect the preview_chars setting."""
large = "abcdefghij" * 100 # 1000 chars
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(preview_chars=50)
result = trimmer(_make_data(items))
trimmed = _output(result, 2)
# The preview portion should be exactly 50 chars of the original
assert "abcdefghij" * 5 in trimmed
def test_preserves_user_and_assistant_messages(self) -> None:
"""User and assistant messages are never modified."""
items = [
_user("important"),
_assistant("detailed " * 100),
_user("follow up"),
_assistant("another"),
_user("final"),
_assistant("done"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
assert result.input == items
# ---------------------------------------------------------------------------
# Sliding window behavior
# ---------------------------------------------------------------------------
class TestSlidingWindow:
"""Verify the trimmer acts as a sliding window across turns."""
def test_turn3_trims_turn1(self) -> None:
"""On turn 3, turn 1 outputs should be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_func_call("c2", "search"),
_func_output("c2", large),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
# Turn 1 (old) trimmed
assert "[Trimmed:" in _output(result, 2)
# Turn 2 (recent) preserved
assert _output(result, 6) == large
def test_turn4_trims_turns_1_and_2(self) -> None:
"""On turn 4, turns 1 and 2 outputs should both be trimmed."""
large = "x" * 1000
items = [
_user("q1"),
_func_call("c1", "s"),
_func_output("c1", large),
_assistant("a1"),
_user("q2"),
_func_call("c2", "s"),
_func_output("c2", large),
_assistant("a2"),
_user("q3"),
_func_call("c3", "s"),
_func_output("c3", large),
_assistant("a3"),
_user("q4"),
_assistant("a4"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
# Turns 1 and 2 trimmed
assert "[Trimmed:" in _output(result, 2)
assert "[Trimmed:" in _output(result, 6)
# Turn 3 (recent) preserved
assert _output(result, 10) == large
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_skips_trim_when_summary_would_exceed_original(self) -> None:
"""When preview_chars is large relative to the output, the summary can be
longer than the original. In that case the output should be left untouched."""
# Output is 501 chars (just above default max_output_chars=500).
# With preview_chars=490, the summary header + 490-char preview + "..." will
# easily exceed 501 chars, so trimming should be skipped.
borderline = "x" * 501
items = [
_user("q1"),
_func_call("c1", "search"),
_func_output("c1", borderline),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(max_output_chars=500, preview_chars=490)
result = trimmer(_make_data(items))
# Output left untouched because summary would be longer
assert _output(result, 2) == borderline
def test_unknown_tool_name_fallback(self) -> None:
"""When a function_call_output has no matching function_call, the summary
should show 'unknown_tool' instead of a blank name."""
large = "x" * 1000
# Deliberately omit the _func_call so the call_id has no name mapping
items = [
_user("q1"),
_func_output("orphan_id", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer()
result = trimmer(_make_data(items))
trimmed = _output(result, 1)
assert "unknown_tool" in trimmed
assert "[Trimmed:" in trimmed
def test_unresolved_tool_skipped_with_allowlist(self) -> None:
"""When trimmable_tools is set and the tool name can't be resolved,
the output should NOT be trimmed (empty string won't match the allowlist)."""
large = "x" * 1000
items = [
_user("q1"),
_func_output("orphan_id", large),
_assistant("a1"),
_user("q2"),
_assistant("a2"),
_user("q3"),
_assistant("a3"),
]
trimmer = ToolOutputTrimmer(trimmable_tools=frozenset({"search"}))
result = trimmer(_make_data(items))
# Unresolved tool name is "" which is not in the allowlist — left untouched
assert _output(result, 1) == large