From 440e6e878f1e219711a53241ae8fc89ab5846d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 16 Mar 2026 23:35:20 -0600 Subject: [PATCH] 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/pi-agent-core/src/agent-loop.ts | 2 + packages/pi-ai/src/providers/anthropic.ts | 39 ++++++++- packages/pi-ai/src/providers/mistral.ts | 3 + packages/pi-ai/src/types.ts | 20 ++++- .../interactive/components/tool-execution.ts | 18 ++++ .../src/modes/interactive/interactive-mode.ts | 84 +++++++++++++++++++ .../search-the-web/native-search.ts | 20 +++-- 7 files changed, 179 insertions(+), 7 deletions(-) diff --git a/packages/pi-agent-core/src/agent-loop.ts b/packages/pi-agent-core/src/agent-loop.ts index b7ade645b..fa05a0eff 100644 --- a/packages/pi-agent-core/src/agent-loop.ts +++ b/packages/pi-agent-core/src/agent-loop.ts @@ -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; diff --git a/packages/pi-ai/src/providers/anthropic.ts b/packages/pi-ai/src/providers/anthropic.ts index c8a81f6a9..192b9eb58 100644 --- a/packages/pi-ai/src/providers/anthropic.ts +++ b/packages/pi-ai/src/providers/anthropic.ts @@ -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; diff --git a/packages/pi-ai/src/providers/mistral.ts b/packages/pi-ai/src/providers/mistral.ts index a7a495a3a..7c9b54b91 100644 --- a/packages/pi-ai/src/providers/mistral.ts +++ b/packages/pi-ai/src/providers/mistral.ts @@ -501,6 +501,9 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl } continue; } + if (block.type !== "toolCall") { + continue; + } toolCalls.push({ id: block.id, type: "function", diff --git a/packages/pi-ai/src/types.ts b/packages/pi-ai/src/types.ts index d4cdf286c..9903b9c79 100644 --- a/packages/pi-ai/src/types.ts +++ b/packages/pi-ai/src/types.ts @@ -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; message: AssistantMessage } | { type: "error"; reason: Extract; error: AssistantMessage }; diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts index 15562943d..88514f3b0 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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)); diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index e536b63d3..39b867c00 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -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") { diff --git a/src/resources/extensions/search-the-web/native-search.ts b/src/resources/extensions/search-the-web/native-search.ts index dd0181145..f46bd42a4 100644 --- a/src/resources/extensions/search-the-web/native-search.ts +++ b/src/resources/extensions/search-the-web/native-search.ts @@ -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>; @@ -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(