use std::path::PathBuf;
use std::sync::Arc;
use crate::agent::capability::Capability;
use crate::agent::driver::LlmDriver;
use crate::agent::manifest::{AgentManifest, ModelConfig, ResourceQuota};
use crate::agent::tool::file::{FileEditTool, FileReadTool, FileWriteTool};
use crate::agent::tool::search::{GlobTool, GrepTool};
use crate::agent::tool::shell::ShellTool;
use crate::agent::tool::ToolRegistry;
use crate::serve::backends::PrivacyTier;
#[allow(clippy::too_many_arguments)]
pub fn cmd_code(
model: Option<PathBuf>,
project: PathBuf,
resume: Option<Option<String>>,
prompt: Vec<String>,
print: bool,
max_turns: u32,
manifest_path: Option<PathBuf>,
emit_trace: Option<PathBuf>,
output_format: &str,
input_format: &str,
) -> anyhow::Result<()> {
if project.as_os_str() != "." && project.is_dir() {
std::env::set_current_dir(&project)?;
}
let mut manifest = match manifest_path {
Some(ref path) => {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read manifest {}: {e}", path.display()))?;
let m = AgentManifest::from_toml(&content)
.map_err(|e| anyhow::anyhow!("invalid manifest: {e}"))?;
eprintln!("✓ Loaded manifest: {}", path.display());
m
}
None => {
let mut m = build_default_manifest();
let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let settings = crate::agent::settings::AprSettings::load_layered(&project_root)?;
apply_settings_to_manifest(&mut m, &settings)?;
m
}
};
if let Some(ref model_path) = model {
manifest.model.model_path = Some(model_path.clone());
}
discover_and_set_model(&mut manifest);
if let Some(ref path) = manifest.model.model_path {
let params_b = estimate_model_params_from_name(path);
if params_b < 2.0 {
manifest.model.system_prompt = scale_prompt_for_model(params_b);
}
}
if manifest.model.resolve_model_path().is_none() && manifest_path.is_none() {
print_no_model_error();
std::process::exit(exit_code::NO_MODEL);
}
let driver: Arc<dyn LlmDriver> = if let Some(model_path) = manifest.model.resolve_model_path() {
match crate::agent::driver::apr_serve::AprServeDriver::launch(
model_path,
manifest.model.context_window,
) {
Ok(d) => Arc::new(d),
Err(e) => {
eprintln!("⚠ apr serve unavailable ({e}), using embedded inference");
Arc::from(build_fallback_driver(&manifest)?)
}
}
} else {
Arc::from(build_fallback_driver(&manifest)?)
};
#[cfg(feature = "agents-mcp")]
{
let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match crate::agent::mcp_json::load_and_merge(&mut manifest, &project_root) {
Ok(0) => {}
Ok(n) => {
eprintln!("✓ Loaded {n} MCP server(s) from .mcp.json");
}
Err(e) => {
anyhow::bail!("invalid .mcp.json: {e}");
}
}
}
let mut tools = build_code_tools(&manifest);
register_mcp_client_tools(&mut tools, &manifest);
crate::agent::task_tool::register_task_tool(
&mut tools,
&manifest,
Arc::clone(&driver),
3,
);
let hooks_reg = crate::agent::hooks::HookRegistry::from_configs(manifest.hooks.clone());
let hook_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match hooks_reg.run(crate::agent::hooks::HookEvent::SessionStart, "", &hook_cwd) {
crate::agent::hooks::HookDecision::Allow => {}
crate::agent::hooks::HookDecision::Warn(msg) => {
if !msg.is_empty() {
eprintln!("⚠ SessionStart hook: {msg}");
}
}
crate::agent::hooks::HookDecision::Block(reason) => {
anyhow::bail!("SessionStart hook blocked session: {reason}");
}
}
let memory = crate::agent::memory::InMemorySubstrate::new();
if print || !prompt.is_empty() {
let prompt_text = if prompt.is_empty() {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
if input_format.eq_ignore_ascii_case("json") {
parse_json_input_envelope(&buf)?
} else {
buf
}
} else {
prompt.join(" ")
};
let code = run_single_prompt(
&manifest,
driver.as_ref(),
&tools,
&memory,
&prompt_text,
emit_trace.as_deref(),
output_format,
);
drop(driver); std::process::exit(code);
}
let resume_session_id = match resume {
Some(Some(id)) => Some(id), Some(None) => {
crate::agent::session::SessionStore::find_recent_for_cwd().map(|m| m.id)
}
None => {
crate::agent::session::offer_auto_resume()
}
};
crate::agent::repl::run_repl(
&manifest,
driver.as_ref(),
&tools,
&memory,
max_turns,
f64::MAX,
resume_session_id.as_deref(),
)
}
fn apply_settings_to_manifest(
manifest: &mut AgentManifest,
settings: &crate::agent::settings::AprSettings,
) -> anyhow::Result<()> {
if let Some(ref model) = settings.model {
if std::path::Path::new(model).is_absolute()
|| model.starts_with("./")
|| model.starts_with("../")
|| (!model.contains(':') && !model.starts_with("hf://"))
{
manifest.model.model_path = Some(std::path::PathBuf::from(model));
} else {
manifest.model.model_repo = Some(model.clone());
}
}
if let Some(extra) = settings.extra_system_prompt.as_deref() {
if !extra.trim().is_empty() {
manifest.model.system_prompt.push_str("\n\n");
manifest.model.system_prompt.push_str(extra);
}
}
if let Some(mt) = settings.max_turns {
manifest.resources.max_iterations = mt;
}
if let Some(ref pm) = settings.permission_mode {
if crate::agent::permission::PermissionMode::parse(pm).is_none() {
anyhow::bail!(
"settings.json permissionMode: unknown mode {pm:?} \
(expected default | plan | acceptEdits | bypassPermissions)"
);
}
}
if let Some(ref hosts) = settings.allowed_hosts {
if manifest.allowed_hosts.is_empty() {
manifest.allowed_hosts = hosts.clone();
}
}
Ok(())
}
fn build_fallback_driver(manifest: &AgentManifest) -> anyhow::Result<Box<dyn LlmDriver>> {
#[cfg(feature = "inference")]
{
if let Some(model_path) = manifest.model.resolve_model_path() {
let driver = crate::agent::driver::realizar::RealizarDriver::new(
model_path,
manifest.model.context_window,
)?;
return Ok(Box::new(driver));
}
}
let _ = manifest;
Ok(Box::new(crate::agent::driver::mock::MockDriver::single_response(
"Hello! I'm running in dry-run mode. \
Set model_path in your agent manifest or install the `apr` binary.",
)))
}
fn discover_and_set_model(manifest: &mut AgentManifest) {
if manifest.model.model_path.is_some() || manifest.model.model_repo.is_some() {
return;
}
let Some(discovered) = ModelConfig::discover_model() else {
return;
};
eprintln!(
"Model: {} (auto-discovered)",
discovered.file_name().unwrap_or_default().to_string_lossy()
);
let ext = discovered.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "gguf" && check_invalid_apr_in_search_dirs() {
eprintln!(
"⚠ APR model found but invalid (missing tokenizer). Using GGUF fallback: {}",
discovered.display()
);
eprintln!(" Re-convert with: apr convert <source>.gguf -o <output>.apr\n");
}
manifest.model.model_path = Some(discovered);
}
fn print_no_model_error() {
eprintln!("✗ No local model found. apr code requires a local model.\n");
if check_invalid_apr_in_search_dirs() {
eprintln!(" ⚠ APR model(s) found but invalid (missing embedded tokenizer).");
eprintln!(" Re-convert: apr convert <source>.gguf -o <output>.apr\n");
}
eprintln!(" Download a model (APR format preferred):");
eprintln!(" apr pull qwen3:1.7b-q4k (default — best tool use at 1.2GB)");
eprintln!(" apr pull qwen3:8b-q4k (recommended for complex tasks)");
eprintln!();
eprintln!(" Or place a .apr/.gguf file in ~/.apr/models/ (auto-discovered)");
eprintln!();
eprintln!(" Then run: apr code or apr code --model <path>");
}
fn check_invalid_apr_in_search_dirs() -> bool {
for dir in &ModelConfig::model_search_dirs() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "apr")
&& !crate::agent::driver::validate::is_valid_model_file(&path)
{
return true;
}
}
}
}
false
}
fn load_project_instructions(max_bytes: usize) -> Option<String> {
let cwd = std::env::current_dir().ok()?;
for filename in &["APR.md", "CLAUDE.md"] {
let path = cwd.join(filename);
if path.is_file() {
if let Ok(content) = std::fs::read_to_string(&path) {
if max_bytes == 0 {
return None;
}
let truncated = if content.len() > max_bytes {
let end = content
.char_indices()
.take_while(|(i, _)| *i < max_bytes)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(max_bytes.min(content.len()));
format!("{}...\n(truncated from {} bytes)", &content[..end], content.len())
} else {
content
};
return Some(truncated);
}
}
}
None
}
fn instruction_budget(context_window: usize) -> usize {
if context_window < 4096 {
return 0;
}
let budget = context_window / 4;
budget.min(4096)
}
fn assemble_system_prompt(
base: &str,
project_context: &str,
project_instructions: Option<&str>,
auto_memory: Option<&str>,
org_policy: Option<&crate::agent::org_policy::OrgPolicy>,
) -> String {
let mut out = String::from(base);
if let Some(pol) = org_policy {
out.push_str(&format!(
"\n\n## Enforced organization policy ({source})\n\n{content}",
source = pol.source.display(),
content = pol.content
));
}
out.push_str(&format!("\n\n## Project Context\n\n{project_context}"));
if let Some(instructions) = project_instructions {
out.push_str(&format!("\n## Project Instructions\n\n{instructions}"));
}
if let Some(mem) = auto_memory {
out.push_str(&format!("\n## Auto-memory\n\n{mem}"));
}
out
}
fn gather_project_context() -> String {
let mut ctx = String::new();
let cwd = std::env::current_dir().unwrap_or_default();
ctx.push_str(&format!("Working directory: {}\n", cwd.display()));
if let Ok(output) =
std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"]).output()
{
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
ctx.push_str(&format!("Git branch: {branch}\n"));
}
}
if let Ok(output) =
std::process::Command::new("git").args(["diff", "--stat", "--no-color"]).output()
{
if output.status.success() {
let diff = String::from_utf8_lossy(&output.stdout);
let dirty_count = diff.lines().count().saturating_sub(1);
if dirty_count > 0 {
ctx.push_str(&format!("Dirty files: {dirty_count}\n"));
}
}
}
let mut rs_count = 0u32;
let mut py_count = 0u32;
let mut total = 0u32;
if let Ok(entries) = std::fs::read_dir("src") {
for e in entries.flatten() {
total += 1;
if let Some(ext) = e.path().extension() {
match ext.to_str() {
Some("rs") => rs_count += 1,
Some("py") => py_count += 1,
_ => {}
}
}
}
}
let lang = if rs_count > py_count {
"Rust"
} else if py_count > 0 {
"Python"
} else {
"unknown"
};
ctx.push_str(&format!("Language: {lang} ({total} files in src/)\n"));
if PathBuf::from("Cargo.toml").exists() {
ctx.push_str("Build system: Cargo (Rust)\n");
} else if PathBuf::from("pyproject.toml").exists() {
ctx.push_str("Build system: pyproject.toml (Python)\n");
}
ctx
}
fn build_default_manifest() -> AgentManifest {
let ctx_window = 4096_usize;
let budget = instruction_budget(ctx_window);
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut import_warnings = Vec::new();
let project_instructions =
crate::agent::instructions::load_layered_instructions(&cwd, budget, &mut import_warnings)
.or_else(|| load_project_instructions(budget));
for w in &import_warnings {
eprintln!("⚠ instructions: {w}");
}
let project_context = gather_project_context();
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut auto_warns: Vec<String> = Vec::new();
let auto_memory = crate::agent::auto_memory::load_auto_memory(&cwd, &mut auto_warns);
for w in &auto_warns {
eprintln!("⚠ {w}");
}
let org_policy = crate::agent::org_policy::load_org_policy(
&crate::agent::org_policy::canonical_system_roots(),
"CLAUDE.md",
budget,
);
let system_prompt = assemble_system_prompt(
CODE_SYSTEM_PROMPT,
&project_context,
project_instructions.as_deref(),
auto_memory.as_deref(),
org_policy.as_ref(),
);
AgentManifest {
name: "apr-code".to_string(),
description: "Interactive AI coding assistant".to_string(),
privacy: PrivacyTier::Sovereign,
model: ModelConfig {
system_prompt,
max_tokens: 4096,
temperature: 0.0,
context_window: Some(32768),
..ModelConfig::default()
},
resources: ResourceQuota {
max_iterations: 50,
max_tool_calls: 200,
max_cost_usd: 0.0,
max_tokens_budget: None,
},
capabilities: vec![
Capability::FileRead { allowed_paths: vec!["*".into()] },
Capability::FileWrite { allowed_paths: vec!["*".into()] },
Capability::Shell { allowed_commands: vec!["*".into()] },
Capability::Memory,
Capability::Rag,
],
..AgentManifest::default()
}
}
#[allow(unused_variables)]
fn register_mcp_client_tools(tools: &mut ToolRegistry, manifest: &AgentManifest) {
#[cfg(feature = "agents-mcp")]
{
if manifest.mcp_servers.is_empty() {
return;
}
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => {
eprintln!("⚠ failed to create MCP discovery runtime: {e}");
return;
}
};
let discovered = rt.block_on(crate::agent::tool::mcp_client::discover_mcp_tools(manifest));
let count = discovered.len();
for tool in discovered {
tools.register(Box::new(tool));
}
if count > 0 {
eprintln!(
"✓ Registered {count} MCP tool(s) from {} server(s)",
manifest.mcp_servers.len()
);
}
}
}
fn build_code_tools(manifest: &AgentManifest) -> ToolRegistry {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut tools = ToolRegistry::new();
tools.register(Box::new(FileReadTool::new(vec!["*".into()])));
tools.register(Box::new(FileWriteTool::new(vec!["*".into()])));
tools.register(Box::new(FileEditTool::new(vec!["*".into()])));
tools.register(Box::new(GlobTool::new(vec!["*".into()])));
tools.register(Box::new(GrepTool::new(vec!["*".into()])));
tools.register(Box::new(ShellTool::new(vec!["*".into()], cwd)));
let memory_sub = Arc::new(crate::agent::memory::InMemorySubstrate::new());
tools.register(Box::new(crate::agent::tool::memory::MemoryTool::new(
memory_sub,
manifest.name.clone(),
)));
tools.register(Box::new(crate::agent::tool::pmat_query::PmatQueryTool::new()));
#[cfg(feature = "rag")]
{
let oracle = Arc::new(crate::oracle::rag::RagOracle::new());
tools.register(Box::new(crate::agent::tool::rag::RagTool::new(oracle, 5)));
}
register_web_tools(&mut tools, manifest);
tools
}
fn register_web_tools(tools: &mut ToolRegistry, manifest: &AgentManifest) {
use crate::serve::backends::PrivacyTier;
if matches!(manifest.privacy, PrivacyTier::Sovereign) {
return;
}
if manifest.allowed_hosts.is_empty() {
return;
}
tools.register(Box::new(crate::agent::tool::network::NetworkTool::new(
manifest.allowed_hosts.clone(),
)));
#[cfg(feature = "agents-browser")]
{
tools.register(Box::new(crate::agent::tool::browser::BrowserTool::new(manifest.privacy)));
}
}
pub use super::code_prompts::exit_code;
fn run_single_prompt(
manifest: &AgentManifest,
driver: &dyn LlmDriver,
tools: &ToolRegistry,
memory: &dyn crate::agent::memory::MemorySubstrate,
prompt: &str,
emit_trace: Option<&std::path::Path>,
output_format: &str,
) -> i32 {
let mut single_manifest = manifest.clone();
single_manifest.resources.max_iterations = single_manifest.resources.max_iterations.min(10);
single_manifest.model.system_prompt = COMPACT_SYSTEM_PROMPT.to_string();
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => {
eprintln!("Error: failed to create tokio runtime: {e}");
return exit_code::AGENT_ERROR;
}
};
let started = std::time::Instant::now();
let result = rt.block_on(crate::agent::runtime::run_agent_loop(
&single_manifest,
prompt,
driver,
tools,
memory,
None,
));
match result {
Ok(r) => {
let elapsed = started.elapsed();
if r.text.is_empty() {
eprintln!(
"⚠ Empty response ({} iterations, {} tool calls). \
Model may be in thinking mode — rebuild apr from source for Qwen3NoThinkTemplate fix.",
r.iterations, r.tool_calls
);
if output_format.eq_ignore_ascii_case("json") {
println!("{}", build_json_result_envelope(&r, elapsed, true));
}
} else if output_format.eq_ignore_ascii_case("json") {
println!("{}", build_json_result_envelope(&r, elapsed, false));
} else {
println!("{}", r.text);
}
if let Some(trace_path) = emit_trace {
let model = single_manifest
.model
.resolve_model_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "apr-code-unknown".to_owned());
if let Err(e) = emit_ccpa_trace(trace_path, prompt, &r, started.elapsed(), &model) {
eprintln!("⚠ failed to write ccpa-trace to {}: {e}", trace_path.display());
}
}
exit_code::SUCCESS
}
Err(e) => {
eprintln!("Error: {e}");
map_error_to_exit_code(&e)
}
}
}
fn emit_ccpa_trace(
path: &std::path::Path,
prompt: &str,
result: &super::result::AgentLoopResult,
elapsed: std::time::Duration,
model: &str,
) -> std::io::Result<()> {
use std::time::{SystemTime, UNIX_EPOCH};
let ts_micros =
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros()).unwrap_or(0);
let session_id = format!(
"{:08x}-{:04x}-7000-{:04x}-{:012x}",
(ts_micros >> 64) as u32 & 0xFFFF_FFFF,
((ts_micros >> 48) & 0xFFFF) as u16,
((ts_micros >> 32) & 0xFFFF) as u16,
(ts_micros & 0xFFFF_FFFF_FFFF) as u64
);
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let ts = format!("@{secs}");
let cwd_sha256 = "0".repeat(64);
let session_start = serde_json::json!({
"v": 1,
"kind": "session_start",
"session_id": session_id,
"ts": ts,
"actor": "apr-code",
"model": model,
"cwd_sha256": cwd_sha256,
});
let user_prompt = serde_json::json!({
"v": 1,
"kind": "user_prompt",
"turn": 0,
"text": prompt,
});
let assistant_turn = serde_json::json!({
"v": 1,
"kind": "assistant_turn",
"turn": 1,
"blocks": [{"type": "text", "text": result.text}],
"stop_reason": "end_turn",
});
let session_end = serde_json::json!({
"v": 1,
"kind": "session_end",
"turn": 1,
"stop_reason": "end_turn",
"elapsed_ms": elapsed.as_millis() as u64,
"tokens_in": result.usage.input_tokens,
"tokens_out": result.usage.output_tokens,
});
let body = format!("{}\n{}\n{}\n{}\n", session_start, user_prompt, assistant_turn, session_end);
std::fs::write(path, body)
}
fn parse_json_input_envelope(buf: &str) -> anyhow::Result<String> {
let trimmed = buf.trim();
if trimmed.is_empty() {
anyhow::bail!("--input-format=json: stdin is empty (expected JSON envelope)");
}
let v: serde_json::Value = serde_json::from_str(trimmed)
.map_err(|e| anyhow::anyhow!("--input-format=json: invalid JSON on stdin: {e}"))?;
let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("user");
if role != "user" {
anyhow::bail!("--input-format=json: only role=\"user\" supported, got \"{role}\"");
}
let content = v
.get("content")
.and_then(|c| c.as_str())
.ok_or_else(|| anyhow::anyhow!("--input-format=json: missing string field `content`"))?;
Ok(content.to_owned())
}
fn build_json_result_envelope(
result: &super::result::AgentLoopResult,
elapsed: std::time::Duration,
is_error: bool,
) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts_micros =
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros()).unwrap_or(0);
let session_id = format!(
"{:08x}-{:04x}-7000-{:04x}-{:012x}",
(ts_micros >> 64) as u32 & 0xFFFF_FFFF,
((ts_micros >> 48) & 0xFFFF) as u16,
((ts_micros >> 32) & 0xFFFF) as u16,
(ts_micros & 0xFFFF_FFFF_FFFF) as u64
);
let envelope = serde_json::json!({
"type": "result",
"subtype": if is_error { "error" } else { "success" },
"is_error": is_error,
"duration_ms": elapsed.as_millis() as u64,
"result": result.text,
"session_id": session_id,
"num_turns": result.iterations,
"tokens_in": result.usage.input_tokens,
"tokens_out": result.usage.output_tokens,
"total_cost_usd": 0,
});
envelope.to_string()
}
use super::code_prompts::{
estimate_model_params_from_name, map_error_to_exit_code, scale_prompt_for_model,
CODE_SYSTEM_PROMPT, COMPACT_SYSTEM_PROMPT,
};
#[cfg(test)]
#[path = "code_tests.rs"]
mod tests;