Skip to content

Commit c728136

Browse files
committed
feat: Add responses.compact-wired session feature
1 parent 2a4a696 commit c728136

17 files changed

+691
-9
lines changed

.changeset/tall-tips-vanish.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@openai/agents-openai': patch
3+
'@openai/agents-core': patch
4+
---
5+
6+
feat: Add responses.compact-wired session feature

examples/memory/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
tmp/
22
*.db
3+
.agents-sessions/

examples/memory/oai-compact.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
Agent,
3+
OpenAIResponsesCompactionSession,
4+
run,
5+
withTrace,
6+
} from '@openai/agents';
7+
import { fetchImageData } from './tools';
8+
import { FileSession } from './sessions';
9+
10+
async function main() {
11+
const session = new OpenAIResponsesCompactionSession({
12+
// This compaction decorator handles only compaction logic.
13+
// The underlying session is responsible for storing the history.
14+
underlyingSession: new FileSession(),
15+
// Set a low threshold to observe compaction in action.
16+
compactionThreshold: 3,
17+
model: 'gpt-4.1',
18+
});
19+
20+
const agent = new Agent({
21+
name: 'Assistant',
22+
model: 'gpt-4.1',
23+
instructions:
24+
'Keep answers short. This example demonstrates responses.compact with a custom session. For every user turn, call fetch_image_data with the provided label. Do not include raw image bytes or data URLs in your final answer.',
25+
modelSettings: { toolChoice: 'required' },
26+
tools: [fetchImageData],
27+
});
28+
29+
// To see compaction debug logs, run with:
30+
// DEBUG=openai-agents:openai:compaction pnpm -C examples/memory start:oai-compact
31+
await withTrace('memory:compactSession:main', async () => {
32+
const prompts = [
33+
'Call fetch_image_data with label "alpha". Then explain compaction in 1 sentence.',
34+
'Call fetch_image_data with label "beta". Then add a fun fact about space in 1 sentence.',
35+
'Call fetch_image_data with label "gamma". Then add a fun fact about oceans in 1 sentence.',
36+
'Call fetch_image_data with label "delta". Then add a fun fact about volcanoes in 1 sentence.',
37+
'Call fetch_image_data with label "epsilon". Then add a fun fact about deserts in 1 sentence.',
38+
];
39+
40+
for (const prompt of prompts) {
41+
const result = await run(agent, prompt, { session, stream: true });
42+
console.log(`\nUser: ${prompt}`);
43+
44+
for await (const event of result.toStream()) {
45+
if (event.type === 'raw_model_stream_event') {
46+
continue;
47+
}
48+
if (event.type === 'agent_updated_stream_event') {
49+
continue;
50+
}
51+
if (event.type !== 'run_item_stream_event') {
52+
continue;
53+
}
54+
55+
if (event.item.type === 'tool_call_item') {
56+
const toolName = (event.item as any).rawItem?.name;
57+
console.log(`-- Tool called: ${toolName ?? '(unknown)'}`);
58+
} else if (event.item.type === 'tool_call_output_item') {
59+
console.log(
60+
`-- Tool output: ${formatToolOutputForLog((event.item as any).output)}`,
61+
);
62+
} else if (event.item.type === 'message_output_item') {
63+
console.log(`Assistant: ${event.item.content.trim()}`);
64+
}
65+
}
66+
}
67+
68+
const compactedHistory = await session.getItems();
69+
console.log('\nStored history after compaction:');
70+
for (const item of compactedHistory) {
71+
console.log(`- ${item.type}`);
72+
}
73+
});
74+
}
75+
76+
function formatToolOutputForLog(output: unknown): string {
77+
if (output === null) {
78+
return 'null';
79+
}
80+
if (output === undefined) {
81+
return 'undefined';
82+
}
83+
if (typeof output === 'string') {
84+
return output.length > 200 ? `${output.slice(0, 200)}…` : output;
85+
}
86+
if (Array.isArray(output)) {
87+
const parts = output.map((part) => formatToolOutputPartForLog(part));
88+
return `[${parts.join(', ')}]`;
89+
}
90+
if (typeof output === 'object') {
91+
const keys = Object.keys(output as Record<string, unknown>).sort();
92+
return `{${keys.slice(0, 10).join(', ')}${keys.length > 10 ? ', …' : ''}}`;
93+
}
94+
return String(output);
95+
}
96+
97+
function formatToolOutputPartForLog(part: unknown): string {
98+
if (!part || typeof part !== 'object') {
99+
return String(part);
100+
}
101+
const record = part as Record<string, unknown>;
102+
const type = typeof record.type === 'string' ? record.type : 'unknown';
103+
if (type === 'text' && typeof record.text === 'string') {
104+
return `text(${record.text.length} chars)`;
105+
}
106+
if (type === 'image' && typeof record.image === 'string') {
107+
return `image(${record.image.length} chars)`;
108+
}
109+
return type;
110+
}
111+
112+
main().catch((error) => {
113+
console.error(error);
114+
process.exit(1);
115+
});

examples/memory/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"start:memory-hitl": "tsx memory-hitl.ts",
1212
"start:oai": "tsx oai.ts",
1313
"start:oai-hitl": "tsx oai-hitl.ts",
14+
"start:oai-compact": "tsx oai-compact.ts",
1415
"start:file": "tsx file.ts",
1516
"start:file-hitl": "tsx file-hitl.ts",
1617
"start:prisma": "pnpm prisma db push --schema ./prisma/schema.prisma && pnpm prisma generate --schema ./prisma/schema.prisma && tsx prisma.ts"

packages/agents-core/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,13 @@ export type {
178178
StreamEventGenericItem,
179179
} from './types';
180180
export { RequestUsage, Usage } from './usage';
181-
export type { Session, SessionInputCallback } from './memory/session';
181+
export type {
182+
Session,
183+
SessionInputCallback,
184+
OpenAIResponsesCompactionArgs,
185+
OpenAIResponsesCompactionAwareSession,
186+
} from './memory/session';
187+
export { isOpenAIResponsesCompactionAwareSession } from './memory/session';
182188
export { MemorySession } from './memory/memorySession';
183189

184190
/**

packages/agents-core/src/memory/session.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,38 @@ export interface Session {
4343
*/
4444
clearSession(): Promise<void>;
4545
}
46+
47+
/**
48+
* Session subtype that can run compaction logic after a completed turn is persisted.
49+
*/
50+
export type OpenAIResponsesCompactionArgs = {
51+
/**
52+
* The `response.id` from a completed OpenAI Responses API turn, if available.
53+
*
54+
* When undefined, compaction should be skipped.
55+
*/
56+
responseId: string | undefined;
57+
};
58+
59+
export interface OpenAIResponsesCompactionAwareSession extends Session {
60+
/**
61+
* Invoked by the runner after it persists a completed turn into the session.
62+
*
63+
* Implementations may decide to call `responses.compact` (or an equivalent API) and replace the
64+
* stored history.
65+
*
66+
* This hook is best-effort. Implementations should consider handling transient failures and
67+
* deciding whether to retry or skip compaction for the current turn.
68+
*/
69+
runCompaction(args: OpenAIResponsesCompactionArgs): Promise<void> | void;
70+
}
71+
72+
export function isOpenAIResponsesCompactionAwareSession(
73+
session: Session | undefined,
74+
): session is OpenAIResponsesCompactionAwareSession {
75+
return (
76+
!!session &&
77+
typeof (session as OpenAIResponsesCompactionAwareSession).runCompaction ===
78+
'function'
79+
);
80+
}

packages/agents-core/src/runImplementation.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ import type { ApplyPatchResult } from './editor';
5959
import { RunState } from './runState';
6060
import { isZodObject } from './utils';
6161
import * as ProviderData from './types/providerData';
62-
import type { Session, SessionInputCallback } from './memory/session';
62+
import {
63+
isOpenAIResponsesCompactionAwareSession,
64+
type Session,
65+
type SessionInputCallback,
66+
} from './memory/session';
6367

6468
// Represents a single handoff function call that still needs to be executed after the model turn.
6569
type ToolRunHandoff = {
@@ -1360,12 +1364,25 @@ export async function executeFunctionToolCalls<TContext = UnknownContext>(
13601364

13611365
// Emit agent_tool_end even on error to maintain consistent event lifecycle
13621366
const errorResult = String(error);
1363-
runner.emit('agent_tool_end', state._context, agent, toolRun.tool, errorResult, {
1364-
toolCall: toolRun.toolCall,
1365-
});
1366-
agent.emit('agent_tool_end', state._context, toolRun.tool, errorResult, {
1367-
toolCall: toolRun.toolCall,
1368-
});
1367+
runner.emit(
1368+
'agent_tool_end',
1369+
state._context,
1370+
agent,
1371+
toolRun.tool,
1372+
errorResult,
1373+
{
1374+
toolCall: toolRun.toolCall,
1375+
},
1376+
);
1377+
agent.emit(
1378+
'agent_tool_end',
1379+
state._context,
1380+
toolRun.tool,
1381+
errorResult,
1382+
{
1383+
toolCall: toolRun.toolCall,
1384+
},
1385+
);
13691386

13701387
throw error;
13711388
}
@@ -2270,6 +2287,16 @@ function shouldStripIdForType(type: string): boolean {
22702287
}
22712288
}
22722289

2290+
async function runCompactionOnSession(
2291+
session: Session | undefined,
2292+
responseId: string | undefined,
2293+
): Promise<void> {
2294+
if (isOpenAIResponsesCompactionAwareSession(session)) {
2295+
// Called after a completed turn is persisted so compaction can consider the latest stored state.
2296+
await session.runCompaction({ responseId });
2297+
}
2298+
}
2299+
22732300
/**
22742301
* @internal
22752302
* Persist full turn (input + outputs) for non-streaming runs.
@@ -2299,10 +2326,12 @@ export async function saveToSession(
22992326
if (itemsToSave.length === 0) {
23002327
state._currentTurnPersistedItemCount =
23012328
alreadyPersisted + newRunItems.length;
2329+
await runCompactionOnSession(session, result.lastResponseId);
23022330
return;
23032331
}
23042332
const sanitizedItems = normalizeItemsForSessionPersistence(itemsToSave);
23052333
await session.addItems(sanitizedItems);
2334+
await runCompactionOnSession(session, result.lastResponseId);
23062335
state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length;
23072336
}
23082337

@@ -2344,10 +2373,12 @@ export async function saveStreamResultToSession(
23442373
if (itemsToSave.length === 0) {
23452374
state._currentTurnPersistedItemCount =
23462375
alreadyPersisted + newRunItems.length;
2376+
await runCompactionOnSession(session, result.lastResponseId);
23472377
return;
23482378
}
23492379
const sanitizedItems = normalizeItemsForSessionPersistence(itemsToSave);
23502380
await session.addItems(sanitizedItems);
2381+
await runCompactionOnSession(session, result.lastResponseId);
23512382
state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length;
23522383
}
23532384

packages/agents-core/src/types/aliases.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ApplyPatchCallItem,
1313
ApplyPatchCallResultItem,
1414
ReasoningItem,
15+
CompactionItem,
1516
UnknownItem,
1617
} from './protocol';
1718

@@ -42,6 +43,7 @@ export type AgentOutputItem =
4243
| ShellCallResultItem
4344
| ApplyPatchCallResultItem
4445
| ReasoningItem
46+
| CompactionItem
4547
| UnknownItem;
4648

4749
/**
@@ -61,4 +63,5 @@ export type AgentInputItem =
6163
| ShellCallResultItem
6264
| ApplyPatchCallResultItem
6365
| ReasoningItem
66+
| CompactionItem
6467
| UnknownItem;

packages/agents-core/src/types/protocol.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,24 @@ export const ReasoningItem = SharedBase.extend({
685685

686686
export type ReasoningItem = z.infer<typeof ReasoningItem>;
687687

688+
export const CompactionItem = ItemBase.extend({
689+
type: z.literal('compaction'),
690+
/**
691+
* Encrypted payload returned by the compaction endpoint.
692+
*/
693+
encrypted_content: z.string(),
694+
/**
695+
* Identifier for the compaction item.
696+
*/
697+
id: z.string().optional(),
698+
/**
699+
* Identifier for the generator of this compaction item.
700+
*/
701+
created_by: z.string().optional(),
702+
});
703+
704+
export type CompactionItem = z.infer<typeof CompactionItem>;
705+
688706
/**
689707
* This is a catch all for items that are not part of the protocol.
690708
*
@@ -715,6 +733,7 @@ export const OutputModelItem = z.discriminatedUnion('type', [
715733
ShellCallResultItem,
716734
ApplyPatchCallResultItem,
717735
ReasoningItem,
736+
CompactionItem,
718737
UnknownItem,
719738
]);
720739

@@ -734,6 +753,7 @@ export const ModelItem = z.union([
734753
ShellCallResultItem,
735754
ApplyPatchCallResultItem,
736755
ReasoningItem,
756+
CompactionItem,
737757
UnknownItem,
738758
]);
739759

0 commit comments

Comments
 (0)