feat: render native web search in TUI + PREFER_BRAVE_SEARCH toggle (#806)

* feat: render native web search tool calls in TUI

The Anthropic streaming parser silently dropped server_tool_use and
web_search_tool_result content blocks, making native web search
invisible. Add ServerToolUseContent and WebSearchResultContent types,
handle both block types in the streaming parser and conversation replay,
and render them as ToolExecutionComponent in the interactive TUI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add PREFER_BRAVE_SEARCH env var to bypass native web search

Set PREFER_BRAVE_SEARCH=1 to keep Brave/custom search tools active
on Anthropic models instead of injecting native server-side web search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip non-toolCall blocks in Mistral provider conversation replay

The ServerToolUseContent and WebSearchResultContent types added for
native web search don't have id/name/arguments properties, causing
TypeScript errors when the Mistral provider tried to push them as
tool calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-16 23:35:20 -06:00 committed by GitHub
parent 3adacf3ff5
commit 440e6e878f
7 changed files with 179 additions and 7 deletions

View file

@ -333,6 +333,8 @@ async function streamAssistantResponse(
case "toolcall_start":
case "toolcall_delta":
case "toolcall_end":
case "server_tool_use":
case "web_search_result":
if (partialMessage) {
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage;

View file

@ -16,6 +16,7 @@ import type {
ImageContent,
Message,
Model,
ServerToolUseContent,
SimpleStreamOptions,
StopReason,
StreamFunction,
@ -25,6 +26,7 @@ import type {
Tool,
ToolCall,
ToolResultMessage,
WebSearchResultContent,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
@ -291,7 +293,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
const anthropicStream = client.messages.stream({ ...params, stream: true }, { signal: options?.signal });
stream.push({ type: "start", partial: output });
type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number };
type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | ServerToolUseContent | WebSearchResultContent) & { index: number };
const blocks = output.content as Block[];
for await (const event of anthropicStream) {
@ -347,6 +349,27 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
};
output.content.push(block);
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
} else if ((event.content_block as any).type === "server_tool_use") {
const serverBlock = event.content_block as any;
const block: Block = {
type: "serverToolUse",
id: serverBlock.id,
name: serverBlock.name,
input: serverBlock.input,
index: event.index,
};
output.content.push(block);
stream.push({ type: "server_tool_use", contentIndex: output.content.length - 1, partial: output });
} else if ((event.content_block as any).type === "web_search_tool_result") {
const resultBlock = event.content_block as any;
const block: Block = {
type: "webSearchResult",
toolUseId: resultBlock.tool_use_id,
content: resultBlock.content,
index: event.index,
};
output.content.push(block);
stream.push({ type: "web_search_result", contentIndex: output.content.length - 1, partial: output });
}
} else if (event.type === "content_block_delta") {
if (event.delta.type === "text_delta") {
@ -423,6 +446,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
partial: output,
});
}
// serverToolUse and webSearchResult blocks just need index cleanup (already emitted on start)
}
} else if (event.type === "message_delta") {
if (event.delta.stop_reason) {
@ -840,6 +864,19 @@ function convertMessages(
name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,
input: block.arguments ?? {},
});
} else if (block.type === "serverToolUse") {
blocks.push({
type: "server_tool_use",
id: block.id,
name: block.name,
input: block.input ?? {},
} as any);
} else if (block.type === "webSearchResult") {
blocks.push({
type: "web_search_tool_result",
tool_use_id: block.toolUseId,
content: block.content,
} as any);
}
}
if (blocks.length === 0) continue;

View file

@ -501,6 +501,9 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl
}
continue;
}
if (block.type !== "toolCall") {
continue;
}
toolCalls.push({
id: block.id,
type: "function",

View file

@ -159,6 +159,22 @@ export interface ToolCall {
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
}
/** Server-side tool use (e.g., Anthropic native web search). Executed by the API, not the client. */
export interface ServerToolUseContent {
type: "serverToolUse";
id: string;
name: string; // e.g., "web_search"
input: unknown;
}
/** Result of a server-side tool execution, paired with a ServerToolUseContent by toolUseId. */
export interface WebSearchResultContent {
type: "webSearchResult";
toolUseId: string;
/** Search results or error from the server. Opaque — stored for API replay. */
content: unknown;
}
export interface Usage {
input: number;
output: number;
@ -184,7 +200,7 @@ export interface UserMessage {
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
content: (TextContent | ThinkingContent | ToolCall | ServerToolUseContent | WebSearchResultContent)[];
api: Api;
provider: Provider;
model: string;
@ -233,6 +249,8 @@ export type AssistantMessageEvent =
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| { type: "server_tool_use"; contentIndex: number; partial: AssistantMessage }
| { type: "web_search_result"; contentIndex: number; partial: AssistantMessage }
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };

View file

@ -903,6 +903,24 @@ export class ToolExecutionComponent extends Container {
text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
}
} else if (this.toolName === "web_search") {
// Server-side Anthropic web search
text = theme.fg("toolTitle", theme.bold("web search"));
if (this.result) {
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`;
}
}
}
} else {
// Generic tool (shouldn't reach here for custom tools)
text = theme.fg("toolTitle", theme.bold(this.toolName));

View file

@ -1165,6 +1165,34 @@ export class InteractiveMode {
return registeredTool?.definition;
}
/**
* Format web search result content for display in the TUI.
*/
private formatWebSearchResult(content: unknown): string {
if (!content) return "Web search completed";
// Error result
if (typeof content === "object" && "type" in (content as any) && (content as any).type === "web_search_tool_result_error") {
const error = content as any;
return `Search error: ${error.error_code || "unknown"}`;
}
// Array of search results
if (Array.isArray(content)) {
const results = content.filter((r: any) => r.type === "web_search_result");
if (results.length === 0) return "No results found";
return results
.map((r: any) => {
const title = r.title || "Untitled";
const url = r.url || "";
return `${title}\n ${url}`;
})
.join("\n");
}
return "Web search completed";
}
/**
* Set up keyboard shortcuts registered by extensions.
*/
@ -2201,6 +2229,35 @@ export class InteractiveMode {
component.updateArgs(content.arguments);
}
}
} else if (content.type === "serverToolUse") {
// Server-side tool (e.g., native web search) — show as pending tool execution
if (!this.pendingTools.has(content.id)) {
const component = new ToolExecutionComponent(
content.name,
content.input ?? {},
{
showImages: this.settingsManager.getShowImages(),
},
undefined,
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
this.pendingTools.set(content.id, component);
}
} else if (content.type === "webSearchResult") {
// Server-side tool result — resolve the pending server tool execution
const component = this.pendingTools.get(content.toolUseId);
if (component) {
const searchContent = content.content;
const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error";
const resultText = this.formatWebSearchResult(searchContent);
component.updateResult({
content: [{ type: "text", text: resultText }],
isError: !!isError,
});
this.pendingTools.delete(content.toolUseId);
}
}
}
this.ui.requestRender();
@ -2594,6 +2651,33 @@ export class InteractiveMode {
} else {
this.pendingTools.set(content.id, component);
}
} else if (content.type === "serverToolUse") {
// Server-side tool (e.g., native web search)
const component = new ToolExecutionComponent(
content.name,
content.input ?? {},
{ showImages: this.settingsManager.getShowImages() },
undefined,
this.ui,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
// Find matching webSearchResult in this message's content
const resultBlock = message.content.find(
(c) => c.type === "webSearchResult" && c.toolUseId === content.id,
);
if (resultBlock && resultBlock.type === "webSearchResult") {
const searchContent = resultBlock.content;
const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error";
const resultText = this.formatWebSearchResult(searchContent);
component.updateResult({
content: [{ type: "text", text: resultText }],
isError: !!isError,
});
} else {
// No result yet (aborted stream?) — show as pending
this.pendingTools.set(content.id, component);
}
}
}
} else if (message.role === "toolResult") {

View file

@ -14,6 +14,11 @@ export const CUSTOM_SEARCH_TOOL_NAMES = ["search-the-web", "search_and_read", "g
/** Thinking block types that require signature validation by the API */
const THINKING_TYPES = new Set(["thinking", "redacted_thinking"]);
/** When true, skip native web search injection and keep Brave/custom tools active on Anthropic. */
export function preferBraveSearch(): boolean {
return process.env.PREFER_BRAVE_SEARCH === "1" || process.env.PREFER_BRAVE_SEARCH === "true";
}
/** Minimal interface matching the subset of ExtensionAPI we use */
export interface NativeSearchPI {
on(event: string, handler: (...args: any[]) => any): void;
@ -71,9 +76,9 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
const hasBrave = !!process.env.BRAVE_API_KEY;
// When Anthropic: disable all custom search tools — native web_search is
// server-side and more reliable. Custom tools cause confusion and failures.
if (isAnthropicProvider) {
// When Anthropic (and not preferring Brave): disable custom search tools —
// native web_search is server-side and more reliable.
if (isAnthropicProvider && !preferBraveSearch()) {
const active = pi.getActiveTools();
pi.setActiveTools(
active.filter((t: string) => !CUSTOM_SEARCH_TOOL_NAMES.includes(t))
@ -90,8 +95,10 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
}
// Show provider-aware diagnostics on first selection or provider change
if (isAnthropicProvider && !wasAnthropic && event.source !== "restore") {
if (isAnthropicProvider && !preferBraveSearch() && !wasAnthropic && event.source !== "restore") {
ctx.ui.notify("Native Anthropic web search active", "info");
} else if (isAnthropicProvider && preferBraveSearch() && !wasAnthropic && event.source !== "restore") {
ctx.ui.notify("Brave search active (PREFER_BRAVE_SEARCH)", "info");
} else if (!isAnthropicProvider && !hasBrave) {
ctx.ui.notify(
"Web search: Set BRAVE_API_KEY or use an Anthropic model for built-in search",
@ -129,6 +136,9 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
stripThinkingFromHistory(messages);
}
// When preferring Brave, skip native search injection entirely
if (preferBraveSearch()) return;
if (!Array.isArray(payload.tools)) payload.tools = [];
let tools = payload.tools as Array<Record<string, unknown>>;
@ -136,7 +146,7 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
// Don't double-inject if already present
if (tools.some((t) => t.type === "web_search_20250305")) return;
// Always remove custom search tool definitions from Anthropic requests.
// Remove custom search tool definitions from Anthropic requests.
// Native web_search is server-side and more reliable — keeping both confuses
// the model and causes it to pick custom tools which can fail with network errors.
tools = tools.filter(