diff --git a/.changeset/tall-tips-vanish.md b/.changeset/tall-tips-vanish.md new file mode 100644 index 00000000..296918fb --- /dev/null +++ b/.changeset/tall-tips-vanish.md @@ -0,0 +1,6 @@ +--- +'@openai/agents-openai': patch +'@openai/agents-core': patch +--- + +feat: Add responses.compact-wired session feature diff --git a/examples/memory/.gitignore b/examples/memory/.gitignore index 9a1c3101..317c5a90 100644 --- a/examples/memory/.gitignore +++ b/examples/memory/.gitignore @@ -1,2 +1,3 @@ tmp/ *.db +.agents-sessions/ diff --git a/examples/memory/oai-compact.ts b/examples/memory/oai-compact.ts new file mode 100644 index 00000000..8fa78d16 --- /dev/null +++ b/examples/memory/oai-compact.ts @@ -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).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; + 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); +}); diff --git a/examples/memory/package.json b/examples/memory/package.json index a46a1d19..269d3696 100644 --- a/examples/memory/package.json +++ b/examples/memory/package.json @@ -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" diff --git a/packages/agents-core/src/index.ts b/packages/agents-core/src/index.ts index b993c732..e59545c3 100644 --- a/packages/agents-core/src/index.ts +++ b/packages/agents-core/src/index.ts @@ -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'; /** diff --git a/packages/agents-core/src/memory/session.ts b/packages/agents-core/src/memory/session.ts index 9afbf14f..1e8ae454 100644 --- a/packages/agents-core/src/memory/session.ts +++ b/packages/agents-core/src/memory/session.ts @@ -43,3 +43,42 @@ export interface Session { */ clearSession(): Promise; } + +/** + * 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 { + /** + * 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; +} + +export function isOpenAIResponsesCompactionAwareSession( + session: Session | undefined, +): session is OpenAIResponsesCompactionAwareSession { + return ( + !!session && + typeof (session as OpenAIResponsesCompactionAwareSession).runCompaction === + 'function' + ); +} diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index bd66582e..2417a459 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -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 = { @@ -1360,12 +1364,25 @@ export async function executeFunctionToolCalls( // 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; } @@ -2270,6 +2287,19 @@ function shouldStripIdForType(type: string): boolean { } } +async function runCompactionOnSession( + session: Session | undefined, + responseId: string | undefined, +): Promise { + 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. @@ -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; } @@ -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; } diff --git a/packages/agents-core/src/types/aliases.ts b/packages/agents-core/src/types/aliases.ts index 828495d3..3bd39944 100644 --- a/packages/agents-core/src/types/aliases.ts +++ b/packages/agents-core/src/types/aliases.ts @@ -12,6 +12,7 @@ import { ApplyPatchCallItem, ApplyPatchCallResultItem, ReasoningItem, + CompactionItem, UnknownItem, } from './protocol'; @@ -42,6 +43,7 @@ export type AgentOutputItem = | ShellCallResultItem | ApplyPatchCallResultItem | ReasoningItem + | CompactionItem | UnknownItem; /** @@ -61,4 +63,5 @@ export type AgentInputItem = | ShellCallResultItem | ApplyPatchCallResultItem | ReasoningItem + | CompactionItem | UnknownItem; diff --git a/packages/agents-core/src/types/protocol.ts b/packages/agents-core/src/types/protocol.ts index 8d292b97..581bf1ec 100644 --- a/packages/agents-core/src/types/protocol.ts +++ b/packages/agents-core/src/types/protocol.ts @@ -685,6 +685,24 @@ export const ReasoningItem = SharedBase.extend({ export type ReasoningItem = z.infer; +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; + /** * This is a catch all for items that are not part of the protocol. * @@ -715,6 +733,7 @@ export const OutputModelItem = z.discriminatedUnion('type', [ ShellCallResultItem, ApplyPatchCallResultItem, ReasoningItem, + CompactionItem, UnknownItem, ]); @@ -734,6 +753,7 @@ export const ModelItem = z.union([ ShellCallResultItem, ApplyPatchCallResultItem, ReasoningItem, + CompactionItem, UnknownItem, ]); diff --git a/packages/agents-core/test/runImplementation.test.ts b/packages/agents-core/test/runImplementation.test.ts index 68ffb987..8dda7497 100644 --- a/packages/agents-core/test/runImplementation.test.ts +++ b/packages/agents-core/test/runImplementation.test.ts @@ -550,6 +550,168 @@ describe('saveToSession', () => { expect(latest.type).toBe('function_call_result'); expect(latest.callId).toBe(approvalCall.callId); }); + + it('propagates lastResponseId to sessions after persisting items', async () => { + class TrackingSession implements Session { + items: AgentInputItem[] = []; + events: string[] = []; + + async getSessionId(): Promise { + return 'session'; + } + + async getItems(): Promise { + return [...this.items]; + } + + async addItems(items: AgentInputItem[]): Promise { + this.events.push(`addItems:${items.length}`); + this.items.push(...items); + } + + async popItem(): Promise { + return undefined; + } + + async clearSession(): Promise { + this.items = []; + } + + async runCompaction(args: { + responseId: string | undefined; + }): Promise { + this.events.push(`runCompaction:${args.responseId}`); + } + } + + const textAgent = new Agent({ + name: 'Recorder', + outputType: 'text', + instructions: 'capture', + }); + const agent = textAgent as unknown as Agent< + UnknownContext, + AgentOutputType + >; + const session = new TrackingSession(); + const context = new RunContext(undefined as UnknownContext); + const state = new RunState< + UnknownContext, + Agent + >(context, 'hello', agent, 10); + + state._modelResponses.push({ + output: [], + usage: new Usage(), + responseId: 'resp_123', + }); + state._generatedItems = [ + new MessageOutputItem( + { + type: 'message', + role: 'assistant', + id: 'msg_123', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'here is the reply', + }, + ], + providerData: {}, + }, + textAgent, + ), + ]; + state._currentStep = { + type: 'next_step_final_output', + output: 'here is the reply', + }; + + const result = new RunResult(state); + await saveToSession(session, toInputItemList(state._originalInput), result); + + expect(session.events).toEqual(['addItems:2', 'runCompaction:resp_123']); + expect(session.items).toHaveLength(2); + }); + + it('invokes runCompaction when responseId is undefined', async () => { + class TrackingSession implements Session { + items: AgentInputItem[] = []; + events: string[] = []; + + async getSessionId(): Promise { + return 'session'; + } + + async getItems(): Promise { + return [...this.items]; + } + + async addItems(items: AgentInputItem[]): Promise { + this.events.push(`addItems:${items.length}`); + this.items.push(...items); + } + + async popItem(): Promise { + return undefined; + } + + async clearSession(): Promise { + this.items = []; + } + + async runCompaction(args?: { responseId?: string }): Promise { + this.events.push(`runCompaction:${String(args?.responseId)}`); + } + } + + const textAgent = new Agent({ + name: 'Recorder', + outputType: 'text', + instructions: 'capture', + }); + const agent = textAgent as unknown as Agent< + UnknownContext, + AgentOutputType + >; + const session = new TrackingSession(); + const context = new RunContext(undefined as UnknownContext); + const state = new RunState< + UnknownContext, + Agent + >(context, 'hello', agent, 10); + + state._modelResponses = []; + state._generatedItems = [ + new MessageOutputItem( + { + type: 'message', + role: 'assistant', + id: 'msg_123', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'here is the reply', + }, + ], + providerData: {}, + }, + textAgent, + ), + ]; + state._currentStep = { + type: 'next_step_final_output', + output: 'here is the reply', + }; + + const result = new RunResult(state); + await saveToSession(session, toInputItemList(state._originalInput), result); + + expect(session.events).toEqual(['addItems:2', 'runCompaction:undefined']); + expect(session.items).toHaveLength(2); + }); }); describe('prepareInputItemsWithSession', () => { diff --git a/packages/agents-openai/src/index.ts b/packages/agents-openai/src/index.ts index e741ff01..7af3d33c 100644 --- a/packages/agents-openai/src/index.ts +++ b/packages/agents-openai/src/index.ts @@ -23,3 +23,8 @@ export { startOpenAIConversationsSession, type OpenAIConversationsSessionOptions, } from './memory/openaiConversationsSession'; +export { + OpenAIResponsesCompactionSession, + type OpenAIResponsesCompactionSessionOptions, + type OpenAIResponsesCompactionDecisionContext, +} from './memory/openaiResponsesCompactionSession'; diff --git a/packages/agents-openai/src/memory/openaiConversationsSession.ts b/packages/agents-openai/src/memory/openaiConversationsSession.ts index d7a04d45..aad7c20b 100644 --- a/packages/agents-openai/src/memory/openaiConversationsSession.ts +++ b/packages/agents-openai/src/memory/openaiConversationsSession.ts @@ -5,6 +5,10 @@ import { convertToOutputItem, getInputItems } from '../openaiResponsesModel'; import { protocol } from '@openai/agents-core'; import type { ConversationItem as APIConversationItem } from 'openai/resources/conversations/items'; import type { Message as APIConversationMessage } from 'openai/resources/conversations/conversations'; +import { + OPENAI_SESSION_API, + type OpenAISessionApiTagged, +} from './openaiSessionApi'; export type OpenAIConversationsSessionOptions = { conversationId?: string; @@ -23,7 +27,12 @@ export async function startOpenAIConversationsSession( return response.id; } -export class OpenAIConversationsSession implements Session { +export class OpenAIConversationsSession + implements Session, OpenAISessionApiTagged<'conversations'> +{ + // Marks this session as backed by the Conversations API so Responses-only integrations can reject it. + readonly [OPENAI_SESSION_API] = 'conversations' as const; + #client: OpenAI; #conversationId?: string; diff --git a/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts b/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts new file mode 100644 index 00000000..afc29e82 --- /dev/null +++ b/packages/agents-openai/src/memory/openaiResponsesCompactionSession.ts @@ -0,0 +1,340 @@ +import OpenAI from 'openai'; +import { getLogger, MemorySession, UserError } from '@openai/agents-core'; +import type { + AgentInputItem, + OpenAIResponsesCompactionArgs, + OpenAIResponsesCompactionAwareSession as OpenAIResponsesCompactionSessionLike, + Session, +} from '@openai/agents-core'; +import { DEFAULT_OPENAI_MODEL, getDefaultOpenAIClient } from '../defaults'; +import { + OPENAI_SESSION_API, + type OpenAISessionApiTagged, +} from './openaiSessionApi'; + +const DEFAULT_COMPACTION_THRESHOLD = 10; +const logger = getLogger('openai-agents:openai:compaction'); + +export type OpenAIResponsesCompactionDecisionContext = { + /** + * The `response.id` from a completed OpenAI Responses API turn, if available. + */ + responseId: string | undefined; + /** + * Items considered compaction candidates (excludes user and compaction items). + * The array must not be mutated. + */ + compactionCandidateItems: AgentInputItem[]; + /** + * All stored items retrieved from the underlying session, if available. + * The array must not be mutated. + */ + sessionItems: AgentInputItem[]; +}; + +export type OpenAIResponsesCompactionSessionOptions = { + /** + * OpenAI client used to call `responses.compact`. + * + * When omitted, the session will use `getDefaultOpenAIClient()` if configured. Otherwise it + * creates a new `OpenAI()` instance via `new OpenAI()`. + */ + client?: OpenAI; + /** + * Session store that receives items and holds the compacted history. + * + * The underlying session is the source of truth for persisted items. Compaction clears the + * underlying session and writes the output items returned by `responses.compact`. + * + * This must not be an `OpenAIConversationsSession`, because compaction relies on the Responses + * API `previous_response_id` flow. + * + * Defaults to an in-memory session for demos. + */ + underlyingSession?: Session & { [OPENAI_SESSION_API]?: 'responses' }; + /** + * The OpenAI model to use for `responses.compact`. + * + * Defaults to `DEFAULT_OPENAI_MODEL`. The value must resemble an OpenAI model name (for example + * `gpt-*`, `o*`, or a fine-tuned `ft:gpt-*` identifier), otherwise the constructor throws. + */ + model?: OpenAI.ResponsesModel; + /** + * Custom decision hook that determines whether to call `responses.compact`. + * + * The default implementation compares the length of + * {@link OpenAIResponsesCompactionDecisionContext.compactionCandidateItems} to an internal threshold + * (10). Override this to support token-based triggers or other heuristics using + * {@link OpenAIResponsesCompactionDecisionContext.compactionCandidateItems} or + * {@link OpenAIResponsesCompactionDecisionContext.sessionItems}. + */ + shouldTriggerCompaction?: ( + context: OpenAIResponsesCompactionDecisionContext, + ) => boolean | Promise; +}; + +/** + * Session decorator that triggers `responses.compact` when the stored history grows. + * + * This session is intended to be passed to `run()` so the runner can automatically supply the + * latest `responseId` and invoke compaction after each completed turn is persisted. + * + * To debug compaction decisions, enable the `debug` logger for + * `openai-agents:openai:compaction` (for example, `DEBUG=openai-agents:openai:compaction`). + */ +export class OpenAIResponsesCompactionSession + implements + OpenAIResponsesCompactionSessionLike, + OpenAISessionApiTagged<'responses'> +{ + readonly [OPENAI_SESSION_API] = 'responses' as const; + + private readonly client: OpenAI; + private readonly underlyingSession: Session; + private readonly model: OpenAI.ResponsesModel; + private responseId?: string; + private readonly shouldTriggerCompaction: ( + context: OpenAIResponsesCompactionDecisionContext, + ) => boolean | Promise; + private compactionCandidateItems: AgentInputItem[] | undefined; + private sessionItems: AgentInputItem[] | undefined; + + constructor(options: OpenAIResponsesCompactionSessionOptions) { + this.client = resolveClient(options); + if (isOpenAIConversationsSessionDelegate(options.underlyingSession)) { + throw new UserError( + 'OpenAIResponsesCompactionSession does not support OpenAIConversationsSession as an underlying session.', + ); + } + this.underlyingSession = options.underlyingSession ?? new MemorySession(); + const model = (options.model ?? DEFAULT_OPENAI_MODEL).trim(); + + assertSupportedOpenAIResponsesCompactionModel(model); + this.model = model; + + this.shouldTriggerCompaction = + options.shouldTriggerCompaction ?? defaultShouldTriggerCompaction; + this.compactionCandidateItems = undefined; + this.sessionItems = undefined; + } + + async runCompaction(args: OpenAIResponsesCompactionArgs = {}) { + this.responseId = args.responseId ?? this.responseId ?? undefined; + + if (!this.responseId) { + throw new UserError( + 'OpenAIResponsesCompactionSession.runCompaction requires a responseId from the last completed turn.', + ); + } + + const { compactionCandidateItems, sessionItems } = + await this.ensureCompactionCandidates(); + const shouldTriggerCompaction = + args.force === true + ? true + : await this.shouldTriggerCompaction({ + responseId: this.responseId, + compactionCandidateItems, + sessionItems, + }); + if (!shouldTriggerCompaction) { + logger.debug('skip: decision hook %o', { + responseId: this.responseId, + }); + return; + } + + logger.debug('compact: start %o', { + responseId: this.responseId, + model: this.model, + }); + + const compacted = await this.client.responses.compact({ + previous_response_id: this.responseId, + model: this.model, + }); + + await this.underlyingSession.clearSession(); + const outputItems = (compacted.output ?? []) as AgentInputItem[]; + if (outputItems.length > 0) { + await this.underlyingSession.addItems(outputItems); + } + this.compactionCandidateItems = selectCompactionCandidateItems(outputItems); + this.sessionItems = outputItems; + + logger.debug('compact: done %o', { + responseId: this.responseId, + outputItemCount: outputItems.length, + candidateCount: this.compactionCandidateItems.length, + }); + } + + async getSessionId(): Promise { + return this.underlyingSession.getSessionId(); + } + + async getItems(limit?: number): Promise { + return this.underlyingSession.getItems(limit); + } + + async addItems(items: AgentInputItem[]) { + if (items.length === 0) { + return; + } + + await this.underlyingSession.addItems(items); + if (this.compactionCandidateItems) { + const candidates = selectCompactionCandidateItems(items); + if (candidates.length > 0) { + this.compactionCandidateItems = [ + ...this.compactionCandidateItems, + ...candidates, + ]; + } + } + if (this.sessionItems) { + this.sessionItems = [...this.sessionItems, ...items]; + } + } + + async popItem() { + const popped = await this.underlyingSession.popItem(); + if (!popped) { + return popped; + } + if (this.sessionItems) { + const index = this.sessionItems.lastIndexOf(popped); + if (index >= 0) { + this.sessionItems.splice(index, 1); + } else { + this.sessionItems = await this.underlyingSession.getItems(); + } + } + if (this.compactionCandidateItems) { + const isCandidate = selectCompactionCandidateItems([popped]).length > 0; + if (isCandidate) { + const index = this.compactionCandidateItems.indexOf(popped); + if (index >= 0) { + this.compactionCandidateItems.splice(index, 1); + } else { + // Fallback when the popped item reference differs from stored candidates. + this.compactionCandidateItems = selectCompactionCandidateItems( + await this.underlyingSession.getItems(), + ); + } + } + } + return popped; + } + + async clearSession() { + await this.underlyingSession.clearSession(); + this.compactionCandidateItems = []; + this.sessionItems = []; + } + + private async ensureCompactionCandidates(): Promise<{ + compactionCandidateItems: AgentInputItem[]; + sessionItems: AgentInputItem[]; + }> { + if (this.compactionCandidateItems && this.sessionItems) { + logger.debug('candidates: cached %o', { + candidateCount: this.compactionCandidateItems.length, + }); + return { + compactionCandidateItems: [...this.compactionCandidateItems], + sessionItems: [...this.sessionItems], + }; + } + const history = await this.underlyingSession.getItems(); + const compactionCandidates = selectCompactionCandidateItems(history); + this.compactionCandidateItems = compactionCandidates; + this.sessionItems = history; + logger.debug('candidates: initialized %o', { + historyLength: history.length, + candidateCount: compactionCandidates.length, + }); + return { + compactionCandidateItems: [...compactionCandidates], + sessionItems: [...history], + }; + } +} + +function resolveClient( + options: OpenAIResponsesCompactionSessionOptions, +): OpenAI { + if (options.client) { + return options.client; + } + + const defaultClient = getDefaultOpenAIClient(); + if (defaultClient) { + return defaultClient; + } + + return new OpenAI(); +} + +function defaultShouldTriggerCompaction({ + compactionCandidateItems, +}: OpenAIResponsesCompactionDecisionContext): boolean { + return compactionCandidateItems.length >= DEFAULT_COMPACTION_THRESHOLD; +} + +function selectCompactionCandidateItems( + items: AgentInputItem[], +): AgentInputItem[] { + return items.filter((item) => { + if (item.type === 'compaction') { + return false; + } + return !(item.type === 'message' && item.role === 'user'); + }); +} + +function assertSupportedOpenAIResponsesCompactionModel(model: string): void { + if (!isOpenAIModelName(model)) { + throw new Error( + `Unsupported model for OpenAI responses compaction: ${JSON.stringify(model)}`, + ); + } +} + +function isOpenAIModelName(model: string): boolean { + const trimmed = model.trim(); + if (!trimmed) { + return false; + } + // The OpenAI SDK does not ship a runtime allowlist of model names. + // This check relies on common model naming conventions and intentionally allows unknown `gpt-*` variants. + // Fine-tuned model IDs typically look like: ft:gpt-4o-mini:org:project:suffix. + const withoutFineTunePrefix = trimmed.startsWith('ft:') + ? trimmed.slice('ft:'.length) + : trimmed; + const root = withoutFineTunePrefix.split(':', 1)[0]; + + // Allow unknown `gpt-*` variants to avoid needing updates whenever new models ship. + if (root.startsWith('gpt-')) { + return true; + } + // Allow the `o*` reasoning models + if (/^o\d[a-z0-9-]*$/i.test(root)) { + return true; + } + + return false; +} + +function isOpenAIConversationsSessionDelegate( + underlyingSession: Session | undefined, +): underlyingSession is Session & OpenAISessionApiTagged<'conversations'> { + return ( + !!underlyingSession && + typeof underlyingSession === 'object' && + OPENAI_SESSION_API in underlyingSession && + (underlyingSession as OpenAISessionApiTagged<'conversations'>)[ + OPENAI_SESSION_API + ] === 'conversations' + ); +} diff --git a/packages/agents-openai/src/memory/openaiSessionApi.ts b/packages/agents-openai/src/memory/openaiSessionApi.ts new file mode 100644 index 00000000..d3169304 --- /dev/null +++ b/packages/agents-openai/src/memory/openaiSessionApi.ts @@ -0,0 +1,14 @@ +/** + * Branding symbol used to tag OpenAI-backed sessions with the underlying API family they rely on. + * + * This enables runtime checks (and some type narrowing) to prevent mixing sessions that are not + * compatible with each other (e.g., using a Conversations-based session where a Responses-only + * feature is required). + */ +export const OPENAI_SESSION_API: unique symbol = Symbol('OPENAI_SESSION_API'); + +export type OpenAISessionAPI = 'responses' | 'conversations'; + +export type OpenAISessionApiTagged = { + readonly [OPENAI_SESSION_API]: API; +}; diff --git a/packages/agents-openai/src/openaiChatCompletionsConverter.ts b/packages/agents-openai/src/openaiChatCompletionsConverter.ts index 2a5d2bd3..5c25095d 100644 --- a/packages/agents-openai/src/openaiChatCompletionsConverter.ts +++ b/packages/agents-openai/src/openaiChatCompletionsConverter.ts @@ -300,6 +300,10 @@ export function itemsToMessages( result.push({ ...item.providerData, } as any); + } else if (item.type === 'compaction') { + throw new UserError( + 'Compaction items are not supported for chat completions. Please use the Responses API when working with compaction.', + ); } else { const exhaustive = item satisfies never; // ensures that the type is exhaustive throw new Error(`Unknown item type: ${JSON.stringify(exhaustive)}`); diff --git a/packages/agents-openai/src/openaiResponsesModel.ts b/packages/agents-openai/src/openaiResponsesModel.ts index 373e750c..94c3f953 100644 --- a/packages/agents-openai/src/openaiResponsesModel.ts +++ b/packages/agents-openai/src/openaiResponsesModel.ts @@ -1231,6 +1231,19 @@ function getInputItems( ); } + if (item.type === 'compaction') { + const encryptedContent = + (item as any).encrypted_content ?? (item as any).encryptedContent; + if (typeof encryptedContent !== 'string') { + throw new UserError('Compaction item missing encrypted_content'); + } + return { + type: 'compaction', + id: item.id ?? undefined, + encrypted_content: encryptedContent, + } as OpenAI.Responses.ResponseInputItem; + } + if (item.type === 'unknown') { return { ...camelOrSnakeToSnakeCase(item.providerData), // place here to prioritize the below fields @@ -1524,6 +1537,23 @@ function convertToOutputItem( providerData, }; return output; + } else if (item.type === 'compaction') { + const { encrypted_content, created_by, ...providerData } = item as { + encrypted_content?: string; + created_by?: string; + id?: string; + }; + if (typeof encrypted_content !== 'string') { + throw new UserError('Compaction item missing encrypted_content'); + } + const output: protocol.CompactionItem = { + type: 'compaction', + id: item.id ?? undefined, + encrypted_content, + created_by, + providerData, + }; + return output; } return { diff --git a/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts b/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts new file mode 100644 index 00000000..528e4129 --- /dev/null +++ b/packages/agents-openai/test/openaiResponsesCompactionSession.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MemorySession } from '@openai/agents-core'; +import { UserError } from '@openai/agents-core'; + +import { OpenAIResponsesCompactionSession } from '../src'; + +describe('OpenAIResponsesCompactionSession', () => { + it('rejects non-OpenAI model names', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'yet-another-model', + }); + }).toThrow(/Unsupported model/); + }); + + it('allows unknown gpt-* model names', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'gpt-9999-super-new-model', + }); + }).not.toThrow(); + }); + + it('allows fine-tuned gpt-* model ids', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'ft:gpt-4.1-nano-2025-04-14:org:proj:suffix', + }); + }).not.toThrow(); + }); + + it('allows o* model names', () => { + expect(() => { + new OpenAIResponsesCompactionSession({ + client: {} as any, + model: 'o1-pro', + }); + }).not.toThrow(); + }); + + it('skips compaction when the decision hook declines', async () => { + const compact = vi.fn(); + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + shouldTriggerCompaction: () => false, + }); + + await session.addItems([ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello' }], + }, + ]); + + await session.runCompaction({ responseId: 'resp_1' }); + expect(compact).not.toHaveBeenCalled(); + }); + + it('allows custom compaction decisions using the stored history', async () => { + const compact = vi.fn().mockResolvedValue({ + output: [ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'compacted output' }], + }, + ], + }); + const underlyingSession = new MemorySession(); + const decisionHistoryLengths: number[] = []; + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + underlyingSession, + shouldTriggerCompaction: async ({ compactionCandidateItems }) => { + decisionHistoryLengths.push(compactionCandidateItems.length); + const estimatedTokens = compactionCandidateItems.reduce( + (total, item) => total + JSON.stringify(item).length, + 0, + ); + return estimatedTokens > 40; + }, + }); + + await session.addItems([ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'This reply is intentionally long to trigger compaction.', + }, + ], + }, + ]); + + await session.runCompaction({ responseId: 'resp_2' }); + + expect(compact).toHaveBeenCalledTimes(1); + expect(compact).toHaveBeenCalledWith({ + previous_response_id: 'resp_2', + model: 'gpt-4.1', + }); + expect(decisionHistoryLengths).toEqual([1]); + + const storedItems = await session.getItems(); + expect(storedItems).toEqual([ + { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'compacted output' }], + }, + ]); + }); + + it('provides compaction candidates to the decision hook', async () => { + const compact = vi.fn(); + const receivedCandidates: unknown[][] = []; + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + shouldTriggerCompaction: async ({ compactionCandidateItems }) => { + receivedCandidates.push(compactionCandidateItems); + return false; + }, + }); + + const userItem = { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }; + const assistantItem = { + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'world' }], + }; + + await session.addItems([userItem, assistantItem] as any); + await session.runCompaction({ responseId: 'resp_3' }); + + expect(receivedCandidates).toEqual([[assistantItem]]); + expect(compact).not.toHaveBeenCalled(); + }); + + it('throws when runCompaction is called without a responseId', async () => { + const compact = vi.fn(); + const session = new OpenAIResponsesCompactionSession({ + client: { responses: { compact } } as any, + }); + + await expect(session.runCompaction({} as any)).rejects.toBeInstanceOf( + UserError, + ); + }); +});