use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use serde_json::Value;
use super::client::{ChatClient, ChatRequest, ClientError, Message, Usage};
use super::config::{ExploreConfig, Steering};
use super::trace::TraceWriter;
use super::{grounding, steering, toolset};
pub const MAX_TURNS: usize = 6;
pub const RECON_TURNS: usize = 2;
const MAX_COMPLETION_TOKENS: u32 = 1024;
const TEMPERATURE: f32 = 0.0;
const TOP_P: f32 = 0.95;
const CACHED_HINT: &str = "PRIOR STRUCTURAL MAP of this repository, from an earlier recon pass (use as a starting hint — it may not fully cover THIS question; verify with tools):\n";
#[derive(Debug, Clone)]
pub struct ExploreAnswer {
pub text: String,
pub turns: usize,
pub truncated: bool,
}
#[derive(Debug)]
pub enum ExploreError {
ProviderDown {
url: String,
detail: String,
},
Client(String),
}
impl fmt::Display for ExploreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExploreError::ProviderDown { url, detail } => {
write!(f, "inference server unreachable at {url}: {detail}")
}
ExploreError::Client(msg) => write!(f, "chat client error: {msg}"),
}
}
}
impl std::error::Error for ExploreError {}
fn map_client_error(e: ClientError) -> ExploreError {
match e {
ClientError::Connection { url, detail } => ExploreError::ProviderDown { url, detail },
other => ExploreError::Client(other.to_string()),
}
}
fn plan_cache() -> &'static Mutex<HashMap<String, String>> {
static CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cache_key(root: &Path) -> String {
root.canonicalize()
.unwrap_or_else(|_| root.to_path_buf())
.display()
.to_string()
}
#[derive(PartialEq)]
enum Phase {
Recon,
Execute,
}
pub trait ProgressReporter {
fn report(&self, progress: usize, total: usize, message: &str);
}
pub struct NoopReporter;
impl ProgressReporter for NoopReporter {
fn report(&self, _progress: usize, _total: usize, _message: &str) {}
}
pub fn run_explore(
question: &str,
root: &Path,
cfg: &ExploreConfig,
client: &dyn ChatClient,
) -> Result<ExploreAnswer, ExploreError> {
run_explore_reporting(question, root, cfg, client, &NoopReporter, None)
}
pub fn run_explore_reporting(
question: &str,
root: &Path,
cfg: &ExploreConfig,
client: &dyn ChatClient,
progress: &dyn ProgressReporter,
trace: Option<&TraceWriter>,
) -> Result<ExploreAnswer, ExploreError> {
let total = MAX_TURNS + 2;
let qwen = cfg.model.to_lowercase().contains("qwen");
let plan_first = cfg.steering == Steering::Balanced;
let cached_plan = if plan_first {
plan_cache().lock().unwrap().get(&cache_key(root)).cloned()
} else {
None
};
let do_recon = plan_first && cached_plan.is_none();
let mut sys_content = steering::system_prompt(cfg.steering, root);
if do_recon {
sys_content.push_str(steering::PHASE1_NOTE);
}
let mut messages: Vec<Message> = vec![Message::system(sys_content), Message::user(question)];
if let Some(plan) = &cached_plan {
messages.push(Message::user(format!("{CACHED_HINT}{plan}")));
}
let call_id = trace.map(|tw| tw.call_start(question)).unwrap_or(0);
let call_t0 = std::time::Instant::now();
let mut agg = Usage::default();
let mut phase = if do_recon { Phase::Recon } else { Phase::Execute };
let mut grove_turns = 0usize;
let mut n = 0usize;
let mut last_text = String::new();
let mut activity = if do_recon {
"planning: mapping structure".to_string()
} else {
"exploring the codebase".to_string()
};
loop {
n += 1;
if n > MAX_TURNS + 1 {
break;
}
if n == MAX_TURNS + 1 {
messages.push(Message::user(steering::FORCE_FINAL_ANSWER));
activity = "wrapping up: final answer".to_string();
}
progress.report(n, total, &format!("turn {n}/{} · {activity}", MAX_TURNS + 1));
let tools = if phase == Phase::Recon {
toolset::recon_toolset(grove_turns < RECON_TURNS)
} else {
toolset::execute_toolset()
};
let allowed: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
let req = ChatRequest::new(messages.clone())
.with_tools(tools)
.with_bench_sampling(TEMPERATURE, TOP_P, MAX_COMPLETION_TOKENS, None, qwen);
let req_trace = trace.map(|_| {
let mut v = serde_json::to_value(&req).unwrap_or(Value::Null);
if let Some(obj) = v.as_object_mut() {
obj.insert("model".to_string(), Value::String(cfg.model.clone()));
}
v
});
let t0 = std::time::Instant::now();
let resp = client.chat(req).map_err(map_client_error)?;
let wall = t0.elapsed().as_millis();
if let (Some(tw), Some(req_v)) = (trace, &req_trace) {
if let Some(u) = resp.usage {
agg.prompt_tokens = agg.prompt_tokens.saturating_add(u.prompt_tokens);
agg.completion_tokens = agg.completion_tokens.saturating_add(u.completion_tokens);
agg.total_tokens = agg.total_tokens.saturating_add(u.total_tokens);
}
let resp_v = serde_json::to_value(&resp).unwrap_or(Value::Null);
tw.turn(call_id, n, req_v, &resp_v, resp.usage, wall);
}
let step = match resp.first_message() {
Some(m) => m.clone(),
None => break,
};
last_text = step.content.clone().unwrap_or_default();
messages.push(step.clone());
if step.tool_calls.is_empty() {
if phase == Phase::Execute {
progress.report(total, total, "grounding answer");
let text = grounding::get_final_answer(&last_text, root);
if let Some(tw) = trace {
tw.call_end(call_id, &text, n, agg, call_t0.elapsed().as_millis(), false);
}
return Ok(ExploreAnswer { text, turns: n, truncated: false });
}
continue;
}
let mut used_grove = false;
let mut transition = false;
for c in &step.tool_calls {
let obs = if c.name == toolset::SUBMIT_PLAN && phase == Phase::Recon {
let plan_args = serialize_args(&c.arguments);
if !plan_args.is_empty() {
plan_cache()
.lock()
.unwrap()
.insert(cache_key(root), plan_args.clone());
}
messages.push(Message::tool(&c.id, steering::PLAN_RECORDED_NOTE));
messages.push(Message::user(format!(
"{}\n\nYour recorded plan:\n{}",
steering::PHASE2_NOTE, plan_args
)));
transition = true;
continue;
} else if !allowed.contains(&c.name) {
if phase == Phase::Recon {
steering::RECON_CLOSED_NOTE.to_string()
} else {
"<system-reminder>Planning is done. Use Read/Grep/Glob/Grove to execute your plan, then emit <final_answer>.</system-reminder>".to_string()
}
} else if phase == Phase::Recon
&& c.name == toolset::GROVE
&& !toolset::RECON_VERBS.contains(&toolset::grove_verb(&c.arguments).as_str())
{
steering::RECON_VERB_NOTE.to_string()
} else {
let o = toolset::dispatch(&c.name, &c.arguments, root);
if c.name == toolset::GROVE {
used_grove = true;
}
o
};
messages.push(Message::tool(&c.id, obs));
}
if used_grove {
grove_turns += 1;
}
if transition {
phase = Phase::Execute;
}
activity = summarize_activity(&step.tool_calls, transition);
}
progress.report(total, total, "grounding answer");
let text = grounding::get_final_answer(&last_text, root);
let turns = n.saturating_sub(1);
if let Some(tw) = trace {
tw.call_end(call_id, &text, turns, agg, call_t0.elapsed().as_millis(), true);
}
Ok(ExploreAnswer { text, turns, truncated: true })
}
fn summarize_activity(calls: &[super::client::ToolCall], transitioned: bool) -> String {
if transitioned {
return "plan set — executing".to_string();
}
let mut parts: Vec<String> = Vec::new();
for c in calls {
let part = match c.name.as_str() {
toolset::GROVE => format!("Grove {}", toolset::grove_verb(&c.arguments)),
toolset::READ => format!("Read {}", basename_arg(&c.arguments, "path")),
toolset::GLOB => format!("Glob {}", str_arg(&c.arguments, "pattern")),
toolset::GREP => format!("Grep {}", str_arg(&c.arguments, "pattern")),
other => other.to_string(),
};
parts.push(part);
}
let joined = parts.join(", ");
let s = if joined.chars().count() > 80 {
let mut t: String = joined.chars().take(77).collect();
t.push('…');
t
} else {
joined
};
if s.is_empty() {
"exploring the codebase".to_string()
} else {
s
}
}
fn str_arg(args: &Value, key: &str) -> String {
args.get(key)
.and_then(Value::as_str)
.unwrap_or("")
.chars()
.take(30)
.collect()
}
fn basename_arg(args: &Value, key: &str) -> String {
let p = args.get(key).and_then(Value::as_str).unwrap_or("");
Path::new(p)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| p.to_string())
}
fn serialize_args(args: &Value) -> String {
if args.is_null() {
String::new()
} else {
serde_json::to_string(args).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::explore::client::{ChatResponse, Choice, Role, ToolCall};
use crate::explore::config::Provider;
use serde_json::json;
use std::cell::RefCell;
struct FakeClient {
scripted: RefCell<std::collections::VecDeque<ChatResponse>>,
seen_tool_names: RefCell<Vec<Vec<String>>>,
}
impl FakeClient {
fn new(responses: Vec<ChatResponse>) -> Self {
FakeClient {
scripted: RefCell::new(responses.into()),
seen_tool_names: RefCell::new(Vec::new()),
}
}
}
impl ChatClient for FakeClient {
fn chat(&self, req: ChatRequest) -> Result<ChatResponse, ClientError> {
self.seen_tool_names
.borrow_mut()
.push(req.tools.iter().map(|t| t.function.name.clone()).collect());
Ok(self
.scripted
.borrow_mut()
.pop_front()
.unwrap_or_else(|| text_response("(end)")))
}
}
fn text_response(s: &str) -> ChatResponse {
ChatResponse {
choices: vec![Choice {
message: Message {
role: Role::Assistant,
content: Some(s.to_string()),
tool_calls: vec![],
tool_call_id: None,
name: None,
},
finish_reason: None,
}],
usage: None,
}
}
fn tool_call_response(name: &str, args: Value) -> ChatResponse {
ChatResponse {
choices: vec![Choice {
message: Message {
role: Role::Assistant,
content: None,
tool_calls: vec![ToolCall {
id: "call_1".into(),
name: name.into(),
arguments: args,
}],
tool_call_id: None,
name: None,
},
finish_reason: None,
}],
usage: None,
}
}
fn cfg(steering: Steering) -> ExploreConfig {
ExploreConfig {
provider: Provider::Ollama,
base_url: "http://localhost:11434/v1".into(),
model: "qwen3.5:4b".into(),
steering,
allowed_tools: vec!["grove".into(), "rg".into()],
tap: false,
trace_retain: 50,
}
}
#[test]
fn standard_returns_first_text_only_turn_as_answer() {
let client = FakeClient::new(vec![text_response("done\n<final_answer>\n</final_answer>")]);
let ans = run_explore("q", Path::new("."), &cfg(Steering::Standard), &client).unwrap();
assert!(!ans.truncated);
assert_eq!(ans.turns, 1);
assert!(ans.text.starts_with("done"));
}
#[test]
fn standard_offers_the_four_execute_tools() {
let client = FakeClient::new(vec![text_response("x")]);
run_explore("q", Path::new("."), &cfg(Steering::Standard), &client).unwrap();
let seen = &client.seen_tool_names.borrow()[0];
assert_eq!(seen, &vec!["Read", "Glob", "Grep", "Grove"]);
}
#[test]
fn turn_cap_forces_a_final_answer_not_a_sentinel() {
let mut responses = Vec::new();
for _ in 0..(MAX_TURNS + 2) {
responses.push(tool_call_response("Grove", json!({"command": "map ."})));
}
let client = FakeClient::new(responses);
let ans = run_explore("q", Path::new("."), &cfg(Steering::Standard), &client).unwrap();
assert!(ans.truncated, "hit the turn cap");
assert!(ans.turns >= MAX_TURNS);
}
#[test]
fn balanced_recon_closes_grove_then_forces_submit_plan() {
let client = FakeClient::new(vec![
tool_call_response("Grove", json!({"command": "map ."})),
tool_call_response("Grove", json!({"command": "symbols ."})),
tool_call_response(
"submit_plan",
json!({"focus_files": "a.rs", "steps": "read a.rs"}),
),
text_response("answer\n<final_answer>\n</final_answer>"),
]);
let root = std::env::temp_dir().join(format!("grove-agent-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let ans = run_explore("q", &root, &cfg(Steering::Balanced), &client).unwrap();
assert!(!ans.truncated);
let seen = client.seen_tool_names.borrow();
assert!(seen[0].contains(&"Grove".to_string()) && seen[0].contains(&"submit_plan".to_string()));
assert_eq!(seen[2], vec!["submit_plan"]);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn progress_is_reported_each_turn_and_at_the_end() {
use std::cell::RefCell;
struct Recorder {
ticks: RefCell<Vec<(usize, usize, String)>>,
}
impl ProgressReporter for Recorder {
fn report(&self, progress: usize, total: usize, message: &str) {
self.ticks
.borrow_mut()
.push((progress, total, message.to_string()));
}
}
let client = FakeClient::new(vec![
tool_call_response("Grove", json!({"command": "symbols ."})),
text_response("done\n<final_answer>\n</final_answer>"),
]);
let rec = Recorder {
ticks: RefCell::new(Vec::new()),
};
run_explore_reporting("q", Path::new("."), &cfg(Steering::Standard), &client, &rec, None)
.unwrap();
let ticks = rec.ticks.borrow();
assert!(ticks.len() >= 3, "got {} ticks", ticks.len());
assert!(ticks[0].2.contains("turn 1/"), "first tick: {:?}", ticks[0]);
assert!(ticks.windows(2).all(|w| w[0].0 <= w[1].0));
assert_eq!(ticks.last().unwrap().2, "grounding answer");
}
#[test]
fn provider_down_maps_to_provider_down_error() {
struct DownClient;
impl ChatClient for DownClient {
fn chat(&self, _req: ChatRequest) -> Result<ChatResponse, ClientError> {
Err(ClientError::Connection {
url: "http://x".into(),
detail: "refused".into(),
})
}
}
let err = run_explore("q", Path::new("."), &cfg(Steering::Standard), &DownClient).unwrap_err();
assert!(matches!(err, ExploreError::ProviderDown { .. }));
}
}