From eede6cf7aae2a72abd60b99ccde181aadc6cdde6 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 6 Jun 2025 17:06:53 +0900 Subject: [PATCH 1/9] Fix #23 Add Hosted MCP server tool support --- examples/mcp/hosted-mcp-approvals.ts | 73 ++++++ examples/mcp/hosted-mcp-simple.ts | 55 +++++ examples/mcp/package.json | 4 +- packages/agents-core/src/index.ts | 2 + packages/agents-core/src/runImplementation.ts | 111 ++++++++- packages/agents-core/src/runState.ts | 15 ++ packages/agents-core/src/tool.ts | 56 +++++ packages/agents-core/src/types/index.ts | 1 + .../agents-core/src/types/providerData.ts | 52 ++++ packages/agents-core/test/runState.test.ts | 2 + packages/agents-core/test/tool.test.ts | 17 +- .../agents-openai/src/openaiResponsesModel.ts | 226 +++++++++++++----- packages/agents-openai/src/tools.ts | 38 +++ .../agents-openai/src/utils/providerData.ts | 44 ++++ .../test/utils/providerData.test.ts | 79 ++++++ 15 files changed, 712 insertions(+), 63 deletions(-) create mode 100644 examples/mcp/hosted-mcp-approvals.ts create mode 100644 examples/mcp/hosted-mcp-simple.ts create mode 100644 packages/agents-core/src/types/providerData.ts create mode 100644 packages/agents-openai/src/utils/providerData.ts create mode 100644 packages/agents-openai/test/utils/providerData.test.ts diff --git a/examples/mcp/hosted-mcp-approvals.ts b/examples/mcp/hosted-mcp-approvals.ts new file mode 100644 index 00000000..b3b5ea0d --- /dev/null +++ b/examples/mcp/hosted-mcp-approvals.ts @@ -0,0 +1,73 @@ +import * as readline from 'readline/promises'; +import { stdin, stdout } from 'node:process'; +import { Agent, run, hostedMcpTool, RunToolApprovalItem } from '@openai/agents'; + +async function promptApproval(item: RunToolApprovalItem): Promise { + const rl = readline.createInterface({ input: stdin, output: stdout }); + const name = item.rawItem.name; + const params = JSON.parse(item.rawItem.providerData?.arguments || '{}'); + const answer = await rl.question( + `Approve running tool (mcp: ${name}, params: ${JSON.stringify(params)})? (y/n) `, + ); + rl.close(); + return answer.toLowerCase().trim() === 'y'; +} + +async function main(verbose: boolean, stream: boolean): Promise { + const agent = new Agent({ + name: 'MCP Assistant', + instructions: 'You must always use the MCP tools to answer questions.', + tools: [ + hostedMcpTool({ + serverLabel: 'gitmcp', + serverUrl: 'https://gitmcp.io/openai/codex', + requireApproval: 'always', + onApproval: async (_, data) => { + return { approve: await promptApproval(data) }; + }, + }), + ], + }); + + const input = 'Which language is this repo written in?'; + + if (stream) { + // Streaming + const result = await run(agent, input, { stream: true }); + for await (const event of result) { + if (verbose) { + console.log(JSON.stringify(event, null, 2)); + } else { + if ( + event.type === 'raw_model_stream_event' && + event.data.type === 'model' + ) { + console.log(event.data.event.type); + } + } + } + console.log(`Done streaming; final result: ${result.finalOutput}`); + } else { + // Non-streaming + let result = await run(agent, input); + while (result.interruptions && result.interruptions.length) { + result = await run(agent, result.state); + } + console.log(result.finalOutput); + + if (verbose) { + console.log('----------------------------------------------------------'); + console.log(JSON.stringify(result.newItems, null, 2)); + console.log('----------------------------------------------------------'); + } + } +} + +const args = process.argv.slice(2); +const verbose = args.includes('--verbose'); +const stream = args.includes('--stream'); + +main(verbose, stream).catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/mcp/hosted-mcp-simple.ts b/examples/mcp/hosted-mcp-simple.ts new file mode 100644 index 00000000..439ab546 --- /dev/null +++ b/examples/mcp/hosted-mcp-simple.ts @@ -0,0 +1,55 @@ +import { Agent, run, hostedMcpTool, withTrace } from '@openai/agents'; + +async function main(verbose: boolean, stream: boolean): Promise { + withTrace('Hosted MCP Example', async () => { + const agent = new Agent({ + name: 'MCP Assistant', + instructions: 'You must always use the MCP tools to answer questions.', + tools: [ + hostedMcpTool({ + serverLabel: 'gitmcp', + serverUrl: 'https://gitmcp.io/openai/codex', + requireApproval: 'never', + }), + ], + }); + + const input = + 'Which language is the repo I pointed in the MCP tool settings written in?'; + if (stream) { + const result = await run(agent, input, { stream: true }); + for await (const event of result) { + if ( + event.type === 'raw_model_stream_event' && + event.data.type === 'model' && + event.data.event.type !== 'response.mcp_call_arguments.delta' && + event.data.event.type !== 'response.output_text.delta' + ) { + console.log(`Got event of type ${JSON.stringify(event.data)}`); + } + } + for (const item of result.newItems) { + console.log(JSON.stringify(item, null, 2)); + } + console.log(`Done streaming; final result: ${result.finalOutput}`); + } else { + const res = await run(agent, input); + // The repository is primarily written in multiple languages, including Rust and TypeScript... + if (verbose) { + for (const item of res.output) { + console.log(JSON.stringify(item, null, 2)); + } + } + console.log(res.finalOutput); + } + }); +} + +const args = process.argv.slice(2); +const verbose = args.includes('--verbose'); +const stream = args.includes('--stream'); + +main(verbose, stream).catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/mcp/package.json b/examples/mcp/package.json index 81e30f62..ce092a61 100644 --- a/examples/mcp/package.json +++ b/examples/mcp/package.json @@ -8,6 +8,8 @@ }, "scripts": { "build-check": "tsc --noEmit", - "start:stdio": "tsx filesystem-example.ts" + "start:stdio": "tsx filesystem-example.ts", + "start:hosted-mcp-approvals": "tsx hosted-mcp-approvals.ts", + "start:hosted-mcp-simple": "tsx hosted-mcp-simple.ts" } } diff --git a/packages/agents-core/src/index.ts b/packages/agents-core/src/index.ts index f21f6f03..e2cfc772 100644 --- a/packages/agents-core/src/index.ts +++ b/packages/agents-core/src/index.ts @@ -99,6 +99,8 @@ export { HostedTool, ComputerTool, computerTool, + HostedMCPTool, + hostedMcpTool, FunctionTool, FunctionToolResult, Tool, diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index e79887c2..9ee44938 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -14,7 +14,13 @@ import { } from './items'; import logger, { Logger } from './logger'; import { ModelResponse, ModelSettings } from './model'; -import { ComputerTool, FunctionTool, Tool, FunctionToolResult } from './tool'; +import { + ComputerTool, + FunctionTool, + Tool, + FunctionToolResult, + HostedMCPTool, +} from './tool'; import { AgentInputItem, UnknownContext } from './types'; import { Runner } from './run'; import { RunContext } from './runContext'; @@ -31,6 +37,7 @@ import * as protocol from './types/protocol'; import { Computer } from './computer'; import { RunState } from './runState'; import { isZodObject } from './utils'; +import * as ProviderData from './types/providerData'; type ToolRunHandoff = { toolCall: protocol.FunctionCallItem; @@ -47,11 +54,17 @@ type ToolRunComputer = { computer: ComputerTool; }; +type ToolRunMCPApprovalRequest = { + requestItem: RunToolApprovalItem; + mcpTool: HostedMCPTool; +}; + export type ProcessedResponse = { newItems: RunItem[]; handoffs: ToolRunHandoff[]; functions: ToolRunFunction[]; computerActions: ToolRunComputer[]; + mcpApprovalRequests: ToolRunMCPApprovalRequest[]; toolsUsed: string[]; hasToolsOrApprovalsToRun(): boolean; }; @@ -69,12 +82,19 @@ export function processModelResponse( const runHandoffs: ToolRunHandoff[] = []; const runFunctions: ToolRunFunction[] = []; const runComputerActions: ToolRunComputer[] = []; + const runMCPApprovalRequests: ToolRunMCPApprovalRequest[] = []; const toolsUsed: string[] = []; const handoffMap = new Map(handoffs.map((h) => [h.toolName, h])); const functionMap = new Map( tools.filter((t) => t.type === 'function').map((t) => [t.name, t]), ); const computerTool = tools.find((t) => t.type === 'computer'); + const mcpToolMap = new Map( + tools + .filter((t) => t.type === 'hosted_tool' && t.providerData?.type === 'mcp') + .map((t) => t as HostedMCPTool) + .map((t) => [t.providerData.serverLabel, t]), + ); for (const output of modelResponse.output) { if (output.type === 'message') { @@ -82,8 +102,58 @@ export function processModelResponse( items.push(new RunMessageOutputItem(output, agent)); } } else if (output.type === 'hosted_tool_call') { - items.push(new RunToolCallItem(output, agent)); - toolsUsed.push(output.name); + if ( + output.providerData?.type === 'mcp_approval_request' || + output.name === 'mcp_approval_request' + ) { + // Hosted remote MCP server support + const providerData = + output.providerData as ProviderData.HostedMCPApprovalRequest; + const mcpServerLabel = providerData.serverLabel; + const mcpServerTool = mcpToolMap.get(mcpServerLabel); + if (mcpServerTool !== undefined) { + const toolName = JSON.stringify({ + server: providerData.serverLabel, + name: providerData.name, + }); + // Do this approval later + runMCPApprovalRequests.push({ + requestItem: new RunToolApprovalItem( + { + type: 'function_call', + name: toolName, + callId: providerData.id, + arguments: providerData.arguments || '', + status: 'in_progress', + providerData, + }, + agent, + ), + mcpTool: mcpServerTool, + }); + items.push(new RunToolCallItem(output, agent)); + toolsUsed.push(toolName); + } else { + const message = `MCP server (${mcpServerLabel}) not found in Agent (${agent.name})`; + addErrorToCurrentSpan({ + message, + data: { mcp_server_label: mcpServerLabel }, + }); + throw new ModelBehaviorError(message); + } + } else { + // the rest of the hosted + items.push(new RunToolCallItem(output, agent)); + const toolName = output.providerData?.serverLabel + ? // hosted MCP tool + JSON.stringify({ + server: output.providerData.serverLabel, + name: output.name, + }) + : // other hosted tools + output.name; + toolsUsed.push(toolName); + } } else if (output.type === 'reasoning') { items.push(new RunReasoningItem(output, agent)); } else if (output.type === 'computer_call') { @@ -147,6 +217,7 @@ export function processModelResponse( handoffs: runHandoffs, functions: runFunctions, computerActions: runComputerActions, + mcpApprovalRequests: runMCPApprovalRequests, toolsUsed: toolsUsed, hasToolsOrApprovalsToRun(): boolean { return ( @@ -344,6 +415,40 @@ export async function executeToolsAndSideEffects( newItems = newItems.concat(functionResults.map((r) => r.runItem)); newItems = newItems.concat(computerResults); + // run hosted MCP approval requests + if (processedResponse.mcpApprovalRequests.length > 0) { + for (const approvalRequest of processedResponse.mcpApprovalRequests) { + const toolData = approvalRequest.mcpTool + .providerData as ProviderData.HostedMCPTool; + if (!toolData.onApproval) { + throw new UserError( + `Hosted remote MCP server tool (${toolData.serverLabel}) does not have an onApproval function`, + ); + } + const approvalResult = await toolData.onApproval( + state._context, + approvalRequest.requestItem, + ); + const requestData = approvalRequest.requestItem.rawItem + .providerData as ProviderData.HostedMCPApprovalRequest; + const approvalResponseData: ProviderData.HostedMCPApprovalResponse = { + approve: approvalResult.approve, + approvalRequestId: requestData.id, + reason: approvalResult.reason, + }; + newItems.push( + new RunToolCallItem( + { + type: 'hosted_tool_call', + name: 'mcp_approval_response', + providerData: approvalResponseData, + }, + agent as Agent, + ), + ); + } + } + // process handoffs if (processedResponse.handoffs.length > 0) { return await executeHandoffCalls( diff --git a/packages/agents-core/src/runState.ts b/packages/agents-core/src/runState.ts index 6acb049a..37cd79b1 100644 --- a/packages/agents-core/src/runState.ts +++ b/packages/agents-core/src/runState.ts @@ -152,6 +152,12 @@ const serializedProcessedResponseSchema = z.object({ computer: z.any(), }), ), + mcpApprovalRequests: z.array( + z.object({ + requestItem: z.any(), + mcpTool: z.any(), + }), + ), }); const guardrailFunctionOutputSchema = z.object({ @@ -734,6 +740,15 @@ async function deserializeProcessedResponse( }; }, ), + mcpApprovalRequests: serializedProcessedResponse.mcpApprovalRequests.map( + (approvalRequest) => ({ + requestItem: new RunToolApprovalItem( + approvalRequest.requestItem.rawItem, + currentAgent, + ), + mcpTool: approvalRequest.mcpTool, + }), + ), }; return { diff --git a/packages/agents-core/src/tool.ts b/packages/agents-core/src/tool.ts index 8e7f0015..088d49e6 100644 --- a/packages/agents-core/src/tool.ts +++ b/packages/agents-core/src/tool.ts @@ -16,6 +16,7 @@ import logger from './logger'; import { getCurrentSpan } from './tracing'; import { RunToolApprovalItem, RunToolCallOutputItem } from './items'; import { toSmartString } from './utils/smartString'; +import * as ProviderData from './types/providerData'; /** * A function that determines if a tool call should be approved. @@ -110,6 +111,61 @@ export function computerTool( }; } +export type HostedMCPApprovalFunction = ( + context: RunContext, + data: RunToolApprovalItem, +) => Promise<{ approve: boolean; reason?: string }>; + +/** + * A hosted MCP tool that lets the model call a remote MCP server directly + * without a round trip back to your code. + */ +export type HostedMCPTool = HostedTool & { + name: 'hosted_mcp'; + providerData: ProviderData.HostedMCPTool; +}; + +/** + * Creates a hosted MCP tool definition. + * + * @param serverLabel - The label identifying the MCP server. + * @param serverUrl - The URL of the MCP server. + * @param requireApproval - Whether tool calls require approval. + */ +export function hostedMcpTool( + options: { + serverLabel: string; + serverUrl: string; + } & ( + | { requireApproval?: 'never' } + | { + requireApproval: 'always'; + onApproval: HostedMCPApprovalFunction; + } + ), +): HostedMCPTool { + const providerData: ProviderData.HostedMCPTool = + options.requireApproval === 'always' + ? { + type: 'mcp', + serverLabel: options.serverLabel, + serverUrl: options.serverUrl, + requireApproval: 'always', + onApproval: options.onApproval, + } + : { + type: 'mcp', + serverLabel: options.serverLabel, + serverUrl: options.serverUrl, + requireApproval: 'never', + }; + return { + type: 'hosted_tool', + name: 'hosted_mcp', + providerData, + }; +} + /** * A built-in hosted tool that will be executed directly by the model during the request and won't result in local code executions. * Examples of these are `web_search_call` or `file_search_call`. diff --git a/packages/agents-core/src/types/index.ts b/packages/agents-core/src/types/index.ts index 36b05b5d..42522e13 100644 --- a/packages/agents-core/src/types/index.ts +++ b/packages/agents-core/src/types/index.ts @@ -2,3 +2,4 @@ export * from './protocol'; export * from './helpers'; export * from '../model'; export * from './aliases'; +export * as ProviderData from './providerData'; diff --git a/packages/agents-core/src/types/providerData.ts b/packages/agents-core/src/types/providerData.ts new file mode 100644 index 00000000..97dc88eb --- /dev/null +++ b/packages/agents-core/src/types/providerData.ts @@ -0,0 +1,52 @@ +import { HostedMCPApprovalFunction } from '../tool'; +import { UnknownContext } from './aliases'; + +/** + * OpenAI providerData type definition + */ +export type HostedMCPTool = { + type: 'mcp'; + serverLabel: string; + serverUrl: string; +} & ( + | { requireApproval?: 'never'; onApproval?: never } + | { + requireApproval: 'always'; + onApproval: HostedMCPApprovalFunction; + } +); + +export type HostedMCPListTools = { + id: string; + serverLabel: string; + tools: { + inputSchema: unknown; + name: string; + annotations?: unknown | null; + description?: string | null; + }[]; + error?: string | null; +}; +export type HostedMCPCall = { + id: string; + arguments: string; + name: string; + serverLabel: string; + error?: string | null; + // excluding this large data field + // output?: string | null; +}; + +export type HostedMCPApprovalRequest = { + id: string; + name: string; + arguments: string; + serverLabel: string; +}; + +export type HostedMCPApprovalResponse = { + id?: string; + approve: boolean; + approvalRequestId: string; + reason?: string; +}; diff --git a/packages/agents-core/test/runState.test.ts b/packages/agents-core/test/runState.test.ts index 9984d100..341d9caf 100644 --- a/packages/agents-core/test/runState.test.ts +++ b/packages/agents-core/test/runState.test.ts @@ -252,6 +252,7 @@ describe('deserialize helpers', () => { functions: [], handoffs: [], computerActions: [{ toolCall: call, computer: tool }], + mcpApprovalRequests: [], toolsUsed: [], hasToolsOrApprovalsToRun: () => true, }; @@ -277,6 +278,7 @@ describe('deserialize helpers', () => { functions: [], handoffs: [], computerActions: [{ toolCall: call, computer: tool }], + mcpApprovalRequests: [], toolsUsed: [], hasToolsOrApprovalsToRun: () => true, }; diff --git a/packages/agents-core/test/tool.test.ts b/packages/agents-core/test/tool.test.ts index 4d951fc4..d31501e7 100644 --- a/packages/agents-core/test/tool.test.ts +++ b/packages/agents-core/test/tool.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { computerTool, tool } from '../src/tool'; +import { computerTool, hostedMcpTool, tool } from '../src/tool'; import { z } from 'zod/v3'; import { Computer } from '../src'; import { RunContext } from '../src/runContext'; @@ -35,6 +35,21 @@ describe('Tool', () => { }); }); +describe('create a tool using hostedMcpTool utility', () => { + it('hostedMcpTool', () => { + const t = hostedMcpTool({ + serverLabel: 'gitmcp', + serverUrl: 'https://gitmcp.io/openai/codex', + requireApproval: 'never', + }); + expect(t).toBeDefined(); + expect(t.type).toBe('hosted_tool'); + expect(t.name).toBe('hosted_mcp'); + expect(t.providerData.type).toBe('mcp'); + expect(t.providerData.serverLabel).toBe('gitmcp'); + }); +}); + describe('tool.invoke', () => { it('parses input and returns result', async () => { const t = tool({ diff --git a/packages/agents-openai/src/openaiResponsesModel.ts b/packages/agents-openai/src/openaiResponsesModel.ts index c37a8756..7430de23 100644 --- a/packages/agents-openai/src/openaiResponsesModel.ts +++ b/packages/agents-openai/src/openaiResponsesModel.ts @@ -33,6 +33,8 @@ import { ImageGenerationStatus, WebSearchStatus, } from './tools'; +import { camelToSnakeCase, snakeToCamelCase } from './utils/providerData'; +import { ProviderData } from '@openai/agents-core/types'; type ToolChoice = ToolChoiceOptions | ToolChoiceTypes | ToolChoiceFunction; @@ -40,6 +42,9 @@ const HostedToolChoice = z.enum([ 'file_search', 'web_search_preview', 'computer_use_preview', + 'code_interpreter', + 'image_generation', + 'mcp', ]); const DefaultToolChoice = z.enum(['auto', 'required', 'none']); @@ -142,7 +147,12 @@ function converTool<_TContext = unknown>( return { tool: { type: 'file_search', - vector_store_ids: tool.providerData.vectorStoreId, + vector_store_ids: + tool.providerData.vectorStoreIds || + // for backwards compatibility + (typeof tool.providerData.vectorStoreId === 'string' + ? [tool.providerData.vectorStoreId] + : tool.providerData.vectorStoreId), max_num_results: tool.providerData.maxNumResults, ranking_options: tool.providerData.rankingOptions, filters: tool.providerData.filters, @@ -175,6 +185,16 @@ function converTool<_TContext = unknown>( }, include: undefined, }; + } else if (tool.providerData?.type === 'mcp') { + return { + tool: { + type: 'mcp', + server_label: tool.providerData.serverLabel, + server_url: tool.providerData.serverUrl, + require_approval: tool.providerData.requireApproval, + }, + include: undefined, + }; } else if (tool.providerData) { return { tool: tool.providerData as unknown as OpenAI.Responses.Tool, @@ -203,7 +223,7 @@ function getInputMessageContent( return { type: 'input_text', text: entry.text, - ...entry.providerData, + ...camelToSnakeCase(entry.providerData), }; } else if (entry.type === 'input_image') { const imageEntry: OpenAI.Responses.ResponseInputImage = { @@ -217,7 +237,7 @@ function getInputMessageContent( } return { ...imageEntry, - ...entry.providerData, + ...camelToSnakeCase(entry.providerData), }; } else if (entry.type === 'input_file') { const fileEntry: OpenAI.Responses.ResponseInputFile = { @@ -230,7 +250,7 @@ function getInputMessageContent( } return { ...fileEntry, - ...entry.providerData, + ...camelToSnakeCase(entry.providerData), }; } @@ -247,7 +267,7 @@ function getOutputMessageContent( type: 'output_text', text: entry.text, annotations: [], - ...entry.providerData, + ...camelToSnakeCase(entry.providerData), }; } @@ -255,7 +275,7 @@ function getOutputMessageContent( return { type: 'refusal', refusal: entry.refusal, - ...entry.providerData, + ...camelToSnakeCase(entry.providerData), }; } @@ -275,7 +295,7 @@ function getMessageItem( id: item.id, role: 'system', content: item.content, - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; } @@ -285,7 +305,7 @@ function getMessageItem( id: item.id, role: 'user', content: item.content, - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; } @@ -293,7 +313,7 @@ function getMessageItem( id: item.id, role: 'user', content: item.content.map(getInputMessageContent), - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; } @@ -304,7 +324,7 @@ function getMessageItem( role: 'assistant', content: item.content.map(getOutputMessageContent), status: item.status, - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return assistantMessage; } @@ -349,7 +369,7 @@ function getInputItems( call_id: item.callId, arguments: item.arguments, status: item.status, - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; @@ -367,7 +387,7 @@ function getInputItems( id: item.id, call_id: item.callId, output: item.output.text, - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; @@ -380,9 +400,9 @@ function getInputItems( summary: item.content.map((content) => ({ type: 'summary_text', text: content.text, - ...content.providerData, + ...camelToSnakeCase(content.providerData), })), - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; } @@ -395,7 +415,7 @@ function getInputItems( action: item.action, status: item.status, pending_safety_checks: [], - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; @@ -407,7 +427,7 @@ function getInputItems( id: item.id, call_id: item.callId, output: buildResponseOutput(item), - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; } @@ -418,7 +438,7 @@ function getInputItems( type: 'web_search_call', id: item.id!, status: WebSearchStatus.parse(item.status ?? 'failed'), - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; @@ -430,7 +450,7 @@ function getInputItems( id: item.id!, status: FileSearchStatus.parse(item.status ?? 'failed'), queries: item.providerData?.queries ?? [], - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; @@ -443,7 +463,7 @@ function getInputItems( code: item.providerData?.code ?? '', results: item.providerData?.results ?? [], status: CodeInterpreterStatus.parse(item.status ?? 'failed'), - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; @@ -455,12 +475,71 @@ function getInputItems( id: item.id!, result: item.providerData?.result ?? null, status: ImageGenerationStatus.parse(item.status ?? 'failed'), - ...item.providerData, + ...camelToSnakeCase(item.providerData), }; return entry; } + if ( + item.providerData?.type === 'mcp_list_tools' || + item.name === 'mcp_list_tools' + ) { + const providerData = + item.providerData as ProviderData.HostedMCPListTools; + const entry: OpenAI.Responses.ResponseInputItem.McpListTools = { + type: 'mcp_list_tools', + id: item.id!, + tools: camelToSnakeCase(providerData.tools) as any, + server_label: providerData.serverLabel, + ...camelToSnakeCase(item.providerData), + }; + return entry; + } else if ( + item.providerData?.type === 'mcp_approval_request' || + item.name === 'mcp_approval_request' + ) { + const providerData = + item.providerData as ProviderData.HostedMCPApprovalRequest; + const entry: OpenAI.Responses.ResponseInputItem.McpApprovalRequest = { + type: 'mcp_approval_request', + id: providerData.id ?? item.id!, + name: providerData.name, + arguments: providerData.arguments, + server_label: providerData.serverLabel, + ...camelToSnakeCase(item.providerData), + }; + return entry; + } else if ( + item.providerData?.type === 'mcp_approval_response' || + item.name === 'mcp_approval_response' + ) { + const providerData = + item.providerData as ProviderData.HostedMCPApprovalResponse; + const entry: OpenAI.Responses.ResponseInputItem.McpApprovalResponse = { + type: 'mcp_approval_response', + approve: providerData.approve, + approval_request_id: providerData.approvalRequestId, + reason: providerData.reason, + ...camelToSnakeCase(providerData), + }; + return entry; + } else if ( + item.providerData?.type === 'mcp_call' || + item.name === 'mcp_call' + ) { + const providerData = item.providerData as ProviderData.HostedMCPCall; + const entry: OpenAI.Responses.ResponseInputItem.McpCall = { + type: 'mcp_call', + id: providerData.id ?? item.id!, + name: providerData.name, + arguments: providerData.arguments, + server_label: providerData.serverLabel, + ...camelToSnakeCase(providerData), + }; + return entry; + } + throw new UserError( `Unsupported built-in tool call type: ${JSON.stringify(item)}`, ); @@ -469,7 +548,7 @@ function getInputItems( if (item.type === 'unknown') { return { id: item.id, - ...item.providerData, + ...camelToSnakeCase(item.providerData), } as OpenAI.Responses.ResponseItem; } @@ -495,7 +574,7 @@ function convertToMessageContentItem( const { type, text, ...remainingItem } = item; return { type, - text: text, + text, ...remainingItem, }; } @@ -504,7 +583,7 @@ function convertToMessageContentItem( const { type, refusal, ...remainingItem } = item; return { type, - refusal: refusal, + refusal, ...remainingItem, }; } @@ -517,14 +596,14 @@ function convertToOutputItem( ): protocol.OutputModelItem[] { return items.map((item) => { if (item.type === 'message') { - const { id, type, role, content, status, ...remainingItem } = item; + const { id, type, role, content, status, ...providerData } = item; return { - type, id, + type, role, content: content.map(convertToMessageContentItem), status, - providerData: remainingItem, + providerData: snakeToCamelCase(providerData), }; } else if ( item.type === 'file_search_call' || @@ -532,71 +611,102 @@ function convertToOutputItem( item.type === 'image_generation_call' || item.type === 'code_interpreter_call' ) { - const { id, type, status, ...remainingItem } = item; - const outputData = - 'result' in remainingItem && remainingItem.result !== null - ? remainingItem.result // type: "image_generation_call" - : undefined; + const { status, ...remainingItem } = item; + let outputData = undefined; + if ('result' in remainingItem && remainingItem.result !== null) { + // type: "image_generation_call" + outputData = remainingItem.result; + delete (remainingItem as any).result; + } const output: protocol.HostedToolCallItem = { type: 'hosted_tool_call', - id, - name: type, - status: status, + id: item.id!, + name: item.type, + status, output: outputData, - providerData: remainingItem, + providerData: snakeToCamelCase(remainingItem), }; return output; } else if (item.type === 'function_call') { - const { - id, - call_id, - name, - status, - arguments: args, - ...remainingItem - } = item; + const { call_id, name, status, arguments: args, ...providerData } = item; const output: protocol.FunctionCallItem = { type: 'function_call', - id: id, + id: item.id!, callId: call_id, - name: name, - status: status, + name, + status, arguments: args, - providerData: remainingItem, + providerData: snakeToCamelCase(providerData), }; return output; } else if (item.type === 'computer_call') { - const { id, call_id, status, action, ...remainingItem } = item; + const { call_id, status, action, ...providerData } = item; const output: protocol.ComputerUseCallItem = { type: 'computer_call', - id: id, + id: item.id!, callId: call_id, - status: status, - action: action, - providerData: remainingItem, + status, + action, + providerData: snakeToCamelCase(providerData), + }; + return output; + } else if (item.type === 'mcp_list_tools') { + const { ...providerData } = item; + const output: protocol.HostedToolCallItem = { + type: 'hosted_tool_call', + id: item.id!, + name: item.type, + status: 'completed', + output: undefined, + providerData: snakeToCamelCase(providerData), + }; + return output; + } else if (item.type === 'mcp_approval_request') { + const { ...providerData } = item; + const output: protocol.HostedToolCallItem = { + type: 'hosted_tool_call', + id: item.id!, + name: 'mcp_approval_request', + status: 'completed', + output: undefined, + providerData: snakeToCamelCase(providerData), + }; + return output; + } else if (item.type === 'mcp_call') { + // Avoiding to duplicate potentially large output data + const { output: outputData, ...providerData } = item; + const output: protocol.HostedToolCallItem = { + type: 'hosted_tool_call', + id: item.id!, + name: item.type, + status: 'completed', + output: outputData || undefined, + providerData: snakeToCamelCase(providerData), }; return output; } else if (item.type === 'reasoning') { - const { id, summary, ...remainingItem } = item; + // Avoiding to duplicate potentially large summary data + const { summary, ...providerData } = item; const output: protocol.ReasoningItem = { type: 'reasoning', - id: id, + id: item.id!, content: summary.map((content) => { + // Avoiding to duplicate potentially large text const { text, ...remainingContent } = content; return { type: 'input_text', - text: text, - providerData: remainingContent, + text, + providerData: snakeToCamelCase(remainingContent), }; }), - providerData: remainingItem, + providerData: snakeToCamelCase(providerData), }; return output; } return { type: 'unknown', - providerData: item, + providerData: snakeToCamelCase(item), }; }); } diff --git a/packages/agents-openai/src/tools.ts b/packages/agents-openai/src/tools.ts index 2fdec7ac..8608bda9 100644 --- a/packages/agents-openai/src/tools.ts +++ b/packages/agents-openai/src/tools.ts @@ -188,3 +188,41 @@ export function imageGenerationTool( }, }; } + +/** + * A hosted MCP tool that lets the model call a remote MCP server directly + * without a round trip back to your code. + */ +export type HostedMCPTool = HostedTool & { + name: 'hosted_mcp'; + providerData: { + type: 'mcp'; + serverLabel: string; + serverUrl: string; + requireApproval?: 'never' | 'once' | 'always'; + }; +}; + +/** + * Creates a hosted MCP tool definition. + * + * @param serverLabel - The label identifying the MCP server. + * @param serverUrl - The URL of the MCP server. + * @param requireApproval - Whether tool calls require approval. + */ +export function hostedMcpTool(options: { + serverLabel: string; + serverUrl: string; + requireApproval?: 'never' | 'once' | 'always'; +}): HostedMCPTool { + return { + type: 'hosted_tool', + name: 'hosted_mcp', + providerData: { + type: 'mcp', + serverLabel: options.serverLabel, + serverUrl: options.serverUrl, + requireApproval: options.requireApproval, + }, + }; +} diff --git a/packages/agents-openai/src/utils/providerData.ts b/packages/agents-openai/src/utils/providerData.ts new file mode 100644 index 00000000..2b09ff0a --- /dev/null +++ b/packages/agents-openai/src/utils/providerData.ts @@ -0,0 +1,44 @@ +/** + * Converts camelCase keys of an object to snake_case recursively. + */ +export function camelToSnakeCase | undefined>( + providerData: T | undefined, +): Record | undefined { + if ( + !providerData || + typeof providerData !== 'object' || + Array.isArray(providerData) + ) { + return providerData; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(providerData)) { + const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + result[snakeKey] = camelToSnakeCase(value); + } + return result; +} + +/** + * Converts snake_case keys of an object to camelCase recursively. + * Symmetric with camelToSnakeCase. + */ +export function snakeToCamelCase>( + providerData: T | undefined, +): Record | undefined { + if ( + !providerData || + typeof providerData !== 'object' || + Array.isArray(providerData) + ) { + return providerData; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(providerData)) { + const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); + result[camelKey] = snakeToCamelCase(value); + } + return result; +} diff --git a/packages/agents-openai/test/utils/providerData.test.ts b/packages/agents-openai/test/utils/providerData.test.ts new file mode 100644 index 00000000..6153365e --- /dev/null +++ b/packages/agents-openai/test/utils/providerData.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { + camelToSnakeCase, + snakeToCamelCase, +} from '../../src/utils/providerData'; + +describe('camelToSnakeCase', () => { + it('converts flat camelCase keys to snake_case', () => { + expect(camelToSnakeCase({ fooBar: 1, bazQux: 2 })).toEqual({ + foo_bar: 1, + baz_qux: 2, + }); + }); + + it('handles nested objects', () => { + expect( + camelToSnakeCase({ + outerKey: { innerKey: 42, anotherInner: { deepKey: 'x' } }, + }), + ).toEqual({ + outer_key: { inner_key: 42, another_inner: { deep_key: 'x' } }, + }); + }); + + it('handles arrays and primitives', () => { + expect(camelToSnakeCase([1, 2, 3])).toEqual([1, 2, 3]); + expect(camelToSnakeCase(undefined)).toBe(undefined); + }); + + it('leaves already snake_case keys as is', () => { + expect(camelToSnakeCase({ already_snake: 1, also_snake_case: 2 })).toEqual({ + already_snake: 1, + also_snake_case: 2, + }); + }); + + it('handles mixed keys', () => { + expect(camelToSnakeCase({ fooBar: 1, already_snake: 2 })).toEqual({ + foo_bar: 1, + already_snake: 2, + }); + }); +}); + +describe('snakeToCamelCase', () => { + it('converts flat snake_case keys to camelCase', () => { + expect(snakeToCamelCase({ foo_bar: 1, baz_qux: [2, 3, 4] })).toEqual({ + fooBar: 1, + bazQux: [2, 3, 4], + }); + }); + + it('handles nested objects', () => { + expect( + snakeToCamelCase({ + outer_key: { inner_key: 42, another_inner: { deep_key: 'x' } }, + }), + ).toEqual({ outerKey: { innerKey: 42, anotherInner: { deepKey: 'x' } } }); + }); + + it('handles arrays and primitives', () => { + expect(snakeToCamelCase([1, 2, 3])).toEqual([1, 2, 3]); + expect(snakeToCamelCase(undefined)).toBe(undefined); + }); + + it('leaves already camelCase keys as is', () => { + expect(snakeToCamelCase({ alreadyCamel: 1, alsoCamelCase: 2 })).toEqual({ + alreadyCamel: 1, + alsoCamelCase: 2, + }); + }); + + it('handles mixed keys', () => { + expect(snakeToCamelCase({ foo_bar: 1, alreadyCamel: 2 })).toEqual({ + fooBar: 1, + alreadyCamel: 2, + }); + }); +}); From a4ead0b881d8bd7cc29f081f12014f8ef4a18681 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 6 Jun 2025 17:26:30 +0900 Subject: [PATCH 2/9] Add changeset --- .changeset/wild-stars-teach.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/wild-stars-teach.md diff --git a/.changeset/wild-stars-teach.md b/.changeset/wild-stars-teach.md new file mode 100644 index 00000000..814b239d --- /dev/null +++ b/.changeset/wild-stars-teach.md @@ -0,0 +1,6 @@ +--- +'@openai/agents-openai': patch +'@openai/agents-core': patch +--- + +Add hosted MCP server support From add3dbe4f5c42c6cd241cf25e444cdfa90790abc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 10 Jun 2025 15:11:00 +0900 Subject: [PATCH 3/9] Support both onApproval and HITL --- examples/mcp/hosted-mcp-human-in-the-loop.ts | 86 ++++++++ ...approvals.ts => hosted-mcp-on-approval.ts} | 18 +- examples/mcp/package.json | 3 +- packages/agents-core/src/items.ts | 2 +- packages/agents-core/src/runContext.ts | 14 +- packages/agents-core/src/runImplementation.ts | 184 +++++++++++------- packages/agents-core/src/runState.ts | 2 +- packages/agents-core/src/tool.ts | 34 +++- packages/agents-core/src/types/protocol.ts | 8 + .../agents-core/src/types/providerData.ts | 9 +- .../agents-openai/src/openaiResponsesModel.ts | 21 +- .../agents-realtime/src/realtimeSession.ts | 12 +- 12 files changed, 306 insertions(+), 87 deletions(-) create mode 100644 examples/mcp/hosted-mcp-human-in-the-loop.ts rename examples/mcp/{hosted-mcp-approvals.ts => hosted-mcp-on-approval.ts} (82%) diff --git a/examples/mcp/hosted-mcp-human-in-the-loop.ts b/examples/mcp/hosted-mcp-human-in-the-loop.ts new file mode 100644 index 00000000..a185d116 --- /dev/null +++ b/examples/mcp/hosted-mcp-human-in-the-loop.ts @@ -0,0 +1,86 @@ +import * as readline from 'readline/promises'; +import { stdin, stdout } from 'node:process'; +import { Agent, run, hostedMcpTool, RunToolApprovalItem } from '@openai/agents'; + +async function promptApproval(item: RunToolApprovalItem): Promise { + const rl = readline.createInterface({ input: stdin, output: stdout }); + const name = item.rawItem.name; + const params = JSON.parse(item.rawItem.providerData?.arguments || '{}'); + const answer = await rl.question( + `Approve running tool (mcp: ${name}, params: ${JSON.stringify(params)})? (y/n) `, + ); + rl.close(); + return answer.toLowerCase().trim() === 'y'; +} + +async function main(verbose: boolean, stream: boolean): Promise { + // 'always' | + // 'never' | + // { never?: { toolNames: string[] }; always?: { toolNames: string[] } } + const requireApproval = { + never: { toolNames: ['search_codex_code', 'fetch_codex_documentation'] }, + always: { toolNames: ['fetch_generic_url_content'] }, + }; + const agent = new Agent({ + name: 'MCP Assistant', + instructions: 'You must always use the MCP tools to answer questions.', + tools: [ + hostedMcpTool({ + serverLabel: 'gitmcp', + serverUrl: 'https://gitmcp.io/openai/codex', + requireApproval, + // when you don't pass onApproval, the agent loop will handle the approval process + }), + ], + }); + + const input = 'Which language is this repo written in?'; + + if (stream) { + // Streaming + const result = await run(agent, input, { stream: true, maxTurns: 100 }); + for await (const event of result) { + if (verbose) { + console.log(JSON.stringify(event, null, 2)); + } else { + if ( + event.type === 'raw_model_stream_event' && + event.data.type === 'model' + ) { + console.log(event.data.event.type); + } + } + } + console.log(`Done streaming; final result: ${result.finalOutput}`); + } else { + // Non-streaming + let result = await run(agent, input, { maxTurns: 100 }); + while (result.interruptions && result.interruptions.length) { + for (const interruption of result.interruptions) { + const approval = await promptApproval(interruption); + if (approval) { + result.state.approve(interruption); + } else { + result.state.reject(interruption); + } + } + result = await run(agent, result.state, { maxTurns: 100 }); + } + console.log(result.finalOutput); + + if (verbose) { + console.log('----------------------------------------------------------'); + console.log(JSON.stringify(result.newItems, null, 2)); + console.log('----------------------------------------------------------'); + } + } +} + +const args = process.argv.slice(2); +const verbose = args.includes('--verbose'); +const stream = args.includes('--stream'); + +main(verbose, stream).catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/mcp/hosted-mcp-approvals.ts b/examples/mcp/hosted-mcp-on-approval.ts similarity index 82% rename from examples/mcp/hosted-mcp-approvals.ts rename to examples/mcp/hosted-mcp-on-approval.ts index b3b5ea0d..59085d31 100644 --- a/examples/mcp/hosted-mcp-approvals.ts +++ b/examples/mcp/hosted-mcp-on-approval.ts @@ -14,6 +14,17 @@ async function promptApproval(item: RunToolApprovalItem): Promise { } async function main(verbose: boolean, stream: boolean): Promise { + // 'always' | + // 'never' | + // { never?: { toolNames: string[] }; always?: { toolNames: string[] } } + const requireApproval = { + never: { + toolNames: ['fetch_codex_documentation', 'fetch_generic_url_content'], + }, + always: { + toolNames: ['search_codex_code'], + }, + }; const agent = new Agent({ name: 'MCP Assistant', instructions: 'You must always use the MCP tools to answer questions.', @@ -21,9 +32,10 @@ async function main(verbose: boolean, stream: boolean): Promise { hostedMcpTool({ serverLabel: 'gitmcp', serverUrl: 'https://gitmcp.io/openai/codex', - requireApproval: 'always', - onApproval: async (_, data) => { - return { approve: await promptApproval(data) }; + requireApproval, + onApproval: async (_context, item) => { + const approval = await promptApproval(item); + return { approve: approval, reason: undefined }; }, }), ], diff --git a/examples/mcp/package.json b/examples/mcp/package.json index ce092a61..92a08322 100644 --- a/examples/mcp/package.json +++ b/examples/mcp/package.json @@ -9,7 +9,8 @@ "scripts": { "build-check": "tsc --noEmit", "start:stdio": "tsx filesystem-example.ts", - "start:hosted-mcp-approvals": "tsx hosted-mcp-approvals.ts", + "start:hosted-mcp-on-approval": "tsx hosted-mcp-on-approval.ts", + "start:hosted-mcp-human-in-the-loop": "tsx hosted-mcp-human-in-the-loop.ts", "start:hosted-mcp-simple": "tsx hosted-mcp-simple.ts" } } diff --git a/packages/agents-core/src/items.ts b/packages/agents-core/src/items.ts index eee492bd..4955994a 100644 --- a/packages/agents-core/src/items.ts +++ b/packages/agents-core/src/items.ts @@ -142,7 +142,7 @@ export class RunToolApprovalItem extends RunItemBase { public readonly type = 'tool_approval_item' as const; constructor( - public rawItem: protocol.FunctionCallItem, + public rawItem: protocol.FunctionCallItem | protocol.HostedToolCallItem, public agent: Agent, ) { super(); diff --git a/packages/agents-core/src/runContext.ts b/packages/agents-core/src/runContext.ts index cd579231..8eb03202 100644 --- a/packages/agents-core/src/runContext.ts +++ b/packages/agents-core/src/runContext.ts @@ -117,7 +117,12 @@ export class RunContext { rejected: [], }; if (Array.isArray(approvalEntry.approved)) { - approvalEntry.approved.push(approvalItem.rawItem.callId); + // function tool has call_id, hosted tool call has id + const callId = + 'callId' in approvalItem.rawItem + ? approvalItem.rawItem.callId // function tools + : approvalItem.rawItem.id!; // hosted tools + approvalEntry.approved.push(callId); } this.#approvals.set(toolName, approvalEntry); } @@ -146,7 +151,12 @@ export class RunContext { }; if (Array.isArray(approvalEntry.rejected)) { - approvalEntry.rejected.push(approvalItem.rawItem.callId); + // function tool has call_id, hosted tool call has id + const callId = + 'callId' in approvalItem.rawItem + ? approvalItem.rawItem.callId // function tools + : approvalItem.rawItem.id!; // hosted tools + approvalEntry.rejected.push(callId); } this.#approvals.set(toolName, approvalEntry); } diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index 9ee44938..fcbc103c 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -102,38 +102,21 @@ export function processModelResponse( items.push(new RunMessageOutputItem(output, agent)); } } else if (output.type === 'hosted_tool_call') { + items.push(new RunToolCallItem(output, agent)); + const toolName = output.name; + toolsUsed.push(toolName); + if ( output.providerData?.type === 'mcp_approval_request' || output.name === 'mcp_approval_request' ) { - // Hosted remote MCP server support + // Hosted remote MCP server's approval process const providerData = output.providerData as ProviderData.HostedMCPApprovalRequest; + const mcpServerLabel = providerData.serverLabel; const mcpServerTool = mcpToolMap.get(mcpServerLabel); - if (mcpServerTool !== undefined) { - const toolName = JSON.stringify({ - server: providerData.serverLabel, - name: providerData.name, - }); - // Do this approval later - runMCPApprovalRequests.push({ - requestItem: new RunToolApprovalItem( - { - type: 'function_call', - name: toolName, - callId: providerData.id, - arguments: providerData.arguments || '', - status: 'in_progress', - providerData, - }, - agent, - ), - mcpTool: mcpServerTool, - }); - items.push(new RunToolCallItem(output, agent)); - toolsUsed.push(toolName); - } else { + if (typeof mcpServerTool === 'undefined') { const message = `MCP server (${mcpServerLabel}) not found in Agent (${agent.name})`; addErrorToCurrentSpan({ message, @@ -141,18 +124,29 @@ export function processModelResponse( }); throw new ModelBehaviorError(message); } - } else { - // the rest of the hosted - items.push(new RunToolCallItem(output, agent)); - const toolName = output.providerData?.serverLabel - ? // hosted MCP tool - JSON.stringify({ - server: output.providerData.serverLabel, - name: output.name, - }) - : // other hosted tools - output.name; - toolsUsed.push(toolName); + + // Do this approval later: + // We support both onApproval callback (like the Python SDK does) and HITL patterns. + const approvalItem = new RunToolApprovalItem( + { + type: 'hosted_tool_call', + // We must use this name to align with the name sent from the servers + name: providerData.name, + id: providerData.id, + status: 'in_progress', + providerData, + }, + agent, + ); + runMCPApprovalRequests.push({ + requestItem: approvalItem, + mcpTool: mcpServerTool, + }); + if (!mcpServerTool.providerData.onApproval) { + // When onApproval function exists, it confirms the approval right after this. + // Thus, this approval item must be appended only for the next turn interrpution patterns. + items.push(approvalItem); + } } } else if (output.type === 'reasoning') { items.push(new RunReasoningItem(output, agent)); @@ -223,6 +217,7 @@ export function processModelResponse( return ( runHandoffs.length > 0 || runFunctions.length > 0 || + runMCPApprovalRequests.length > 0 || runComputerActions.length > 0 ); }, @@ -310,17 +305,16 @@ export async function executeInterruptedToolsAndSideEffects( const preStepItems = originalPreStepItems.filter((item) => { return !(item instanceof RunToolApprovalItem); }); - - const approvalRequests = originalPreStepItems + const approvalRequestCallIds = originalPreStepItems .filter((item) => { - return item instanceof RunToolApprovalItem; + return item instanceof RunToolApprovalItem && 'callId' in item.rawItem; }) .map((item) => { - return item.rawItem.callId; + return 'callId' in item.rawItem && item.rawItem.callId!; }); - + // Run function tools that require approval after they get their approval results const functionToolRuns = processedResponse.functions.filter((run) => { - return approvalRequests.includes(run.toolCall.callId); + return approvalRequestCallIds.includes(run.toolCall.callId); }); const functionResults = await executeFunctionToolCalls( @@ -330,7 +324,43 @@ export async function executeInterruptedToolsAndSideEffects( state, ); - const newItems = functionResults.map((r) => r.runItem); + // The output items + const newItems: RunItem[] = functionResults.map((r) => r.runItem); + + // Run MCP tools that require approval after they get their approval results + const mcpApprovalRuns = processedResponse.mcpApprovalRequests.filter( + (run) => { + return ( + run.requestItem.type === 'tool_approval_item' && + run.requestItem.rawItem.type === 'hosted_tool_call' && + run.requestItem.rawItem.providerData?.type === 'mcp_approval_request' + ); + }, + ); + for (const run of mcpApprovalRuns) { + const callId = run.requestItem.rawItem.id!; + const approved = state._context.isToolApproved({ + toolName: run.requestItem.rawItem.name, + callId, + }); + if (typeof approved !== 'undefined') { + const providerData: ProviderData.HostedMCPApprovalResponse = { + approve: approved, + approvalRequestId: callId, + reason: undefined, + }; + newItems.push( + new RunToolCallItem( + { + type: 'hosted_tool_call', + name: 'mcp_approval_response', + providerData, + }, + agent as Agent, + ), + ); + } + } const checkToolOutput = await checkForFinalOutputFromTools( agent, @@ -420,32 +450,50 @@ export async function executeToolsAndSideEffects( for (const approvalRequest of processedResponse.mcpApprovalRequests) { const toolData = approvalRequest.mcpTool .providerData as ProviderData.HostedMCPTool; - if (!toolData.onApproval) { - throw new UserError( - `Hosted remote MCP server tool (${toolData.serverLabel}) does not have an onApproval function`, - ); - } - const approvalResult = await toolData.onApproval( - state._context, - approvalRequest.requestItem, - ); const requestData = approvalRequest.requestItem.rawItem .providerData as ProviderData.HostedMCPApprovalRequest; - const approvalResponseData: ProviderData.HostedMCPApprovalResponse = { - approve: approvalResult.approve, - approvalRequestId: requestData.id, - reason: approvalResult.reason, - }; - newItems.push( - new RunToolCallItem( - { - type: 'hosted_tool_call', - name: 'mcp_approval_response', - providerData: approvalResponseData, - }, - agent as Agent, - ), - ); + if (toolData.onApproval) { + // synchronously handle the approval process here + const approvalResult = await toolData.onApproval( + state._context, + approvalRequest.requestItem, + ); + const approvalResponseData: ProviderData.HostedMCPApprovalResponse = { + approve: approvalResult.approve, + approvalRequestId: requestData.id, + reason: approvalResult.reason, + }; + newItems.push( + new RunToolCallItem( + { + type: 'hosted_tool_call', + name: 'mcp_approval_response', + providerData: approvalResponseData, + }, + agent as Agent, + ), + ); + } else { + // receive a user's approval on the next turn + newItems.push(approvalRequest.requestItem); + const approvalItem = { + type: 'hosted_mcp_tool_approval' as const, + tool: approvalRequest.mcpTool, + runItem: new RunToolApprovalItem( + { + type: 'hosted_tool_call', + name: requestData.name, + id: requestData.id, + arguments: requestData.arguments, + status: 'in_progress', + providerData: requestData, + }, + agent, + ), + }; + functionResults.push(approvalItem); + // newItems.push(approvalItem.runItem); + } } } @@ -626,6 +674,7 @@ export async function executeFunctionToolCalls( }); if (approval === false) { + // rejected return withFunctionSpan( async (span) => { const response = 'Tool execution was not approved.'; @@ -659,6 +708,7 @@ export async function executeFunctionToolCalls( } if (approval !== true) { + // this approval process needs to be done in the next turn return { type: 'function_approval' as const, tool: toolRun.tool, diff --git a/packages/agents-core/src/runState.ts b/packages/agents-core/src/runState.ts index 37cd79b1..65c2d0f1 100644 --- a/packages/agents-core/src/runState.ts +++ b/packages/agents-core/src/runState.ts @@ -118,7 +118,7 @@ const itemSchema = z.discriminatedUnion('type', [ }), z.object({ type: z.literal('tool_approval_item'), - rawItem: protocol.FunctionCallItem, + rawItem: protocol.FunctionCallItem.or(protocol.HostedToolCallItem), agent: serializedAgentSchema, }), ]); diff --git a/packages/agents-core/src/tool.ts b/packages/agents-core/src/tool.ts index 088d49e6..5aea3200 100644 --- a/packages/agents-core/src/tool.ts +++ b/packages/agents-core/src/tool.ts @@ -137,27 +137,33 @@ export function hostedMcpTool( serverLabel: string; serverUrl: string; } & ( - | { requireApproval?: 'never' } + | { requireApproval: never } + | { requireApproval: 'never' } | { - requireApproval: 'always'; - onApproval: HostedMCPApprovalFunction; + requireApproval: + | 'always' + | { + never?: { toolNames: string[] }; + always?: { toolNames: string[] }; + }; + onApproval?: HostedMCPApprovalFunction; } ), ): HostedMCPTool { const providerData: ProviderData.HostedMCPTool = - options.requireApproval === 'always' + options.requireApproval === 'never' ? { type: 'mcp', serverLabel: options.serverLabel, serverUrl: options.serverUrl, - requireApproval: 'always', - onApproval: options.onApproval, + requireApproval: 'never', } : { type: 'mcp', serverLabel: options.serverLabel, serverUrl: options.serverUrl, - requireApproval: 'never', + requireApproval: options.requireApproval, + onApproval: options.onApproval, }; return { type: 'hosted_tool', @@ -233,6 +239,20 @@ export type FunctionToolResult< * The item representing the tool call that is requiring approval. */ runItem: RunToolApprovalItem; + } + | { + /** + * Indiciates that the tool requires approval before it can be called. + */ + type: 'hosted_mcp_tool_approval'; + /** + * The tool that is requiring to be approved. + */ + tool: HostedMCPTool; + /** + * The item representing the tool call that is requiring approval. + */ + runItem: RunToolApprovalItem; }; /** diff --git a/packages/agents-core/src/types/protocol.ts b/packages/agents-core/src/types/protocol.ts index 0dc00d3e..f8ad384c 100644 --- a/packages/agents-core/src/types/protocol.ts +++ b/packages/agents-core/src/types/protocol.ts @@ -316,6 +316,14 @@ export const HostedToolCallItem = ItemBase.extend({ */ name: z.string().describe('The name of the hosted tool'), + /** + * The arguments of the hosted tool call. + */ + arguments: z + .string() + .describe('The arguments of the hosted tool call') + .optional(), + /** * The status of the tool call. */ diff --git a/packages/agents-core/src/types/providerData.ts b/packages/agents-core/src/types/providerData.ts index 97dc88eb..e23510b1 100644 --- a/packages/agents-core/src/types/providerData.ts +++ b/packages/agents-core/src/types/providerData.ts @@ -11,8 +11,13 @@ export type HostedMCPTool = { } & ( | { requireApproval?: 'never'; onApproval?: never } | { - requireApproval: 'always'; - onApproval: HostedMCPApprovalFunction; + requireApproval: + | 'always' + | { + never?: { toolNames: string[] }; + always?: { toolNames: string[] }; + }; + onApproval?: HostedMCPApprovalFunction; } ); diff --git a/packages/agents-openai/src/openaiResponsesModel.ts b/packages/agents-openai/src/openaiResponsesModel.ts index 7430de23..10723652 100644 --- a/packages/agents-openai/src/openaiResponsesModel.ts +++ b/packages/agents-openai/src/openaiResponsesModel.ts @@ -191,7 +191,9 @@ function converTool<_TContext = unknown>( type: 'mcp', server_label: tool.providerData.serverLabel, server_url: tool.providerData.serverUrl, - require_approval: tool.providerData.requireApproval, + require_approval: convertMCPRequireApproval( + tool.providerData.requireApproval, + ), }, include: undefined, }; @@ -206,6 +208,23 @@ function converTool<_TContext = unknown>( throw new Error(`Unsupported tool type: ${JSON.stringify(tool)}`); } +function convertMCPRequireApproval( + requireApproval: ProviderData.HostedMCPTool['requireApproval'], +): OpenAI.Responses.Tool.Mcp.McpToolApprovalFilter | 'always' | 'never' | null { + if (requireApproval === 'never' || requireApproval === undefined) { + return 'never'; + } + + if (requireApproval === 'always') { + return 'always'; + } + + return { + never: { tool_names: requireApproval.never?.toolNames }, + always: { tool_names: requireApproval.always?.toolNames }, + }; +} + function getHandoffTool(handoff: SerializedHandoff): OpenAI.Responses.Tool { return { name: handoff.toolName, diff --git a/packages/agents-realtime/src/realtimeSession.ts b/packages/agents-realtime/src/realtimeSession.ts index b8b637be..a6bef503 100644 --- a/packages/agents-realtime/src/realtimeSession.ts +++ b/packages/agents-realtime/src/realtimeSession.ts @@ -714,7 +714,11 @@ export class RealtimeSession< const tool = this.#currentAgent.tools.find( (tool) => tool.name === approvalItem.rawItem.name, ); - if (tool && tool.type === 'function') { + if ( + tool && + tool.type === 'function' && + approvalItem.rawItem.type === 'function_call' + ) { await this.#handleFunctionToolCall(approvalItem.rawItem, tool); } else { throw new ModelBehaviorError( @@ -739,7 +743,11 @@ export class RealtimeSession< const tool = this.#currentAgent.tools.find( (tool) => tool.name === approvalItem.rawItem.name, ); - if (tool && tool.type === 'function') { + if ( + tool && + tool.type === 'function' && + approvalItem.rawItem.type === 'function_call' + ) { await this.#handleFunctionToolCall(approvalItem.rawItem, tool); } else { throw new ModelBehaviorError( From b50f076cea4fb3c1f57b37783538e5f388fd6f90 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 10 Jun 2025 16:01:04 +0900 Subject: [PATCH 4/9] Fix --- packages/agents-core/src/runState.ts | 49 +++++++++++++------ packages/agents-core/test/run.test.ts | 3 +- .../test/openaiResponsesModel.helpers.test.ts | 6 +-- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/agents-core/src/runState.ts b/packages/agents-core/src/runState.ts index 65c2d0f1..980601e3 100644 --- a/packages/agents-core/src/runState.ts +++ b/packages/agents-core/src/runState.ts @@ -31,6 +31,7 @@ import * as protocol from './types/protocol'; import { AgentInputItem, UnknownContext } from './types'; import type { InputGuardrailResult, OutputGuardrailResult } from './guardrail'; import { safeExecute } from './utils/safeExecute'; +import { HostedMCPTool } from './tool'; /** * The schema version of the serialized run state. This is used to ensure that the serialized @@ -152,12 +153,28 @@ const serializedProcessedResponseSchema = z.object({ computer: z.any(), }), ), - mcpApprovalRequests: z.array( - z.object({ - requestItem: z.any(), - mcpTool: z.any(), - }), - ), + mcpApprovalRequests: z + .array( + z.object({ + requestItem: z.object({ + // protocol.HostedToolCallItem + rawItem: z.object({ + type: z.literal('hosted_tool_call'), + name: z.string(), + arguments: z.string().optional(), + status: z.string().optional(), + output: z.string().optional(), + }), + }), + // HostedMCPTool + mcpTool: z.object({ + type: z.literal('hosted_tool'), + name: z.literal('hosted_mcp'), + providerData: z.record(z.string(), z.any()), + }), + }), + ) + .optional(), }); const guardrailFunctionOutputSchema = z.object({ @@ -740,15 +757,16 @@ async function deserializeProcessedResponse( }; }, ), - mcpApprovalRequests: serializedProcessedResponse.mcpApprovalRequests.map( - (approvalRequest) => ({ - requestItem: new RunToolApprovalItem( - approvalRequest.requestItem.rawItem, - currentAgent, - ), - mcpTool: approvalRequest.mcpTool, - }), - ), + mcpApprovalRequests: ( + serializedProcessedResponse.mcpApprovalRequests ?? [] + ).map((approvalRequest) => ({ + requestItem: new RunToolApprovalItem( + approvalRequest.requestItem + .rawItem as unknown as protocol.HostedToolCallItem, + currentAgent, + ), + mcpTool: approvalRequest.mcpTool as unknown as HostedMCPTool, + })), }; return { @@ -757,6 +775,7 @@ async function deserializeProcessedResponse( return ( result.handoffs.length > 0 || result.functions.length > 0 || + result.mcpApprovalRequests.length > 0 || result.computerActions.length > 0 ); }, diff --git a/packages/agents-core/test/run.test.ts b/packages/agents-core/test/run.test.ts index 1ef76f19..7aef1368 100644 --- a/packages/agents-core/test/run.test.ts +++ b/packages/agents-core/test/run.test.ts @@ -88,7 +88,7 @@ describe('Runner.run', () => { const rawItem = { name: 'toolZ', - call_id: 'c1', + callId: 'c1', type: 'function_call', arguments: '{}', } as any; @@ -126,6 +126,7 @@ describe('Runner.run', () => { }, ], handoffs: [], + mcpApprovalRequests: [], computerActions: [], } as any; diff --git a/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts b/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts index 68f60c68..1f5e3aa5 100644 --- a/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts +++ b/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts @@ -130,9 +130,9 @@ describe('converTool', () => { type: 'hosted_tool', providerData: { type: 'mcp', - server_label: 'deepwiki', - server_url: 'https://mcp.deepwiki.com/mcp', - require_approval: 'never', + serverLabel: 'deepwiki', + serverUrl: 'https://mcp.deepwiki.com/mcp', + requireApproval: 'never', }, } as any); From 22d9b43dc57536bb345a5b92db9d7a3c19237c23 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 10 Jun 2025 16:07:16 +0900 Subject: [PATCH 5/9] Remove unused code --- packages/agents-openai/src/tools.ts | 38 +---------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/packages/agents-openai/src/tools.ts b/packages/agents-openai/src/tools.ts index 8608bda9..4504027a 100644 --- a/packages/agents-openai/src/tools.ts +++ b/packages/agents-openai/src/tools.ts @@ -189,40 +189,4 @@ export function imageGenerationTool( }; } -/** - * A hosted MCP tool that lets the model call a remote MCP server directly - * without a round trip back to your code. - */ -export type HostedMCPTool = HostedTool & { - name: 'hosted_mcp'; - providerData: { - type: 'mcp'; - serverLabel: string; - serverUrl: string; - requireApproval?: 'never' | 'once' | 'always'; - }; -}; - -/** - * Creates a hosted MCP tool definition. - * - * @param serverLabel - The label identifying the MCP server. - * @param serverUrl - The URL of the MCP server. - * @param requireApproval - Whether tool calls require approval. - */ -export function hostedMcpTool(options: { - serverLabel: string; - serverUrl: string; - requireApproval?: 'never' | 'once' | 'always'; -}): HostedMCPTool { - return { - type: 'hosted_tool', - name: 'hosted_mcp', - providerData: { - type: 'mcp', - serverLabel: options.serverLabel, - serverUrl: options.serverUrl, - requireApproval: options.requireApproval, - }, - }; -} +// HostedMCPTool exists in agents-core package From 6452aaf3e8ffa9979f01415273854774fb4dd97a Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Tue, 10 Jun 2025 16:24:58 +0900 Subject: [PATCH 6/9] Refactor --- packages/agents-core/src/runImplementation.ts | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index fcbc103c..058b3b4d 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -302,19 +302,18 @@ export async function executeInterruptedToolsAndSideEffects( runner: Runner, state: RunState>, ): Promise { - const preStepItems = originalPreStepItems.filter((item) => { - return !(item instanceof RunToolApprovalItem); - }); - const approvalRequestCallIds = originalPreStepItems - .filter((item) => { - return item instanceof RunToolApprovalItem && 'callId' in item.rawItem; - }) - .map((item) => { - return 'callId' in item.rawItem && item.rawItem.callId!; - }); + // call_ids for function tools + const functionCallIds = originalPreStepItems + .filter( + (item) => + item instanceof RunToolApprovalItem && + 'callId' in item.rawItem && + item.rawItem.type === 'function_call', + ) + .map((item) => (item.rawItem as protocol.FunctionCallItem).callId); // Run function tools that require approval after they get their approval results const functionToolRuns = processedResponse.functions.filter((run) => { - return approvalRequestCallIds.includes(run.toolCall.callId); + return functionCallIds.includes(run.toolCall.callId); }); const functionResults = await executeFunctionToolCalls( @@ -324,7 +323,7 @@ export async function executeInterruptedToolsAndSideEffects( state, ); - // The output items + // Create the initial set of the output items const newItems: RunItem[] = functionResults.map((r) => r.runItem); // Run MCP tools that require approval after they get their approval results @@ -338,17 +337,20 @@ export async function executeInterruptedToolsAndSideEffects( }, ); for (const run of mcpApprovalRuns) { - const callId = run.requestItem.rawItem.id!; + // the approval_request_id "mcpr_123..." + const approvalRequestId = run.requestItem.rawItem.id!; const approved = state._context.isToolApproved({ + // Since this item name must be the same with the one sent from Responses API server toolName: run.requestItem.rawItem.name, - callId, + callId: approvalRequestId, }); if (typeof approved !== 'undefined') { const providerData: ProviderData.HostedMCPApprovalResponse = { approve: approved, - approvalRequestId: callId, + approvalRequestId, reason: undefined, }; + // Tell Responses API server the approval result in the next turn newItems.push( new RunToolCallItem( { @@ -368,6 +370,12 @@ export async function executeInterruptedToolsAndSideEffects( state, ); + // Exclude the tool approval items, which should not be sent to Responses API, + // from the SingleStepResult's preStepItems + const preStepItems = originalPreStepItems.filter((item) => { + return !(item instanceof RunToolApprovalItem); + }); + if (checkToolOutput.isFinalOutput) { runner.emit( 'agent_end', From 2318ddbb3882fb3412ad248e56f4b64b2d163f77 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 11 Jun 2025 06:38:41 +0900 Subject: [PATCH 7/9] Add more unit tests --- .../test/utils/providerData.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/agents-openai/test/utils/providerData.test.ts b/packages/agents-openai/test/utils/providerData.test.ts index 6153365e..f2bbdd04 100644 --- a/packages/agents-openai/test/utils/providerData.test.ts +++ b/packages/agents-openai/test/utils/providerData.test.ts @@ -11,6 +11,22 @@ describe('camelToSnakeCase', () => { baz_qux: 2, }); }); + it('converts snake_case keys to snake_case', () => { + expect( + camelToSnakeCase({ foo_bar_buz: 1, baz_qux: 2, foo_bar: 3 }), + ).toEqual({ + foo_bar_buz: 1, + baz_qux: 2, + foo_bar: 3, + }); + }); + it('converts mixed keys to snake_case', () => { + expect(camelToSnakeCase({ foo_barBuz: 1, bazQux: 2, foo_bar: 3 })).toEqual({ + foo_bar_buz: 1, + baz_qux: 2, + foo_bar: 3, + }); + }); it('handles nested objects', () => { expect( @@ -22,6 +38,16 @@ describe('camelToSnakeCase', () => { }); }); + it('handles nested objects with mixed keys', () => { + expect( + camelToSnakeCase({ + outerKey: { innerKey: 42, anotherInner: { deep_key: 'x' } }, + }), + ).toEqual({ + outer_key: { inner_key: 42, another_inner: { deep_key: 'x' } }, + }); + }); + it('handles arrays and primitives', () => { expect(camelToSnakeCase([1, 2, 3])).toEqual([1, 2, 3]); expect(camelToSnakeCase(undefined)).toBe(undefined); From d3521dadfbcedc342d8c0adc7a3c6cd0f3026b2c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 11 Jun 2025 06:57:12 +0900 Subject: [PATCH 8/9] Add all currently available properties to the openai conversion --- .../agents-openai/src/openaiResponsesModel.ts | 66 +++++++++++-------- .../agents-openai/src/utils/providerData.ts | 10 +-- .../test/utils/providerData.test.ts | 24 ++++--- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/packages/agents-openai/src/openaiResponsesModel.ts b/packages/agents-openai/src/openaiResponsesModel.ts index 10723652..1f011dff 100644 --- a/packages/agents-openai/src/openaiResponsesModel.ts +++ b/packages/agents-openai/src/openaiResponsesModel.ts @@ -33,7 +33,10 @@ import { ImageGenerationStatus, WebSearchStatus, } from './tools'; -import { camelToSnakeCase, snakeToCamelCase } from './utils/providerData'; +import { + camelOrSnakeToSnakeCase, + snakeToCamelCase, +} from './utils/providerData'; import { ProviderData } from '@openai/agents-core/types'; type ToolChoice = ToolChoiceOptions | ToolChoiceTypes | ToolChoiceFunction; @@ -242,7 +245,7 @@ function getInputMessageContent( return { type: 'input_text', text: entry.text, - ...camelToSnakeCase(entry.providerData), + ...camelOrSnakeToSnakeCase(entry.providerData), }; } else if (entry.type === 'input_image') { const imageEntry: OpenAI.Responses.ResponseInputImage = { @@ -256,7 +259,7 @@ function getInputMessageContent( } return { ...imageEntry, - ...camelToSnakeCase(entry.providerData), + ...camelOrSnakeToSnakeCase(entry.providerData), }; } else if (entry.type === 'input_file') { const fileEntry: OpenAI.Responses.ResponseInputFile = { @@ -269,7 +272,7 @@ function getInputMessageContent( } return { ...fileEntry, - ...camelToSnakeCase(entry.providerData), + ...camelOrSnakeToSnakeCase(entry.providerData), }; } @@ -286,7 +289,7 @@ function getOutputMessageContent( type: 'output_text', text: entry.text, annotations: [], - ...camelToSnakeCase(entry.providerData), + ...camelOrSnakeToSnakeCase(entry.providerData), }; } @@ -294,7 +297,7 @@ function getOutputMessageContent( return { type: 'refusal', refusal: entry.refusal, - ...camelToSnakeCase(entry.providerData), + ...camelOrSnakeToSnakeCase(entry.providerData), }; } @@ -314,7 +317,7 @@ function getMessageItem( id: item.id, role: 'system', content: item.content, - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; } @@ -324,7 +327,7 @@ function getMessageItem( id: item.id, role: 'user', content: item.content, - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; } @@ -332,7 +335,7 @@ function getMessageItem( id: item.id, role: 'user', content: item.content.map(getInputMessageContent), - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; } @@ -343,7 +346,7 @@ function getMessageItem( role: 'assistant', content: item.content.map(getOutputMessageContent), status: item.status, - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; return assistantMessage; } @@ -388,7 +391,7 @@ function getInputItems( call_id: item.callId, arguments: item.arguments, status: item.status, - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -406,7 +409,8 @@ function getInputItems( id: item.id, call_id: item.callId, output: item.output.text, - ...camelToSnakeCase(item.providerData), + status: item.status, + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -419,9 +423,10 @@ function getInputItems( summary: item.content.map((content) => ({ type: 'summary_text', text: content.text, - ...camelToSnakeCase(content.providerData), + ...camelOrSnakeToSnakeCase(content.providerData), })), - ...camelToSnakeCase(item.providerData), + encrypted_content: item.providerData?.encryptedContent, + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; } @@ -434,7 +439,7 @@ function getInputItems( action: item.action, status: item.status, pending_safety_checks: [], - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -446,7 +451,9 @@ function getInputItems( id: item.id, call_id: item.callId, output: buildResponseOutput(item), - ...camelToSnakeCase(item.providerData), + status: item.providerData?.status, + acknowledged_safety_checks: item.providerData?.acknowledgedSafetyChecks, + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; } @@ -457,7 +464,7 @@ function getInputItems( type: 'web_search_call', id: item.id!, status: WebSearchStatus.parse(item.status ?? 'failed'), - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -469,7 +476,8 @@ function getInputItems( id: item.id!, status: FileSearchStatus.parse(item.status ?? 'failed'), queries: item.providerData?.queries ?? [], - ...camelToSnakeCase(item.providerData), + results: item.providerData?.results, + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -482,7 +490,8 @@ function getInputItems( code: item.providerData?.code ?? '', results: item.providerData?.results ?? [], status: CodeInterpreterStatus.parse(item.status ?? 'failed'), - ...camelToSnakeCase(item.providerData), + container_id: item.providerData?.containerId, + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -494,7 +503,7 @@ function getInputItems( id: item.id!, result: item.providerData?.result ?? null, status: ImageGenerationStatus.parse(item.status ?? 'failed'), - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -509,9 +518,10 @@ function getInputItems( const entry: OpenAI.Responses.ResponseInputItem.McpListTools = { type: 'mcp_list_tools', id: item.id!, - tools: camelToSnakeCase(providerData.tools) as any, + tools: camelOrSnakeToSnakeCase(providerData.tools) as any, server_label: providerData.serverLabel, - ...camelToSnakeCase(item.providerData), + error: providerData.error, + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; } else if ( @@ -526,7 +536,7 @@ function getInputItems( name: providerData.name, arguments: providerData.arguments, server_label: providerData.serverLabel, - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; } else if ( @@ -537,10 +547,11 @@ function getInputItems( item.providerData as ProviderData.HostedMCPApprovalResponse; const entry: OpenAI.Responses.ResponseInputItem.McpApprovalResponse = { type: 'mcp_approval_response', + id: providerData.id, approve: providerData.approve, approval_request_id: providerData.approvalRequestId, reason: providerData.reason, - ...camelToSnakeCase(providerData), + ...camelOrSnakeToSnakeCase(providerData), }; return entry; } else if ( @@ -554,7 +565,10 @@ function getInputItems( name: providerData.name, arguments: providerData.arguments, server_label: providerData.serverLabel, - ...camelToSnakeCase(providerData), + error: providerData.error, + // output, which can be a large text string, is optional here, so we don't include it + // output: item.output, + ...camelOrSnakeToSnakeCase(providerData), }; return entry; } @@ -567,7 +581,7 @@ function getInputItems( if (item.type === 'unknown') { return { id: item.id, - ...camelToSnakeCase(item.providerData), + ...camelOrSnakeToSnakeCase(item.providerData), } as OpenAI.Responses.ResponseItem; } diff --git a/packages/agents-openai/src/utils/providerData.ts b/packages/agents-openai/src/utils/providerData.ts index 2b09ff0a..a518f900 100644 --- a/packages/agents-openai/src/utils/providerData.ts +++ b/packages/agents-openai/src/utils/providerData.ts @@ -1,9 +1,9 @@ /** - * Converts camelCase keys of an object to snake_case recursively. + * Converts camelCase or snake_case keys of an object to snake_case recursively. */ -export function camelToSnakeCase | undefined>( - providerData: T | undefined, -): Record | undefined { +export function camelOrSnakeToSnakeCase< + T extends Record | undefined, +>(providerData: T | undefined): Record | undefined { if ( !providerData || typeof providerData !== 'object' || @@ -15,7 +15,7 @@ export function camelToSnakeCase | undefined>( const result: Record = {}; for (const [key, value] of Object.entries(providerData)) { const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); - result[snakeKey] = camelToSnakeCase(value); + result[snakeKey] = camelOrSnakeToSnakeCase(value); } return result; } diff --git a/packages/agents-openai/test/utils/providerData.test.ts b/packages/agents-openai/test/utils/providerData.test.ts index f2bbdd04..51f1a7c2 100644 --- a/packages/agents-openai/test/utils/providerData.test.ts +++ b/packages/agents-openai/test/utils/providerData.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect } from 'vitest'; import { - camelToSnakeCase, + camelOrSnakeToSnakeCase, snakeToCamelCase, } from '../../src/utils/providerData'; describe('camelToSnakeCase', () => { it('converts flat camelCase keys to snake_case', () => { - expect(camelToSnakeCase({ fooBar: 1, bazQux: 2 })).toEqual({ + expect(camelOrSnakeToSnakeCase({ fooBar: 1, bazQux: 2 })).toEqual({ foo_bar: 1, baz_qux: 2, }); }); it('converts snake_case keys to snake_case', () => { expect( - camelToSnakeCase({ foo_bar_buz: 1, baz_qux: 2, foo_bar: 3 }), + camelOrSnakeToSnakeCase({ foo_bar_buz: 1, baz_qux: 2, foo_bar: 3 }), ).toEqual({ foo_bar_buz: 1, baz_qux: 2, @@ -21,7 +21,9 @@ describe('camelToSnakeCase', () => { }); }); it('converts mixed keys to snake_case', () => { - expect(camelToSnakeCase({ foo_barBuz: 1, bazQux: 2, foo_bar: 3 })).toEqual({ + expect( + camelOrSnakeToSnakeCase({ foo_barBuz: 1, bazQux: 2, foo_bar: 3 }), + ).toEqual({ foo_bar_buz: 1, baz_qux: 2, foo_bar: 3, @@ -30,7 +32,7 @@ describe('camelToSnakeCase', () => { it('handles nested objects', () => { expect( - camelToSnakeCase({ + camelOrSnakeToSnakeCase({ outerKey: { innerKey: 42, anotherInner: { deepKey: 'x' } }, }), ).toEqual({ @@ -40,7 +42,7 @@ describe('camelToSnakeCase', () => { it('handles nested objects with mixed keys', () => { expect( - camelToSnakeCase({ + camelOrSnakeToSnakeCase({ outerKey: { innerKey: 42, anotherInner: { deep_key: 'x' } }, }), ).toEqual({ @@ -49,19 +51,21 @@ describe('camelToSnakeCase', () => { }); it('handles arrays and primitives', () => { - expect(camelToSnakeCase([1, 2, 3])).toEqual([1, 2, 3]); - expect(camelToSnakeCase(undefined)).toBe(undefined); + expect(camelOrSnakeToSnakeCase([1, 2, 3])).toEqual([1, 2, 3]); + expect(camelOrSnakeToSnakeCase(undefined)).toBe(undefined); }); it('leaves already snake_case keys as is', () => { - expect(camelToSnakeCase({ already_snake: 1, also_snake_case: 2 })).toEqual({ + expect( + camelOrSnakeToSnakeCase({ already_snake: 1, also_snake_case: 2 }), + ).toEqual({ already_snake: 1, also_snake_case: 2, }); }); it('handles mixed keys', () => { - expect(camelToSnakeCase({ fooBar: 1, already_snake: 2 })).toEqual({ + expect(camelOrSnakeToSnakeCase({ fooBar: 1, already_snake: 2 })).toEqual({ foo_bar: 1, already_snake: 2, }); From 9779f1c5cb386caaf11f9b9effbebcd67592dc1c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 11 Jun 2025 13:00:47 +0900 Subject: [PATCH 9/9] update --- packages/agents-core/src/runImplementation.ts | 14 ++-- packages/agents-core/src/tool.ts | 34 +++++++-- .../agents-core/src/types/providerData.ts | 24 +++---- packages/agents-core/test/tool.test.ts | 2 +- .../agents-openai/src/openaiResponsesModel.ts | 71 +++++++++---------- packages/agents-openai/src/tools.ts | 71 ++++++++++--------- .../agents-openai/src/types/providerData.ts | 40 +++++++++++ .../agents-openai/src/utils/providerData.ts | 23 ------ .../test/openaiResponsesModel.helpers.test.ts | 16 ++--- .../test/utils/providerData.test.ts | 41 +---------- 10 files changed, 168 insertions(+), 168 deletions(-) create mode 100644 packages/agents-openai/src/types/providerData.ts diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index 058b3b4d..6036a022 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -93,7 +93,7 @@ export function processModelResponse( tools .filter((t) => t.type === 'hosted_tool' && t.providerData?.type === 'mcp') .map((t) => t as HostedMCPTool) - .map((t) => [t.providerData.serverLabel, t]), + .map((t) => [t.providerData.server_label, t]), ); for (const output of modelResponse.output) { @@ -114,7 +114,7 @@ export function processModelResponse( const providerData = output.providerData as ProviderData.HostedMCPApprovalRequest; - const mcpServerLabel = providerData.serverLabel; + const mcpServerLabel = providerData.server_label; const mcpServerTool = mcpToolMap.get(mcpServerLabel); if (typeof mcpServerTool === 'undefined') { const message = `MCP server (${mcpServerLabel}) not found in Agent (${agent.name})`; @@ -142,7 +142,7 @@ export function processModelResponse( requestItem: approvalItem, mcpTool: mcpServerTool, }); - if (!mcpServerTool.providerData.onApproval) { + if (!mcpServerTool.providerData.on_approval) { // When onApproval function exists, it confirms the approval right after this. // Thus, this approval item must be appended only for the next turn interrpution patterns. items.push(approvalItem); @@ -347,7 +347,7 @@ export async function executeInterruptedToolsAndSideEffects( if (typeof approved !== 'undefined') { const providerData: ProviderData.HostedMCPApprovalResponse = { approve: approved, - approvalRequestId, + approval_request_id: approvalRequestId, reason: undefined, }; // Tell Responses API server the approval result in the next turn @@ -460,15 +460,15 @@ export async function executeToolsAndSideEffects( .providerData as ProviderData.HostedMCPTool; const requestData = approvalRequest.requestItem.rawItem .providerData as ProviderData.HostedMCPApprovalRequest; - if (toolData.onApproval) { + if (toolData.on_approval) { // synchronously handle the approval process here - const approvalResult = await toolData.onApproval( + const approvalResult = await toolData.on_approval( state._context, approvalRequest.requestItem, ); const approvalResponseData: ProviderData.HostedMCPApprovalResponse = { approve: approvalResult.approve, - approvalRequestId: requestData.id, + approval_request_id: requestData.id, reason: approvalResult.reason, }; newItems.push( diff --git a/packages/agents-core/src/tool.ts b/packages/agents-core/src/tool.ts index 5aea3200..d815e5cb 100644 --- a/packages/agents-core/src/tool.ts +++ b/packages/agents-core/src/tool.ts @@ -154,16 +154,19 @@ export function hostedMcpTool( options.requireApproval === 'never' ? { type: 'mcp', - serverLabel: options.serverLabel, - serverUrl: options.serverUrl, - requireApproval: 'never', + server_label: options.serverLabel, + server_url: options.serverUrl, + require_approval: 'never', } : { type: 'mcp', - serverLabel: options.serverLabel, - serverUrl: options.serverUrl, - requireApproval: options.requireApproval, - onApproval: options.onApproval, + server_label: options.serverLabel, + server_url: options.serverUrl, + require_approval: + typeof options.requireApproval === 'string' + ? options.requireApproval + : buildRequireApproval(options.requireApproval), + on_approval: options.onApproval, }; return { type: 'hosted_tool', @@ -575,3 +578,20 @@ export function tool< needsApproval, }; } + +function buildRequireApproval(requireApproval: { + never?: { toolNames: string[] }; + always?: { toolNames: string[] }; +}): { never?: { tool_names: string[] }; always?: { tool_names: string[] } } { + const result: { + never?: { tool_names: string[] }; + always?: { tool_names: string[] }; + } = {}; + if (requireApproval.always) { + result.always = { tool_names: requireApproval.always.toolNames }; + } + if (requireApproval.never) { + result.never = { tool_names: requireApproval.never.toolNames }; + } + return result; +} diff --git a/packages/agents-core/src/types/providerData.ts b/packages/agents-core/src/types/providerData.ts index e23510b1..04d97c92 100644 --- a/packages/agents-core/src/types/providerData.ts +++ b/packages/agents-core/src/types/providerData.ts @@ -6,26 +6,26 @@ import { UnknownContext } from './aliases'; */ export type HostedMCPTool = { type: 'mcp'; - serverLabel: string; - serverUrl: string; + server_label: string; + server_url: string; } & ( - | { requireApproval?: 'never'; onApproval?: never } + | { require_approval?: 'never'; on_approval?: never } | { - requireApproval: + require_approval: | 'always' | { - never?: { toolNames: string[] }; - always?: { toolNames: string[] }; + never?: { tool_names: string[] }; + always?: { tool_names: string[] }; }; - onApproval?: HostedMCPApprovalFunction; + on_approval?: HostedMCPApprovalFunction; } ); export type HostedMCPListTools = { id: string; - serverLabel: string; + server_label: string; tools: { - inputSchema: unknown; + input_schema: unknown; name: string; annotations?: unknown | null; description?: string | null; @@ -36,7 +36,7 @@ export type HostedMCPCall = { id: string; arguments: string; name: string; - serverLabel: string; + server_label: string; error?: string | null; // excluding this large data field // output?: string | null; @@ -46,12 +46,12 @@ export type HostedMCPApprovalRequest = { id: string; name: string; arguments: string; - serverLabel: string; + server_label: string; }; export type HostedMCPApprovalResponse = { id?: string; approve: boolean; - approvalRequestId: string; + approval_request_id: string; reason?: string; }; diff --git a/packages/agents-core/test/tool.test.ts b/packages/agents-core/test/tool.test.ts index d31501e7..27da8855 100644 --- a/packages/agents-core/test/tool.test.ts +++ b/packages/agents-core/test/tool.test.ts @@ -46,7 +46,7 @@ describe('create a tool using hostedMcpTool utility', () => { expect(t.type).toBe('hosted_tool'); expect(t.name).toBe('hosted_mcp'); expect(t.providerData.type).toBe('mcp'); - expect(t.providerData.serverLabel).toBe('gitmcp'); + expect(t.providerData.server_label).toBe('gitmcp'); }); }); diff --git a/packages/agents-openai/src/openaiResponsesModel.ts b/packages/agents-openai/src/openaiResponsesModel.ts index 1f011dff..c544ad10 100644 --- a/packages/agents-openai/src/openaiResponsesModel.ts +++ b/packages/agents-openai/src/openaiResponsesModel.ts @@ -33,10 +33,7 @@ import { ImageGenerationStatus, WebSearchStatus, } from './tools'; -import { - camelOrSnakeToSnakeCase, - snakeToCamelCase, -} from './utils/providerData'; +import { camelOrSnakeToSnakeCase } from './utils/providerData'; import { ProviderData } from '@openai/agents-core/types'; type ToolChoice = ToolChoiceOptions | ToolChoiceTypes | ToolChoiceFunction; @@ -141,8 +138,8 @@ function converTool<_TContext = unknown>( return { tool: { type: 'web_search_preview', - user_location: tool.providerData.userLocation, - search_context_size: tool.providerData.searchContextSize, + user_location: tool.providerData.user_location, + search_context_size: tool.providerData.search_context_size, }, include: undefined, }; @@ -151,16 +148,16 @@ function converTool<_TContext = unknown>( tool: { type: 'file_search', vector_store_ids: - tool.providerData.vectorStoreIds || + tool.providerData.vector_store_ids || // for backwards compatibility - (typeof tool.providerData.vectorStoreId === 'string' - ? [tool.providerData.vectorStoreId] - : tool.providerData.vectorStoreId), - max_num_results: tool.providerData.maxNumResults, - ranking_options: tool.providerData.rankingOptions, + (typeof tool.providerData.vector_store_id === 'string' + ? [tool.providerData.vector_store_id] + : tool.providerData.vector_store_id), + max_num_results: tool.providerData.max_num_results, + ranking_options: tool.providerData.ranking_options, filters: tool.providerData.filters, }, - include: tool.providerData.includeSearchResults + include: tool.providerData.include_search_results ? ['file_search_call.results'] : undefined, }; @@ -177,12 +174,12 @@ function converTool<_TContext = unknown>( tool: { type: 'image_generation', background: tool.providerData.background, - input_image_mask: tool.providerData.inputImageMask, + input_image_mask: tool.providerData.input_image_mask, model: tool.providerData.model, moderation: tool.providerData.moderation, - output_compression: tool.providerData.outputCompression, - output_format: tool.providerData.outputFormat, - partial_images: tool.providerData.partialImages, + output_compression: tool.providerData.output_compression, + output_format: tool.providerData.output_format, + partial_images: tool.providerData.partial_images, quality: tool.providerData.quality, size: tool.providerData.size, }, @@ -192,10 +189,10 @@ function converTool<_TContext = unknown>( return { tool: { type: 'mcp', - server_label: tool.providerData.serverLabel, - server_url: tool.providerData.serverUrl, + server_label: tool.providerData.server_label, + server_url: tool.providerData.server_url, require_approval: convertMCPRequireApproval( - tool.providerData.requireApproval, + tool.providerData.require_approval, ), }, include: undefined, @@ -212,7 +209,7 @@ function converTool<_TContext = unknown>( } function convertMCPRequireApproval( - requireApproval: ProviderData.HostedMCPTool['requireApproval'], + requireApproval: ProviderData.HostedMCPTool['require_approval'], ): OpenAI.Responses.Tool.Mcp.McpToolApprovalFilter | 'always' | 'never' | null { if (requireApproval === 'never' || requireApproval === undefined) { return 'never'; @@ -223,8 +220,8 @@ function convertMCPRequireApproval( } return { - never: { tool_names: requireApproval.never?.toolNames }, - always: { tool_names: requireApproval.always?.toolNames }, + never: { tool_names: requireApproval.never?.tool_names }, + always: { tool_names: requireApproval.always?.tool_names }, }; } @@ -519,7 +516,7 @@ function getInputItems( type: 'mcp_list_tools', id: item.id!, tools: camelOrSnakeToSnakeCase(providerData.tools) as any, - server_label: providerData.serverLabel, + server_label: providerData.server_label, error: providerData.error, ...camelOrSnakeToSnakeCase(item.providerData), }; @@ -535,7 +532,7 @@ function getInputItems( id: providerData.id ?? item.id!, name: providerData.name, arguments: providerData.arguments, - server_label: providerData.serverLabel, + server_label: providerData.server_label, ...camelOrSnakeToSnakeCase(item.providerData), }; return entry; @@ -549,7 +546,7 @@ function getInputItems( type: 'mcp_approval_response', id: providerData.id, approve: providerData.approve, - approval_request_id: providerData.approvalRequestId, + approval_request_id: providerData.approval_request_id, reason: providerData.reason, ...camelOrSnakeToSnakeCase(providerData), }; @@ -564,7 +561,7 @@ function getInputItems( id: providerData.id ?? item.id!, name: providerData.name, arguments: providerData.arguments, - server_label: providerData.serverLabel, + server_label: providerData.server_label, error: providerData.error, // output, which can be a large text string, is optional here, so we don't include it // output: item.output, @@ -636,7 +633,7 @@ function convertToOutputItem( role, content: content.map(convertToMessageContentItem), status, - providerData: snakeToCamelCase(providerData), + providerData, }; } else if ( item.type === 'file_search_call' || @@ -657,7 +654,7 @@ function convertToOutputItem( name: item.type, status, output: outputData, - providerData: snakeToCamelCase(remainingItem), + providerData: remainingItem, }; return output; } else if (item.type === 'function_call') { @@ -669,7 +666,7 @@ function convertToOutputItem( name, status, arguments: args, - providerData: snakeToCamelCase(providerData), + providerData, }; return output; } else if (item.type === 'computer_call') { @@ -680,7 +677,7 @@ function convertToOutputItem( callId: call_id, status, action, - providerData: snakeToCamelCase(providerData), + providerData, }; return output; } else if (item.type === 'mcp_list_tools') { @@ -691,7 +688,7 @@ function convertToOutputItem( name: item.type, status: 'completed', output: undefined, - providerData: snakeToCamelCase(providerData), + providerData, }; return output; } else if (item.type === 'mcp_approval_request') { @@ -702,7 +699,7 @@ function convertToOutputItem( name: 'mcp_approval_request', status: 'completed', output: undefined, - providerData: snakeToCamelCase(providerData), + providerData, }; return output; } else if (item.type === 'mcp_call') { @@ -714,7 +711,7 @@ function convertToOutputItem( name: item.type, status: 'completed', output: outputData || undefined, - providerData: snakeToCamelCase(providerData), + providerData, }; return output; } else if (item.type === 'reasoning') { @@ -729,17 +726,17 @@ function convertToOutputItem( return { type: 'input_text', text, - providerData: snakeToCamelCase(remainingContent), + providerData: remainingContent, }; }), - providerData: snakeToCamelCase(providerData), + providerData, }; return output; } return { type: 'unknown', - providerData: snakeToCamelCase(item), + providerData: item, }; }); } diff --git a/packages/agents-openai/src/tools.ts b/packages/agents-openai/src/tools.ts index 4504027a..19476808 100644 --- a/packages/agents-openai/src/tools.ts +++ b/packages/agents-openai/src/tools.ts @@ -1,6 +1,7 @@ import { HostedTool } from '@openai/agents-core'; import type OpenAI from 'openai'; import { z } from '@openai/zod/v3'; +import * as ProviderData from './types/providerData'; // ----------------------------------------------------- // Status enums @@ -50,15 +51,16 @@ export type WebSearchTool = { export function webSearchTool( options: Partial> = {}, ): HostedTool { + const providerData: ProviderData.WebSearchTool = { + type: 'web_search', + name: options.name ?? 'web_search_preview', + user_location: options.userLocation, + search_context_size: options.searchContextSize ?? 'medium', + }; return { type: 'hosted_tool', name: options.name ?? 'web_search_preview', - providerData: { - type: 'web_search', - name: options.name ?? 'web_search_preview', - userLocation: options.userLocation, - searchContextSize: options.searchContextSize ?? 'medium', - }, + providerData, }; } @@ -103,18 +105,19 @@ export function fileSearchTool( const vectorIds = Array.isArray(vectorStoreIds) ? vectorStoreIds : [vectorStoreIds]; + const providerData: ProviderData.FileSearchTool = { + type: 'file_search', + name: options.name ?? 'file_search', + vector_store_ids: vectorIds, + max_num_results: options.maxNumResults, + include_search_results: options.includeSearchResults, + ranking_options: options.rankingOptions, + filters: options.filters, + }; return { type: 'hosted_tool', name: options.name ?? 'file_search', - providerData: { - type: 'file_search', - name: options.name ?? 'file_search', - vectorStoreId: vectorIds, - maxNumResults: options.maxNumResults, - includeSearchResults: options.includeSearchResults, - rankingOptions: options.rankingOptions, - filters: options.filters, - }, + providerData, }; } @@ -134,14 +137,15 @@ export type CodeInterpreterTool = { export function codeInterpreterTool( options: Partial> = {}, ): HostedTool { + const providerData: ProviderData.CodeInterpreterTool = { + type: 'code_interpreter', + name: options.name ?? 'code_interpreter', + container: options.container ?? { type: 'auto' }, + }; return { type: 'hosted_tool', name: options.name ?? 'code_interpreter', - providerData: { - type: 'code_interpreter', - name: options.name ?? 'code_interpreter', - container: options.container, - }, + providerData, }; } @@ -170,22 +174,23 @@ export type ImageGenerationTool = { export function imageGenerationTool( options: Partial> = {}, ): HostedTool { + const providerData: ProviderData.ImageGenerationTool = { + type: 'image_generation', + name: options.name ?? 'image_generation', + background: options.background, + input_image_mask: options.inputImageMask, + model: options.model, + moderation: options.moderation, + output_compression: options.outputCompression, + output_format: options.outputFormat, + partial_images: options.partialImages, + quality: options.quality, + size: options.size, + }; return { type: 'hosted_tool', name: options.name ?? 'image_generation', - providerData: { - type: 'image_generation', - name: options.name ?? 'image_generation', - background: options.background, - inputImageMask: options.inputImageMask, - model: options.model, - moderation: options.moderation, - outputCompression: options.outputCompression, - outputFormat: options.outputFormat, - partialImages: options.partialImages, - quality: options.quality, - size: options.size, - }, + providerData, }; } diff --git a/packages/agents-openai/src/types/providerData.ts b/packages/agents-openai/src/types/providerData.ts new file mode 100644 index 00000000..73f8f8ff --- /dev/null +++ b/packages/agents-openai/src/types/providerData.ts @@ -0,0 +1,40 @@ +import OpenAI from 'openai'; + +export type WebSearchTool = Omit & { + type: 'web_search'; + name: 'web_search_preview' | string; +}; + +export type FileSearchTool = Omit & { + type: 'file_search'; + name: 'file_search' | string; + include_search_results?: boolean; +}; + +export type CodeInterpreterTool = Omit< + OpenAI.Responses.Tool.CodeInterpreter, + 'type' +> & { + type: 'code_interpreter'; + name: 'code_interpreter' | string; +}; + +export type ImageGenerationTool = Omit< + OpenAI.Responses.Tool.ImageGeneration, + | 'type' + | 'background' + | 'model' + | 'moderation' + | 'output_format' + | 'quality' + | 'size' +> & { + type: 'image_generation'; + name: 'image_generation' | string; + background?: 'transparent' | 'opaque' | 'auto' | string; + model?: 'gpt-image-1' | string; + moderation?: 'auto' | 'low' | string; + output_format?: 'png' | 'webp' | 'jpeg' | string; + quality?: 'low' | 'medium' | 'high' | 'auto' | string; + size?: '1024x1024' | '1024x1536' | '1536x1024' | 'auto' | string; +}; diff --git a/packages/agents-openai/src/utils/providerData.ts b/packages/agents-openai/src/utils/providerData.ts index a518f900..6db93a2a 100644 --- a/packages/agents-openai/src/utils/providerData.ts +++ b/packages/agents-openai/src/utils/providerData.ts @@ -19,26 +19,3 @@ export function camelOrSnakeToSnakeCase< } return result; } - -/** - * Converts snake_case keys of an object to camelCase recursively. - * Symmetric with camelToSnakeCase. - */ -export function snakeToCamelCase>( - providerData: T | undefined, -): Record | undefined { - if ( - !providerData || - typeof providerData !== 'object' || - Array.isArray(providerData) - ) { - return providerData; - } - - const result: Record = {}; - for (const [key, value] of Object.entries(providerData)) { - const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); - result[camelKey] = snakeToCamelCase(value); - } - return result; -} diff --git a/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts b/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts index 1f5e3aa5..96a326d3 100644 --- a/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts +++ b/packages/agents-openai/test/openaiResponsesModel.helpers.test.ts @@ -72,8 +72,8 @@ describe('converTool', () => { type: 'hosted_tool', providerData: { type: 'web_search', - userLocation: {}, - searchContextSize: 'low', + user_location: {}, + search_context_size: 'low', }, } as any); expect(web.tool).toEqual({ @@ -86,9 +86,9 @@ describe('converTool', () => { type: 'hosted_tool', providerData: { type: 'file_search', - vectorStoreId: ['v'], - maxNumResults: 5, - includeSearchResults: true, + vector_store_ids: ['v'], + max_num_results: 5, + include_search_results: true, }, } as any); expect(file.tool).toEqual({ @@ -130,9 +130,9 @@ describe('converTool', () => { type: 'hosted_tool', providerData: { type: 'mcp', - serverLabel: 'deepwiki', - serverUrl: 'https://mcp.deepwiki.com/mcp', - requireApproval: 'never', + server_label: 'deepwiki', + server_url: 'https://mcp.deepwiki.com/mcp', + require_approval: 'never', }, } as any); diff --git a/packages/agents-openai/test/utils/providerData.test.ts b/packages/agents-openai/test/utils/providerData.test.ts index 51f1a7c2..3abf2ee0 100644 --- a/packages/agents-openai/test/utils/providerData.test.ts +++ b/packages/agents-openai/test/utils/providerData.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - camelOrSnakeToSnakeCase, - snakeToCamelCase, -} from '../../src/utils/providerData'; +import { camelOrSnakeToSnakeCase } from '../../src/utils/providerData'; describe('camelToSnakeCase', () => { it('converts flat camelCase keys to snake_case', () => { @@ -71,39 +68,3 @@ describe('camelToSnakeCase', () => { }); }); }); - -describe('snakeToCamelCase', () => { - it('converts flat snake_case keys to camelCase', () => { - expect(snakeToCamelCase({ foo_bar: 1, baz_qux: [2, 3, 4] })).toEqual({ - fooBar: 1, - bazQux: [2, 3, 4], - }); - }); - - it('handles nested objects', () => { - expect( - snakeToCamelCase({ - outer_key: { inner_key: 42, another_inner: { deep_key: 'x' } }, - }), - ).toEqual({ outerKey: { innerKey: 42, anotherInner: { deepKey: 'x' } } }); - }); - - it('handles arrays and primitives', () => { - expect(snakeToCamelCase([1, 2, 3])).toEqual([1, 2, 3]); - expect(snakeToCamelCase(undefined)).toBe(undefined); - }); - - it('leaves already camelCase keys as is', () => { - expect(snakeToCamelCase({ alreadyCamel: 1, alsoCamelCase: 2 })).toEqual({ - alreadyCamel: 1, - alsoCamelCase: 2, - }); - }); - - it('handles mixed keys', () => { - expect(snakeToCamelCase({ foo_bar: 1, alreadyCamel: 2 })).toEqual({ - fooBar: 1, - alreadyCamel: 2, - }); - }); -});