Skip to main content

batuta/agent/driver/
realizar.rs

1//! RealizarDriver — sovereign local inference via GGUF/APR models.
2//!
3//! Uses the `realizar` crate for local LLM inference. All data
4//! stays on-device (Sovereign privacy tier, Genchi Genbutsu).
5//!
6//! Tool call parsing: local models output `<tool_call>` JSON blocks
7//! in their text. The driver extracts these into `ToolCall` structs.
8//!
9//! Feature-gated behind `inference`.
10
11use async_trait::async_trait;
12use std::path::PathBuf;
13use tracing::info;
14
15use super::chat_template::{format_prompt_with_template, ChatTemplate};
16use super::validate::validate_model_file;
17use super::{CompletionRequest, CompletionResponse, LlmDriver, ToolCall};
18use crate::agent::result::{AgentError, DriverError, StopReason, TokenUsage};
19use crate::serve::backends::PrivacyTier;
20
21/// Local inference driver using realizar (GGUF/APR/SafeTensors).
22pub struct RealizarDriver {
23    /// Path to model file.
24    model_path: PathBuf,
25    /// Context window size.
26    context_window_size: usize,
27    /// Auto-detected chat template.
28    template: ChatTemplate,
29}
30
31impl RealizarDriver {
32    /// Create a new RealizarDriver from a model path.
33    ///
34    /// **Contract: `apr_model_validity` (apr-code-v1.yaml)**
35    ///
36    /// Preconditions enforced at the load boundary (Jidoka):
37    /// - File must exist
38    /// - APR files: must have embedded tokenizer (checked via header)
39    /// - GGUF files: must have valid magic bytes
40    ///
41    /// Violation → actionable error with re-conversion instructions.
42    /// No broken model ever reaches the inference loop.
43    pub fn new(model_path: PathBuf, context_window: Option<usize>) -> Result<Self, AgentError> {
44        if !model_path.exists() {
45            return Err(AgentError::Driver(DriverError::InferenceFailed(format!(
46                "model not found: {}",
47                model_path.display()
48            ))));
49        }
50
51        // ═══ CONTRACT: apr_model_validity — Jidoka boundary check ═══
52        validate_model_file(&model_path)?;
53
54        let context_window_size = context_window.unwrap_or(4096);
55        let template = ChatTemplate::from_model_path(&model_path);
56        Ok(Self { model_path, context_window_size, template })
57    }
58}
59
60#[async_trait]
61impl LlmDriver for RealizarDriver {
62    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, AgentError> {
63        // Format messages using auto-detected chat template
64        let prompt = format_prompt_with_template(&request, self.template);
65
66        // Build inference config (explicit fields — no Default impl)
67        let config = realizar::infer::InferenceConfig {
68            model_path: self.model_path.clone(),
69            prompt: Some(prompt),
70            input_tokens: None,
71            max_tokens: request.max_tokens as usize,
72            temperature: request.temperature,
73            top_k: 0,
74            // PMAT-823: new sampling fields default to greedy/disabled so this
75            // agent driver's behavior is unchanged (it only exposes temperature).
76            top_p: None,
77            seed: 42,
78            repeat_penalty: 1.0,
79            repeat_last_n: 64,
80            // PMAT-156/158: Disable GPU only for APR models (wgpu shader bug).
81            // GGUF models work fine with CUDA — keep GPU enabled for them.
82            no_gpu: self.model_path.extension().is_some_and(|e| e == "apr"),
83            trace: false,
84            trace_verbose: false,
85            trace_output: None,
86            trace_steps: None,
87            verbose: false,
88            use_mock_backend: false,
89            stop_tokens: vec![],
90        };
91
92        // Run inference in blocking thread (realizar is sync)
93        let result = tokio::task::spawn_blocking(move || realizar::infer::run_inference(&config))
94            .await
95            .map_err(|e| {
96                AgentError::Driver(DriverError::InferenceFailed(format!("spawn_blocking: {e}")))
97            })?
98            .map_err(|e| AgentError::Driver(DriverError::InferenceFailed(e.to_string())))?;
99
100        // Parse tool calls from text output
101        let (raw_text, tool_calls) = parse_tool_calls(&result.text);
102
103        // Sanitize output: strip echoed system prompt and chat template markers
104        let text = sanitize_output(&raw_text, request.system.as_deref());
105
106        let stop_reason =
107            if tool_calls.is_empty() { StopReason::EndTurn } else { StopReason::ToolUse };
108
109        Ok(CompletionResponse {
110            text,
111            stop_reason,
112            tool_calls,
113            usage: TokenUsage {
114                input_tokens: result.input_token_count as u64,
115                output_tokens: result.generated_token_count as u64,
116            },
117        })
118    }
119
120    fn context_window(&self) -> usize {
121        self.context_window_size
122    }
123
124    fn privacy_tier(&self) -> PrivacyTier {
125        PrivacyTier::Sovereign
126    }
127}
128
129/// Parse tool calls from model output text.
130///
131/// Supports multiple formats (PMAT-158):
132/// 1. `<tool_call>{"name":...}</tool_call>` — custom XML tags
133/// 2. `<tool_call>{"name":...}` — unclosed XML (small model fallback)
134/// 3. `` ```json\n{"name":...}\n``` `` — markdown code block (Qwen native)
135///
136/// CCPA-m296 SALVAGE: if the envelope parser above finds NOTHING but the
137/// text is recoverably tool-call-shaped — a generically-fenced block
138/// (`` ```...{"name":..,"input":..}... ``` ``, any language tag or none) or a
139/// bare top-level `{"name":..,"input":..}` JSON object — [`salvage_tool_calls`]
140/// recovers it. This converts "the model almost emitted a tool_call" near-misses
141/// into real tool calls instead of letting the raw Markdown re-prime prose mode
142/// across turns (the self-reinforcing text loop that defeats tool-calling).
143///
144/// Returns the remaining text (with tool call blocks removed)
145/// and the extracted tool calls.
146/// Public wrapper for tool call parsing (used by AprServeDriver).
147pub fn parse_tool_calls_pub(text: &str) -> (String, Vec<ToolCall>) {
148    parse_tool_calls(text)
149}
150
151fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
152    let (remaining, calls) = parse_tool_calls_envelope(text);
153    if !calls.is_empty() {
154        return (remaining, calls);
155    }
156    // CCPA-m296: envelope parser found no tool call — try the conservative
157    // salvage parser before scoring this turn as inert prose text.
158    let (salvaged_remaining, salvaged) = salvage_tool_calls(&remaining);
159    if !salvaged.is_empty() {
160        info!(count = salvaged.len(), "salvaged tool call(s) from non-envelope output (CCPA-m296)");
161        return (salvaged_remaining, salvaged);
162    }
163    (remaining, calls)
164}
165
166fn parse_tool_calls_envelope(text: &str) -> (String, Vec<ToolCall>) {
167    let mut tool_calls = Vec::new();
168    let mut remaining = String::new();
169    let mut call_counter = 0u32;
170
171    let mut cursor = text;
172    loop {
173        // Find next tool call start — try <tool_call> first, then ```json
174        let xml_pos = cursor.find("<tool_call>");
175        let md_pos = cursor.find("```json");
176
177        let (start, tag_len, is_markdown) = match (xml_pos, md_pos) {
178            (Some(x), Some(m)) if x <= m => (x, "<tool_call>".len(), false),
179            (Some(x), None) => (x, "<tool_call>".len(), false),
180            (_, Some(m)) => (m, "```json".len(), true),
181            (None, None) => {
182                remaining.push_str(cursor);
183                break;
184            }
185        };
186
187        remaining.push_str(&cursor[..start]);
188        let after_tag = &cursor[start + tag_len..];
189
190        // Find closing tag and extract JSON
191        let (json_str, advance_past) = if is_markdown {
192            // Markdown: ```json\n...\n```
193            if let Some(end) = after_tag.find("```") {
194                (&after_tag[..end], &after_tag[end + "```".len()..])
195            } else {
196                (after_tag, "")
197            }
198        } else if let Some(end) = after_tag.find("</tool_call>") {
199            (&after_tag[..end], &after_tag[end + "</tool_call>".len()..])
200        } else {
201            // PMAT-158: No closing tag — try parsing to end-of-string
202            (after_tag, "")
203        };
204        let json_str = json_str.trim();
205
206        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
207            // Must have "name" field to be a tool call (not just any JSON)
208            if let Some(name) = parsed.get("name").and_then(|n| n.as_str()) {
209                let name = name.to_string();
210                let input = parsed.get("input").cloned().unwrap_or(serde_json::json!({}));
211                call_counter += 1;
212                tool_calls.push(ToolCall { id: format!("local-{call_counter}"), name, input });
213            } else {
214                remaining.push_str(&cursor[start..]);
215                break;
216            }
217        } else {
218            remaining.push_str(&cursor[start..]);
219            break;
220        }
221
222        cursor = advance_past;
223        if cursor.is_empty() {
224            break;
225        }
226    }
227
228    (remaining.trim().to_string(), tool_calls)
229}
230
231/// CCPA-m296 salvage parser: recover a tool call the model emitted OUTSIDE the
232/// exact `<tool_call>` / ```json envelope, but in an unambiguous, recoverable
233/// shape. Two recoverable shapes are accepted, in priority order:
234///
235/// 1. A generically-fenced code block — `` ```<anylang>\n{...}\n``` `` — whose
236///    inner content parses as a tool-call-shaped JSON object. (The envelope
237///    parser only recognises the exact `` ```json `` tag; coder-finetuned models
238///    routinely emit `` ```tool_call ``, `` ```rust ``, or a bare `` ``` ``.)
239/// 2. A bare top-level `{"name": "...", "input": {...}}` JSON object embedded in
240///    prose (no fence, no tags).
241///
242/// CONSERVATIVE BY DESIGN: only JSON objects that (a) parse cleanly and (b) have
243/// a string `name` field AND an `input` field are salvaged. Plain JSON examples
244/// (e.g. `{"key": "value"}`) and prose are never mistaken for tool calls. This
245/// directly recovers the "model almost emitted a tool_call" near-misses that
246/// would otherwise be scored as inert text and re-prime prose mode next turn.
247///
248/// Returns the remaining text (with the salvaged span removed) and the
249/// recovered calls (`salvage-{n}` ids so salvage events stay traceable).
250fn salvage_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
251    // Shape 1: a generic fenced block ```<tag>\n ... \n```
252    if let Some((before, inner, after)) = extract_first_fenced_block(text) {
253        if let Some(call) = tool_call_from_json_str(inner.trim(), 1) {
254            let remaining = format!("{before}{after}");
255            return (remaining.trim().to_string(), vec![call]);
256        }
257    }
258
259    // Shape 2: a bare top-level {"name":..,"input":..} object embedded in prose.
260    if let Some((start, end)) = find_balanced_json_object(text) {
261        if let Some(call) = tool_call_from_json_str(text[start..end].trim(), 1) {
262            let remaining = format!("{}{}", &text[..start], &text[end..]);
263            return (remaining.trim().to_string(), vec![call]);
264        }
265    }
266
267    (text.trim().to_string(), Vec::new())
268}
269
270/// Parse a JSON string into a tool call iff it is unambiguously tool-call-shaped:
271/// a JSON object with a string `name` field AND an `input` field. Returns `None`
272/// otherwise (plain JSON, arrays, scalars, prose).
273fn tool_call_from_json_str(json_str: &str, idx: u32) -> Option<ToolCall> {
274    let parsed = serde_json::from_str::<serde_json::Value>(json_str).ok()?;
275    let obj = parsed.as_object()?;
276    // Require BOTH name (string) and an explicit input field — stricter than the
277    // envelope parser (which defaults input to {}) so prose/JSON examples that
278    // merely contain a "name" key are never salvaged.
279    let name = obj.get("name")?.as_str()?.to_string();
280    if name.is_empty() {
281        return None;
282    }
283    let input = obj.get("input")?.clone();
284    Some(ToolCall { id: format!("salvage-{idx}"), name, input })
285}
286
287/// Extract the first ```...``` fenced block: returns (text-before, inner, text-after).
288/// Accepts any language tag (or none); the inner content is everything between the
289/// opening fence's newline and the closing fence.
290fn extract_first_fenced_block(text: &str) -> Option<(&str, &str, &str)> {
291    let open = text.find("```")?;
292    let before = &text[..open];
293    let rest = &text[open + 3..];
294    // Skip the optional language tag up to (and including) the first newline.
295    let inner_start = rest.find('\n').map(|i| i + 1)?;
296    let body = &rest[inner_start..];
297    let close = body.find("```")?;
298    let inner = &body[..close];
299    let after = &body[close + 3..];
300    Some((before, inner, after))
301}
302
303/// Find the first balanced top-level `{...}` JSON object span in `text`.
304/// Returns `(start, end)` byte indices (end exclusive) of the object including
305/// braces, tracking string literals + escapes so braces inside strings don't
306/// unbalance the scan. Returns `None` if no balanced object is found.
307fn find_balanced_json_object(text: &str) -> Option<(usize, usize)> {
308    let bytes = text.as_bytes();
309    let start = text.find('{')?;
310    let mut depth = 0i32;
311    let mut in_str = false;
312    let mut escaped = false;
313    let mut i = start;
314    while i < bytes.len() {
315        let c = bytes[i];
316        if in_str {
317            if escaped {
318                escaped = false;
319            } else if c == b'\\' {
320                escaped = true;
321            } else if c == b'"' {
322                in_str = false;
323            }
324        } else {
325            match c {
326                b'"' => in_str = true,
327                b'{' => depth += 1,
328                b'}' => {
329                    depth -= 1;
330                    if depth == 0 {
331                        return Some((start, i + 1));
332                    }
333                }
334                _ => {}
335            }
336        }
337        i += 1;
338    }
339    None
340}
341
342/// Sanitize model output: strip echoed system prompt and chat template markers.
343///
344/// Small models (<3B) often echo the system prompt or leak chat template
345/// tokens into their response. This strips those artifacts so the agent
346/// loop sees clean assistant text.
347fn sanitize_output(text: &str, system_prompt: Option<&str>) -> String {
348    let mut cleaned = text.to_string();
349
350    // Strip echoed system prompt (common with small models)
351    if let Some(sys) = system_prompt {
352        // Check if output starts with a significant prefix of the system prompt
353        let sys_prefix = &sys[..sys.len().min(80)];
354        if cleaned.starts_with(sys_prefix) {
355            // The model regurgitated the system prompt — strip it
356            cleaned = cleaned[sys.len().min(cleaned.len())..].to_string();
357        }
358    }
359
360    // Strip leaked chat template markers
361    for marker in &[
362        "<|im_start|>",
363        "<|im_end|>",
364        "<|start_header_id|>",
365        "<|end_header_id|>",
366        "<|eot_id|>",
367        "<|system|>",
368        "<|user|>",
369        "<|assistant|>",
370        "<|end|>",
371    ] {
372        cleaned = cleaned.replace(marker, "");
373    }
374
375    // Strip leading/trailing whitespace and role labels
376    let cleaned = cleaned.trim();
377    let cleaned = cleaned.strip_prefix("system\n").unwrap_or(cleaned);
378    let cleaned = cleaned.strip_prefix("assistant\n").unwrap_or(cleaned);
379    cleaned.trim().to_string()
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_parse_no_tool_calls() {
388        let (text, calls) = parse_tool_calls("Hello world");
389        assert_eq!(text, "Hello world");
390        assert!(calls.is_empty());
391    }
392
393    #[test]
394    fn test_parse_single_tool_call() {
395        let input = r#"Before text
396<tool_call>
397{"name": "rag", "input": {"query": "SIMD"}}
398</tool_call>
399After text"#;
400        let (text, calls) = parse_tool_calls(input);
401        assert_eq!(text, "Before text\n\nAfter text");
402        assert_eq!(calls.len(), 1);
403        assert_eq!(calls[0].name, "rag");
404        assert_eq!(calls[0].id, "local-1");
405        assert_eq!(calls[0].input, serde_json::json!({"query": "SIMD"}));
406    }
407
408    #[test]
409    fn test_parse_multiple_tool_calls() {
410        let input = r#"<tool_call>
411{"name": "rag", "input": {"query": "a"}}
412</tool_call>
413Middle
414<tool_call>
415{"name": "memory", "input": {"action": "recall", "query": "b"}}
416</tool_call>"#;
417        let (text, calls) = parse_tool_calls(input);
418        assert_eq!(text, "Middle");
419        assert_eq!(calls.len(), 2);
420        assert_eq!(calls[0].name, "rag");
421        assert_eq!(calls[0].id, "local-1");
422        assert_eq!(calls[1].name, "memory");
423        assert_eq!(calls[1].id, "local-2");
424    }
425
426    #[test]
427    fn test_parse_malformed_json() {
428        let input = r#"<tool_call>
429not valid json
430</tool_call>"#;
431        let (_text, calls) = parse_tool_calls(input);
432        assert!(calls.is_empty());
433    }
434
435    #[test]
436    fn test_parse_missing_close_tag_with_valid_json() {
437        // PMAT-158: Small models omit </tool_call>. Parser should still extract.
438        let input =
439            "<tool_call>\n{\"name\": \"file_read\", \"input\": {\"path\": \"src/main.rs\"}}";
440        let (text, calls) = parse_tool_calls(input);
441        assert_eq!(calls.len(), 1, "should extract tool call without closing tag");
442        assert_eq!(calls[0].name, "file_read");
443        assert!(text.is_empty(), "no remaining text expected");
444    }
445
446    #[test]
447    fn test_parse_missing_close_tag_with_trailing_text() {
448        // Unclosed tag with text before it — text preserved, tool call extracted
449        let input =
450            "Let me read that.\n<tool_call> {\"name\": \"file_read\", \"input\": {\"path\": \"foo.rs\"}}";
451        let (text, calls) = parse_tool_calls(input);
452        assert_eq!(calls.len(), 1);
453        assert_eq!(calls[0].name, "file_read");
454        assert!(text.contains("Let me read that"));
455    }
456
457    #[test]
458    fn test_parse_missing_close_tag_invalid_json() {
459        // Unclosed tag with invalid JSON — treated as plain text
460        let input = "<tool_call>\nnot valid json at all";
461        let (text, calls) = parse_tool_calls(input);
462        assert!(calls.is_empty(), "invalid JSON should not produce tool call");
463        assert!(text.contains("<tool_call>"));
464    }
465
466    #[test]
467    fn test_parse_markdown_code_block() {
468        // PMAT-158: Qwen2.5-Coder native format — ```json blocks
469        let input = "Let me read that file.\n```json\n{\"name\": \"file_read\", \"input\": {\"path\": \"src/main.rs\"}}\n```";
470        let (text, calls) = parse_tool_calls(input);
471        assert_eq!(calls.len(), 1, "should extract tool call from markdown block");
472        assert_eq!(calls[0].name, "file_read");
473        assert_eq!(calls[0].input["path"], "src/main.rs");
474        assert!(text.contains("Let me read that"));
475    }
476
477    #[test]
478    fn test_parse_markdown_code_block_not_tool_call() {
479        // JSON in code block without "name" field — not a tool call
480        let input = "Here's an example:\n```json\n{\"key\": \"value\"}\n```";
481        let (text, calls) = parse_tool_calls(input);
482        assert!(calls.is_empty(), "JSON without name field should not be a tool call");
483        assert!(text.contains("example"));
484    }
485
486    #[test]
487    fn test_parse_missing_name() {
488        let input = r#"<tool_call>
489{"input": {"query": "test"}}
490</tool_call>"#;
491        let (_, calls) = parse_tool_calls(input);
492        assert!(calls.is_empty(), "JSON without name should not be extracted");
493    }
494
495    #[test]
496    fn test_privacy_tier_always_sovereign() {
497        assert_eq!(PrivacyTier::Sovereign, PrivacyTier::Sovereign);
498    }
499
500    // ── Output sanitization tests ──
501
502    #[test]
503    fn test_sanitize_strips_echoed_system_prompt() {
504        let sys = "You are apr code, a sovereign AI coding assistant.";
505        let output = format!("{sys} And then the model continues here.");
506        let cleaned = sanitize_output(&output, Some(sys));
507        assert!(!cleaned.contains("sovereign AI coding assistant"));
508        assert!(cleaned.contains("continues here"));
509    }
510
511    #[test]
512    fn test_sanitize_strips_chat_markers() {
513        let output = "<|im_start|>assistant\nHello world<|im_end|>";
514        let cleaned = sanitize_output(output, None);
515        assert_eq!(cleaned, "Hello world");
516    }
517
518    #[test]
519    fn test_sanitize_preserves_clean_output() {
520        let output = "The answer is 42.";
521        let cleaned = sanitize_output(output, Some("You are helpful."));
522        assert_eq!(cleaned, "The answer is 42.");
523    }
524
525    #[test]
526    fn test_sanitize_strips_role_prefix() {
527        let output = "assistant\nHere is my response.";
528        let cleaned = sanitize_output(output, None);
529        assert_eq!(cleaned, "Here is my response.");
530    }
531
532    // ── CCPA-m296 salvage parser tests ──
533
534    #[test]
535    fn test_salvage_bare_top_level_json_tool_call() {
536        // Model emitted a bare {"name","input"} object with NO envelope/fence.
537        // Without salvage this scores as inert prose and re-primes prose mode.
538        let input =
539            "Sure, I'll read it.\n{\"name\": \"file_read\", \"input\": {\"path\": \"src/lib.rs\"}}";
540        let (text, calls) = parse_tool_calls(input);
541        assert_eq!(calls.len(), 1, "salvage must recover a bare tool-call JSON object");
542        assert_eq!(calls[0].name, "file_read");
543        assert_eq!(calls[0].input["path"], "src/lib.rs");
544        assert!(calls[0].id.starts_with("salvage-"), "salvaged calls carry a traceable id");
545        assert!(text.contains("Sure, I'll read it"), "prose around the call is preserved");
546        assert!(!text.contains("file_read"), "the salvaged JSON span is removed from text");
547    }
548
549    #[test]
550    fn test_salvage_generic_fenced_block_non_json_tag() {
551        // Coder models fence tool calls with ```tool_call / ```rust, not ```json.
552        // The envelope parser only knows ```json; salvage must catch the rest.
553        let input =
554            "```tool_call\n{\"name\": \"shell\", \"input\": {\"command\": \"cargo test\"}}\n```";
555        let (_text, calls) = parse_tool_calls(input);
556        assert_eq!(calls.len(), 1, "salvage must recover a generically-fenced tool call");
557        assert_eq!(calls[0].name, "shell");
558        assert_eq!(calls[0].input["command"], "cargo test");
559    }
560
561    #[test]
562    fn test_salvage_conservative_rejects_plain_json() {
563        // A bare JSON object WITHOUT name+input is NOT a tool call — never salvage it.
564        let input = "Here is some config:\n{\"key\": \"value\", \"count\": 3}";
565        let (text, calls) = parse_tool_calls(input);
566        assert!(calls.is_empty(), "plain JSON (no name+input) must not be salvaged");
567        assert!(text.contains("config"));
568    }
569
570    #[test]
571    fn test_salvage_conservative_rejects_name_without_input() {
572        // Stricter than the envelope parser: salvage requires an explicit `input`.
573        let input = "{\"name\": \"file_read\"}";
574        let (_text, calls) = parse_tool_calls(input);
575        assert!(calls.is_empty(), "name without input is too ambiguous to salvage");
576    }
577
578    #[test]
579    fn test_salvage_handles_braces_inside_strings() {
580        // The balanced-object scanner must not unbalance on braces inside strings.
581        let input = "{\"name\": \"shell\", \"input\": {\"command\": \"echo ${HOME} and }{\"}}";
582        let (_text, calls) = parse_tool_calls(input);
583        assert_eq!(calls.len(), 1);
584        assert_eq!(calls[0].name, "shell");
585        assert_eq!(calls[0].input["command"], "echo ${HOME} and }{");
586    }
587
588    #[test]
589    fn test_envelope_takes_precedence_over_salvage() {
590        // A proper <tool_call> envelope must be parsed by the envelope path
591        // (id "local-1"), never falling through to salvage.
592        let input =
593            "<tool_call>\n{\"name\": \"glob\", \"input\": {\"pattern\": \"*.rs\"}}\n</tool_call>";
594        let (_text, calls) = parse_tool_calls(input);
595        assert_eq!(calls.len(), 1);
596        assert_eq!(calls[0].id, "local-1", "envelope parser owns this, not salvage");
597    }
598}