Skip to content

Commit 5cdcf84

Browse files
fix(sessions): strip output-only metadata from compaction replay (#5196)
1 parent 86a13db commit 5cdcf84

3 files changed

Lines changed: 93 additions & 3 deletions

File tree

‎src/agents/memory/openai_responses_compaction_session.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,8 @@ async def _run_compaction_locked(
442442
if resolved_mode == "previous_response_id":
443443
compact_kwargs["previous_response_id"] = self._response_id
444444
else:
445-
compact_kwargs["input"] = session_items
445+
# Manual compaction may reuse cached raw output from the preceding compaction.
446+
compact_kwargs["input"] = normalize_input_items_for_api(session_items)
446447

447448
compacted = await self.client.responses.compact(**compact_kwargs)
448449

‎src/agents/run_internal/items.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,13 +713,15 @@ def _dedupe_key(item: TResponseInputItem) -> str | None:
713713

714714

715715
def strip_internal_input_item_metadata(item: TResponseInputItem) -> TResponseInputItem:
716-
"""Remove SDK-only session metadata before sending items back to the model."""
716+
"""Remove session and output-only metadata before replaying items as input."""
717717
if not isinstance(item, dict):
718718
return item
719719

720720
cleaned = dict(item)
721721
cleaned.pop(TOOL_CALL_SESSION_DESCRIPTION_KEY, None)
722722
cleaned.pop(TOOL_CALL_SESSION_TITLE_KEY, None)
723+
# Compaction and persisted history can bypass RunItem's output-to-input conversion.
724+
cleaned.pop("created_by", None)
723725
return cast(TResponseInputItem, cleaned)
724726

725727

‎tests/memory/test_openai_responses_compaction_session.py‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import json
45
import logging
56
import sqlite3
67
import threading
@@ -11,15 +12,17 @@
1112
from typing import Any, cast
1213
from unittest.mock import AsyncMock, MagicMock, patch
1314

15+
import httpx2
1416
import pytest
17+
from openai import AsyncOpenAI
1518
from openai.types.responses.response_usage import (
1619
InputTokensDetails,
1720
OutputTokensDetails,
1821
ResponseUsage,
1922
)
2023

2124
import agents._debug as _debug
22-
from agents import Agent, Runner
25+
from agents import Agent, OpenAIResponsesModel, RunConfig, Runner
2326
from agents.items import MessageOutputItem, TResponseInputItem
2427
from agents.memory import (
2528
OpenAIResponsesCompactionSession,
@@ -45,10 +48,94 @@
4548
)
4649
from agents.run_state import RunState
4750
from agents.testing import ModelStep, ScriptedModel
51+
from tests.model_test_helpers import get_response_obj
4852
from tests.test_responses import get_function_tool, get_function_tool_call, get_text_message
4953
from tests.utils.simple_session import SimpleListSession
5054

5155

56+
@pytest.mark.asyncio
57+
@pytest.mark.allow_call_model_methods
58+
@pytest.mark.parametrize("history_source", ["automatic", "manual", "stored"])
59+
async def test_compaction_replay_strips_created_by(history_source: str) -> None:
60+
compacted_item = {
61+
"id": "cmp_synthetic",
62+
"type": "compaction",
63+
"encrypted_content": "synthetic-encrypted-content",
64+
"created_by": "server",
65+
}
66+
request_inputs: list[list[dict[str, Any]]] = []
67+
68+
async def handler(request: httpx2.Request) -> httpx2.Response:
69+
request_inputs.append(json.loads(request.content)["input"])
70+
if request.url.path.endswith("/compact"):
71+
return httpx2.Response(
72+
200,
73+
json={
74+
"id": "cmp_response",
75+
"object": "response.compaction",
76+
"created_at": 1,
77+
"output": [compacted_item],
78+
},
79+
)
80+
assert request.url.path.endswith("/responses")
81+
return httpx2.Response(
82+
200, content=get_response_obj([get_text_message("42")]).model_dump_json()
83+
)
84+
85+
underlying = SQLiteSession("created-by-replay")
86+
try:
87+
async with AsyncOpenAI(
88+
api_key="synthetic-key",
89+
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)),
90+
) as client:
91+
session = OpenAIResponsesCompactionSession(
92+
session_id="created-by-replay",
93+
underlying_session=underlying,
94+
client=client,
95+
model="gpt-5",
96+
compaction_mode="input",
97+
should_trigger_compaction=lambda ctx: bool(ctx["compaction_candidate_items"]),
98+
)
99+
agent = Agent(name="test", model=OpenAIResponsesModel("gpt-5", openai_client=client))
100+
config = RunConfig(tracing_disabled=True)
101+
if history_source == "stored":
102+
# History saved before the fix must also remain usable.
103+
await underlying.add_items([cast(TResponseInputItem, compacted_item)])
104+
elif history_source == "manual":
105+
await session.add_items([{"role": "user", "content": "How many?"}])
106+
await session.run_compaction({"force": True})
107+
else:
108+
await Runner.run(agent, "How many?", session=session, run_config=config)
109+
110+
stored_items = await underlying.get_items()
111+
assert stored_items == [compacted_item]
112+
if history_source == "manual":
113+
# Repeated manual compaction reuses cached output without a history reload.
114+
await session.add_items([{"role": "user", "content": "More context"}])
115+
await session.run_compaction({"force": True})
116+
result = await Runner.run(agent, "And urgent?", session=session, run_config=config)
117+
assert result.final_output == "42"
118+
119+
# Inspect encoded inputs to both Responses and the next compact request.
120+
replayed_items = [
121+
item
122+
for items in request_inputs
123+
for item in items
124+
if item.get("type") == "compaction"
125+
]
126+
assert replayed_items == [
127+
{
128+
"id": "cmp_synthetic",
129+
"type": "compaction",
130+
"encrypted_content": "synthetic-encrypted-content",
131+
}
132+
] * (3 if history_source == "manual" else 2)
133+
assert stored_items[0] == compacted_item
134+
assert (await underlying.get_items())[0] == compacted_item
135+
finally:
136+
underlying.close()
137+
138+
52139
class TestIsOpenAIModelName:
53140
def test_gpt_models(self) -> None:
54141
assert is_openai_model_name("gpt-4o") is True

0 commit comments

Comments
 (0)