Skip to main content

reddb_server/runtime/ai/
ask_planner.rs

1//! ASK planner-first typed plan (ADR 0068, issue #1747).
2//!
3//! The planner runs *after* the deterministic AskPipeline funnel has
4//! narrowed the schema slice (candidate collections + columns + scores).
5//! The narrowed slice — never the raw catalog — is the only schema the
6//! planner LLM ever sees. The model emits a **typed plan** whose `query`
7//! step carries a read-only RQL candidate; the candidate is re-validated
8//! through the production parser and the read-only classifier
9//! ([`super::ask_rql_planner`]) before it can execute.
10//!
11//! This module is pure: it holds the plan types, the JSON plan parser, the
12//! narrowed-slice prompt builder, and the routing/refusal decision. The LLM
13//! transport, auto-execution, and synthesis live in the orchestrator
14//! ([`super::super::impl_search`]). The [`PlannerModel`] closure seam lets
15//! the routing/refusal logic be unit-tested without any HTTP round-trip.
16
17use std::collections::BTreeSet;
18
19use crate::api::{RedDBError, RedDBResult};
20/// Typed calls and phases from the protocol-neutral RedDB tool registry.
21pub use crate::runtime::agent_tools::{QueryPhase, RedToolCall as PlannerToolCall};
22
23use super::ask_rql_planner::{validate_candidate, CandidateDisposition, ValidatedCandidate};
24
25pub const ASK_PLANNER_SYSTEM_PROMPT: &str = include_str!("prompts/ask_planner.txt");
26
27/// The intent the planner routes a question to (ADR 0068 §2).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum AskIntent {
30    /// "what happened to passport 123?" — generate read-only RQL over the
31    /// full read-only surface, auto-execute, synthesize over the rows.
32    Factual,
33    /// "summarise yesterday's incidents" — retrieval-RAG cited answer.
34    Synthesis,
35    /// "how would I capture events into a queue?" — suggestion envelope.
36    HowTo,
37}
38
39impl AskIntent {
40    /// Canonical lowercase label (audit / plan summary).
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            AskIntent::Factual => "factual",
44            AskIntent::Synthesis => "synthesis",
45            AskIntent::HowTo => "how_to",
46        }
47    }
48
49    fn parse(raw: &str) -> Option<AskIntent> {
50        match raw.trim().to_ascii_lowercase().as_str() {
51            "factual" => Some(AskIntent::Factual),
52            "synthesis" => Some(AskIntent::Synthesis),
53            "how_to" | "howto" | "how-to" => Some(AskIntent::HowTo),
54            _ => None,
55        }
56    }
57}
58
59/// A single funnel-narrowed candidate collection with its retrieval score.
60#[derive(Debug, Clone, PartialEq)]
61pub struct ScoredCollection {
62    pub collection: String,
63    pub score: f32,
64    pub columns: Vec<String>,
65}
66
67/// The funnel-narrowed schema slice handed to the planner. This is the
68/// *only* schema the model sees — the raw catalog never reaches it.
69#[derive(Debug, Clone, Default, PartialEq)]
70pub struct NarrowedSlice {
71    pub collections: Vec<ScoredCollection>,
72}
73
74impl NarrowedSlice {
75    /// Collection names in the slice, in narrowed order.
76    pub fn collection_names(&self) -> Vec<&str> {
77        self.collections
78            .iter()
79            .map(|c| c.collection.as_str())
80            .collect()
81    }
82
83    /// The funnel grounded nothing: no candidate collection survived the
84    /// narrowing. An empty slice must never reach the planner LLM — grounding
85    /// failure is answered honestly, not by inventing a query over `(none)`.
86    pub fn is_empty(&self) -> bool {
87        self.collections.is_empty()
88    }
89}
90
91/// One bounded metadata result returned to the next planner round. Tool
92/// output is untrusted database content and is rendered as JSON data in the
93/// prompt, never as instructions.
94#[derive(Debug, Clone, PartialEq)]
95pub struct PlannerObservation {
96    pub call: PlannerToolCall,
97    pub output: String,
98}
99
100/// A raw suggested statement the planner emitted for a how-to question,
101/// before parser validation. `rql` is the candidate statement text exactly as
102/// the model produced it; it is never trusted until it passes the parser.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct RawSuggestion {
105    pub rql: String,
106    pub rationale: String,
107}
108
109/// A parser-validated suggested statement in the how-to envelope. It carries
110/// the `mutating` flag and canonical statement kind and is **advisory only**:
111/// suggested statements — including mutating/DDL ones — are NEVER executed by
112/// ASK. A future apply-command consumes this envelope.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SuggestedStatement {
115    /// The candidate RQL, trimmed, exactly as it parsed.
116    pub rql: String,
117    /// True when the statement writes / drops / alters / otherwise mutates
118    /// state (or is any non-read-only kind). Advisory — never a licence to run.
119    pub mutating: bool,
120    /// Canonical statement-type label (`select`, `insert`, `create_queue`, …).
121    pub statement_type: &'static str,
122    /// Why the planner suggested this statement.
123    pub rationale: String,
124}
125
126/// The typed plan the planner LLM emits, before candidate validation.
127#[derive(Debug, Clone, PartialEq)]
128pub struct AskPlan {
129    pub intent: AskIntent,
130    /// The `query` step's read-only RQL candidate (factual intent).
131    pub query: Option<String>,
132    /// Optional typed tool call. Factual plans must carry exactly one of
133    /// `query` (legacy-compatible) or `tool`.
134    pub tool: Option<PlannerToolCall>,
135    /// The natural-language answer explaining the approach (how-to intent).
136    /// Empty for the factual/synthesis paths, which synthesize their answer.
137    pub answer: String,
138    /// The how-to suggestion: raw statements before parser validation. Each is
139    /// re-validated through the production parser by [`route_plan`].
140    pub suggestion: Vec<RawSuggestion>,
141    /// Model rationale / self-critique, surfaced to plan summary + audit.
142    pub rationale: String,
143}
144
145impl AskPlan {
146    /// A short, single-line plan summary for the audit row.
147    pub fn summary(&self) -> String {
148        let mut out = format!("intent={}", self.intent.as_str());
149        if let Some(query) = &self.query {
150            out.push_str("; query=");
151            out.push_str(query.trim());
152        }
153        if let Some(tool) = &self.tool {
154            out.push_str("; tool=");
155            out.push_str(tool.name());
156        }
157        out
158    }
159}
160
161/// Validate each suggested statement through the production parser, **dropping**
162/// any that do not parse (unparseable model output is never returned raw), and
163/// flag each read-only vs mutating. Mutating/DDL statements are kept in the
164/// envelope — so a future apply-command can consume them — but ASK never runs
165/// them; this function only classifies, it never executes.
166pub fn validate_suggestions(raw: &[RawSuggestion]) -> Vec<SuggestedStatement> {
167    let mut out = Vec::new();
168    for item in raw {
169        // A candidate that fails the production parser is dropped, never
170        // returned raw. The suggestion is advisory regardless of disposition.
171        if let Ok(candidate) = validate_candidate(&item.rql) {
172            out.push(SuggestedStatement {
173                mutating: !candidate.is_read_only(),
174                statement_type: candidate.statement_type,
175                rql: candidate.rql,
176                rationale: item.rationale.clone(),
177            });
178        }
179    }
180    out
181}
182
183/// A model that turns a planner prompt into a typed-plan JSON document.
184///
185/// The blanket impl over `Fn(&str) -> RedDBResult<String>` lets callers
186/// pass a closure wrapping the configured planner provider (production) or
187/// a canned string (tests / mock model) without a bespoke type — this is
188/// the closure-model seam the routing/refusal unit tests use.
189pub trait PlannerModel {
190    fn plan(&self, prompt: &str) -> RedDBResult<String>;
191}
192
193impl<F> PlannerModel for F
194where
195    F: Fn(&str) -> RedDBResult<String>,
196{
197    fn plan(&self, prompt: &str) -> RedDBResult<String> {
198        self(prompt)
199    }
200}
201
202/// Structured model seam used by production providers and deterministic
203/// harnesses. It mirrors RedCode's model boundary: the runner owns the
204/// transcript and tools, while the adapter only performs one model turn.
205pub trait PlannerChatModel {
206    fn chat(
207        &self,
208        messages: &[crate::ai::AiChatMessage],
209        tools: &[crate::ai::AiToolDefinition],
210    ) -> RedDBResult<crate::ai::AiChatResponse>;
211}
212
213impl<F> PlannerChatModel for F
214where
215    F: Fn(
216        &[crate::ai::AiChatMessage],
217        &[crate::ai::AiToolDefinition],
218    ) -> RedDBResult<crate::ai::AiChatResponse>,
219{
220    fn chat(
221        &self,
222        messages: &[crate::ai::AiChatMessage],
223        tools: &[crate::ai::AiToolDefinition],
224    ) -> RedDBResult<crate::ai::AiChatResponse> {
225        self(messages, tools)
226    }
227}
228
229/// Typed registry shared by every ASK provider adapter.
230pub fn ask_tool_definitions() -> Vec<crate::ai::AiToolDefinition> {
231    crate::runtime::agent_tools::definitions(crate::runtime::agent_tools::ToolSurface::Ask)
232        .into_iter()
233        .map(|definition| crate::ai::AiToolDefinition {
234            name: definition.name.to_string(),
235            description: definition.description.to_string(),
236            input_schema: definition.input_schema,
237        })
238        .collect()
239}
240
241/// How the planner routed a plan after candidate validation.
242#[derive(Debug, Clone, PartialEq)]
243pub enum PlanRouting {
244    /// Factual intent with a validated **read-only** candidate ready to
245    /// auto-execute under the caller's EffectiveScope.
246    Execute {
247        candidate: ValidatedCandidate,
248        params: Vec<crate::json::Value>,
249        purpose: String,
250    },
251    /// A bounded metadata call whose result is fed into a later planner
252    /// round. The orchestrator charges the step before executing it.
253    UseTool { call: PlannerToolCall },
254    /// Factual intent whose candidate is **mutating** — a structured
255    /// refusal. Mutating candidates are never executed under any flag; the
256    /// suggestion envelope arrives in a later slice.
257    RefuseMutating {
258        statement_type: &'static str,
259        rql: String,
260    },
261    /// How-to intent: an advisory suggestion envelope. `answer` explains the
262    /// approach in natural language; `suggestion` carries the parser-validated
263    /// statements, each flagged `mutating`, plus their rationale. Suggested
264    /// statements — including mutating/DDL ones — are NEVER executed; a future
265    /// apply-command consumes this envelope.
266    Suggest {
267        answer: String,
268        suggestion: Vec<SuggestedStatement>,
269    },
270    /// A remaining non-factual intent (synthesis). The orchestrator decides
271    /// the fallback (retrieval-RAG cited answer).
272    Unsupported { intent: AskIntent },
273}
274
275/// A planner pass: the prompt sent, the parsed plan, and the routing.
276#[derive(Debug, Clone, PartialEq)]
277pub struct PlannedRoute {
278    pub prompt: String,
279    pub plan: AskPlan,
280    pub routing: PlanRouting,
281}
282
283/// Assemble the planner prompt from *only* the narrowed slice. A catalog
284/// with many collections never reaches the model raw: the slice is the
285/// anti-drowning gate (ADR 0068 §1).
286pub fn build_planner_prompt(question: &str, slice: &NarrowedSlice) -> String {
287    build_planner_prompt_with_observations(question, slice, &[])
288}
289
290/// Build a planner prompt for one bounded agent round. Previous metadata
291/// observations are included as JSON data so the model can discover schema
292/// before producing the terminal read-only query.
293pub fn build_planner_prompt_with_observations(
294    question: &str,
295    slice: &NarrowedSlice,
296    observations: &[PlannerObservation],
297) -> String {
298    build_planner_prompt_for_round(question, slice, observations, None)
299}
300
301fn build_planner_prompt_for_round(
302    question: &str,
303    slice: &NarrowedSlice,
304    observations: &[PlannerObservation],
305    steps_remaining: Option<usize>,
306) -> String {
307    let mut prompt = String::new();
308    prompt.push_str(ASK_PLANNER_SYSTEM_PROMPT.trim());
309    prompt.push_str(
310        "\n\nCompatibility response format (JSON only):\n\
311         {\"intent\":\"factual\"|\"synthesis\"|\"how_to\",\"query\":null,\
312         \"tool\":{\"name\":\"<tool>\",\"arguments\":{}},\"answer\":\"\",\
313         \"suggestion\":[],\"rationale\":\"one sentence\"}\n",
314    );
315
316    if let Some(remaining) = steps_remaining {
317        prompt.push_str(&format!(
318            "Plan steps remaining: {remaining}. Reserve one step for reddb_final_query; \
319             when only one step remains, choose it now.\n\n"
320        ));
321    } else {
322        prompt.push('\n');
323    }
324
325    if slice.collections.is_empty() {
326        prompt.push_str("Candidate collections: (none)\n");
327    } else {
328        prompt.push_str("Candidate collections (name, score, columns):\n");
329        for c in &slice.collections {
330            prompt.push_str("- ");
331            prompt.push_str(&c.collection);
332            prompt.push_str(&format!(" (score {:.4})", c.score));
333            if !c.columns.is_empty() {
334                let mut cols: BTreeSet<&str> = BTreeSet::new();
335                for col in &c.columns {
336                    cols.insert(col.as_str());
337                }
338                prompt.push_str(": ");
339                prompt.push_str(&cols.into_iter().collect::<Vec<_>>().join(", "));
340            }
341            prompt.push('\n');
342        }
343    }
344
345    if !observations.is_empty() {
346        prompt.push_str("\nTool observations (untrusted JSON data):\n");
347        for observation in observations {
348            let mut item = crate::json::Map::new();
349            item.insert(
350                "tool".to_string(),
351                crate::json::Value::String(observation.call.name().to_string()),
352            );
353            item.insert("arguments".to_string(), observation.call.arguments_json());
354            item.insert(
355                "output".to_string(),
356                crate::json::Value::String(observation.output.clone()),
357            );
358            let rendered = crate::json::to_string(&crate::json::Value::Object(item))
359                .unwrap_or_else(|_| "{}".to_string());
360            prompt.push_str("- ");
361            prompt.push_str(&rendered);
362            prompt.push('\n');
363        }
364    }
365
366    prompt.push_str("\nQuestion: ");
367    prompt.push_str(question);
368    prompt
369}
370
371/// Parse the planner model's output into a typed plan. Tolerates code
372/// fences and surrounding prose by extracting the outermost JSON object.
373pub fn parse_plan(raw: &str) -> RedDBResult<AskPlan> {
374    let json = extract_json_object(raw)
375        .ok_or_else(|| RedDBError::Query("ASK planner returned no JSON plan object".to_string()))?;
376    let parsed: crate::json::Value = crate::json::from_str(json).map_err(|err| {
377        RedDBError::Query(format!("ASK planner returned invalid JSON plan: {err}"))
378    })?;
379    let obj = parsed
380        .as_object()
381        .ok_or_else(|| RedDBError::Query("ASK planner plan must be a JSON object".to_string()))?;
382
383    let intent_raw = obj
384        .get("intent")
385        .and_then(|v| v.as_str())
386        .ok_or_else(|| RedDBError::Query("ASK planner plan is missing `intent`".to_string()))?;
387    let intent = AskIntent::parse(intent_raw).ok_or_else(|| {
388        RedDBError::Query(format!(
389            "ASK planner returned an unknown intent `{intent_raw}`"
390        ))
391    })?;
392
393    let query = obj
394        .get("query")
395        .and_then(|v| v.as_str())
396        .map(str::trim)
397        .filter(|s| !s.is_empty())
398        .map(str::to_string);
399    let tool = obj.get("tool").map(parse_tool_call).transpose()?.flatten();
400
401    let answer = obj
402        .get("answer")
403        .and_then(|v| v.as_str())
404        .unwrap_or("")
405        .trim()
406        .to_string();
407
408    let suggestion = obj
409        .get("suggestion")
410        .and_then(|v| v.as_array())
411        .map(parse_suggestions)
412        .unwrap_or_default();
413
414    let rationale = obj
415        .get("rationale")
416        .and_then(|v| v.as_str())
417        .unwrap_or("")
418        .trim()
419        .to_string();
420
421    Ok(AskPlan {
422        intent,
423        query,
424        tool,
425        answer,
426        suggestion,
427        rationale,
428    })
429}
430
431fn parse_tool_call(value: &crate::json::Value) -> RedDBResult<Option<PlannerToolCall>> {
432    if matches!(value, crate::json::Value::Null) {
433        return Ok(None);
434    }
435    let object = value.as_object().ok_or_else(|| {
436        RedDBError::Query("ASK planner `tool` must be an object or null".to_string())
437    })?;
438    let name = object
439        .get("name")
440        .and_then(crate::json::Value::as_str)
441        .ok_or_else(|| RedDBError::Query("ASK planner tool is missing `name`".to_string()))?;
442    let arguments = object
443        .get("arguments")
444        .cloned()
445        .unwrap_or_else(|| crate::json::Value::Object(crate::json::Map::new()));
446    crate::runtime::agent_tools::parse_call(
447        name,
448        &arguments,
449        crate::runtime::agent_tools::ToolParseMode::AskLegacy,
450    )
451    .map(Some)
452}
453
454fn parse_native_tool_call(call: &crate::ai::AiToolCall) -> RedDBResult<PlannerToolCall> {
455    crate::runtime::agent_tools::parse_call(
456        &call.name,
457        &call.arguments,
458        crate::runtime::agent_tools::ToolParseMode::AskNative,
459    )
460}
461
462/// Read the `suggestion` array of a how-to plan into raw statements. Each item
463/// may be a bare RQL string or a `{ "rql": "...", "rationale": "..." }` object;
464/// items without any statement text are skipped. Parser validation happens
465/// later in [`validate_suggestions`], not here.
466fn parse_suggestions(items: &[crate::json::Value]) -> Vec<RawSuggestion> {
467    let mut out = Vec::new();
468    for item in items {
469        if let Some(rql) = item.as_str() {
470            let rql = rql.trim();
471            if !rql.is_empty() {
472                out.push(RawSuggestion {
473                    rql: rql.to_string(),
474                    rationale: String::new(),
475                });
476            }
477        } else if let Some(obj) = item.as_object() {
478            let rql = obj
479                .get("rql")
480                .or_else(|| obj.get("statement"))
481                .and_then(|v| v.as_str())
482                .unwrap_or("")
483                .trim()
484                .to_string();
485            if rql.is_empty() {
486                continue;
487            }
488            let rationale = obj
489                .get("rationale")
490                .and_then(|v| v.as_str())
491                .unwrap_or("")
492                .trim()
493                .to_string();
494            out.push(RawSuggestion { rql, rationale });
495        }
496    }
497    out
498}
499
500/// Route a parsed plan: validate the factual candidate through the parser +
501/// read-only classifier, refuse mutating candidates, and mark non-factual
502/// intents unsupported for this slice.
503pub fn route_plan(plan: &AskPlan) -> RedDBResult<PlanRouting> {
504    match plan.intent {
505        AskIntent::Factual => match (&plan.query, &plan.tool) {
506            (Some(_), Some(_)) => Err(RedDBError::Query(
507                "ASK planner factual plan must choose exactly one of `query` or `tool`".to_string(),
508            )),
509            (Some(rql), None) => {
510                route_query_candidate(rql, &[], &plan.rationale, QueryPhase::Final)
511            }
512            (
513                None,
514                Some(PlannerToolCall::Query {
515                    rql,
516                    params,
517                    purpose,
518                    phase,
519                }),
520            ) => route_query_candidate(rql, params, purpose, *phase),
521            (None, Some(call)) => Ok(PlanRouting::UseTool { call: call.clone() }),
522            (None, None) => Err(RedDBError::Query(
523                "ASK planner classified the question as factual but produced no query or \
524                     tool call"
525                    .to_string(),
526            )),
527        },
528        AskIntent::HowTo => {
529            // Parser-validate every suggested statement; unparseable ones are
530            // dropped. Mutating/DDL statements survive in the envelope but are
531            // never executed — the suggestion is advisory.
532            let suggestion = validate_suggestions(&plan.suggestion);
533            Ok(PlanRouting::Suggest {
534                answer: plan.answer.clone(),
535                suggestion,
536            })
537        }
538        other => Ok(PlanRouting::Unsupported { intent: other }),
539    }
540}
541
542fn route_query_candidate(
543    rql: &str,
544    params: &[crate::json::Value],
545    purpose: &str,
546    phase: QueryPhase,
547) -> RedDBResult<PlanRouting> {
548    // Re-validate through the production parser; malformed candidates are
549    // rejected here and never execute.
550    let candidate = validate_candidate(rql)?;
551    match candidate.disposition {
552        CandidateDisposition::ReadOnly if phase == QueryPhase::Explore => {
553            Ok(PlanRouting::UseTool {
554                call: PlannerToolCall::Query {
555                    rql: candidate.rql,
556                    params: params.to_vec(),
557                    purpose: purpose.to_string(),
558                    phase,
559                },
560            })
561        }
562        CandidateDisposition::ReadOnly => Ok(PlanRouting::Execute {
563            candidate,
564            params: params.to_vec(),
565            purpose: purpose.to_string(),
566        }),
567        CandidateDisposition::Mutating => Ok(PlanRouting::RefuseMutating {
568            statement_type: candidate.statement_type,
569            rql: candidate.rql,
570        }),
571    }
572}
573
574/// Full planner pass: build the narrowed-slice prompt, call the model,
575/// parse the plan, and route it. The model is the closure-model seam.
576pub fn plan_and_route<M: PlannerModel>(
577    question: &str,
578    slice: &NarrowedSlice,
579    model: &M,
580) -> RedDBResult<PlannedRoute> {
581    plan_and_route_with_observations(question, slice, &[], model)
582}
583
584/// One agent-loop planner round over the narrowed schema plus prior bounded
585/// metadata observations.
586pub fn plan_and_route_with_observations<M: PlannerModel>(
587    question: &str,
588    slice: &NarrowedSlice,
589    observations: &[PlannerObservation],
590    model: &M,
591) -> RedDBResult<PlannedRoute> {
592    let prompt = build_planner_prompt_with_observations(question, slice, observations);
593    let raw = model.plan(&prompt)?;
594    let plan = parse_plan(&raw)?;
595    let routing = route_plan(&plan)?;
596    Ok(PlannedRoute {
597        prompt,
598        plan,
599        routing,
600    })
601}
602
603#[derive(Debug, Clone, PartialEq)]
604pub enum AgentLoopOutcome {
605    Routed {
606        route: Box<PlannedRoute>,
607        observations: Vec<PlannerObservation>,
608    },
609    BudgetExhausted(BudgetExhausted),
610}
611
612/// Run the bounded metadata-tool loop until the planner returns a terminal
613/// route or the shared step budget is exhausted. A terminal query is charged
614/// here too, which keeps planner rounds and executable steps linear.
615pub fn run_bounded_agent_loop<M, E>(
616    question: &str,
617    slice: &NarrowedSlice,
618    budget: &mut PlanBudget,
619    model: &M,
620    executor: &E,
621) -> RedDBResult<AgentLoopOutcome>
622where
623    M: PlannerModel,
624    E: Fn(&PlannerToolCall) -> RedDBResult<String>,
625{
626    let mut observations = Vec::new();
627    loop {
628        if budget.remaining() == 0 {
629            return Ok(AgentLoopOutcome::BudgetExhausted(BudgetExhausted {
630                attempted: PlanStep::Query,
631                executed_steps: budget.executed_count(),
632                max_steps: budget.max_steps(),
633            }));
634        }
635
636        let prompt = build_planner_prompt_for_round(
637            question,
638            slice,
639            &observations,
640            Some(budget.remaining()),
641        );
642        let raw = model.plan(&prompt)?;
643        let plan = parse_plan(&raw)?;
644        let route = PlannedRoute {
645            prompt,
646            routing: route_plan(&plan)?,
647            plan,
648        };
649        match &route.routing {
650            PlanRouting::UseTool { call } => {
651                // Metadata and exploratory reads must not consume the final
652                // reserved step. This guarantees that exploration can never
653                // leave ASK without an evidence-producing final query.
654                if budget.remaining() <= 1 {
655                    return Ok(AgentLoopOutcome::BudgetExhausted(BudgetExhausted {
656                        attempted: planner_tool_step(call),
657                        executed_steps: budget.executed_count(),
658                        max_steps: budget.max_steps(),
659                    }));
660                }
661                if let Err(exhausted) = budget.charge(planner_tool_step(call)) {
662                    return Ok(AgentLoopOutcome::BudgetExhausted(exhausted));
663                }
664                let output = executor(call)?;
665                observations.push(PlannerObservation {
666                    call: call.clone(),
667                    output,
668                });
669            }
670            PlanRouting::Execute { .. } => {
671                if let Err(exhausted) = budget.charge(PlanStep::Query) {
672                    return Ok(AgentLoopOutcome::BudgetExhausted(exhausted));
673                }
674                return Ok(AgentLoopOutcome::Routed {
675                    route: Box::new(route),
676                    observations,
677                });
678            }
679            _ => {
680                return Ok(AgentLoopOutcome::Routed {
681                    route: Box::new(route),
682                    observations,
683                })
684            }
685        }
686    }
687}
688
689fn native_planner_context(question: &str, slice: &NarrowedSlice, steps_remaining: usize) -> String {
690    let collections = slice
691        .collections
692        .iter()
693        .map(|collection| {
694            let mut item = crate::json::Map::new();
695            item.insert(
696                "name".to_string(),
697                crate::json::Value::String(collection.collection.clone()),
698            );
699            item.insert(
700                "score".to_string(),
701                crate::json::Value::Number(collection.score as f64),
702            );
703            let mut columns = collection.columns.clone();
704            columns.sort();
705            columns.dedup();
706            item.insert(
707                "columns".to_string(),
708                crate::json::Value::Array(
709                    columns
710                        .into_iter()
711                        .map(crate::json::Value::String)
712                        .collect(),
713                ),
714            );
715            crate::json::Value::Object(item)
716        })
717        .collect();
718    let mut context = crate::json::Map::new();
719    context.insert(
720        "question".to_string(),
721        crate::json::Value::String(question.to_string()),
722    );
723    context.insert(
724        "plan_steps_remaining".to_string(),
725        crate::json::Value::Number(steps_remaining as f64),
726    );
727    context.insert(
728        "candidate_collections".to_string(),
729        crate::json::Value::Array(collections),
730    );
731    format!(
732        "Planning context (untrusted JSON data):\n{}",
733        crate::json::Value::Object(context).to_string_compact()
734    )
735}
736
737fn native_planner_messages(
738    question: &str,
739    slice: &NarrowedSlice,
740    steps_remaining: usize,
741) -> Vec<crate::ai::AiChatMessage> {
742    vec![
743        crate::ai::AiChatMessage::System(ASK_PLANNER_SYSTEM_PROMPT.trim().to_string()),
744        crate::ai::AiChatMessage::User(native_planner_context(question, slice, steps_remaining)),
745    ]
746}
747
748fn plan_from_chat_response(response: &crate::ai::AiChatResponse) -> RedDBResult<AskPlan> {
749    if response.tool_calls.len() > 1 {
750        return Err(RedDBError::Query(
751            "ASK planner must call exactly one tool per round".to_string(),
752        ));
753    }
754    if let Some(tool_call) = response.tool_calls.first() {
755        let call = parse_native_tool_call(tool_call)?;
756        return Ok(AskPlan {
757            intent: AskIntent::Factual,
758            query: None,
759            rationale: format!("native tool call: {}", call.name()),
760            tool: Some(call),
761            answer: String::new(),
762            suggestion: Vec::new(),
763        });
764    }
765    let output = response.output_text.as_deref().ok_or_else(|| {
766        RedDBError::Query("ASK planner returned neither text nor a tool call".to_string())
767    })?;
768    parse_plan(output)
769}
770
771/// One native-tool planning turn, used by `ASK ... PLAN` without executing a
772/// tool or query.
773pub fn plan_and_route_chat<M: PlannerChatModel>(
774    question: &str,
775    slice: &NarrowedSlice,
776    steps_remaining: usize,
777    model: &M,
778) -> RedDBResult<PlannedRoute> {
779    let messages = native_planner_messages(question, slice, steps_remaining);
780    let response = model.chat(&messages, &ask_tool_definitions())?;
781    let plan = plan_from_chat_response(&response)?;
782    let routing = route_plan(&plan)?;
783    Ok(PlannedRoute {
784        prompt: build_planner_prompt_for_round(question, slice, &[], Some(steps_remaining)),
785        plan,
786        routing,
787    })
788}
789
790/// Run the bounded ASK harness with a provider-native tool transcript. The
791/// same transcript/request boundary is reusable by deterministic test models.
792pub fn run_bounded_chat_agent_loop<M, E>(
793    question: &str,
794    slice: &NarrowedSlice,
795    budget: &mut PlanBudget,
796    model: &M,
797    executor: &E,
798) -> RedDBResult<AgentLoopOutcome>
799where
800    M: PlannerChatModel,
801    E: Fn(&PlannerToolCall) -> RedDBResult<String>,
802{
803    let tools = ask_tool_definitions();
804    let mut messages = native_planner_messages(question, slice, budget.remaining());
805    let mut observations = Vec::new();
806    loop {
807        if budget.remaining() == 0 {
808            return Ok(AgentLoopOutcome::BudgetExhausted(BudgetExhausted {
809                attempted: PlanStep::Query,
810                executed_steps: budget.executed_count(),
811                max_steps: budget.max_steps(),
812            }));
813        }
814
815        let prompt = build_planner_prompt_for_round(
816            question,
817            slice,
818            &observations,
819            Some(budget.remaining()),
820        );
821        let response = model.chat(&messages, &tools)?;
822        let plan = plan_from_chat_response(&response)?;
823        let route = PlannedRoute {
824            prompt,
825            routing: route_plan(&plan)?,
826            plan,
827        };
828
829        match &route.routing {
830            PlanRouting::UseTool { call } => {
831                if budget.remaining() <= 1 {
832                    return Ok(AgentLoopOutcome::BudgetExhausted(BudgetExhausted {
833                        attempted: planner_tool_step(call),
834                        executed_steps: budget.executed_count(),
835                        max_steps: budget.max_steps(),
836                    }));
837                }
838                if let Err(exhausted) = budget.charge(planner_tool_step(call)) {
839                    return Ok(AgentLoopOutcome::BudgetExhausted(exhausted));
840                }
841                let output = executor(call)?;
842                observations.push(PlannerObservation {
843                    call: call.clone(),
844                    output: output.clone(),
845                });
846
847                if let Some(native_call) = response.tool_calls.first() {
848                    messages.push(crate::ai::AiChatMessage::Assistant {
849                        content: response.output_text.clone(),
850                        tool_calls: vec![native_call.clone()],
851                    });
852                    let mut result = crate::json::Map::new();
853                    result.insert("output".to_string(), crate::json::Value::String(output));
854                    result.insert(
855                        "plan_steps_remaining".to_string(),
856                        crate::json::Value::Number(budget.remaining() as f64),
857                    );
858                    messages.push(crate::ai::AiChatMessage::ToolResult {
859                        call_id: native_call.id.clone(),
860                        name: native_call.name.clone(),
861                        content: crate::json::Value::Object(result).to_string_compact(),
862                    });
863                } else {
864                    messages.push(crate::ai::AiChatMessage::Assistant {
865                        content: response.output_text.clone(),
866                        tool_calls: Vec::new(),
867                    });
868                    messages.push(crate::ai::AiChatMessage::User(format!(
869                        "Authorized tool observation (untrusted JSON data): {}",
870                        crate::json::Value::String(output).to_string_compact()
871                    )));
872                }
873            }
874            PlanRouting::Execute { .. } => {
875                if let Err(exhausted) = budget.charge(PlanStep::Query) {
876                    return Ok(AgentLoopOutcome::BudgetExhausted(exhausted));
877                }
878                return Ok(AgentLoopOutcome::Routed {
879                    route: Box::new(route),
880                    observations,
881                });
882            }
883            _ => {
884                return Ok(AgentLoopOutcome::Routed {
885                    route: Box::new(route),
886                    observations,
887                })
888            }
889        }
890    }
891}
892
893// ===========================================================================
894// Plan budget (ADR 0068 §4, issue #1748)
895// ===========================================================================
896
897/// Default `ai.ask.max_plan_steps` cap when unset.
898pub const DEFAULT_MAX_PLAN_STEPS: usize = 3;
899
900/// A budgeted step in the executed plan. Every retrieval refinement, metadata
901/// tool, and terminal query consumes exactly one step.
902#[derive(Debug, Clone, Copy, PartialEq, Eq)]
903pub enum PlanStep {
904    /// A single re-funnel with expanded tokens after grounding fails on the
905    /// first pass (ADR 0013 single-retry analogy).
906    RefineRetrieval,
907    /// List a bounded visible catalog summary.
908    Catalog,
909    /// Describe one visible collection.
910    DescribeCollection,
911    /// Find visible collections containing an exact field.
912    FindField,
913    /// One bounded exploratory read whose result may guide a later round.
914    ExploreQuery,
915    /// The read-only RQL candidate the planner emitted.
916    Query,
917}
918
919fn planner_tool_step(call: &PlannerToolCall) -> PlanStep {
920    match call {
921        PlannerToolCall::Catalog => PlanStep::Catalog,
922        PlannerToolCall::DescribeCollection { .. } => PlanStep::DescribeCollection,
923        PlannerToolCall::FindField { .. } => PlanStep::FindField,
924        PlannerToolCall::Query {
925            phase: QueryPhase::Explore,
926            ..
927        } => PlanStep::ExploreQuery,
928        PlannerToolCall::Query {
929            phase: QueryPhase::Final,
930            ..
931        } => PlanStep::Query,
932    }
933}
934
935impl PlanStep {
936    pub fn as_str(&self) -> &'static str {
937        match self {
938            PlanStep::RefineRetrieval => "refine_retrieval",
939            PlanStep::Catalog => "reddb_catalog",
940            PlanStep::DescribeCollection => "reddb_describe_collection",
941            PlanStep::FindField => "reddb_find_field",
942            PlanStep::ExploreQuery => "reddb_query_explore",
943            PlanStep::Query => "query",
944        }
945    }
946}
947
948/// Resolve the effective plan-step budget: a per-query `STEPS N` request is
949/// clamped to the configured `max_plan_steps` cap and never exceeds it; an
950/// absent request falls back to the cap. The result is always at least 1.
951pub fn clamp_plan_steps(requested: Option<usize>, cap: usize) -> usize {
952    let cap = cap.max(1);
953    match requested {
954        Some(n) => n.clamp(1, cap),
955        None => cap,
956    }
957}
958
959/// The budget was exhausted mid-plan: a step was attempted after the cap was
960/// already reached. The orchestrator turns this into a structured
961/// partial-with-warning result rather than looping unbounded.
962#[derive(Debug, Clone, PartialEq, Eq)]
963pub struct BudgetExhausted {
964    pub attempted: PlanStep,
965    pub executed_steps: usize,
966    pub max_steps: usize,
967}
968
969/// A bounded plan-step counter. Total executed steps can never exceed
970/// `max_steps` — the anti-unbounded-loop guard (ADR 0068 §4).
971#[derive(Debug, Clone)]
972pub struct PlanBudget {
973    max_steps: usize,
974    executed: Vec<PlanStep>,
975}
976
977impl PlanBudget {
978    /// Build a budget from a per-query `STEPS N` request and the config cap.
979    /// The request is clamped so it can never exceed the cap.
980    pub fn new(requested_steps: Option<usize>, cap: usize) -> Self {
981        Self {
982            max_steps: clamp_plan_steps(requested_steps, cap),
983            executed: Vec::new(),
984        }
985    }
986
987    /// The clamped ceiling on total executed steps.
988    pub fn max_steps(&self) -> usize {
989        self.max_steps
990    }
991
992    /// How many steps have executed so far.
993    pub fn executed_count(&self) -> usize {
994        self.executed.len()
995    }
996
997    /// Steps still available before the budget is exhausted.
998    pub fn remaining(&self) -> usize {
999        self.max_steps.saturating_sub(self.executed.len())
1000    }
1001
1002    /// The executed steps, in order — surfaced to the audit row.
1003    pub fn executed_steps(&self) -> &[PlanStep] {
1004        &self.executed
1005    }
1006
1007    /// Charge one plan step. Returns `Err(BudgetExhausted)` — without
1008    /// recording the step — when the cap is already reached.
1009    pub fn charge(&mut self, step: PlanStep) -> Result<(), BudgetExhausted> {
1010        if self.executed.len() >= self.max_steps {
1011            return Err(BudgetExhausted {
1012                attempted: step,
1013                executed_steps: self.executed.len(),
1014                max_steps: self.max_steps,
1015            });
1016        }
1017        self.executed.push(step);
1018        Ok(())
1019    }
1020}
1021
1022// ===========================================================================
1023// Grounding critique + single refine_retrieval retry (ADR 0068 §4)
1024// ===========================================================================
1025
1026/// The funnel behind a closure seam so the retry/refusal logic is unit-tested
1027/// without a live retrieval pass. `expanded` requests the refine_retrieval
1028/// widening (expanded tokens, relaxed score floor) on the single retry.
1029pub trait RetrievalFunnel {
1030    fn funnel(&self, expanded: bool) -> RedDBResult<NarrowedSlice>;
1031}
1032
1033impl<F> RetrievalFunnel for F
1034where
1035    F: Fn(bool) -> RedDBResult<NarrowedSlice>,
1036{
1037    fn funnel(&self, expanded: bool) -> RedDBResult<NarrowedSlice> {
1038        self(expanded)
1039    }
1040}
1041
1042/// The outcome of the grounding critique: either a grounded slice (possibly
1043/// after the single refine_retrieval retry) or an honest "no matching
1044/// sources" — never an invented answer.
1045#[derive(Debug, Clone, PartialEq)]
1046pub enum GroundingOutcome {
1047    /// A slice that grounds. `refined` is true when the single
1048    /// refine_retrieval retry produced it.
1049    Grounded { slice: NarrowedSlice, refined: bool },
1050    /// Both the first funnel pass and the single refine_retrieval retry
1051    /// grounded nothing. ASK answers honestly instead of inventing.
1052    NoMatchingSources,
1053}
1054
1055/// Fold the self-critique into grounding: run the funnel; if it grounds
1056/// nothing, re-funnel **exactly once** with expanded tokens (mirroring the
1057/// single citation retry of ADR 0013); if that still grounds nothing, report
1058/// `NoMatchingSources` so ASK answers honestly rather than inventing.
1059pub fn ground_with_refine<F: RetrievalFunnel>(funnel: &F) -> RedDBResult<GroundingOutcome> {
1060    let first = funnel.funnel(false)?;
1061    if !first.is_empty() {
1062        return Ok(GroundingOutcome::Grounded {
1063            slice: first,
1064            refined: false,
1065        });
1066    }
1067    // Exactly one refine_retrieval re-funnel with expanded tokens.
1068    let refined = funnel.funnel(true)?;
1069    if !refined.is_empty() {
1070        return Ok(GroundingOutcome::Grounded {
1071            slice: refined,
1072            refined: true,
1073        });
1074    }
1075    Ok(GroundingOutcome::NoMatchingSources)
1076}
1077
1078/// Extract the outermost `{...}` JSON object from a model response that may
1079/// carry code fences or surrounding prose.
1080fn extract_json_object(raw: &str) -> Option<&str> {
1081    let start = raw.find('{')?;
1082    let end = raw.rfind('}')?;
1083    if end <= start {
1084        return None;
1085    }
1086    Some(&raw[start..=end])
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use std::cell::RefCell;
1092    use std::collections::VecDeque;
1093
1094    use super::*;
1095
1096    fn slice() -> NarrowedSlice {
1097        NarrowedSlice {
1098            collections: vec![
1099                ScoredCollection {
1100                    collection: "travelers".to_string(),
1101                    score: 0.91,
1102                    columns: vec!["passport".to_string(), "name".to_string()],
1103                },
1104                ScoredCollection {
1105                    collection: "trips".to_string(),
1106                    score: 0.44,
1107                    columns: vec!["passport".to_string(), "city".to_string()],
1108                },
1109            ],
1110        }
1111    }
1112
1113    /// A mock planner model that returns a fixed plan JSON regardless of the
1114    /// prompt — stands in for the configured planner provider.
1115    fn mock_model(plan_json: &'static str) -> impl PlannerModel {
1116        move |_prompt: &str| Ok(plan_json.to_string())
1117    }
1118
1119    struct TestChatModel {
1120        responses: RefCell<VecDeque<crate::ai::AiChatResponse>>,
1121        requests: RefCell<Vec<(Vec<crate::ai::AiChatMessage>, Vec<String>)>>,
1122    }
1123
1124    impl TestChatModel {
1125        fn new(responses: Vec<crate::ai::AiChatResponse>) -> Self {
1126            Self {
1127                responses: RefCell::new(responses.into()),
1128                requests: RefCell::new(Vec::new()),
1129            }
1130        }
1131    }
1132
1133    impl PlannerChatModel for TestChatModel {
1134        fn chat(
1135            &self,
1136            messages: &[crate::ai::AiChatMessage],
1137            tools: &[crate::ai::AiToolDefinition],
1138        ) -> RedDBResult<crate::ai::AiChatResponse> {
1139            self.requests.borrow_mut().push((
1140                messages.to_vec(),
1141                tools.iter().map(|tool| tool.name.clone()).collect(),
1142            ));
1143            self.responses
1144                .borrow_mut()
1145                .pop_front()
1146                .ok_or_else(|| RedDBError::Internal("test model script exhausted".to_string()))
1147        }
1148    }
1149
1150    fn tool_response(
1151        id: &str,
1152        name: &str,
1153        arguments: Vec<(&str, crate::json::Value)>,
1154    ) -> crate::ai::AiChatResponse {
1155        let mut object = crate::json::Map::new();
1156        for (key, value) in arguments {
1157            object.insert(key.to_string(), value);
1158        }
1159        crate::ai::AiChatResponse {
1160            provider: "test",
1161            model: "test-model".to_string(),
1162            output_text: None,
1163            tool_calls: vec![crate::ai::AiToolCall {
1164                id: id.to_string(),
1165                name: name.to_string(),
1166                arguments: crate::json::Value::Object(object),
1167            }],
1168            prompt_tokens: None,
1169            completion_tokens: None,
1170            total_tokens: None,
1171            stop_reason: Some("tool_use".to_string()),
1172        }
1173    }
1174
1175    #[test]
1176    fn prompt_contains_only_narrowed_slice() {
1177        let prompt = build_planner_prompt("who owns passport FDD-1?", &slice());
1178        assert!(prompt.contains("travelers"));
1179        assert!(prompt.contains("trips"));
1180        assert!(prompt.contains("passport"));
1181        // A collection outside the narrowed slice must never appear.
1182        assert!(!prompt.contains("secret_ledger"));
1183    }
1184
1185    #[test]
1186    fn native_harness_keeps_tool_transcript_and_requires_final_query() {
1187        let model = TestChatModel::new(vec![
1188            tool_response(
1189                "call_1",
1190                "reddb_find_field",
1191                vec![("field", crate::json::Value::String("passport".to_string()))],
1192            ),
1193            tool_response(
1194                "call_2",
1195                "reddb_final_query",
1196                vec![
1197                    (
1198                        "sql",
1199                        crate::json::Value::String(
1200                            "SELECT name FROM travelers WHERE passport = $1".to_string(),
1201                        ),
1202                    ),
1203                    (
1204                        "params",
1205                        crate::json::Value::Array(vec![crate::json::Value::String(
1206                            "FDD-1".to_string(),
1207                        )]),
1208                    ),
1209                    (
1210                        "purpose",
1211                        crate::json::Value::String("answer the passport lookup".to_string()),
1212                    ),
1213                ],
1214            ),
1215        ]);
1216        let mut budget = PlanBudget::new(Some(3), 3);
1217        let executor = |call: &PlannerToolCall| {
1218            assert!(matches!(call, PlannerToolCall::FindField { .. }));
1219            Ok("[{\"collection\":\"travelers\",\"name\":\"passport\"}]".to_string())
1220        };
1221
1222        let outcome = run_bounded_chat_agent_loop(
1223            "who owns passport FDD-1?",
1224            &slice(),
1225            &mut budget,
1226            &model,
1227            &executor,
1228        )
1229        .expect("native harness");
1230        let AgentLoopOutcome::Routed {
1231            route,
1232            observations,
1233        } = outcome
1234        else {
1235            panic!("expected routed outcome")
1236        };
1237        assert_eq!(observations.len(), 1);
1238        assert_eq!(
1239            budget.executed_steps(),
1240            &[PlanStep::FindField, PlanStep::Query]
1241        );
1242        assert!(matches!(route.routing, PlanRouting::Execute { .. }));
1243
1244        let requests = model.requests.borrow();
1245        assert_eq!(requests.len(), 2);
1246        assert!(requests[0].1.iter().any(|name| name == "reddb_final_query"));
1247        assert!(requests[1]
1248            .0
1249            .iter()
1250            .any(|message| matches!(message, crate::ai::AiChatMessage::ToolResult { call_id, .. } if call_id == "call_1")));
1251    }
1252
1253    #[test]
1254    fn native_query_tool_cannot_smuggle_a_final_phase() {
1255        let call = crate::ai::AiToolCall {
1256            id: "call_1".to_string(),
1257            name: "reddb_query".to_string(),
1258            arguments: {
1259                let mut arguments = crate::json::Map::new();
1260                arguments.insert(
1261                    "sql".to_string(),
1262                    crate::json::Value::String("SELECT 1".to_string()),
1263                );
1264                arguments.insert("params".to_string(), crate::json::Value::Array(Vec::new()));
1265                arguments.insert(
1266                    "purpose".to_string(),
1267                    crate::json::Value::String("inspect".to_string()),
1268                );
1269                arguments.insert(
1270                    "phase".to_string(),
1271                    crate::json::Value::String("final".to_string()),
1272                );
1273                crate::json::Value::Object(arguments)
1274            },
1275        };
1276
1277        assert!(matches!(
1278            parse_native_tool_call(&call).expect("native call"),
1279            PlannerToolCall::Query {
1280                phase: QueryPhase::Explore,
1281                ..
1282            }
1283        ));
1284    }
1285
1286    #[test]
1287    fn parse_plan_reads_intent_and_query() {
1288        let plan = parse_plan(
1289            "{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"lookup\"}",
1290        )
1291        .unwrap();
1292        assert_eq!(plan.intent, AskIntent::Factual);
1293        assert_eq!(
1294            plan.query.as_deref(),
1295            Some("SELECT * FROM travelers WHERE passport = 'FDD-1'")
1296        );
1297        assert_eq!(plan.rationale, "lookup");
1298    }
1299
1300    #[test]
1301    fn metadata_tool_call_routes_to_a_bounded_agent_step() {
1302        let route = plan_and_route(
1303            "which collections contain passport_number?",
1304            &slice(),
1305            &mock_model(
1306                "{\"intent\":\"factual\",\"query\":null,\"tool\":{\"name\":\"reddb_find_field\",\"arguments\":{\"field\":\"passport_number\"}},\"rationale\":\"discover schema\"}",
1307            ),
1308        )
1309        .expect("metadata tool plan must route");
1310
1311        assert_eq!(
1312            route.routing,
1313            PlanRouting::UseTool {
1314                call: PlannerToolCall::FindField {
1315                    field: "passport_number".to_string()
1316                }
1317            }
1318        );
1319    }
1320
1321    #[test]
1322    fn query_tool_call_uses_the_same_read_only_validator() {
1323        let route = plan_and_route(
1324            "who owns passport FDD-1?",
1325            &slice(),
1326            &mock_model(
1327                "{\"intent\":\"factual\",\"query\":null,\"tool\":{\"name\":\"reddb_query\",\"arguments\":{\"sql\":\"SELECT * FROM travelers WHERE passport = $1\",\"params\":[\"FDD-1\"],\"purpose\":\"find the traveler\",\"phase\":\"final\"}}}",
1328            ),
1329        )
1330        .expect("read-only query tool must route");
1331
1332        match route.routing {
1333            PlanRouting::Execute {
1334                params, purpose, ..
1335            } => {
1336                assert_eq!(
1337                    params,
1338                    vec![crate::json::Value::String("FDD-1".to_string())]
1339                );
1340                assert_eq!(purpose, "find the traveler");
1341            }
1342            other => panic!("expected final query, got {other:?}"),
1343        }
1344    }
1345
1346    #[test]
1347    fn exploratory_query_cannot_bypass_the_mutation_refusal() {
1348        let route = plan_and_route(
1349            "remove FDD-1",
1350            &slice(),
1351            &mock_model(
1352                "{\"intent\":\"factual\",\"tool\":{\"name\":\"reddb_query\",\"arguments\":{\"sql\":\"DELETE FROM travelers WHERE passport = $1\",\"params\":[\"FDD-1\"],\"purpose\":\"remove a row\",\"phase\":\"explore\"}}}",
1353            ),
1354        )
1355        .expect("mutating tool calls become structured refusals");
1356
1357        assert!(matches!(
1358            route.routing,
1359            PlanRouting::RefuseMutating {
1360                statement_type: "delete",
1361                ..
1362            }
1363        ));
1364    }
1365
1366    #[test]
1367    fn query_tool_rejects_unbounded_or_invalid_params() {
1368        let not_an_array = parse_plan(
1369            "{\"intent\":\"factual\",\"tool\":{\"name\":\"reddb_query\",\"arguments\":{\"sql\":\"SELECT 1\",\"params\":\"secret\",\"phase\":\"final\"}}}",
1370        )
1371        .expect_err("params must be an array");
1372        assert!(not_an_array.to_string().contains("must be an array"));
1373
1374        let params = vec![
1375            crate::json::Value::Null;
1376            crate::runtime::agent_tools::ASK_QUERY_PARAMETER_COUNT_MAX + 1
1377        ];
1378        let mut arguments = crate::json::Map::new();
1379        arguments.insert(
1380            "sql".to_string(),
1381            crate::json::Value::String("SELECT 1".to_string()),
1382        );
1383        arguments.insert("params".to_string(), crate::json::Value::Array(params));
1384        let mut tool = crate::json::Map::new();
1385        tool.insert(
1386            "name".to_string(),
1387            crate::json::Value::String("reddb_query".to_string()),
1388        );
1389        tool.insert(
1390            "arguments".to_string(),
1391            crate::json::Value::Object(arguments),
1392        );
1393        let mut root = crate::json::Map::new();
1394        root.insert(
1395            "intent".to_string(),
1396            crate::json::Value::String("factual".to_string()),
1397        );
1398        root.insert("tool".to_string(), crate::json::Value::Object(tool));
1399        let raw = crate::json::to_string(&crate::json::Value::Object(root)).unwrap();
1400        let oversized = parse_plan(&raw).expect_err("parameter count must be bounded");
1401        assert!(oversized.to_string().contains("exceeds 64 values"));
1402    }
1403
1404    #[test]
1405    fn planner_rejects_unknown_tools_and_ambiguous_actions() {
1406        let unknown = parse_plan(
1407            "{\"intent\":\"factual\",\"tool\":{\"name\":\"reddb_vault_reveal\",\"arguments\":{}}}",
1408        )
1409        .expect_err("unknown tool must be rejected");
1410        assert!(
1411            unknown.to_string().contains("unsupported tool"),
1412            "{unknown}"
1413        );
1414
1415        let ambiguous = plan_and_route(
1416            "who owns passport FDD-1?",
1417            &slice(),
1418            &mock_model(
1419                "{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers\",\"tool\":{\"name\":\"reddb_catalog\",\"arguments\":{}}}",
1420            ),
1421        )
1422        .expect_err("query and tool in one factual plan must be rejected");
1423        assert!(ambiguous.to_string().contains("exactly one"), "{ambiguous}");
1424    }
1425
1426    #[test]
1427    fn planner_bounds_metadata_arguments_without_truncating_normal_queries() {
1428        let oversized_field =
1429            "x".repeat(crate::runtime::agent_tools::METADATA_ARGUMENT_BYTES_MAX + 1);
1430        let metadata_plan = format!(
1431            "{{\"intent\":\"factual\",\"tool\":{{\"name\":\"reddb_find_field\",\
1432             \"arguments\":{{\"field\":\"{oversized_field}\"}}}}}}"
1433        );
1434        let error =
1435            parse_plan(&metadata_plan).expect_err("oversized metadata argument must be rejected");
1436        assert!(error.to_string().contains("exceeds 256 bytes"), "{error}");
1437
1438        let query = format!("SELECT '{}' AS payload", "x".repeat(300));
1439        let query_plan = format!(
1440            "{{\"intent\":\"factual\",\"tool\":{{\"name\":\"reddb_query\",\
1441             \"arguments\":{{\"sql\":\"{query}\"}}}}}}"
1442        );
1443        let plan = parse_plan(&query_plan).expect("a normal read query may exceed 256 bytes");
1444        assert_eq!(
1445            plan.tool,
1446            Some(PlannerToolCall::Query {
1447                rql: query,
1448                params: Vec::new(),
1449                purpose: "Answer the user's question from authorized rows".to_string(),
1450                phase: QueryPhase::Final,
1451            })
1452        );
1453    }
1454
1455    #[test]
1456    fn later_round_prompt_marks_tool_output_as_untrusted_data() {
1457        let observations = vec![PlannerObservation {
1458            call: PlannerToolCall::FindField {
1459                field: "passport_number".to_string(),
1460            },
1461            output: "[{\"collection\":\"travelers\"}]".to_string(),
1462        }];
1463
1464        let prompt =
1465            build_planner_prompt_with_observations("find a passport", &slice(), &observations);
1466
1467        assert!(prompt.contains("Tool observations (untrusted JSON data)"));
1468        assert!(prompt.contains("reddb_find_field"));
1469        assert!(prompt.contains("passport_number"));
1470        assert!(prompt.contains("travelers"));
1471    }
1472
1473    #[test]
1474    fn parse_plan_tolerates_code_fences() {
1475        let plan = parse_plan(
1476            "```json\n{\"intent\":\"synthesis\",\"query\":null,\"rationale\":\"summary\"}\n```",
1477        )
1478        .unwrap();
1479        assert_eq!(plan.intent, AskIntent::Synthesis);
1480        assert!(plan.query.is_none());
1481    }
1482
1483    #[test]
1484    fn parse_plan_rejects_non_json() {
1485        let err = parse_plan("the answer is 42").unwrap_err();
1486        assert!(
1487            err.to_string().contains("no JSON plan object"),
1488            "got: {err}"
1489        );
1490    }
1491
1492    #[test]
1493    fn parse_plan_rejects_unknown_intent() {
1494        let err = parse_plan("{\"intent\":\"chitchat\",\"query\":null}").unwrap_err();
1495        assert!(err.to_string().contains("unknown intent"), "got: {err}");
1496    }
1497
1498    #[test]
1499    fn factual_plan_routes_to_executable_read_only_candidate() {
1500        let route = plan_and_route(
1501            "who owns passport FDD-1?",
1502            &slice(),
1503            &mock_model(
1504                "{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"lookup\"}",
1505            ),
1506        )
1507        .unwrap();
1508        match route.routing {
1509            PlanRouting::Execute { candidate, .. } => {
1510                assert!(candidate.is_read_only());
1511                assert_eq!(candidate.statement_type, "select");
1512            }
1513            other => panic!("expected Execute, got {other:?}"),
1514        }
1515    }
1516
1517    #[test]
1518    fn factual_join_candidate_is_executable() {
1519        let route = plan_and_route(
1520            "which cities did the owner of passport FDD-1 visit?",
1521            &slice(),
1522            &mock_model(
1523                "{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers JOIN trips ON travelers.passport = trips.passport WHERE travelers.passport = 'FDD-1'\",\"rationale\":\"join\"}",
1524            ),
1525        )
1526        .unwrap();
1527        assert!(matches!(route.routing, PlanRouting::Execute { .. }));
1528    }
1529
1530    #[test]
1531    fn factual_plan_with_mutating_candidate_is_refused_not_executed() {
1532        let route = plan_and_route(
1533            "delete traveler FDD-1",
1534            &slice(),
1535            &mock_model(
1536                "{\"intent\":\"factual\",\"query\":\"DELETE FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"oops\"}",
1537            ),
1538        )
1539        .unwrap();
1540        match route.routing {
1541            PlanRouting::RefuseMutating { statement_type, .. } => {
1542                assert_eq!(statement_type, "delete")
1543            }
1544            other => panic!("expected RefuseMutating, got {other:?}"),
1545        }
1546    }
1547
1548    #[test]
1549    fn factual_plan_with_malformed_candidate_is_rejected() {
1550        let err = plan_and_route(
1551            "who owns passport FDD-1?",
1552            &slice(),
1553            &mock_model(
1554                "{\"intent\":\"factual\",\"query\":\"this is not rql\",\"rationale\":\"x\"}",
1555            ),
1556        )
1557        .unwrap_err();
1558        assert!(
1559            err.to_string().contains("invalid RQL candidate"),
1560            "got: {err}"
1561        );
1562    }
1563
1564    #[test]
1565    fn factual_plan_without_query_is_rejected() {
1566        let err = plan_and_route(
1567            "who owns passport FDD-1?",
1568            &slice(),
1569            &mock_model("{\"intent\":\"factual\",\"query\":null,\"rationale\":\"x\"}"),
1570        )
1571        .unwrap_err();
1572        assert!(
1573            err.to_string().contains("no query or tool call"),
1574            "got: {err}"
1575        );
1576    }
1577
1578    #[test]
1579    fn synthesis_intent_routes_unsupported_in_this_slice() {
1580        let route = plan_and_route(
1581            "summarise yesterday's incidents",
1582            &slice(),
1583            &mock_model("{\"intent\":\"synthesis\",\"query\":null,\"rationale\":\"rag\"}"),
1584        )
1585        .unwrap();
1586        assert_eq!(
1587            route.routing,
1588            PlanRouting::Unsupported {
1589                intent: AskIntent::Synthesis
1590            }
1591        );
1592    }
1593
1594    #[test]
1595    fn synthesis_intent_carries_the_routed_intent_for_the_rag_fallthrough() {
1596        // #1749: a summarise/explain question routes to the RAG path. The
1597        // routing carries the intent so the orchestrator can record it on the
1598        // downstream audit row without re-classifying.
1599        let route = plan_and_route(
1600            "summarise the incidents from yesterday",
1601            &slice(),
1602            &mock_model("{\"intent\":\"synthesis\",\"query\":null,\"rationale\":\"rag\"}"),
1603        )
1604        .unwrap();
1605        match route.routing {
1606            PlanRouting::Unsupported { intent } => {
1607                assert_eq!(intent, AskIntent::Synthesis);
1608                assert_eq!(intent.as_str(), "synthesis");
1609            }
1610            other => panic!("expected Unsupported{{synthesis}}, got {other:?}"),
1611        }
1612    }
1613
1614    #[test]
1615    fn calculation_question_routes_factual_not_synthesis() {
1616        // ADR 0013 conformance boundary (#1749): a calculation-shaped question
1617        // must land on the *factual* intent — a read-only aggregate the engine
1618        // computes — never synthesis, where the LLM would be free to invent the
1619        // number. The planner emits `factual` + a read-only aggregate SELECT;
1620        // routing executes it and never falls through to RAG synthesis.
1621        let route = plan_and_route(
1622            "how many trips went to Lisbon?",
1623            &slice(),
1624            &mock_model(
1625                "{\"intent\":\"factual\",\"query\":\"SELECT COUNT(*) FROM trips WHERE city = 'Lisbon'\",\"rationale\":\"aggregate count\"}",
1626            ),
1627        )
1628        .unwrap();
1629        assert_ne!(
1630            route.plan.intent,
1631            AskIntent::Synthesis,
1632            "a calculation must never classify as synthesis (the LLM must not invent numbers)"
1633        );
1634        match route.routing {
1635            PlanRouting::Execute { candidate, .. } => {
1636                assert!(candidate.is_read_only());
1637                assert_eq!(candidate.statement_type, "select");
1638            }
1639            other => {
1640                panic!("a calculation must route to an executable factual candidate, got {other:?}")
1641            }
1642        }
1643    }
1644
1645    #[test]
1646    fn how_to_intent_routes_to_a_validated_suggestion_envelope() {
1647        // A how-to plan carries a natural-language answer plus a suggestion of
1648        // parser-validated statements — a read-only SELECT, a mutating DDL
1649        // CREATE QUEUE, and a mutating EVENTS BACKFILL.
1650        let route = plan_and_route(
1651            "how would I capture events from orders into a queue?",
1652            &slice(),
1653            &mock_model(
1654                "{\"intent\":\"how_to\",\"answer\":\"Create a queue and backfill events into it.\",\
1655                  \"suggestion\":[\
1656                    {\"rql\":\"CREATE QUEUE events_q WORK\",\"rationale\":\"the sink queue\"},\
1657                    {\"rql\":\"EVENTS BACKFILL orders TO events_q\",\"rationale\":\"seed history\"},\
1658                    {\"rql\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"inspect\"}\
1659                  ],\"rationale\":\"guide\"}",
1660            ),
1661        )
1662        .unwrap();
1663        match route.routing {
1664            PlanRouting::Suggest { answer, suggestion } => {
1665                assert!(answer.contains("Create a queue"));
1666                assert_eq!(suggestion.len(), 3);
1667                // DDL / mutating statements are present and flagged mutating,
1668                // and are NEVER executed — this envelope is advisory only.
1669                assert!(suggestion[0].mutating, "CREATE QUEUE is mutating DDL");
1670                assert!(suggestion[1].mutating, "EVENTS BACKFILL is mutating");
1671                assert!(!suggestion[2].mutating, "the SELECT is read-only");
1672                assert_eq!(suggestion[2].statement_type, "select");
1673                assert_eq!(suggestion[0].rationale, "the sink queue");
1674            }
1675            other => panic!("expected Suggest, got {other:?}"),
1676        }
1677    }
1678
1679    #[test]
1680    fn how_to_suggestion_drops_unparseable_statements_never_returns_them_raw() {
1681        // The middle statement is not valid RQL; it is dropped, and only the
1682        // two parser-valid statements survive in the envelope.
1683        let route = plan_and_route(
1684            "how do I list travelers?",
1685            &slice(),
1686            &mock_model(
1687                "{\"intent\":\"how_to\",\"answer\":\"Select from the collection.\",\
1688                  \"suggestion\":[\
1689                    {\"rql\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\"},\
1690                    {\"rql\":\"this is not rql at all\"},\
1691                    \"DELETE FROM travelers WHERE passport = 'FDD-1'\"\
1692                  ]}",
1693            ),
1694        )
1695        .unwrap();
1696        match route.routing {
1697            PlanRouting::Suggest { suggestion, .. } => {
1698                assert_eq!(suggestion.len(), 2, "the unparseable statement is dropped");
1699                assert_eq!(suggestion[0].statement_type, "select");
1700                // A bare-string suggestion item is accepted and validated too.
1701                assert_eq!(suggestion[1].statement_type, "delete");
1702                assert!(suggestion[1].mutating);
1703                for s in &suggestion {
1704                    assert!(
1705                        !s.rql.contains("not rql"),
1706                        "raw unparseable text must never survive"
1707                    );
1708                }
1709            }
1710            other => panic!("expected Suggest, got {other:?}"),
1711        }
1712    }
1713
1714    #[test]
1715    fn routing_distinguishes_how_to_from_factual_at_the_closure_model_seam() {
1716        // Same slice + question, only the model's intent classification differs
1717        // — the closure-model seam is what routes factual vs how-to.
1718        let factual = plan_and_route(
1719            "how would I capture events into a queue?",
1720            &slice(),
1721            &mock_model(
1722                "{\"intent\":\"factual\",\"query\":\"SELECT * FROM travelers WHERE passport = 'FDD-1'\",\"rationale\":\"x\"}",
1723            ),
1724        )
1725        .unwrap();
1726        assert!(matches!(factual.routing, PlanRouting::Execute { .. }));
1727
1728        let how_to = plan_and_route(
1729            "how would I capture events into a queue?",
1730            &slice(),
1731            &mock_model(
1732                "{\"intent\":\"how_to\",\"answer\":\"Use a queue.\",\"suggestion\":[\"CREATE QUEUE events_q WORK\"],\"rationale\":\"x\"}",
1733            ),
1734        )
1735        .unwrap();
1736        assert!(matches!(how_to.routing, PlanRouting::Suggest { .. }));
1737    }
1738
1739    // -----------------------------------------------------------------------
1740    // Plan budget: STEPS clamp + bounded step counter (#1748).
1741    // -----------------------------------------------------------------------
1742
1743    #[test]
1744    fn steps_clause_is_clamped_to_the_config_cap_never_exceeding_it() {
1745        // Above the cap → clamped down to the cap.
1746        assert_eq!(clamp_plan_steps(Some(9), 3), 3);
1747        // At/below the cap → honored verbatim.
1748        assert_eq!(clamp_plan_steps(Some(2), 3), 2);
1749        assert_eq!(clamp_plan_steps(Some(3), 3), 3);
1750        // Absent → falls back to the cap.
1751        assert_eq!(clamp_plan_steps(None, 3), 3);
1752        // Never below 1, even for a zero cap or a zero request.
1753        assert_eq!(clamp_plan_steps(Some(0), 3), 1);
1754        assert_eq!(clamp_plan_steps(None, 0), 1);
1755    }
1756
1757    #[test]
1758    fn plan_budget_charges_until_exhausted_then_refuses_more() {
1759        let mut budget = PlanBudget::new(Some(2), 3);
1760        assert_eq!(budget.max_steps(), 2);
1761        assert_eq!(budget.remaining(), 2);
1762
1763        assert!(budget.charge(PlanStep::RefineRetrieval).is_ok());
1764        assert_eq!(budget.remaining(), 1);
1765        assert!(budget.charge(PlanStep::Query).is_ok());
1766        assert_eq!(budget.remaining(), 0);
1767        assert_eq!(
1768            budget.executed_steps(),
1769            &[PlanStep::RefineRetrieval, PlanStep::Query]
1770        );
1771
1772        // The third step exhausts the budget — no unbounded loop.
1773        let err = budget.charge(PlanStep::Query).unwrap_err();
1774        assert_eq!(err.max_steps, 2);
1775        assert_eq!(err.executed_steps, 2);
1776        assert_eq!(err.attempted, PlanStep::Query);
1777        // The refused step is not recorded.
1778        assert_eq!(budget.executed_count(), 2);
1779    }
1780
1781    #[test]
1782    fn plan_budget_request_above_cap_cannot_buy_extra_steps() {
1783        // STEPS 5 with a cap of 1 → only one step ever executes.
1784        let mut budget = PlanBudget::new(Some(5), 1);
1785        assert_eq!(budget.max_steps(), 1);
1786        assert!(budget.charge(PlanStep::Query).is_ok());
1787        assert!(budget.charge(PlanStep::RefineRetrieval).is_err());
1788    }
1789
1790    #[test]
1791    fn metadata_calls_and_terminal_query_share_one_step_budget() {
1792        let mut budget = PlanBudget::new(Some(3), 3);
1793        budget
1794            .charge(PlanStep::Catalog)
1795            .expect("catalog step fits budget");
1796        budget
1797            .charge(PlanStep::FindField)
1798            .expect("field step fits budget");
1799        budget
1800            .charge(PlanStep::Query)
1801            .expect("query step fits budget");
1802
1803        assert_eq!(budget.remaining(), 0);
1804        assert_eq!(
1805            budget.executed_steps(),
1806            &[PlanStep::Catalog, PlanStep::FindField, PlanStep::Query]
1807        );
1808        assert!(budget.charge(PlanStep::DescribeCollection).is_err());
1809    }
1810
1811    #[test]
1812    fn exploratory_and_final_queries_are_distinct_budgeted_actions() {
1813        let mut budget = PlanBudget::new(Some(2), 2);
1814        budget
1815            .charge(PlanStep::ExploreQuery)
1816            .expect("exploration fits budget");
1817        budget
1818            .charge(PlanStep::Query)
1819            .expect("final query fits budget");
1820        assert_eq!(
1821            budget.executed_steps(),
1822            &[PlanStep::ExploreQuery, PlanStep::Query]
1823        );
1824    }
1825
1826    #[test]
1827    fn bounded_agent_loop_feeds_metadata_to_the_next_round_then_routes_query() {
1828        let responses = std::rc::Rc::new(std::cell::RefCell::new(
1829            std::collections::VecDeque::from([
1830                "{\"intent\":\"factual\",\"query\":null,\"tool\":{\"name\":\"reddb_find_field\",\"arguments\":{\"field\":\"passport_number\"}}}"
1831                    .to_string(),
1832                "{\"intent\":\"factual\",\"query\":\"SELECT name FROM travelers WHERE passport_number = 'FDD-1'\",\"tool\":null}"
1833                    .to_string(),
1834            ]),
1835        ));
1836        let prompts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1837        let responses_inner = responses.clone();
1838        let prompts_inner = prompts.clone();
1839        let model = move |prompt: &str| {
1840            prompts_inner.borrow_mut().push(prompt.to_string());
1841            Ok(responses_inner
1842                .borrow_mut()
1843                .pop_front()
1844                .expect("scripted planner response"))
1845        };
1846
1847        let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1848        let calls_inner = calls.clone();
1849        let executor = move |call: &PlannerToolCall| {
1850            calls_inner.borrow_mut().push(call.clone());
1851            Ok("{\"rows\":[{\"collection\":\"travelers\"}],\"truncated\":false}".to_string())
1852        };
1853        let mut budget = PlanBudget::new(Some(2), 3);
1854
1855        let outcome =
1856            run_bounded_agent_loop("who owns FDD-1?", &slice(), &mut budget, &model, &executor)
1857                .expect("bounded loop must reach the terminal query");
1858
1859        assert!(matches!(
1860            outcome,
1861            AgentLoopOutcome::Routed {
1862                route,
1863                ..
1864            } if matches!(route.routing, PlanRouting::Execute { .. })
1865        ));
1866        assert_eq!(
1867            budget.executed_steps(),
1868            &[PlanStep::FindField, PlanStep::Query]
1869        );
1870        assert_eq!(
1871            calls.borrow().as_slice(),
1872            &[PlannerToolCall::FindField {
1873                field: "passport_number".to_string()
1874            }]
1875        );
1876        let prompts = prompts.borrow();
1877        assert_eq!(prompts.len(), 2);
1878        assert!(prompts[1].contains("Tool observations (untrusted JSON data)"));
1879        assert!(prompts[1].contains("travelers"));
1880    }
1881
1882    #[test]
1883    fn bounded_agent_loop_executes_parameterized_exploration_before_final_query() {
1884        let responses = std::rc::Rc::new(std::cell::RefCell::new(
1885            std::collections::VecDeque::from([
1886                "{\"intent\":\"factual\",\"tool\":{\"name\":\"reddb_query\",\"arguments\":{\"sql\":\"SELECT id FROM travelers WHERE passport = $1 LIMIT 5\",\"params\":[\"FDD-1\"],\"purpose\":\"resolve person id\",\"phase\":\"explore\"}}}"
1887                    .to_string(),
1888                "{\"intent\":\"factual\",\"tool\":{\"name\":\"reddb_query\",\"arguments\":{\"sql\":\"SELECT name FROM travelers WHERE passport = $1\",\"params\":[\"FDD-1\"],\"purpose\":\"answer who owns the passport\",\"phase\":\"final\"}}}"
1889                    .to_string(),
1890            ]),
1891        ));
1892        let responses_inner = responses.clone();
1893        let model = move |_prompt: &str| {
1894            Ok(responses_inner
1895                .borrow_mut()
1896                .pop_front()
1897                .expect("scripted planner response"))
1898        };
1899        let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1900        let calls_inner = calls.clone();
1901        let executor = move |call: &PlannerToolCall| {
1902            calls_inner.borrow_mut().push(call.clone());
1903            Ok("{\"rows\":[{\"id\":\"t1\"}],\"truncated\":false}".to_string())
1904        };
1905        let mut budget = PlanBudget::new(Some(2), 2);
1906
1907        let outcome =
1908            run_bounded_agent_loop("who owns FDD-1?", &slice(), &mut budget, &model, &executor)
1909                .expect("exploration must lead to a final query");
1910
1911        let AgentLoopOutcome::Routed {
1912            route,
1913            observations,
1914        } = outcome
1915        else {
1916            panic!("expected routed outcome");
1917        };
1918        assert_eq!(observations.len(), 1);
1919        assert_eq!(calls.borrow().len(), 1);
1920        assert_eq!(
1921            budget.executed_steps(),
1922            &[PlanStep::ExploreQuery, PlanStep::Query]
1923        );
1924        match route.routing {
1925            PlanRouting::Execute {
1926                candidate,
1927                params,
1928                purpose,
1929            } => {
1930                assert_eq!(
1931                    candidate.rql,
1932                    "SELECT name FROM travelers WHERE passport = $1"
1933                );
1934                assert_eq!(
1935                    params,
1936                    vec![crate::json::Value::String("FDD-1".to_string())]
1937                );
1938                assert_eq!(purpose, "answer who owns the passport");
1939            }
1940            other => panic!("expected final query, got {other:?}"),
1941        }
1942    }
1943
1944    #[test]
1945    fn bounded_agent_loop_does_not_call_the_model_after_the_last_tool_step() {
1946        let planner_calls = std::rc::Rc::new(std::cell::Cell::new(0usize));
1947        let planner_calls_inner = planner_calls.clone();
1948        let model = move |_prompt: &str| {
1949            planner_calls_inner.set(planner_calls_inner.get() + 1);
1950            Ok("{\"intent\":\"factual\",\"query\":null,\"tool\":{\"name\":\"reddb_catalog\",\"arguments\":{}}}"
1951                .to_string())
1952        };
1953        let tool_calls = std::rc::Rc::new(std::cell::Cell::new(0usize));
1954        let tool_calls_inner = tool_calls.clone();
1955        let executor = move |_call: &PlannerToolCall| {
1956            tool_calls_inner.set(tool_calls_inner.get() + 1);
1957            Ok("{\"rows\":[],\"truncated\":false}".to_string())
1958        };
1959        let mut budget = PlanBudget::new(Some(1), 3);
1960
1961        let outcome = run_bounded_agent_loop(
1962            "what data is available?",
1963            &slice(),
1964            &mut budget,
1965            &model,
1966            &executor,
1967        )
1968        .expect("bounded loop must return structured exhaustion");
1969
1970        assert_eq!(
1971            outcome,
1972            AgentLoopOutcome::BudgetExhausted(BudgetExhausted {
1973                attempted: PlanStep::Catalog,
1974                executed_steps: 0,
1975                max_steps: 1,
1976            })
1977        );
1978        assert_eq!(planner_calls.get(), 1, "no extra planner round");
1979        assert_eq!(tool_calls.get(), 0, "the final step stays reserved");
1980        assert!(budget.executed_steps().is_empty());
1981    }
1982
1983    // -----------------------------------------------------------------------
1984    // Grounding critique + single refine_retrieval retry (#1748).
1985    // -----------------------------------------------------------------------
1986
1987    /// A funnel closure that counts calls and returns empty/non-empty per the
1988    /// script `[first, refined]`.
1989    fn scripted_funnel(
1990        script: Vec<bool>,
1991    ) -> (impl RetrievalFunnel, std::rc::Rc<std::cell::Cell<usize>>) {
1992        let calls = std::rc::Rc::new(std::cell::Cell::new(0usize));
1993        let calls_inner = calls.clone();
1994        let funnel = move |_expanded: bool| -> RedDBResult<NarrowedSlice> {
1995            let n = calls_inner.get();
1996            calls_inner.set(n + 1);
1997            let non_empty = script.get(n).copied().unwrap_or(false);
1998            Ok(if non_empty {
1999                slice()
2000            } else {
2001                NarrowedSlice::default()
2002            })
2003        };
2004        (funnel, calls)
2005    }
2006
2007    #[test]
2008    fn grounding_succeeds_on_first_pass_without_refining() {
2009        let (funnel, calls) = scripted_funnel(vec![true]);
2010        let outcome = ground_with_refine(&funnel).unwrap();
2011        assert_eq!(calls.get(), 1, "no refine when the first pass grounds");
2012        match outcome {
2013            GroundingOutcome::Grounded { refined, .. } => assert!(!refined),
2014            other => panic!("expected Grounded, got {other:?}"),
2015        }
2016    }
2017
2018    #[test]
2019    fn empty_first_pass_triggers_exactly_one_refine_retrieval() {
2020        // First pass grounds nothing; the single refine retry grounds.
2021        let (funnel, calls) = scripted_funnel(vec![false, true]);
2022        let outcome = ground_with_refine(&funnel).unwrap();
2023        assert_eq!(calls.get(), 2, "exactly one refine re-funnel");
2024        match outcome {
2025            GroundingOutcome::Grounded { refined, .. } => assert!(refined),
2026            other => panic!("expected Grounded after refine, got {other:?}"),
2027        }
2028    }
2029
2030    #[test]
2031    fn second_grounding_failure_returns_no_matching_sources_not_an_invented_answer() {
2032        // Both passes ground nothing → honest no-matching-sources; the funnel
2033        // is called exactly twice and never a third time.
2034        let (funnel, calls) = scripted_funnel(vec![false, false]);
2035        let outcome = ground_with_refine(&funnel).unwrap();
2036        assert_eq!(calls.get(), 2, "exactly one refine retry, then give up");
2037        assert_eq!(outcome, GroundingOutcome::NoMatchingSources);
2038    }
2039
2040    #[test]
2041    fn plan_summary_includes_intent_and_query() {
2042        let plan = AskPlan {
2043            intent: AskIntent::Factual,
2044            query: Some("SELECT * FROM travelers WHERE passport = 'FDD-1'".to_string()),
2045            tool: None,
2046            answer: String::new(),
2047            suggestion: Vec::new(),
2048            rationale: "lookup".to_string(),
2049        };
2050        let summary = plan.summary();
2051        assert!(summary.contains("intent=factual"));
2052        assert!(summary.contains("SELECT * FROM travelers"));
2053    }
2054}