Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/server/proxy/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Comment thread
orangeboyChen marked this conversation as resolved.
);

if (cancelled) {
Expand Down Expand Up @@ -1404,6 +1406,7 @@ export const handleMessagesRequest = async (
undefined,
debugTrace,
'/v1/messages',
{ findingsAsStructuredBlocks: true },
);

if (!upstreamResponse.ok) {
Expand Down
37 changes: 32 additions & 5 deletions lib/server/proxy/web-search-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 }],
Expand Down Expand Up @@ -1334,6 +1356,7 @@ const createInlineServerToolStream = async ({

if (nextRemainingCalls.length) {
finalPayload = buildMixedTurnPayload({
findingsAsStructuredBlocks: callbacks?.findingsAsStructuredBlocks,
message,
payload: {
choices: [{ message }],
Expand Down Expand Up @@ -1420,6 +1443,8 @@ const createInlineServerToolStream = async ({
};

finalPayload = buildMixedTurnPayload({
findingsAsStructuredBlocks:
callbacks?.findingsAsStructuredBlocks,
message: fallbackMessage,
payload: {
choices: [{ message: fallbackMessage }],
Expand Down Expand Up @@ -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.
Expand Down
234 changes: 234 additions & 0 deletions tests/server/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading