From 9e7291214b3a3e592e0763adea3edb671468176e Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Wed, 19 Aug 2026 13:04:53 +0200 Subject: [PATCH] fix: use Native structured output for reviewer instead of forced tool call Ollama doesn't support tool_choice, so rig's tool-forced extractor (client.extractor::()) 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::()), 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. --- src/research.rs | 9 +++------ src/review.rs | 51 ++++++++++++++++++++++++++++++++++++++----------- src/tools.rs | 1 + 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/research.rs b/src/research.rs index 2dc803e..ffd68c7 100644 --- a/src/research.rs +++ b/src/research.rs @@ -27,20 +27,17 @@ pub(crate) async fn research(topic: &str, show_progress: bool) -> anyhow::Result findings = gathered.findings; incomplete = gathered.incomplete; - // The researcher ran out of turns mid-investigation rather than - // concluding on its own — another round would just repeat the same - // dead end, so stop and write up whatever was gathered. + // Prevent repeat iterations where researcher hits same dead end. if incomplete { tracing::info!(round, "researcher exhausted its turn budget"); break; } 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; } diff --git a/src/review.rs b/src/review.rs index 8b43b59..bd427c4 100644 --- a/src/review.rs +++ b/src/review.rs @@ -1,4 +1,5 @@ use rig::client::AgentClientExt; +use rig::completion::TypedPrompt; use rig::providers::ollama; use rig::schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -25,10 +26,18 @@ pub(crate) struct Review { pub(crate) gaps: String, } -/// Uses rig's typed extractor — a forced tool call into a `submit(Review)` -/// schema — rather than parsing free-text output, so the verdict and its -/// two feedback fields always come back structured instead of relying on -/// scanning prose for a trailing yes/no. +/// Retries for a review that fails to come back as valid structured output +/// (e.g. a transient network error), on top of the initial attempt. +const REVIEW_RETRIES: usize = 2; + +/// 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"))] pub(crate) async fn review_findings( client: &ollama::Client, @@ -37,7 +46,7 @@ pub(crate) async fn review_findings( show_progress: bool, ) -> anyhow::Result { let reviewer = client - .extractor::(REVIEWER_MODEL) + .agent(REVIEWER_MODEL) .preamble( "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 \ @@ -47,20 +56,40 @@ pub(crate) async fn review_findings( 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.", ) - .retries(2) .build(); let spinner = Spinner::start( show_progress, format!("{REVIEW_EMOJI} Reviewing findings..."), ); - let review = reviewer - .extract(format!( - "Topic: {topic}\n\nResearch findings to review:\n{findings}" - )) - .await?; + let prompt = format!("Topic: {topic}\n\nResearch findings to review:\n{findings}"); + + let mut last_error = None; + let mut review = None; + for attempt in 0..=REVIEW_RETRIES { + match reviewer.prompt_typed::(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); + 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"); Ok(review) diff --git a/src/tools.rs b/src/tools.rs index 51237c2..f3b604a 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -44,6 +44,7 @@ pub(crate) async fn search_web( ) -> Result { progress::set_activity(format!("{SEARCH_EMOJI} Searching: {query}")); + // TODO re-use same client object? let response = reqwest::Client::new() .get(format!("{}/search", searxng_base_url())) .query(&[("q", query.as_str()), ("format", "json")])