1use 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
22pub 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 if project.as_os_str() != "." && project.is_dir() {
38 std::env::set_current_dir(&project)?;
39 }
40
41 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 if let Some(ref model_path) = model {
56 manifest.model.model_path = Some(model_path.clone());
57 }
58
59 discover_and_set_model(&mut manifest);
61
62 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 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 let driver: Box<dyn LlmDriver> = if let Some(model_path) = manifest.model.resolve_model_path() {
80 match crate::agent::driver::apr_serve::AprServeDriver::launch(
81 model_path,
82 manifest.model.context_window,
83 ) {
84 Ok(d) => Box::new(d),
85 Err(e) => {
86 eprintln!("⚠ apr serve unavailable ({e}), using embedded inference");
87 build_fallback_driver(&manifest)?
88 }
89 }
90 } else {
91 build_fallback_driver(&manifest)?
92 };
93
94 let tools = build_code_tools(&manifest);
96
97 let memory = crate::agent::memory::InMemorySubstrate::new();
99
100 if print || !prompt.is_empty() {
104 let prompt_text = if prompt.is_empty() {
105 let mut buf = String::new();
106 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
107 buf
108 } else {
109 prompt.join(" ")
110 };
111 let code = run_single_prompt(&manifest, driver.as_ref(), &tools, &memory, &prompt_text);
112 drop(driver); std::process::exit(code);
114 }
115
116 let resume_session_id = match resume {
119 Some(Some(id)) => Some(id), Some(None) => {
121 crate::agent::session::SessionStore::find_recent_for_cwd().map(|m| m.id)
123 }
124 None => {
125 crate::agent::session::offer_auto_resume()
127 }
128 };
129
130 crate::agent::repl::run_repl(
132 &manifest,
133 driver.as_ref(),
134 &tools,
135 &memory,
136 max_turns,
137 f64::MAX,
138 resume_session_id.as_deref(),
139 )
140}
141
142fn build_fallback_driver(manifest: &AgentManifest) -> anyhow::Result<Box<dyn LlmDriver>> {
144 #[cfg(feature = "inference")]
145 {
146 if let Some(model_path) = manifest.model.resolve_model_path() {
147 let driver = crate::agent::driver::realizar::RealizarDriver::new(
148 model_path,
149 manifest.model.context_window,
150 )?;
151 return Ok(Box::new(driver));
152 }
153 }
154 let _ = manifest;
155 Ok(Box::new(crate::agent::driver::mock::MockDriver::single_response(
157 "Hello! I'm running in dry-run mode. \
158 Set model_path in your agent manifest or install the `apr` binary.",
159 )))
160}
161
162fn discover_and_set_model(manifest: &mut AgentManifest) {
164 if manifest.model.model_path.is_some() || manifest.model.model_repo.is_some() {
165 return;
166 }
167 let Some(discovered) = ModelConfig::discover_model() else {
168 return;
169 };
170 eprintln!(
171 "Model: {} (auto-discovered)",
172 discovered.file_name().unwrap_or_default().to_string_lossy()
173 );
174 let ext = discovered.extension().and_then(|e| e.to_str()).unwrap_or("");
175 if ext == "gguf" && check_invalid_apr_in_search_dirs() {
176 eprintln!(
177 "⚠ APR model found but invalid (missing tokenizer). Using GGUF fallback: {}",
178 discovered.display()
179 );
180 eprintln!(" Re-convert with: apr convert <source>.gguf -o <output>.apr\n");
181 }
182 manifest.model.model_path = Some(discovered);
183}
184
185fn print_no_model_error() {
187 eprintln!("✗ No local model found. apr code requires a local model.\n");
188 if check_invalid_apr_in_search_dirs() {
189 eprintln!(" ⚠ APR model(s) found but invalid (missing embedded tokenizer).");
190 eprintln!(" Re-convert: apr convert <source>.gguf -o <output>.apr\n");
191 }
192 eprintln!(" Download a model (APR format preferred):");
193 eprintln!(" apr pull qwen3:1.7b-q4k (default — best tool use at 1.2GB)");
194 eprintln!(" apr pull qwen3:8b-q4k (recommended for complex tasks)");
195 eprintln!();
196 eprintln!(" Or place a .apr/.gguf file in ~/.apr/models/ (auto-discovered)");
197 eprintln!();
198 eprintln!(" Then run: apr code or apr code --model <path>");
199}
200
201fn check_invalid_apr_in_search_dirs() -> bool {
203 for dir in &ModelConfig::model_search_dirs() {
204 if let Ok(entries) = std::fs::read_dir(dir) {
205 for entry in entries.flatten() {
206 let path = entry.path();
207 if path.extension().is_some_and(|e| e == "apr")
208 && !crate::agent::driver::validate::is_valid_model_file(&path)
209 {
210 return true;
211 }
212 }
213 }
214 }
215 false
216}
217
218fn load_project_instructions(max_bytes: usize) -> Option<String> {
220 let cwd = std::env::current_dir().ok()?;
221
222 for filename in &["APR.md", "CLAUDE.md"] {
223 let path = cwd.join(filename);
224 if path.is_file() {
225 if let Ok(content) = std::fs::read_to_string(&path) {
226 if max_bytes == 0 {
227 return None;
228 }
229 let truncated = if content.len() > max_bytes {
230 let end = content
231 .char_indices()
232 .take_while(|(i, _)| *i < max_bytes)
233 .last()
234 .map(|(i, c)| i + c.len_utf8())
235 .unwrap_or(max_bytes.min(content.len()));
236 format!("{}...\n(truncated from {} bytes)", &content[..end], content.len())
237 } else {
238 content
239 };
240 return Some(truncated);
241 }
242 }
243 }
244 None
245}
246
247fn instruction_budget(context_window: usize) -> usize {
249 if context_window < 4096 {
250 return 0;
251 }
252 let budget = context_window / 4;
253 budget.min(4096)
254}
255
256fn gather_project_context() -> String {
258 let mut ctx = String::new();
259 let cwd = std::env::current_dir().unwrap_or_default();
260 ctx.push_str(&format!("Working directory: {}\n", cwd.display()));
261
262 if let Ok(output) =
263 std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"]).output()
264 {
265 if output.status.success() {
266 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
267 ctx.push_str(&format!("Git branch: {branch}\n"));
268 }
269 }
270 if let Ok(output) =
271 std::process::Command::new("git").args(["diff", "--stat", "--no-color"]).output()
272 {
273 if output.status.success() {
274 let diff = String::from_utf8_lossy(&output.stdout);
275 let dirty_count = diff.lines().count().saturating_sub(1);
276 if dirty_count > 0 {
277 ctx.push_str(&format!("Dirty files: {dirty_count}\n"));
278 }
279 }
280 }
281
282 let mut rs_count = 0u32;
283 let mut py_count = 0u32;
284 let mut total = 0u32;
285 if let Ok(entries) = std::fs::read_dir("src") {
286 for e in entries.flatten() {
287 total += 1;
288 if let Some(ext) = e.path().extension() {
289 match ext.to_str() {
290 Some("rs") => rs_count += 1,
291 Some("py") => py_count += 1,
292 _ => {}
293 }
294 }
295 }
296 }
297 let lang = if rs_count > py_count {
298 "Rust"
299 } else if py_count > 0 {
300 "Python"
301 } else {
302 "unknown"
303 };
304 ctx.push_str(&format!("Language: {lang} ({total} files in src/)\n"));
305
306 if PathBuf::from("Cargo.toml").exists() {
307 ctx.push_str("Build system: Cargo (Rust)\n");
308 } else if PathBuf::from("pyproject.toml").exists() {
309 ctx.push_str("Build system: pyproject.toml (Python)\n");
310 }
311
312 ctx
313}
314
315fn build_default_manifest() -> AgentManifest {
317 let ctx_window = 4096_usize;
318 let budget = instruction_budget(ctx_window);
319 let project_instructions = load_project_instructions(budget);
320 let project_context = gather_project_context();
321
322 let mut system_prompt = CODE_SYSTEM_PROMPT.to_string();
323 system_prompt.push_str(&format!("\n\n## Project Context\n\n{project_context}"));
324 if let Some(ref instructions) = project_instructions {
325 system_prompt.push_str(&format!("\n## Project Instructions\n\n{instructions}"));
326 }
327
328 AgentManifest {
329 name: "apr-code".to_string(),
330 description: "Interactive AI coding assistant".to_string(),
331 privacy: PrivacyTier::Sovereign,
332 model: ModelConfig {
333 system_prompt,
334 max_tokens: 4096,
335 temperature: 0.0,
336 context_window: Some(32768),
340 ..ModelConfig::default()
341 },
342 resources: ResourceQuota {
343 max_iterations: 50,
344 max_tool_calls: 200,
345 max_cost_usd: 0.0,
346 max_tokens_budget: None,
347 },
348 capabilities: vec![
349 Capability::FileRead { allowed_paths: vec!["*".into()] },
350 Capability::FileWrite { allowed_paths: vec!["*".into()] },
351 Capability::Shell { allowed_commands: vec!["*".into()] },
352 Capability::Memory,
353 Capability::Rag,
354 ],
355 ..AgentManifest::default()
356 }
357}
358
359fn build_code_tools(manifest: &AgentManifest) -> ToolRegistry {
361 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
362
363 let mut tools = ToolRegistry::new();
364 tools.register(Box::new(FileReadTool::new(vec!["*".into()])));
365 tools.register(Box::new(FileWriteTool::new(vec!["*".into()])));
366 tools.register(Box::new(FileEditTool::new(vec!["*".into()])));
367 tools.register(Box::new(GlobTool::new(vec!["*".into()])));
368 tools.register(Box::new(GrepTool::new(vec!["*".into()])));
369 tools.register(Box::new(ShellTool::new(vec!["*".into()], cwd)));
370
371 let memory_sub = Arc::new(crate::agent::memory::InMemorySubstrate::new());
372 tools.register(Box::new(crate::agent::tool::memory::MemoryTool::new(
373 memory_sub,
374 manifest.name.clone(),
375 )));
376
377 tools.register(Box::new(crate::agent::tool::pmat_query::PmatQueryTool::new()));
379
380 #[cfg(feature = "rag")]
381 {
382 let oracle = Arc::new(crate::oracle::rag::RagOracle::new());
383 tools.register(Box::new(crate::agent::tool::rag::RagTool::new(oracle, 5)));
384 }
385
386 tools
387}
388
389pub use super::code_prompts::exit_code;
390
391fn run_single_prompt(
393 manifest: &AgentManifest,
394 driver: &dyn LlmDriver,
395 tools: &ToolRegistry,
396 memory: &dyn crate::agent::memory::MemorySubstrate,
397 prompt: &str,
398) -> i32 {
399 let mut single_manifest = manifest.clone();
400 single_manifest.resources.max_iterations = single_manifest.resources.max_iterations.min(10);
401 single_manifest.model.system_prompt = COMPACT_SYSTEM_PROMPT.to_string();
406 let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
410 Ok(rt) => rt,
411 Err(e) => {
412 eprintln!("Error: failed to create tokio runtime: {e}");
413 return exit_code::AGENT_ERROR;
414 }
415 };
416
417 let result = rt.block_on(crate::agent::runtime::run_agent_loop(
421 &single_manifest,
422 prompt,
423 driver,
424 tools,
425 memory,
426 None,
427 ));
428
429 match result {
430 Ok(r) => {
431 if r.text.is_empty() {
432 eprintln!(
436 "⚠ Empty response ({} iterations, {} tool calls). \
437 Model may be in thinking mode — rebuild apr from source for Qwen3NoThinkTemplate fix.",
438 r.iterations, r.tool_calls
439 );
440 } else {
441 println!("{}", r.text);
442 }
443 exit_code::SUCCESS
444 }
445 Err(e) => {
446 eprintln!("Error: {e}");
447 map_error_to_exit_code(&e)
448 }
449 }
450}
451
452use super::code_prompts::{
454 estimate_model_params_from_name, map_error_to_exit_code, scale_prompt_for_model,
455 CODE_SYSTEM_PROMPT, COMPACT_SYSTEM_PROMPT,
456};
457
458#[cfg(test)]
459#[path = "code_tests.rs"]
460mod tests;