fix: use Native structured output for reviewer instead of forced tool call
Some checks failed
CI / test (pull_request) Failing after 56s
Some checks failed
CI / test (pull_request) Failing after 56s
Ollama doesn't support tool_choice, so rig's tool-forced extractor (client.extractor::<Review>()) never actually compelled the reviewer's small local model to call the submit tool -- it just answered in prose, and extraction exhausted its retries with "No data extracted" every time. Switch to rig's typed-prompt API (agent.prompt_typed::<Review>()), which uses Native output mode: Ollama's own `format` JSON-schema constraint on the completion request, honored regardless of tool-calling ability. Verified against a live local Ollama instance.
This commit is contained in:
parent
cd580f3bec
commit
9e7291214b
3 changed files with 44 additions and 17 deletions
|
|
@ -27,20 +27,17 @@ pub(crate) async fn research(topic: &str, show_progress: bool) -> anyhow::Result
|
||||||
findings = gathered.findings;
|
findings = gathered.findings;
|
||||||
incomplete = gathered.incomplete;
|
incomplete = gathered.incomplete;
|
||||||
|
|
||||||
// The researcher ran out of turns mid-investigation rather than
|
// Prevent repeat iterations where researcher hits same dead end.
|
||||||
// concluding on its own — another round would just repeat the same
|
|
||||||
// dead end, so stop and write up whatever was gathered.
|
|
||||||
if incomplete {
|
if incomplete {
|
||||||
tracing::info!(round, "researcher exhausted its turn budget");
|
tracing::info!(round, "researcher exhausted its turn budget");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let review = review::review_findings(&client, topic, &findings, show_progress).await?;
|
let review = review::review_findings(&client, topic, &findings, show_progress).await?;
|
||||||
let approved = review.approved;
|
|
||||||
|
|
||||||
tracing::info!(round, approved, "review verdict");
|
tracing::info!(round, review.approved, "review verdict");
|
||||||
|
|
||||||
if approved || round == MAX_RESEARCH_ROUNDS {
|
if review.approved || round == MAX_RESEARCH_ROUNDS {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use rig::client::AgentClientExt;
|
use rig::client::AgentClientExt;
|
||||||
|
use rig::completion::TypedPrompt;
|
||||||
use rig::providers::ollama;
|
use rig::providers::ollama;
|
||||||
use rig::schemars::JsonSchema;
|
use rig::schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -25,10 +26,18 @@ pub(crate) struct Review {
|
||||||
pub(crate) gaps: String,
|
pub(crate) gaps: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uses rig's typed extractor — a forced tool call into a `submit(Review)`
|
/// Retries for a review that fails to come back as valid structured output
|
||||||
/// schema — rather than parsing free-text output, so the verdict and its
|
/// (e.g. a transient network error), on top of the initial attempt.
|
||||||
/// two feedback fields always come back structured instead of relying on
|
const REVIEW_RETRIES: usize = 2;
|
||||||
/// scanning prose for a trailing yes/no.
|
|
||||||
|
/// Uses rig's typed-prompt API — `Native` structured output constraining the
|
||||||
|
/// model's own reply to the `Review` schema — rather than a forced tool call
|
||||||
|
/// or parsing free-text output. A forced tool call needs `tool_choice`
|
||||||
|
/// support, which Ollama doesn't have: the reviewer's small local model would
|
||||||
|
/// just answer in prose and never call the tool, so extraction only ever
|
||||||
|
/// exhausted its retries and errored out. `Native` mode instead uses Ollama's
|
||||||
|
/// own `format` JSON-schema constraint, which is honored regardless of
|
||||||
|
/// tool-calling ability.
|
||||||
#[tracing::instrument(skip(client, findings), fields(gen_ai.agent.name = "reviewer"))]
|
#[tracing::instrument(skip(client, findings), fields(gen_ai.agent.name = "reviewer"))]
|
||||||
pub(crate) async fn review_findings(
|
pub(crate) async fn review_findings(
|
||||||
client: &ollama::Client,
|
client: &ollama::Client,
|
||||||
|
|
@ -37,7 +46,7 @@ pub(crate) async fn review_findings(
|
||||||
show_progress: bool,
|
show_progress: bool,
|
||||||
) -> anyhow::Result<Review> {
|
) -> anyhow::Result<Review> {
|
||||||
let reviewer = client
|
let reviewer = client
|
||||||
.extractor::<Review>(REVIEWER_MODEL)
|
.agent(REVIEWER_MODEL)
|
||||||
.preamble(
|
.preamble(
|
||||||
"You are a skeptical fact-checker reviewing another researcher's notes before they \
|
"You are a skeptical fact-checker reviewing another researcher's notes before they \
|
||||||
get turned into a report. Approve only if every conclusion in the findings is \
|
get turned into a report. Approve only if every conclusion in the findings is \
|
||||||
|
|
@ -47,20 +56,40 @@ pub(crate) async fn review_findings(
|
||||||
Always separate the solid, well-supported findings from the gaps so a follow-up \
|
Always separate the solid, well-supported findings from the gaps so a follow-up \
|
||||||
research pass knows what to keep and what to dig into further.",
|
research pass knows what to keep and what to dig into further.",
|
||||||
)
|
)
|
||||||
.retries(2)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let spinner = Spinner::start(
|
let spinner = Spinner::start(
|
||||||
show_progress,
|
show_progress,
|
||||||
format!("{REVIEW_EMOJI} Reviewing findings..."),
|
format!("{REVIEW_EMOJI} Reviewing findings..."),
|
||||||
);
|
);
|
||||||
let review = reviewer
|
let prompt = format!("Topic: {topic}\n\nResearch findings to review:\n{findings}");
|
||||||
.extract(format!(
|
|
||||||
"Topic: {topic}\n\nResearch findings to review:\n{findings}"
|
let mut last_error = None;
|
||||||
))
|
let mut review = None;
|
||||||
.await?;
|
for attempt in 0..=REVIEW_RETRIES {
|
||||||
|
match reviewer.prompt_typed::<Review>(prompt.clone()).await {
|
||||||
|
Ok(r) => {
|
||||||
|
review = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let suffix = if attempt < REVIEW_RETRIES {
|
||||||
|
" Retrying..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
tracing::warn!("Attempt {attempt} to extract JSON failed: {e:?}.{suffix}");
|
||||||
|
last_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
drop(spinner);
|
drop(spinner);
|
||||||
|
|
||||||
|
let review = match review {
|
||||||
|
Some(review) => review,
|
||||||
|
None => return Err(last_error.expect("loop always sets last_error on failure").into()),
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!(approved = review.approved, gaps = %review.gaps, "review complete");
|
tracing::info!(approved = review.approved, gaps = %review.gaps, "review complete");
|
||||||
|
|
||||||
Ok(review)
|
Ok(review)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ pub(crate) async fn search_web(
|
||||||
) -> Result<String, ToolExecutionError> {
|
) -> Result<String, ToolExecutionError> {
|
||||||
progress::set_activity(format!("{SEARCH_EMOJI} Searching: {query}"));
|
progress::set_activity(format!("{SEARCH_EMOJI} Searching: {query}"));
|
||||||
|
|
||||||
|
// TODO re-use same client object?
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.get(format!("{}/search", searxng_base_url()))
|
.get(format!("{}/search", searxng_base_url()))
|
||||||
.query(&[("q", query.as_str()), ("format", "json")])
|
.query(&[("q", query.as_str()), ("format", "json")])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue