From 3b28c7bb6b3438644b001e30ae42efc653f33488 Mon Sep 17 00:00:00 2001 From: Krishna Janakiraman Date: Wed, 11 Feb 2026 19:48:08 +0530 Subject: [PATCH 1/3] feat(memory): add ToolOutputTrimmer for smart context management Built-in call_model_input_filter that surgically trims large tool outputs from older conversation turns using a sliding window approach, reducing token usage while preserving recent context at full fidelity. --- src/agents/__init__.py | 2 + src/agents/memory/__init__.py | 2 + src/agents/memory/tool_output_trimmer.py | 170 ++++++++ tests/memory/test_tool_output_trimmer.py | 474 +++++++++++++++++++++++ 4 files changed, 648 insertions(+) create mode 100644 src/agents/memory/tool_output_trimmer.py create mode 100644 tests/memory/test_tool_output_trimmer.py diff --git a/src/agents/__init__.py b/src/agents/__init__.py index c4f1de30f2..c13eaa67d9 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -74,6 +74,7 @@ SessionABC, SessionSettings, SQLiteSession, + ToolOutputTrimmer, is_openai_responses_compaction_aware_session, ) from .model_settings import ModelSettings @@ -316,6 +317,7 @@ def enable_verbose_stdout_logging(): "OpenAIResponsesCompactionArgs", "OpenAIResponsesCompactionAwareSession", "is_openai_responses_compaction_aware_session", + "ToolOutputTrimmer", "CompactionItem", "AgentHookContext", "RunContextWrapper", diff --git a/src/agents/memory/__init__.py b/src/agents/memory/__init__.py index 909a907134..bd54420729 100644 --- a/src/agents/memory/__init__.py +++ b/src/agents/memory/__init__.py @@ -9,6 +9,7 @@ ) from .session_settings import SessionSettings from .sqlite_session import SQLiteSession +from .tool_output_trimmer import ToolOutputTrimmer from .util import SessionInputCallback __all__ = [ @@ -17,6 +18,7 @@ "SessionInputCallback", "SessionSettings", "SQLiteSession", + "ToolOutputTrimmer", "OpenAIConversationsSession", "OpenAIResponsesCompactionSession", "OpenAIResponsesCompactionArgs", diff --git a/src/agents/memory/tool_output_trimmer.py b/src/agents/memory/tool_output_trimmer.py new file mode 100644 index 0000000000..5807b421e3 --- /dev/null +++ b/src/agents/memory/tool_output_trimmer.py @@ -0,0 +1,170 @@ +"""Built-in call_model_input_filter that trims large tool outputs from older turns. + +Agentic applications often accumulate large tool outputs (search results, code execution +output, error analyses) that consume significant tokens but lose relevance as the +conversation progresses. This module provides a configurable filter that surgically trims +bulky tool outputs from older turns while keeping recent turns at full fidelity. + +Usage:: + + from agents import RunConfig + from agents.memory import ToolOutputTrimmer + + config = RunConfig( + call_model_input_filter=ToolOutputTrimmer( + recent_turns=2, + max_output_chars=500, + preview_chars=200, + trimmable_tools={"search", "execute_code"}, + ), + ) + +The trimmer operates as a sliding window: the last ``recent_turns`` user messages (and +all items after them) are never modified. Older tool outputs that exceed +``max_output_chars`` — and optionally belong to ``trimmable_tools`` — are replaced with a +compact preview. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..run_config import CallModelData, ModelInputData + +logger = logging.getLogger(__name__) + + +@dataclass +class ToolOutputTrimmer: + """Configurable filter that trims large tool outputs from older conversation turns. + + This class implements the ``CallModelInputFilter`` protocol and can be passed directly + to ``RunConfig.call_model_input_filter``. It runs immediately before each model call + and replaces large tool outputs from older turns with a concise preview, reducing token + usage without losing the context of what happened. + + Args: + recent_turns: Number of recent user messages whose surrounding items are never + trimmed. Defaults to 2. + max_output_chars: Tool outputs above this character count are candidates for + trimming. Defaults to 500. + preview_chars: How many characters of the original output to preserve as a + preview when trimming. Defaults to 200. + trimmable_tools: Optional set of tool names whose outputs can be trimmed. If + ``None``, all tool outputs are eligible for trimming. Defaults to ``None``. + """ + + recent_turns: int = 2 + max_output_chars: int = 500 + preview_chars: int = 200 + trimmable_tools: frozenset[str] | None = field(default=None) + + def __post_init__(self) -> None: + if self.recent_turns < 1: + raise ValueError(f"recent_turns must be >= 1, got {self.recent_turns}") + if self.max_output_chars < 1: + raise ValueError(f"max_output_chars must be >= 1, got {self.max_output_chars}") + if self.preview_chars < 0: + raise ValueError(f"preview_chars must be >= 0, got {self.preview_chars}") + # Coerce any iterable to frozenset for immutability + if self.trimmable_tools is not None and not isinstance(self.trimmable_tools, frozenset): + object.__setattr__(self, "trimmable_tools", frozenset(self.trimmable_tools)) + + def __call__(self, data: CallModelData[Any]) -> ModelInputData: + """Filter callback invoked before each model call. + + Finds the boundary between old and recent items, then trims large tool outputs + from old turns. Does NOT mutate the original items — creates shallow copies when + needed. + """ + from ..run_config import ModelInputData as _ModelInputData + + model_data = data.model_data + items = model_data.input + + if not items: + return model_data + + boundary = self._find_recent_boundary(items) + if boundary == 0: + return model_data + + call_id_to_name = self._build_call_id_to_name(items) + + trimmed_count = 0 + chars_saved = 0 + new_items: list[Any] = [] + + for i, item in enumerate(items): + if ( + i < boundary + and isinstance(item, dict) + and item.get("type") == "function_call_output" + ): + output = item.get("output", "") + output_str = output if isinstance(output, str) else str(output) + output_len = len(output_str) + + call_id = str(item.get("call_id", "")) + tool_name = call_id_to_name.get(call_id, "") + + if output_len > self.max_output_chars and ( + self.trimmable_tools is None or tool_name in self.trimmable_tools + ): + display_name = tool_name or "unknown_tool" + preview = output_str[: self.preview_chars] + summary = ( + f"[Trimmed: {display_name} output — {output_len} chars → " + f"{self.preview_chars} char preview]\n{preview}..." + ) + # Only replace if summary is actually shorter than the original + if len(summary) < output_len: + trimmed_item = dict(item) + trimmed_item["output"] = summary + new_items.append(trimmed_item) + + trimmed_count += 1 + chars_saved += output_len - len(summary) + continue + + new_items.append(item) + + if trimmed_count > 0: + logger.debug( + f"ToolOutputTrimmer: trimmed {trimmed_count} tool output(s), " + f"saved ~{chars_saved} chars" + ) + + return _ModelInputData(input=new_items, instructions=model_data.instructions) + + def _find_recent_boundary(self, items: list[Any]) -> int: + """Find the index separating 'old' items from 'recent' items. + + Walks backward through the items list counting user messages. Returns the index + of the Nth user message from the end, where N = ``recent_turns``. Items at or + after this index are considered recent and will not be trimmed. + + If there are fewer than N user messages, returns 0 (nothing is old). + """ + user_msg_count = 0 + for i in range(len(items) - 1, -1, -1): + item = items[i] + if isinstance(item, dict) and item.get("role") == "user": + user_msg_count += 1 + if user_msg_count >= self.recent_turns: + return i + return 0 + + def _build_call_id_to_name(self, items: list[Any]) -> dict[str, str]: + """Build a mapping from function call_id to tool name.""" + mapping: dict[str, str] = {} + for item in items: + if isinstance(item, dict) and item.get("type") == "function_call": + call_id = item.get("call_id") + name = item.get("name") + if call_id and name: + mapping[call_id] = name + return mapping diff --git a/tests/memory/test_tool_output_trimmer.py b/tests/memory/test_tool_output_trimmer.py new file mode 100644 index 0000000000..8299ae31f9 --- /dev/null +++ b/tests/memory/test_tool_output_trimmer.py @@ -0,0 +1,474 @@ +"""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 +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agents.memory.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) -> dict[str, Any]: + return {"type": "function_call", "call_id": call_id, "name": name, "arguments": "{}"} + + +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"]) # type: ignore[arg-type] + assert isinstance(trimmer.trimmable_tools, frozenset) + assert "search" in trimmer.trimmable_tools + assert "run_code" in trimmer.trimmable_tools + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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_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 From e4cb3cfd333948aa26a15d637a2dd409703eed6f Mon Sep 17 00:00:00 2001 From: Krishna Date: Thu, 12 Feb 2026 00:08:59 +0530 Subject: [PATCH 2/3] refactor: move ToolOutputTrimmer to extensions/memory Move ToolOutputTrimmer from agents.memory to agents.extensions.memory. Update import paths and exports. --- src/agents/__init__.py | 2 -- src/agents/extensions/memory/__init__.py | 3 +++ src/agents/{ => extensions}/memory/tool_output_trimmer.py | 6 +++--- src/agents/memory/__init__.py | 2 -- tests/{ => extensions}/memory/test_tool_output_trimmer.py | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) rename src/agents/{ => extensions}/memory/tool_output_trimmer.py (97%) rename tests/{ => extensions}/memory/test_tool_output_trimmer.py (99%) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index c13eaa67d9..c4f1de30f2 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -74,7 +74,6 @@ SessionABC, SessionSettings, SQLiteSession, - ToolOutputTrimmer, is_openai_responses_compaction_aware_session, ) from .model_settings import ModelSettings @@ -317,7 +316,6 @@ def enable_verbose_stdout_logging(): "OpenAIResponsesCompactionArgs", "OpenAIResponsesCompactionAwareSession", "is_openai_responses_compaction_aware_session", - "ToolOutputTrimmer", "CompactionItem", "AgentHookContext", "RunContextWrapper", diff --git a/src/agents/extensions/memory/__init__.py b/src/agents/extensions/memory/__init__.py index 2c7d268a76..4b7b3c1f54 100644 --- a/src/agents/extensions/memory/__init__.py +++ b/src/agents/extensions/memory/__init__.py @@ -22,6 +22,8 @@ from .redis_session import RedisSession from .sqlalchemy_session import SQLAlchemySession +from .tool_output_trimmer import ToolOutputTrimmer + __all__: list[str] = [ "AdvancedSQLiteSession", "AsyncSQLiteSession", @@ -31,6 +33,7 @@ "EncryptedSession", "RedisSession", "SQLAlchemySession", + "ToolOutputTrimmer", ] diff --git a/src/agents/memory/tool_output_trimmer.py b/src/agents/extensions/memory/tool_output_trimmer.py similarity index 97% rename from src/agents/memory/tool_output_trimmer.py rename to src/agents/extensions/memory/tool_output_trimmer.py index 5807b421e3..0089b2fc4f 100644 --- a/src/agents/memory/tool_output_trimmer.py +++ b/src/agents/extensions/memory/tool_output_trimmer.py @@ -8,7 +8,7 @@ Usage:: from agents import RunConfig - from agents.memory import ToolOutputTrimmer + from agents.extensions.memory import ToolOutputTrimmer config = RunConfig( call_model_input_filter=ToolOutputTrimmer( @@ -32,7 +32,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from ..run_config import CallModelData, ModelInputData + from ...run_config import CallModelData, ModelInputData logger = logging.getLogger(__name__) @@ -80,7 +80,7 @@ def __call__(self, data: CallModelData[Any]) -> ModelInputData: from old turns. Does NOT mutate the original items — creates shallow copies when needed. """ - from ..run_config import ModelInputData as _ModelInputData + from ...run_config import ModelInputData as _ModelInputData model_data = data.model_data items = model_data.input diff --git a/src/agents/memory/__init__.py b/src/agents/memory/__init__.py index bd54420729..909a907134 100644 --- a/src/agents/memory/__init__.py +++ b/src/agents/memory/__init__.py @@ -9,7 +9,6 @@ ) from .session_settings import SessionSettings from .sqlite_session import SQLiteSession -from .tool_output_trimmer import ToolOutputTrimmer from .util import SessionInputCallback __all__ = [ @@ -18,7 +17,6 @@ "SessionInputCallback", "SessionSettings", "SQLiteSession", - "ToolOutputTrimmer", "OpenAIConversationsSession", "OpenAIResponsesCompactionSession", "OpenAIResponsesCompactionArgs", diff --git a/tests/memory/test_tool_output_trimmer.py b/tests/extensions/memory/test_tool_output_trimmer.py similarity index 99% rename from tests/memory/test_tool_output_trimmer.py rename to tests/extensions/memory/test_tool_output_trimmer.py index 8299ae31f9..256310d412 100644 --- a/tests/memory/test_tool_output_trimmer.py +++ b/tests/extensions/memory/test_tool_output_trimmer.py @@ -10,7 +10,7 @@ import pytest -from agents.memory.tool_output_trimmer import ToolOutputTrimmer +from agents.extensions.memory.tool_output_trimmer import ToolOutputTrimmer from agents.run_config import CallModelData, ModelInputData # --------------------------------------------------------------------------- From 6e3e9c03fa6495223e34404162ed69bd629ef16b Mon Sep 17 00:00:00 2001 From: Krishna Date: Thu, 12 Feb 2026 20:18:25 +0530 Subject: [PATCH 3/3] refactor: move ToolOutputTrimmer to extensions/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ToolOutputTrimmer up one level from extensions/memory/ to extensions/ since call_model_input_filter is not tied to the memory subsystem. - src/agents/extensions/memory/tool_output_trimmer.py → src/agents/extensions/tool_output_trimmer.py - tests/extensions/memory/test_tool_output_trimmer.py → tests/extensions/test_tool_output_trimmer.py - Import path: from agents.extensions import ToolOutputTrimmer --- src/agents/extensions/__init__.py | 3 +++ src/agents/extensions/memory/__init__.py | 3 --- src/agents/extensions/{memory => }/tool_output_trimmer.py | 6 +++--- tests/extensions/{memory => }/test_tool_output_trimmer.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename src/agents/extensions/{memory => }/tool_output_trimmer.py (97%) rename tests/extensions/{memory => }/test_tool_output_trimmer.py (99%) diff --git a/src/agents/extensions/__init__.py b/src/agents/extensions/__init__.py index e69de29bb2..3622d0a924 100644 --- a/src/agents/extensions/__init__.py +++ b/src/agents/extensions/__init__.py @@ -0,0 +1,3 @@ +from .tool_output_trimmer import ToolOutputTrimmer + +__all__ = ["ToolOutputTrimmer"] diff --git a/src/agents/extensions/memory/__init__.py b/src/agents/extensions/memory/__init__.py index 4b7b3c1f54..2c7d268a76 100644 --- a/src/agents/extensions/memory/__init__.py +++ b/src/agents/extensions/memory/__init__.py @@ -22,8 +22,6 @@ from .redis_session import RedisSession from .sqlalchemy_session import SQLAlchemySession -from .tool_output_trimmer import ToolOutputTrimmer - __all__: list[str] = [ "AdvancedSQLiteSession", "AsyncSQLiteSession", @@ -33,7 +31,6 @@ "EncryptedSession", "RedisSession", "SQLAlchemySession", - "ToolOutputTrimmer", ] diff --git a/src/agents/extensions/memory/tool_output_trimmer.py b/src/agents/extensions/tool_output_trimmer.py similarity index 97% rename from src/agents/extensions/memory/tool_output_trimmer.py rename to src/agents/extensions/tool_output_trimmer.py index 0089b2fc4f..b75883b6f2 100644 --- a/src/agents/extensions/memory/tool_output_trimmer.py +++ b/src/agents/extensions/tool_output_trimmer.py @@ -8,7 +8,7 @@ Usage:: from agents import RunConfig - from agents.extensions.memory import ToolOutputTrimmer + from agents.extensions import ToolOutputTrimmer config = RunConfig( call_model_input_filter=ToolOutputTrimmer( @@ -32,7 +32,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from ...run_config import CallModelData, ModelInputData + from ..run_config import CallModelData, ModelInputData logger = logging.getLogger(__name__) @@ -80,7 +80,7 @@ def __call__(self, data: CallModelData[Any]) -> ModelInputData: from old turns. Does NOT mutate the original items — creates shallow copies when needed. """ - from ...run_config import ModelInputData as _ModelInputData + from ..run_config import ModelInputData as _ModelInputData model_data = data.model_data items = model_data.input diff --git a/tests/extensions/memory/test_tool_output_trimmer.py b/tests/extensions/test_tool_output_trimmer.py similarity index 99% rename from tests/extensions/memory/test_tool_output_trimmer.py rename to tests/extensions/test_tool_output_trimmer.py index 256310d412..02a993d801 100644 --- a/tests/extensions/memory/test_tool_output_trimmer.py +++ b/tests/extensions/test_tool_output_trimmer.py @@ -10,7 +10,7 @@ import pytest -from agents.extensions.memory.tool_output_trimmer import ToolOutputTrimmer +from agents.extensions.tool_output_trimmer import ToolOutputTrimmer from agents.run_config import CallModelData, ModelInputData # ---------------------------------------------------------------------------