Skip to content

Commit 86acfb4

Browse files
authored
fix: #2317 defer compaction when local tool outputs are present (#2322)
1 parent bf2625c commit 86acfb4

File tree

3 files changed

+164
-2
lines changed

3 files changed

+164
-2
lines changed

src/agents/memory/openai_responses_compaction_session.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def __init__(
121121
self._compaction_candidate_items: list[TResponseInputItem] | None = None
122122
self._session_items: list[TResponseInputItem] | None = None
123123
self._response_id: str | None = None
124+
self._deferred_response_id: str | None = None
124125

125126
@property
126127
def client(self) -> AsyncOpenAI:
@@ -153,6 +154,7 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
153154
logger.debug(f"skip: decision hook declined compaction for {self._response_id}")
154155
return
155156

157+
self._deferred_response_id = None
156158
logger.debug(f"compact: start for {self._response_id} using {self.model}")
157159

158160
compacted = await self.client.responses.compact(
@@ -187,6 +189,26 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
187189
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
188190
return await self.underlying_session.get_items(limit)
189191

192+
async def _defer_compaction(self, response_id: str) -> None:
193+
if self._deferred_response_id is not None:
194+
return
195+
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
196+
should_compact = self.should_trigger_compaction(
197+
{
198+
"response_id": response_id,
199+
"compaction_candidate_items": compaction_candidate_items,
200+
"session_items": session_items,
201+
}
202+
)
203+
if should_compact:
204+
self._deferred_response_id = response_id
205+
206+
def _get_deferred_compaction_response_id(self) -> str | None:
207+
return self._deferred_response_id
208+
209+
def _clear_deferred_compaction(self) -> None:
210+
self._deferred_response_id = None
211+
190212
async def add_items(self, items: list[TResponseInputItem]) -> None:
191213
await self.underlying_session.add_items(items)
192214
if self._compaction_candidate_items is not None:
@@ -207,6 +229,7 @@ async def clear_session(self) -> None:
207229
await self.underlying_session.clear_session()
208230
self._compaction_candidate_items = []
209231
self._session_items = []
232+
self._deferred_response_id = None
210233

211234
async def _ensure_compaction_candidates(
212235
self,

src/agents/run.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,14 @@
4949
from .handoffs import Handoff, HandoffHistoryMapper, HandoffInputFilter, handoff
5050
from .items import (
5151
HandoffCallItem,
52+
HandoffOutputItem,
5253
ItemHelpers,
5354
ModelResponse,
5455
ReasoningItem,
5556
RunItem,
5657
ToolCallItem,
5758
ToolCallItemTypes,
59+
ToolCallOutputItem,
5860
TResponseInputItem,
5961
)
6062
from .lifecycle import AgentHooksBase, RunHooks, RunHooksBase
@@ -2094,7 +2096,32 @@ async def _save_result_to_session(
20942096

20952097
# Run compaction if session supports it and we have a response_id
20962098
if response_id and is_openai_responses_compaction_aware_session(session):
2097-
await session.run_compaction({"response_id": response_id})
2099+
has_local_tool_outputs = any(
2100+
isinstance(item, (ToolCallOutputItem, HandoffOutputItem)) for item in new_items
2101+
)
2102+
if has_local_tool_outputs:
2103+
defer_compaction = getattr(session, "_defer_compaction", None)
2104+
if callable(defer_compaction):
2105+
result = defer_compaction(response_id)
2106+
if inspect.isawaitable(result):
2107+
await result
2108+
logger.debug(
2109+
"skip: deferring compaction for response %s due to local tool outputs",
2110+
response_id,
2111+
)
2112+
return
2113+
deferred_response_id = None
2114+
get_deferred = getattr(session, "_get_deferred_compaction_response_id", None)
2115+
if callable(get_deferred):
2116+
deferred_response_id = get_deferred()
2117+
force_compaction = deferred_response_id is not None
2118+
if force_compaction:
2119+
logger.debug(
2120+
"compact: forcing for response %s after deferred %s",
2121+
response_id,
2122+
deferred_response_id,
2123+
)
2124+
await session.run_compaction({"response_id": response_id, "force": force_compaction})
20982125

20992126
@staticmethod
21002127
async def _input_guardrail_tripwire_triggered_for_stream(

tests/memory/test_openai_responses_compaction_session.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
select_compaction_candidate_items,
2121
)
2222
from tests.fake_model import FakeModel
23-
from tests.test_responses import get_text_message
23+
from tests.test_responses import get_function_tool, get_function_tool_call, get_text_message
2424
from tests.utils.simple_session import SimpleListSession
2525

2626

@@ -289,6 +289,118 @@ async def test_compaction_runs_during_runner_flow(self) -> None:
289289
items = await session.get_items()
290290
assert any(isinstance(item, dict) and item.get("type") == "compaction" for item in items)
291291

292+
@pytest.mark.asyncio
293+
async def test_compaction_skips_when_tool_outputs_present(self) -> None:
294+
underlying = SimpleListSession()
295+
mock_client = MagicMock()
296+
mock_client.responses.compact = AsyncMock()
297+
298+
session = OpenAIResponsesCompactionSession(
299+
session_id="demo",
300+
underlying_session=underlying,
301+
client=mock_client,
302+
should_trigger_compaction=lambda ctx: True,
303+
)
304+
305+
tool = get_function_tool(name="do_thing", return_value="done")
306+
model = FakeModel(initial_output=[get_function_tool_call("do_thing")])
307+
agent = Agent(
308+
name="assistant",
309+
model=model,
310+
tools=[tool],
311+
tool_use_behavior="stop_on_first_tool",
312+
)
313+
314+
await Runner.run(agent, "hello", session=session)
315+
316+
mock_client.responses.compact.assert_not_called()
317+
318+
@pytest.mark.asyncio
319+
async def test_compaction_runs_after_deferred_tool_outputs_when_due(self) -> None:
320+
underlying = SimpleListSession()
321+
compacted = SimpleNamespace(
322+
output=[{"type": "compaction", "summary": "compacted"}],
323+
)
324+
mock_client = MagicMock()
325+
mock_client.responses.compact = AsyncMock(return_value=compacted)
326+
327+
def should_trigger_compaction(context: dict[str, Any]) -> bool:
328+
return any(
329+
isinstance(item, dict) and item.get("type") == "function_call_output"
330+
for item in context["session_items"]
331+
)
332+
333+
session = OpenAIResponsesCompactionSession(
334+
session_id="demo",
335+
underlying_session=underlying,
336+
client=mock_client,
337+
should_trigger_compaction=should_trigger_compaction,
338+
)
339+
340+
tool = get_function_tool(name="do_thing", return_value="done")
341+
model = FakeModel()
342+
model.add_multiple_turn_outputs(
343+
[
344+
[get_function_tool_call("do_thing")],
345+
[get_text_message("ok")],
346+
]
347+
)
348+
agent = Agent(
349+
name="assistant",
350+
model=model,
351+
tools=[tool],
352+
tool_use_behavior="stop_on_first_tool",
353+
)
354+
355+
await Runner.run(agent, "hello", session=session)
356+
await Runner.run(agent, "followup", session=session)
357+
358+
mock_client.responses.compact.assert_awaited_once()
359+
360+
@pytest.mark.asyncio
361+
async def test_deferred_compaction_persists_across_tool_turns(self) -> None:
362+
underlying = SimpleListSession()
363+
compacted = SimpleNamespace(
364+
output=[{"type": "compaction", "summary": "compacted"}],
365+
)
366+
mock_client = MagicMock()
367+
mock_client.responses.compact = AsyncMock(return_value=compacted)
368+
369+
should_compact_calls = {"count": 0}
370+
371+
def should_trigger_compaction(context: dict[str, Any]) -> bool:
372+
should_compact_calls["count"] += 1
373+
return should_compact_calls["count"] == 1
374+
375+
session = OpenAIResponsesCompactionSession(
376+
session_id="demo",
377+
underlying_session=underlying,
378+
client=mock_client,
379+
should_trigger_compaction=should_trigger_compaction,
380+
)
381+
382+
tool = get_function_tool(name="do_thing", return_value="done")
383+
model = FakeModel()
384+
model.add_multiple_turn_outputs(
385+
[
386+
[get_function_tool_call("do_thing")],
387+
[get_function_tool_call("do_thing")],
388+
[get_text_message("ok")],
389+
]
390+
)
391+
agent = Agent(
392+
name="assistant",
393+
model=model,
394+
tools=[tool],
395+
tool_use_behavior="stop_on_first_tool",
396+
)
397+
398+
await Runner.run(agent, "hello", session=session)
399+
await Runner.run(agent, "again", session=session)
400+
await Runner.run(agent, "final", session=session)
401+
402+
mock_client.responses.compact.assert_awaited_once()
403+
292404

293405
class TestTypeGuard:
294406
def test_is_compaction_aware_session_true(self) -> None:

0 commit comments

Comments
 (0)