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;
13
14use super::chat_template::{format_prompt_with_template, ChatTemplate};
15use super::validate::validate_model_file;
16use super::{CompletionRequest, CompletionResponse, LlmDriver, ToolCall};
17use crate::agent::result::{AgentError, DriverError, StopReason, TokenUsage};
18use crate::serve::backends::PrivacyTier;
19
20/// Local inference driver using realizar (GGUF/APR/SafeTensors).
21pub struct RealizarDriver {
22    /// Path to model file.
23    model_path: PathBuf,
24    /// Context window size.
25    context_window_size: usize,
26    /// Auto-detected chat template.
27    template: ChatTemplate,
28}
29
30impl RealizarDriver {
31    /// Create a new RealizarDriver from a model path.
32    ///
33    /// **Contract: `apr_model_validity` (apr-code-v1.yaml)**
34    ///
35    /// Preconditions enforced at the load boundary (Jidoka):
36    /// - File must exist
37    /// - APR files: must have embedded tokenizer (checked via header)
38    /// - GGUF files: must have valid magic bytes
39    ///
40    /// Violation → actionable error with re-conversion instructions.
41    /// No broken model ever reaches the inference loop.
42    pub fn new(model_path: PathBuf, context_window: Option<usize>) -> Result<Self, AgentError> {
43        if !model_path.exists() {
44            return Err(AgentError::Driver(DriverError::InferenceFailed(format!(
45                "model not found: {}",
46                model_path.display()
47            ))));
48        }
49
50        // ═══ CONTRACT: apr_model_validity — Jidoka boundary check ═══
51        validate_model_file(&model_path)?;
52
53        let context_window_size = context_window.unwrap_or(4096);
54        let template = ChatTemplate::from_model_path(&model_path);
55        Ok(Self { model_path, context_window_size, template })
56    }
57}
58
59#[async_trait]
60impl LlmDriver for RealizarDriver {
61    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, AgentError> {
62        // Format messages using auto-detected chat template
63        let prompt = format_prompt_with_template(&request, self.template);
64
65        // Build inference config (explicit fields — no Default impl)
66        let config = realizar::infer::InferenceConfig {
67            model_path: self.model_path.clone(),
68            prompt: Some(prompt),
69            input_tokens: None,
70            max_tokens: request.max_tokens as usize,
71            temperature: request.temperature,
72            top_k: 0,
73            // PMAT-823: new sampling fields default to greedy/disabled so this
74            // agent driver's behavior is unchanged (it only exposes temperature).
75            top_p: None,
76            seed: 42,
77            repeat_penalty: 1.0,
78            repeat_last_n: 64,
79            // PMAT-156/158: Disable GPU only for APR models (wgpu shader bug).
80            // GGUF models work fine with CUDA — keep GPU enabled for them.
81            no_gpu: self.model_path.extension().is_some_and(|e| e == "apr"),
82            trace: false,
83            trace_verbose: false,
84            trace_output: None,
85            trace_steps: None,
86            verbose: false,
87            use_mock_backend: false,
88            stop_tokens: vec![],
89        };
90
91        // Run inference in blocking thread (realizar is sync)
92        let result = tokio::task::spawn_blocking(move || realizar::infer::run_inference(&config))
93            .await
94            .map_err(|e| {
95                AgentError::Driver(DriverError::InferenceFailed(format!("spawn_blocking: {e}")))
96            })?
97            .map_err(|e| AgentError::Driver(DriverError::InferenceFailed(e.to_string())))?;
98
99        // Parse tool calls from text output
100        let (raw_text, tool_calls) = parse_tool_calls(&result.text);
101
102        // Sanitize output: strip echoed system prompt and chat template markers
103        let text = sanitize_output(&raw_text, request.system.as_deref());
104
105        let stop_reason =
106            if tool_calls.is_empty() { StopReason::EndTurn } else { StopReason::ToolUse };
107
108        Ok(CompletionResponse {
109            text,
110            stop_reason,
111            tool_calls,
112            usage: TokenUsage {
113                input_tokens: result.input_token_count as u64,
114                output_tokens: result.generated_token_count as u64,
115            },
116        })
117    }
118
119    fn context_window(&self) -> usize {
120        self.context_window_size
121    }
122
123    fn privacy_tier(&self) -> PrivacyTier {
124        PrivacyTier::Sovereign
125    }
126}
127
128/// Parse tool calls from model output text.
129///
130/// Supports multiple formats (PMAT-158):
131/// 1. `<tool_call>{"name":...}</tool_call>` — custom XML tags
132/// 2. `<tool_call>{"name":...}` — unclosed XML (small model fallback)
133/// 3. `` ```json\n{"name":...}\n``` `` — markdown code block (Qwen native)
134///
135/// Returns the remaining text (with tool call blocks removed)
136/// and the extracted tool calls.
137/// Public wrapper for tool call parsing (used by AprServeDriver).
138pub fn parse_tool_calls_pub(text: &str) -> (String, Vec<ToolCall>) {
139    parse_tool_calls(text)
140}
141
142fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
143    let mut tool_calls = Vec::new();
144    let mut remaining = String::new();
145    let mut call_counter = 0u32;
146
147    let mut cursor = text;
148    loop {
149        // Find next tool call start — try <tool_call> first, then ```json
150        let xml_pos = cursor.find("<tool_call>");
151        let md_pos = cursor.find("```json");
152
153        let (start, tag_len, is_markdown) = match (xml_pos, md_pos) {
154            (Some(x), Some(m)) if x <= m => (x, "<tool_call>".len(), false),
155            (Some(x), None) => (x, "<tool_call>".len(), false),
156            (_, Some(m)) => (m, "```json".len(), true),
157            (None, None) => {
158                remaining.push_str(cursor);
159                break;
160            }
161        };
162
163        remaining.push_str(&cursor[..start]);
164        let after_tag = &cursor[start + tag_len..];
165
166        // Find closing tag and extract JSON
167        let (json_str, advance_past) = if is_markdown {
168            // Markdown: ```json\n...\n```
169            if let Some(end) = after_tag.find("```") {
170                (&after_tag[..end], &after_tag[end + "```".len()..])
171            } else {
172                (after_tag, "")
173            }
174        } else if let Some(end) = after_tag.find("</tool_call>") {
175            (&after_tag[..end], &after_tag[end + "</tool_call>".len()..])
176        } else {
177            // PMAT-158: No closing tag — try parsing to end-of-string
178            (after_tag, "")
179        };
180        let json_str = json_str.trim();
181
182        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_str) {
183            // Must have "name" field to be a tool call (not just any JSON)
184            if let Some(name) = parsed.get("name").and_then(|n| n.as_str()) {
185                let name = name.to_string();
186                let input = parsed.get("input").cloned().unwrap_or(serde_json::json!({}));
187                call_counter += 1;
188                tool_calls.push(ToolCall { id: format!("local-{call_counter}"), name, input });
189            } else {
190                remaining.push_str(&cursor[start..]);
191                break;
192            }
193        } else {
194            remaining.push_str(&cursor[start..]);
195            break;
196        }
197
198        cursor = advance_past;
199        if cursor.is_empty() {
200            break;
201        }
202    }
203
204    (remaining.trim().to_string(), tool_calls)
205}
206
207/// Sanitize model output: strip echoed system prompt and chat template markers.
208///
209/// Small models (<3B) often echo the system prompt or leak chat template
210/// tokens into their response. This strips those artifacts so the agent
211/// loop sees clean assistant text.
212fn sanitize_output(text: &str, system_prompt: Option<&str>) -> String {
213    let mut cleaned = text.to_string();
214
215    // Strip echoed system prompt (common with small models)
216    if let Some(sys) = system_prompt {
217        // Check if output starts with a significant prefix of the system prompt
218        let sys_prefix = &sys[..sys.len().min(80)];
219        if cleaned.starts_with(sys_prefix) {
220            // The model regurgitated the system prompt — strip it
221            cleaned = cleaned[sys.len().min(cleaned.len())..].to_string();
222        }
223    }
224
225    // Strip leaked chat template markers
226    for marker in &[
227        "<|im_start|>",
228        "<|im_end|>",
229        "<|start_header_id|>",
230        "<|end_header_id|>",
231        "<|eot_id|>",
232        "<|system|>",
233        "<|user|>",
234        "<|assistant|>",
235        "<|end|>",
236    ] {
237        cleaned = cleaned.replace(marker, "");
238    }
239
240    // Strip leading/trailing whitespace and role labels
241    let cleaned = cleaned.trim();
242    let cleaned = cleaned.strip_prefix("system\n").unwrap_or(cleaned);
243    let cleaned = cleaned.strip_prefix("assistant\n").unwrap_or(cleaned);
244    cleaned.trim().to_string()
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_parse_no_tool_calls() {
253        let (text, calls) = parse_tool_calls("Hello world");
254        assert_eq!(text, "Hello world");
255        assert!(calls.is_empty());
256    }
257
258    #[test]
259    fn test_parse_single_tool_call() {
260        let input = r#"Before text
261<tool_call>
262{"name": "rag", "input": {"query": "SIMD"}}
263</tool_call>
264After text"#;
265        let (text, calls) = parse_tool_calls(input);
266        assert_eq!(text, "Before text\n\nAfter text");
267        assert_eq!(calls.len(), 1);
268        assert_eq!(calls[0].name, "rag");
269        assert_eq!(calls[0].id, "local-1");
270        assert_eq!(calls[0].input, serde_json::json!({"query": "SIMD"}));
271    }
272
273    #[test]
274    fn test_parse_multiple_tool_calls() {
275        let input = r#"<tool_call>
276{"name": "rag", "input": {"query": "a"}}
277</tool_call>
278Middle
279<tool_call>
280{"name": "memory", "input": {"action": "recall", "query": "b"}}
281</tool_call>"#;
282        let (text, calls) = parse_tool_calls(input);
283        assert_eq!(text, "Middle");
284        assert_eq!(calls.len(), 2);
285        assert_eq!(calls[0].name, "rag");
286        assert_eq!(calls[0].id, "local-1");
287        assert_eq!(calls[1].name, "memory");
288        assert_eq!(calls[1].id, "local-2");
289    }
290
291    #[test]
292    fn test_parse_malformed_json() {
293        let input = r#"<tool_call>
294not valid json
295</tool_call>"#;
296        let (_text, calls) = parse_tool_calls(input);
297        assert!(calls.is_empty());
298    }
299
300    #[test]
301    fn test_parse_missing_close_tag_with_valid_json() {
302        // PMAT-158: Small models omit </tool_call>. Parser should still extract.
303        let input =
304            "<tool_call>\n{\"name\": \"file_read\", \"input\": {\"path\": \"src/main.rs\"}}";
305        let (text, calls) = parse_tool_calls(input);
306        assert_eq!(calls.len(), 1, "should extract tool call without closing tag");
307        assert_eq!(calls[0].name, "file_read");
308        assert!(text.is_empty(), "no remaining text expected");
309    }
310
311    #[test]
312    fn test_parse_missing_close_tag_with_trailing_text() {
313        // Unclosed tag with text before it — text preserved, tool call extracted
314        let input =
315            "Let me read that.\n<tool_call> {\"name\": \"file_read\", \"input\": {\"path\": \"foo.rs\"}}";
316        let (text, calls) = parse_tool_calls(input);
317        assert_eq!(calls.len(), 1);
318        assert_eq!(calls[0].name, "file_read");
319        assert!(text.contains("Let me read that"));
320    }
321
322    #[test]
323    fn test_parse_missing_close_tag_invalid_json() {
324        // Unclosed tag with invalid JSON — treated as plain text
325        let input = "<tool_call>\nnot valid json at all";
326        let (text, calls) = parse_tool_calls(input);
327        assert!(calls.is_empty(), "invalid JSON should not produce tool call");
328        assert!(text.contains("<tool_call>"));
329    }
330
331    #[test]
332    fn test_parse_markdown_code_block() {
333        // PMAT-158: Qwen2.5-Coder native format — ```json blocks
334        let input = "Let me read that file.\n```json\n{\"name\": \"file_read\", \"input\": {\"path\": \"src/main.rs\"}}\n```";
335        let (text, calls) = parse_tool_calls(input);
336        assert_eq!(calls.len(), 1, "should extract tool call from markdown block");
337        assert_eq!(calls[0].name, "file_read");
338        assert_eq!(calls[0].input["path"], "src/main.rs");
339        assert!(text.contains("Let me read that"));
340    }
341
342    #[test]
343    fn test_parse_markdown_code_block_not_tool_call() {
344        // JSON in code block without "name" field — not a tool call
345        let input = "Here's an example:\n```json\n{\"key\": \"value\"}\n```";
346        let (text, calls) = parse_tool_calls(input);
347        assert!(calls.is_empty(), "JSON without name field should not be a tool call");
348        assert!(text.contains("example"));
349    }
350
351    #[test]
352    fn test_parse_missing_name() {
353        let input = r#"<tool_call>
354{"input": {"query": "test"}}
355</tool_call>"#;
356        let (_, calls) = parse_tool_calls(input);
357        assert!(calls.is_empty(), "JSON without name should not be extracted");
358    }
359
360    #[test]
361    fn test_privacy_tier_always_sovereign() {
362        assert_eq!(PrivacyTier::Sovereign, PrivacyTier::Sovereign);
363    }
364
365    // ── Output sanitization tests ──
366
367    #[test]
368    fn test_sanitize_strips_echoed_system_prompt() {
369        let sys = "You are apr code, a sovereign AI coding assistant.";
370        let output = format!("{sys} And then the model continues here.");
371        let cleaned = sanitize_output(&output, Some(sys));
372        assert!(!cleaned.contains("sovereign AI coding assistant"));
373        assert!(cleaned.contains("continues here"));
374    }
375
376    #[test]
377    fn test_sanitize_strips_chat_markers() {
378        let output = "<|im_start|>assistant\nHello world<|im_end|>";
379        let cleaned = sanitize_output(output, None);
380        assert_eq!(cleaned, "Hello world");
381    }
382
383    #[test]
384    fn test_sanitize_preserves_clean_output() {
385        let output = "The answer is 42.";
386        let cleaned = sanitize_output(output, Some("You are helpful."));
387        assert_eq!(cleaned, "The answer is 42.");
388    }
389
390    #[test]
391    fn test_sanitize_strips_role_prefix() {
392        let output = "assistant\nHere is my response.";
393        let cleaned = sanitize_output(output, None);
394        assert_eq!(cleaned, "Here is my response.");
395    }
396}