Write a partial report instead of erroring out when research hits max turns #8
7 changed files with 254 additions and 31 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1941,6 +1941,7 @@ dependencies = [
|
||||||
"schemars 1.2.2",
|
"schemars 1.2.2",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use crate::progress::{REJECTED_EMOJI, REPORT_EMOJI, RESEARCH_EMOJI, Spinner};
|
use crate::progress::{REJECTED_EMOJI, REPORT_EMOJI, RESEARCH_EMOJI, SUMMARIZE_EMOJI, Spinner};
|
||||||
use crate::review::{self, Review};
|
use crate::review::{self, Review};
|
||||||
use crate::stream::write_text_stream;
|
use crate::stream::write_text_stream;
|
||||||
use crate::tools::{FetchPage, SearchWeb};
|
use crate::tools::{FetchPage, SearchWeb};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use rig::client::{AgentClientExt, Nothing};
|
use rig::client::{AgentClientExt, Nothing};
|
||||||
use rig::completion::message::{ToolResultContent, UserContent};
|
use rig::completion::message::{ToolResultContent, UserContent};
|
||||||
use rig::completion::{AssistantContent, Message, PromptError};
|
use rig::completion::{AssistantContent, Message, Prompt, PromptError};
|
||||||
use rig::providers::ollama;
|
use rig::providers::ollama;
|
||||||
use rig::streaming::StreamingPrompt;
|
use rig::streaming::StreamingPrompt;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
@ -23,7 +23,6 @@ const MAX_RESEARCH_TURNS: usize = 12;
|
||||||
/// the reviewer can never be satisfied with.
|
/// the reviewer can never be satisfied with.
|
||||||
const MAX_RESEARCH_ROUNDS: usize = 3;
|
const MAX_RESEARCH_ROUNDS: usize = 3;
|
||||||
|
|
||||||
|
|
||||||
/// Deep research agentic loop over local Gemma models: a tool-calling agent
|
/// Deep research agentic loop over local Gemma models: a tool-calling agent
|
||||||
/// gathers and cross-checks web evidence, a reviewer agent gates it, and a
|
/// gathers and cross-checks web evidence, a reviewer agent gates it, and a
|
||||||
/// writer agent turns approved findings into a structured report.
|
/// writer agent turns approved findings into a structured report.
|
||||||
|
|
@ -88,7 +87,10 @@ pub(crate) async fn research(topic: &str, show_progress: bool) -> anyhow::Result
|
||||||
}
|
}
|
||||||
|
|
||||||
if show_progress {
|
if show_progress {
|
||||||
eprintln!("{REJECTED_EMOJI} Findings rejected — revising for round {}...", round + 1);
|
eprintln!(
|
||||||
|
"{REJECTED_EMOJI} Findings rejected — revising for round {}...",
|
||||||
|
round + 1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
feedback = Some(review);
|
feedback = Some(review);
|
||||||
|
|
@ -171,6 +173,115 @@ fn truncate(text: &str, max_chars: usize) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chronological transcript of a partial research run, annotated with tool
|
||||||
|
/// calls (so a fetch's URL or a search's query stays attached to its
|
||||||
|
/// result) rather than just the bare result text `partial_findings_from_history`
|
||||||
|
/// collects — the summarizer agent below needs that context to attribute
|
||||||
|
/// facts to the right source.
|
||||||
|
fn annotated_transcript_from_history(chat_history: &[Message]) -> String {
|
||||||
|
chat_history
|
||||||
|
.iter()
|
||||||
|
.flat_map(transcript_lines)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcript_lines(message: &Message) -> Vec<String> {
|
||||||
|
const MAX_TOOL_RESULT_CHARS: usize = 2000;
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Message::Assistant { content, .. } => content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
AssistantContent::Text(text) => Some(format!("Note: {}", text.text())),
|
||||||
|
AssistantContent::ToolCall(call) => Some(format!(
|
||||||
|
"Called {}({})",
|
||||||
|
call.function.name, call.function.arguments
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Message::User { content } => content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
UserContent::ToolResult(result) => Some(result),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.flat_map(|result| result.content.iter())
|
||||||
|
.filter_map(|part| match part {
|
||||||
|
ToolResultContent::Text(text) => Some(format!(
|
||||||
|
"Result: {}",
|
||||||
|
truncate(text.text(), MAX_TOOL_RESULT_CHARS)
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Message::System { .. } => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agentic alternative to the plain programmatic extraction above: hands the
|
||||||
|
/// annotated transcript to a fresh model call and asks it to reconstruct the
|
||||||
|
/// same footnote-style findings dump the researcher would have written
|
||||||
|
/// itself, had it not run out of turns. This can dedupe repeated URLs and
|
||||||
|
/// restore correct `[n]` citation numbering in a way string concatenation
|
||||||
|
/// can't — but it's a model call like any other, so on an empty transcript
|
||||||
|
/// or a failure it falls back to `partial_findings_from_history` rather than
|
||||||
|
/// letting a second turn-budget problem take down the one recovery path
|
||||||
|
/// that's supposed to be bulletproof.
|
||||||
|
#[tracing::instrument(skip(client, chat_history), fields(gen_ai.agent.name = "history-summarizer"))]
|
||||||
|
async fn summarize_partial_history(
|
||||||
|
client: &ollama::Client,
|
||||||
|
topic: &str,
|
||||||
|
chat_history: &[Message],
|
||||||
|
show_progress: bool,
|
||||||
|
) -> String {
|
||||||
|
let transcript = annotated_transcript_from_history(chat_history);
|
||||||
|
|
||||||
|
if transcript.is_empty() {
|
||||||
|
tracing::warn!("no usable transcript to summarize; skipping summarizer pass");
|
||||||
|
return partial_findings_from_history(chat_history);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summarizer = client
|
||||||
|
.agent(WRITER_MODEL)
|
||||||
|
.name("history-summarizer")
|
||||||
|
.preamble(
|
||||||
|
"You are reconstructing research notes from a research session that was cut off \
|
||||||
|
before the researcher could write its own summary. You'll be given a raw transcript \
|
||||||
|
of tool calls (searches run, pages fetched), their results, and any interim comments \
|
||||||
|
the researcher made. Turn this into a footnote-style findings dump: write each fact \
|
||||||
|
the transcript actually supports, followed by a bracketed number like [1], then end \
|
||||||
|
with a 'Sources' section mapping each number to its exact URL, one per line. Reuse \
|
||||||
|
the same number for a URL that appears more than once. Do not invent facts beyond \
|
||||||
|
what the transcript shows, and note explicitly where it looks thin or cuts off \
|
||||||
|
mid-investigation.",
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let spinner = Spinner::start(
|
||||||
|
show_progress,
|
||||||
|
format!("{SUMMARIZE_EMOJI} Reconstructing partial findings..."),
|
||||||
|
);
|
||||||
|
let result = summarizer
|
||||||
|
.prompt(format!(
|
||||||
|
"Topic: {topic}\n\nPartial research transcript:\n{transcript}"
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
drop(spinner);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(findings) => {
|
||||||
|
tracing::info!(findings = %findings, "summarizer reconstructed partial findings");
|
||||||
|
findings
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "summarizer agent failed; falling back to programmatic extraction");
|
||||||
|
partial_findings_from_history(chat_history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
/// Wraps the tool-calling research loop in its own span so it's visible as a
|
||||||
/// single unit in traces, distinct from the writing and review phases and
|
/// single unit in traces, distinct from the writing and review phases and
|
||||||
/// nesting rig's own per-turn `chat`/`execute_tool` spans underneath it.
|
/// nesting rig's own per-turn `chat`/`execute_tool` spans underneath it.
|
||||||
|
|
@ -224,15 +335,23 @@ async fn gather_findings(
|
||||||
};
|
};
|
||||||
|
|
||||||
let spinner = Spinner::start(show_progress, format!("{RESEARCH_EMOJI} Researching..."));
|
let spinner = Spinner::start(show_progress, format!("{RESEARCH_EMOJI} Researching..."));
|
||||||
let run_result = researcher.runner(task).max_turns(MAX_RESEARCH_TURNS).run().await;
|
let run_result = researcher
|
||||||
|
.runner(task)
|
||||||
|
.max_turns(MAX_RESEARCH_TURNS)
|
||||||
|
.run()
|
||||||
|
.await;
|
||||||
drop(spinner);
|
drop(spinner);
|
||||||
|
|
||||||
let gathered = match run_result {
|
let gathered = match run_result {
|
||||||
Ok(response) => GatheredFindings { findings: response.output, incomplete: false },
|
Ok(response) => GatheredFindings {
|
||||||
|
findings: response.output,
|
||||||
|
incomplete: false,
|
||||||
|
},
|
||||||
Err(PromptError::MaxTurnsError { chat_history, .. }) => {
|
Err(PromptError::MaxTurnsError { chat_history, .. }) => {
|
||||||
tracing::warn!(round, "researcher hit its turn budget before concluding");
|
tracing::warn!(round, "researcher hit its turn budget before concluding");
|
||||||
GatheredFindings {
|
GatheredFindings {
|
||||||
findings: partial_findings_from_history(&chat_history),
|
findings: summarize_partial_history(client, topic, &chat_history, show_progress)
|
||||||
|
.await,
|
||||||
incomplete: true,
|
incomplete: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -271,10 +390,11 @@ async fn write_report(
|
||||||
format!(
|
format!(
|
||||||
"Topic: {topic}\n\n\
|
"Topic: {topic}\n\n\
|
||||||
The researcher ran out of turns before it could finish investigating, so these notes \
|
The researcher ran out of turns before it could finish investigating, so these notes \
|
||||||
are incomplete and may be a raw, unsorted mix of tool output rather than clean \
|
are an incomplete, best-effort reconstruction rather than the researcher's own \
|
||||||
footnoted findings. Write up what the notes actually support, note explicitly where \
|
conclusions — treat any citations already in them as tentative. Write up what the \
|
||||||
evidence is thin or missing, and open the report with a short callout that this \
|
notes actually support, note explicitly where evidence is thin or missing, and open \
|
||||||
research is inconclusive and further manual searching is needed.\n\n\
|
the report with a short callout that this research is inconclusive and further \
|
||||||
|
manual searching is needed.\n\n\
|
||||||
Research notes:\n{findings}"
|
Research notes:\n{findings}"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -296,7 +416,7 @@ async fn write_report(
|
||||||
// instruction above — the reader should never mistake a budget-exhausted
|
// instruction above — the reader should never mistake a budget-exhausted
|
||||||
// run for a complete one just because the model forgot to say so.
|
// run for a complete one just because the model forgot to say so.
|
||||||
let disclaimer = incomplete.then_some(
|
let disclaimer = incomplete.then_some(
|
||||||
"> **Disclaimer:** this research did not complete — the researcher exhausted its turn \
|
"> **Disclaimer:** incomplete research: the researcher exhausted its turn \
|
||||||
budget before it could finish investigating. The findings below are partial and may be \
|
budget before it could finish investigating. The findings below are partial and may be \
|
||||||
incomplete or unbalanced; treat them as a starting point and verify further manually.\n\n",
|
incomplete or unbalanced; treat them as a starting point and verify further manually.\n\n",
|
||||||
);
|
);
|
||||||
|
|
@ -360,10 +480,16 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_result_text_extracts_and_truncates() {
|
fn tool_result_text_extracts_and_truncates() {
|
||||||
let short = Message::tool_result("call-1", "short result");
|
let short = Message::tool_result("call-1", "short result");
|
||||||
assert_eq!(tool_result_text(&short, 100), vec!["short result".to_string()]);
|
assert_eq!(
|
||||||
|
tool_result_text(&short, 100),
|
||||||
|
vec!["short result".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
let long = Message::tool_result("call-2", "0123456789");
|
let long = Message::tool_result("call-2", "0123456789");
|
||||||
assert_eq!(tool_result_text(&long, 5), vec!["01234 [...truncated]".to_string()]);
|
assert_eq!(
|
||||||
|
tool_result_text(&long, 5),
|
||||||
|
vec!["01234 [...truncated]".to_string()]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -406,4 +532,74 @@ mod tests {
|
||||||
"Checking sources...\nResult A [1]\nCross-checking...\nResult B [2]"
|
"Checking sources...\nResult A [1]\nCross-checking...\nResult B [2]"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_lines_prefixes_assistant_notes() {
|
||||||
|
let message = Message::assistant("still checking this claim");
|
||||||
|
assert_eq!(
|
||||||
|
transcript_lines(&message),
|
||||||
|
vec!["Note: still checking this claim".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_lines_renders_tool_calls_with_their_arguments() {
|
||||||
|
let message = Message::Assistant {
|
||||||
|
id: None,
|
||||||
|
content: rig::OneOrMany::one(AssistantContent::tool_call(
|
||||||
|
"call-1",
|
||||||
|
"search_web",
|
||||||
|
serde_json::json!({ "query": "uruguay senior engineers" }),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
transcript_lines(&message),
|
||||||
|
vec!["Called search_web({\"query\":\"uruguay senior engineers\"})".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_lines_prefixes_and_truncates_tool_results() {
|
||||||
|
let short = Message::tool_result("call-1", "found via search [1]");
|
||||||
|
assert_eq!(
|
||||||
|
transcript_lines(&short),
|
||||||
|
vec!["Result: found via search [1]".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_lines_ignores_system_messages() {
|
||||||
|
assert!(transcript_lines(&Message::system("be thorough")).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotated_transcript_from_history_joins_calls_results_and_notes_in_order() {
|
||||||
|
let history = vec![
|
||||||
|
Message::Assistant {
|
||||||
|
id: None,
|
||||||
|
content: rig::OneOrMany::one(AssistantContent::tool_call(
|
||||||
|
"call-1",
|
||||||
|
"search_web",
|
||||||
|
serde_json::json!({ "query": "test" }),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Message::tool_result("call-1", "1. Example\n https://example.com\n snippet"),
|
||||||
|
Message::assistant("that source looks solid"),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
annotated_transcript_from_history(&history),
|
||||||
|
"Called search_web({\"query\":\"test\"})\n\
|
||||||
|
Result: 1. Example\n https://example.com\n snippet\n\
|
||||||
|
Note: that source looks solid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotated_transcript_from_history_is_empty_with_no_usable_content() {
|
||||||
|
assert_eq!(
|
||||||
|
annotated_transcript_from_history(&[Message::system("preamble")]),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
mod core;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod review;
|
mod review;
|
||||||
mod core;
|
|
||||||
mod stream;
|
mod stream;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub(crate) const FETCH_EMOJI: &str = "📄";
|
||||||
pub(crate) const REVIEW_EMOJI: &str = "🧐";
|
pub(crate) const REVIEW_EMOJI: &str = "🧐";
|
||||||
pub(crate) const REJECTED_EMOJI: &str = "❌";
|
pub(crate) const REJECTED_EMOJI: &str = "❌";
|
||||||
pub(crate) const REPORT_EMOJI: &str = "✍️";
|
pub(crate) const REPORT_EMOJI: &str = "✍️";
|
||||||
|
pub(crate) const SUMMARIZE_EMOJI: &str = "🧩";
|
||||||
|
|
||||||
/// The spinner currently on screen, if any — set by `Spinner::start` and
|
/// The spinner currently on screen, if any — set by `Spinner::start` and
|
||||||
/// cleared on drop. Tool implementations don't otherwise have a handle to
|
/// cleared on drop. Tool implementations don't otherwise have a handle to
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,14 @@ pub(crate) async fn review_findings(
|
||||||
.retries(2)
|
.retries(2)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let spinner = Spinner::start(show_progress, format!("{REVIEW_EMOJI} Reviewing findings..."));
|
let spinner = Spinner::start(
|
||||||
|
show_progress,
|
||||||
|
format!("{REVIEW_EMOJI} Reviewing findings..."),
|
||||||
|
);
|
||||||
let review = reviewer
|
let review = reviewer
|
||||||
.extract(format!("Topic: {topic}\n\nResearch findings to review:\n{findings}"))
|
.extract(format!(
|
||||||
|
"Topic: {topic}\n\nResearch findings to review:\n{findings}"
|
||||||
|
))
|
||||||
.await?;
|
.await?;
|
||||||
drop(spinner);
|
drop(spinner);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ mod tests {
|
||||||
let items = vec![text_item("Hello, "), text_item("world!")];
|
let items = vec![text_item("Hello, "), text_item("world!")];
|
||||||
let mut written = Vec::new();
|
let mut written = Vec::new();
|
||||||
|
|
||||||
let accumulated = write_text_stream(stream::iter(items), &mut written, Spinner::start(false, ""))
|
let accumulated =
|
||||||
|
write_text_stream(stream::iter(items), &mut written, Spinner::start(false, ""))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -81,7 +82,8 @@ mod tests {
|
||||||
let items = vec![text_item("kept"), final_item];
|
let items = vec![text_item("kept"), final_item];
|
||||||
let mut written = Vec::new();
|
let mut written = Vec::new();
|
||||||
|
|
||||||
let accumulated = write_text_stream(stream::iter(items), &mut written, Spinner::start(false, ""))
|
let accumulated =
|
||||||
|
write_text_stream(stream::iter(items), &mut written, Spinner::start(false, ""))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -97,7 +99,8 @@ mod tests {
|
||||||
let items = vec![text_item("kept"), Err(error)];
|
let items = vec![text_item("kept"), Err(error)];
|
||||||
let mut written = Vec::new();
|
let mut written = Vec::new();
|
||||||
|
|
||||||
let result = write_text_stream(stream::iter(items), &mut written, Spinner::start(false, "")).await;
|
let result =
|
||||||
|
write_text_stream(stream::iter(items), &mut written, Spinner::start(false, "")).await;
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert_eq!(String::from_utf8(written).unwrap(), "kept");
|
assert_eq!(String::from_utf8(written).unwrap(), "kept");
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ const MAX_PAGE_CHARS: usize = 6000;
|
||||||
/// Searches the web via DuckDuckGo's HTML endpoint (no API key required) and
|
/// Searches the web via DuckDuckGo's HTML endpoint (no API key required) and
|
||||||
/// returns each hit's title, URL, and snippet so the caller can decide which
|
/// returns each hit's title, URL, and snippet so the caller can decide which
|
||||||
/// pages are worth fetching in full.
|
/// pages are worth fetching in full.
|
||||||
#[rig::tool_macro(description = "Search the web for pages related to a query", required(query))]
|
#[rig::tool_macro(
|
||||||
|
description = "Search the web for pages related to a query",
|
||||||
|
required(query)
|
||||||
|
)]
|
||||||
pub(crate) async fn search_web(
|
pub(crate) async fn search_web(
|
||||||
/// The search query
|
/// The search query
|
||||||
query: String,
|
query: String,
|
||||||
|
|
@ -23,7 +26,10 @@ pub(crate) async fn search_web(
|
||||||
.await
|
.await
|
||||||
.map_err(ToolExecutionError::from_error)?;
|
.map_err(ToolExecutionError::from_error)?;
|
||||||
|
|
||||||
let body = response.text().await.map_err(ToolExecutionError::from_error)?;
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(ToolExecutionError::from_error)?;
|
||||||
let results = parse_search_results(&body);
|
let results = parse_search_results(&body);
|
||||||
|
|
||||||
if results.is_empty() {
|
if results.is_empty() {
|
||||||
|
|
@ -40,7 +46,10 @@ pub(crate) async fn search_web(
|
||||||
|
|
||||||
/// Fetches a page and returns its main text content, stripped of markup and
|
/// Fetches a page and returns its main text content, stripped of markup and
|
||||||
/// truncated so a single fetch can't blow out the model's context window.
|
/// truncated so a single fetch can't blow out the model's context window.
|
||||||
#[rig::tool_macro(description = "Fetch a web page and return its readable text content", required(url))]
|
#[rig::tool_macro(
|
||||||
|
description = "Fetch a web page and return its readable text content",
|
||||||
|
required(url)
|
||||||
|
)]
|
||||||
pub(crate) async fn fetch_page(
|
pub(crate) async fn fetch_page(
|
||||||
/// The URL to fetch
|
/// The URL to fetch
|
||||||
url: String,
|
url: String,
|
||||||
|
|
@ -54,7 +63,10 @@ pub(crate) async fn fetch_page(
|
||||||
.await
|
.await
|
||||||
.map_err(ToolExecutionError::from_error)?;
|
.map_err(ToolExecutionError::from_error)?;
|
||||||
|
|
||||||
let body = response.text().await.map_err(ToolExecutionError::from_error)?;
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(ToolExecutionError::from_error)?;
|
||||||
|
|
||||||
Ok(extract_readable_text(&body))
|
Ok(extract_readable_text(&body))
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +103,11 @@ fn parse_search_results(body: &str) -> Vec<SearchResult> {
|
||||||
if title.is_empty() || url.is_empty() {
|
if title.is_empty() || url.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(SearchResult { title, url, snippet })
|
Some(SearchResult {
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
snippet,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.take(MAX_SEARCH_RESULTS)
|
.take(MAX_SEARCH_RESULTS)
|
||||||
|
|
@ -118,7 +134,8 @@ fn resolve_ddg_redirect(href: &str) -> String {
|
||||||
|
|
||||||
fn extract_readable_text(html: &str) -> String {
|
fn extract_readable_text(html: &str) -> String {
|
||||||
let document = Html::parse_document(html);
|
let document = Html::parse_document(html);
|
||||||
let content_selector = Selector::parse("p, h1, h2, h3, h4, h5, li, td").expect("valid selector");
|
let content_selector =
|
||||||
|
Selector::parse("p, h1, h2, h3, h4, h5, li, td").expect("valid selector");
|
||||||
|
|
||||||
let mut text: String = document
|
let mut text: String = document
|
||||||
.select(&content_selector)
|
.select(&content_selector)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue