feat(websearch): add parallel provider rollout (#26227)
This commit is contained in:
@@ -77,6 +77,7 @@ export const Flag = {
|
|||||||
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||||
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||||
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
|
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_URL: process.env["OPENCODE_MODELS_URL"],
|
||||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { ReadTool } from "../../tool/read"
|
|||||||
import { WebFetchTool } from "../../tool/webfetch"
|
import { WebFetchTool } from "../../tool/webfetch"
|
||||||
import { EditTool } from "../../tool/edit"
|
import { EditTool } from "../../tool/edit"
|
||||||
import { WriteTool } from "../../tool/write"
|
import { WriteTool } from "../../tool/write"
|
||||||
import { WebSearchTool } from "../../tool/websearch"
|
import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch"
|
||||||
import { TaskTool } from "../../tool/task"
|
import { TaskTool } from "../../tool/task"
|
||||||
import { SkillTool } from "../../tool/skill"
|
import { SkillTool } from "../../tool/skill"
|
||||||
import { ShellTool } from "../../tool/shell"
|
import { ShellTool } from "../../tool/shell"
|
||||||
@@ -148,7 +148,7 @@ function edit(info: ToolProps<typeof EditTool>) {
|
|||||||
function websearch(info: ToolProps<typeof WebSearchTool>) {
|
function websearch(info: ToolProps<typeof WebSearchTool>) {
|
||||||
inline({
|
inline({
|
||||||
icon: "◈",
|
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({
|
inline({
|
||||||
icon: "✗",
|
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)
|
UI.error(part.state.error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/c
|
|||||||
import { useBindings } from "../../keymap"
|
import { useBindings } from "../../keymap"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
|
import { webSearchProviderLabel } from "@/tool/websearch"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import type {
|
import type {
|
||||||
@@ -89,6 +90,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
|||||||
<Match when={message.type === "assistant"}>
|
<Match when={message.type === "assistant"}>
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
message={message as SessionMessageAssistant}
|
message={message as SessionMessageAssistant}
|
||||||
|
sessionID={props.sessionID}
|
||||||
last={lastAssistant()?.id === message.id}
|
last={lastAssistant()?.id === message.id}
|
||||||
syntax={syntax()}
|
syntax={syntax()}
|
||||||
subtleSyntax={subtleSyntax()}
|
subtleSyntax={subtleSyntax()}
|
||||||
@@ -286,6 +288,7 @@ function UnknownMessage(props: { message: SessionMessage }) {
|
|||||||
|
|
||||||
function AssistantMessage(props: {
|
function AssistantMessage(props: {
|
||||||
message: SessionMessageAssistant
|
message: SessionMessageAssistant
|
||||||
|
sessionID: string
|
||||||
last: boolean
|
last: boolean
|
||||||
syntax: SyntaxStyle
|
syntax: SyntaxStyle
|
||||||
subtleSyntax: SyntaxStyle
|
subtleSyntax: SyntaxStyle
|
||||||
@@ -314,7 +317,7 @@ function AssistantMessage(props: {
|
|||||||
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
|
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={part.type === "tool"}>
|
<Match when={part.type === "tool"}>
|
||||||
<AssistantTool part={part as SessionMessageAssistantTool} />
|
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</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 input = createMemo(() => toolInputRecord(props.part.state.input))
|
||||||
const toolprops = {
|
const toolprops = {
|
||||||
get input() {
|
get input() {
|
||||||
@@ -412,6 +415,7 @@ function AssistantTool(props: { part: SessionMessageAssistantTool }) {
|
|||||||
get output() {
|
get output() {
|
||||||
return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
|
return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
|
||||||
},
|
},
|
||||||
|
sessionID: props.sessionID,
|
||||||
part: props.part,
|
part: props.part,
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -469,6 +473,7 @@ type ToolProps = {
|
|||||||
input: Record<string, unknown>
|
input: Record<string, unknown>
|
||||||
metadata: Record<string, unknown>
|
metadata: Record<string, unknown>
|
||||||
output?: string
|
output?: string
|
||||||
|
sessionID: string
|
||||||
part: SessionMessageAssistantTool
|
part: SessionMessageAssistantTool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,9 +780,10 @@ function CodeSearch(props: ToolProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WebSearch(props: ToolProps) {
|
function WebSearch(props: ToolProps) {
|
||||||
|
const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
|
<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>
|
<Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import type { GrepTool } from "@/tool/grep"
|
|||||||
import type { EditTool } from "@/tool/edit"
|
import type { EditTool } from "@/tool/edit"
|
||||||
import type { ApplyPatchTool } from "@/tool/apply_patch"
|
import type { ApplyPatchTool } from "@/tool/apply_patch"
|
||||||
import type { WebFetchTool } from "@/tool/webfetch"
|
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 { TaskTool } from "@/tool/task"
|
||||||
import type { QuestionTool } from "@/tool/question"
|
import type { QuestionTool } from "@/tool/question"
|
||||||
import type { SkillTool } from "@/tool/skill"
|
import type { SkillTool } from "@/tool/skill"
|
||||||
@@ -1933,10 +1933,11 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
|
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
|
||||||
const metadata = props.metadata as { numResults?: number }
|
const metadata = props.metadata as { numResults?: number; provider?: unknown }
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
|
<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>
|
</InlineTool>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
|||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { ShellID } from "@/tool/shell/id"
|
import { ShellID } from "@/tool/shell/id"
|
||||||
|
import { webSearchProviderLabel } from "@/tool/websearch"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
@@ -338,7 +339,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
const query = typeof data.query === "string" ? data.query : ""
|
const query = typeof data.query === "string" ? data.query : ""
|
||||||
return {
|
return {
|
||||||
icon: "◈",
|
icon: "◈",
|
||||||
title: `Exa Web Search "${query}"`,
|
title: `${webSearchProviderLabel(data.provider)} "${query}"`,
|
||||||
body: (
|
body: (
|
||||||
<Show when={query}>
|
<Show when={query}>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
|
|||||||
@@ -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.
|
- **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 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.
|
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
@@ -1,9 +1,10 @@
|
|||||||
import { Duration, Effect, Schema } from "effect"
|
import { Duration, Effect, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
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?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}`
|
||||||
: "https://mcp.exa.ai/mcp"
|
: "https://mcp.exa.ai/mcp"
|
||||||
|
export const PARALLEL_URL = "https://search.parallel.ai/mcp"
|
||||||
|
|
||||||
const McpResult = Schema.Struct({
|
const McpResult = Schema.Struct({
|
||||||
result: Schema.Struct({
|
result: Schema.Struct({
|
||||||
@@ -18,11 +19,23 @@ const McpResult = Schema.Struct({
|
|||||||
|
|
||||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
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")) {
|
for (const line of body.split("\n")) {
|
||||||
if (!line.startsWith("data: ")) continue
|
if (!line.startsWith("data: ")) continue
|
||||||
const data = yield* decode(line.substring(6))
|
const data = yield* parsePayload(line.substring(6))
|
||||||
if (data.result.content[0]?.text) return data.result.content[0].text
|
if (data) return data
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
@@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({
|
|||||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
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>) =>
|
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
jsonrpc: Schema.Literal("2.0"),
|
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>(
|
export const call = <F extends Schema.Struct.Fields>(
|
||||||
http: HttpClient.HttpClient,
|
http: HttpClient.HttpClient,
|
||||||
|
url: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
args: Schema.Struct<F>,
|
args: Schema.Struct<F>,
|
||||||
value: Schema.Struct.Type<F>,
|
value: Schema.Struct.Type<F>,
|
||||||
timeout: Duration.Input,
|
timeout: Duration.Input,
|
||||||
|
headers?: Record<string, string>,
|
||||||
) =>
|
) =>
|
||||||
Effect.gen(function* () {
|
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.accept("application/json, text/event-stream"),
|
||||||
|
HttpClientRequest.setHeaders(headers ?? {}),
|
||||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||||
jsonrpc: "2.0" as const,
|
jsonrpc: "2.0" as const,
|
||||||
id: 1 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`)) }),
|
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
|
||||||
)
|
)
|
||||||
const body = yield* response.text
|
const body = yield* response.text
|
||||||
return yield* parseSse(body)
|
return yield* parseResponse(body)
|
||||||
})
|
})
|
||||||
@@ -49,6 +49,13 @@ import { Permission } from "@/permission"
|
|||||||
|
|
||||||
const log = Log.create({ service: "tool.registry" })
|
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 TaskDef = Tool.InferDef<typeof TaskTool>
|
||||||
type ReadDef = Tool.InferDef<typeof ReadTool>
|
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 tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||||
const filtered = (yield* all()).filter((tool) => {
|
const filtered = (yield* all()).filter((tool) => {
|
||||||
if (tool.id === WebSearchTool.id) {
|
if (tool.id === WebSearchTool.id) {
|
||||||
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
return webSearchEnabled(input.providerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const usePatch =
|
const usePatch =
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import * as McpExa from "./mcp-exa"
|
import * as McpWebSearch from "./mcp-websearch"
|
||||||
import DESCRIPTION from "./websearch.txt"
|
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({
|
export const Parameters = Schema.Struct({
|
||||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
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(
|
export const WebSearchTool = Tool.define(
|
||||||
"websearch",
|
"websearch",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define(
|
|||||||
parameters: Parameters,
|
parameters: Parameters,
|
||||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const provider = selectWebSearchProvider(ctx.sessionID)
|
||||||
|
const title = webSearchProviderLabel(provider)
|
||||||
|
yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } })
|
||||||
|
|
||||||
yield* ctx.ask({
|
yield* ctx.ask({
|
||||||
permission: "websearch",
|
permission: "websearch",
|
||||||
patterns: [params.query],
|
patterns: [params.query],
|
||||||
@@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define(
|
|||||||
livecrawl: params.livecrawl,
|
livecrawl: params.livecrawl,
|
||||||
type: params.type,
|
type: params.type,
|
||||||
contextMaxCharacters: params.contextMaxCharacters,
|
contextMaxCharacters: params.contextMaxCharacters,
|
||||||
|
provider,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = yield* McpExa.call(
|
const result = yield* callProvider(http, provider, params, ctx)
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
output: result ?? "No search results found. Please try a different query.",
|
output: result ?? "No search results found. Please try a different query.",
|
||||||
title: `Web search: ${params.query}`,
|
title: `${title}: ${params.query}`,
|
||||||
metadata: {},
|
metadata: { provider },
|
||||||
}
|
}
|
||||||
}).pipe(Effect.orDie),
|
}).pipe(Effect.orDie),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
- Provides up-to-date information for current events and recent data
|
||||||
- Supports configurable result counts and returns the content from the most relevant websites
|
- Supports configurable result counts and returns the content from the most relevant websites
|
||||||
- Use this tool for accessing information beyond knowledge cutoff
|
- Use this tool for accessing information beyond knowledge cutoff
|
||||||
- Searches are performed automatically within a single API call
|
- Searches are performed automatically within a single API call
|
||||||
|
|
||||||
Usage notes:
|
Usage notes:
|
||||||
- Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)
|
- Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)
|
||||||
- Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)
|
- Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)
|
||||||
- Configurable context length for optimal LLM integration
|
- Configurable context length for optimal LLM integration
|
||||||
- Domain filtering and advanced search options available
|
- Domain filtering and advanced search options available
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
const i18n = useI18n()
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "read":
|
case "read":
|
||||||
@@ -353,7 +363,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
|||||||
case "websearch":
|
case "websearch":
|
||||||
return {
|
return {
|
||||||
icon: "window-cursor",
|
icon: "window-cursor",
|
||||||
title: i18n.t("ui.tool.websearch"),
|
title: webSearchProviderLabel(metadata?.provider),
|
||||||
subtitle: input.query,
|
subtitle: input.query,
|
||||||
}
|
}
|
||||||
case "task": {
|
case "task": {
|
||||||
@@ -692,7 +702,11 @@ function isContextGroupTool(part: PartType): part is ToolPart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function contextToolDetail(part: ToolPart): string | undefined {
|
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 (info.subtitle) return info.subtitle
|
||||||
if (part.state.status === "error") return part.state.error
|
if (part.state.status === "error") return part.state.error
|
||||||
if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
|
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: {
|
default: {
|
||||||
const info = getToolInfo(part.tool, input)
|
const info = getToolInfo(
|
||||||
|
part.tool,
|
||||||
|
input,
|
||||||
|
"metadata" in part.state ? part.state.metadata : undefined,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
title: info.title,
|
title: info.title,
|
||||||
subtitle: info.subtitle || contextToolDetail(part),
|
subtitle: info.subtitle || contextToolDetail(part),
|
||||||
@@ -1224,6 +1242,7 @@ export interface ToolProps {
|
|||||||
input: Record<string, any>
|
input: Record<string, any>
|
||||||
metadata: Record<string, any>
|
metadata: Record<string, any>
|
||||||
tool: string
|
tool: string
|
||||||
|
sessionID?: string
|
||||||
output?: string
|
output?: string
|
||||||
status?: string
|
status?: string
|
||||||
hideDetails?: boolean
|
hideDetails?: boolean
|
||||||
@@ -1346,6 +1365,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
<ToolErrorCard
|
<ToolErrorCard
|
||||||
tool={part().tool}
|
tool={part().tool}
|
||||||
error={error()}
|
error={error()}
|
||||||
|
title={
|
||||||
|
part().tool === "websearch"
|
||||||
|
? webSearchProviderLabel(partMetadata().provider)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
defaultOpen={props.defaultOpen}
|
defaultOpen={props.defaultOpen}
|
||||||
subtitle={taskSubtitle()}
|
subtitle={taskSubtitle()}
|
||||||
href={taskHref()}
|
href={taskHref()}
|
||||||
@@ -1358,6 +1382,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
component={render()}
|
component={render()}
|
||||||
input={input()}
|
input={input()}
|
||||||
tool={part().tool}
|
tool={part().tool}
|
||||||
|
sessionID={part().sessionID}
|
||||||
metadata={partMetadata()}
|
metadata={partMetadata()}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
output={part().state.output}
|
output={part().state.output}
|
||||||
@@ -1681,19 +1706,19 @@ ToolRegistry.register({
|
|||||||
ToolRegistry.register({
|
ToolRegistry.register({
|
||||||
name: "websearch",
|
name: "websearch",
|
||||||
render(props) {
|
render(props) {
|
||||||
const i18n = useI18n()
|
|
||||||
const query = createMemo(() => {
|
const query = createMemo(() => {
|
||||||
const value = props.input.query
|
const value = props.input.query
|
||||||
if (typeof value !== "string") return ""
|
if (typeof value !== "string") return ""
|
||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
|
const title = createMemo(() => webSearchProviderLabel(props.metadata.provider))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BasicTool
|
<BasicTool
|
||||||
{...props}
|
{...props}
|
||||||
icon="window-cursor"
|
icon="window-cursor"
|
||||||
trigger={{
|
trigger={{
|
||||||
title: i18n.t("ui.tool.websearch"),
|
title: title(),
|
||||||
subtitle: query(),
|
subtitle: query(),
|
||||||
subtitleClass: "exa-tool-query",
|
subtitleClass: "exa-tool-query",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useI18n } from "../context/i18n"
|
|||||||
export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant"> {
|
export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant"> {
|
||||||
tool: string
|
tool: string
|
||||||
error: string
|
error: string
|
||||||
|
title?: string
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
href?: string
|
href?: string
|
||||||
@@ -23,8 +24,9 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
|||||||
})
|
})
|
||||||
const open = () => state.open
|
const open = () => state.open
|
||||||
const copied = () => state.copied
|
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(() => {
|
const name = createMemo(() => {
|
||||||
|
if (split.title) return split.title
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
read: "ui.tool.read",
|
read: "ui.tool.read",
|
||||||
list: "ui.tool.list",
|
list: "ui.tool.list",
|
||||||
|
|||||||
Reference in New Issue
Block a user