diff --git a/src/services/api/codexShim.ts b/src/services/api/codexShim.ts index 7e9a07f2..c65abdf0 100644 --- a/src/services/api/codexShim.ts +++ b/src/services/api/codexShim.ts @@ -295,20 +295,97 @@ export function convertAnthropicMessagesToResponsesInput( ) } +/** + * Recursively enforces Codex strict-mode constraints on a JSON schema: + * - Every `object` type gets `additionalProperties: false` + * - All property keys are listed in `required` + * - Nested schemas (properties, items, anyOf/oneOf/allOf) are processed too + */ +function enforceStrictSchema(schema: unknown): Record { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return (schema ?? {}) as Record + } + + const record = { ...(schema as Record) } + + // Codex API strict schemas reject these JSON schema keywords + delete record.$schema + delete record.propertyNames + + if (record.type === 'object') { + // OpenAI structured outputs completely forbid dynamic additionalProperties. + // They must be set to false unconditionally. + record.additionalProperties = false + + if ( + record.properties && + typeof record.properties === 'object' && + !Array.isArray(record.properties) + ) { + const props = record.properties as Record + const allKeys = Object.keys(props) + + const enforcedProps: Record = {} + for (const [key, value] of Object.entries(props)) { + const strictValue = enforceStrictSchema(value) + // If the resulting schema is an empty object (no properties), OpenAI structured outputs will likely + // strip it silently and then complain about a 'required' mismatch if it remains in the required list. + // E.g. z.record() objects (like AskUserQuestion.answers) lose their schema due to additionalProperties + // restrictions. We can safely drop these from the schema sent to the LLM. + if ( + strictValue && + typeof strictValue === 'object' && + strictValue.type === 'object' && + strictValue.additionalProperties === false && + (!strictValue.properties || Object.keys(strictValue.properties).length === 0) + ) { + continue + } + enforcedProps[key] = strictValue + } + record.properties = enforcedProps + record.required = Object.keys(enforcedProps) + } else { + // No properties — empty required array + record.required = [] + } + } + + // Recurse into array items + if ('items' in record) { + if (Array.isArray(record.items)) { + record.items = (record.items as unknown[]).map(item => enforceStrictSchema(item)) + } else { + record.items = enforceStrictSchema(record.items) + } + } + + // Recurse into combinators + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (key in record && Array.isArray(record[key])) { + record[key] = (record[key] as unknown[]).map(item => enforceStrictSchema(item)) + } + } + + return record +} + export function convertToolsToResponsesTools( tools: Array<{ name?: string; description?: string; input_schema?: Record }>, ): ResponsesTool[] { return tools .filter(tool => tool.name && tool.name !== 'ToolSearchTool') .map(tool => { - const parameters = tool.input_schema ?? { type: 'object', properties: {} } + const rawParameters = tool.input_schema ?? { type: 'object', properties: {} } + // Codex requires strict schemas: all properties must be required + const parameters = enforceStrictSchema(rawParameters) return { type: 'function', name: tool.name ?? 'tool', description: tool.description ?? '', parameters, - ...(isStrictResponsesSchema(parameters) ? { strict: true } : {}), + strict: true, } }) } @@ -443,11 +520,18 @@ export async function performCodexRequest(options: { body.reasoning = options.request.reasoning } - if (options.params.temperature !== undefined) { - body.temperature = options.params.temperature - } - if (options.params.top_p !== undefined) { - body.top_p = options.params.top_p + const isTargetModel = + options.request.resolvedModel?.toLowerCase().includes('gpt') || + options.request.resolvedModel?.toLowerCase().includes('codex') + + // Only pass temperature and top_p if it's not a GPT/Codex model that rejects them + if (!isTargetModel) { + if (options.params.temperature !== undefined) { + body.temperature = options.params.temperature + } + if (options.params.top_p !== undefined) { + body.top_p = options.params.top_p + } } const headers: Record = { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 4eb00583..8fed9bdd 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -22,6 +22,7 @@ import { convertCodexResponseToAnthropicMessage, performCodexRequest, type AnthropicStreamEvent, + type AnthropicUsage, type ShimCreateParams, } from './codexShim.js' import { @@ -236,15 +237,27 @@ function convertTools( tools: Array<{ name: string; description?: string; input_schema?: Record }>, ): OpenAITool[] { return tools - .filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI - .map(t => ({ + .filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI + .map(t => { + // Estraiamo lo schema + const schema = (t.input_schema ?? { type: 'object', properties: {} }) as any; + + // PATCH PER CODEX: Se è lo strumento Agent, forziamo i campi obbligatori + if (t.name === 'Agent' && schema.properties) { + if (!schema.required) schema.required = []; + if (!schema.required.includes('message')) schema.required.push('message'); + if (!schema.required.includes('subagent_type')) schema.required.push('subagent_type'); + } + + return { type: 'function' as const, function: { name: t.name, description: t.description ?? '', - parameters: normalizeSchemaForOpenAI(t.input_schema ?? { type: 'object', properties: {} }), + parameters: normalizeSchemaForOpenAI(schema), }, - })) + } + }) } // --------------------------------------------------------------------------- @@ -547,15 +560,15 @@ class OpenAIShimMessages { return self._convertNonStreamingResponse(data, request.resolvedModel) })() - ;(promise as unknown as Record).withResponse = - async () => { - const data = await promise - return { - data, - response: new Response(), - request_id: makeMessageId(), + ; (promise as unknown as Record).withResponse = + async () => { + const data = await promise + return { + data, + response: new Response(), + request_id: makeMessageId(), + } } - } return promise } diff --git a/src/tools/shared/spawnMultiAgent.ts b/src/tools/shared/spawnMultiAgent.ts index dc7c8dd0..e4c52873 100644 --- a/src/tools/shared/spawnMultiAgent.ts +++ b/src/tools/shared/spawnMultiAgent.ts @@ -51,7 +51,9 @@ import { } from '../../utils/swarm/spawnInProcess.js' import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js' import { + getTeamFilePath, readTeamFileAsync, + registerTeamForSessionCleanup, sanitizeAgentName, sanitizeName, writeTeamFileAsync, @@ -293,6 +295,77 @@ export async function generateUniqueTeammateName( return `${baseName}-${suffix}` } +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Ensures a team file exists on disk. If it doesn't (e.g. when a non-Claude + * model skips the TeamCreate step), auto-creates a minimal team file so + * the spawn can proceed. + */ +async function ensureTeamFileExists( + teamName: string, + context: ToolUseContext, +): Promise { + const existing = await readTeamFileAsync(teamName) + if (existing) return existing + + // Auto-create the team + const leadAgentId = formatAgentId(TEAM_LEAD_NAME, teamName) + + const teamFile: import('../../utils/swarm/teamHelpers.js').TeamFile = { + name: teamName, + description: `Auto-created team for ${teamName}`, + createdAt: Date.now(), + leadAgentId, + leadSessionId: getSessionId(), + members: [ + { + agentId: leadAgentId, + name: TEAM_LEAD_NAME, + agentType: TEAM_LEAD_NAME, + joinedAt: Date.now(), + tmuxPaneId: '', + cwd: getCwd(), + subscriptions: [], + }, + ], + } + + await writeTeamFileAsync(teamName, teamFile) + registerTeamForSessionCleanup(teamName) + + // Update AppState so the rest of the session is team-aware + context.setAppState(prev => ({ + ...prev, + teamContext: { + ...prev.teamContext, + teamName, + teamFilePath: getTeamFilePath(teamName), + leadAgentId, + teammates: { + ...(prev.teamContext?.teammates || {}), + [leadAgentId]: { + name: TEAM_LEAD_NAME, + agentType: TEAM_LEAD_NAME, + color: assignTeammateColor(leadAgentId), + tmuxSessionName: '', + tmuxPaneId: '', + cwd: getCwd(), + spawnedAt: Date.now(), + }, + }, + }, + })) + + logForDebugging( + `[spawnMultiAgent] Auto-created team "${teamName}" (team file was missing)`, + ) + + return teamFile +} + // ============================================================================ // Spawn Handlers // ============================================================================ @@ -485,13 +558,8 @@ async function handleSpawnSplitPane( toolUseId: context.toolUseId, }) - // Register agent in the team file - const teamFile = await readTeamFileAsync(teamName) - if (!teamFile) { - throw new Error( - `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, - ) - } + // Register agent in the team file (auto-create if missing) + const teamFile = await ensureTeamFileExists(teamName, context) teamFile.members.push({ agentId: teammateId, name: sanitizedName, @@ -699,13 +767,8 @@ async function handleSpawnSeparateWindow( toolUseId: context.toolUseId, }) - // Register agent in the team file - const teamFile = await readTeamFileAsync(teamName) - if (!teamFile) { - throw new Error( - `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, - ) - } + // Register agent in the team file (auto-create if missing) + const teamFile = await ensureTeamFileExists(teamName, context) teamFile.members.push({ agentId: teammateId, name: sanitizedName, @@ -985,13 +1048,8 @@ async function handleSpawnInProcess( } }) - // Register agent in the team file - const teamFile = await readTeamFileAsync(teamName) - if (!teamFile) { - throw new Error( - `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, - ) - } + // Register agent in the team file (auto-create if missing) + const teamFile = await ensureTeamFileExists(teamName, context) teamFile.members.push({ agentId: teammateId, name: sanitizedName,