Skip to content

Commit 42f2c40

Browse files
authored
feat: #2333 support input-based responses compaction with store-aware auto mode (#2334)
1 parent e449a2d commit 42f2c40

File tree

6 files changed

+447
-18
lines changed

6 files changed

+447
-18
lines changed

examples/memory/compaction_session_example.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ async def main():
4545
result = await Runner.run(agent, prompt, session=session)
4646
print(f"Assistant: {result.final_output}\n")
4747

48-
# Show final session state
48+
# Show session state after automatic compaction (if triggered)
4949
items = await session.get_items()
50-
print("=== Final Session State ===")
50+
print("=== Session State (Auto Compaction) ===")
5151
print(f"Total items: {len(items)}")
5252
for item in items:
5353
# Some inputs are stored as easy messages (only `role` and `content`).
@@ -59,6 +59,27 @@ async def main():
5959
print(f" - message ({role})")
6060
else:
6161
print(f" - {item_type}")
62+
print()
63+
64+
# Manual compaction after inspecting the auto-compacted state.
65+
print("=== Manual Compaction ===")
66+
await session.run_compaction({"force": True})
67+
print("Done")
68+
print()
69+
70+
# Show final session state after manual compaction
71+
items = await session.get_items()
72+
print("=== Session State (Manual Compaction) ===")
73+
print(f"Total items: {len(items)}")
74+
for item in items:
75+
item_type = item.get("type") or ("message" if "role" in item else "unknown")
76+
if item_type == "compaction":
77+
print(" - compaction (encrypted content)")
78+
elif item_type == "message":
79+
role = item.get("role", "unknown")
80+
print(f" - message ({role})")
81+
else:
82+
print(f" - {item_type}")
6283

6384

6485
if __name__ == "__main__":
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
Example demonstrating stateless compaction with store=False.
3+
4+
In auto mode, OpenAIResponsesCompactionSession uses input-based compaction when
5+
responses are not stored on the server.
6+
"""
7+
8+
import asyncio
9+
10+
from agents import Agent, ModelSettings, OpenAIResponsesCompactionSession, Runner, SQLiteSession
11+
12+
13+
async def main():
14+
# Create an underlying session for storage
15+
underlying = SQLiteSession(":memory:")
16+
17+
# Wrap with compaction session in auto mode. When store=False, this will
18+
# compact using the locally stored input items.
19+
session = OpenAIResponsesCompactionSession(
20+
session_id="demo-session",
21+
underlying_session=underlying,
22+
model="gpt-4.1",
23+
compaction_mode="auto",
24+
should_trigger_compaction=lambda ctx: len(ctx["compaction_candidate_items"]) >= 3,
25+
)
26+
27+
agent = Agent(
28+
name="Assistant",
29+
instructions="Reply concisely. Keep answers to 1-2 sentences.",
30+
model_settings=ModelSettings(store=False),
31+
)
32+
33+
print("=== Stateless Compaction Session Example ===\n")
34+
35+
prompts = [
36+
"What is the tallest mountain in the world?",
37+
"How tall is it in feet?",
38+
"When was it first climbed?",
39+
"Who was on that expedition?",
40+
]
41+
42+
for i, prompt in enumerate(prompts, 1):
43+
print(f"Turn {i}:")
44+
print(f"User: {prompt}")
45+
result = await Runner.run(agent, prompt, session=session)
46+
print(f"Assistant: {result.final_output}\n")
47+
48+
# Show session state after automatic compaction (if triggered)
49+
items = await session.get_items()
50+
print("=== Session State (Auto Compaction) ===")
51+
print(f"Total items: {len(items)}")
52+
for item in items:
53+
item_type = item.get("type") or ("message" if "role" in item else "unknown")
54+
if item_type == "compaction":
55+
print(" - compaction (encrypted content)")
56+
elif item_type == "message":
57+
role = item.get("role", "unknown")
58+
print(f" - message ({role})")
59+
else:
60+
print(f" - {item_type}")
61+
print()
62+
63+
# Manual compaction in stateless mode.
64+
print("=== Manual Compaction ===")
65+
await session.run_compaction({"force": True})
66+
print("Done")
67+
print()
68+
69+
# Show final session state
70+
items = await session.get_items()
71+
print("=== Final Session State ===")
72+
print(f"Total items: {len(items)}")
73+
for item in items:
74+
item_type = item.get("type") or ("message" if "role" in item else "unknown")
75+
if item_type == "compaction":
76+
print(" - compaction (encrypted content)")
77+
elif item_type == "message":
78+
role = item.get("role", "unknown")
79+
print(f" - message ({role})")
80+
else:
81+
print(f" - {item_type}")
82+
83+
84+
if __name__ == "__main__":
85+
asyncio.run(main())

src/agents/memory/openai_responses_compaction_session.py

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

33
import logging
4-
from typing import TYPE_CHECKING, Any, Callable
4+
from typing import TYPE_CHECKING, Any, Callable, Literal
55

66
from openai import AsyncOpenAI
77

@@ -21,6 +21,8 @@
2121

2222
DEFAULT_COMPACTION_THRESHOLD = 10
2323

24+
OpenAIResponsesCompactionMode = Literal["previous_response_id", "input", "auto"]
25+
2426

2527
def select_compaction_candidate_items(
2628
items: list[TResponseInputItem],
@@ -85,6 +87,7 @@ def __init__(
8587
*,
8688
client: AsyncOpenAI | None = None,
8789
model: str = "gpt-4.1",
90+
compaction_mode: OpenAIResponsesCompactionMode = "auto",
8891
should_trigger_compaction: Callable[[dict[str, Any]], bool] | None = None,
8992
):
9093
"""Initialize the compaction session.
@@ -97,6 +100,9 @@ def __init__(
97100
get_default_openai_client() or new AsyncOpenAI().
98101
model: Model to use for responses.compact. Defaults to "gpt-4.1". Must be an
99102
OpenAI model name (gpt-*, o*, or ft:gpt-*).
103+
compaction_mode: Controls how the compaction request provides conversation
104+
history. "auto" (default) uses input when the last response was not
105+
stored or no response_id is available.
100106
should_trigger_compaction: Custom decision hook. Defaults to triggering when
101107
10+ compaction candidates exist.
102108
"""
@@ -113,6 +119,7 @@ def __init__(
113119
self.underlying_session = underlying_session
114120
self._client = client
115121
self.model = model
122+
self.compaction_mode = compaction_mode
116123
self.should_trigger_compaction = (
117124
should_trigger_compaction or default_should_trigger_compaction
118125
)
@@ -122,21 +129,54 @@ def __init__(
122129
self._session_items: list[TResponseInputItem] | None = None
123130
self._response_id: str | None = None
124131
self._deferred_response_id: str | None = None
132+
self._last_unstored_response_id: str | None = None
125133

126134
@property
127135
def client(self) -> AsyncOpenAI:
128136
if self._client is None:
129137
self._client = get_default_openai_client() or AsyncOpenAI()
130138
return self._client
131139

140+
def _resolve_compaction_mode_for_response(
141+
self,
142+
*,
143+
response_id: str | None,
144+
store: bool | None,
145+
requested_mode: OpenAIResponsesCompactionMode | None,
146+
) -> _ResolvedCompactionMode:
147+
mode = requested_mode or self.compaction_mode
148+
if (
149+
mode == "auto"
150+
and store is None
151+
and response_id is not None
152+
and response_id == self._last_unstored_response_id
153+
):
154+
return "input"
155+
return _resolve_compaction_mode(mode, response_id=response_id, store=store)
156+
132157
async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None) -> None:
133158
"""Run compaction using responses.compact API."""
134159
if args and args.get("response_id"):
135160
self._response_id = args["response_id"]
161+
requested_mode = args.get("compaction_mode") if args else None
162+
if args and "store" in args:
163+
store = args["store"]
164+
if store is False and self._response_id:
165+
self._last_unstored_response_id = self._response_id
166+
elif store is True and self._response_id == self._last_unstored_response_id:
167+
self._last_unstored_response_id = None
168+
else:
169+
store = None
170+
resolved_mode = self._resolve_compaction_mode_for_response(
171+
response_id=self._response_id,
172+
store=store,
173+
requested_mode=requested_mode,
174+
)
136175

137-
if not self._response_id:
176+
if resolved_mode == "previous_response_id" and not self._response_id:
138177
raise ValueError(
139-
"OpenAIResponsesCompactionSession.run_compaction requires a response_id"
178+
"OpenAIResponsesCompactionSession.run_compaction requires a response_id "
179+
"when using previous_response_id compaction."
140180
)
141181

142182
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
@@ -145,23 +185,32 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
145185
should_compact = force or self.should_trigger_compaction(
146186
{
147187
"response_id": self._response_id,
188+
"compaction_mode": resolved_mode,
148189
"compaction_candidate_items": compaction_candidate_items,
149190
"session_items": session_items,
150191
}
151192
)
152193

153194
if not should_compact:
154-
logger.debug(f"skip: decision hook declined compaction for {self._response_id}")
195+
logger.debug(
196+
f"skip: decision hook declined compaction for {self._response_id} "
197+
f"(mode={resolved_mode})"
198+
)
155199
return
156200

157201
self._deferred_response_id = None
158-
logger.debug(f"compact: start for {self._response_id} using {self.model}")
159-
160-
compacted = await self.client.responses.compact(
161-
previous_response_id=self._response_id,
162-
model=self.model,
202+
logger.debug(
203+
f"compact: start for {self._response_id} using {self.model} (mode={resolved_mode})"
163204
)
164205

206+
compact_kwargs: dict[str, Any] = {"model": self.model}
207+
if resolved_mode == "previous_response_id":
208+
compact_kwargs["previous_response_id"] = self._response_id
209+
else:
210+
compact_kwargs["input"] = session_items
211+
212+
compacted = await self.client.responses.compact(**compact_kwargs)
213+
165214
await self.underlying_session.clear_session()
166215
output_items: list[TResponseInputItem] = []
167216
if compacted.output:
@@ -183,19 +232,26 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
183232

184233
logger.debug(
185234
f"compact: done for {self._response_id} "
186-
f"(output={len(output_items)}, candidates={len(self._compaction_candidate_items)})"
235+
f"(mode={resolved_mode}, output={len(output_items)}, "
236+
f"candidates={len(self._compaction_candidate_items)})"
187237
)
188238

189239
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
190240
return await self.underlying_session.get_items(limit)
191241

192-
async def _defer_compaction(self, response_id: str) -> None:
242+
async def _defer_compaction(self, response_id: str, store: bool | None = None) -> None:
193243
if self._deferred_response_id is not None:
194244
return
195245
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
246+
resolved_mode = self._resolve_compaction_mode_for_response(
247+
response_id=response_id,
248+
store=store,
249+
requested_mode=None,
250+
)
196251
should_compact = self.should_trigger_compaction(
197252
{
198253
"response_id": response_id,
254+
"compaction_mode": resolved_mode,
199255
"compaction_candidate_items": compaction_candidate_items,
200256
"session_items": session_items,
201257
}
@@ -247,3 +303,21 @@ async def _ensure_compaction_candidates(
247303
f"candidates: initialized (history={len(history)}, candidates={len(candidates)})"
248304
)
249305
return (candidates[:], history[:])
306+
307+
308+
_ResolvedCompactionMode = Literal["previous_response_id", "input"]
309+
310+
311+
def _resolve_compaction_mode(
312+
requested_mode: OpenAIResponsesCompactionMode,
313+
*,
314+
response_id: str | None,
315+
store: bool | None,
316+
) -> _ResolvedCompactionMode:
317+
if requested_mode != "auto":
318+
return requested_mode
319+
if store is False:
320+
return "input"
321+
if not response_id:
322+
return "input"
323+
return "previous_response_id"

src/agents/memory/session.py

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

33
from abc import ABC, abstractmethod
4-
from typing import TYPE_CHECKING, Protocol, runtime_checkable
4+
from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
55

66
from typing_extensions import TypedDict, TypeGuard
77

@@ -107,6 +107,20 @@ class OpenAIResponsesCompactionArgs(TypedDict, total=False):
107107
response_id: str
108108
"""The ID of the last response to use for compaction."""
109109

110+
compaction_mode: Literal["previous_response_id", "input", "auto"]
111+
"""How to provide history for compaction.
112+
113+
- "auto": Use input when the last response was not stored or no response ID is available.
114+
- "previous_response_id": Use server-managed response history.
115+
- "input": Send locally stored session items as input.
116+
"""
117+
118+
store: bool
119+
"""Whether the last model response was stored on the server.
120+
121+
When set to False, compaction should avoid "previous_response_id" unless explicitly requested.
122+
"""
123+
110124
force: bool
111125
"""Whether to force compaction even if the threshold is not met."""
112126

0 commit comments

Comments
 (0)