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(