Просмотр исходного кода

feat(websearch): add parallel provider rollout (#26227)

Shoubhit Dash 3 месяцев назад
Родитель
Сommit
a43d3e0e1e

+ 1 - 0
packages/core/src/flag/flag.ts

@@ -77,6 +77,7 @@ export const Flag = {
   OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
   OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
   OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
+  OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
   OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
   OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
   OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),

+ 6 - 3
packages/opencode/src/cli/cmd/run.ts

@@ -20,7 +20,7 @@ import { ReadTool } from "../../tool/read"
 import { WebFetchTool } from "../../tool/webfetch"
 import { EditTool } from "../../tool/edit"
 import { WriteTool } from "../../tool/write"
-import { WebSearchTool } from "../../tool/websearch"
+import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch"
 import { TaskTool } from "../../tool/task"
 import { SkillTool } from "../../tool/skill"
 import { ShellTool } from "../../tool/shell"
@@ -148,7 +148,7 @@ function edit(info: ToolProps<typeof EditTool>) {
 function websearch(info: ToolProps<typeof WebSearchTool>) {
   inline({
     icon: "◈",
-    title: `Exa Web Search "${info.input.query}"`,
+    title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`,
   })
 }
 
@@ -469,7 +469,10 @@ export const RunCommand = effectCmd({
                 }
                 inline({
                   icon: "✗",
-                  title: `${part.tool} failed`,
+                  title:
+                    part.tool === "websearch"
+                      ? `${webSearchProviderLabel(props<typeof WebSearchTool>(part).metadata.provider)} failed`
+                      : `${part.tool} failed`,
                 })
                 UI.error(part.state.error)
               }

+ 9 - 3
packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

@@ -9,6 +9,7 @@ import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/c
 import { useBindings } from "../../keymap"
 import { Locale } from "@/util/locale"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+import { webSearchProviderLabel } from "@/tool/websearch"
 import path from "path"
 import stripAnsi from "strip-ansi"
 import type {
@@ -89,6 +90,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
                   <Match when={message.type === "assistant"}>
                     <AssistantMessage
                       message={message as SessionMessageAssistant}
+                      sessionID={props.sessionID}
                       last={lastAssistant()?.id === message.id}
                       syntax={syntax()}
                       subtleSyntax={subtleSyntax()}
@@ -286,6 +288,7 @@ function UnknownMessage(props: { message: SessionMessage }) {
 
 function AssistantMessage(props: {
   message: SessionMessageAssistant
+  sessionID: string
   last: boolean
   syntax: SyntaxStyle
   subtleSyntax: SyntaxStyle
@@ -314,7 +317,7 @@ function AssistantMessage(props: {
               <AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
             </Match>
             <Match when={part.type === "tool"}>
-              <AssistantTool part={part as SessionMessageAssistantTool} />
+              <AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
             </Match>
           </Switch>
         )}
@@ -400,7 +403,7 @@ function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; sub
   )
 }
 
-function AssistantTool(props: { part: SessionMessageAssistantTool }) {
+function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) {
   const input = createMemo(() => toolInputRecord(props.part.state.input))
   const toolprops = {
     get input() {
@@ -412,6 +415,7 @@ function AssistantTool(props: { part: SessionMessageAssistantTool }) {
     get output() {
       return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
     },
+    sessionID: props.sessionID,
     part: props.part,
   }
   return (
@@ -469,6 +473,7 @@ type ToolProps = {
   input: Record<string, unknown>
   metadata: Record<string, unknown>
   output?: string
+  sessionID: string
   part: SessionMessageAssistantTool
 }
 
@@ -775,9 +780,10 @@ function CodeSearch(props: ToolProps) {
 }
 
 function WebSearch(props: ToolProps) {
+  const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
   return (
     <InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
-      Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
+      {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
       <Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
     </InlineTool>
   )

+ 4 - 3
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -45,7 +45,7 @@ import type { GrepTool } from "@/tool/grep"
 import type { EditTool } from "@/tool/edit"
 import type { ApplyPatchTool } from "@/tool/apply_patch"
 import type { WebFetchTool } from "@/tool/webfetch"
-import type { WebSearchTool } from "@/tool/websearch"
+import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch"
 import type { TaskTool } from "@/tool/task"
 import type { QuestionTool } from "@/tool/question"
 import type { SkillTool } from "@/tool/skill"
@@ -1933,10 +1933,11 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
 }
 
 function WebSearch(props: ToolProps<typeof WebSearchTool>) {
-  const metadata = props.metadata as { numResults?: number }
+  const metadata = props.metadata as { numResults?: number; provider?: unknown }
   return (
     <InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
-      Exa Web Search "{props.input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
+      {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "}
+      <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
     </InlineTool>
   )
 }

+ 2 - 1
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -13,6 +13,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
 import { Locale } from "@/util/locale"
 import { Global } from "@opencode-ai/core/global"
 import { ShellID } from "@/tool/shell/id"
+import { webSearchProviderLabel } from "@/tool/websearch"
 import { useDialog } from "../../ui/dialog"
 import { getScrollAcceleration } from "../../util/scroll"
 import { useTuiConfig } from "../../context/tui-config"
@@ -338,7 +339,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
               const query = typeof data.query === "string" ? data.query : ""
               return {
                 icon: "◈",
-                title: `Exa Web Search "${query}"`,
+                title: `${webSearchProviderLabel(data.provider)} "${query}"`,
                 body: (
                   <Show when={query}>
                     <box paddingLeft={1}>

+ 1 - 1
packages/opencode/src/command/template/review.txt

@@ -85,7 +85,7 @@ Use these to inform your review:
 
 - **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit.
 - **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong.
-- **Exa Web Search** - Research best practices if you're unsure about a pattern.
+- **Web Search** - Research best practices if you're unsure about a pattern.
 
 If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue.
 

+ 29 - 6
packages/opencode/src/tool/mcp-exa.ts → packages/opencode/src/tool/mcp-websearch.ts

@@ -1,9 +1,10 @@
 import { Duration, Effect, Schema } from "effect"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
 
-const URL = process.env.EXA_API_KEY
+export const EXA_URL = process.env.EXA_API_KEY
   ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}`
   : "https://mcp.exa.ai/mcp"
+export const PARALLEL_URL = "https://search.parallel.ai/mcp"
 
 const McpResult = Schema.Struct({
   result: Schema.Struct({
@@ -18,11 +19,23 @@ const McpResult = Schema.Struct({
 
 const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
 
-const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
+const parsePayload = (payload: string) =>
+  Effect.gen(function* () {
+    const trimmed = payload.trim()
+    if (!trimmed.startsWith("{")) return undefined
+    const data = yield* decode(trimmed)
+    return data.result.content.find((item) => item.text)?.text
+  })
+
+export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) {
+  const trimmed = body.trim()
+  const direct = trimmed ? yield* parsePayload(trimmed) : undefined
+  if (direct) return direct
+
   for (const line of body.split("\n")) {
     if (!line.startsWith("data: ")) continue
-    const data = yield* decode(line.substring(6))
-    if (data.result.content[0]?.text) return data.result.content[0].text
+    const data = yield* parsePayload(line.substring(6))
+    if (data) return data
   }
   return undefined
 })
@@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({
   contextMaxCharacters: Schema.optional(Schema.Number),
 })
 
+export const ParallelSearchArgs = Schema.Struct({
+  objective: Schema.String,
+  search_queries: Schema.Array(Schema.String),
+  session_id: Schema.optional(Schema.String),
+  model_name: Schema.optional(Schema.String),
+})
+
 const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
   Schema.Struct({
     jsonrpc: Schema.Literal("2.0"),
@@ -48,14 +68,17 @@ const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
 
 export const call = <F extends Schema.Struct.Fields>(
   http: HttpClient.HttpClient,
+  url: string,
   tool: string,
   args: Schema.Struct<F>,
   value: Schema.Struct.Type<F>,
   timeout: Duration.Input,
+  headers?: Record<string, string>,
 ) =>
   Effect.gen(function* () {
-    const request = yield* HttpClientRequest.post(URL).pipe(
+    const request = yield* HttpClientRequest.post(url).pipe(
       HttpClientRequest.accept("application/json, text/event-stream"),
+      HttpClientRequest.setHeaders(headers ?? {}),
       HttpClientRequest.schemaBodyJson(McpRequest(args))({
         jsonrpc: "2.0" as const,
         id: 1 as const,
@@ -69,5 +92,5 @@ export const call = <F extends Schema.Struct.Fields>(
         Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
       )
     const body = yield* response.text
-    return yield* parseSse(body)
+    return yield* parseResponse(body)
   })

+ 8 - 1
packages/opencode/src/tool/registry.ts

@@ -49,6 +49,13 @@ import { Permission } from "@/permission"
 
 const log = Log.create({ service: "tool.registry" })
 
+export function webSearchEnabled(
+  providerID: ProviderID,
+  flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL },
+) {
+  return providerID === ProviderID.opencode || flags.exa || flags.parallel
+}
+
 type TaskDef = Tool.InferDef<typeof TaskTool>
 type ReadDef = Tool.InferDef<typeof ReadTool>
 
@@ -284,7 +291,7 @@ export const layer: Layer.Layer<
     const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
       const filtered = (yield* all()).filter((tool) => {
         if (tool.id === WebSearchTool.id) {
-          return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
+          return webSearchEnabled(input.providerID)
         }
 
         const usePatch =

+ 87 - 16
packages/opencode/src/tool/websearch.ts

@@ -1,8 +1,11 @@
 import { Effect, Schema } from "effect"
 import { HttpClient } from "effect/unstable/http"
 import * as Tool from "./tool"
-import * as McpExa from "./mcp-exa"
+import * as McpWebSearch from "./mcp-websearch"
 import DESCRIPTION from "./websearch.txt"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { checksum } from "@opencode-ai/core/util/encode"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
 
 export const Parameters = Schema.Struct({
   query: Schema.String.annotate({ description: "Websearch query" }),
@@ -21,6 +24,81 @@ export const Parameters = Schema.Struct({
   }),
 })
 
+const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"])
+export type WebSearchProvider = Schema.Schema.Type<typeof WebSearchProviderSchema>
+
+export function selectWebSearchProvider(
+  sessionID: string,
+  flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL },
+): WebSearchProvider {
+  const override = process.env.OPENCODE_WEBSEARCH_PROVIDER
+  if (override === "exa" || override === "parallel") return override
+  if (flags.parallel) return "parallel"
+  if (flags.exa) return "exa"
+
+  return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel"
+}
+
+export function webSearchProviderLabel(provider: unknown) {
+  if (provider === "parallel") return "Parallel Web Search"
+  if (provider === "exa") return "Exa Web Search"
+  return "Web Search"
+}
+
+export function webSearchModelName(extra: Tool.Context["extra"]) {
+  const model = extra?.model
+  if (!model || typeof model !== "object") return undefined
+  const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined
+  const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined
+  const id = "id" in model && typeof model.id === "string" ? model.id : undefined
+  return (apiID ?? id)?.slice(0, 100)
+}
+
+function parallelAuthHeaders() {
+  const headers = { "User-Agent": `opencode/${InstallationVersion}` }
+  if (!process.env.PARALLEL_API_KEY) return headers
+  return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` }
+}
+
+function callProvider(
+  http: HttpClient.HttpClient,
+  provider: WebSearchProvider,
+  params: Schema.Schema.Type<typeof Parameters>,
+  ctx: Tool.Context,
+) {
+  if (provider === "parallel") {
+    return McpWebSearch.call(
+      http,
+      McpWebSearch.PARALLEL_URL,
+      "web_search",
+      McpWebSearch.ParallelSearchArgs,
+      {
+        objective: params.query,
+        search_queries: [params.query],
+        session_id: ctx.sessionID,
+        model_name: webSearchModelName(ctx.extra),
+      },
+      "25 seconds",
+      parallelAuthHeaders(),
+    )
+  }
+
+  return McpWebSearch.call(
+    http,
+    McpWebSearch.EXA_URL,
+    "web_search_exa",
+    McpWebSearch.SearchArgs,
+    {
+      query: params.query,
+      type: params.type || "auto",
+      numResults: params.numResults || 8,
+      livecrawl: params.livecrawl || "fallback",
+      contextMaxCharacters: params.contextMaxCharacters,
+    },
+    "25 seconds",
+  )
+}
+
 export const WebSearchTool = Tool.define(
   "websearch",
   Effect.gen(function* () {
@@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define(
       parameters: Parameters,
       execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
         Effect.gen(function* () {
+          const provider = selectWebSearchProvider(ctx.sessionID)
+          const title = webSearchProviderLabel(provider)
+          yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } })
+
           yield* ctx.ask({
             permission: "websearch",
             patterns: [params.query],
@@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define(
               livecrawl: params.livecrawl,
               type: params.type,
               contextMaxCharacters: params.contextMaxCharacters,
+              provider,
             },
           })
 
-          const result = yield* McpExa.call(
-            http,
-            "web_search_exa",
-            McpExa.SearchArgs,
-            {
-              query: params.query,
-              type: params.type || "auto",
-              numResults: params.numResults || 8,
-              livecrawl: params.livecrawl || "fallback",
-              contextMaxCharacters: params.contextMaxCharacters,
-            },
-            "25 seconds",
-          )
+          const result = yield* callProvider(http, provider, params, ctx)
 
           return {
             output: result ?? "No search results found. Please try a different query.",
-            title: `Web search: ${params.query}`,
-            metadata: {},
+            title: `${title}: ${params.query}`,
+            metadata: { provider },
           }
         }).pipe(Effect.orDie),
     }

+ 3 - 3
packages/opencode/src/tool/websearch.txt

@@ -1,12 +1,12 @@
-- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs
+- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs
 - Provides up-to-date information for current events and recent data
 - Supports configurable result counts and returns the content from the most relevant websites
 - Use this tool for accessing information beyond knowledge cutoff
 - Searches are performed automatically within a single API call
 
 Usage notes:
-  - Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)
-  - Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)
+  - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)
+  - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)
   - Configurable context length for optimal LLM integration
   - Domain filtering and advanced search options available
 

+ 92 - 0
packages/opencode/test/tool/websearch.test.ts

@@ -0,0 +1,92 @@
+import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { parseResponse } from "../../src/tool/mcp-websearch"
+import {
+  selectWebSearchProvider,
+  webSearchModelName,
+  webSearchProviderLabel,
+} from "../../src/tool/websearch"
+import { ProviderID } from "../../src/provider/schema"
+import { webSearchEnabled } from "../../src/tool/registry"
+
+const SESSION_ID = "ses_0196aabbccddeeff001122334455"
+
+describe("websearch provider", () => {
+  test("selects a stable provider per session", () => {
+    expect(selectWebSearchProvider(SESSION_ID)).toBe(selectWebSearchProvider(SESSION_ID))
+  })
+
+  test("supports an operational override", () => {
+    const original = process.env.OPENCODE_WEBSEARCH_PROVIDER
+
+    try {
+      process.env.OPENCODE_WEBSEARCH_PROVIDER = "parallel"
+      expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel")
+
+      process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa"
+      expect(selectWebSearchProvider(SESSION_ID)).toBe("exa")
+    } finally {
+      if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER
+      else process.env.OPENCODE_WEBSEARCH_PROVIDER = original
+    }
+  })
+
+  test("routes to Exa when the Exa flag is enabled", () => {
+    expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: false })).toBe("exa")
+  })
+
+  test("routes to Parallel when the Parallel flag is enabled", () => {
+    expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel")
+  })
+
+  test("is only enabled for opencode or explicit websearch provider flags", () => {
+    expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true)
+    expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false)
+    expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true)
+    expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true)
+  })
+
+  test("uses branded labels", () => {
+    expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search")
+    expect(webSearchProviderLabel("exa")).toBe("Exa Web Search")
+    expect(webSearchProviderLabel(undefined)).toBe("Web Search")
+  })
+
+  test("uses the provider API model id for Parallel analytics", () => {
+    expect(
+      webSearchModelName({
+        model: {
+          id: "claude-opus-4-7",
+          api: { id: "claude-opus-4.7" },
+        },
+      }),
+    ).toBe("claude-opus-4.7")
+  })
+})
+
+describe("websearch MCP response parser", () => {
+  const payload = JSON.stringify({
+    jsonrpc: "2.0",
+    id: 1,
+    result: {
+      content: [
+        {
+          type: "text",
+          text: "search results",
+        },
+      ],
+    },
+  })
+
+  test("parses plain JSON-RPC responses", async () => {
+    await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results")
+  })
+
+  test("parses SSE JSON-RPC responses", async () => {
+    await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results")
+  })
+
+  test("ignores non-JSON SSE data frames", async () => {
+    await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results")
+  })
+})

+ 31 - 6
packages/ui/src/components/message-part.tsx

@@ -317,7 +317,17 @@ function taskAgent(
   }
 }
 
-export function getToolInfo(tool: string, input: any = {}): ToolInfo {
+function webSearchProviderLabel(provider: unknown) {
+  if (provider === "parallel") return "Parallel Web Search"
+  if (provider === "exa") return "Exa Web Search"
+  return "Web Search"
+}
+
+export function getToolInfo(
+  tool: string,
+  input: any = {},
+  metadata: Record<string, unknown> | undefined = {},
+): ToolInfo {
   const i18n = useI18n()
   switch (tool) {
     case "read":
@@ -353,7 +363,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
     case "websearch":
       return {
         icon: "window-cursor",
-        title: i18n.t("ui.tool.websearch"),
+        title: webSearchProviderLabel(metadata?.provider),
         subtitle: input.query,
       }
     case "task": {
@@ -692,7 +702,11 @@ function isContextGroupTool(part: PartType): part is ToolPart {
 }
 
 function contextToolDetail(part: ToolPart): string | undefined {
-  const info = getToolInfo(part.tool, part.state.input ?? {})
+  const info = getToolInfo(
+    part.tool,
+    part.state.input ?? {},
+    "metadata" in part.state ? part.state.metadata : undefined,
+  )
   if (info.subtitle) return info.subtitle
   if (part.state.status === "error") return part.state.error
   if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
@@ -744,7 +758,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
       }
     }
     default: {
-      const info = getToolInfo(part.tool, input)
+      const info = getToolInfo(
+        part.tool,
+        input,
+        "metadata" in part.state ? part.state.metadata : undefined,
+      )
       return {
         title: info.title,
         subtitle: info.subtitle || contextToolDetail(part),
@@ -1224,6 +1242,7 @@ export interface ToolProps {
   input: Record<string, any>
   metadata: Record<string, any>
   tool: string
+  sessionID?: string
   output?: string
   status?: string
   hideDetails?: boolean
@@ -1346,6 +1365,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
                 <ToolErrorCard
                   tool={part().tool}
                   error={error()}
+                  title={
+                    part().tool === "websearch"
+                      ? webSearchProviderLabel(partMetadata().provider)
+                      : undefined
+                  }
                   defaultOpen={props.defaultOpen}
                   subtitle={taskSubtitle()}
                   href={taskHref()}
@@ -1358,6 +1382,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
               component={render()}
               input={input()}
               tool={part().tool}
+              sessionID={part().sessionID}
               metadata={partMetadata()}
               // @ts-expect-error
               output={part().state.output}
@@ -1681,19 +1706,19 @@ ToolRegistry.register({
 ToolRegistry.register({
   name: "websearch",
   render(props) {
-    const i18n = useI18n()
     const query = createMemo(() => {
       const value = props.input.query
       if (typeof value !== "string") return ""
       return value
     })
+    const title = createMemo(() => webSearchProviderLabel(props.metadata.provider))
 
     return (
       <BasicTool
         {...props}
         icon="window-cursor"
         trigger={{
-          title: i18n.t("ui.tool.websearch"),
+          title: title(),
           subtitle: query(),
           subtitleClass: "exa-tool-query",
         }}

+ 3 - 1
packages/ui/src/components/tool-error-card.tsx

@@ -10,6 +10,7 @@ import { useI18n } from "../context/i18n"
 export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant"> {
   tool: string
   error: string
+  title?: string
   defaultOpen?: boolean
   subtitle?: string
   href?: string
@@ -23,8 +24,9 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
   })
   const open = () => state.open
   const copied = () => state.copied
-  const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"])
+  const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"])
   const name = createMemo(() => {
+    if (split.title) return split.title
     const map: Record<string, string> = {
       read: "ui.tool.read",
       list: "ui.tool.list",