diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 19f6dc7..a8d94f0 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -1285,7 +1285,9 @@ const createAnthropicServerToolEventStream = ( undefined, debugTrace, '/v1/messages', - { emitStreamEvents: true }, + // The findings reach the client as `web_search_tool_result` blocks, + // so the loop must not also fold them into the assistant text. + { emitStreamEvents: true, findingsAsStructuredBlocks: true }, ); if (cancelled) { @@ -1404,6 +1406,7 @@ export const handleMessagesRequest = async ( undefined, debugTrace, '/v1/messages', + { findingsAsStructuredBlocks: true }, ); if (!upstreamResponse.ok) { diff --git a/lib/server/proxy/web-search-loop.ts b/lib/server/proxy/web-search-loop.ts index 11c75cc..07a8ea7 100644 --- a/lib/server/proxy/web-search-loop.ts +++ b/lib/server/proxy/web-search-loop.ts @@ -496,14 +496,22 @@ const sumUsage = (accumulated: unknown, incoming: unknown): unknown => { * outstanding tool calls unchanged, so a turn that mixed search with * client-side calls stays a valid transcript. The client sees its own calls * come back as if upstream had returned them directly. + * + * The findings are folded only when the route has no other way to carry them. + * A route that renders them structurally passes + * `findingsAsStructuredBlocks`, and the text is left alone: the results are + * already on the wire as a result block, and a second copy in the prose is + * what the user reads as the model reciting its own search output. */ const buildMixedTurnPayload = ({ + findingsAsStructuredBlocks = false, message, payload, remainingCalls, searchResults, usage, }: { + findingsAsStructuredBlocks?: boolean; message: ChatCompletionMessage | undefined; payload: ChatCompletionPayload; remainingCalls: ChatCompletionToolCall[]; @@ -514,7 +522,9 @@ const buildMixedTurnPayload = ({ typeof message?.content === 'string' && message.content.trim() ? message.content.trim() : ''; - const findings = searchResults.filter(Boolean).join('\n\n'); + const findings = findingsAsStructuredBlocks + ? '' + : searchResults.filter(Boolean).join('\n\n'); const content = [existingText, findings].filter(Boolean).join('\n\n'); return { @@ -634,6 +644,14 @@ export type ServerToolExecution = export interface ServerToolCallbacks { emitStreamEvents?: boolean; + /** + * Set by routes that render a server tool's findings structurally — + * Anthropic's `web_search_tool_result` block — instead of as prose. Those + * routes must not also fold the same findings into the assistant text, or + * the user sees the results twice: once as a result block and once as if + * the model had written them. + */ + findingsAsStructuredBlocks?: boolean; onCall?: (invocation: ServerToolInvocation) => void; onResult?: (execution: ServerToolExecution) => void; } @@ -1126,7 +1144,11 @@ const createInlineServerToolStream = async ({ executions.push(...results.map((result) => result.execution)); if (remainingCalls.length) { - const findings = results.map((result) => result.content).join('\n\n'); + // Same opt-out as `buildMixedTurnPayload`: the result event above + // already carries these findings, so a text copy would be the second. + const findings = callbacks.findingsAsStructuredBlocks + ? '' + : results.map((result) => result.content).join('\n\n'); if (findings) { emitJson(controller, { choices: [{ delta: { content: findings }, index: 0 }], @@ -1334,6 +1356,7 @@ const createInlineServerToolStream = async ({ if (nextRemainingCalls.length) { finalPayload = buildMixedTurnPayload({ + findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, message, payload: { choices: [{ message }], @@ -1420,6 +1443,8 @@ const createInlineServerToolStream = async ({ }; finalPayload = buildMixedTurnPayload({ + findingsAsStructuredBlocks: + callbacks?.findingsAsStructuredBlocks, message: fallbackMessage, payload: { choices: [{ message: fallbackMessage }], @@ -1665,15 +1690,17 @@ export const executeWebSearchLoop = async ({ // A turn mixing server tools with client-side calls cannot be continued // locally: the client owns those calls, and re-issuing the transcript with // only server-tool results would leave them unanswered, which upstream - // rejects as an invalid tool-call transcript. Run the server tools, fold the - // findings into the message text, and hand the outstanding calls back so the - // client resolves them on its next turn. + // rejects as an invalid tool-call transcript. Run the server tools and + // hand the outstanding calls back so the client resolves them on its next + // turn. The findings ride along in the message text only for routes that + // cannot render them structurally; see `buildMixedTurnPayload`. if (remainingCalls.length) { return { body: loopBody, executions, response: Response.json( buildMixedTurnPayload({ + findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks, // `buildMixedTurnPayload` reads this iteration's text and reasoning // off `message`, so only the earlier iterations go on top; the // current one is folded in by the helper itself. diff --git a/tests/server/web-search.test.ts b/tests/server/web-search.test.ts index c2e7e35..14c4a07 100644 --- a/tests/server/web-search.test.ts +++ b/tests/server/web-search.test.ts @@ -5298,6 +5298,240 @@ describe('chat proxy web search integration', () => { expect(upstreamCalls).toBe(2); }); + it('does not fold findings into the text when a structured block carries them', async () => { + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/agenttool/v1/search')) { + return makeJsonResponse({ + results: [ + { snippet: 'snip', title: 'Result', url: 'https://r.test' }, + ], + }); + } + + upstreamCalls++; + // One turn mixing a local search with a client-owned call, so the loop + // cannot continue and has to hand the outstanding call back. + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + tool_calls: [ + { + id: 'call_search', + function: { + arguments: '{"query":"two results"}', + name: 'web_search', + }, + }, + { + id: 'call_client', + function: { arguments: '{}', name: 'client_tool' }, + type: 'function', + }, + ], + }, + }, + ], + }); + }); + + const response = await handleMessagesRequest( + makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ role: 'user', content: 'Mixed turn' }], + tools: [ + { + type: 'web_search_20260209', + name: 'web_search', + input_schema: {}, + }, + ], + }, + ); + const payload = (await response.json()) as { + content: Array<{ text?: string; type: string }>; + }; + + // The result block is how this route reports the findings, so the prose + // must not repeat them: a second copy reads as the model reciting its own + // search output, and the "Cite the URL" line is an instruction to the + // model rather than something the user ever asked to see. + expect(upstreamCalls).toBeGreaterThan(0); + expect(payload.content.map((block) => block.type)).toContain( + 'web_search_tool_result', + ); + expect( + payload.content + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join(''), + ).not.toContain('https://r.test'); + expect(JSON.stringify(payload)).not.toContain('Cite the URL'); + }); + + it('does not stream folded findings when a structured block carries them', async () => { + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/agenttool/v1/search')) { + return makeJsonResponse({ + results: [ + { snippet: 'snip', title: 'Result', url: 'https://r.test' }, + ], + }); + } + + // The very first upstream turn mixes the local search with a client + // call. That branch emits its own text delta rather than going through + // `buildMixedTurnPayload`, so it needs the same opt-out. + return makeSseResponse({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'call_search', + index: 0, + function: { + arguments: '{"query":"two results"}', + name: 'web_search', + }, + }, + { + id: 'call_client', + index: 1, + function: { arguments: '{}', name: 'client_tool' }, + type: 'function', + }, + ], + }, + finish_reason: 'tool_calls', + index: 0, + }, + ], + }); + }); + + const response = await handleMessagesRequest( + makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 1024, + messages: [{ role: 'user', content: 'Mixed turn' }], + stream: true, + tools: [ + { + type: 'web_search_20260209', + name: 'web_search', + input_schema: {}, + }, + ], + }, + ); + const text = await response.text(); + + // Structured result block present, findings not repeated as prose. + expect(text).toContain('"type":"web_search_tool_result"'); + + // `handleMessagesRequest` answers in Anthropic SSE, so the text lives in + // `content_block_delta` frames as `text_delta` — not in `choices`. + const contentDeltas = ( + await readSseEvents( + new Response(text, { + headers: { 'Content-Type': 'text/event-stream' }, + }), + ) + ) + .flatMap((payload) => { + try { + const parsed = JSON.parse(payload) as { + delta?: { text?: string; type?: string }; + }; + + return parsed.delta?.type === 'text_delta' && parsed.delta.text + ? [parsed.delta.text] + : []; + } catch { + return []; + } + }) + .join(''); + + expect(contentDeltas).not.toContain('https://r.test'); + expect(contentDeltas).not.toContain('Cite the URL'); + }); + + it('keeps folding findings for routes without a structured channel', async () => { + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + + let upstreamCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/agenttool/v1/search')) { + return makeJsonResponse({ + results: [ + { snippet: 'snip', title: 'Result', url: 'https://r.test' }, + ], + }); + } + + upstreamCalls++; + return makeJsonResponse({ + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + tool_calls: [ + { + id: 'call_search', + function: { + arguments: '{"query":"two results"}', + name: 'web_search', + }, + }, + { + id: 'call_client', + function: { arguments: '{}', name: 'client_tool' }, + type: 'function', + }, + ], + }, + }, + ], + }); + }); + + const response = await proxyChatCompletions( + makeNextRequest('http://localhost/v1/chat/completions', { + method: 'POST', + }), + { + messages: [{ content: 'Mixed turn', role: 'user' }], + model: 'glm-5.1', + tools: [{ type: 'web_search_preview' }], + } as never, + ); + const payload = (await response.json()) as { + choices: Array<{ message: { content: string | null } }>; + }; + + // /v1/chat/completions has no structured channel for the findings, so the + // fold has to stay: it is the only way the results reach the caller. + expect(upstreamCalls).toBeGreaterThan(0); + expect(payload.choices[0]?.message.content).toContain('https://r.test'); + }); + it('maps a completed fetch to a Responses open_page call', async () => { await updateSettings({ CODEBUDDY_WEB_FETCH_BACKEND: 'codebuddy' }); let upstreamCalls = 0;