Skip to main content

batuta/agent/
code.rs

1//! Public entry point for `apr code` / `batuta code`.
2//!
3//! This module provides the library-level API that both the `batuta` binary
4//! and `apr-cli` use to launch the coding assistant. All logic lives here;
5//! CLI wrappers are thin dispatchers.
6//!
7//! PMAT-162: Phase 6 — makes `cmd_code` accessible from the library crate
8//! so `apr-cli` can call `batuta::agent::code::cmd_code()` directly.
9
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use crate::agent::capability::Capability;
14use crate::agent::driver::LlmDriver;
15use crate::agent::manifest::{AgentManifest, ModelConfig, ResourceQuota};
16use crate::agent::tool::file::{FileEditTool, FileReadTool, FileWriteTool};
17use crate::agent::tool::search::{GlobTool, GrepTool};
18use crate::agent::tool::shell::ShellTool;
19use crate::agent::tool::ToolRegistry;
20use crate::serve::backends::PrivacyTier;
21
22/// Entry point for `batuta code` / `apr code`.
23///
24/// This is the public library API — callable from both the batuta binary
25/// and apr-cli (PMAT-162). Handles model discovery, driver selection,
26/// tool registration, and REPL launch.
27pub fn cmd_code(
28    model: Option<PathBuf>,
29    project: PathBuf,
30    resume: Option<Option<String>>,
31    prompt: Vec<String>,
32    print: bool,
33    max_turns: u32,
34    manifest_path: Option<PathBuf>,
35) -> anyhow::Result<()> {
36    // --project: change working directory for project instructions
37    if project.as_os_str() != "." && project.is_dir() {
38        std::env::set_current_dir(&project)?;
39    }
40
41    // Load manifest or build default
42    let mut manifest = match manifest_path {
43        Some(ref path) => {
44            let content = std::fs::read_to_string(path)
45                .map_err(|e| anyhow::anyhow!("cannot read manifest {}: {e}", path.display()))?;
46            let m = AgentManifest::from_toml(&content)
47                .map_err(|e| anyhow::anyhow!("invalid manifest: {e}"))?;
48            eprintln!("✓ Loaded manifest: {}", path.display());
49            m
50        }
51        None => build_default_manifest(),
52    };
53
54    // --model flag overrides manifest model_path
55    if let Some(ref model_path) = model {
56        manifest.model.model_path = Some(model_path.clone());
57    }
58
59    // PMAT-150: discover model with Jidoka validation (broken APR → GGUF fallback)
60    discover_and_set_model(&mut manifest);
61
62    // PMAT-198: Scale system prompt based on model size.
63    // Small models (<2B) degrade with the full tool table + project context.
64    if let Some(ref path) = manifest.model.model_path {
65        let params_b = estimate_model_params_from_name(path);
66        if params_b < 2.0 {
67            manifest.model.system_prompt = scale_prompt_for_model(params_b);
68        }
69    }
70
71    // Contract: no_model_error — never silently use MockDriver
72    if manifest.model.resolve_model_path().is_none() && manifest_path.is_none() {
73        print_no_model_error();
74        std::process::exit(exit_code::NO_MODEL);
75    }
76
77    // PMAT-160: Try AprServeDriver first (apr serve has full CUDA/GPU).
78    // Falls back to embedded RealizarDriver if `apr` binary not found.
79    // PMAT-CODE-SPAWN-PARITY-001: driver stored as Arc so TaskTool can
80    // share it with the AgentPool for sub-agent execution.
81    let driver: Arc<dyn LlmDriver> = if let Some(model_path) = manifest.model.resolve_model_path() {
82        match crate::agent::driver::apr_serve::AprServeDriver::launch(
83            model_path,
84            manifest.model.context_window,
85        ) {
86            Ok(d) => Arc::new(d),
87            Err(e) => {
88                eprintln!("⚠ apr serve unavailable ({e}), using embedded inference");
89                Arc::from(build_fallback_driver(&manifest)?)
90            }
91        }
92    } else {
93        Arc::from(build_fallback_driver(&manifest)?)
94    };
95
96    // Build tool registry with coding tools
97    let mut tools = build_code_tools(&manifest);
98
99    // PMAT-CODE-MCP-CLIENT-001: register MCP client tools from manifest.mcp_servers.
100    // Synchronous wrapper over async discover_mcp_tools — a no-op when mcp_servers is
101    // empty (the default for `apr code` without a manifest).
102    register_mcp_client_tools(&mut tools, &manifest);
103
104    // PMAT-CODE-SPAWN-PARITY-001: register Task tool (Claude-Code Agent parity).
105    // `task` lets the agent delegate to typed subagents (general-purpose,
106    // explore, plan) with bounded recursion depth (Jidoka).
107    crate::agent::task_tool::register_task_tool(
108        &mut tools,
109        &manifest,
110        Arc::clone(&driver),
111        /* max_depth */ 3,
112    );
113
114    // PMAT-CODE-HOOKS-001: build hook registry from manifest and fire SessionStart.
115    // Returned Warn messages are surfaced to the user; a Block here aborts session
116    // startup (matching Claude Code's exit-code-2 semantics).
117    let hooks_reg = crate::agent::hooks::HookRegistry::from_configs(manifest.hooks.clone());
118    let hook_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
119    match hooks_reg.run(crate::agent::hooks::HookEvent::SessionStart, "", &hook_cwd) {
120        crate::agent::hooks::HookDecision::Allow => {}
121        crate::agent::hooks::HookDecision::Warn(msg) => {
122            if !msg.is_empty() {
123                eprintln!("⚠ SessionStart hook: {msg}");
124            }
125        }
126        crate::agent::hooks::HookDecision::Block(reason) => {
127            anyhow::bail!("SessionStart hook blocked session: {reason}");
128        }
129    }
130
131    // Build memory
132    let memory = crate::agent::memory::InMemorySubstrate::new();
133
134    // Non-interactive mode: single prompt
135    // PMAT-161: Return exit code instead of process::exit() so driver Drop
136    // runs and kills the apr serve subprocess (no zombie processes).
137    if print || !prompt.is_empty() {
138        let prompt_text = if prompt.is_empty() {
139            let mut buf = String::new();
140            std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
141            buf
142        } else {
143            prompt.join(" ")
144        };
145        let code = run_single_prompt(&manifest, driver.as_ref(), &tools, &memory, &prompt_text);
146        drop(driver); // Kill apr serve subprocess before exit
147        std::process::exit(code);
148    }
149
150    // --resume: load previous session
151    // PMAT-165: auto-resume prompt when recent session exists (spec §6.3)
152    let resume_session_id = match resume {
153        Some(Some(id)) => Some(id), // --resume=<session-id>
154        Some(None) => {
155            // --resume (no ID): find most recent for cwd
156            crate::agent::session::SessionStore::find_recent_for_cwd().map(|m| m.id)
157        }
158        None => {
159            // No --resume flag: check for recent session and prompt
160            crate::agent::session::offer_auto_resume()
161        }
162    };
163
164    // Interactive REPL (local inference is free — budget unlimited)
165    crate::agent::repl::run_repl(
166        &manifest,
167        driver.as_ref(),
168        &tools,
169        &memory,
170        max_turns,
171        f64::MAX,
172        resume_session_id.as_deref(),
173    )
174}
175
176/// Build fallback driver (embedded RealizarDriver) when AprServeDriver unavailable.
177fn build_fallback_driver(manifest: &AgentManifest) -> anyhow::Result<Box<dyn LlmDriver>> {
178    #[cfg(feature = "inference")]
179    {
180        if let Some(model_path) = manifest.model.resolve_model_path() {
181            let driver = crate::agent::driver::realizar::RealizarDriver::new(
182                model_path,
183                manifest.model.context_window,
184            )?;
185            return Ok(Box::new(driver));
186        }
187    }
188    let _ = manifest;
189    // No model or no inference feature — return MockDriver
190    Ok(Box::new(crate::agent::driver::mock::MockDriver::single_response(
191        "Hello! I'm running in dry-run mode. \
192         Set model_path in your agent manifest or install the `apr` binary.",
193    )))
194}
195
196/// Auto-discover model if none explicitly set (APR preferred over GGUF).
197fn discover_and_set_model(manifest: &mut AgentManifest) {
198    if manifest.model.model_path.is_some() || manifest.model.model_repo.is_some() {
199        return;
200    }
201    let Some(discovered) = ModelConfig::discover_model() else {
202        return;
203    };
204    eprintln!(
205        "Model: {} (auto-discovered)",
206        discovered.file_name().unwrap_or_default().to_string_lossy()
207    );
208    let ext = discovered.extension().and_then(|e| e.to_str()).unwrap_or("");
209    if ext == "gguf" && check_invalid_apr_in_search_dirs() {
210        eprintln!(
211            "⚠ APR model found but invalid (missing tokenizer). Using GGUF fallback: {}",
212            discovered.display()
213        );
214        eprintln!("  Re-convert with: apr convert <source>.gguf -o <output>.apr\n");
215    }
216    manifest.model.model_path = Some(discovered);
217}
218
219/// Print actionable error when no local model is available.
220fn print_no_model_error() {
221    eprintln!("✗ No local model found. apr code requires a local model.\n");
222    if check_invalid_apr_in_search_dirs() {
223        eprintln!("  ⚠ APR model(s) found but invalid (missing embedded tokenizer).");
224        eprintln!("  Re-convert: apr convert <source>.gguf -o <output>.apr\n");
225    }
226    eprintln!("  Download a model (APR format preferred):");
227    eprintln!("    apr pull qwen3:1.7b-q4k            (default — best tool use at 1.2GB)");
228    eprintln!("    apr pull qwen3:8b-q4k              (recommended for complex tasks)");
229    eprintln!();
230    eprintln!("  Or place a .apr/.gguf file in ~/.apr/models/ (auto-discovered)");
231    eprintln!();
232    eprintln!("  Then run: apr code or apr code --model <path>");
233}
234
235/// Check if any APR files in standard model search dirs are invalid.
236fn check_invalid_apr_in_search_dirs() -> bool {
237    for dir in &ModelConfig::model_search_dirs() {
238        if let Ok(entries) = std::fs::read_dir(dir) {
239            for entry in entries.flatten() {
240                let path = entry.path();
241                if path.extension().is_some_and(|e| e == "apr")
242                    && !crate::agent::driver::validate::is_valid_model_file(&path)
243                {
244                    return true;
245                }
246            }
247        }
248    }
249    false
250}
251
252/// Load project-level instructions from APR.md or CLAUDE.md.
253fn load_project_instructions(max_bytes: usize) -> Option<String> {
254    let cwd = std::env::current_dir().ok()?;
255
256    for filename in &["APR.md", "CLAUDE.md"] {
257        let path = cwd.join(filename);
258        if path.is_file() {
259            if let Ok(content) = std::fs::read_to_string(&path) {
260                if max_bytes == 0 {
261                    return None;
262                }
263                let truncated = if content.len() > max_bytes {
264                    let end = content
265                        .char_indices()
266                        .take_while(|(i, _)| *i < max_bytes)
267                        .last()
268                        .map(|(i, c)| i + c.len_utf8())
269                        .unwrap_or(max_bytes.min(content.len()));
270                    format!("{}...\n(truncated from {} bytes)", &content[..end], content.len())
271                } else {
272                    content
273                };
274                return Some(truncated);
275            }
276        }
277    }
278    None
279}
280
281/// Compute instruction budget based on model context window.
282fn instruction_budget(context_window: usize) -> usize {
283    if context_window < 4096 {
284        return 0;
285    }
286    let budget = context_window / 4;
287    budget.min(4096)
288}
289
290/// Gather project context — git info, file stats, language.
291fn gather_project_context() -> String {
292    let mut ctx = String::new();
293    let cwd = std::env::current_dir().unwrap_or_default();
294    ctx.push_str(&format!("Working directory: {}\n", cwd.display()));
295
296    if let Ok(output) =
297        std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"]).output()
298    {
299        if output.status.success() {
300            let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
301            ctx.push_str(&format!("Git branch: {branch}\n"));
302        }
303    }
304    if let Ok(output) =
305        std::process::Command::new("git").args(["diff", "--stat", "--no-color"]).output()
306    {
307        if output.status.success() {
308            let diff = String::from_utf8_lossy(&output.stdout);
309            let dirty_count = diff.lines().count().saturating_sub(1);
310            if dirty_count > 0 {
311                ctx.push_str(&format!("Dirty files: {dirty_count}\n"));
312            }
313        }
314    }
315
316    let mut rs_count = 0u32;
317    let mut py_count = 0u32;
318    let mut total = 0u32;
319    if let Ok(entries) = std::fs::read_dir("src") {
320        for e in entries.flatten() {
321            total += 1;
322            if let Some(ext) = e.path().extension() {
323                match ext.to_str() {
324                    Some("rs") => rs_count += 1,
325                    Some("py") => py_count += 1,
326                    _ => {}
327                }
328            }
329        }
330    }
331    let lang = if rs_count > py_count {
332        "Rust"
333    } else if py_count > 0 {
334        "Python"
335    } else {
336        "unknown"
337    };
338    ctx.push_str(&format!("Language: {lang} ({total} files in src/)\n"));
339
340    if PathBuf::from("Cargo.toml").exists() {
341        ctx.push_str("Build system: Cargo (Rust)\n");
342    } else if PathBuf::from("pyproject.toml").exists() {
343        ctx.push_str("Build system: pyproject.toml (Python)\n");
344    }
345
346    ctx
347}
348
349/// Build a default `AgentManifest` for coding tasks.
350fn build_default_manifest() -> AgentManifest {
351    let ctx_window = 4096_usize;
352    let budget = instruction_budget(ctx_window);
353    let project_instructions = load_project_instructions(budget);
354    let project_context = gather_project_context();
355
356    let mut system_prompt = CODE_SYSTEM_PROMPT.to_string();
357    system_prompt.push_str(&format!("\n\n## Project Context\n\n{project_context}"));
358    if let Some(ref instructions) = project_instructions {
359        system_prompt.push_str(&format!("\n## Project Instructions\n\n{instructions}"));
360    }
361
362    AgentManifest {
363        name: "apr-code".to_string(),
364        description: "Interactive AI coding assistant".to_string(),
365        privacy: PrivacyTier::Sovereign,
366        model: ModelConfig {
367            system_prompt,
368            max_tokens: 4096,
369            temperature: 0.0,
370            // PMAT-197: Qwen3 supports 32K context. Default 4096 caused
371            // truncate_messages to drop user query (9 tool schemas ~4000 tokens
372            // consumed the entire window). Set to 32K for Qwen3-class models.
373            context_window: Some(32768),
374            ..ModelConfig::default()
375        },
376        resources: ResourceQuota {
377            max_iterations: 50,
378            max_tool_calls: 200,
379            max_cost_usd: 0.0,
380            max_tokens_budget: None,
381        },
382        capabilities: vec![
383            Capability::FileRead { allowed_paths: vec!["*".into()] },
384            Capability::FileWrite { allowed_paths: vec!["*".into()] },
385            Capability::Shell { allowed_commands: vec!["*".into()] },
386            Capability::Memory,
387            Capability::Rag,
388        ],
389        ..AgentManifest::default()
390    }
391}
392
393/// PMAT-CODE-MCP-CLIENT-001 — register external MCP servers declared in
394/// `manifest.mcp_servers[]` as tools in the `apr code` registry. Mirrors
395/// Claude Code's `.mcp.json` → agent-tool-provider wiring. Synchronous
396/// wrapper because `cmd_code` is sync; opens a scoped current-thread
397/// runtime for the discovery handshake. No-op when the feature is off
398/// or the manifest has no servers.
399#[allow(unused_variables)]
400fn register_mcp_client_tools(tools: &mut ToolRegistry, manifest: &AgentManifest) {
401    #[cfg(feature = "agents-mcp")]
402    {
403        if manifest.mcp_servers.is_empty() {
404            return;
405        }
406        let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
407            Ok(rt) => rt,
408            Err(e) => {
409                eprintln!("⚠ failed to create MCP discovery runtime: {e}");
410                return;
411            }
412        };
413        let discovered = rt.block_on(crate::agent::tool::mcp_client::discover_mcp_tools(manifest));
414        let count = discovered.len();
415        for tool in discovered {
416            tools.register(Box::new(tool));
417        }
418        if count > 0 {
419            eprintln!(
420                "✓ Registered {count} MCP tool(s) from {} server(s)",
421                manifest.mcp_servers.len()
422            );
423        }
424    }
425}
426
427/// Register all coding tools.
428fn build_code_tools(manifest: &AgentManifest) -> ToolRegistry {
429    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
430
431    let mut tools = ToolRegistry::new();
432    tools.register(Box::new(FileReadTool::new(vec!["*".into()])));
433    tools.register(Box::new(FileWriteTool::new(vec!["*".into()])));
434    tools.register(Box::new(FileEditTool::new(vec!["*".into()])));
435    tools.register(Box::new(GlobTool::new(vec!["*".into()])));
436    tools.register(Box::new(GrepTool::new(vec!["*".into()])));
437    tools.register(Box::new(ShellTool::new(vec!["*".into()], cwd)));
438
439    let memory_sub = Arc::new(crate::agent::memory::InMemorySubstrate::new());
440    tools.register(Box::new(crate::agent::tool::memory::MemoryTool::new(
441        memory_sub,
442        manifest.name.clone(),
443    )));
444
445    // PMAT-163: dedicated pmat_query tool
446    tools.register(Box::new(crate::agent::tool::pmat_query::PmatQueryTool::new()));
447
448    #[cfg(feature = "rag")]
449    {
450        let oracle = Arc::new(crate::oracle::rag::RagOracle::new());
451        tools.register(Box::new(crate::agent::tool::rag::RagTool::new(oracle, 5)));
452    }
453
454    // PMAT-CODE-WEB-TOOLS-001: register NetworkTool behind the privacy-tier
455    // gate. Sovereign tier always blocks (Poka-Yoke); Standard/Private
456    // tiers register iff `allowed_hosts` is non-empty (explicit opt-in).
457    register_web_tools(&mut tools, manifest);
458
459    tools
460}
461
462/// Register NetworkTool (+ BrowserTool when the `agents-browser` feature is
463/// on) when the manifest declares a non-Sovereign privacy tier and a
464/// non-empty `allowed_hosts` list.
465fn register_web_tools(tools: &mut ToolRegistry, manifest: &AgentManifest) {
466    use crate::serve::backends::PrivacyTier;
467
468    if matches!(manifest.privacy, PrivacyTier::Sovereign) {
469        return;
470    }
471    if manifest.allowed_hosts.is_empty() {
472        return;
473    }
474
475    tools.register(Box::new(crate::agent::tool::network::NetworkTool::new(
476        manifest.allowed_hosts.clone(),
477    )));
478
479    #[cfg(feature = "agents-browser")]
480    {
481        tools.register(Box::new(crate::agent::tool::browser::BrowserTool::new(manifest.privacy)));
482    }
483}
484
485pub use super::code_prompts::exit_code;
486
487/// Run a single prompt (non-interactive). PMAT-172: cap iterations at 10.
488fn run_single_prompt(
489    manifest: &AgentManifest,
490    driver: &dyn LlmDriver,
491    tools: &ToolRegistry,
492    memory: &dyn crate::agent::memory::MemorySubstrate,
493    prompt: &str,
494) -> i32 {
495    let mut single_manifest = manifest.clone();
496    single_manifest.resources.max_iterations = single_manifest.resources.max_iterations.min(10);
497    // PMAT-197: Use compact system prompt for -p mode.
498    // The full CODE_SYSTEM_PROMPT (9-tool table + project context + CLAUDE.md)
499    // overwhelms Qwen3 1.7B causing </think> loops. For -p mode, use a minimal
500    // prompt that lets the model answer directly. Tools still available if needed.
501    single_manifest.model.system_prompt = COMPACT_SYSTEM_PROMPT.to_string();
502    // Note: context_window is set at driver launch time (build_default_manifest),
503    // not here. See PMAT-197 fix in build_default_manifest.
504
505    let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
506        Ok(rt) => rt,
507        Err(e) => {
508            eprintln!("Error: failed to create tokio runtime: {e}");
509            return exit_code::AGENT_ERROR;
510        }
511    };
512
513    // PMAT-197: Use non-nudge loop for -p mode. The nudge ("Use a tool!") forces
514    // small models to make tool calls even for simple questions like "What is 2+2?"
515    // which causes stuck loops. Let the model decide whether to use tools.
516    let result = rt.block_on(crate::agent::runtime::run_agent_loop(
517        &single_manifest,
518        prompt,
519        driver,
520        tools,
521        memory,
522        None,
523    ));
524
525    match result {
526        Ok(r) => {
527            if r.text.is_empty() {
528                // PMAT-190: Empty response — model may be emitting only thinking tokens
529                // that get stripped by strip_thinking_blocks(). Common with Qwen3 when
530                // the serve backend doesn't use Qwen3NoThinkTemplate.
531                eprintln!(
532                    "⚠ Empty response ({} iterations, {} tool calls). \
533                     Model may be in thinking mode — rebuild apr from source for Qwen3NoThinkTemplate fix.",
534                    r.iterations, r.tool_calls
535                );
536            } else {
537                println!("{}", r.text);
538            }
539            exit_code::SUCCESS
540        }
541        Err(e) => {
542            eprintln!("Error: {e}");
543            map_error_to_exit_code(&e)
544        }
545    }
546}
547
548// Prompts and exit codes extracted to code_prompts.rs
549use super::code_prompts::{
550    estimate_model_params_from_name, map_error_to_exit_code, scale_prompt_for_model,
551    CODE_SYSTEM_PROMPT, COMPACT_SYSTEM_PROMPT,
552};
553
554#[cfg(test)]
555#[path = "code_tests.rs"]
556mod tests;