Skip to content

Commit cb496bf

Browse files
committed
test: Add comprehensive tests for compaction session (#2206)
1 parent 6425481 commit cb496bf

File tree

4 files changed

+322
-20
lines changed

4 files changed

+322
-20
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Example demonstrating OpenAI responses.compact session functionality.
3+
4+
This example shows how to use OpenAIResponsesCompactionSession to automatically
5+
compact conversation history when it grows too large, reducing token usage
6+
while preserving context.
7+
"""
8+
9+
import asyncio
10+
11+
from agents import Agent, OpenAIResponsesCompactionSession, Runner, SQLiteSession
12+
13+
14+
async def main():
15+
# Create an underlying session for storage
16+
underlying = SQLiteSession(":memory:")
17+
18+
# Wrap with compaction session - will automatically compact when threshold hit
19+
session = OpenAIResponsesCompactionSession(
20+
session_id="demo-session",
21+
underlying_session=underlying,
22+
model="gpt-4.1",
23+
# Custom compaction trigger (default is 10 candidates)
24+
should_trigger_compaction=lambda ctx: len(ctx["compaction_candidate_items"]) >= 4,
25+
)
26+
27+
agent = Agent(
28+
name="Assistant",
29+
instructions="Reply concisely. Keep answers to 1-2 sentences.",
30+
)
31+
32+
print("=== Compaction Session Example ===\n")
33+
34+
prompts = [
35+
"What is the tallest mountain in the world?",
36+
"How tall is it in feet?",
37+
"When was it first climbed?",
38+
"Who was on that expedition?",
39+
"What country is the mountain in?",
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 final session state
49+
items = await session.get_items()
50+
print("=== Final Session State ===")
51+
print(f"Total items: {len(items)}")
52+
for item in items:
53+
item_type = item.get("type", "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+
62+
63+
if __name__ == "__main__":
64+
asyncio.run(main())

src/agents/items.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,14 +328,21 @@ class MCPApprovalResponseItem(RunItemBase[McpApprovalResponse]):
328328

329329

330330
@dataclass
331-
class CompactionItem(RunItemBase[dict[str, Any]]):
331+
class CompactionItem:
332332
"""Represents a compaction item from responses.compact."""
333333

334+
agent: Agent[Any]
335+
"""The agent whose run caused this item to be generated."""
336+
334337
raw_item: dict[str, Any]
335-
"""The raw compaction item."""
338+
"""The raw compaction item containing encrypted_content."""
336339

337340
type: Literal["compaction_item"] = "compaction_item"
338341

342+
def to_input_item(self) -> TResponseInputItem:
343+
"""Converts this item into an input item suitable for passing to the model."""
344+
return cast(TResponseInputItem, self.raw_item)
345+
339346

340347
RunItem: TypeAlias = Union[
341348
MessageOutputItem,

src/agents/memory/openai_responses_compaction_session.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@
2525
def select_compaction_candidate_items(
2626
items: list[TResponseInputItem],
2727
) -> list[TResponseInputItem]:
28-
"""Select items that are candidates for compaction.
28+
"""Select compaction candidate items.
2929
30-
Excludes:
31-
- User messages (type=message, role=user)
32-
- Compaction items (type=compaction)
30+
Excludes user messages and compaction items.
3331
"""
3432
return [
3533
item
@@ -52,7 +50,7 @@ def is_openai_model_name(model: str) -> bool:
5250
if not trimmed:
5351
return False
5452

55-
# Handle fine-tuned models: ft:gpt-4o-mini:org:proj:suffix
53+
# Handle fine-tuned models: ft:gpt-4.1:org:proj:suffix
5654
without_ft_prefix = trimmed[3:] if trimmed.startswith("ft:") else trimmed
5755
root = without_ft_prefix.split(":", 1)[0]
5856

@@ -68,8 +66,9 @@ def is_openai_model_name(model: str) -> bool:
6866
class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwareSession):
6967
"""Session decorator that triggers responses.compact when stored history grows.
7068
71-
Wraps any Session (except OpenAIConversationsSession) and automatically calls
72-
the OpenAI responses.compact API after each turn when the decision hook returns True.
69+
Works with OpenAI Responses API models only. Wraps any Session (except
70+
OpenAIConversationsSession) and automatically calls the OpenAI responses.compact
71+
API after each turn when the decision hook returns True.
7372
"""
7473

7574
def __init__(
@@ -78,9 +77,22 @@ def __init__(
7877
underlying_session: Session,
7978
*,
8079
client: AsyncOpenAI | None = None,
81-
model: str = "gpt-4o",
80+
model: str = "gpt-4.1",
8281
should_trigger_compaction: Callable[[dict[str, Any]], bool] | None = None,
8382
):
83+
"""Initialize the compaction session.
84+
85+
Args:
86+
session_id: Identifier for this session.
87+
underlying_session: Session store that holds the compacted history. Cannot be
88+
OpenAIConversationsSession.
89+
client: OpenAI client for responses.compact API calls. Defaults to
90+
get_default_openai_client() or new AsyncOpenAI().
91+
model: Model to use for responses.compact. Defaults to "gpt-4.1". Must be an
92+
OpenAI model name (gpt-*, o*, or ft:gpt-*).
93+
should_trigger_compaction: Custom decision hook. Defaults to triggering when
94+
10+ compaction candidates exist.
95+
"""
8496
if isinstance(underlying_session, OpenAIConversationsSession):
8597
raise ValueError(
8698
"OpenAIResponsesCompactionSession cannot wrap OpenAIConversationsSession "
@@ -119,10 +131,8 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
119131
"OpenAIResponsesCompactionSession.run_compaction requires a response_id"
120132
)
121133

122-
# Get compaction candidates
123134
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
124135

125-
# Check if should compact
126136
force = args.get("force", False) if args else False
127137
should_compact = force or self.should_trigger_compaction(
128138
{
@@ -138,18 +148,14 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
138148

139149
logger.debug(f"compact: start for {self._response_id} using {self.model}")
140150

141-
# Call OpenAI responses.compact API
142151
compacted = await self.client.responses.compact(
143152
previous_response_id=self._response_id,
144153
model=self.model,
145154
)
146155

147-
# Replace entire session with compacted output
148156
await self.underlying_session.clear_session()
149157
output_items: list[TResponseInputItem] = []
150158
if compacted.output:
151-
# We assume output items from API are compatible with input items (dicts)
152-
# or we cast them accordingly. The SDK types usually allow this.
153159
for item in compacted.output:
154160
if isinstance(item, dict):
155161
output_items.append(item)
@@ -159,7 +165,6 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
159165
if output_items:
160166
await self.underlying_session.add_items(output_items)
161167

162-
# Update caches
163168
self._compaction_candidate_items = select_compaction_candidate_items(output_items)
164169
self._session_items = output_items
165170

@@ -168,13 +173,11 @@ async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None
168173
f"(output={len(output_items)}, candidates={len(self._compaction_candidate_items)})"
169174
)
170175

171-
# Delegate all Session methods to underlying_session
172176
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
173177
return await self.underlying_session.get_items(limit)
174178

175179
async def add_items(self, items: list[TResponseInputItem]) -> None:
176180
await self.underlying_session.add_items(items)
177-
# Update caches incrementally
178181
if self._compaction_candidate_items is not None:
179182
new_candidates = select_compaction_candidate_items(items)
180183
if new_candidates:
@@ -184,7 +187,6 @@ async def add_items(self, items: list[TResponseInputItem]) -> None:
184187

185188
async def pop_item(self) -> TResponseInputItem | None:
186189
popped = await self.underlying_session.pop_item()
187-
# Invalidate caches on pop (simple approach)
188190
if popped:
189191
self._compaction_candidate_items = None
190192
self._session_items = None

0 commit comments

Comments
 (0)