diff --git a/.changeset/breezy-moles-fix.md b/.changeset/breezy-moles-fix.md new file mode 100644 index 00000000..7c9e56dd --- /dev/null +++ b/.changeset/breezy-moles-fix.md @@ -0,0 +1,6 @@ +--- +"@openai/agents-core": patch +"@openai/agents-openai": patch +--- + +feat: fix #272 add memory feature diff --git a/examples/memory/.gitignore b/examples/memory/.gitignore new file mode 100644 index 00000000..c0363794 --- /dev/null +++ b/examples/memory/.gitignore @@ -0,0 +1 @@ +tmp/ \ No newline at end of file diff --git a/examples/memory/file.ts b/examples/memory/file.ts new file mode 100644 index 00000000..5987a6a2 --- /dev/null +++ b/examples/memory/file.ts @@ -0,0 +1,216 @@ +import type { AgentInputItem, Session } from '@openai/agents'; +import { protocol } from '@openai/agents'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export type FileSessionOptions = { + /** + * Directory where session files are stored. Defaults to `./.agents-sessions`. + */ + dir?: string; + /** + * Optional pre-existing session id to bind to. + */ + sessionId?: string; +}; + +/** + * A simple filesystem-backed Session implementation that stores history as a JSON array. + */ +export class FileSession implements Session { + #dir: string; + #sessionId?: string; + + constructor(options: FileSessionOptions = {}) { + this.#dir = options.dir ?? path.resolve(process.cwd(), '.agents-sessions'); + this.#sessionId = options.sessionId; + } + + /** + * Get the current session id, creating one if necessary. + */ + async getSessionId(): Promise { + if (!this.#sessionId) { + // Compact, URL-safe-ish id without dashes. + this.#sessionId = randomUUID().replace(/-/g, '').slice(0, 24); + } + await this.#ensureDir(); + // Ensure the file exists. + const file = this.#filePath(this.#sessionId); + try { + await fs.access(file); + } catch { + await fs.writeFile(file, '[]', 'utf8'); + } + return this.#sessionId; + } + + /** + * Retrieve items from the conversation history. + */ + async getItems(limit?: number): Promise { + const sessionId = await this.getSessionId(); + const items = await this.#readItems(sessionId); + if (typeof limit === 'number' && limit >= 0) { + return items.slice(-limit); + } + return items; + } + + /** + * Append new items to the conversation history. + */ + async addItems(items: AgentInputItem[]): Promise { + if (!items.length) return; + const sessionId = await this.getSessionId(); + const current = await this.#readItems(sessionId); + const next = current.concat(items); + await this.#writeItems(sessionId, next); + } + + /** + * Remove and return the most recent item, if any. + */ + async popItem(): Promise { + const sessionId = await this.getSessionId(); + const items = await this.#readItems(sessionId); + if (items.length === 0) return undefined; + const popped = items.pop(); + await this.#writeItems(sessionId, items); + return popped; + } + + /** + * Delete all stored items and reset the session state. + */ + async clearSession(): Promise { + if (!this.#sessionId) return; // Nothing to clear. + const file = this.#filePath(this.#sessionId); + try { + await fs.unlink(file); + } catch { + // Ignore if already removed or inaccessible. + } + this.#sessionId = undefined; + } + + // Internal helpers + async #ensureDir(): Promise { + await fs.mkdir(this.#dir, { recursive: true }); + } + + #filePath(sessionId: string): string { + return path.join(this.#dir, `${sessionId}.json`); + } + + async #readItems(sessionId: string): Promise { + const file = this.#filePath(sessionId); + try { + const data = await fs.readFile(file, 'utf8'); + const parsed = JSON.parse(data); + if (!Array.isArray(parsed)) return []; + // Validate and coerce items to the protocol shape where possible. + const result: AgentInputItem[] = []; + for (const raw of parsed) { + const check = protocol.ModelItem.safeParse(raw); + if (check.success) { + result.push(check.data as AgentInputItem); + } + // Silently skip invalid entries. + } + return result; + } catch (err: any) { + // On missing file, return empty list. + if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return []; + // For other errors, rethrow. + throw err; + } + } + + async #writeItems(sessionId: string, items: AgentInputItem[]): Promise { + await this.#ensureDir(); + const file = this.#filePath(sessionId); + // Keep JSON compact but deterministic. + await fs.writeFile(file, JSON.stringify(items, null, 2), 'utf8'); + } +} + +import { Agent, run } from '@openai/agents'; + +async function main() { + const agent = new Agent({ + name: 'Assistant', + instructions: 'You are a helpful assistant. be VERY concise.', + }); + + const session = new FileSession({ dir: './tmp/' }); + let result = await run( + agent, + 'What is the largest country in South America?', + { session }, + ); + console.log(result.finalOutput); // e.g., Brazil + + result = await run(agent, 'What is the capital of that country?', { + session, + }); + console.log(result.finalOutput); // e.g., Brasilia +} + +async function mainStream() { + const agent = new Agent({ + name: 'Assistant', + instructions: 'You are a helpful assistant. be VERY concise.', + }); + + const session = new FileSession({ dir: './tmp/' }); + let result = await run( + agent, + 'What is the largest country in South America?', + { + stream: true, + session, + }, + ); + + for await (const event of result) { + if ( + event.type === 'raw_model_stream_event' && + event.data.type === 'output_text_delta' + ) + process.stdout.write(event.data.delta); + } + console.log(); + + result = await run(agent, 'What is the capital of that country?', { + stream: true, + session, + }); + + // toTextStream() automatically returns a readable stream of strings intended to be displayed + // to the user + for await (const event of result.toTextStream()) { + process.stdout.write(event); + } + console.log(); +} + +async function promptAndRun() { + const readline = await import('node:readline/promises'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const isStream = await rl.question('Run in stream mode? (y/n): '); + rl.close(); + if (isStream.trim().toLowerCase() === 'y') { + await mainStream(); + } else { + await main(); + } +} + +if (require.main === module) { + promptAndRun().catch(console.error); +} diff --git a/examples/memory/oai.ts b/examples/memory/oai.ts new file mode 100644 index 00000000..a88ce2bd --- /dev/null +++ b/examples/memory/oai.ts @@ -0,0 +1,78 @@ +import { Agent, OpenAIConversationsSession, run } from '@openai/agents'; + +async function main() { + const agent = new Agent({ + name: 'Assistant', + instructions: 'You are a helpful assistant. be VERY concise.', + }); + + const session = new OpenAIConversationsSession(); + let result = await run( + agent, + 'What is the largest country in South America?', + { session }, + ); + console.log(result.finalOutput); // e.g., Brazil + + result = await run(agent, 'What is the capital of that country?', { + session, + }); + console.log(result.finalOutput); // e.g., Brasilia +} + +async function mainStream() { + const agent = new Agent({ + name: 'Assistant', + instructions: 'You are a helpful assistant. be VERY concise.', + }); + + const session = new OpenAIConversationsSession(); + let result = await run( + agent, + 'What is the largest country in South America?', + { + stream: true, + session, + }, + ); + + for await (const event of result) { + if ( + event.type === 'raw_model_stream_event' && + event.data.type === 'output_text_delta' + ) + process.stdout.write(event.data.delta); + } + console.log(); + + result = await run(agent, 'What is the capital of that country?', { + stream: true, + session, + }); + + // toTextStream() automatically returns a readable stream of strings intended to be displayed + // to the user + for await (const event of result.toTextStream()) { + process.stdout.write(event); + } + console.log(); +} + +async function promptAndRun() { + const readline = await import('node:readline/promises'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const isStream = await rl.question('Run in stream mode? (y/n): '); + rl.close(); + if (isStream.trim().toLowerCase() === 'y') { + await mainStream(); + } else { + await main(); + } +} + +if (require.main === module) { + promptAndRun().catch(console.error); +} diff --git a/examples/memory/package.json b/examples/memory/package.json new file mode 100644 index 00000000..8f981dde --- /dev/null +++ b/examples/memory/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "name": "memory", + "dependencies": { + "@openai/agents": "workspace:*" + }, + "scripts": { + "build-check": "tsc --noEmit", + "start:oai": "tsx oai.ts", + "start:file": "tsx file.ts" + } +} diff --git a/examples/memory/tsconfig.json b/examples/memory/tsconfig.json new file mode 100644 index 00000000..150a0961 --- /dev/null +++ b/examples/memory/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.examples.json" +} diff --git a/packages/agents-core/src/index.ts b/packages/agents-core/src/index.ts index fc5266ca..320b80fb 100644 --- a/packages/agents-core/src/index.ts +++ b/packages/agents-core/src/index.ts @@ -152,6 +152,7 @@ export type { StreamEventGenericItem, } from './types'; export { Usage } from './usage'; +export type { Session } from './memory/session'; /** * Exporting the whole protocol as an object here. This contains both the types diff --git a/packages/agents-core/src/memory/session.ts b/packages/agents-core/src/memory/session.ts new file mode 100644 index 00000000..2be0d48c --- /dev/null +++ b/packages/agents-core/src/memory/session.ts @@ -0,0 +1,37 @@ +import type { AgentInputItem } from '../types'; + +/** + * Interface representing a persistent session store for conversation history. + */ +export interface Session { + /** + * Ensure and return the identifier for this session. + */ + getSessionId(): Promise; + + /** + * Retrieve items from the conversation history. + * + * @param limit - The maximum number of items to return. When provided the most + * recent {@link limit} items should be returned in chronological order. + */ + getItems(limit?: number): Promise; + + /** + * Append new items to the conversation history. + * + * @param items - Items to add to the session history. + */ + addItems(items: AgentInputItem[]): Promise; + + /** + * Remove and return the most recent item from the conversation history if it + * exists. + */ + popItem(): Promise; + + /** + * Remove all items that belong to the session and reset its state. + */ + clearSession(): Promise; +} diff --git a/packages/agents-core/src/run.ts b/packages/agents-core/src/run.ts index dbd5f700..5ef4a07f 100644 --- a/packages/agents-core/src/run.ts +++ b/packages/agents-core/src/run.ts @@ -39,6 +39,10 @@ import { maybeResetToolChoice, ProcessedResponse, processModelResponse, + saveStreamInputToSession, + saveStreamResultToSession, + saveToSession, + prepareInputItemsWithSession, } from './runImplementation'; import { RunItem } from './items'; import { @@ -55,6 +59,7 @@ import { RunState } from './runState'; import { StreamEventResponseCompleted } from './types/protocol'; import { convertAgentOutputTypeToSerializable } from './utils/tools'; import { gpt5ReasoningSettingsRequired, isGpt5Default } from './defaultModel'; +import type { Session } from './memory/session'; const DEFAULT_MAX_TURNS = 10; @@ -137,6 +142,7 @@ type SharedRunOptions = { signal?: AbortSignal; previousResponseId?: string; conversationId?: string; + session?: Session; }; export type StreamRunOptions = @@ -831,6 +837,7 @@ export class Runner extends RunHooks> { } if (result.state._currentStep.type === 'next_step_final_output') { + await saveStreamResultToSession(options.session, result); await this.#runOutputGuardrails( result.state, result.state._currentStep.output, @@ -851,6 +858,7 @@ export class Runner extends RunHooks> { result.state._currentStep.type === 'next_step_interruption' ) { // we are done for now. Don't run any output guardrails + await saveStreamResultToSession(options.session, result); return; } else if (result.state._currentStep.type === 'next_step_handoff') { result.state._currentAgent = result.state._currentStep @@ -975,7 +983,7 @@ export class Runner extends RunHooks> { input: string | AgentInputItem[] | RunState, options?: StreamRunOptions, ): Promise>; - run, TContext = undefined>( + async run, TContext = undefined>( agent: TAgent, input: string | AgentInputItem[] | RunState, options: IndividualRunOptions = { @@ -985,35 +993,55 @@ export class Runner extends RunHooks> { ): Promise< RunResult | StreamedRunResult > { - if (input instanceof RunState && input._trace) { - return withTrace(input._trace, async () => { - if (input._currentAgentSpan) { - setCurrentSpan(input._currentAgentSpan); - } + const resolvedOptions = options ?? { stream: false, context: undefined }; + const session = resolvedOptions.session; + const sessionOriginalInput = + input instanceof RunState + ? undefined + : (input as string | AgentInputItem[]); + + let preparedInput: typeof input = input; + if (!(preparedInput instanceof RunState)) { + preparedInput = await prepareInputItemsWithSession( + preparedInput, + session, + ); + } else if (session) { + throw new UserError( + 'Cannot provide a session when resuming from a previous RunState.', + ); + } - if (options?.stream) { - return this.#runIndividualStream(agent, input, options); - } else { - return this.#runIndividualNonStream(agent, input, options); + const executeRun = async () => { + if (resolvedOptions.stream) { + // Persist the user's input before streaming outputs so the session + // transcript preserves the turn order. + await saveStreamInputToSession(session, sessionOriginalInput); + return this.#runIndividualStream(agent, preparedInput, resolvedOptions); + } + const runResult = await this.#runIndividualNonStream( + agent, + preparedInput, + resolvedOptions, + ); + await saveToSession(session, sessionOriginalInput, runResult); + return runResult; + }; + + if (preparedInput instanceof RunState && preparedInput._trace) { + return withTrace(preparedInput._trace, async () => { + if (preparedInput._currentAgentSpan) { + setCurrentSpan(preparedInput._currentAgentSpan); } + return executeRun(); }); } - - return getOrCreateTrace( - async () => { - if (options?.stream) { - return this.#runIndividualStream(agent, input, options); - } else { - return this.#runIndividualNonStream(agent, input, options); - } - }, - { - traceId: this.config.traceId, - name: this.config.workflowName, - groupId: this.config.groupId, - metadata: this.config.traceMetadata, - }, - ); + return getOrCreateTrace(async () => executeRun(), { + traceId: this.config.traceId, + name: this.config.workflowName, + groupId: this.config.groupId, + metadata: this.config.traceMetadata, + }); } } diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index 3acff6d9..6d815cd8 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -30,7 +30,7 @@ import { getSchemaAndParserFromInputType } from './utils/tools'; import { safeExecute } from './utils/safeExecute'; import { addErrorToCurrentSpan } from './tracing/context'; import { RunItemStreamEvent, RunItemStreamEventName } from './events'; -import { StreamedRunResult } from './result'; +import { RunResult, StreamedRunResult } from './result'; import { z } from 'zod'; import { toSmartString } from './utils/smartString'; import * as protocol from './types/protocol'; @@ -38,6 +38,7 @@ import { Computer } from './computer'; import { RunState } from './runState'; import { isZodObject } from './utils'; import * as ProviderData from './types/providerData'; +import type { Session } from './memory/session'; type ToolRunHandoff = { toolCall: protocol.FunctionCallItem; @@ -1171,3 +1172,119 @@ export class AgentToolUseTracker { ); } } + +/** + * @internal + * Convert a user-provided input into a list of input items. + */ +export function toInputItemList( + input: string | AgentInputItem[], +): AgentInputItem[] { + if (typeof input === 'string') { + return [ + { + role: 'user', + content: input, + } as AgentInputItem, + ]; + } + return [...input]; +} + +/** + * @internal + * Extract model output items from run items, excluding tool approval items. + */ +export function extractOutputItemsFromRunItems( + items: RunItem[], +): AgentInputItem[] { + return items + .filter((item) => item.type !== 'tool_approval_item') + .map((item) => item.rawItem as AgentInputItem); +} + +/** + * @internal + * Persist full turn (input + outputs) for non-streaming runs. + */ +export async function saveToSession( + session: Session | undefined, + originalInput: string | AgentInputItem[] | undefined, + result: RunResult, +): Promise { + if (!session) { + return; + } + if (!originalInput) { + return; + } + const itemsToSave = [ + ...toInputItemList(originalInput), + ...extractOutputItemsFromRunItems(result.newItems), + ]; + if (itemsToSave.length === 0) { + return; + } + await session.addItems(itemsToSave); +} + +/** + * @internal + * Persist only the user input for streaming runs at start. + */ +export async function saveStreamInputToSession( + session: Session | undefined, + originalInput: string | AgentInputItem[] | undefined, +): Promise { + if (!session) { + return; + } + if (!originalInput) { + return; + } + const itemsToSave = toInputItemList(originalInput); + if (itemsToSave.length === 0) { + return; + } + await session.addItems(itemsToSave); +} + +/** + * @internal + * Persist only the model outputs for streaming runs at the end of a turn. + */ +export async function saveStreamResultToSession( + session: Session | undefined, + result: StreamedRunResult, +): Promise { + if (!session) { + return; + } + const itemsToSave = extractOutputItemsFromRunItems(result.newItems); + if (itemsToSave.length === 0) { + return; + } + await session.addItems(itemsToSave); +} + +/** + * @internal + * If a session is provided, expands the input with session history; otherwise returns the input. + */ +export async function prepareInputItemsWithSession( + input: string | AgentInputItem[], + session?: Session, +): Promise { + if (!session) { + return input; + } + + if (Array.isArray(input)) { + throw new UserError( + 'Cannot provide both a session and a list of input items. When using session memory, pass a single string input.', + ); + } + + const history = await session.getItems(); + return [...history, ...toInputItemList(input)]; +} diff --git a/packages/agents-core/test/run.test.ts b/packages/agents-core/test/run.test.ts index ccba70fb..c32fa5f3 100644 --- a/packages/agents-core/test/run.test.ts +++ b/packages/agents-core/test/run.test.ts @@ -5,6 +5,8 @@ import { MaxTurnsExceededError, ModelResponse, OutputGuardrailTripwireTriggered, + Session, + type AgentInputItem, run, Runner, setDefaultModelProvider, @@ -148,7 +150,11 @@ describe('Runner.run', () => { // Track agent_end events on both the agent and runner const agentEndEvents: Array<{ context: any; output: string }> = []; - const runnerEndEvents: Array<{ context: any; agent: any; output: string }> = []; + const runnerEndEvents: Array<{ + context: any; + agent: any; + output: string; + }> = []; agent.on('agent_end', (context, output) => { agentEndEvents.push({ context, output }); @@ -407,7 +413,7 @@ describe('Runner.run', () => { usage: new Usage(), }; class SimpleStreamingModel implements Model { - constructor(private resps: ModelResponse[]) { } + constructor(private resps: ModelResponse[]) {} async getResponse(_req: ModelRequest): Promise { const r = this.resps.shift(); if (!r) { @@ -523,6 +529,117 @@ describe('Runner.run', () => { expect(spy.mock.instances[0]).toBe(spy.mock.instances[1]); spy.mockRestore(); }); + + describe('sessions', () => { + class MemorySession implements Session { + #history: AgentInputItem[]; + #added: AgentInputItem[][] = []; + sessionId?: string; + + constructor(history: AgentInputItem[] = []) { + this.#history = [...history]; + } + + get added(): AgentInputItem[][] { + return this.#added; + } + + async getSessionId(): Promise { + if (!this.sessionId) { + this.sessionId = 'conv_test'; + } + return this.sessionId; + } + + async getItems(limit?: number): Promise { + if (limit == null) { + return [...this.#history]; + } + return this.#history.slice(-limit); + } + + async addItems(items: AgentInputItem[]): Promise { + this.#added.push(items); + this.#history.push(...items); + } + + async popItem(): Promise { + return this.#history.pop(); + } + + async clearSession(): Promise { + this.#history = []; + this.sessionId = undefined; + } + } + + class RecordingModel extends FakeModel { + lastRequest: ModelRequest | undefined; + + override async getResponse( + request: ModelRequest, + ): Promise { + this.lastRequest = request; + return super.getResponse(request); + } + } + + it('uses session history and stores run results', async () => { + const model = new RecordingModel([ + { + ...TEST_MODEL_RESPONSE_BASIC, + output: [fakeModelMessage('response')], + }, + ]); + const agent = new Agent({ name: 'SessionAgent', model }); + const historyItem = fakeModelMessage( + 'earlier message', + ) as AgentInputItem; + const session = new MemorySession([historyItem]); + const runner = new Runner(); + + await runner.run(agent, 'How are you?', { session }); + + const recordedInput = model.lastRequest?.input as AgentInputItem[]; + expect(Array.isArray(recordedInput)).toBe(true); + expect(recordedInput[0]).toEqual(historyItem); + expect(recordedInput[1]).toMatchObject({ + role: 'user', + content: 'How are you?', + }); + + expect(session.added).toHaveLength(1); + expect(session.added[0][0]).toMatchObject({ + role: 'user', + content: 'How are you?', + }); + expect(session.added[0][1]).toMatchObject({ role: 'assistant' }); + }); + + it('rejects list inputs when using session history', async () => { + const runner = new Runner(); + const agent = new Agent({ name: 'ListSession' }); + const session = new MemorySession(); + + await expect( + runner.run( + agent, + [ + { + type: 'message', + role: 'user', + content: 'Hello', + } as AgentInputItem, + ], + { + session, + }, + ), + ).rejects.toThrow( + 'Cannot provide both a session and a list of input items.', + ); + }); + }); }); describe('selectModel', () => { diff --git a/packages/agents-openai/src/index.ts b/packages/agents-openai/src/index.ts index c62d7aeb..39517260 100644 --- a/packages/agents-openai/src/index.ts +++ b/packages/agents-openai/src/index.ts @@ -18,3 +18,7 @@ export { codeInterpreterTool, imageGenerationTool, } from './tools'; +export { + OpenAIConversationsSession, + startOpenAIConversationsSession, +} from './openaiConversationsSession'; diff --git a/packages/agents-openai/src/openaiConversationsSession.ts b/packages/agents-openai/src/openaiConversationsSession.ts new file mode 100644 index 00000000..9487aa27 --- /dev/null +++ b/packages/agents-openai/src/openaiConversationsSession.ts @@ -0,0 +1,150 @@ +import OpenAI from 'openai'; +import type { AgentInputItem, Session } from '@openai/agents-core'; +import { getDefaultOpenAIClient, getDefaultOpenAIKey } from './defaults'; +import { convertToOutputItem, getInputItems } from './openaiResponsesModel'; +import { protocol } from '@openai/agents-core'; + +export type OpenAIConversationsSessionOptions = { + conversationId?: string; + client?: OpenAI; + apiKey?: string; + baseURL?: string; + organization?: string; + project?: string; +}; + +function resolveClient(options: OpenAIConversationsSessionOptions): OpenAI { + if (options.client) { + return options.client; + } + + return ( + getDefaultOpenAIClient() ?? + new OpenAI({ + apiKey: options.apiKey ?? getDefaultOpenAIKey(), + baseURL: options.baseURL, + organization: options.organization, + project: options.project, + }) + ); +} + +export async function startOpenAIConversationsSession( + client?: OpenAI, +): Promise { + const resolvedClient = client ?? resolveClient({}); + const response = await resolvedClient.conversations.create({ items: [] }); + return response.id; +} + +export class OpenAIConversationsSession implements Session { + #client: OpenAI; + #conversationId?: string; + + constructor(options: OpenAIConversationsSessionOptions = {}) { + this.#client = resolveClient(options); + this.#conversationId = options.conversationId; + } + + get sessionId(): string | undefined { + return this.#conversationId; + } + + async getSessionId(): Promise { + if (!this.#conversationId) { + this.#conversationId = await startOpenAIConversationsSession( + this.#client, + ); + } + + return this.#conversationId; + } + + async getItems(limit?: number): Promise { + const conversationId = await this.getSessionId(); + const items: AgentInputItem[] = []; + + const iterator = this.#client.conversations.items.list( + conversationId, + limit ? { limit, order: 'desc' as const } : { order: 'asc' as const }, + ); + for await (const item of iterator) { + if (item.type === 'message' && item.role === 'user') { + items.push({ + id: item.id, + type: 'message', + role: 'user', + content: item.content + .map((c) => { + if (c.type === 'input_text') { + return { type: 'input_text', text: c.text }; + } else if (c.type === 'input_image') { + if (c.image_url) { + return { type: 'input_image', image: c.image_url }; + } else if (c.file_id) { + return { type: 'input_image', image: { id: c.file_id } }; + } + } else if (c.type === 'input_file') { + if (c.file_url) { + return { type: 'input_file', file: c.file_url }; + } else if (c.file_id) { + return { type: 'input_file', file: { id: c.file_id } }; + } + } + // Add more content types here when they're added + return null; + }) + .filter((c) => c !== null) as protocol.UserContent[], + }); + } else { + items.push( + ...convertToOutputItem([item as OpenAI.Responses.ResponseOutputItem]), + ); + } + if (limit && items.length >= limit) { + break; + } + } + if (limit) { + items.reverse(); + } + return items; + } + + async addItems(items: AgentInputItem[]): Promise { + if (!items.length) { + return; + } + + const conversationId = await this.getSessionId(); + await this.#client.conversations.items.create(conversationId, { + items: getInputItems(items), + }); + } + + async popItem(): Promise { + const conversationId = await this.getSessionId(); + const [latest] = await this.getItems(1); + if (!latest) { + return undefined; + } + + const itemId = (latest as { id?: string }).id; + if (itemId) { + await this.#client.conversations.items.delete(itemId, { + conversation_id: conversationId, + }); + } + + return latest; + } + + async clearSession(): Promise { + if (!this.#conversationId) { + return; + } + + await this.#client.conversations.delete(this.#conversationId); + this.#conversationId = undefined; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0fad770..7eb5a375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,6 +254,12 @@ importers: specifier: ^3.25.40 version: 3.25.62 + examples/memory: + dependencies: + '@openai/agents': + specifier: workspace:* + version: link:../../packages/agents + examples/model-providers: dependencies: '@openai/agents': @@ -6473,9 +6479,6 @@ packages: zod@3.25.62: resolution: {integrity: sha512-YCxsr4DmhPcrKPC9R1oBHQNlQzlJEyPAId//qTau/vBee9uO8K6prmRq4eMkOyxvBfH4wDPIPdLx9HVMWIY3xA==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6652,7 +6655,7 @@ snapshots: dependencies: sitemap: 8.0.0 stream-replace-string: 2.0.0 - zod: 3.25.76 + zod: 3.25.62 '@astrojs/starlight-tailwind@4.0.1(@astrojs/starlight@0.35.2(astro@5.13.5(@types/node@24.3.0)(jiti@2.4.2)(lightningcss@1.30.1)(rollup@4.44.2)(tsx@4.20.1)(typescript@5.8.3)(yaml@2.7.1)))(tailwindcss@3.4.17)': dependencies: @@ -13121,6 +13124,4 @@ snapshots: zod@3.25.62: {} - zod@3.25.76: {} - zwitch@2.0.4: {}