Skip to content

Commit ab7aa5a

Browse files
committed
add serpersearch tool
1 parent 474f764 commit ab7aa5a

5 files changed

Lines changed: 280 additions & 0 deletions

File tree

packages/opencode/src/cli/cmd/run.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { EditTool } from "../../tool/edit"
2121
import { WriteTool } from "../../tool/write"
2222
import { CodeSearchTool } from "../../tool/codesearch"
2323
import { WebSearchTool } from "../../tool/websearch"
24+
import { SerperSearchTool } from "../../tool/serpersearch"
2425
import { TaskTool } from "../../tool/task"
2526
import { SkillTool } from "../../tool/skill"
2627
import { BashTool } from "../../tool/bash"
@@ -166,6 +167,13 @@ function websearch(info: ToolProps<typeof WebSearchTool>) {
166167
})
167168
}
168169

170+
function serpersearch(info: ToolProps<typeof SerperSearchTool>) {
171+
inline({
172+
icon: "◈",
173+
title: `Google Search (${info.input.queries.length} ${info.input.queries.length === 1 ? "query" : "queries"})`,
174+
})
175+
}
176+
169177
function task(info: ToolProps<typeof TaskTool>) {
170178
const agent = Locale.titlecase(info.input.subagent_type)
171179
const desc = info.input.description
@@ -400,6 +408,7 @@ export const RunCommand = cmd({
400408
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
401409
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
402410
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
411+
if (part.tool === "serpersearch") return serpersearch(props<typeof SerperSearchTool>(part))
403412
if (part.tool === "task") return task(props<typeof TaskTool>(part))
404413
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
405414
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import z from "zod"
2121
import { Plugin } from "../plugin"
2222
import { WebSearchTool } from "./websearch"
2323
import { CodeSearchTool } from "./codesearch"
24+
import { SerperSearchTool } from "./serpersearch"
2425
import { Flag } from "@/flag/flag"
2526
import { Log } from "@/util/log"
2627
import { LspTool } from "./lsp"
@@ -110,6 +111,7 @@ export namespace ToolRegistry {
110111
// TodoReadTool,
111112
WebSearchTool,
112113
CodeSearchTool,
114+
SerperSearchTool,
113115
SkillTool,
114116
ApplyPatchTool,
115117
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, test, expect, beforeAll } from "bun:test"
2+
import z from "zod"
3+
4+
const SERPER_URL = "https://google.serper.dev/search"
5+
6+
let apiKey: string
7+
8+
async function fetchSerperSearch(query: string, signal?: AbortSignal): Promise<any> {
9+
const response = await fetch(SERPER_URL, {
10+
method: "POST",
11+
headers: {
12+
"X-API-KEY": apiKey,
13+
"Content-Type": "application/json",
14+
},
15+
body: JSON.stringify({ q: query }),
16+
signal,
17+
})
18+
19+
if (!response.ok) {
20+
const errorText = await response.text()
21+
throw new Error(`Serper search error (${response.status}): ${errorText}`)
22+
}
23+
24+
return response.json()
25+
}
26+
27+
const queriesSchema = z.array(z.string()).min(1).max(10)
28+
29+
describe("serpersearch", () => {
30+
beforeAll(() => {
31+
const key = process.env.SERPER_API_KEY
32+
if (!key) throw new Error("SERPER_API_KEY must be set")
33+
apiKey = key
34+
})
35+
36+
test(
37+
"single query returns non-empty results with URLs",
38+
async () => {
39+
const data = await fetchSerperSearch("capital of France")
40+
expect(data.organic).toBeDefined()
41+
expect(data.organic.length).toBeGreaterThan(0)
42+
expect(data.organic[0].link).toMatch(/https?:\/\//)
43+
},
44+
{ timeout: 30000 },
45+
)
46+
47+
test(
48+
"multiple queries return results for each",
49+
async () => {
50+
const queries = ["capital of France", "population of Japan"]
51+
const results = await Promise.all(queries.map((q) => fetchSerperSearch(q)))
52+
53+
expect(results).toHaveLength(2)
54+
for (const data of results) {
55+
expect(data.organic).toBeDefined()
56+
expect(data.organic.length).toBeGreaterThan(0)
57+
}
58+
},
59+
{ timeout: 30000 },
60+
)
61+
62+
test(
63+
"10 queries works",
64+
async () => {
65+
const queries = [
66+
"TypeScript generics",
67+
"Rust ownership",
68+
"Python asyncio",
69+
"Go goroutines",
70+
"Java streams",
71+
"C++ templates",
72+
"Kotlin coroutines",
73+
"Swift protocols",
74+
"Ruby blocks",
75+
"Elixir processes",
76+
]
77+
const results = await Promise.all(queries.map((q) => fetchSerperSearch(q)))
78+
79+
expect(results).toHaveLength(10)
80+
for (const data of results) {
81+
expect(data.organic).toBeDefined()
82+
expect(data.organic.length).toBeGreaterThan(0)
83+
}
84+
},
85+
{ timeout: 60000 },
86+
)
87+
88+
test("schema rejects >10 queries", () => {
89+
const elevenQueries = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"]
90+
const parsed = queriesSchema.safeParse(elevenQueries)
91+
expect(parsed.success).toBe(false)
92+
})
93+
94+
test("schema rejects empty array", () => {
95+
const parsed = queriesSchema.safeParse([])
96+
expect(parsed.success).toBe(false)
97+
})
98+
99+
test(
100+
"results contain organic data with title/URL/snippet",
101+
async () => {
102+
const data = await fetchSerperSearch("climate change effects 2025")
103+
expect(data.organic).toBeDefined()
104+
expect(data.organic.length).toBeGreaterThan(0)
105+
const first = data.organic[0]
106+
expect(first.title).toBeTruthy()
107+
expect(first.link).toMatch(/https?:\/\//)
108+
expect(first.snippet).toBeTruthy()
109+
},
110+
{ timeout: 30000 },
111+
)
112+
})
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import z from "zod"
2+
import { Tool } from "./tool"
3+
import DESCRIPTION from "./serpersearch.txt"
4+
import { abortAfterAny } from "../util/abort"
5+
6+
const NUM_RESULTS_PER_QUERY = 5
7+
8+
interface SerperResponse {
9+
knowledgeGraph?: {
10+
title?: string
11+
description?: string
12+
attributes?: Record<string, string>
13+
}
14+
organic?: Array<{
15+
title?: string
16+
link?: string
17+
snippet?: string
18+
}>
19+
peopleAlsoAsk?: Array<{
20+
question?: string
21+
snippet?: string
22+
}>
23+
}
24+
25+
function formatSerperResults(data: SerperResponse, query: string): string {
26+
const sections: string[] = []
27+
28+
const kg = data.knowledgeGraph
29+
if (kg) {
30+
const kgLines: string[] = []
31+
const title = kg.title?.trim()
32+
if (title) kgLines.push(`Knowledge Graph: ${title}`)
33+
const description = kg.description?.trim()
34+
if (description) kgLines.push(description)
35+
const attributes = kg.attributes ?? {}
36+
for (const [key, value] of Object.entries(attributes)) {
37+
const text = String(value).trim()
38+
if (text) kgLines.push(`${key}: ${text}`)
39+
}
40+
if (kgLines.length) sections.push(kgLines.join("\n"))
41+
}
42+
43+
for (const [index, result] of (data.organic ?? []).slice(0, NUM_RESULTS_PER_QUERY).entries()) {
44+
const title = result.title?.trim() || "Untitled"
45+
const lines = [`Result ${index}: ${title}`]
46+
const link = result.link?.trim()
47+
if (link) lines.push(`URL: ${link}`)
48+
const snippet = result.snippet?.trim()
49+
if (snippet) lines.push(snippet)
50+
sections.push(lines.join("\n"))
51+
}
52+
53+
const peopleAlsoAsk = data.peopleAlsoAsk ?? []
54+
if (peopleAlsoAsk.length) {
55+
const maxQuestions = Math.max(1, Math.min(3, peopleAlsoAsk.length))
56+
const questions: string[] = []
57+
for (const item of peopleAlsoAsk.slice(0, maxQuestions)) {
58+
const question = item.question?.trim()
59+
if (!question) continue
60+
let entry = `Q: ${question}`
61+
const answer = item.snippet?.trim()
62+
if (answer) entry += `\nA: ${answer}`
63+
questions.push(entry)
64+
}
65+
if (questions.length) sections.push("People Also Ask:\n" + questions.join("\n"))
66+
}
67+
68+
if (!sections.length) return `No results returned for query: ${query}`
69+
70+
return sections.join("\n\n---\n\n")
71+
}
72+
73+
async function fetchSerperSearch(query: string, apiKey: string, signal: AbortSignal): Promise<string> {
74+
const response = await fetch("https://google.serper.dev/search", {
75+
method: "POST",
76+
headers: {
77+
"X-API-KEY": apiKey,
78+
"Content-Type": "application/json",
79+
},
80+
body: JSON.stringify({ q: query }),
81+
signal,
82+
})
83+
84+
if (!response.ok) {
85+
const errorText = await response.text()
86+
throw new Error(`Serper search error (${response.status}): ${errorText}`)
87+
}
88+
89+
const data: SerperResponse = await response.json()
90+
return formatSerperResults(data, query)
91+
}
92+
93+
export const SerperSearchTool = Tool.define("serpersearch", async () => {
94+
return {
95+
get description() {
96+
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
97+
},
98+
parameters: z.object({
99+
queries: z
100+
.array(z.string())
101+
.min(1)
102+
.max(10)
103+
.describe(
104+
"Google search queries (up to 10). Use multiple queries to search different angles in parallel.",
105+
),
106+
}),
107+
async execute(params, ctx) {
108+
const apiKey = process.env.SERPER_API_KEY
109+
if (!apiKey) {
110+
throw new Error("SERPER_API_KEY environment variable is not set")
111+
}
112+
113+
await ctx.ask({
114+
permission: "serpersearch",
115+
patterns: params.queries,
116+
always: ["*"],
117+
metadata: { queries: params.queries },
118+
})
119+
120+
const { signal, clearTimeout } = abortAfterAny(45000, ctx.abort)
121+
122+
try {
123+
const results = await Promise.all(
124+
params.queries.slice(0, 10).map((query) => fetchSerperSearch(query, apiKey, signal)),
125+
)
126+
127+
clearTimeout()
128+
129+
const output = results
130+
.map((result, i) => {
131+
const query = params.queries[i]
132+
return `Results for query "${query}":\n\n${result}`
133+
})
134+
.join("\n\n---\n\n")
135+
136+
return {
137+
output,
138+
title: `Google Search (${params.queries.length} ${params.queries.length === 1 ? "query" : "queries"})`,
139+
metadata: {},
140+
}
141+
} catch (error) {
142+
clearTimeout()
143+
144+
if (error instanceof Error && error.name === "AbortError") {
145+
throw new Error("Search request timed out")
146+
}
147+
148+
throw error
149+
}
150+
},
151+
}
152+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- Search Google with up to 10 queries in parallel
2+
- Returns titles, URLs, snippets, and knowledge graph data from Google
3+
- Use multiple queries to search different angles simultaneously
4+
5+
Use the current year ({{year}}) when searching for recent information.

0 commit comments

Comments
 (0)