Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tall-tips-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@openai/agents-openai': patch
'@openai/agents-core': patch
---

feat: Add responses.compact-wired session feature
1 change: 1 addition & 0 deletions examples/memory/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
tmp/
*.db
.agents-sessions/
129 changes: 129 additions & 0 deletions examples/memory/oai-compact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
Agent,
OpenAIResponsesCompactionSession,
run,
withTrace,
} from '@openai/agents';
import { fetchImageData } from './tools';
import { FileSession } from './sessions';

async function main() {
const session = new OpenAIResponsesCompactionSession({
model: 'gpt-5.2',
// This compaction decorator handles only compaction logic.
// The underlying session is responsible for storing the history.
underlyingSession: new FileSession(),
// (optional customization) This example demonstrates the simplest compaction logic,
// but you can also estimate the context window size using sessionItems (all items)
// and trigger compaction at the optimal time.
shouldTriggerCompaction: ({ compactionCandidateItems }) => {
// Set a low threshold to observe compaction in action.
return compactionCandidateItems.length >= 4;
},
});

const agent = new Agent({
name: 'Assistant',
model: 'gpt-5.2',
instructions:
'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.',
modelSettings: { toolChoice: 'required' },
tools: [fetchImageData],
});

// To see compaction debug logs, run with:
// DEBUG=openai-agents:openai:compaction pnpm -C examples/memory start:oai-compact
await withTrace('memory:compactSession:main', async () => {
const prompts = [
'Call fetch_image_data with label "alpha". Then explain compaction in 1 sentence.',
'Call fetch_image_data with label "beta". Then add a fun fact about space in 1 sentence.',
'Call fetch_image_data with label "gamma". Then add a fun fact about oceans in 1 sentence.',
'Call fetch_image_data with label "delta". Then add a fun fact about volcanoes in 1 sentence.',
'Call fetch_image_data with label "epsilon". Then add a fun fact about deserts in 1 sentence.',
];

for (const prompt of prompts) {
const result = await run(agent, prompt, { session, stream: true });
console.log(`\nUser: ${prompt}`);

for await (const event of result.toStream()) {
if (event.type === 'raw_model_stream_event') {
continue;
}
if (event.type === 'agent_updated_stream_event') {
continue;
}
if (event.type !== 'run_item_stream_event') {
continue;
}

if (event.item.type === 'tool_call_item') {
const toolName = (event.item as any).rawItem?.name;
console.log(`-- Tool called: ${toolName ?? '(unknown)'}`);
} else if (event.item.type === 'tool_call_output_item') {
console.log(
`-- Tool output: ${formatToolOutputForLog((event.item as any).output)}`,
);
} else if (event.item.type === 'message_output_item') {
console.log(`Assistant: ${event.item.content.trim()}`);
}
}
}

const compactedHistory = await session.getItems();
console.log('\nHitory including both compaction and newer items:');
for (const item of compactedHistory) {
console.log(`- ${item.type}`);
}

// You can manually run compaction this way:
await session.runCompaction({ force: true });

const finalHistory = await session.getItems();
console.log('\nStored history after final compaction:');
for (const item of finalHistory) {
console.log(`- ${item.type}`);
}
});
}

function formatToolOutputForLog(output: unknown): string {
if (output === null) {
return 'null';
}
if (output === undefined) {
return 'undefined';
}
if (typeof output === 'string') {
return output.length > 200 ? `${output.slice(0, 200)}…` : output;
}
if (Array.isArray(output)) {
const parts = output.map((part) => formatToolOutputPartForLog(part));
return `[${parts.join(', ')}]`;
}
if (typeof output === 'object') {
const keys = Object.keys(output as Record<string, unknown>).sort();
return `{${keys.slice(0, 10).join(', ')}${keys.length > 10 ? ', …' : ''}}`;
}
return String(output);
}

function formatToolOutputPartForLog(part: unknown): string {
if (!part || typeof part !== 'object') {
return String(part);
}
const record = part as Record<string, unknown>;
const type = typeof record.type === 'string' ? record.type : 'unknown';
if (type === 'text' && typeof record.text === 'string') {
return `text(${record.text.length} chars)`;
}
if (type === 'image' && typeof record.image === 'string') {
return `image(${record.image.length} chars)`;
}
return type;
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
1 change: 1 addition & 0 deletions examples/memory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"start:memory-hitl": "tsx memory-hitl.ts",
"start:oai": "tsx oai.ts",
"start:oai-hitl": "tsx oai-hitl.ts",
"start:oai-compact": "tsx oai-compact.ts",
"start:file": "tsx file.ts",
"start:file-hitl": "tsx file-hitl.ts",
"start:prisma": "pnpm prisma db push --schema ./prisma/schema.prisma && pnpm prisma generate --schema ./prisma/schema.prisma && tsx prisma.ts"
Expand Down
8 changes: 7 additions & 1 deletion packages/agents-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,13 @@ export type {
StreamEventGenericItem,
} from './types';
export { RequestUsage, Usage } from './usage';
export type { Session, SessionInputCallback } from './memory/session';
export type {
Session,
SessionInputCallback,
OpenAIResponsesCompactionArgs,
OpenAIResponsesCompactionAwareSession,
} from './memory/session';
export { isOpenAIResponsesCompactionAwareSession } from './memory/session';
export { MemorySession } from './memory/memorySession';

/**
Expand Down
39 changes: 39 additions & 0 deletions packages/agents-core/src/memory/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,42 @@ export interface Session {
*/
clearSession(): Promise<void>;
}

/**
* Session subtype that can run compaction logic after a completed turn is persisted.
*/
export type OpenAIResponsesCompactionArgs = {
/**
* The `response.id` from a completed OpenAI Responses API turn, if available.
*
* When omitted, implementations may fall back to a cached value or throw.
*/
responseId?: string | undefined;
/**
* When true, compaction should run regardless of any internal thresholds or hooks.
*/
force?: boolean;
};

export interface OpenAIResponsesCompactionAwareSession extends Session {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this type of Session extends the regular session can't we just call compaction inside the addItem part of Session rather than introducing a new subtype? My understanding was that the point of Session was that it would decide what to store and how to store it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you use responses.compact API with an external session store, your code has to clear all items (at least all non-user-message items) associated with the session ID first, then re-insert everything (N user messages + 1 compaction item). Because of that, it doesn’t fit well with addItems / getItems method customization.

That said, it's still feasible to do above in addItems method. Another benefit of the current design is that you can use this new subclass as a decorator-design-pattern style wrapper around your existing session store. This enables developers to reuse this single logic without having similar logic within their code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't change this for the above reason.

/**
* Invoked by the runner after it persists a completed turn into the session.
*
* Implementations may decide to call `responses.compact` (or an equivalent API) and replace the
* stored history.
*
* This hook is best-effort. Implementations should consider handling transient failures and
* deciding whether to retry or skip compaction for the current turn.
*/
runCompaction(args?: OpenAIResponsesCompactionArgs): Promise<void> | void;
}

export function isOpenAIResponsesCompactionAwareSession(
session: Session | undefined,
): session is OpenAIResponsesCompactionAwareSession {
return (
!!session &&
typeof (session as OpenAIResponsesCompactionAwareSession).runCompaction ===
'function'
);
}
48 changes: 41 additions & 7 deletions packages/agents-core/src/runImplementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ import type { ApplyPatchResult } from './editor';
import { RunState } from './runState';
import { isZodObject } from './utils';
import * as ProviderData from './types/providerData';
import type { Session, SessionInputCallback } from './memory/session';
import {
isOpenAIResponsesCompactionAwareSession,
type Session,
type SessionInputCallback,
} from './memory/session';

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

// Emit agent_tool_end even on error to maintain consistent event lifecycle
const errorResult = String(error);
runner.emit('agent_tool_end', state._context, agent, toolRun.tool, errorResult, {
toolCall: toolRun.toolCall,
});
agent.emit('agent_tool_end', state._context, toolRun.tool, errorResult, {
toolCall: toolRun.toolCall,
});
runner.emit(
'agent_tool_end',
state._context,
agent,
toolRun.tool,
errorResult,
{
toolCall: toolRun.toolCall,
},
);
agent.emit(
'agent_tool_end',
state._context,
toolRun.tool,
errorResult,
{
toolCall: toolRun.toolCall,
},
);

throw error;
}
Expand Down Expand Up @@ -2270,6 +2287,19 @@ function shouldStripIdForType(type: string): boolean {
}
}

async function runCompactionOnSession(
session: Session | undefined,
responseId: string | undefined,
): Promise<void> {
if (!isOpenAIResponsesCompactionAwareSession(session)) {
return;
}
// Called after a completed turn is persisted so compaction can consider the latest stored state.
await session.runCompaction(
typeof responseId === 'undefined' ? undefined : { responseId },
);
}

/**
* @internal
* Persist full turn (input + outputs) for non-streaming runs.
Expand Down Expand Up @@ -2299,10 +2329,12 @@ export async function saveToSession(
if (itemsToSave.length === 0) {
state._currentTurnPersistedItemCount =
alreadyPersisted + newRunItems.length;
await runCompactionOnSession(session, result.lastResponseId);
return;
}
const sanitizedItems = normalizeItemsForSessionPersistence(itemsToSave);
await session.addItems(sanitizedItems);
await runCompactionOnSession(session, result.lastResponseId);
state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length;
}

Expand Down Expand Up @@ -2344,10 +2376,12 @@ export async function saveStreamResultToSession(
if (itemsToSave.length === 0) {
state._currentTurnPersistedItemCount =
alreadyPersisted + newRunItems.length;
await runCompactionOnSession(session, result.lastResponseId);
return;
}
const sanitizedItems = normalizeItemsForSessionPersistence(itemsToSave);
await session.addItems(sanitizedItems);
await runCompactionOnSession(session, result.lastResponseId);
state._currentTurnPersistedItemCount = alreadyPersisted + newRunItems.length;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/agents-core/src/types/aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ApplyPatchCallItem,
ApplyPatchCallResultItem,
ReasoningItem,
CompactionItem,
UnknownItem,
} from './protocol';

Expand Down Expand Up @@ -42,6 +43,7 @@ export type AgentOutputItem =
| ShellCallResultItem
| ApplyPatchCallResultItem
| ReasoningItem
| CompactionItem
| UnknownItem;

/**
Expand All @@ -61,4 +63,5 @@ export type AgentInputItem =
| ShellCallResultItem
| ApplyPatchCallResultItem
| ReasoningItem
| CompactionItem
| UnknownItem;
20 changes: 20 additions & 0 deletions packages/agents-core/src/types/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,24 @@ export const ReasoningItem = SharedBase.extend({

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

export const CompactionItem = ItemBase.extend({
type: z.literal('compaction'),
/**
* Encrypted payload returned by the compaction endpoint.
*/
encrypted_content: z.string(),
/**
* Identifier for the compaction item.
*/
id: z.string().optional(),
/**
* Identifier for the generator of this compaction item.
*/
created_by: z.string().optional(),
});

export type CompactionItem = z.infer<typeof CompactionItem>;

/**
* This is a catch all for items that are not part of the protocol.
*
Expand Down Expand Up @@ -715,6 +733,7 @@ export const OutputModelItem = z.discriminatedUnion('type', [
ShellCallResultItem,
ApplyPatchCallResultItem,
ReasoningItem,
CompactionItem,
UnknownItem,
]);

Expand All @@ -734,6 +753,7 @@ export const ModelItem = z.union([
ShellCallResultItem,
ApplyPatchCallResultItem,
ReasoningItem,
CompactionItem,
UnknownItem,
]);

Expand Down
Loading