Skip to content

Commit 09443fd

Browse files
authored
feat: #2206 Add responses.compact: auto-compact long conversations (#2224)
1 parent b5d9152 commit 09443fd

File tree

10 files changed

+679
-9
lines changed

10 files changed

+679
-9
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
# Some inputs are stored as easy messages (only `role` and `content`).
54+
item_type = item.get("type") or ("message" if "role" in item else "unknown")
55+
if item_type == "compaction":
56+
print(" - compaction (encrypted content)")
57+
elif item_type == "message":
58+
role = item.get("role", "unknown")
59+
print(f" - message ({role})")
60+
else:
61+
print(f" - {item_type}")
62+
63+
64+
if __name__ == "__main__":
65+
asyncio.run(main())

src/agents/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
set_conversation_history_wrappers,
5050
)
5151
from .items import (
52+
CompactionItem,
5253
HandoffCallItem,
5354
HandoffOutputItem,
5455
ItemHelpers,
@@ -63,9 +64,13 @@
6364
from .lifecycle import AgentHooks, RunHooks
6465
from .memory import (
6566
OpenAIConversationsSession,
67+
OpenAIResponsesCompactionArgs,
68+
OpenAIResponsesCompactionAwareSession,
69+
OpenAIResponsesCompactionSession,
6670
Session,
6771
SessionABC,
6872
SQLiteSession,
73+
is_openai_responses_compaction_aware_session,
6974
)
7075
from .model_settings import ModelSettings
7176
from .models.interface import Model, ModelProvider, ModelTracing
@@ -291,6 +296,11 @@ def enable_verbose_stdout_logging():
291296
"SessionABC",
292297
"SQLiteSession",
293298
"OpenAIConversationsSession",
299+
"OpenAIResponsesCompactionSession",
300+
"OpenAIResponsesCompactionArgs",
301+
"OpenAIResponsesCompactionAwareSession",
302+
"is_openai_responses_compaction_aware_session",
303+
"CompactionItem",
294304
"AgentHookContext",
295305
"RunContextWrapper",
296306
"TContext",

src/agents/_run_impl.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult
5858
from .handoffs import Handoff, HandoffInputData, nest_handoff_history
5959
from .items import (
60+
CompactionItem,
6061
HandoffCallItem,
6162
HandoffOutputItem,
6263
ItemHelpers,
@@ -540,6 +541,9 @@ def process_model_response(
540541
logger.debug("Queuing shell_call %s", call_identifier)
541542
shell_calls.append(ToolRunShellCall(tool_call=output, shell_tool=shell_tool))
542543
continue
544+
if output_type == "compaction":
545+
items.append(CompactionItem(raw_item=cast(dict[str, Any], output), agent=agent))
546+
continue
543547
if output_type == "apply_patch_call":
544548
items.append(ToolCallItem(raw_item=cast(Any, output), agent=agent))
545549
if apply_patch_tool:

src/agents/items.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,23 @@ class MCPApprovalResponseItem(RunItemBase[McpApprovalResponse]):
327327
type: Literal["mcp_approval_response_item"] = "mcp_approval_response_item"
328328

329329

330+
@dataclass
331+
class CompactionItem:
332+
"""Represents a compaction item from responses.compact."""
333+
334+
agent: Agent[Any]
335+
"""The agent whose run caused this item to be generated."""
336+
337+
raw_item: dict[str, Any]
338+
"""The raw compaction item containing encrypted_content."""
339+
340+
type: Literal["compaction_item"] = "compaction_item"
341+
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+
346+
330347
RunItem: TypeAlias = Union[
331348
MessageOutputItem,
332349
HandoffCallItem,
@@ -337,6 +354,7 @@ class MCPApprovalResponseItem(RunItemBase[McpApprovalResponse]):
337354
MCPListToolsItem,
338355
MCPApprovalRequestItem,
339356
MCPApprovalResponseItem,
357+
CompactionItem,
340358
]
341359
"""An item generated by an agent."""
342360

src/agents/memory/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
from .openai_conversations_session import OpenAIConversationsSession
2-
from .session import Session, SessionABC
2+
from .openai_responses_compaction_session import OpenAIResponsesCompactionSession
3+
from .session import (
4+
OpenAIResponsesCompactionArgs,
5+
OpenAIResponsesCompactionAwareSession,
6+
Session,
7+
SessionABC,
8+
is_openai_responses_compaction_aware_session,
9+
)
310
from .sqlite_session import SQLiteSession
411
from .util import SessionInputCallback
512

@@ -9,4 +16,8 @@
916
"SessionInputCallback",
1017
"SQLiteSession",
1118
"OpenAIConversationsSession",
19+
"OpenAIResponsesCompactionSession",
20+
"OpenAIResponsesCompactionArgs",
21+
"OpenAIResponsesCompactionAwareSession",
22+
"is_openai_responses_compaction_aware_session",
1223
]
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import TYPE_CHECKING, Any, Callable
5+
6+
from openai import AsyncOpenAI
7+
8+
from ..models._openai_shared import get_default_openai_client
9+
from .openai_conversations_session import OpenAIConversationsSession
10+
from .session import (
11+
OpenAIResponsesCompactionArgs,
12+
OpenAIResponsesCompactionAwareSession,
13+
SessionABC,
14+
)
15+
16+
if TYPE_CHECKING:
17+
from ..items import TResponseInputItem
18+
from .session import Session
19+
20+
logger = logging.getLogger("openai-agents.openai.compaction")
21+
22+
DEFAULT_COMPACTION_THRESHOLD = 10
23+
24+
25+
def select_compaction_candidate_items(
26+
items: list[TResponseInputItem],
27+
) -> list[TResponseInputItem]:
28+
"""Select compaction candidate items.
29+
30+
Excludes user messages and compaction items.
31+
"""
32+
33+
def _is_user_message(item: TResponseInputItem) -> bool:
34+
if not isinstance(item, dict):
35+
return False
36+
if item.get("type") == "message":
37+
return item.get("role") == "user"
38+
return item.get("role") == "user" and "content" in item
39+
40+
return [
41+
item
42+
for item in items
43+
if not (
44+
_is_user_message(item) or (isinstance(item, dict) and item.get("type") == "compaction")
45+
)
46+
]
47+
48+
49+
def default_should_trigger_compaction(context: dict[str, Any]) -> bool:
50+
"""Default decision: compact when >= 10 candidate items exist."""
51+
return len(context["compaction_candidate_items"]) >= DEFAULT_COMPACTION_THRESHOLD
52+
53+
54+
def is_openai_model_name(model: str) -> bool:
55+
"""Validate model name follows OpenAI conventions."""
56+
trimmed = model.strip()
57+
if not trimmed:
58+
return False
59+
60+
# Handle fine-tuned models: ft:gpt-4.1:org:proj:suffix
61+
without_ft_prefix = trimmed[3:] if trimmed.startswith("ft:") else trimmed
62+
root = without_ft_prefix.split(":", 1)[0]
63+
64+
# Allow gpt-* and o* models
65+
if root.startswith("gpt-"):
66+
return True
67+
if root.startswith("o") and root[1:2].isdigit():
68+
return True
69+
70+
return False
71+
72+
73+
class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwareSession):
74+
"""Session decorator that triggers responses.compact when stored history grows.
75+
76+
Works with OpenAI Responses API models only. Wraps any Session (except
77+
OpenAIConversationsSession) and automatically calls the OpenAI responses.compact
78+
API after each turn when the decision hook returns True.
79+
"""
80+
81+
def __init__(
82+
self,
83+
session_id: str,
84+
underlying_session: Session,
85+
*,
86+
client: AsyncOpenAI | None = None,
87+
model: str = "gpt-4.1",
88+
should_trigger_compaction: Callable[[dict[str, Any]], bool] | None = None,
89+
):
90+
"""Initialize the compaction session.
91+
92+
Args:
93+
session_id: Identifier for this session.
94+
underlying_session: Session store that holds the compacted history. Cannot be
95+
OpenAIConversationsSession.
96+
client: OpenAI client for responses.compact API calls. Defaults to
97+
get_default_openai_client() or new AsyncOpenAI().
98+
model: Model to use for responses.compact. Defaults to "gpt-4.1". Must be an
99+
OpenAI model name (gpt-*, o*, or ft:gpt-*).
100+
should_trigger_compaction: Custom decision hook. Defaults to triggering when
101+
10+ compaction candidates exist.
102+
"""
103+
if isinstance(underlying_session, OpenAIConversationsSession):
104+
raise ValueError(
105+
"OpenAIResponsesCompactionSession cannot wrap OpenAIConversationsSession "
106+
"because it manages its own history on the server."
107+
)
108+
109+
if not is_openai_model_name(model):
110+
raise ValueError(f"Unsupported model for OpenAI responses compaction: {model}")
111+
112+
self.session_id = session_id
113+
self.underlying_session = underlying_session
114+
self._client = client
115+
self.model = model
116+
self.should_trigger_compaction = (
117+
should_trigger_compaction or default_should_trigger_compaction
118+
)
119+
120+
# cache for incremental candidate tracking
121+
self._compaction_candidate_items: list[TResponseInputItem] | None = None
122+
self._session_items: list[TResponseInputItem] | None = None
123+
self._response_id: str | None = None
124+
125+
@property
126+
def client(self) -> AsyncOpenAI:
127+
if self._client is None:
128+
self._client = get_default_openai_client() or AsyncOpenAI()
129+
return self._client
130+
131+
async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None) -> None:
132+
"""Run compaction using responses.compact API."""
133+
if args and args.get("response_id"):
134+
self._response_id = args["response_id"]
135+
136+
if not self._response_id:
137+
raise ValueError(
138+
"OpenAIResponsesCompactionSession.run_compaction requires a response_id"
139+
)
140+
141+
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
142+
143+
force = args.get("force", False) if args else False
144+
should_compact = force or self.should_trigger_compaction(
145+
{
146+
"response_id": self._response_id,
147+
"compaction_candidate_items": compaction_candidate_items,
148+
"session_items": session_items,
149+
}
150+
)
151+
152+
if not should_compact:
153+
logger.debug(f"skip: decision hook declined compaction for {self._response_id}")
154+
return
155+
156+
logger.debug(f"compact: start for {self._response_id} using {self.model}")
157+
158+
compacted = await self.client.responses.compact(
159+
previous_response_id=self._response_id,
160+
model=self.model,
161+
)
162+
163+
await self.underlying_session.clear_session()
164+
output_items: list[TResponseInputItem] = []
165+
if compacted.output:
166+
for item in compacted.output:
167+
if isinstance(item, dict):
168+
output_items.append(item)
169+
else:
170+
# Suppress Pydantic literal warnings: responses.compact can return
171+
# user-style input_text content inside ResponseOutputMessage.
172+
output_items.append(
173+
item.model_dump(exclude_unset=True, warnings=False) # type: ignore
174+
)
175+
176+
if output_items:
177+
await self.underlying_session.add_items(output_items)
178+
179+
self._compaction_candidate_items = select_compaction_candidate_items(output_items)
180+
self._session_items = output_items
181+
182+
logger.debug(
183+
f"compact: done for {self._response_id} "
184+
f"(output={len(output_items)}, candidates={len(self._compaction_candidate_items)})"
185+
)
186+
187+
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
188+
return await self.underlying_session.get_items(limit)
189+
190+
async def add_items(self, items: list[TResponseInputItem]) -> None:
191+
await self.underlying_session.add_items(items)
192+
if self._compaction_candidate_items is not None:
193+
new_candidates = select_compaction_candidate_items(items)
194+
if new_candidates:
195+
self._compaction_candidate_items.extend(new_candidates)
196+
if self._session_items is not None:
197+
self._session_items.extend(items)
198+
199+
async def pop_item(self) -> TResponseInputItem | None:
200+
popped = await self.underlying_session.pop_item()
201+
if popped:
202+
self._compaction_candidate_items = None
203+
self._session_items = None
204+
return popped
205+
206+
async def clear_session(self) -> None:
207+
await self.underlying_session.clear_session()
208+
self._compaction_candidate_items = []
209+
self._session_items = []
210+
211+
async def _ensure_compaction_candidates(
212+
self,
213+
) -> tuple[list[TResponseInputItem], list[TResponseInputItem]]:
214+
"""Lazy-load and cache compaction candidates."""
215+
if self._compaction_candidate_items is not None and self._session_items is not None:
216+
return (self._compaction_candidate_items[:], self._session_items[:])
217+
218+
history = await self.underlying_session.get_items()
219+
candidates = select_compaction_candidate_items(history)
220+
self._compaction_candidate_items = candidates
221+
self._session_items = history
222+
223+
logger.debug(
224+
f"candidates: initialized (history={len(history)}, candidates={len(candidates)})"
225+
)
226+
return (candidates[:], history[:])

0 commit comments

Comments
 (0)