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    emit_trace: Option<PathBuf>,
36) -> anyhow::Result<()> {
37    // --project: change working directory for project instructions
38    if project.as_os_str() != "." && project.is_dir() {
39        std::env::set_current_dir(&project)?;
40    }
41
42    // Load manifest or build default
43    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    // --model flag overrides manifest model_path
56    if let Some(ref model_path) = model {
57        manifest.model.model_path = Some(model_path.clone());
58    }
59
60    // PMAT-150: discover model with Jidoka validation (broken APR → GGUF fallback)
61    discover_and_set_model(&mut manifest);
62
63    // PMAT-198: Scale system prompt based on model size.
64    // Small models (<2B) degrade with the full tool table + project context.
65    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    // Contract: no_model_error — never silently use MockDriver
73    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    // PMAT-160: Try AprServeDriver first (apr serve has full CUDA/GPU).
79    // Falls back to embedded RealizarDriver if `apr` binary not found.
80    // PMAT-CODE-SPAWN-PARITY-001: driver stored as Arc so TaskTool can
81    // share it with the AgentPool for sub-agent execution.
82    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    // Build tool registry with coding tools
98    let mut tools = build_code_tools(&manifest);
99
100    // PMAT-CODE-MCP-CLIENT-001: register MCP client tools from manifest.mcp_servers.
101    // Synchronous wrapper over async discover_mcp_tools — a no-op when mcp_servers is
102    // empty (the default for `apr code` without a manifest).
103    register_mcp_client_tools(&mut tools, &manifest);
104
105    // PMAT-CODE-SPAWN-PARITY-001: register Task tool (Claude-Code Agent parity).
106    // `task` lets the agent delegate to typed subagents (general-purpose,
107    // explore, plan) with bounded recursion depth (Jidoka).
108    crate::agent::task_tool::register_task_tool(
109        &mut tools,
110        &manifest,
111        Arc::clone(&driver),
112        /* max_depth */ 3,
113    );
114
115    // PMAT-CODE-HOOKS-001: build hook registry from manifest and fire SessionStart.
116    // Returned Warn messages are surfaced to the user; a Block here aborts session
117    // startup (matching Claude Code's exit-code-2 semantics).
118    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    // Build memory
133    let memory = crate::agent::memory::InMemorySubstrate::new();
134
135    // Non-interactive mode: single prompt
136    // PMAT-161: Return exit code instead of process::exit() so driver Drop
137    // runs and kills the apr serve subprocess (no zombie processes).
138    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); // Kill apr serve subprocess before exit
155        std::process::exit(code);
156    }
157
158    // --resume: load previous session
159    // PMAT-165: auto-resume prompt when recent session exists (spec §6.3)
160    let resume_session_id = match resume {
161        Some(Some(id)) => Some(id), // --resume=<session-id>
162        Some(None) => {
163            // --resume (no ID): find most recent for cwd
164            crate::agent::session::SessionStore::find_recent_for_cwd().map(|m| m.id)
165        }
166        None => {
167            // No --resume flag: check for recent session and prompt
168            crate::agent::session::offer_auto_resume()
169        }
170    };
171
172    // Interactive REPL (local inference is free — budget unlimited)
173    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
184/// Build fallback driver (embedded RealizarDriver) when AprServeDriver unavailable.
185fn 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    // No model or no inference feature — return MockDriver
198    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
204/// Auto-discover model if none explicitly set (APR preferred over GGUF).
205fn 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
227/// Print actionable error when no local model is available.
228fn 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
243/// Check if any APR files in standard model search dirs are invalid.
244fn 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
260/// Load project-level instructions from APR.md or CLAUDE.md.
261fn 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
289/// Compute instruction budget based on model context window.
290fn 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
298/// Gather project context — git info, file stats, language.
299fn 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
357/// Build a default `AgentManifest` for coding tasks.
358fn 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            // PMAT-197: Qwen3 supports 32K context. Default 4096 caused
379            // truncate_messages to drop user query (9 tool schemas ~4000 tokens
380            // consumed the entire window). Set to 32K for Qwen3-class models.
381            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/// PMAT-CODE-MCP-CLIENT-001 — register external MCP servers declared in
402/// `manifest.mcp_servers[]` as tools in the `apr code` registry. Mirrors
403/// Claude Code's `.mcp.json` → agent-tool-provider wiring. Synchronous
404/// wrapper because `cmd_code` is sync; opens a scoped current-thread
405/// runtime for the discovery handshake. No-op when the feature is off
406/// or the manifest has no servers.
407#[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
435/// Register all coding tools.
436fn 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    // PMAT-163: dedicated pmat_query tool
454    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    // PMAT-CODE-WEB-TOOLS-001: register NetworkTool behind the privacy-tier
463    // gate. Sovereign tier always blocks (Poka-Yoke); Standard/Private
464    // tiers register iff `allowed_hosts` is non-empty (explicit opt-in).
465    register_web_tools(&mut tools, manifest);
466
467    tools
468}
469
470/// Register NetworkTool (+ BrowserTool when the `agents-browser` feature is
471/// on) when the manifest declares a non-Sovereign privacy tier and a
472/// non-empty `allowed_hosts` list.
473fn 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
495/// Run a single prompt (non-interactive). PMAT-172: cap iterations at 10.
496fn 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    // PMAT-197: Use compact system prompt for -p mode.
507    // The full CODE_SYSTEM_PROMPT (9-tool table + project context + CLAUDE.md)
508    // overwhelms Qwen3 1.7B causing </think> loops. For -p mode, use a minimal
509    // prompt that lets the model answer directly. Tools still available if needed.
510    single_manifest.model.system_prompt = COMPACT_SYSTEM_PROMPT.to_string();
511    // Note: context_window is set at driver launch time (build_default_manifest),
512    // not here. See PMAT-197 fix in build_default_manifest.
513
514    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    // PMAT-197: Use non-nudge loop for -p mode. The nudge ("Use a tool!") forces
525    // small models to make tool calls even for simple questions like "What is 2+2?"
526    // which causes stuck loops. Let the model decide whether to use tools.
527    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                // PMAT-190: Empty response — model may be emitting only thinking tokens
540                // that get stripped by strip_thinking_blocks(). Common with Qwen3 when
541                // the serve backend doesn't use Qwen3NoThinkTemplate.
542                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            // PMAT-CODE-EMIT-TRACE-001 (M28): write a ccpa-trace.jsonl
552            // describing this run. Used by `ccpa measure` to score
553            // apr code against canonical Claude Code reference fixtures.
554            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
574/// Emit a `ccpa-trace.jsonl` (M28) describing a single apr-code run.
575///
576/// Schema mirrors `claude-code-parity-apr-v1.yaml § trace_schema`. For
577/// the M28 minimum-viable scope we emit four records:
578///
579///   1. `session_start`  with a synthetic `session_id` derived from
580///      `started`'s wall-clock ts so re-runs differ; `cwd_sha256`
581///      placeholder is normalized at compare time by the differ.
582///   2. `user_prompt`    turn 0, verbatim text.
583///   3. `assistant_turn` turn 1, single `Block::Text` carrying
584///      `result.text`. Tool dispatch + hook + skill records are
585///      M29+ enrichment follow-ups.
586///   4. `session_end`    real elapsed_ms + token counts from
587///      `result.usage`.
588fn 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    // session_id: UUIDv7-shaped hex string of the start ts. Normalized
600    // by the differ at compare time so this only needs to be stable
601    // across teacher and student of the SAME fixture (re-running the
602    // same fixture produces a different session_id, which is fine).
603    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    // ts in ISO 8601 — not strictly RFC 3339, but the differ
611    // normalizes ts at compare time.
612    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
652// Prompts and exit codes extracted to code_prompts.rs
653use 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;