Skip to content

Commit 25291b5

Browse files
feat: add serpapi websearch tool
Co-Authored-By: ForgeCode <noreply@forgecode.dev>
1 parent fa1fd37 commit 25291b5

File tree

17 files changed

+1232
-4
lines changed

17 files changed

+1232
-4
lines changed

crates/forge_app/src/fmt/fmt_input.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ impl FormatContent for ToolCatalog {
116116
ToolCatalog::Fetch(input) => {
117117
Some(TitleFormat::debug("GET").sub_title(&input.url).into())
118118
}
119+
ToolCatalog::Websearch(input) => Some(
120+
TitleFormat::debug("Web Search")
121+
.sub_title(&input.query)
122+
.into(),
123+
),
119124
ToolCatalog::Followup(input) => Some(
120125
TitleFormat::debug("Follow-up")
121126
.sub_title(&input.question)

crates/forge_app/src/fmt/fmt_output.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ impl FormatContent for ToolOperation {
5050
| ToolOperation::CodebaseSearch { output: _ }
5151
| ToolOperation::FsUndo { input: _, output: _ }
5252
| ToolOperation::NetFetch { input: _, output: _ }
53+
| ToolOperation::WebSearch { input: _, output: _ }
5354
| ToolOperation::Shell { output: _ }
5455
| ToolOperation::FollowUp { output: _ }
5556
| ToolOperation::Skill { output: _ } => None,

crates/forge_app/src/operation.rs

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use forge_config::ForgeConfig;
88
use forge_display::DiffFormat;
99
use forge_domain::{
1010
CodebaseSearchResults, Environment, FSMultiPatch, FSPatch, FSRead, FSRemove, FSSearch, FSUndo,
11-
FSWrite, FileOperation, LineNumbers, Metrics, NetFetch, PlanCreate, ToolKind,
11+
FSWrite, FileOperation, LineNumbers, Metrics, NetFetch, PlanCreate, ToolKind, WebSearch,
1212
};
1313
use forge_template::Element;
1414

@@ -19,7 +19,7 @@ use crate::truncation::{
1919
use crate::utils::{compute_hash, format_display_path};
2020
use crate::{
2121
FsRemoveOutput, FsUndoOutput, FsWriteOutput, HttpResponse, PatchOutput, PlanCreateOutput,
22-
ReadOutput, ResponseContext, SearchResult, ShellOutput,
22+
ReadOutput, ResponseContext, SearchResult, ShellOutput, WebSearchResponse,
2323
};
2424

2525
#[derive(Debug, Default, Setters)]
@@ -66,6 +66,10 @@ pub enum ToolOperation {
6666
input: NetFetch,
6767
output: HttpResponse,
6868
},
69+
WebSearch {
70+
input: WebSearch,
71+
output: WebSearchResponse,
72+
},
6973
Shell {
7074
output: ShellOutput,
7175
},
@@ -584,6 +588,73 @@ impl ToolOperation {
584588

585589
forge_domain::ToolOutput::text(elm)
586590
}
591+
ToolOperation::WebSearch { input, output } => {
592+
let mut elm = Element::new("web_search_results")
593+
.attr("query", &input.query)
594+
.attr("engine", &output.engine)
595+
.attr("result_count", output.organic_results.len());
596+
597+
elm = elm.attr_if_some("search_id", output.search_id.as_ref());
598+
599+
if let Some(answer_box) = &output.answer_box {
600+
let mut answer_elm = Element::new("answer_box");
601+
answer_elm = answer_elm.attr_if_some("title", answer_box.title.as_ref());
602+
answer_elm = answer_elm.attr_if_some("link", answer_box.link.as_ref());
603+
answer_elm = answer_elm.attr_if_some("answer", answer_box.answer.as_ref());
604+
answer_elm = answer_elm.attr_if_some("snippet", answer_box.snippet.as_ref());
605+
elm = elm.append(answer_elm);
606+
}
607+
608+
if let Some(knowledge_graph) = &output.knowledge_graph {
609+
let mut graph_elm = Element::new("knowledge_graph");
610+
graph_elm = graph_elm.attr_if_some("title", knowledge_graph.title.as_ref());
611+
graph_elm =
612+
graph_elm.attr_if_some("type", knowledge_graph.entity_type.as_ref());
613+
graph_elm =
614+
graph_elm.attr_if_some("website", knowledge_graph.website.as_ref());
615+
graph_elm = graph_elm.attr_if_some(
616+
"description",
617+
knowledge_graph.description.as_ref(),
618+
);
619+
elm = elm.append(graph_elm);
620+
}
621+
622+
for result in &output.organic_results {
623+
let mut result_elm = Element::new("organic_result")
624+
.attr("title", &result.title)
625+
.attr("link", &result.link);
626+
let position = result.position.map(|value| value.to_string());
627+
result_elm = result_elm.attr_if_some("position", position.as_ref());
628+
result_elm =
629+
result_elm.attr_if_some("displayed_link", result.displayed_link.as_ref());
630+
result_elm = result_elm.attr_if_some("source", result.source.as_ref());
631+
result_elm = result_elm.attr_if_some("snippet", result.snippet.as_ref());
632+
elm = elm.append(result_elm);
633+
}
634+
635+
for question in &output.related_questions {
636+
let mut question_elm =
637+
Element::new("related_question").attr("question", &question.question);
638+
question_elm =
639+
question_elm.attr_if_some("snippet", question.snippet.as_ref());
640+
elm = elm.append(question_elm);
641+
}
642+
643+
for query in &output.related_searches {
644+
elm = elm.append(Element::new("related_search").attr("query", query));
645+
}
646+
647+
for story in &output.top_stories {
648+
let mut story_elm = Element::new("top_story").attr("title", &story.title);
649+
story_elm = story_elm.attr_if_some("link", story.link.as_ref());
650+
story_elm = story_elm.attr_if_some("source", story.source.as_ref());
651+
story_elm = story_elm.attr_if_some("date", story.date.as_ref());
652+
story_elm = story_elm.attr_if_some("snippet", story.snippet.as_ref());
653+
elm = elm.append(story_elm);
654+
}
655+
656+
forge_domain::ToolOutput::text(elm)
657+
}
587658
ToolOperation::Shell { output } => {
588659
let mut parent_elem = Element::new("shell_output")
589660
.attr("command", &output.output.command)
@@ -2378,6 +2449,103 @@ mod tests {
23782449
insta::assert_snapshot!(to_value(actual));
23792450
}
23802451

2452+
#[test]
2453+
fn test_web_search_success() {
2454+
let fixture = ToolOperation::WebSearch {
2455+
input: forge_domain::WebSearch::default()
2456+
.query("saturn facts")
2457+
.mode(forge_domain::WebSearchMode::Standard),
2458+
output: crate::WebSearchResponse {
2459+
query: "saturn facts".to_string(),
2460+
engine: "google".to_string(),
2461+
search_id: Some("search-123".to_string()),
2462+
answer_box: Some(crate::WebSearchAnswerBox {
2463+
title: Some("Saturn".to_string()),
2464+
answer: Some("A gas giant planet".to_string()),
2465+
snippet: None,
2466+
link: Some("https://example.com/saturn".to_string()),
2467+
}),
2468+
knowledge_graph: Some(crate::WebSearchKnowledgeGraph {
2469+
title: Some("Saturn".to_string()),
2470+
entity_type: Some("Planet".to_string()),
2471+
description: Some("The sixth planet from the Sun.".to_string()),
2472+
website: Some("https://science.nasa.gov/saturn/".to_string()),
2473+
}),
2474+
organic_results: vec![crate::WebSearchOrganicResult {
2475+
position: Some(1),
2476+
title: "Saturn Facts".to_string(),
2477+
link: "https://science.nasa.gov/saturn/facts/".to_string(),
2478+
displayed_link: Some("science.nasa.gov › saturn › facts".to_string()),
2479+
source: Some("NASA".to_string()),
2480+
snippet: Some("Saturn facts and figures.".to_string()),
2481+
}],
2482+
related_questions: vec![crate::WebSearchRelatedQuestion {
2483+
question: "What is Saturn made of?".to_string(),
2484+
snippet: Some("Mostly hydrogen and helium.".to_string()),
2485+
}],
2486+
related_searches: vec!["saturn rings".to_string()],
2487+
top_stories: vec![crate::WebSearchTopStory {
2488+
title: "New Saturn mission announced".to_string(),
2489+
link: Some("https://example.com/story".to_string()),
2490+
source: Some("Space News".to_string()),
2491+
date: Some("1 day ago".to_string()),
2492+
snippet: Some("A new mission could launch soon.".to_string()),
2493+
}],
2494+
},
2495+
};
2496+
2497+
let env = fixture_environment();
2498+
let config = fixture_config();
2499+
2500+
let actual = fixture.into_tool_output(
2501+
ToolKind::Websearch,
2502+
TempContentFiles::default(),
2503+
&env,
2504+
&config,
2505+
&mut Metrics::default(),
2506+
);
2507+
2508+
insta::assert_snapshot!(to_value(actual));
2509+
}
2510+
2511+
#[test]
2512+
fn test_web_search_light_minimal_output() {
2513+
let fixture = ToolOperation::WebSearch {
2514+
input: forge_domain::WebSearch::default().query("coffee"),
2515+
output: crate::WebSearchResponse {
2516+
query: "coffee".to_string(),
2517+
engine: "google_light".to_string(),
2518+
search_id: None,
2519+
answer_box: None,
2520+
knowledge_graph: None,
2521+
organic_results: vec![crate::WebSearchOrganicResult {
2522+
position: Some(1),
2523+
title: "Coffee - Wikipedia".to_string(),
2524+
link: "https://en.wikipedia.org/wiki/Coffee".to_string(),
2525+
displayed_link: Some("en.wikipedia.org › wiki › Coffee".to_string()),
2526+
source: None,
2527+
snippet: Some("Coffee is a brewed drink.".to_string()),
2528+
}],
2529+
related_questions: vec![],
2530+
related_searches: vec![],
2531+
top_stories: vec![],
2532+
},
2533+
};
2534+
2535+
let env = fixture_environment();
2536+
let config = fixture_config();
2537+
2538+
let actual = fixture.into_tool_output(
2539+
ToolKind::Websearch,
2540+
TempContentFiles::default(),
2541+
&env,
2542+
&config,
2543+
&mut Metrics::default(),
2544+
);
2545+
2546+
insta::assert_snapshot!(to_value(actual));
2547+
}
2548+
23812549
#[test]
23822550
fn test_shell_success() {
23832551
let fixture = ToolOperation::Shell {

crates/forge_app/src/services.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,60 @@ pub struct HttpResponse {
108108
pub content_type: String,
109109
}
110110

111+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
112+
pub struct WebSearchResponse {
113+
pub query: String,
114+
pub engine: String,
115+
pub search_id: Option<String>,
116+
pub answer_box: Option<WebSearchAnswerBox>,
117+
pub knowledge_graph: Option<WebSearchKnowledgeGraph>,
118+
pub organic_results: Vec<WebSearchOrganicResult>,
119+
pub related_questions: Vec<WebSearchRelatedQuestion>,
120+
pub related_searches: Vec<String>,
121+
pub top_stories: Vec<WebSearchTopStory>,
122+
}
123+
124+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
125+
pub struct WebSearchAnswerBox {
126+
pub title: Option<String>,
127+
pub answer: Option<String>,
128+
pub snippet: Option<String>,
129+
pub link: Option<String>,
130+
}
131+
132+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
133+
pub struct WebSearchKnowledgeGraph {
134+
pub title: Option<String>,
135+
pub entity_type: Option<String>,
136+
pub description: Option<String>,
137+
pub website: Option<String>,
138+
}
139+
140+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
141+
pub struct WebSearchOrganicResult {
142+
pub position: Option<u32>,
143+
pub title: String,
144+
pub link: String,
145+
pub displayed_link: Option<String>,
146+
pub source: Option<String>,
147+
pub snippet: Option<String>,
148+
}
149+
150+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
151+
pub struct WebSearchRelatedQuestion {
152+
pub question: String,
153+
pub snippet: Option<String>,
154+
}
155+
156+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
157+
pub struct WebSearchTopStory {
158+
pub title: String,
159+
pub link: Option<String>,
160+
pub source: Option<String>,
161+
pub date: Option<String>,
162+
pub snippet: Option<String>,
163+
}
164+
111165
#[derive(Debug)]
112166
pub enum ResponseContext {
113167
Parsed,
@@ -204,6 +258,7 @@ pub trait AppConfigService: Send + Sync {
204258
/// all configuration changes; use [`forge_domain::ConfigOperation`]
205259
/// variants to describe each mutation.
206260
async fn update_config(&self, ops: Vec<forge_domain::ConfigOperation>) -> anyhow::Result<()>;
261+
207262
}
208263

209264
#[async_trait::async_trait]
@@ -428,6 +483,15 @@ pub trait NetFetchService: Send + Sync {
428483
async fn fetch(&self, url: String, raw: Option<bool>) -> anyhow::Result<HttpResponse>;
429484
}
430485

486+
#[async_trait::async_trait]
487+
pub trait WebSearchService: Send + Sync {
488+
/// Searches the public web and returns a normalized structured result set.
489+
async fn web_search(
490+
&self,
491+
params: forge_domain::WebSearch,
492+
) -> anyhow::Result<WebSearchResponse>;
493+
}
494+
431495
#[async_trait::async_trait]
432496
pub trait ShellService: Send + Sync {
433497
/// Executes a shell command and returns the output.
@@ -550,6 +614,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra {
550614
type FollowUpService: FollowUpService;
551615
type FsUndoService: FsUndoService;
552616
type NetFetchService: NetFetchService;
617+
type WebSearchService: WebSearchService;
553618
type ShellService: ShellService;
554619
type McpService: McpService;
555620
type AuthService: AuthService;
@@ -577,6 +642,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra {
577642
fn follow_up_service(&self) -> &Self::FollowUpService;
578643
fn fs_undo_service(&self) -> &Self::FsUndoService;
579644
fn net_fetch_service(&self) -> &Self::NetFetchService;
645+
fn web_search_service(&self) -> &Self::WebSearchService;
580646
fn shell_service(&self) -> &Self::ShellService;
581647
fn mcp_service(&self) -> &Self::McpService;
582648
fn custom_instructions_service(&self) -> &Self::CustomInstructionsService;
@@ -845,6 +911,16 @@ impl<I: Services> NetFetchService for I {
845911
}
846912
}
847913

914+
#[async_trait::async_trait]
915+
impl<I: Services> WebSearchService for I {
916+
async fn web_search(
917+
&self,
918+
params: forge_domain::WebSearch,
919+
) -> anyhow::Result<WebSearchResponse> {
920+
self.web_search_service().web_search(params).await
921+
}
922+
}
923+
848924
#[async_trait::async_trait]
849925
impl<I: Services> ShellService for I {
850926
async fn execute(
@@ -965,6 +1041,7 @@ impl<I: Services> AppConfigService for I {
9651041
async fn update_config(&self, ops: Vec<forge_domain::ConfigOperation>) -> anyhow::Result<()> {
9661042
self.config_service().update_config(ops).await
9671043
}
1044+
9681045
}
9691046

9701047
#[async_trait::async_trait]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
source: crates/forge_app/src/operation.rs
3+
expression: to_value(actual)
4+
---
5+
<web_search_results
6+
query="coffee"
7+
engine="google_light"
8+
result_count="1"
9+
>
10+
<organic_result
11+
title="Coffee - Wikipedia"
12+
link="https://en.wikipedia.org/wiki/Coffee"
13+
position="1"
14+
displayed_link="en.wikipedia.org › wiki › Coffee"
15+
snippet="Coffee is a brewed drink."
16+
>
17+
</organic_result>
18+
</web_search_results>

0 commit comments

Comments
 (0)