Skip to main content

steeldb/agent/
run.rs

1//! The agent loop — provider-agnostic. Plan → probe (tool calls against the corpus) → synthesise,
2//! bounded by `max_steps`. Records a trace of every tool call for transparency.
3
4use crate::agent::provider::LlmProvider;
5use crate::agent::tools::{engine_tools, exec_tool, SYSTEM_PROMPT};
6use crate::agent::types::{Block, Msg, Role, Stop};
7use crate::db::Corpus;
8use serde::Serialize;
9use serde_json::{json, Value};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize)]
13pub struct ToolCallLog {
14    pub name: String,
15    pub input: Value,
16    pub result: String,
17    pub is_error: bool,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct AgentAnswer {
22    pub answer: String,
23    pub trace: Vec<ToolCallLog>,
24    pub steps: usize,
25}
26
27pub async fn run_agent(
28    provider: &dyn LlmProvider,
29    corpus: &Corpus,
30    question: &str,
31    max_steps: usize,
32) -> Result<AgentAnswer, String> {
33    // Bound the workflow DSL's facet slots to the corpus's real facets → constrained-by-construction.
34    let tools = engine_tools(&corpus.facet_names());
35    let mut trace: Vec<ToolCallLog> = Vec::new();
36    // Latest structured program result — used to render a deterministic template answer for providers
37    // that don't synthesise (Needle / native), so the numbers can't be hallucinated.
38    let mut last_program: Option<Value> = None;
39
40    // ALWAYS-ON entity-linked recall (17d6490): resolve the question to the corpus's precise tokens and
41    // retrieve on them BEFORE the model plans — independent of what it chooses to call — so recall for
42    // entity-specific questions never depends on the model guessing the right tokens. Injected as
43    // grounding context in the opening turn, and recorded in the trace for transparency.
44    let mut opening = question.to_string();
45    let linked = corpus.entity_link(question);
46    if !linked.is_empty() {
47        // Evidence passages from the SAME projection space (SPLADE/tagger/gazetteer tokens), ranked by
48        // token coverage over the roaring bitmap — symmetric query→corpus recall, not vector retrieval.
49        // Lets the answer be grounded even if the model never calls search/ikl itself.
50        let ranked = corpus.search_ranked(&linked, 8);
51        let sample = ranked
52            .iter()
53            .enumerate()
54            .map(|(i, (_sid, _cov, cells))| {
55                let joined: String = cells.join(" · ").chars().take(240).collect();
56                format!("  [{}] {}", i + 1, joined)
57            })
58            .collect::<Vec<_>>()
59            .join("\n");
60        opening.push_str(&format!(
61            "\n\n[Retrieved evidence for your question (corpus tokens: {}; {} matching situations). \
62             ANSWER FROM these passages when the question is about what the documents say; for \
63             counting/comparing use the analytics programs.{}]",
64            linked.join(", "),
65            ranked.len(),
66            if sample.is_empty() { String::new() } else { format!("\n{sample}") }
67        ));
68        trace.push(ToolCallLog {
69            name: "entity_link".into(),
70            input: json!({ "question": question }),
71            result: json!({ "linked": linked, "passages": ranked.len() }).to_string(),
72            is_error: false,
73        });
74    }
75    let mut msgs: Vec<Msg> = vec![Msg::user_text(opening)];
76
77    // small-model loop guards: cache tool results (so repeats are free) and break out of a model that
78    // keeps emitting the same probe instead of answering.
79    let mut cache: HashMap<String, (String, bool)> = HashMap::new();
80    let mut last_sig = String::new();
81    let mut repeat = 0usize;
82
83    for step in 1..=max_steps {
84        let turn = provider.chat(SYSTEM_PROMPT, &msgs, &tools).await?;
85
86        // record the assistant turn (text + any tool-use blocks) so the model sees its own calls
87        let mut ablocks: Vec<Block> = Vec::new();
88        if !turn.text.trim().is_empty() {
89            ablocks.push(Block::Text(turn.text.clone()));
90        }
91        for (id, name, input) in &turn.tool_uses {
92            ablocks.push(Block::ToolUse { id: id.clone(), name: name.clone(), input: input.clone() });
93        }
94        if !ablocks.is_empty() {
95            msgs.push(Msg { role: Role::Assistant, blocks: ablocks });
96        }
97
98        if turn.tool_uses.is_empty() || turn.stop == Stop::EndTurn {
99            // Discovery fallback: an on-device driver may not route a vague "what's in here?" to a tool.
100            // Rather than surface its refusal, profile the corpus deterministically over the query's
101            // candidate scope — a grounded overview needs no model.
102            if !provider.synthesizes() && last_program.is_none() {
103                let anchor = crate::agent::workflow::compile_anchor(&linked, &[]);
104                let profile = crate::agent::profile::auto_profile(corpus, &anchor);
105                if let Some(ans) = crate::agent::synth::render_program(&profile) {
106                    trace.push(ToolCallLog { name: "auto_profile".into(), input: json!({ "anchor": anchor }), result: profile.to_string(), is_error: false });
107                    return Ok(AgentAnswer { answer: ans, trace, steps: step });
108                }
109            }
110            let answer = finalize(provider, &last_program, &turn.text);
111            return Ok(AgentAnswer { answer, trace, steps: step });
112        }
113
114        // execute every requested tool (from cache when repeated), feed results back as one user message
115        let mut rblocks: Vec<Block> = Vec::new();
116        for (id, name, input) in &turn.tool_uses {
117            let key = format!("{name}|{input}");
118            let (result, is_error) = cache
119                .entry(key)
120                .or_insert_with(|| {
121                    // run_workflow compiles the DSL against the query's SPLADE/entity-link candidate
122                    // scope; other tools dispatch normally.
123                    if name == "run_workflow" {
124                        crate::agent::workflow::execute(corpus, input, &linked)
125                    } else {
126                        exec_tool(corpus, name, input)
127                    }
128                })
129                .clone();
130            if !is_error {
131                if let Ok(v) = serde_json::from_str::<Value>(&result) {
132                    if v.get("program").is_some() {
133                        last_program = Some(v);
134                    }
135                }
136            }
137            trace.push(ToolCallLog { name: name.clone(), input: input.clone(), result: result.clone(), is_error });
138            rblocks.push(Block::ToolResult { id: id.clone(), content: result, is_error });
139        }
140
141        // Short-circuit for on-device tool-callers (Needle): a single valid program result IS the answer
142        // — render the template now instead of looping for prose the model won't produce well.
143        if !provider.synthesizes() {
144            if let Some(p) = &last_program {
145                if let Some(ans) = crate::agent::synth::render_program(p) {
146                    return Ok(AgentAnswer { answer: ans, trace, steps: step });
147                }
148            }
149        }
150
151        // repeat-breaker: if this turn's calls are identical to the previous turn's, the model is stuck —
152        // nudge it to answer, and bail with a best-effort answer if it keeps looping.
153        let sig = turn.tool_uses.iter().map(|(_, n, i)| format!("{n}|{i}")).collect::<Vec<_>>().join(";");
154        if sig == last_sig {
155            repeat += 1;
156            if repeat >= 3 {
157                let ans = if last_program.is_some() || !provider.synthesizes() {
158                    finalize(provider, &last_program, &turn.text)
159                } else if turn.text.trim().is_empty() {
160                    "I could not converge on an answer from the corpus with the available evidence.".to_string()
161                } else {
162                    sanitize_answer(&turn.text)
163                };
164                return Ok(AgentAnswer { answer: ans, trace, steps: step });
165            }
166            rblocks.push(Block::Text(
167                "You already ran these exact queries and the results are unchanged. Do NOT repeat tool \
168                 calls — give your final answer now, grounded in the evidence above."
169                    .into(),
170            ));
171        } else {
172            repeat = 0;
173            last_sig = sig;
174        }
175        msgs.push(Msg { role: Role::User, blocks: rblocks });
176    }
177
178    // Ran out of steps: if a program produced a result, render it rather than failing.
179    if let Some(p) = &last_program {
180        if let Some(ans) = crate::agent::synth::render_program(p) {
181            return Ok(AgentAnswer { answer: ans, trace, steps: max_steps });
182        }
183    }
184    Err(format!("agent exceeded max_steps ({max_steps}) without a final answer"))
185}
186
187/// Choose the final answer: for providers that synthesise (large models), keep the model's prose; for
188/// small tool-callers, render a deterministic template from the latest program result so the figures are
189/// grounded, falling back to the model text only if no program ran.
190fn finalize(provider: &dyn LlmProvider, last_program: &Option<Value>, model_text: &str) -> String {
191    if !provider.synthesizes() {
192        if let Some(p) = last_program {
193            if let Some(rendered) = crate::agent::synth::render_program(p) {
194                return rendered;
195            }
196        }
197    }
198    sanitize_answer(model_text)
199}
200
201/// Guardrail for small-model degenerate output: if any short substring (2-10 chars) repeats 6+ times
202/// in a row (typical of a stuck decoding loop on `と`, `,`, etc.), truncate to before the run.
203fn sanitize_answer(text: &str) -> String {
204    for w in 2..=10 {
205        let chars: Vec<char> = text.chars().collect();
206        let mut run_start = 0usize;
207        let mut run_len = 1usize;
208        for i in w..chars.len() {
209            if chars[i] == chars[i - w] {
210                run_len += 1;
211                if run_len >= w * 6 {
212                    // find the actual byte position of the run start
213                    let cutoff: usize = text.chars().take(run_start).map(|c| c.len_utf8()).sum();
214                    return text[..cutoff].trim_end_matches(|c: char| c.is_whitespace() || c == ',' || c == ';').to_string();
215                }
216            } else {
217                run_start = i - w + 1;
218                run_len = 1;
219            }
220        }
221    }
222    text.to_string()
223}