fix(codex): Support Multi-Agent framework schemas for OpenAI/Codex endpoints
This commit addresses strict schema validation limitations when running subagents under OpenAI backend shims. - Drops empty properties from payloads (like Record<string, string>) that break OpenAI's Structured Outputs validation. - Handles edge cases for automated initial teams when subagents bypass standard creation routines. - Aborts sending unsupported experimental backend parameters like temperature and top_p for GPT-5 derivatives.
This commit is contained in:
@@ -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<string, unknown> {
|
||||||
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||||
|
return (schema ?? {}) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = { ...(schema as Record<string, unknown>) }
|
||||||
|
|
||||||
|
// 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<string, unknown>
|
||||||
|
const allKeys = Object.keys(props)
|
||||||
|
|
||||||
|
const enforcedProps: Record<string, unknown> = {}
|
||||||
|
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(
|
export function convertToolsToResponsesTools(
|
||||||
tools: Array<{ name?: string; description?: string; input_schema?: Record<string, unknown> }>,
|
tools: Array<{ name?: string; description?: string; input_schema?: Record<string, unknown> }>,
|
||||||
): ResponsesTool[] {
|
): ResponsesTool[] {
|
||||||
return tools
|
return tools
|
||||||
.filter(tool => tool.name && tool.name !== 'ToolSearchTool')
|
.filter(tool => tool.name && tool.name !== 'ToolSearchTool')
|
||||||
.map(tool => {
|
.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 {
|
return {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
name: tool.name ?? 'tool',
|
name: tool.name ?? 'tool',
|
||||||
description: tool.description ?? '',
|
description: tool.description ?? '',
|
||||||
parameters,
|
parameters,
|
||||||
...(isStrictResponsesSchema(parameters) ? { strict: true } : {}),
|
strict: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -443,12 +520,19 @@ export async function performCodexRequest(options: {
|
|||||||
body.reasoning = options.request.reasoning
|
body.reasoning = options.request.reasoning
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (options.params.temperature !== undefined) {
|
||||||
body.temperature = options.params.temperature
|
body.temperature = options.params.temperature
|
||||||
}
|
}
|
||||||
if (options.params.top_p !== undefined) {
|
if (options.params.top_p !== undefined) {
|
||||||
body.top_p = options.params.top_p
|
body.top_p = options.params.top_p
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
convertCodexResponseToAnthropicMessage,
|
convertCodexResponseToAnthropicMessage,
|
||||||
performCodexRequest,
|
performCodexRequest,
|
||||||
type AnthropicStreamEvent,
|
type AnthropicStreamEvent,
|
||||||
|
type AnthropicUsage,
|
||||||
type ShimCreateParams,
|
type ShimCreateParams,
|
||||||
} from './codexShim.js'
|
} from './codexShim.js'
|
||||||
import {
|
import {
|
||||||
@@ -237,14 +238,26 @@ function convertTools(
|
|||||||
): OpenAITool[] {
|
): OpenAITool[] {
|
||||||
return tools
|
return tools
|
||||||
.filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI
|
.filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI
|
||||||
.map(t => ({
|
.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,
|
type: 'function' as const,
|
||||||
function: {
|
function: {
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description ?? '',
|
description: t.description ?? '',
|
||||||
parameters: normalizeSchemaForOpenAI(t.input_schema ?? { type: 'object', properties: {} }),
|
parameters: normalizeSchemaForOpenAI(schema),
|
||||||
},
|
},
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -547,7 +560,7 @@ class OpenAIShimMessages {
|
|||||||
return self._convertNonStreamingResponse(data, request.resolvedModel)
|
return self._convertNonStreamingResponse(data, request.resolvedModel)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
;(promise as unknown as Record<string, unknown>).withResponse =
|
; (promise as unknown as Record<string, unknown>).withResponse =
|
||||||
async () => {
|
async () => {
|
||||||
const data = await promise
|
const data = await promise
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ import {
|
|||||||
} from '../../utils/swarm/spawnInProcess.js'
|
} from '../../utils/swarm/spawnInProcess.js'
|
||||||
import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js'
|
import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js'
|
||||||
import {
|
import {
|
||||||
|
getTeamFilePath,
|
||||||
readTeamFileAsync,
|
readTeamFileAsync,
|
||||||
|
registerTeamForSessionCleanup,
|
||||||
sanitizeAgentName,
|
sanitizeAgentName,
|
||||||
sanitizeName,
|
sanitizeName,
|
||||||
writeTeamFileAsync,
|
writeTeamFileAsync,
|
||||||
@@ -293,6 +295,77 @@ export async function generateUniqueTeammateName(
|
|||||||
return `${baseName}-${suffix}`
|
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<import('../../utils/swarm/teamHelpers.js').TeamFile> {
|
||||||
|
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
|
// Spawn Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -485,13 +558,8 @@ async function handleSpawnSplitPane(
|
|||||||
toolUseId: context.toolUseId,
|
toolUseId: context.toolUseId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register agent in the team file
|
// Register agent in the team file (auto-create if missing)
|
||||||
const teamFile = await readTeamFileAsync(teamName)
|
const teamFile = await ensureTeamFileExists(teamName, context)
|
||||||
if (!teamFile) {
|
|
||||||
throw new Error(
|
|
||||||
`Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
teamFile.members.push({
|
teamFile.members.push({
|
||||||
agentId: teammateId,
|
agentId: teammateId,
|
||||||
name: sanitizedName,
|
name: sanitizedName,
|
||||||
@@ -699,13 +767,8 @@ async function handleSpawnSeparateWindow(
|
|||||||
toolUseId: context.toolUseId,
|
toolUseId: context.toolUseId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register agent in the team file
|
// Register agent in the team file (auto-create if missing)
|
||||||
const teamFile = await readTeamFileAsync(teamName)
|
const teamFile = await ensureTeamFileExists(teamName, context)
|
||||||
if (!teamFile) {
|
|
||||||
throw new Error(
|
|
||||||
`Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
teamFile.members.push({
|
teamFile.members.push({
|
||||||
agentId: teammateId,
|
agentId: teammateId,
|
||||||
name: sanitizedName,
|
name: sanitizedName,
|
||||||
@@ -985,13 +1048,8 @@ async function handleSpawnInProcess(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register agent in the team file
|
// Register agent in the team file (auto-create if missing)
|
||||||
const teamFile = await readTeamFileAsync(teamName)
|
const teamFile = await ensureTeamFileExists(teamName, context)
|
||||||
if (!teamFile) {
|
|
||||||
throw new Error(
|
|
||||||
`Team "${teamName}" does not exist. Call spawnTeam first to create the team.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
teamFile.members.push({
|
teamFile.members.push({
|
||||||
agentId: teammateId,
|
agentId: teammateId,
|
||||||
name: sanitizedName,
|
name: sanitizedName,
|
||||||
|
|||||||
Reference in New Issue
Block a user