Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/forge_app/src/fmt/fmt_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ impl FormatContent for ToolCatalog {
ToolCatalog::Fetch(input) => {
Some(TitleFormat::debug("GET").sub_title(&input.url).into())
}
ToolCatalog::Websearch(input) => Some(
TitleFormat::debug("Web Search")
.sub_title(&input.query)
.into(),
),
ToolCatalog::Followup(input) => Some(
TitleFormat::debug("Follow-up")
.sub_title(&input.question)
Expand Down
1 change: 1 addition & 0 deletions crates/forge_app/src/fmt/fmt_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl FormatContent for ToolOperation {
| ToolOperation::CodebaseSearch { output: _ }
| ToolOperation::FsUndo { input: _, output: _ }
| ToolOperation::NetFetch { input: _, output: _ }
| ToolOperation::WebSearch { input: _, output: _ }
| ToolOperation::Shell { output: _ }
| ToolOperation::FollowUp { output: _ }
| ToolOperation::Skill { output: _ } => None,
Expand Down
172 changes: 170 additions & 2 deletions crates/forge_app/src/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use forge_config::ForgeConfig;
use forge_display::DiffFormat;
use forge_domain::{
CodebaseSearchResults, Environment, FSMultiPatch, FSPatch, FSRead, FSRemove, FSSearch, FSUndo,
FSWrite, FileOperation, LineNumbers, Metrics, NetFetch, PlanCreate, ToolKind,
FSWrite, FileOperation, LineNumbers, Metrics, NetFetch, PlanCreate, ToolKind, WebSearch,
};
use forge_template::Element;

Expand All @@ -19,7 +19,7 @@ use crate::truncation::{
use crate::utils::{compute_hash, format_display_path};
use crate::{
FsRemoveOutput, FsUndoOutput, FsWriteOutput, HttpResponse, PatchOutput, PlanCreateOutput,
ReadOutput, ResponseContext, SearchResult, ShellOutput,
ReadOutput, ResponseContext, SearchResult, ShellOutput, WebSearchResponse,
};

#[derive(Debug, Default, Setters)]
Expand Down Expand Up @@ -66,6 +66,10 @@ pub enum ToolOperation {
input: NetFetch,
output: HttpResponse,
},
WebSearch {
input: WebSearch,
output: WebSearchResponse,
},
Shell {
output: ShellOutput,
},
Expand Down Expand Up @@ -584,6 +588,73 @@ impl ToolOperation {

forge_domain::ToolOutput::text(elm)
}
ToolOperation::WebSearch { input, output } => {
let mut elm = Element::new("web_search_results")
.attr("query", &input.query)
.attr("engine", &output.engine)
.attr("result_count", output.organic_results.len());

elm = elm.attr_if_some("search_id", output.search_id.as_ref());

if let Some(answer_box) = &output.answer_box {
let mut answer_elm = Element::new("answer_box");
answer_elm = answer_elm.attr_if_some("title", answer_box.title.as_ref());
answer_elm = answer_elm.attr_if_some("link", answer_box.link.as_ref());
answer_elm = answer_elm.attr_if_some("answer", answer_box.answer.as_ref());
answer_elm = answer_elm.attr_if_some("snippet", answer_box.snippet.as_ref());
elm = elm.append(answer_elm);
}

if let Some(knowledge_graph) = &output.knowledge_graph {
let mut graph_elm = Element::new("knowledge_graph");
graph_elm = graph_elm.attr_if_some("title", knowledge_graph.title.as_ref());
graph_elm =
graph_elm.attr_if_some("type", knowledge_graph.entity_type.as_ref());
graph_elm =
graph_elm.attr_if_some("website", knowledge_graph.website.as_ref());
graph_elm = graph_elm.attr_if_some(
"description",
knowledge_graph.description.as_ref(),
);
elm = elm.append(graph_elm);
}

for result in &output.organic_results {
let mut result_elm = Element::new("organic_result")
.attr("title", &result.title)
.attr("link", &result.link);
let position = result.position.map(|value| value.to_string());
result_elm = result_elm.attr_if_some("position", position.as_ref());
result_elm =
result_elm.attr_if_some("displayed_link", result.displayed_link.as_ref());
result_elm = result_elm.attr_if_some("source", result.source.as_ref());
result_elm = result_elm.attr_if_some("snippet", result.snippet.as_ref());
elm = elm.append(result_elm);
}

for question in &output.related_questions {
let mut question_elm =
Element::new("related_question").attr("question", &question.question);
question_elm =
question_elm.attr_if_some("snippet", question.snippet.as_ref());
elm = elm.append(question_elm);
}

for query in &output.related_searches {
elm = elm.append(Element::new("related_search").attr("query", query));
}

for story in &output.top_stories {
let mut story_elm = Element::new("top_story").attr("title", &story.title);
story_elm = story_elm.attr_if_some("link", story.link.as_ref());
story_elm = story_elm.attr_if_some("source", story.source.as_ref());
story_elm = story_elm.attr_if_some("date", story.date.as_ref());
story_elm = story_elm.attr_if_some("snippet", story.snippet.as_ref());
elm = elm.append(story_elm);
}

forge_domain::ToolOutput::text(elm)
}
ToolOperation::Shell { output } => {
let mut parent_elem = Element::new("shell_output")
.attr("command", &output.output.command)
Expand Down Expand Up @@ -2378,6 +2449,103 @@ mod tests {
insta::assert_snapshot!(to_value(actual));
}

#[test]
fn test_web_search_success() {
let fixture = ToolOperation::WebSearch {
input: forge_domain::WebSearch::default()
.query("saturn facts")
.mode(forge_domain::WebSearchMode::Standard),
output: crate::WebSearchResponse {
query: "saturn facts".to_string(),
engine: "google".to_string(),
search_id: Some("search-123".to_string()),
answer_box: Some(crate::WebSearchAnswerBox {
title: Some("Saturn".to_string()),
answer: Some("A gas giant planet".to_string()),
snippet: None,
link: Some("https://example.com/saturn".to_string()),
}),
knowledge_graph: Some(crate::WebSearchKnowledgeGraph {
title: Some("Saturn".to_string()),
entity_type: Some("Planet".to_string()),
description: Some("The sixth planet from the Sun.".to_string()),
website: Some("https://science.nasa.gov/saturn/".to_string()),
}),
organic_results: vec![crate::WebSearchOrganicResult {
position: Some(1),
title: "Saturn Facts".to_string(),
link: "https://science.nasa.gov/saturn/facts/".to_string(),
displayed_link: Some("science.nasa.gov › saturn › facts".to_string()),
source: Some("NASA".to_string()),
snippet: Some("Saturn facts and figures.".to_string()),
}],
related_questions: vec![crate::WebSearchRelatedQuestion {
question: "What is Saturn made of?".to_string(),
snippet: Some("Mostly hydrogen and helium.".to_string()),
}],
related_searches: vec!["saturn rings".to_string()],
top_stories: vec![crate::WebSearchTopStory {
title: "New Saturn mission announced".to_string(),
link: Some("https://example.com/story".to_string()),
source: Some("Space News".to_string()),
date: Some("1 day ago".to_string()),
snippet: Some("A new mission could launch soon.".to_string()),
}],
},
};

let env = fixture_environment();
let config = fixture_config();

let actual = fixture.into_tool_output(
ToolKind::Websearch,
TempContentFiles::default(),
&env,
&config,
&mut Metrics::default(),
);

insta::assert_snapshot!(to_value(actual));
}

#[test]
fn test_web_search_light_minimal_output() {
let fixture = ToolOperation::WebSearch {
input: forge_domain::WebSearch::default().query("coffee"),
output: crate::WebSearchResponse {
query: "coffee".to_string(),
engine: "google_light".to_string(),
search_id: None,
answer_box: None,
knowledge_graph: None,
organic_results: vec![crate::WebSearchOrganicResult {
position: Some(1),
title: "Coffee - Wikipedia".to_string(),
link: "https://en.wikipedia.org/wiki/Coffee".to_string(),
displayed_link: Some("en.wikipedia.org › wiki › Coffee".to_string()),
source: None,
snippet: Some("Coffee is a brewed drink.".to_string()),
}],
related_questions: vec![],
related_searches: vec![],
top_stories: vec![],
},
};

let env = fixture_environment();
let config = fixture_config();

let actual = fixture.into_tool_output(
ToolKind::Websearch,
TempContentFiles::default(),
&env,
&config,
&mut Metrics::default(),
);

insta::assert_snapshot!(to_value(actual));
}

#[test]
fn test_shell_success() {
let fixture = ToolOperation::Shell {
Expand Down
77 changes: 77 additions & 0 deletions crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,60 @@ pub struct HttpResponse {
pub content_type: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebSearchResponse {
pub query: String,
pub engine: String,
pub search_id: Option<String>,
pub answer_box: Option<WebSearchAnswerBox>,
pub knowledge_graph: Option<WebSearchKnowledgeGraph>,
pub organic_results: Vec<WebSearchOrganicResult>,
pub related_questions: Vec<WebSearchRelatedQuestion>,
pub related_searches: Vec<String>,
pub top_stories: Vec<WebSearchTopStory>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebSearchAnswerBox {
pub title: Option<String>,
pub answer: Option<String>,
pub snippet: Option<String>,
pub link: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebSearchKnowledgeGraph {
pub title: Option<String>,
pub entity_type: Option<String>,
pub description: Option<String>,
pub website: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebSearchOrganicResult {
pub position: Option<u32>,
pub title: String,
pub link: String,
pub displayed_link: Option<String>,
pub source: Option<String>,
pub snippet: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebSearchRelatedQuestion {
pub question: String,
pub snippet: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WebSearchTopStory {
pub title: String,
pub link: Option<String>,
pub source: Option<String>,
pub date: Option<String>,
pub snippet: Option<String>,
}

#[derive(Debug)]
pub enum ResponseContext {
Parsed,
Expand Down Expand Up @@ -204,6 +258,7 @@ pub trait AppConfigService: Send + Sync {
/// all configuration changes; use [`forge_domain::ConfigOperation`]
/// variants to describe each mutation.
async fn update_config(&self, ops: Vec<forge_domain::ConfigOperation>) -> anyhow::Result<()>;

}

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

#[async_trait::async_trait]
pub trait WebSearchService: Send + Sync {
/// Searches the public web and returns a normalized structured result set.
async fn web_search(
&self,
params: forge_domain::WebSearch,
) -> anyhow::Result<WebSearchResponse>;
}

#[async_trait::async_trait]
pub trait ShellService: Send + Sync {
/// Executes a shell command and returns the output.
Expand Down Expand Up @@ -550,6 +614,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra {
type FollowUpService: FollowUpService;
type FsUndoService: FsUndoService;
type NetFetchService: NetFetchService;
type WebSearchService: WebSearchService;
type ShellService: ShellService;
type McpService: McpService;
type AuthService: AuthService;
Expand Down Expand Up @@ -577,6 +642,7 @@ pub trait Services: Send + Sync + 'static + Clone + EnvironmentInfra {
fn follow_up_service(&self) -> &Self::FollowUpService;
fn fs_undo_service(&self) -> &Self::FsUndoService;
fn net_fetch_service(&self) -> &Self::NetFetchService;
fn web_search_service(&self) -> &Self::WebSearchService;
fn shell_service(&self) -> &Self::ShellService;
fn mcp_service(&self) -> &Self::McpService;
fn custom_instructions_service(&self) -> &Self::CustomInstructionsService;
Expand Down Expand Up @@ -845,6 +911,16 @@ impl<I: Services> NetFetchService for I {
}
}

#[async_trait::async_trait]
impl<I: Services> WebSearchService for I {
async fn web_search(
&self,
params: forge_domain::WebSearch,
) -> anyhow::Result<WebSearchResponse> {
self.web_search_service().web_search(params).await
}
}

#[async_trait::async_trait]
impl<I: Services> ShellService for I {
async fn execute(
Expand Down Expand Up @@ -965,6 +1041,7 @@ impl<I: Services> AppConfigService for I {
async fn update_config(&self, ops: Vec<forge_domain::ConfigOperation>) -> anyhow::Result<()> {
self.config_service().update_config(ops).await
}

}

#[async_trait::async_trait]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
source: crates/forge_app/src/operation.rs
expression: to_value(actual)
---
<web_search_results
query="coffee"
engine="google_light"
result_count="1"
>
<organic_result
title="Coffee - Wikipedia"
link="https://en.wikipedia.org/wiki/Coffee"
position="1"
displayed_link="en.wikipedia.org › wiki › Coffee"
snippet="Coffee is a brewed drink."
>
</organic_result>
</web_search_results>
Loading
Loading