From c8b8c86e1fb30069dc2393ff194d262304cfc676 Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Fri, 31 Oct 2025 14:30:44 -0700 Subject: [PATCH 1/2] feat(examples): add codex tool example --- examples/tools/README.md | 8 +- examples/tools/codex-tool.ts | 83 +++ examples/tools/package.json | 2 + package.json | 1 + packages/agents-extensions/package.json | 1 + packages/agents-extensions/src/codexTool.ts | 579 ++++++++++++++++++ packages/agents-extensions/src/index.ts | 1 + packages/agents-extensions/src/metadata.ts | 3 +- .../agents-extensions/test/codexTool.test.ts | 241 ++++++++ pnpm-lock.yaml | 12 + 10 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 examples/tools/codex-tool.ts create mode 100644 packages/agents-extensions/src/codexTool.ts create mode 100644 packages/agents-extensions/test/codexTool.test.ts diff --git a/examples/tools/README.md b/examples/tools/README.md index 6d6956ef..20af8dd2 100644 --- a/examples/tools/README.md +++ b/examples/tools/README.md @@ -16,6 +16,12 @@ These examples demonstrate the hosted tools provided by the Agents SDK. pnpm examples:tools-file-search ``` +- `codex-tool.ts` – Wraps the Codex SDK as an agent tool and prints structured tracing output. Requires `OPENAI_API_KEY` and `CODEX_API_KEY` environment variables. + + ```bash + pnpm examples:tools-codex + ``` + - `web-search.ts` – Demonstrates `webSearchTool` for general web queries. ```bash @@ -32,4 +38,4 @@ These examples demonstrate the hosted tools provided by the Agents SDK. ```bash pnpm examples:tools-image-generation - ``` \ No newline at end of file + ``` diff --git a/examples/tools/codex-tool.ts b/examples/tools/codex-tool.ts new file mode 100644 index 00000000..b9488af8 --- /dev/null +++ b/examples/tools/codex-tool.ts @@ -0,0 +1,83 @@ +import { + Agent, + run, + withTrace, + type RunItem, + type RunToolCallOutputItem, +} from '@openai/agents'; +import { codexTool } from '@openai/agents-extensions'; + +type CodexToolOutput = { + threadId: string | null; + response: string; + usage: Record | null; +}; + +function ensureEnvironmentVariables(): void { + const requiredVariables = ['OPENAI_API_KEY', 'CODEX_API_KEY']; + const missing = requiredVariables.filter((name) => !process.env[name]); + + if (missing.length > 0) { + throw new Error( + `Missing required environment variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}.`, + ); + } +} + +function isCodexToolOutputItem( + item: RunItem, +): item is RunToolCallOutputItem & { output: CodexToolOutput } { + if (item.type !== 'tool_call_output_item' || item.rawItem.name !== 'codex') { + return false; + } + + const output = item.output as unknown; + if (typeof output !== 'object' || output === null) { + return false; + } + + const maybeOutput = output as Partial; + return ( + typeof maybeOutput.response === 'string' && + (typeof maybeOutput.threadId === 'string' || maybeOutput.threadId === null) + ); +} + +async function main(): Promise { + ensureEnvironmentVariables(); + + const agent = new Agent({ + name: 'Codex tool orchestrator', + instructions: + 'You route workspace automation tasks through the codex tool. Always call the codex tool at least once before responding, and use it to run commands or inspect files before summarizing the results.', + tools: [codexTool()], + }); + + const task = + 'Call the codex tool to run `ls -1` in the current directory and summarize the output for the user.'; + + const result = await withTrace('Codex tool example', async () => { + console.log('Starting Codex tool run...\n'); + return run(agent, task); + }); + + console.log(`Agent response:\n${String(result.finalOutput ?? '')}\n`); + + const codexOutput = result.newItems.find(isCodexToolOutputItem); + if (codexOutput) { + const { threadId, response, usage } = codexOutput.output; + console.log('Codex tool call returned:'); + console.log(` Thread ID: ${threadId ?? 'not provided'}`); + console.log(` Response: ${response}`); + if (usage) { + console.log(' Usage:', usage); + } + } else { + console.warn('The Codex tool did not produce a structured result.'); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/examples/tools/package.json b/examples/tools/package.json index 43053e4d..3de2a9ae 100644 --- a/examples/tools/package.json +++ b/examples/tools/package.json @@ -3,10 +3,12 @@ "name": "tools", "dependencies": { "@openai/agents": "workspace:*", + "@openai/agents-extensions": "workspace:*", "playwright": "^1.55.1" }, "scripts": { "build-check": "tsc --noEmit", + "start:codex-tool": "tsx codex-tool.ts", "start:computer-use": "tsx computer-use.ts", "start:file-search": "tsx file-search.ts", "start:web-search": "tsx web-search.ts", diff --git a/package.json b/package.json index 3d1fc563..df0269a8 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "examples:research-bot": "pnpm -F research-bot start", "examples:financial-research-agent": "pnpm -F financial-research-agent start", "examples:tools-computer-use": "pnpm -F tools start:computer-use", + "examples:tools-codex": "pnpm -F tools start:codex-tool", "examples:tools-file-search": "pnpm -F tools start:file-search", "examples:tools-web-search": "pnpm -F tools start:web-search", "examples:tool-filter": "tsx examples/mcp/tool-filter-example.ts", diff --git a/packages/agents-extensions/package.json b/packages/agents-extensions/package.json index 70aa4730..19ecf09a 100644 --- a/packages/agents-extensions/package.json +++ b/packages/agents-extensions/package.json @@ -15,6 +15,7 @@ "dependencies": { "@ai-sdk/provider": "^2.0.0", "@openai/agents-core": "workspace:*", + "@openai/codex-sdk": "^0.53.0", "@types/ws": "^8.18.1", "debug": "^4.4.0" }, diff --git a/packages/agents-extensions/src/codexTool.ts b/packages/agents-extensions/src/codexTool.ts new file mode 100644 index 00000000..78c1b565 --- /dev/null +++ b/packages/agents-extensions/src/codexTool.ts @@ -0,0 +1,579 @@ +import { + createCustomSpan, + CustomSpanData, + FunctionTool, + JsonObjectSchemaStrict, + RunContext, + Span, + Usage, + UserError, +} from '@openai/agents'; +import { + Codex, + CodexOptions, + CommandExecutionItem, + McpToolCallItem, + ReasoningItem, + RunStreamedResult, + SandboxMode, + Thread, + ThreadItem, + ThreadOptions, + TurnOptions, + Usage as CodexUsage, + UserInput, +} from '@openai/codex-sdk'; + +type CustomSpan = Span; + +type CodexToolCallArguments = { + task: string; + inputs?: UserInput[]; + threadId?: string; + outputSchema?: unknown; + sandboxMode?: SandboxMode; + workingDirectory?: string; + skipGitRepoCheck?: boolean; +}; + +type CodexToolOptions = { + /** + * Name of the tool as exposed to the agent model. + * + * @defaultValue `'codex'` + */ + name?: string; + /** + * Description surfaced to the agent model. + */ + description?: string; + /** + * Explicit parameter schema. When omitted, the default schema is used. + */ + parameters?: JsonObjectSchemaStrict<{ + task: { type: 'string'; description: string }; + thread_id: { type: 'string'; description: string }; + output_schema: { type: 'object'; description: string }; + sandbox_mode: { + type: 'string'; + enum: ['read-only', 'workspace-write', 'danger-full-access']; + description: string; + }; + working_directory: { type: 'string'; description: string }; + skip_git_repo_check: { type: 'boolean'; description: string }; + inputs: { + type: 'array'; + description: string; + items: { + oneOf: [ + { + type: 'object'; + properties: { + type: { const: 'text' }; + text: { type: 'string'; description: string }; + }; + required: ['type', 'text']; + additionalProperties: false; + }, + { + type: 'object'; + properties: { + type: { const: 'local_image' }; + path: { type: 'string'; description: string }; + }; + required: ['type', 'path']; + additionalProperties: false; + }, + ]; + }; + }; + }>; + /** + * Reuse an existing Codex instance. When omitted a new Codex instance will be created. + */ + codex?: Codex; + /** + * Options passed to the Codex constructor when {@link CodexToolOptions.codex} is undefined. + */ + codexOptions?: CodexOptions; + /** + * Default options applied to every Codex thread. + */ + defaultThreadOptions?: ThreadOptions; + /** + * Default options applied to every Codex turn. + */ + defaultTurnOptions?: TurnOptions; +}; + +const defaultParameters: NonNullable = { + type: 'object', + additionalProperties: false, + required: ['task'], + properties: { + task: { + type: 'string', + description: + 'Detailed instruction for the Codex agent. This should include enough context for the agent to act.', + }, + thread_id: { + type: 'string', + description: + 'Resume an existing Codex thread by id. Omit to start a fresh thread for this tool call.', + }, + output_schema: { + type: 'object', + description: + 'Optional JSON schema that Codex should satisfy when producing its final answer. The schema must be compatible with OpenAI JSON schema format.', + }, + sandbox_mode: { + type: 'string', + enum: ['read-only', 'workspace-write', 'danger-full-access'], + description: + 'Sandbox permissions for the Codex task. Defaults to Codex CLI defaults if omitted.', + }, + working_directory: { + type: 'string', + description: + 'Absolute path used as the working directory for the Codex thread. Defaults to the current process working directory.', + }, + skip_git_repo_check: { + type: 'boolean', + description: + 'Set to true to allow Codex to run outside a Git repository. By default Codex requires a Git workspace.', + }, + inputs: { + type: 'array', + description: + 'Optional structured inputs appended after the task. Supports additional text snippets and local images.', + items: { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'text' }, + text: { + type: 'string', + description: 'Additional text provided to the Codex task.', + }, + }, + required: ['type', 'text'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + type: { const: 'local_image' }, + path: { + type: 'string', + description: 'Absolute or relative path to the image on disk.', + }, + }, + required: ['type', 'path'], + additionalProperties: false, + }, + ], + }, + }, + }, +}; + +/** + * Wraps the Codex SDK in a function tool that can be consumed by the Agents SDK. + * + * The tool streams Codex events, creating child spans for reasoning items, command executions, + * and MCP tool invocations. Those spans are nested under the Codex tool span automatically when + * tracing is enabled. + */ +export function codexTool(options: CodexToolOptions = {}): FunctionTool { + const { + name = 'codex', + description = 'Executes an agentic Codex task against the current workspace.', + parameters = defaultParameters, + codex: providedCodex, + codexOptions, + defaultThreadOptions, + defaultTurnOptions, + } = options; + + const codexInstance = providedCodex ?? new Codex(codexOptions); + + return { + type: 'function', + name, + description, + parameters, + strict: true, + needsApproval: async () => false, + isEnabled: async () => true, + invoke: async ( + runContext: RunContext, + rawInput: string, + ): Promise<{ + threadId: string | null; + response: string; + usage: CodexUsage | null; + }> => { + const args = parseArguments(rawInput); + + const thread = getThread(codexInstance, args, defaultThreadOptions); + const turnOptions = buildTurnOptions(args, defaultTurnOptions); + const codexInput = buildCodexInput(args); + + const streamResult = await thread.runStreamed(codexInput, turnOptions); + + const { response, usage } = await consumeEvents(streamResult, args); + + if (usage) { + runContext.usage.add( + new Usage({ + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + total_tokens: usage.input_tokens + usage.output_tokens, + requests: 1, + }), + ); + } + + return { + threadId: thread.id, + response, + usage, + }; + }, + }; +} + +function buildTurnOptions( + args: CodexToolCallArguments, + defaults?: TurnOptions, +): TurnOptions | undefined { + const hasOverrides = typeof args.outputSchema !== 'undefined'; + if (!defaults && !hasOverrides) { + return undefined; + } + + return { + ...(defaults ?? {}), + ...(hasOverrides ? { outputSchema: args.outputSchema } : {}), + }; +} + +function getThread( + codex: Codex, + args: CodexToolCallArguments, + defaults?: ThreadOptions, +): Thread { + const hasOverrides = + typeof args.sandboxMode !== 'undefined' || + typeof args.workingDirectory === 'string' || + typeof args.skipGitRepoCheck === 'boolean'; + + const threadOptions: ThreadOptions | undefined = + defaults || hasOverrides + ? { + ...(defaults ?? {}), + ...(args.sandboxMode ? { sandboxMode: args.sandboxMode } : {}), + ...(args.workingDirectory + ? { workingDirectory: args.workingDirectory } + : {}), + ...(typeof args.skipGitRepoCheck === 'boolean' + ? { skipGitRepoCheck: args.skipGitRepoCheck } + : {}), + } + : undefined; + + if (args.threadId) { + return codex.resumeThread(args.threadId, threadOptions); + } + return codex.startThread(threadOptions); +} + +function buildCodexInput(args: CodexToolCallArguments): string | UserInput[] { + if (args.inputs && args.inputs.length > 0) { + const base: UserInput[] = args.task.trim().length + ? [{ type: 'text', text: args.task }] + : []; + return base.concat(args.inputs); + } + return args.task; +} + +async function consumeEvents( + { events }: RunStreamedResult, + args: CodexToolCallArguments, +): Promise<{ response: string; usage: CodexUsage | null }> { + const activeSpans = new Map(); + let finalResponse = ''; + let usage: CodexUsage | null = null; + + try { + for await (const event of events) { + switch (event.type) { + case 'item.started': + handleItemStarted(event.item, activeSpans); + break; + case 'item.updated': + handleItemUpdated(event.item, activeSpans); + break; + case 'item.completed': + handleItemCompleted(event.item, activeSpans); + if ( + event.item.type === 'agent_message' && + typeof event.item.text === 'string' + ) { + finalResponse = event.item.text; + } + break; + case 'turn.completed': + usage = event.usage ?? null; + break; + case 'turn.failed': + throw new UserError( + `Codex turn failed${event.error?.message ? `: ${event.error.message}` : ''}`, + ); + case 'error': + throw new UserError(`Codex stream error: ${event.message}`); + default: + // ignore other events + break; + } + } + } finally { + for (const span of activeSpans.values()) { + span.end(); + } + activeSpans.clear(); + } + + if (!finalResponse) { + finalResponse = buildDefaultResponse(args); + } + + return { response: finalResponse, usage }; +} + +function handleItemStarted(item: ThreadItem, spans: Map) { + if (isCommandExecutionItem(item)) { + const span = createCustomSpan({ + data: { + name: 'Codex command execution', + data: { + command: item.command, + status: item.status, + output: item.aggregated_output ?? '', + exitCode: item.exit_code ?? null, + }, + }, + }); + span.start(); + spans.set(item.id, span); + return; + } + + if (isMcpToolCallItem(item)) { + const span = createCustomSpan({ + data: { + name: `Codex MCP tool call`, + data: { + server: item.server, + tool: item.tool, + status: item.status, + arguments: item.arguments ?? null, + }, + }, + }); + span.start(); + spans.set(item.id, span); + return; + } + + if (isReasoningItem(item)) { + const span = createCustomSpan({ + data: { + name: 'Codex reasoning', + data: { + text: item.text, + }, + }, + }); + span.start(); + spans.set(item.id, span); + } +} + +function handleItemUpdated(item: ThreadItem, spans: Map) { + const span = item.id ? spans.get(item.id) : undefined; + if (!span) { + return; + } + + if (isCommandExecutionItem(item)) { + updateCommandSpan(span, item); + } else if (isMcpToolCallItem(item)) { + updateMcpToolSpan(span, item); + } else if (isReasoningItem(item)) { + updateReasoningSpan(span, item); + } +} + +function handleItemCompleted(item: ThreadItem, spans: Map) { + const span = item.id ? spans.get(item.id) : undefined; + if (!span) { + return; + } + + if (isCommandExecutionItem(item)) { + updateCommandSpan(span, item); + if (item.status === 'failed') { + span.setError({ + message: 'Codex command execution failed.', + data: { + exitCode: item.exit_code ?? null, + output: item.aggregated_output ?? '', + }, + }); + } + } else if (isMcpToolCallItem(item)) { + updateMcpToolSpan(span, item); + if (item.status === 'failed' && item.error?.message) { + span.setError({ + message: item.error.message, + }); + } + } else if (isReasoningItem(item)) { + updateReasoningSpan(span, item); + } + + span.end(); + spans.delete(item.id); +} + +function updateCommandSpan(span: CustomSpan, item: CommandExecutionItem) { + const data = span.spanData.data; + data.command = item.command; + data.status = item.status; + data.output = item.aggregated_output ?? ''; + data.exitCode = item.exit_code ?? null; +} + +function updateMcpToolSpan(span: CustomSpan, item: McpToolCallItem) { + const data = span.spanData.data; + data.server = item.server; + data.tool = item.tool; + data.status = item.status; + data.arguments = item.arguments ?? null; + data.result = item.result ?? null; + data.error = item.error ?? null; +} + +function updateReasoningSpan(span: CustomSpan, item: ReasoningItem) { + const data = span.spanData.data; + data.text = item.text; +} + +function parseArguments(rawInput: string): CodexToolCallArguments { + let parsed: any; + try { + parsed = rawInput ? JSON.parse(rawInput) : {}; + } catch (_error) { + throw new UserError('Codex tool arguments must be valid JSON.'); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new UserError( + 'Codex tool arguments must be provided as a JSON object.', + ); + } + + const task = typeof parsed.task === 'string' ? parsed.task.trim() : ''; + if (!task) { + throw new UserError('Codex tool requires a non-empty "task" string.'); + } + + const inputs = parseInputs(parsed.inputs); + + return { + task, + inputs, + threadId: + typeof parsed.thread_id === 'string' && parsed.thread_id.length > 0 + ? parsed.thread_id + : typeof parsed.threadId === 'string' && parsed.threadId.length > 0 + ? parsed.threadId + : undefined, + outputSchema: parsed.output_schema ?? parsed.outputSchema, + sandboxMode: isSandboxMode(parsed.sandbox_mode ?? parsed.sandboxMode) + ? (parsed.sandbox_mode ?? parsed.sandboxMode) + : undefined, + workingDirectory: + typeof parsed.working_directory === 'string' && + parsed.working_directory.length > 0 + ? parsed.working_directory + : typeof parsed.workingDirectory === 'string' && + parsed.workingDirectory.length > 0 + ? parsed.workingDirectory + : undefined, + skipGitRepoCheck: + typeof parsed.skip_git_repo_check === 'boolean' + ? parsed.skip_git_repo_check + : typeof parsed.skipGitRepoCheck === 'boolean' + ? parsed.skipGitRepoCheck + : undefined, + }; +} + +function parseInputs(value: unknown): UserInput[] | undefined { + if (typeof value === 'undefined') { + return undefined; + } + if (!Array.isArray(value)) { + throw new UserError( + 'The "inputs" property must be an array when provided.', + ); + } + + const inputs: UserInput[] = value.map((entry) => { + if (!entry || typeof entry !== 'object') { + throw new UserError('Each item in "inputs" must be an object.'); + } + const typed = entry as Record; + if (typed.type === 'text' && typeof typed.text === 'string') { + return { type: 'text', text: typed.text }; + } + if (typed.type === 'local_image' && typeof typed.path === 'string') { + return { type: 'local_image', path: typed.path }; + } + throw new UserError( + 'Inputs must either be { "type": "text", "text": string } or { "type": "local_image", "path": string }.', + ); + }); + + return inputs.length === 0 ? undefined : inputs; +} + +function buildDefaultResponse(args: CodexToolCallArguments): string { + return `Codex task completed for "${args.task}".`; +} + +function isSandboxMode(value: unknown): value is SandboxMode { + return ( + value === 'read-only' || + value === 'workspace-write' || + value === 'danger-full-access' + ); +} + +function isCommandExecutionItem( + item: ThreadItem, +): item is CommandExecutionItem { + return item?.type === 'command_execution'; +} + +function isMcpToolCallItem(item: ThreadItem): item is McpToolCallItem { + return item?.type === 'mcp_tool_call'; +} + +function isReasoningItem(item: ThreadItem): item is ReasoningItem { + return item?.type === 'reasoning'; +} diff --git a/packages/agents-extensions/src/index.ts b/packages/agents-extensions/src/index.ts index 3b571f4c..ba915360 100644 --- a/packages/agents-extensions/src/index.ts +++ b/packages/agents-extensions/src/index.ts @@ -1,3 +1,4 @@ export * from './aiSdk'; export * from './CloudflareRealtimeTransport'; export * from './TwilioRealtimeTransport'; +export * from './codexTool'; diff --git a/packages/agents-extensions/src/metadata.ts b/packages/agents-extensions/src/metadata.ts index 0ef9fc41..4fc5b346 100644 --- a/packages/agents-extensions/src/metadata.ts +++ b/packages/agents-extensions/src/metadata.ts @@ -6,7 +6,8 @@ export const METADATA = { "version": "0.2.1", "versions": { "@openai/agents-extensions": "0.2.1", - "@openai/agents-core": "workspace:*" + "@openai/agents-core": "workspace:*", + "@openai/codex-sdk": "^0.53.0" } }; diff --git a/packages/agents-extensions/test/codexTool.test.ts b/packages/agents-extensions/test/codexTool.test.ts new file mode 100644 index 00000000..f955abfc --- /dev/null +++ b/packages/agents-extensions/test/codexTool.test.ts @@ -0,0 +1,241 @@ +import { + BatchTraceProcessor, + ConsoleSpanExporter, + RunContext, + Span, + TracingProcessor, + setTraceProcessors, + setTracingDisabled, + withFunctionSpan, + withTrace, +} from '@openai/agents'; +import { describe, afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { codexTool } from '../src/codexTool'; + +type AnySpan = Span; + +const codexMockState: { + events: any[]; + threadId: string; +} = { + events: [], + threadId: 'thread-1', +}; + +vi.mock('@openai/codex-sdk', () => { + class FakeThread { + id: string | null = null; + + async runStreamed(): Promise<{ events: AsyncGenerator }> { + this.id = codexMockState.threadId; + async function* eventStream(events: any[]) { + for (const event of events) { + yield event; + } + } + return { events: eventStream(codexMockState.events) }; + } + } + + return { + Codex: class FakeCodex { + startThread = vi.fn(() => new FakeThread()); + resumeThread = vi.fn(() => new FakeThread()); + }, + }; +}); + +class CollectingProcessor implements TracingProcessor { + public spans: AnySpan[] = []; + + async onTraceStart(): Promise {} + + async onTraceEnd(): Promise {} + + async onSpanStart(): Promise {} + + async onSpanEnd(span: AnySpan): Promise { + this.spans.push(span); + } + + async shutdown(): Promise {} + + async forceFlush(): Promise {} +} + +describe('codexTool', () => { + const processor = new CollectingProcessor(); + + beforeEach(() => { + processor.spans = []; + setTracingDisabled(false); + setTraceProcessors([processor]); + codexMockState.events = []; + codexMockState.threadId = 'thread-1'; + }); + + afterEach(() => { + setTracingDisabled(true); + setTraceProcessors([new BatchTraceProcessor(new ConsoleSpanExporter())]); + vi.restoreAllMocks(); + }); + + test('creates child spans for streamed Codex events and returns final response', async () => { + codexMockState.events = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { type: 'turn.started' }, + { + type: 'item.started', + item: { id: 'reason-1', type: 'reasoning', text: 'Initial reasoning' }, + }, + { + type: 'item.updated', + item: { id: 'reason-1', type: 'reasoning', text: 'Refined reasoning' }, + }, + { + type: 'item.completed', + item: { id: 'reason-1', type: 'reasoning', text: 'Final reasoning' }, + }, + { + type: 'item.started', + item: { + id: 'cmd-1', + type: 'command_execution', + command: 'npm test', + aggregated_output: '', + status: 'in_progress', + }, + }, + { + type: 'item.updated', + item: { + id: 'cmd-1', + type: 'command_execution', + command: 'npm test', + aggregated_output: 'Running tests', + status: 'in_progress', + }, + }, + { + type: 'item.completed', + item: { + id: 'cmd-1', + type: 'command_execution', + command: 'npm test', + aggregated_output: 'All good', + exit_code: 0, + status: 'completed', + }, + }, + { + type: 'item.started', + item: { + id: 'mcp-1', + type: 'mcp_tool_call', + server: 'gitmcp', + tool: 'search_codex_code', + arguments: { query: 'foo' }, + status: 'in_progress', + }, + }, + { + type: 'item.updated', + item: { + id: 'mcp-1', + type: 'mcp_tool_call', + server: 'gitmcp', + tool: 'search_codex_code', + arguments: { query: 'foo' }, + status: 'in_progress', + }, + }, + { + type: 'item.completed', + item: { + id: 'mcp-1', + type: 'mcp_tool_call', + server: 'gitmcp', + tool: 'search_codex_code', + arguments: { query: 'foo' }, + status: 'completed', + result: { content: [], structured_content: null }, + }, + }, + { + type: 'item.completed', + item: { id: 'agent-1', type: 'agent_message', text: 'Codex finished.' }, + }, + { + type: 'turn.completed', + usage: { input_tokens: 10, cached_input_tokens: 1, output_tokens: 5 }, + }, + ]; + + const tool = codexTool(); + const runContext = new RunContext(); + + const result = await withTrace('codex-test', () => + withFunctionSpan( + async () => + tool.invoke( + runContext, + JSON.stringify({ + task: 'Diagnose failure', + }), + ), + { data: { name: tool.name } }, + ), + ); + + expect(result.threadId).toBe('thread-1'); + expect(result.response).toBe('Codex finished.'); + expect(result.usage).toEqual({ + input_tokens: 10, + cached_input_tokens: 1, + output_tokens: 5, + }); + + expect(runContext.usage.totalTokens).toBe(15); + expect(runContext.usage.requests).toBe(1); + + expect(processor.spans.length).toBeGreaterThan(0); + + const functionSpan = processor.spans.find( + (span) => + span.spanData.type === 'function' && span.spanData.name === tool.name, + ); + expect(functionSpan).toBeDefined(); + + const customSpans = processor.spans.filter( + (span) => span.spanData.type === 'custom', + ); + expect(customSpans).toHaveLength(3); + + const reasoningSpan = customSpans.find( + (span) => span.spanData.name === 'Codex reasoning', + ); + expect(reasoningSpan?.parentId).toBe(functionSpan?.spanId); + expect(reasoningSpan?.spanData.data.text).toBe('Final reasoning'); + + const commandSpan = customSpans.find( + (span) => span.spanData.name === 'Codex command execution', + ); + expect(commandSpan?.parentId).toBe(functionSpan?.spanId); + expect(commandSpan?.spanData.data).toMatchObject({ + command: 'npm test', + status: 'completed', + output: 'All good', + exitCode: 0, + }); + + const mcpSpan = customSpans.find( + (span) => span.spanData.name === 'Codex MCP tool call', + ); + expect(mcpSpan?.parentId).toBe(functionSpan?.spanId); + expect(mcpSpan?.spanData.data).toMatchObject({ + server: 'gitmcp', + tool: 'search_codex_code', + status: 'completed', + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81c58231..870d00e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -434,6 +434,9 @@ importers: '@openai/agents': specifier: workspace:* version: link:../../packages/agents + '@openai/agents-extensions': + specifier: workspace:* + version: link:../../packages/agents-extensions playwright: specifier: ^1.55.1 version: 1.55.1 @@ -491,6 +494,9 @@ importers: '@openai/agents-core': specifier: workspace:* version: link:../agents-core + '@openai/codex-sdk': + specifier: ^0.53.0 + version: 0.53.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -1256,6 +1262,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@openai/codex-sdk@0.53.0': + resolution: {integrity: sha512-DtJTku9u9VKTfHsR2OM/d+jW3V14Yc18YHNgo+iIDHpSViApMM/2+2p6Yeeri2m5VUOVvUQuqsQQzAwRH2MMBw==} + engines: {node: '>=18'} + '@openrouter/ai-sdk-provider@1.2.0': resolution: {integrity: sha512-stuIwq7Yb7DNmk3GuCtz+oS3nZOY4TXEV3V5KsknDGQN7Fpu3KRMQVWRc1J073xKdf0FC9EHOctSyzsACmp5Ag==} engines: {node: '>=18'} @@ -6680,6 +6690,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@openai/codex-sdk@0.53.0': {} + '@openrouter/ai-sdk-provider@1.2.0(ai@5.0.23(zod@3.25.76))(zod@3.25.76)': dependencies: ai: 5.0.23(zod@3.25.76) From e6472dabef5263072b2e463d2cf36e73ee64edb7 Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Mon, 3 Nov 2025 17:03:01 -0800 Subject: [PATCH 2/2] fix build --- examples/tools/codex-tool.ts | 32 +- packages/agents-core/src/index.ts | 1 + packages/agents-core/src/tracing/index.ts | 1 + packages/agents-extensions/src/codexTool.ts | 722 ++++++++++++------ .../agents-extensions/test/codexTool.test.ts | 4 + 5 files changed, 531 insertions(+), 229 deletions(-) diff --git a/examples/tools/codex-tool.ts b/examples/tools/codex-tool.ts index b9488af8..52ca14d3 100644 --- a/examples/tools/codex-tool.ts +++ b/examples/tools/codex-tool.ts @@ -5,7 +5,10 @@ import { type RunItem, type RunToolCallOutputItem, } from '@openai/agents'; -import { codexTool } from '@openai/agents-extensions'; +import { + codexTool, + type CodexOutputSchemaDescriptor, +} from '@openai/agents-extensions'; type CodexToolOutput = { threadId: string | null; @@ -43,6 +46,31 @@ function isCodexToolOutputItem( ); } +const codexStructuredOutput: CodexOutputSchemaDescriptor = { + title: 'CodexToolResult', + properties: [ + { + name: 'summary', + description: 'High-level summary of Codex actions and findings.', + schema: { + type: 'string', + }, + }, + { + name: 'commands', + description: + 'Commands executed by Codex, in the order they were invoked.', + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + ], + required: ['summary'], +}; + async function main(): Promise { ensureEnvironmentVariables(); @@ -50,7 +78,7 @@ async function main(): Promise { name: 'Codex tool orchestrator', instructions: 'You route workspace automation tasks through the codex tool. Always call the codex tool at least once before responding, and use it to run commands or inspect files before summarizing the results.', - tools: [codexTool()], + tools: [codexTool({ outputSchema: codexStructuredOutput })], }); const task = diff --git a/packages/agents-core/src/index.ts b/packages/agents-core/src/index.ts index 72e71ffa..d779a39f 100644 --- a/packages/agents-core/src/index.ts +++ b/packages/agents-core/src/index.ts @@ -152,6 +152,7 @@ export type { FunctionCallItem, FunctionCallResultItem, JsonSchemaDefinition, + JsonObjectSchemaStrict, ReasoningItem, ResponseStreamEvent, SystemMessageItem, diff --git a/packages/agents-core/src/tracing/index.ts b/packages/agents-core/src/tracing/index.ts index 57dc48b3..4243c21e 100644 --- a/packages/agents-core/src/tracing/index.ts +++ b/packages/agents-core/src/tracing/index.ts @@ -17,6 +17,7 @@ export { ConsoleSpanExporter, } from './processor'; export { NoopSpan, Span } from './spans'; +export type { CustomSpanData } from './spans'; export { NoopTrace, Trace } from './traces'; export { generateGroupId, generateSpanId, generateTraceId } from './utils'; diff --git a/packages/agents-extensions/src/codexTool.ts b/packages/agents-extensions/src/codexTool.ts index 78c1b565..ed7e64a1 100644 --- a/packages/agents-extensions/src/codexTool.ts +++ b/packages/agents-extensions/src/codexTool.ts @@ -1,14 +1,17 @@ import { + RunContext, + Usage, + UserError, createCustomSpan, + tool, +} from '@openai/agents'; +import type { CustomSpanData, FunctionTool, - JsonObjectSchemaStrict, - RunContext, Span, - Usage, - UserError, + JsonObjectSchemaStrict, } from '@openai/agents'; -import { +import type { Codex, CodexOptions, CommandExecutionItem, @@ -23,6 +26,7 @@ import { Usage as CodexUsage, UserInput, } from '@openai/codex-sdk'; +import { z } from 'zod'; type CustomSpan = Span; @@ -30,12 +34,279 @@ type CodexToolCallArguments = { task: string; inputs?: UserInput[]; threadId?: string; - outputSchema?: unknown; sandboxMode?: SandboxMode; workingDirectory?: string; skipGitRepoCheck?: boolean; }; +const SANDBOX_MODES = [ + 'read-only', + 'workspace-write', + 'danger-full-access', +] as const; + +const JSON_PRIMITIVE_TYPES = [ + 'string', + 'number', + 'integer', + 'boolean', +] as const; + +const CodexToolInputItemSchema = z + .object({ + type: z.enum(['text', 'local_image']), + text: z.string(), + path: z.string(), + }) + .strict() + .superRefine((value, ctx) => { + const textValue = value.text.trim(); + const pathValue = value.path.trim(); + + if (value.type === 'text') { + if (textValue.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Text inputs must include a non-empty "text" field.', + path: ['text'], + }); + } + if (pathValue.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '"path" is not allowed when type is "text".', + path: ['path'], + }); + } + return; + } + + if (pathValue.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Local image inputs must include a non-empty "path" field.', + path: ['path'], + }); + } + if (textValue.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: '"text" is not allowed when type is "local_image".', + path: ['text'], + }); + } + }); + +const OutputSchemaPrimitiveSchema = z + .object({ + type: z.enum(JSON_PRIMITIVE_TYPES), + description: z.string().trim().optional(), + enum: z.array(z.string().trim().min(1)).min(1).optional(), + }) + .strict(); + +const OutputSchemaArraySchema = z + .object({ + type: z.literal('array'), + description: z.string().trim().optional(), + items: OutputSchemaPrimitiveSchema, + }) + .strict(); + +const OutputSchemaFieldSchema = z.union([ + OutputSchemaPrimitiveSchema, + OutputSchemaArraySchema, +]); + +const OutputSchemaPropertyDescriptorSchema = z + .object({ + name: z.string().trim().min(1), + description: z.string().trim().optional(), + schema: OutputSchemaFieldSchema, + }) + .strict(); + +const OutputSchemaDescriptorSchema = z + .object({ + title: z.string().trim().optional(), + description: z.string().trim().optional(), + properties: z + .array(OutputSchemaPropertyDescriptorSchema) + .min(1) + .describe( + 'Property descriptors for the Codex response. Each property name must be unique.', + ), + required: z.array(z.string().trim().min(1)).optional(), + }) + .strict() + .superRefine((descriptor, ctx) => { + const seen = new Set(); + for (const property of descriptor.properties) { + if (seen.has(property.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate property name "${property.name}" in output_schema.`, + path: ['properties'], + }); + break; + } + seen.add(property.name); + } + if (descriptor.required) { + for (const name of descriptor.required) { + if (!seen.has(name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Required property "${name}" must also be defined in "properties".`, + path: ['required'], + }); + } + } + } + }); + +const codexParametersSchema = z + .object({ + task: z + .string() + .trim() + .min(1, 'Codex tool requires a non-empty "task" string.') + .describe( + 'Detailed instruction for the Codex agent. Provide enough context for the agent to act.', + ), + thread_id: z + .string() + .trim() + .min(1) + .nullable() + .describe( + 'Resume an existing Codex thread by id. Provide null to start a new thread.', + ), + sandbox_mode: z + .enum(SANDBOX_MODES) + .nullable() + .describe( + 'Sandbox permissions for the Codex task. Provide null to use Codex defaults.', + ), + working_directory: z + .string() + .trim() + .min(1) + .nullable() + .describe( + 'Absolute path used as the working directory for the Codex thread. Provide null to default to the current process working directory.', + ), + skip_git_repo_check: z + .boolean() + .nullable() + .describe( + 'Allow Codex to run outside a Git repository when true. Provide null to use Codex defaults.', + ), + inputs: z + .array(CodexToolInputItemSchema) + .nullable() + .describe( + 'Optional structured inputs appended after the task. Provide null when no additional inputs are needed.', + ), + }) + .strict(); + +type CodexToolParametersSchema = typeof codexParametersSchema; +type CodexToolParameters = z.infer; +type OutputSchemaDescriptor = z.infer; +type OutputSchemaField = z.infer; + +const codexParametersJsonSchema: JsonObjectSchemaStrict<{ + task: { type: 'string'; description: string }; + thread_id: { type: ['string', 'null']; description: string }; + sandbox_mode: { + type: ['string', 'null']; + description: string; + enum: typeof SANDBOX_MODES extends readonly (infer U)[] ? U[] : string[]; + }; + working_directory: { type: ['string', 'null']; description: string }; + skip_git_repo_check: { type: ['boolean', 'null']; description: string }; + inputs: { + type: ['array', 'null']; + description: string; + items: { + type: 'object'; + additionalProperties: false; + required: ['type', 'text', 'path']; + properties: { + type: { type: 'string'; enum: ['text', 'local_image'] }; + text: { type: 'string'; description: string }; + path: { type: 'string'; description: string }; + }; + }; + }; +}> = { + type: 'object', + additionalProperties: false, + required: [ + 'task', + 'thread_id', + 'sandbox_mode', + 'working_directory', + 'skip_git_repo_check', + 'inputs', + ], + properties: { + task: { + type: 'string', + description: + 'Detailed instruction for the Codex agent. Provide enough context for the agent to act.', + }, + thread_id: { + type: ['string', 'null'], + description: + 'Resume an existing Codex thread by id. Set to null when starting a new thread.', + }, + sandbox_mode: { + type: ['string', 'null'], + enum: [...SANDBOX_MODES], + description: + 'Sandbox permissions for the Codex task. Set to null to use Codex defaults.', + }, + working_directory: { + type: ['string', 'null'], + description: + 'Absolute path used as the working directory for the Codex thread. Set to null to use the current process directory.', + }, + skip_git_repo_check: { + type: ['boolean', 'null'], + description: + 'Allow Codex to run outside a Git repository when true. Set to null to use the Codex default (false).', + }, + inputs: { + type: ['array', 'null'], + description: + 'Optional structured inputs appended after the task. Supports additional text snippets and local images.', + items: { + type: 'object', + additionalProperties: false, + required: ['type', 'text', 'path'], + properties: { + type: { + type: 'string', + enum: ['text', 'local_image'], + }, + text: { + type: 'string', + description: + 'Text content added to the Codex task. Provide a non-empty string when the input type is "text"; otherwise set this to an empty string.', + }, + path: { + type: 'string', + description: + 'Absolute or relative path to an image on disk when the input type is "local_image"; otherwise set this to an empty string.', + }, + }, + }, + }, + }, +}; + type CodexToolOptions = { /** * Name of the tool as exposed to the agent model. @@ -48,46 +319,14 @@ type CodexToolOptions = { */ description?: string; /** - * Explicit parameter schema. When omitted, the default schema is used. + * Explicit Zod parameter schema. When omitted, the default schema is used. */ - parameters?: JsonObjectSchemaStrict<{ - task: { type: 'string'; description: string }; - thread_id: { type: 'string'; description: string }; - output_schema: { type: 'object'; description: string }; - sandbox_mode: { - type: 'string'; - enum: ['read-only', 'workspace-write', 'danger-full-access']; - description: string; - }; - working_directory: { type: 'string'; description: string }; - skip_git_repo_check: { type: 'boolean'; description: string }; - inputs: { - type: 'array'; - description: string; - items: { - oneOf: [ - { - type: 'object'; - properties: { - type: { const: 'text' }; - text: { type: 'string'; description: string }; - }; - required: ['type', 'text']; - additionalProperties: false; - }, - { - type: 'object'; - properties: { - type: { const: 'local_image' }; - path: { type: 'string'; description: string }; - }; - required: ['type', 'path']; - additionalProperties: false; - }, - ]; - }; - }; - }>; + parameters?: CodexToolParametersSchema; + /** + * Optional descriptor or JSON schema used for Codex structured output. + * This schema is applied to every Codex turn unless overridden at call time. + */ + outputSchema?: OutputSchemaDescriptor | Record; /** * Reuse an existing Codex instance. When omitted a new Codex instance will be created. */ @@ -106,76 +345,46 @@ type CodexToolOptions = { defaultTurnOptions?: TurnOptions; }; -const defaultParameters: NonNullable = { - type: 'object', - additionalProperties: false, - required: ['task'], - properties: { - task: { - type: 'string', - description: - 'Detailed instruction for the Codex agent. This should include enough context for the agent to act.', - }, - thread_id: { - type: 'string', - description: - 'Resume an existing Codex thread by id. Omit to start a fresh thread for this tool call.', - }, - output_schema: { - type: 'object', - description: - 'Optional JSON schema that Codex should satisfy when producing its final answer. The schema must be compatible with OpenAI JSON schema format.', - }, - sandbox_mode: { - type: 'string', - enum: ['read-only', 'workspace-write', 'danger-full-access'], - description: - 'Sandbox permissions for the Codex task. Defaults to Codex CLI defaults if omitted.', - }, - working_directory: { - type: 'string', - description: - 'Absolute path used as the working directory for the Codex thread. Defaults to the current process working directory.', - }, - skip_git_repo_check: { - type: 'boolean', - description: - 'Set to true to allow Codex to run outside a Git repository. By default Codex requires a Git workspace.', - }, - inputs: { - type: 'array', - description: - 'Optional structured inputs appended after the task. Supports additional text snippets and local images.', - items: { - oneOf: [ - { - type: 'object', - properties: { - type: { const: 'text' }, - text: { - type: 'string', - description: 'Additional text provided to the Codex task.', - }, - }, - required: ['type', 'text'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - type: { const: 'local_image' }, - path: { - type: 'string', - description: 'Absolute or relative path to the image on disk.', - }, - }, - required: ['type', 'path'], - additionalProperties: false, - }, - ], - }, - }, - }, +type CodexModule = typeof import('@openai/codex-sdk'); + +let cachedCodexModulePromise: Promise | null = null; + +async function importCodexModule(): Promise { + if (!cachedCodexModulePromise) { + // The Codex SDK only ships ESM. Wrapping dynamic import in a Function keeps the call site + // as `import()` in both ESM and CommonJS builds so we avoid generating a `require()` call. + cachedCodexModulePromise = new Function( + 'specifier', + 'return import(specifier);', + )('@openai/codex-sdk') as Promise; + } + return cachedCodexModulePromise; +} + +function createCodexResolver( + providedCodex: Codex | undefined, + options: CodexOptions | undefined, +): () => Promise { + if (providedCodex) { + return async () => providedCodex; + } + + let codexInstance: Codex | null = null; + return async () => { + if (!codexInstance) { + const { Codex } = await importCodexModule(); + codexInstance = new Codex(options); + } + return codexInstance; + }; +} + +const defaultParameters = codexParametersSchema; + +type CodexToolResult = { + threadId: string | null; + response: string; + usage: CodexUsage | null; }; /** @@ -185,7 +394,9 @@ const defaultParameters: NonNullable = { * and MCP tool invocations. Those spans are nested under the Codex tool span automatically when * tracing is enabled. */ -export function codexTool(options: CodexToolOptions = {}): FunctionTool { +export function codexTool( + options: CodexToolOptions = {}, +): FunctionTool { const { name = 'codex', description = 'Executes an agentic Codex task against the current workspace.', @@ -194,30 +405,29 @@ export function codexTool(options: CodexToolOptions = {}): FunctionTool { codexOptions, defaultThreadOptions, defaultTurnOptions, + outputSchema: outputSchemaOption, } = options; - const codexInstance = providedCodex ?? new Codex(codexOptions); + const resolveCodex = createCodexResolver(providedCodex, codexOptions); - return { - type: 'function', + const validatedOutputSchema = resolveOutputSchema(outputSchemaOption); + + return tool({ name, description, - parameters, + parameters: codexParametersJsonSchema, strict: true, - needsApproval: async () => false, - isEnabled: async () => true, - invoke: async ( - runContext: RunContext, - rawInput: string, - ): Promise<{ - threadId: string | null; - response: string; - usage: CodexUsage | null; - }> => { - const args = parseArguments(rawInput); - - const thread = getThread(codexInstance, args, defaultThreadOptions); - const turnOptions = buildTurnOptions(args, defaultTurnOptions); + execute: async (input, runContext = new RunContext()) => { + const parsed = parameters.parse(input); + const args = normalizeParameters(parsed); + + const codex = await resolveCodex(); + const thread = getThread(codex, args, defaultThreadOptions); + const turnOptions = buildTurnOptions( + args, + defaultTurnOptions, + validatedOutputSchema, + ); const codexInput = buildCodexInput(args); const streamResult = await thread.runStreamed(codexInput, turnOptions); @@ -241,22 +451,168 @@ export function codexTool(options: CodexToolOptions = {}): FunctionTool { usage, }; }, - }; + needsApproval: false, + isEnabled: true, + }); +} + +export type CodexOutputSchemaDescriptor = OutputSchemaDescriptor; +export type CodexOutputSchema = Record; + +function resolveOutputSchema( + option?: OutputSchemaDescriptor | Record, +): Record | undefined { + if (!option) { + return undefined; + } + + if (isJsonObjectSchema(option)) { + if (option.additionalProperties !== false) { + throw new UserError( + 'Codex output schema must set "additionalProperties" to false.', + ); + } + return option; + } + + const descriptor = OutputSchemaDescriptorSchema.parse(option); + return buildCodexOutputSchema(descriptor); } function buildTurnOptions( args: CodexToolCallArguments, - defaults?: TurnOptions, + defaults: TurnOptions | undefined, + outputSchema: Record | undefined, ): TurnOptions | undefined { - const hasOverrides = typeof args.outputSchema !== 'undefined'; - if (!defaults && !hasOverrides) { + const hasOverrides = + typeof args.sandboxMode !== 'undefined' || + typeof args.workingDirectory === 'string' || + typeof args.skipGitRepoCheck === 'boolean'; + + if (!defaults && !hasOverrides && !outputSchema) { return undefined; } return { ...(defaults ?? {}), - ...(hasOverrides ? { outputSchema: args.outputSchema } : {}), + ...(outputSchema ? { outputSchema } : {}), + }; +} + +function normalizeParameters( + params: CodexToolParameters, +): CodexToolCallArguments { + const inputs = params.inputs + ? params.inputs.map((item) => + item.type === 'text' + ? { type: 'text', text: item.text.trim() } + : { type: 'local_image', path: item.path.trim() }, + ) + : undefined; + + const sandboxModeCandidate = params.sandbox_mode ?? undefined; + const sandboxMode = + sandboxModeCandidate && SANDBOX_MODES.includes(sandboxModeCandidate) + ? sandboxModeCandidate + : undefined; + + return { + task: params.task.trim(), + inputs: inputs && inputs.length > 0 ? inputs : undefined, + threadId: + typeof params.thread_id === 'string' && params.thread_id.trim().length > 0 + ? params.thread_id.trim() + : undefined, + sandboxMode, + workingDirectory: + typeof params.working_directory === 'string' && + params.working_directory.trim().length > 0 + ? params.working_directory.trim() + : undefined, + skipGitRepoCheck: + typeof params.skip_git_repo_check === 'boolean' + ? params.skip_git_repo_check + : undefined, + }; +} + +function buildCodexOutputSchema( + descriptor: OutputSchemaDescriptor, +): Record { + const properties = Object.fromEntries( + descriptor.properties.map((property) => [ + property.name, + buildCodexOutputSchemaField(property.schema), + ]), + ); + + const required = Array.from( + new Set([ + ...descriptor.properties.map((property) => property.name), + ...(descriptor.required ?? []), + ]), + ); + + const schema: Record = { + type: 'object', + additionalProperties: false, + properties, + required, + }; + + if (descriptor.title) { + schema.title = descriptor.title; + } + + if (descriptor.description) { + schema.description = descriptor.description; + } + + return schema; +} + +function buildCodexOutputSchemaField( + field: OutputSchemaField, +): Record { + if (field.type === 'array') { + return { + type: 'array', + items: buildCodexOutputSchemaPrimitive(field.items), + }; + } + + return buildCodexOutputSchemaPrimitive(field); +} + +function buildCodexOutputSchemaPrimitive( + field: z.infer, +): Record { + const result: Record = { + type: field.type, }; + + if (field.enum) { + result.enum = field.enum; + } + + return result; +} + +type JsonObjectSchemaCandidate = { + type: string; + additionalProperties?: unknown; + [key: string]: unknown; +}; + +function isJsonObjectSchema( + value: unknown, +): value is JsonObjectSchemaCandidate { + if (!value || typeof value !== 'object') { + return false; + } + + const record = value as JsonObjectSchemaCandidate; + return record.type === 'object'; } function getThread( @@ -472,98 +828,10 @@ function updateReasoningSpan(span: CustomSpan, item: ReasoningItem) { data.text = item.text; } -function parseArguments(rawInput: string): CodexToolCallArguments { - let parsed: any; - try { - parsed = rawInput ? JSON.parse(rawInput) : {}; - } catch (_error) { - throw new UserError('Codex tool arguments must be valid JSON.'); - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new UserError( - 'Codex tool arguments must be provided as a JSON object.', - ); - } - - const task = typeof parsed.task === 'string' ? parsed.task.trim() : ''; - if (!task) { - throw new UserError('Codex tool requires a non-empty "task" string.'); - } - - const inputs = parseInputs(parsed.inputs); - - return { - task, - inputs, - threadId: - typeof parsed.thread_id === 'string' && parsed.thread_id.length > 0 - ? parsed.thread_id - : typeof parsed.threadId === 'string' && parsed.threadId.length > 0 - ? parsed.threadId - : undefined, - outputSchema: parsed.output_schema ?? parsed.outputSchema, - sandboxMode: isSandboxMode(parsed.sandbox_mode ?? parsed.sandboxMode) - ? (parsed.sandbox_mode ?? parsed.sandboxMode) - : undefined, - workingDirectory: - typeof parsed.working_directory === 'string' && - parsed.working_directory.length > 0 - ? parsed.working_directory - : typeof parsed.workingDirectory === 'string' && - parsed.workingDirectory.length > 0 - ? parsed.workingDirectory - : undefined, - skipGitRepoCheck: - typeof parsed.skip_git_repo_check === 'boolean' - ? parsed.skip_git_repo_check - : typeof parsed.skipGitRepoCheck === 'boolean' - ? parsed.skipGitRepoCheck - : undefined, - }; -} - -function parseInputs(value: unknown): UserInput[] | undefined { - if (typeof value === 'undefined') { - return undefined; - } - if (!Array.isArray(value)) { - throw new UserError( - 'The "inputs" property must be an array when provided.', - ); - } - - const inputs: UserInput[] = value.map((entry) => { - if (!entry || typeof entry !== 'object') { - throw new UserError('Each item in "inputs" must be an object.'); - } - const typed = entry as Record; - if (typed.type === 'text' && typeof typed.text === 'string') { - return { type: 'text', text: typed.text }; - } - if (typed.type === 'local_image' && typeof typed.path === 'string') { - return { type: 'local_image', path: typed.path }; - } - throw new UserError( - 'Inputs must either be { "type": "text", "text": string } or { "type": "local_image", "path": string }.', - ); - }); - - return inputs.length === 0 ? undefined : inputs; -} - function buildDefaultResponse(args: CodexToolCallArguments): string { return `Codex task completed for "${args.task}".`; } -function isSandboxMode(value: unknown): value is SandboxMode { - return ( - value === 'read-only' || - value === 'workspace-write' || - value === 'danger-full-access' - ); -} - function isCommandExecutionItem( item: ThreadItem, ): item is CommandExecutionItem { diff --git a/packages/agents-extensions/test/codexTool.test.ts b/packages/agents-extensions/test/codexTool.test.ts index f955abfc..a2485f0c 100644 --- a/packages/agents-extensions/test/codexTool.test.ts +++ b/packages/agents-extensions/test/codexTool.test.ts @@ -187,6 +187,10 @@ describe('codexTool', () => { ), ); + if (typeof result === 'string') { + throw new Error('Codex tool unexpectedly returned a string result.'); + } + expect(result.threadId).toBe('thread-1'); expect(result.response).toBe('Codex finished.'); expect(result.usage).toEqual({