diff --git a/lib/agents/researcher.tsx b/lib/agents/researcher.tsx index 2004485f..87c032f6 100644 --- a/lib/agents/researcher.tsx +++ b/lib/agents/researcher.tsx @@ -6,11 +6,12 @@ import { ToolCallPart, ToolResultPart, streamText as nonexperimental_streamText, + generateText, } from 'ai' import { Section } from '@/components/section' import { BotMessage } from '@/components/message' import { getTools } from './tools' -import { getModel } from '../utils' +import { getModel, getReasoningProviderOptions, isNonStreamingModel } from '../utils' import { MapProvider } from '@/lib/store/settings' import { DrawnFeature } from './resolution-search' import { getSelectedModel } from '@/lib/actions/users' @@ -157,10 +158,37 @@ export async function researcher( : 'none' }) - const result = await nonexperimental_streamText({ - model: (await getModel(hasImage)) as LanguageModel, - maxTokens: 2500, + const model = await getModel(hasImage) + const generationOptions = { + model: model as LanguageModel, + maxTokens: 4096, temperature: 0, + providerOptions: getReasoningProviderOptions(model), + maxSteps: 5, + abortSignal: createDeadlineSignal(AI_REQUEST_TIMEOUT_MS), + system: systemPromptToUse, + messages, + tools: getTools({ uiStream, fullResponse, mapProvider, selectedModel, drawnFeatures }), + } + + if (isNonStreamingModel(model)) { + const generated = await generateText(generationOptions) + uiStream.update(null) + fullResponse = generated.text || '' + const generatedToolCalls = (generated.toolCalls || []) as ToolCallPart[] + const generatedToolResults = (generated.toolResults || []) as ToolResultPart[] + if (fullResponse.trim()) { + uiStream.append(answerSection) + streamText.update(fullResponse) + } + streamText.done(fullResponse) + messages.push({ role: 'assistant', content: [{ type: 'text', text: fullResponse }, ...generatedToolCalls] }) + if (generatedToolResults.length > 0) messages.push({ role: 'tool', content: generatedToolResults }) + return { result: generated, fullResponse, hasError: false, toolResponses: generatedToolResults } + } + + const result = await nonexperimental_streamText({ + ...generationOptions, // Allow multi-step tool calling (tool round + synthesis step with headroom for chained tool calls) maxSteps: 5, abortSignal: createDeadlineSignal(AI_REQUEST_TIMEOUT_MS), @@ -199,13 +227,35 @@ export async function researcher( case 'error': hasError = true + console.error('Model response generation failed:', delta.error) fullResponse += `\n\nError: Model response generation failed.` break } } - if (toolResponses.length > 0 && !hasError && fullResponse.trim().length === 0) { + // Some reasoning-model/provider combinations finish with the final text + // available on result.text without emitting a text-delta event. Recover it + // before finalizing the stream so the response section cannot remain empty. + if (fullResponse.trim().length === 0) { + try { + const completedText = await result.text + if (completedText?.trim()) { + fullResponse = completedText + } + } catch (error) { + console.error('Unable to recover completed model text:', error) + } + } + + if (fullResponse.trim().length === 0 && toolResponses.length > 0 && !hasError) { fullResponse = 'Information gathered from search results.' + } + + if (fullResponse.trim().length === 0 && !hasError) { + fullResponse = 'The model returned no visible response. Please try again.' + } + + if (fullResponse.trim().length > 0) { if (!hasAppendedAnswerSection) { uiStream.append(answerSection) hasAppendedAnswerSection = true diff --git a/lib/agents/resolution-search.tsx b/lib/agents/resolution-search.tsx index c99b42e0..7fd89c04 100644 --- a/lib/agents/resolution-search.tsx +++ b/lib/agents/resolution-search.tsx @@ -1,5 +1,5 @@ -import { CoreMessage, streamObject } from 'ai' -import { getModel } from '@/lib/utils' +import { CoreMessage, generateObject, streamObject } from 'ai' +import { getModel, getReasoningProviderOptions, isNonStreamingModel } from '@/lib/utils' import { tavily } from '@tavily/core' import { resolutionSearchSchema } from '@/lib/schema/resolution-search' import { AI_REQUEST_TIMEOUT_MS, ENRICHMENT_TIMEOUT_MS, createDeadlineSignal, withTimeout } from '@/lib/utils/with-timeout' @@ -187,14 +187,32 @@ Analyze the user's prompt and the image to provide a holistic understanding of t message.content.some((part: any) => part.type === 'image') ) - // Use streamObject to get partial results. - return withTimeout(Promise.resolve(streamObject({ - model: await getModel(hasImage), + // Use streamed output when the provider supports it. The configured GPT-5.5 + // endpoint is explicitly non-streaming, so use generateObject and expose the + // completed object through the same async interface consumed by actions.tsx. + const model = await getModel(hasImage) + const generationOptions = { + model, system: systemPrompt, messages: filteredMessages, schema: resolutionSearchSchema, temperature: 0, - maxTokens: 1800, + maxTokens: 4096, + providerOptions: getReasoningProviderOptions(model), abortSignal: createDeadlineSignal(AI_REQUEST_TIMEOUT_MS), - })), AI_REQUEST_TIMEOUT_MS, 'Resolution analysis') + } + + if (isNonStreamingModel(model)) { + const generated = await generateObject(generationOptions) + return { + partialObjectStream: (async function* () { yield generated.object })(), + object: Promise.resolve(generated.object), + } + } + + return withTimeout( + Promise.resolve(streamObject(generationOptions)), + AI_REQUEST_TIMEOUT_MS, + 'Resolution analysis' + ) } diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 5b4f03e1..b497c6ae 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -67,14 +67,15 @@ export async function getModel(requireVision: boolean = false) { console.error('User selected "Gemini 3.1 Pro" but GEMINI_3_PRO_API_KEY is not set.'); throw new Error('Selected model is not configured.'); } + case 'GPT-5.6': case 'GPT-5.1': if (openaiApiKey) { const openai = createOpenAI({ apiKey: openaiApiKey, }); - return openai('gpt-4o'); + return openai.responses('gpt-5.5'); } else { - console.error('User selected "GPT-5.1" but OPENAI_API_KEY is not set.'); + console.error('User selected "GPT-5.6" but OPENAI_API_KEY is not set.'); throw new Error('Selected model is not configured.'); } } @@ -86,7 +87,7 @@ export async function getModel(requireVision: boolean = false) { const openai = createOpenAI({ apiKey: openaiApiKey, }); - return openai('gpt-4o'); + return openai.responses('gpt-5.5'); } catch (error) { console.warn('OpenAI API unavailable, falling back to next provider:', error); } @@ -135,7 +136,24 @@ export async function getModel(requireVision: boolean = false) { const openai = createOpenAI({ apiKey: openaiApiKey, }); - return openai('gpt-4o'); + return openai.responses('gpt-5.5'); +} + +/** + * GPT-5 and o-series models share their output budget with hidden reasoning. + * Bound reasoning so streamed user-visible text is not starved. Detect the + * actual model because getModel can fall back to another provider. + */ +export function getReasoningProviderOptions(model: { modelId?: string }) { + const modelId = model?.modelId ?? '' + return modelId.startsWith('gpt-5') || modelId.startsWith('o') + ? { openai: { reasoningEffort: 'low' as const } } + : undefined +} + +/** The configured API advertises GPT-5.5 as non-streaming. */ +export function isNonStreamingModel(model: { modelId?: string }) { + return model?.modelId === 'gpt-5.5' } /**