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: 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 let mut tools = build_code_tools(&manifest);
98
99 register_mcp_client_tools(&mut tools, &manifest);
103
104 crate::agent::task_tool::register_task_tool(
108 &mut tools,
109 &manifest,
110 Arc::clone(&driver),
111 3,
112 );
113
114 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 let memory = crate::agent::memory::InMemorySubstrate::new();
133
134 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); std::process::exit(code);
148 }
149
150 let resume_session_id = match resume {
153 Some(Some(id)) => Some(id), Some(None) => {
155 crate::agent::session::SessionStore::find_recent_for_cwd().map(|m| m.id)
157 }
158 None => {
159 crate::agent::session::offer_auto_resume()
161 }
162 };
163
164 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
176fn 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 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
196fn 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
219fn 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
235fn 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
252fn 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
281fn 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
290fn 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
349fn 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 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#[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
427fn 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 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 register_web_tools(&mut tools, manifest);
458
459 tools
460}
461
462fn 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
487fn 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 single_manifest.model.system_prompt = COMPACT_SYSTEM_PROMPT.to_string();
502 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 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 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
548use 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;