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 emit_trace: Option<PathBuf>,
36) -> anyhow::Result<()> {
37 if project.as_os_str() != "." && project.is_dir() {
39 std::env::set_current_dir(&project)?;
40 }
41
42 let mut manifest = match manifest_path {
44 Some(ref path) => {
45 let content = std::fs::read_to_string(path)
46 .map_err(|e| anyhow::anyhow!("cannot read manifest {}: {e}", path.display()))?;
47 let m = AgentManifest::from_toml(&content)
48 .map_err(|e| anyhow::anyhow!("invalid manifest: {e}"))?;
49 eprintln!("✓ Loaded manifest: {}", path.display());
50 m
51 }
52 None => build_default_manifest(),
53 };
54
55 if let Some(ref model_path) = model {
57 manifest.model.model_path = Some(model_path.clone());
58 }
59
60 discover_and_set_model(&mut manifest);
62
63 if let Some(ref path) = manifest.model.model_path {
66 let params_b = estimate_model_params_from_name(path);
67 if params_b < 2.0 {
68 manifest.model.system_prompt = scale_prompt_for_model(params_b);
69 }
70 }
71
72 if manifest.model.resolve_model_path().is_none() && manifest_path.is_none() {
74 print_no_model_error();
75 std::process::exit(exit_code::NO_MODEL);
76 }
77
78 let driver: Arc<dyn LlmDriver> = if let Some(model_path) = manifest.model.resolve_model_path() {
83 match crate::agent::driver::apr_serve::AprServeDriver::launch(
84 model_path,
85 manifest.model.context_window,
86 ) {
87 Ok(d) => Arc::new(d),
88 Err(e) => {
89 eprintln!("⚠ apr serve unavailable ({e}), using embedded inference");
90 Arc::from(build_fallback_driver(&manifest)?)
91 }
92 }
93 } else {
94 Arc::from(build_fallback_driver(&manifest)?)
95 };
96
97 let mut tools = build_code_tools(&manifest);
99
100 register_mcp_client_tools(&mut tools, &manifest);
104
105 crate::agent::task_tool::register_task_tool(
109 &mut tools,
110 &manifest,
111 Arc::clone(&driver),
112 3,
113 );
114
115 let hooks_reg = crate::agent::hooks::HookRegistry::from_configs(manifest.hooks.clone());
119 let hook_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
120 match hooks_reg.run(crate::agent::hooks::HookEvent::SessionStart, "", &hook_cwd) {
121 crate::agent::hooks::HookDecision::Allow => {}
122 crate::agent::hooks::HookDecision::Warn(msg) => {
123 if !msg.is_empty() {
124 eprintln!("⚠ SessionStart hook: {msg}");
125 }
126 }
127 crate::agent::hooks::HookDecision::Block(reason) => {
128 anyhow::bail!("SessionStart hook blocked session: {reason}");
129 }
130 }
131
132 let memory = crate::agent::memory::InMemorySubstrate::new();
134
135 if print || !prompt.is_empty() {
139 let prompt_text = if prompt.is_empty() {
140 let mut buf = String::new();
141 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
142 buf
143 } else {
144 prompt.join(" ")
145 };
146 let code = run_single_prompt(
147 &manifest,
148 driver.as_ref(),
149 &tools,
150 &memory,
151 &prompt_text,
152 emit_trace.as_deref(),
153 );
154 drop(driver); std::process::exit(code);
156 }
157
158 let resume_session_id = match resume {
161 Some(Some(id)) => Some(id), Some(None) => {
163 crate::agent::session::SessionStore::find_recent_for_cwd().map(|m| m.id)
165 }
166 None => {
167 crate::agent::session::offer_auto_resume()
169 }
170 };
171
172 crate::agent::repl::run_repl(
174 &manifest,
175 driver.as_ref(),
176 &tools,
177 &memory,
178 max_turns,
179 f64::MAX,
180 resume_session_id.as_deref(),
181 )
182}
183
184fn build_fallback_driver(manifest: &AgentManifest) -> anyhow::Result<Box<dyn LlmDriver>> {
186 #[cfg(feature = "inference")]
187 {
188 if let Some(model_path) = manifest.model.resolve_model_path() {
189 let driver = crate::agent::driver::realizar::RealizarDriver::new(
190 model_path,
191 manifest.model.context_window,
192 )?;
193 return Ok(Box::new(driver));
194 }
195 }
196 let _ = manifest;
197 Ok(Box::new(crate::agent::driver::mock::MockDriver::single_response(
199 "Hello! I'm running in dry-run mode. \
200 Set model_path in your agent manifest or install the `apr` binary.",
201 )))
202}
203
204fn discover_and_set_model(manifest: &mut AgentManifest) {
206 if manifest.model.model_path.is_some() || manifest.model.model_repo.is_some() {
207 return;
208 }
209 let Some(discovered) = ModelConfig::discover_model() else {
210 return;
211 };
212 eprintln!(
213 "Model: {} (auto-discovered)",
214 discovered.file_name().unwrap_or_default().to_string_lossy()
215 );
216 let ext = discovered.extension().and_then(|e| e.to_str()).unwrap_or("");
217 if ext == "gguf" && check_invalid_apr_in_search_dirs() {
218 eprintln!(
219 "⚠ APR model found but invalid (missing tokenizer). Using GGUF fallback: {}",
220 discovered.display()
221 );
222 eprintln!(" Re-convert with: apr convert <source>.gguf -o <output>.apr\n");
223 }
224 manifest.model.model_path = Some(discovered);
225}
226
227fn print_no_model_error() {
229 eprintln!("✗ No local model found. apr code requires a local model.\n");
230 if check_invalid_apr_in_search_dirs() {
231 eprintln!(" ⚠ APR model(s) found but invalid (missing embedded tokenizer).");
232 eprintln!(" Re-convert: apr convert <source>.gguf -o <output>.apr\n");
233 }
234 eprintln!(" Download a model (APR format preferred):");
235 eprintln!(" apr pull qwen3:1.7b-q4k (default — best tool use at 1.2GB)");
236 eprintln!(" apr pull qwen3:8b-q4k (recommended for complex tasks)");
237 eprintln!();
238 eprintln!(" Or place a .apr/.gguf file in ~/.apr/models/ (auto-discovered)");
239 eprintln!();
240 eprintln!(" Then run: apr code or apr code --model <path>");
241}
242
243fn check_invalid_apr_in_search_dirs() -> bool {
245 for dir in &ModelConfig::model_search_dirs() {
246 if let Ok(entries) = std::fs::read_dir(dir) {
247 for entry in entries.flatten() {
248 let path = entry.path();
249 if path.extension().is_some_and(|e| e == "apr")
250 && !crate::agent::driver::validate::is_valid_model_file(&path)
251 {
252 return true;
253 }
254 }
255 }
256 }
257 false
258}
259
260fn load_project_instructions(max_bytes: usize) -> Option<String> {
262 let cwd = std::env::current_dir().ok()?;
263
264 for filename in &["APR.md", "CLAUDE.md"] {
265 let path = cwd.join(filename);
266 if path.is_file() {
267 if let Ok(content) = std::fs::read_to_string(&path) {
268 if max_bytes == 0 {
269 return None;
270 }
271 let truncated = if content.len() > max_bytes {
272 let end = content
273 .char_indices()
274 .take_while(|(i, _)| *i < max_bytes)
275 .last()
276 .map(|(i, c)| i + c.len_utf8())
277 .unwrap_or(max_bytes.min(content.len()));
278 format!("{}...\n(truncated from {} bytes)", &content[..end], content.len())
279 } else {
280 content
281 };
282 return Some(truncated);
283 }
284 }
285 }
286 None
287}
288
289fn instruction_budget(context_window: usize) -> usize {
291 if context_window < 4096 {
292 return 0;
293 }
294 let budget = context_window / 4;
295 budget.min(4096)
296}
297
298fn gather_project_context() -> String {
300 let mut ctx = String::new();
301 let cwd = std::env::current_dir().unwrap_or_default();
302 ctx.push_str(&format!("Working directory: {}\n", cwd.display()));
303
304 if let Ok(output) =
305 std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"]).output()
306 {
307 if output.status.success() {
308 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
309 ctx.push_str(&format!("Git branch: {branch}\n"));
310 }
311 }
312 if let Ok(output) =
313 std::process::Command::new("git").args(["diff", "--stat", "--no-color"]).output()
314 {
315 if output.status.success() {
316 let diff = String::from_utf8_lossy(&output.stdout);
317 let dirty_count = diff.lines().count().saturating_sub(1);
318 if dirty_count > 0 {
319 ctx.push_str(&format!("Dirty files: {dirty_count}\n"));
320 }
321 }
322 }
323
324 let mut rs_count = 0u32;
325 let mut py_count = 0u32;
326 let mut total = 0u32;
327 if let Ok(entries) = std::fs::read_dir("src") {
328 for e in entries.flatten() {
329 total += 1;
330 if let Some(ext) = e.path().extension() {
331 match ext.to_str() {
332 Some("rs") => rs_count += 1,
333 Some("py") => py_count += 1,
334 _ => {}
335 }
336 }
337 }
338 }
339 let lang = if rs_count > py_count {
340 "Rust"
341 } else if py_count > 0 {
342 "Python"
343 } else {
344 "unknown"
345 };
346 ctx.push_str(&format!("Language: {lang} ({total} files in src/)\n"));
347
348 if PathBuf::from("Cargo.toml").exists() {
349 ctx.push_str("Build system: Cargo (Rust)\n");
350 } else if PathBuf::from("pyproject.toml").exists() {
351 ctx.push_str("Build system: pyproject.toml (Python)\n");
352 }
353
354 ctx
355}
356
357fn build_default_manifest() -> AgentManifest {
359 let ctx_window = 4096_usize;
360 let budget = instruction_budget(ctx_window);
361 let project_instructions = load_project_instructions(budget);
362 let project_context = gather_project_context();
363
364 let mut system_prompt = CODE_SYSTEM_PROMPT.to_string();
365 system_prompt.push_str(&format!("\n\n## Project Context\n\n{project_context}"));
366 if let Some(ref instructions) = project_instructions {
367 system_prompt.push_str(&format!("\n## Project Instructions\n\n{instructions}"));
368 }
369
370 AgentManifest {
371 name: "apr-code".to_string(),
372 description: "Interactive AI coding assistant".to_string(),
373 privacy: PrivacyTier::Sovereign,
374 model: ModelConfig {
375 system_prompt,
376 max_tokens: 4096,
377 temperature: 0.0,
378 context_window: Some(32768),
382 ..ModelConfig::default()
383 },
384 resources: ResourceQuota {
385 max_iterations: 50,
386 max_tool_calls: 200,
387 max_cost_usd: 0.0,
388 max_tokens_budget: None,
389 },
390 capabilities: vec![
391 Capability::FileRead { allowed_paths: vec!["*".into()] },
392 Capability::FileWrite { allowed_paths: vec!["*".into()] },
393 Capability::Shell { allowed_commands: vec!["*".into()] },
394 Capability::Memory,
395 Capability::Rag,
396 ],
397 ..AgentManifest::default()
398 }
399}
400
401#[allow(unused_variables)]
408fn register_mcp_client_tools(tools: &mut ToolRegistry, manifest: &AgentManifest) {
409 #[cfg(feature = "agents-mcp")]
410 {
411 if manifest.mcp_servers.is_empty() {
412 return;
413 }
414 let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
415 Ok(rt) => rt,
416 Err(e) => {
417 eprintln!("⚠ failed to create MCP discovery runtime: {e}");
418 return;
419 }
420 };
421 let discovered = rt.block_on(crate::agent::tool::mcp_client::discover_mcp_tools(manifest));
422 let count = discovered.len();
423 for tool in discovered {
424 tools.register(Box::new(tool));
425 }
426 if count > 0 {
427 eprintln!(
428 "✓ Registered {count} MCP tool(s) from {} server(s)",
429 manifest.mcp_servers.len()
430 );
431 }
432 }
433}
434
435fn build_code_tools(manifest: &AgentManifest) -> ToolRegistry {
437 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
438
439 let mut tools = ToolRegistry::new();
440 tools.register(Box::new(FileReadTool::new(vec!["*".into()])));
441 tools.register(Box::new(FileWriteTool::new(vec!["*".into()])));
442 tools.register(Box::new(FileEditTool::new(vec!["*".into()])));
443 tools.register(Box::new(GlobTool::new(vec!["*".into()])));
444 tools.register(Box::new(GrepTool::new(vec!["*".into()])));
445 tools.register(Box::new(ShellTool::new(vec!["*".into()], cwd)));
446
447 let memory_sub = Arc::new(crate::agent::memory::InMemorySubstrate::new());
448 tools.register(Box::new(crate::agent::tool::memory::MemoryTool::new(
449 memory_sub,
450 manifest.name.clone(),
451 )));
452
453 tools.register(Box::new(crate::agent::tool::pmat_query::PmatQueryTool::new()));
455
456 #[cfg(feature = "rag")]
457 {
458 let oracle = Arc::new(crate::oracle::rag::RagOracle::new());
459 tools.register(Box::new(crate::agent::tool::rag::RagTool::new(oracle, 5)));
460 }
461
462 register_web_tools(&mut tools, manifest);
466
467 tools
468}
469
470fn register_web_tools(tools: &mut ToolRegistry, manifest: &AgentManifest) {
474 use crate::serve::backends::PrivacyTier;
475
476 if matches!(manifest.privacy, PrivacyTier::Sovereign) {
477 return;
478 }
479 if manifest.allowed_hosts.is_empty() {
480 return;
481 }
482
483 tools.register(Box::new(crate::agent::tool::network::NetworkTool::new(
484 manifest.allowed_hosts.clone(),
485 )));
486
487 #[cfg(feature = "agents-browser")]
488 {
489 tools.register(Box::new(crate::agent::tool::browser::BrowserTool::new(manifest.privacy)));
490 }
491}
492
493pub use super::code_prompts::exit_code;
494
495fn run_single_prompt(
497 manifest: &AgentManifest,
498 driver: &dyn LlmDriver,
499 tools: &ToolRegistry,
500 memory: &dyn crate::agent::memory::MemorySubstrate,
501 prompt: &str,
502 emit_trace: Option<&std::path::Path>,
503) -> i32 {
504 let mut single_manifest = manifest.clone();
505 single_manifest.resources.max_iterations = single_manifest.resources.max_iterations.min(10);
506 single_manifest.model.system_prompt = COMPACT_SYSTEM_PROMPT.to_string();
511 let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
515 Ok(rt) => rt,
516 Err(e) => {
517 eprintln!("Error: failed to create tokio runtime: {e}");
518 return exit_code::AGENT_ERROR;
519 }
520 };
521
522 let started = std::time::Instant::now();
523
524 let result = rt.block_on(crate::agent::runtime::run_agent_loop(
528 &single_manifest,
529 prompt,
530 driver,
531 tools,
532 memory,
533 None,
534 ));
535
536 match result {
537 Ok(r) => {
538 if r.text.is_empty() {
539 eprintln!(
543 "⚠ Empty response ({} iterations, {} tool calls). \
544 Model may be in thinking mode — rebuild apr from source for Qwen3NoThinkTemplate fix.",
545 r.iterations, r.tool_calls
546 );
547 } else {
548 println!("{}", r.text);
549 }
550
551 if let Some(trace_path) = emit_trace {
555 let model = single_manifest
556 .model
557 .resolve_model_path()
558 .map(|p| p.display().to_string())
559 .unwrap_or_else(|| "apr-code-unknown".to_owned());
560 if let Err(e) = emit_ccpa_trace(trace_path, prompt, &r, started.elapsed(), &model) {
561 eprintln!("⚠ failed to write ccpa-trace to {}: {e}", trace_path.display());
562 }
563 }
564
565 exit_code::SUCCESS
566 }
567 Err(e) => {
568 eprintln!("Error: {e}");
569 map_error_to_exit_code(&e)
570 }
571 }
572}
573
574fn emit_ccpa_trace(
589 path: &std::path::Path,
590 prompt: &str,
591 result: &super::result::AgentLoopResult,
592 elapsed: std::time::Duration,
593 model: &str,
594) -> std::io::Result<()> {
595 use std::time::{SystemTime, UNIX_EPOCH};
596
597 let ts_micros =
598 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros()).unwrap_or(0);
599 let session_id = format!(
604 "{:08x}-{:04x}-7000-{:04x}-{:012x}",
605 (ts_micros >> 64) as u32 & 0xFFFF_FFFF,
606 ((ts_micros >> 48) & 0xFFFF) as u16,
607 ((ts_micros >> 32) & 0xFFFF) as u16,
608 (ts_micros & 0xFFFF_FFFF_FFFF) as u64
609 );
610 let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
613 let ts = format!("@{secs}");
614 let cwd_sha256 = "0".repeat(64);
615
616 let session_start = serde_json::json!({
617 "v": 1,
618 "kind": "session_start",
619 "session_id": session_id,
620 "ts": ts,
621 "actor": "apr-code",
622 "model": model,
623 "cwd_sha256": cwd_sha256,
624 });
625 let user_prompt = serde_json::json!({
626 "v": 1,
627 "kind": "user_prompt",
628 "turn": 0,
629 "text": prompt,
630 });
631 let assistant_turn = serde_json::json!({
632 "v": 1,
633 "kind": "assistant_turn",
634 "turn": 1,
635 "blocks": [{"type": "text", "text": result.text}],
636 "stop_reason": "end_turn",
637 });
638 let session_end = serde_json::json!({
639 "v": 1,
640 "kind": "session_end",
641 "turn": 1,
642 "stop_reason": "end_turn",
643 "elapsed_ms": elapsed.as_millis() as u64,
644 "tokens_in": result.usage.input_tokens,
645 "tokens_out": result.usage.output_tokens,
646 });
647
648 let body = format!("{}\n{}\n{}\n{}\n", session_start, user_prompt, assistant_turn, session_end);
649 std::fs::write(path, body)
650}
651
652use super::code_prompts::{
654 estimate_model_params_from_name, map_error_to_exit_code, scale_prompt_for_model,
655 CODE_SYSTEM_PROMPT, COMPACT_SYSTEM_PROMPT,
656};
657
658#[cfg(test)]
659#[path = "code_tests.rs"]
660mod tests;