Merge pull request #138 from erdemozyol/fix/codex-websearch-and-agent-fallback
fix: support Codex web tools and non-git agents
This commit is contained in:
@@ -144,6 +144,42 @@ describe('Codex request translation', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('removes unsupported uri format from strict Responses schemas', () => {
|
||||||
|
const tools = convertToolsToResponsesTools([
|
||||||
|
{
|
||||||
|
name: 'WebFetch',
|
||||||
|
description: 'Fetch a URL',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', format: 'uri' },
|
||||||
|
prompt: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['url', 'prompt'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(tools).toEqual([
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'WebFetch',
|
||||||
|
description: 'Fetch a URL',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string' },
|
||||||
|
prompt: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['url', 'prompt'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('converts assistant tool use and user tool result into Responses items', () => {
|
test('converts assistant tool use and user tool result into Responses items', () => {
|
||||||
const items = convertAnthropicMessagesToResponsesInput([
|
const items = convertAnthropicMessagesToResponsesInput([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -311,6 +311,11 @@ function enforceStrictSchema(schema: unknown): Record<string, unknown> {
|
|||||||
// Codex API strict schemas reject these JSON schema keywords
|
// Codex API strict schemas reject these JSON schema keywords
|
||||||
delete record.$schema
|
delete record.$schema
|
||||||
delete record.propertyNames
|
delete record.propertyNames
|
||||||
|
// Codex Responses rejects JSON Schema's standard `uri` string format.
|
||||||
|
// Keep URL validation in the tool layer and send a plain string here.
|
||||||
|
if (record.format === 'uri') {
|
||||||
|
delete record.format
|
||||||
|
}
|
||||||
|
|
||||||
if (record.type === 'object') {
|
if (record.type === 'object') {
|
||||||
// OpenAI structured outputs completely forbid dynamic additionalProperties.
|
// OpenAI structured outputs completely forbid dynamic additionalProperties.
|
||||||
|
|||||||
@@ -589,7 +589,19 @@ export const AgentTool = buildTool({
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
if (effectiveIsolation === 'worktree') {
|
if (effectiveIsolation === 'worktree') {
|
||||||
const slug = `agent-${earlyAgentId.slice(0, 8)}`;
|
const slug = `agent-${earlyAgentId.slice(0, 8)}`;
|
||||||
worktreeInfo = await createAgentWorktree(slug);
|
try {
|
||||||
|
worktreeInfo = await createAgentWorktree(slug);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('Cannot create agent worktree: not in a git repository')) {
|
||||||
|
if (isolation === 'worktree') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logForDebugging('Agent worktree isolation unavailable outside a git repository; falling back to the current working directory.');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fork + worktree: inject a notice telling the child to translate paths
|
// Fork + worktree: inject a notice telling the child to translate paths
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js
|
|||||||
import { z } from 'zod/v4'
|
import { z } from 'zod/v4'
|
||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||||
import { queryModelWithStreaming } from '../../services/api/claude.js'
|
import { queryModelWithStreaming } from '../../services/api/claude.js'
|
||||||
|
import { collectCodexCompletedResponse } from '../../services/api/codexShim.js'
|
||||||
|
import {
|
||||||
|
resolveCodexApiCredentials,
|
||||||
|
resolveProviderRequest,
|
||||||
|
} from '../../services/api/providerConfig.js'
|
||||||
import { buildTool, type ToolDef } from '../../Tool.js'
|
import { buildTool, type ToolDef } from '../../Tool.js'
|
||||||
import { lazySchema } from '../../utils/lazySchema.js'
|
import { lazySchema } from '../../utils/lazySchema.js'
|
||||||
import { logError } from '../../utils/log.js'
|
import { logError } from '../../utils/log.js'
|
||||||
@@ -83,6 +88,213 @@ function makeToolSchema(input: Input): BetaWebSearchTool20250305 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCodexResponsesWebSearchEnabled(): boolean {
|
||||||
|
if (getAPIProvider() !== 'openai') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = resolveProviderRequest({
|
||||||
|
model: getMainLoopModel(),
|
||||||
|
baseUrl: process.env.OPENAI_BASE_URL,
|
||||||
|
})
|
||||||
|
return request.transport === 'codex_responses'
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCodexWebSearchTool(input: Input): Record<string, unknown> {
|
||||||
|
const tool: Record<string, unknown> = {
|
||||||
|
type: 'web_search',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.allowed_domains?.length) {
|
||||||
|
tool.filters = {
|
||||||
|
allowed_domains: input.allowed_domains,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
if (timezone) {
|
||||||
|
tool.user_location = {
|
||||||
|
type: 'approximate',
|
||||||
|
timezone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexWebSearchInputText(input: Input): string {
|
||||||
|
if (!input.blocked_domains?.length) {
|
||||||
|
return input.query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responses web_search supports allowed_domains filters but not blocked domains.
|
||||||
|
// Convert blocked domains into common search-engine exclusion operators so the
|
||||||
|
// constraint still affects ranking and candidate selection.
|
||||||
|
const excludedSites = input.blocked_domains.map(domain => `-site:${domain}`)
|
||||||
|
return `${input.query} ${excludedSites.join(' ')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexWebSearchInput(input: Input): Array<Record<string, unknown>> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'input_text',
|
||||||
|
text: buildCodexWebSearchInputText(input),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexWebSearchInstructions(): string {
|
||||||
|
return [
|
||||||
|
'You are the OpenClaude web search tool.',
|
||||||
|
'Search the web for the user query and return a concise factual answer.',
|
||||||
|
'Include source URLs in the response.',
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOutputFromCodexWebSearchResponse(
|
||||||
|
response: Record<string, unknown>,
|
||||||
|
query: string,
|
||||||
|
durationSeconds: number,
|
||||||
|
): Output {
|
||||||
|
const results: (SearchResult | string)[] = []
|
||||||
|
const sourceMap = new Map<string, { title: string; url: string }>()
|
||||||
|
const output = Array.isArray(response.output) ? response.output : []
|
||||||
|
|
||||||
|
for (const item of output) {
|
||||||
|
if (item?.type === 'web_search_call') {
|
||||||
|
const sources = Array.isArray(item.action?.sources)
|
||||||
|
? item.action.sources
|
||||||
|
: []
|
||||||
|
for (const source of sources) {
|
||||||
|
if (typeof source?.url !== 'string' || !source.url) continue
|
||||||
|
sourceMap.set(source.url, {
|
||||||
|
title:
|
||||||
|
typeof source.title === 'string' && source.title
|
||||||
|
? source.title
|
||||||
|
: source.url,
|
||||||
|
url: source.url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item?.type !== 'message' || !Array.isArray(item.content)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of item.content) {
|
||||||
|
if (part?.type === 'output_text' && typeof part.text === 'string') {
|
||||||
|
const trimmed = part.text.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
results.push(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotations = Array.isArray(part?.annotations)
|
||||||
|
? part.annotations
|
||||||
|
: []
|
||||||
|
for (const annotation of annotations) {
|
||||||
|
if (annotation?.type !== 'url_citation') continue
|
||||||
|
if (typeof annotation.url !== 'string' || !annotation.url) continue
|
||||||
|
sourceMap.set(annotation.url, {
|
||||||
|
title:
|
||||||
|
typeof annotation.title === 'string' && annotation.title
|
||||||
|
? annotation.title
|
||||||
|
: annotation.url,
|
||||||
|
url: annotation.url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0 && typeof response.output_text === 'string') {
|
||||||
|
const trimmed = response.output_text.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
results.push(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceMap.size > 0) {
|
||||||
|
results.push({
|
||||||
|
tool_use_id: 'codex-web-search',
|
||||||
|
content: Array.from(sourceMap.values()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
durationSeconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCodexWebSearch(
|
||||||
|
input: Input,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Output> {
|
||||||
|
const startTime = performance.now()
|
||||||
|
const request = resolveProviderRequest({
|
||||||
|
model: getMainLoopModel(),
|
||||||
|
baseUrl: process.env.OPENAI_BASE_URL,
|
||||||
|
})
|
||||||
|
const credentials = resolveCodexApiCredentials()
|
||||||
|
|
||||||
|
if (!credentials.apiKey) {
|
||||||
|
throw new Error('Codex web search requires CODEX_API_KEY or a valid auth.json.')
|
||||||
|
}
|
||||||
|
if (!credentials.accountId) {
|
||||||
|
throw new Error(
|
||||||
|
'Codex web search requires CHATGPT_ACCOUNT_ID or an auth.json with chatgpt_account_id.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: request.resolvedModel,
|
||||||
|
input: buildCodexWebSearchInput(input),
|
||||||
|
instructions: buildCodexWebSearchInstructions(),
|
||||||
|
tools: [makeCodexWebSearchTool(input)],
|
||||||
|
tool_choice: 'required',
|
||||||
|
include: ['web_search_call.action.sources'],
|
||||||
|
store: false,
|
||||||
|
stream: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.reasoning) {
|
||||||
|
body.reasoning = request.reasoning
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${request.baseUrl}/responses`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${credentials.apiKey}`,
|
||||||
|
'chatgpt-account-id': credentials.accountId,
|
||||||
|
originator: 'openclaude',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text().catch(() => 'unknown error')
|
||||||
|
throw new Error(`Codex web search error ${response.status}: ${errorBody}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await collectCodexCompletedResponse(response)
|
||||||
|
const endTime = performance.now()
|
||||||
|
return makeOutputFromCodexWebSearchResponse(
|
||||||
|
payload,
|
||||||
|
input.query,
|
||||||
|
(endTime - startTime) / 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function makeOutputFromSearchResponse(
|
function makeOutputFromSearchResponse(
|
||||||
result: BetaContentBlock[],
|
result: BetaContentBlock[],
|
||||||
query: string,
|
query: string,
|
||||||
@@ -169,6 +381,10 @@ export const WebSearchTool = buildTool({
|
|||||||
const provider = getAPIProvider()
|
const provider = getAPIProvider()
|
||||||
const model = getMainLoopModel()
|
const model = getMainLoopModel()
|
||||||
|
|
||||||
|
if (isCodexResponsesWebSearchEnabled()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Enable for firstParty
|
// Enable for firstParty
|
||||||
if (provider === 'firstParty') {
|
if (provider === 'firstParty') {
|
||||||
return true
|
return true
|
||||||
@@ -221,6 +437,12 @@ export const WebSearchTool = buildTool({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async prompt() {
|
async prompt() {
|
||||||
|
if (isCodexResponsesWebSearchEnabled()) {
|
||||||
|
return getWebSearchPrompt().replace(
|
||||||
|
/\n\s*-\s*Web search is only available in the US/,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
}
|
||||||
return getWebSearchPrompt()
|
return getWebSearchPrompt()
|
||||||
},
|
},
|
||||||
renderToolUseMessage,
|
renderToolUseMessage,
|
||||||
@@ -252,6 +474,12 @@ export const WebSearchTool = buildTool({
|
|||||||
return { result: true }
|
return { result: true }
|
||||||
},
|
},
|
||||||
async call(input, context, _canUseTool, _parentMessage, onProgress) {
|
async call(input, context, _canUseTool, _parentMessage, onProgress) {
|
||||||
|
if (isCodexResponsesWebSearchEnabled()) {
|
||||||
|
return {
|
||||||
|
data: await runCodexWebSearch(input, context.abortController.signal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = performance.now()
|
const startTime = performance.now()
|
||||||
const { query } = input
|
const { query } = input
|
||||||
const userMessage = createUserMessage({
|
const userMessage = createUserMessage({
|
||||||
|
|||||||
Reference in New Issue
Block a user