use crate::config::AskConfig;
use std::collections::HashMap;
use std::io;
use std::io::{IsTerminal, Write};
use std::path::Path;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SourceKind {
Corpus,
Web,
}
pub struct WebCtx {
pub client: reqwest::Client,
pub cfg: crate::config::WebConfig,
}
#[derive(Clone, Debug)]
pub struct Passage {
pub n: usize,
pub kind: SourceKind,
pub source: String,
pub title: String,
pub text: String,
pub score: f32,
}
#[derive(Debug, Clone)]
pub struct Source {
pub n: usize,
pub kind: SourceKind,
pub source: String,
pub title: String,
pub score: f32,
}
#[derive(Debug, Clone)]
pub enum Outcome {
Answered {
answer: String,
sources: Vec<Source>,
retrieved: Vec<Passage>,
searches: usize,
},
NotFound {
searches: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SearchKind { Corpus, Web, Fetch }
#[derive(Debug, Clone)]
pub enum AskEvent {
Search { query: String, kind: SearchKind },
Token(String),
Done(Outcome),
}
pub trait AskSink: Send {
fn emit(&mut self, ev: AskEvent);
}
#[derive(Default)]
pub struct CollectSink { pub events: Vec<AskEvent> }
impl AskSink for CollectSink {
fn emit(&mut self, ev: AskEvent) { self.events.push(ev); }
}
pub enum Choice {
Provide,
Detail,
Chance,
Quit,
Unknown,
}
#[allow(async_fn_in_trait, dead_code)]
pub trait Chat {
async fn turn(&self, messages: &[serde_json::Value], tools: Option<&serde_json::Value>,
on_token: &mut (dyn FnMut(&str) + Send))
-> io::Result<(Option<String>, Vec<crate::llm::ToolCall>)>;
}
pub struct EndpointChat {
client: reqwest::Client,
base_url: String,
model: String,
key: String,
sampling: crate::llm::Sampling,
mode: StreamMode,
}
impl EndpointChat {
pub fn new(cfg: &AskConfig, proxy: Option<&str>, mode: StreamMode) -> io::Result<Self> {
let client = crate::net::build_client(proxy).map_err(|e| io::Error::other(e.to_string()))?;
Ok(EndpointChat {
client,
base_url: cfg.base_url.clone(),
model: cfg.model.clone(),
key: crate::llm::env_key(&["ASK_API_KEY", "OPENAI_API_KEY"]),
sampling: crate::llm::Sampling {
temperature: cfg.temperature, max_tokens: cfg.max_tokens,
top_p: cfg.top_p, top_k: cfg.top_k, repetition_penalty: cfg.repetition_penalty,
},
mode,
})
}
}
impl Chat for EndpointChat {
async fn turn(&self, messages: &[serde_json::Value], tools: Option<&serde_json::Value>,
on_token: &mut (dyn FnMut(&str) + Send))
-> io::Result<(Option<String>, Vec<crate::llm::ToolCall>)> {
if self.mode != StreamMode::Off {
let mut held = String::new();
let mut live = false;
let (content, calls) = crate::llm::chat_turn_stream(
&self.client, &self.base_url, &self.model, &self.key, messages, tools, self.sampling,
|piece| {
if live { on_token(piece); return; }
held.push_str(piece);
let diverged = !"NOTFOUND".starts_with(held.trim_start());
if diverged {
on_token(&held);
held.clear();
live = true;
}
},
).await?;
if !live && content.as_deref().map(str::trim) != Some("NOTFOUND") {
on_token(&held);
}
Ok((content, calls))
} else {
let (content, calls, _tok, _ms) = crate::llm::chat_turn(
&self.client, &self.base_url, &self.model, &self.key, messages, tools, self.sampling,
).await?;
Ok((content, calls))
}
}
}
pub fn search_tool() -> serde_json::Value {
serde_json::json!([{
"type": "function",
"function": {
"name": "search_corpus",
"description": "Search the local knowledge corpus for passages relevant to a query. Call again with a refined query if the passages don't answer the question.",
"parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] }
}
}])
}
pub fn parse_citations(text: &str) -> Vec<usize> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'[' {
let mut j = i + 1;
while j < bytes.len() && bytes[j].is_ascii_digit() { j += 1; }
if j > i + 1 && j < bytes.len() && bytes[j] == b']' {
if let Ok(n) = text[i + 1..j].parse::<usize>() {
if seen.insert(n) { out.push(n); }
}
i = j + 1;
continue;
}
}
i += 1;
}
out
}
pub fn cited_sources(cited: &[usize], registry: &[Passage]) -> Vec<Source> {
cited.iter().filter_map(|&n| {
registry.iter().find(|p| p.n == n).map(|p| Source { n, kind: p.kind, source: p.source.clone(), title: p.title.clone(), score: p.score })
}).collect()
}
pub fn parse_choice(input: &str) -> Choice {
match input.trim().to_lowercase().as_str() {
"1" => Choice::Provide,
"2" => Choice::Detail,
"3" => Choice::Chance,
"q" | "quit" => Choice::Quit,
_ => Choice::Unknown,
}
}
const SYSTEM_PROMPT: &str = "You answer questions using ONLY the provided passages. \
Cite every claim with the passage number in square brackets, e.g. [1]. \
If the passages don't contain the answer, call the search_corpus tool with a better, more specific query and try again. \
Only after genuinely trying and still not finding it, reply with exactly NOTFOUND and nothing else. \
Never invent facts that aren't in the passages.";
const WEB_ADDENDUM: &str = " You also have web_search and fetch_page tools. Prefer the corpus; only if it lacks the answer, use web_search then fetch_page to read a promising result. Web passages are numbered the same way — cite them with [n] too.";
const GENERAL_PROMPT: &str = "Answer the question from your general knowledge, concisely.";
fn query_of(args: &str) -> String {
serde_json::from_str::<serde_json::Value>(args).ok()
.and_then(|v| v.get("query").and_then(|q| q.as_str()).map(String::from))
.unwrap_or_default()
}
fn url_of(args: &str) -> String {
serde_json::from_str::<serde_json::Value>(args).ok()
.and_then(|v| v.get("url").and_then(|u| u.as_str()).map(String::from))
.unwrap_or_default()
}
async fn run_search(repo_root: &Path, cfg: &AskConfig, query: &str, registry: &mut Vec<Passage>, seen: &mut HashMap<String, usize>) -> io::Result<String> {
let hits = crate::retrieve::search(repo_root, query, cfg.k).await?;
let mut block = Vec::new();
for h in hits {
let key = h.chunk.chunk_id.clone();
let n = if let Some(&n) = seen.get(&key) {
n
} else {
let n = registry.len() + 1;
registry.push(Passage { n, kind: SourceKind::Corpus, source: h.chunk.source.clone(), title: h.chunk.title.clone(), text: h.chunk.text.clone(), score: h.score });
seen.insert(key, n);
n
};
let p = ®istry[n - 1];
block.push(format!("[{}] {} ({})\n{}", p.n, p.title, p.source, p.text));
}
Ok(if block.is_empty() { "(no relevant passages found)".to_string() } else { block.join("\n\n") })
}
async fn run_web_search(ctx: &WebCtx, query: &str, registry: &mut Vec<Passage>, seen: &mut HashMap<String, usize>) -> String {
let base = (!ctx.cfg.base_url.is_empty()).then_some(ctx.cfg.base_url.as_str());
let results = crate::websearch::web_search(&ctx.client, base, ctx.cfg.max_results, query).await;
if results.is_empty() {
return "(no web results)".to_string();
}
let mut block = Vec::new();
for r in results {
let n = if let Some(&n) = seen.get(&r.url) {
n
} else {
let n = registry.len() + 1;
registry.push(Passage { n, kind: SourceKind::Web, source: r.url.clone(), title: r.title.clone(), text: r.snippet.clone(), score: 0.0 });
seen.insert(r.url.clone(), n);
n
};
let p = ®istry[n - 1];
block.push(format!("[{}] {} ({})\n{}", p.n, p.title, p.source, p.text));
}
block.join("\n\n")
}
async fn run_fetch_page(ctx: &WebCtx, url: &str, registry: &mut Vec<Passage>, seen: &mut HashMap<String, usize>) -> String {
let text = crate::websearch::fetch_page(&ctx.client, url, &ctx.cfg.allow_hosts, ctx.cfg.fetch_bytes, ctx.cfg.fetch_chars).await;
let ok = !text.starts_with("Blocked:") && !text.starts_with("Fetch failed:");
if let Some(&n) = seen.get(url) {
if ok {
registry[n - 1].text = text.clone(); }
let p = ®istry[n - 1];
format!("[{}] {}\n{}", p.n, p.source, p.text)
} else {
let n = registry.len() + 1;
registry.push(Passage { n, kind: SourceKind::Web, source: url.to_string(), title: url.to_string(), text: text.clone(), score: 0.0 });
seen.insert(url.to_string(), n);
format!("[{n}] {url}\n{text}")
}
}
fn classify(content: Option<String>, registry: &[Passage], searches: usize) -> Outcome {
let ans = content.unwrap_or_default();
let trimmed = ans.trim();
if trimmed.is_empty() || trimmed == "NOTFOUND" {
return Outcome::NotFound { searches };
}
let sources = cited_sources(&parse_citations(&ans), registry);
Outcome::Answered { answer: ans, sources, retrieved: registry.to_vec(), searches }
}
pub async fn answer<C: Chat>(sink: &mut dyn AskSink, chat: &C, repo_root: &Path, cfg: &AskConfig, question: &str, web: Option<&WebCtx>) -> io::Result<Outcome> {
let tools = {
let mut t = search_tool();
if web.is_some() {
if let (Some(a), Some(b)) = (t.as_array_mut(), crate::websearch::web_tools().as_array()) {
a.extend(b.iter().cloned());
}
}
t
};
let system = if web.is_some() { format!("{SYSTEM_PROMPT}{WEB_ADDENDUM}") } else { SYSTEM_PROMPT.to_string() };
let mut registry: Vec<Passage> = Vec::new();
let mut seen: HashMap<String, usize> = HashMap::new();
let seed = run_search(repo_root, cfg, question, &mut registry, &mut seen).await?;
let mut searches = 1usize;
let mut messages = vec![
serde_json::json!({"role":"system","content":system}),
serde_json::json!({"role":"user","content":format!("Question: {question}\n\nRetrieved passages:\n{seed}")}),
];
let max_turns = cfg.max_rounds.max(1);
for turn in 0..max_turns {
let allow_tools = turn + 1 < max_turns;
let (content, calls) = chat.turn(&messages, if allow_tools { Some(&tools) } else { None },
&mut |t: &str| sink.emit(AskEvent::Token(t.to_string()))).await?;
if calls.is_empty() {
return Ok(classify(content, ®istry, searches));
}
messages.push(serde_json::json!({"role":"assistant","content":content,"tool_calls":
calls.iter().map(|c| serde_json::json!({"id":c.id,"type":"function","function":{"name":c.name,"arguments":c.arguments}})).collect::<Vec<_>>()}));
for c in &calls {
let result = match c.name.as_str() {
"search_corpus" => {
let q = query_of(&c.arguments);
sink.emit(AskEvent::Search { query: q.clone(), kind: SearchKind::Corpus });
searches += 1;
match run_search(repo_root, cfg, &q, &mut registry, &mut seen).await {
Ok(s) => s,
Err(e) => format!("search failed: {e}"),
}
}
"web_search" => match web {
Some(ctx) => {
let q = query_of(&c.arguments);
sink.emit(AskEvent::Search { query: q.clone(), kind: SearchKind::Web });
run_web_search(ctx, &q, &mut registry, &mut seen).await
}
None => "web is disabled".to_string(),
},
"fetch_page" => match web {
Some(ctx) => {
let u = url_of(&c.arguments);
sink.emit(AskEvent::Search { query: u.clone(), kind: SearchKind::Fetch });
run_fetch_page(ctx, &u, &mut registry, &mut seen).await
}
None => "web is disabled".to_string(),
},
other => format!("Unknown tool: {other}"),
};
messages.push(serde_json::json!({"role":"tool","tool_call_id":c.id,"content":result}));
}
}
Ok(Outcome::NotFound { searches })
}
pub async fn answer_ungrounded<C: Chat>(chat: &C, question: &str, on_token: &mut (dyn FnMut(&str) + Send)) -> io::Result<String> {
let messages = vec![
serde_json::json!({"role":"system","content":GENERAL_PROMPT}),
serde_json::json!({"role":"user","content":question}),
];
let (content, _calls) = chat.turn(&messages, None, on_token).await?;
Ok(content.unwrap_or_default())
}
fn kind_label(kind: SourceKind) -> &'static str {
match kind {
SourceKind::Corpus => "local",
SourceKind::Web => "web",
}
}
const FALLBACK_REFS: usize = 3;
fn render_attribution(sources: &[Source], retrieved: &[Passage], searches: usize, show_context: bool) -> String {
let mut s = String::new();
if !sources.is_empty() {
s.push_str("Sources:\n");
for src in sources {
match src.kind {
SourceKind::Corpus => s.push_str(&format!(" [{}] [local] {} \"{}\" ({:.3})\n", src.n, src.source, src.title, src.score)),
SourceKind::Web => s.push_str(&format!(" [{}] [web] {} \"{}\"\n", src.n, src.source, src.title)),
}
}
} else if !retrieved.is_empty() {
s.push_str("References (retrieved — the model didn't mark citations):\n");
let mut top: Vec<&Passage> = retrieved.iter().collect();
top.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
for p in top.into_iter().take(FALLBACK_REFS) {
match p.kind {
SourceKind::Corpus => s.push_str(&format!(" [local] {} \"{}\" ({:.3})\n", p.source, p.title, p.score)),
SourceKind::Web => s.push_str(&format!(" [web] {} \"{}\"\n", p.source, p.title)),
}
}
}
if show_context {
s.push_str("\nRetrieved:\n");
for p in retrieved {
let snip: String = p.text.chars().take(100).collect();
s.push_str(&format!(" [{}] [{}] {} — {}\n", p.n, kind_label(p.kind), p.source, snip.replace('\n', " ")));
}
}
if !sources.is_empty() {
s.push_str(&format!("\n({} cited · {searches} search(es))\n", sources.len()));
} else {
s.push_str(&format!("\n(0 cited · {} retrieved · {searches} search(es))\n", retrieved.len()));
}
s
}
fn answer_json(answer: &str, grounded: bool, sources: &[Source], retrieved: &[Passage], searches: usize) -> serde_json::Value {
let src: Vec<serde_json::Value> = sources.iter().map(|s| serde_json::json!({
"n": s.n, "kind": kind_label(s.kind), "source": s.source, "title": s.title, "score": s.score
})).collect();
let mut top: Vec<&Passage> = retrieved.iter().collect();
top.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
let ret: Vec<serde_json::Value> = top.into_iter().take(FALLBACK_REFS).map(|p| serde_json::json!({
"kind": kind_label(p.kind), "source": p.source, "title": p.title, "score": p.score
})).collect();
serde_json::json!({
"answer": answer, "grounded": grounded, "sources": src, "retrieved": ret, "searches": searches
})
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum StreamMode { Off, Text, Json }
fn resolve_stream_mode(no_stream: bool, stream_flag: bool, cfg_stream: bool, json: bool, is_terminal: bool) -> StreamMode {
if json {
if stream_flag && !no_stream { StreamMode::Json } else { StreamMode::Off }
} else {
let want = if no_stream { false } else if stream_flag { true } else { cfg_stream };
if want && is_terminal { StreamMode::Text } else { StreamMode::Off }
}
}
fn token_event(text: &str) -> String {
serde_json::json!({ "type": "token", "text": text }).to_string()
}
fn search_event(query: &str, kind: &str) -> String {
serde_json::json!({ "type": "search", "query": query, "kind": kind }).to_string()
}
fn notfound_event(searches: usize) -> String {
serde_json::json!({ "type": "notfound", "searches": searches }).to_string()
}
fn answer_event(answer: &str, grounded: bool, sources: &[Source], retrieved: &[Passage], searches: usize) -> String {
let mut v = answer_json(answer, grounded, sources, retrieved, searches);
v["type"] = serde_json::json!("answer");
v.to_string()
}
fn search_kind_str(k: SearchKind) -> &'static str {
match k {
SearchKind::Corpus => "corpus",
SearchKind::Web => "web",
SearchKind::Fetch => "fetch",
}
}
fn search_progress_text(q: &str, k: SearchKind) -> String {
match k {
SearchKind::Corpus => format!("⋯ searching corpus: {q:?}"),
SearchKind::Web => format!("⋯ web search: {q:?}"),
SearchKind::Fetch => format!("⋯ fetch: {q}"),
}
}
pub struct StdoutSink<O: std::io::Write, E: std::io::Write> { pub mode: StreamMode, pub out: O, pub err: E }
impl<O: std::io::Write + Send, E: std::io::Write + Send> AskSink for StdoutSink<O, E> {
fn emit(&mut self, ev: AskEvent) {
match (self.mode, ev) {
(StreamMode::Text, AskEvent::Token(t)) => { let _ = write!(self.out, "{t}"); let _ = self.out.flush(); }
(StreamMode::Json, AskEvent::Token(t)) => { let _ = writeln!(self.out, "{}", token_event(&t)); }
(StreamMode::Text, AskEvent::Search { query, kind }) => { let _ = writeln!(self.err, " {}", search_progress_text(&query, kind)); }
(StreamMode::Json, AskEvent::Search { query, kind }) => { let _ = writeln!(self.out, "{}", search_event(&query, search_kind_str(kind))); }
_ => {} }
}
}
fn print_answer(out: &Outcome, grounded: bool, json: bool, show_context: bool, searches: usize, mode: StreamMode) {
if let Outcome::Answered { answer, sources, retrieved, .. } = out {
if json {
if mode == StreamMode::Json {
println!("{}", answer_event(answer, grounded, sources, retrieved, searches));
} else {
println!("{}", serde_json::to_string_pretty(&answer_json(answer, grounded, sources, retrieved, searches)).unwrap());
}
return;
}
let streamed = mode == StreamMode::Text;
if streamed {
println!(); println!(); } else {
println!("{answer}\n");
}
print!("{}", render_attribution(sources, retrieved, searches, show_context));
}
}
fn read_line(prompt: &str) -> String {
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut s = String::new();
let _ = std::io::stdin().read_line(&mut s);
s.trim().to_string()
}
async fn try_chance<C: Chat>(chat: &C, question: &str, json: bool, mode: StreamMode) -> io::Result<()> {
if mode == StreamMode::Json {
let a = answer_ungrounded(chat, question, &mut |t: &str| println!("{}", token_event(t))).await?;
println!("{}", answer_event(&a, false, &[], &[], 0));
return Ok(());
}
if mode == StreamMode::Text {
eprintln!("⚠ Not in your corpus — general knowledge, may be out of date. Verify independently.");
let _ = answer_ungrounded(chat, question, &mut |t: &str| { print!("{t}"); let _ = std::io::stdout().flush(); }).await?;
println!(); return Ok(());
}
let a = answer_ungrounded(chat, question, &mut |_: &str| {}).await?;
if json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"answer": a, "grounded": false, "not_found": true, "sources": []
})).unwrap());
} else {
println!("⚠ Not in your corpus — general knowledge, may be out of date. Verify independently.\n{a}");
}
Ok(())
}
fn print_not_found_options(json: bool, searches: usize) {
if json {
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"answer": serde_json::Value::Null, "grounded": false, "not_found": true, "searches": searches,
"suggestions": ["kibble index <path-or-url> then re-ask", "re-ask with more detail/keywords", "add --chance for a general-knowledge answer"]
})).unwrap());
} else {
println!("Searched the corpus ({searches}×) — nothing covers this. Options:");
println!(" • Add sources: kibble index <path-or-url> then re-ask");
println!(" • Be specific: re-ask with more detail/keywords");
println!(" • Try anyway: add --chance (general knowledge; may be wrong, not grounded)");
}
}
#[derive(Debug, Clone, Default)]
pub struct AskOptions {
pub k: Option<usize>,
pub web: bool,
pub chance: bool,
}
pub async fn query(repo_root: &Path, question: &str, opts: AskOptions) -> io::Result<Outcome> {
let full = crate::config::load_config(&repo_root.join(crate::config::CONFIG_FILE));
let mut cfg = full.ask;
if let Some(k) = opts.k { cfg.k = k; }
if cfg.base_url.is_empty() {
return Err(io::Error::other("ask needs a served LLM — set [ask].base_url in kibble.toml"));
}
let proxy = full.network.proxy;
let chat = EndpointChat::new(&cfg, proxy.as_deref(), StreamMode::Off)?;
let webctx = if opts.web {
Some(WebCtx {
client: crate::net::build_client(proxy.as_deref()).map_err(|e| io::Error::other(e.to_string()))?,
cfg: full.web.clone(),
})
} else {
None
};
let mut sink = CollectSink::default(); let outcome = answer(&mut sink, &chat, repo_root, &cfg, question, webctx.as_ref()).await?;
if opts.chance {
if let Outcome::NotFound { searches } = &outcome {
let ans = answer_ungrounded(&chat, question, &mut |_: &str| {}).await?;
return Ok(Outcome::Answered { answer: ans, sources: vec![], retrieved: vec![], searches: *searches });
}
}
Ok(outcome)
}
pub struct ChannelSink { pub tx: tokio::sync::mpsc::UnboundedSender<AskEvent> }
impl AskSink for ChannelSink {
fn emit(&mut self, ev: AskEvent) { let _ = self.tx.send(ev); }
}
pub fn query_stream(repo_root: &Path, question: &str, opts: AskOptions)
-> io::Result<impl tokio_stream::Stream<Item = AskEvent> + Send>
{
let full = crate::config::load_config(&repo_root.join(crate::config::CONFIG_FILE));
let mut cfg = full.ask;
if let Some(k) = opts.k { cfg.k = k; }
if cfg.base_url.is_empty() {
return Err(io::Error::other("ask needs a served LLM — set [ask].base_url in kibble.toml"));
}
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<AskEvent>();
let repo = repo_root.to_path_buf();
let question = question.to_string();
let proxy = full.network.proxy.clone();
let web_cfg = full.web.clone();
tokio::spawn(async move {
let mut sink = ChannelSink { tx: tx.clone() };
let chat = match EndpointChat::new(&cfg, proxy.as_deref(), StreamMode::Text) {
Ok(c) => c, Err(_) => return,
};
let webctx = if opts.web {
match crate::net::build_client(proxy.as_deref()) {
Ok(client) => Some(WebCtx { client, cfg: web_cfg }),
Err(_) => None,
}
} else { None };
let outcome = match answer(&mut sink, &chat, &repo, &cfg, &question, webctx.as_ref()).await {
Ok(o) => o,
Err(_) => return, };
let mut outcome = outcome;
if opts.chance {
if let Outcome::NotFound { searches } = &outcome {
let searches = *searches;
if let Ok(ans) = answer_ungrounded(&chat, &question,
&mut |t: &str| sink.emit(AskEvent::Token(t.to_string()))).await {
outcome = Outcome::Answered { answer: ans, sources: vec![], retrieved: vec![], searches };
}
}
}
let _ = tx.send(AskEvent::Done(outcome));
});
Ok(tokio_stream::wrappers::UnboundedReceiverStream::new(rx))
}
#[allow(clippy::too_many_arguments)]
pub async fn run(repo_root: &Path, question: &str, k: Option<usize>, json: bool, chance: bool, show_context: bool, web_flag: bool, no_web_flag: bool, stream_flag: bool, no_stream_flag: bool) -> io::Result<()> {
let full = crate::config::load_config(&repo_root.join(crate::config::CONFIG_FILE));
let mut cfg = full.ask;
if let Some(k) = k { cfg.k = k; }
if cfg.base_url.is_empty() {
return Err(io::Error::other("ask needs a served LLM — set [ask].base_url in kibble.toml"));
}
let mode = resolve_stream_mode(no_stream_flag, stream_flag, cfg.stream, json, std::io::stdout().is_terminal());
let proxy = full.network.proxy;
let chat = EndpointChat::new(&cfg, proxy.as_deref(), mode)?;
let web_on = if no_web_flag { false } else if web_flag { true } else { full.web.default };
let webctx = if web_on {
Some(WebCtx {
client: crate::net::build_client(proxy.as_deref()).map_err(|e| io::Error::other(e.to_string()))?,
cfg: full.web.clone(),
})
} else {
None
};
let mut question = question.to_string();
loop {
let mut sink = StdoutSink { mode, out: std::io::stdout(), err: std::io::stderr() };
let out = answer(&mut sink, &chat, repo_root, &cfg, &question, webctx.as_ref()).await?;
match &out {
Outcome::Answered { searches, .. } => {
print_answer(&out, true, json, show_context, *searches, mode);
return Ok(());
}
Outcome::NotFound { searches } => {
if chance {
return try_chance(&chat, &question, json, mode).await;
}
if mode == StreamMode::Json { println!("{}", notfound_event(*searches)); return Ok(()); }
if !std::io::stdin().is_terminal() || json {
print_not_found_options(json, *searches);
return Ok(());
}
println!("Couldn't find it in your corpus (searched {searches}×). What next?");
println!(" [1] provide a source (path or URL) to index & retry");
println!(" [2] add detail/keywords and retry");
println!(" [3] try anyway (general knowledge — may be wrong)");
println!(" [q] quit");
match parse_choice(&read_line(" > ")) {
Choice::Provide => {
let src = read_line(" path or URL: ");
if src.starts_with("http://") || src.starts_with("https://") {
#[cfg(feature = "full")]
{
if let Err(e) = crate::fetch::run_fetch(repo_root, &src, None, None, None).await {
println!(" fetch failed: {e}");
}
crate::index::build_index(repo_root, None).await?;
}
#[cfg(not(feature = "full"))]
{
println!(" URL fetch needs the full build; provide a local path instead.");
}
} else {
crate::index::build_index(repo_root, Some(Path::new(&src))).await?;
}
}
Choice::Detail => {
let more = read_line(" add detail: ");
question = format!("{question} {more}");
}
Choice::Chance => return try_chance(&chat, &question, json, mode).await,
Choice::Quit | Choice::Unknown => {
print_not_found_options(json, *searches);
return Ok(());
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_citations_dedups_in_order() {
assert_eq!(parse_citations("Foo [1] bar [3] baz [1] and [2]."), vec![1, 3, 2]);
assert_eq!(parse_citations("no cites here"), Vec::<usize>::new());
assert_eq!(parse_citations("[10] then [2]"), vec![10, 2]);
}
#[test]
fn cited_sources_maps_and_skips_unknown() {
let reg = vec![
Passage { n: 1, kind: SourceKind::Corpus, source: "a.md".into(), title: "A".into(), text: "x".into(), score: 0.9 },
Passage { n: 2, kind: SourceKind::Corpus, source: "b.md".into(), title: "B".into(), text: "y".into(), score: 0.5 },
];
let s = cited_sources(&[2, 9], ®); assert_eq!(s.len(), 1);
assert_eq!(s[0].n, 2);
assert_eq!(s[0].source, "b.md");
}
#[test]
fn parse_choice_maps_inputs() {
assert!(matches!(parse_choice(" 1 "), Choice::Provide));
assert!(matches!(parse_choice("2"), Choice::Detail));
assert!(matches!(parse_choice("3"), Choice::Chance));
assert!(matches!(parse_choice("Q"), Choice::Quit));
assert!(matches!(parse_choice("wat"), Choice::Unknown));
}
use std::cell::RefCell;
use std::collections::VecDeque;
struct ScriptedChat { turns: RefCell<VecDeque<(Option<String>, Vec<crate::llm::ToolCall>)>> }
impl ScriptedChat {
fn new(turns: Vec<(Option<String>, Vec<crate::llm::ToolCall>)>) -> Self {
ScriptedChat { turns: RefCell::new(turns.into_iter().collect()) }
}
}
impl Chat for ScriptedChat {
async fn turn(&self, _m: &[serde_json::Value], _t: Option<&serde_json::Value>,
_on_token: &mut (dyn FnMut(&str) + Send))
-> std::io::Result<(Option<String>, Vec<crate::llm::ToolCall>)> {
Ok(self.turns.borrow_mut().pop_front().unwrap_or((Some("NOTFOUND".into()), vec![])))
}
}
fn tc(query: &str) -> crate::llm::ToolCall {
crate::llm::ToolCall { id: "c1".into(), name: "search_corpus".into(), arguments: format!("{{\"query\":\"{query}\"}}") }
}
async fn temp_index(id: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("kibble_ask_{id}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("data/notes")).unwrap();
std::fs::write(dir.join("data/notes/fox.md"), "the quick brown fox jumps over the lazy dog").unwrap();
std::fs::write(dir.join("data/notes/db.md"), "databases store rows in tables").unwrap();
std::fs::write(dir.join(crate::config::CONFIG_FILE),
"[index]\nsources=[\"data/notes\"]\nchunk_chars=1000\n[understand.embed]\nbase_url=\"\"\n").unwrap();
crate::index::build_index(&dir, None).await.unwrap();
dir
}
#[tokio::test]
async fn answer_from_seed_cites_source() {
let dir = temp_index("seed").await;
let cfg = AskConfig { k: 3, max_rounds: 3, ..Default::default() };
let chat = ScriptedChat::new(vec![(Some("Foxes jump [1].".into()), vec![])]);
let out = answer(&mut CollectSink::default(), &chat, &dir, &cfg, "fox", None).await.unwrap();
match out {
Outcome::Answered { answer, sources, searches, .. } => {
assert_eq!(answer, "Foxes jump [1].");
assert_eq!(sources.len(), 1);
assert!(sources[0].source.contains("fox.md"));
assert_eq!(searches, 1);
}
_ => panic!("expected Answered"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn iterates_then_answers() {
let dir = temp_index("iter").await;
let cfg = AskConfig { k: 3, max_rounds: 3, ..Default::default() };
let chat = ScriptedChat::new(vec![
(None, vec![tc("fox")]),
(Some("The fox jumps [1].".into()), vec![]),
]);
let out = answer(&mut CollectSink::default(), &chat, &dir, &cfg, "animal that jumps", None).await.unwrap();
match out {
Outcome::Answered { searches, sources, .. } => {
assert!(searches >= 2);
assert!(sources.iter().any(|s| s.source.contains("fox.md")));
}
_ => panic!("expected Answered"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn notfound_when_model_signals() {
let dir = temp_index("nf").await;
let cfg = AskConfig { k: 3, max_rounds: 3, ..Default::default() };
let chat = ScriptedChat::new(vec![(Some("NOTFOUND".into()), vec![])]);
let out = answer(&mut CollectSink::default(), &chat, &dir, &cfg, "quantum chromodynamics", None).await.unwrap();
assert!(matches!(out, Outcome::NotFound { .. }));
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn ungrounded_returns_plain_answer() {
let chat = ScriptedChat::new(vec![(Some("Ulaanbaatar.".into()), vec![])]);
let a = answer_ungrounded(&chat, "capital of Mongolia?", &mut |_: &str| {}).await.unwrap();
assert_eq!(a, "Ulaanbaatar.");
}
fn mock_http(body: String) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read, Write};
if let Ok((mut s, _)) = listener.accept() {
let mut b = [0u8; 4096];
let _ = s.read(&mut b);
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/html\r\n\r\n{}", body.len(), body);
let _ = s.write_all(resp.as_bytes());
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn web_search_registers_web_passages() {
let sx_json = r#"{"results":[{"title":"Rust Book","url":"https://doc.rust-lang.org/x","content":"ownership explained"}]}"#.to_string();
let sx = mock_http(sx_json); let ctx = WebCtx { client: reqwest::Client::new(), cfg: crate::config::WebConfig { base_url: sx, ..Default::default() } };
let mut registry: Vec<Passage> = Vec::new();
let mut seen = std::collections::HashMap::new();
let block = run_web_search(&ctx, "rust ownership", &mut registry, &mut seen).await;
assert_eq!(registry.len(), 1);
assert_eq!(registry[0].kind, SourceKind::Web);
assert_eq!(registry[0].source, "https://doc.rust-lang.org/x");
assert!(block.contains("[1]") && block.contains("ownership explained"));
}
#[tokio::test]
async fn answer_web_cites_web_source() {
let dir = temp_index("web").await;
let cfg = AskConfig { k: 3, max_rounds: 3, ..Default::default() };
let page = mock_http("<html><body><p>The answer is 42.</p></body></html>".to_string());
let sx_json = format!(r#"{{"results":[{{"title":"Answer","url":"{page}","content":"forty two"}}]}}"#);
let sx = mock_http(sx_json);
let ctx = WebCtx {
client: reqwest::Client::new(),
cfg: crate::config::WebConfig { base_url: sx, allow_hosts: vec!["127.0.0.1".into()], ..Default::default() },
};
let chat = ScriptedChat::new(vec![
(None, vec![crate::llm::ToolCall { id: "c1".into(), name: "web_search".into(), arguments: "{\"query\":\"meaning\"}".into() }]),
(None, vec![crate::llm::ToolCall { id: "c2".into(), name: "fetch_page".into(), arguments: format!("{{\"url\":\"{page}\"}}") }]),
(Some("It is 42 [1].".into()), vec![]),
]);
let out = answer(&mut CollectSink::default(), &chat, &dir, &cfg, "meaning of life", Some(&ctx)).await.unwrap();
match out {
Outcome::Answered { sources, .. } => {
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].kind, SourceKind::Web);
assert!(sources[0].source.contains("127.0.0.1"));
}
_ => panic!("expected Answered"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn answer_mixes_corpus_and_web_sources() {
let dir = temp_index("mixed").await;
let cfg = AskConfig { k: 3, max_rounds: 3, ..Default::default() };
let sx_json = r#"{"results":[{"title":"Fox Facts","url":"https://example.com/fox","content":"foxes are canids"}]}"#.to_string();
let sx = mock_http(sx_json);
let ctx = WebCtx { client: reqwest::Client::new(), cfg: crate::config::WebConfig { base_url: sx, ..Default::default() } };
let chat = ScriptedChat::new(vec![
(None, vec![crate::llm::ToolCall { id: "c1".into(), name: "web_search".into(), arguments: "{\"query\":\"fox facts\"}".into() }]),
(Some("Foxes jump [1] and are canids [2].".into()), vec![]),
]);
let out = answer(&mut CollectSink::default(), &chat, &dir, &cfg, "fox", Some(&ctx)).await.unwrap();
match out {
Outcome::Answered { sources, .. } => {
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].kind, SourceKind::Corpus);
assert_eq!(sources[0].n, 1);
assert_eq!(sources[1].kind, SourceKind::Web);
assert_eq!(sources[1].n, 2);
}
_ => panic!("expected Answered"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn attribution_cited_vs_retrieved_fallback() {
let src = vec![Source { n: 1, kind: SourceKind::Corpus, source: "modern-css".into(), title: "centering".into(), score: 0.82 }];
let passages = vec![
Passage { n: 1, kind: SourceKind::Corpus, source: "modern-css".into(), title: "centering".into(), text: "grid place-items".into(), score: 0.82 },
Passage { n: 2, kind: SourceKind::Corpus, source: "modern-css".into(), title: "units".into(), text: "rem em".into(), score: 0.79 },
];
let a1 = render_attribution(&src, &passages, 1, false);
assert!(a1.contains("Sources:"), "got: {a1}");
assert!(a1.contains("1 cited"), "got: {a1}");
assert!(!a1.contains("References (retrieved"));
let a2 = render_attribution(&[], &passages, 2, false);
assert!(a2.contains("References (retrieved"), "got: {a2}");
assert!(a2.contains("\"centering\""), "got: {a2}");
assert!(a2.contains("0 cited · 2 retrieved"), "got: {a2}");
assert!(!a2.contains("Sources:"));
}
#[test]
fn answer_json_includes_retrieved() {
let passages = vec![
Passage { n: 1, kind: SourceKind::Corpus, source: "s".into(), title: "t".into(), text: "x".into(), score: 0.9 },
];
let v = answer_json("hi", true, &[], &passages, 1);
assert_eq!(v["grounded"], true);
assert_eq!(v["sources"].as_array().unwrap().len(), 0);
let ret = v["retrieved"].as_array().unwrap();
assert_eq!(ret.len(), 1);
assert_eq!(ret[0]["title"], "t");
assert_eq!(ret[0]["kind"], "local");
}
#[test]
fn resolve_stream_mode_cases() {
use StreamMode::*;
assert_eq!(resolve_stream_mode(false, true, false, true, false), Json); assert_eq!(resolve_stream_mode(false, false, false, true, true), Off); assert_eq!(resolve_stream_mode(false, true, false, false, true), Text); assert_eq!(resolve_stream_mode(false, true, false, false, false), Off); assert_eq!(resolve_stream_mode(true, true, true, true, true), Off); assert_eq!(resolve_stream_mode(false, false, true, true, false), Off); assert_eq!(resolve_stream_mode(false, true, true, true, false), Json); assert_eq!(resolve_stream_mode(true, false, true, false, true), Off); }
#[test]
fn event_builders_shapes() {
assert!(!token_event("hi").contains('\n'), "events are one line");
let t: serde_json::Value = serde_json::from_str(&token_event("hi")).unwrap();
assert_eq!(t["type"], "token"); assert_eq!(t["text"], "hi");
let s: serde_json::Value = serde_json::from_str(&search_event("q", "web")).unwrap();
assert_eq!(s["type"], "search"); assert_eq!(s["kind"], "web"); assert_eq!(s["query"], "q");
let nf: serde_json::Value = serde_json::from_str(¬found_event(2)).unwrap();
assert_eq!(nf["type"], "notfound"); assert_eq!(nf["searches"], 2);
let passages = vec![Passage { n: 1, kind: SourceKind::Corpus, source: "s".into(), title: "t".into(), text: "x".into(), score: 0.9 }];
let a: serde_json::Value = serde_json::from_str(&answer_event("hi", true, &[], &passages, 1)).unwrap();
assert_eq!(a["type"], "answer"); assert_eq!(a["answer"], "hi"); assert_eq!(a["grounded"], true);
assert_eq!(a["retrieved"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn notfound_emits_no_tokens_to_sink() {
let dir = temp_index("notfound_sink").await;
let chat = ScriptedChat::new(vec![(Some("NOTFOUND".into()), vec![])]);
let cfg = AskConfig { k: 3, max_rounds: 3, ..Default::default() };
let mut sink = CollectSink::default();
let out = answer(&mut sink, &chat, &dir, &cfg, "q", None).await.unwrap();
assert!(matches!(out, Outcome::NotFound { .. }), "sentinel → NotFound");
assert!(!sink.events.iter().any(|e| matches!(e, AskEvent::Token(_))),
"NOTFOUND sentinel must not leak as Token events: {:?}", sink.events);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn stdout_sink_json_matches_legacy_format() {
let mut out = Vec::new(); let mut err = Vec::new();
{
let mut s = StdoutSink { mode: StreamMode::Json, out: &mut out, err: &mut err };
s.emit(AskEvent::Search { query: "q".into(), kind: SearchKind::Corpus });
s.emit(AskEvent::Token("hi".into()));
}
let out = String::from_utf8(out).unwrap();
assert_eq!(out, format!("{}\n{}\n", search_event("q", "corpus"), token_event("hi")));
assert!(err.is_empty(), "Json mode writes nothing to stderr");
}
#[test]
fn stdout_sink_text_tokens_to_out_progress_to_err() {
let mut out = Vec::new(); let mut err = Vec::new();
{
let mut s = StdoutSink { mode: StreamMode::Text, out: &mut out, err: &mut err };
s.emit(AskEvent::Search { query: "q".into(), kind: SearchKind::Web });
s.emit(AskEvent::Token("hi".into()));
}
assert_eq!(String::from_utf8(out).unwrap(), "hi");
assert_eq!(String::from_utf8(err).unwrap(), format!(" {}\n", search_progress_text("q", SearchKind::Web)));
}
#[test]
fn search_progress_text_locks_legacy_strings() {
assert_eq!(search_progress_text("q", SearchKind::Corpus), "⋯ searching corpus: \"q\"");
assert_eq!(search_progress_text("q", SearchKind::Web), "⋯ web search: \"q\"");
assert_eq!(search_progress_text("http://x", SearchKind::Fetch), "⋯ fetch: http://x");
}
fn mock_sse(body: String) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
use std::io::{Read, Write};
if let Ok((mut s, _)) = listener.accept() {
let mut b = [0u8; 4096];
let _ = s.read(&mut b);
let resp = format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
let _ = s.write_all(resp.as_bytes());
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn endpoint_chat_turn_streams_clean_tokens_when_answer_diverges_from_notfound() {
let body = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"NO\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\" WAY\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\", it's a duck!\"}}]}\n\n",
"data: [DONE]\n\n",
).to_string();
let base = mock_sse(body);
let cfg = AskConfig { base_url: base, ..Default::default() };
let chat = EndpointChat::new(&cfg, None, StreamMode::Text).unwrap();
let mut toks: Vec<String> = Vec::new();
let messages = [serde_json::json!({"role":"user","content":"hi"})];
let (content, _calls) = chat.turn(&messages, None, &mut |t: &str| toks.push(t.to_string())).await.unwrap();
assert_eq!(content.as_deref(), Some("NO WAY, it's a duck!"));
assert_eq!(toks.concat(), "NO WAY, it's a duck!");
assert_eq!(toks, vec!["NO WAY".to_string(), ", it's a duck!".to_string()]);
}
#[tokio::test]
async fn endpoint_chat_turn_suppresses_notfound_sentinel_from_stream() {
let body = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"NOT\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"FOUND\"}}]}\n\n",
"data: [DONE]\n\n",
).to_string();
let base = mock_sse(body);
let cfg = AskConfig { base_url: base, ..Default::default() };
let chat = EndpointChat::new(&cfg, None, StreamMode::Text).unwrap();
let mut toks: Vec<String> = Vec::new();
let messages = [serde_json::json!({"role":"user","content":"hi"})];
let (content, _calls) = chat.turn(&messages, None, &mut |t: &str| toks.push(t.to_string())).await.unwrap();
assert_eq!(content.as_deref(), Some("NOTFOUND"));
assert!(toks.is_empty(), "NOTFOUND sentinel must emit zero tokens to on_token: {toks:?}");
}
}