//! The local agent loop: model requests (server gateway or own provider), //! tool calls with permission checks, subagents, compaction, persistence. use crate::conf::Config; use crate::models::{Catalog, ModelInfo}; use crate::server::Server; use crate::session::LocalSession; use crate::tools::{self, Ctx, ToolSpec}; use anyhow::{Result, anyhow}; use codecraft_core::config::PermissionConfig; use codecraft_core::permission::{Decision, Evaluator, bash_prefix, split_command}; use codecraft_core::provider::StreamEvent; use codecraft_core::types::{ImageData, Message, Part, Question, Role, Usage}; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; const MAX_STEPS: usize = 200; const COMPACT_AT: f64 = 0.85; /// A mode (agent) as defined by the server or built in. #[derive(Debug, Clone)] pub struct Mode { pub name: String, pub description: String, pub primary: bool, pub prompt: String, pub read_only: bool, pub tools: Option>, pub model: Option, } pub fn builtin_modes() -> Vec { use codecraft_core::prompts::*; let m = |name: &str, description: &str, primary: bool, prompt: String, read_only: bool| Mode { name: name.into(), description: description.into(), primary, prompt, read_only, tools: None, model: None }; vec![ m("build", "Full access: implement, edit, run", true, BUILD_PROMPT.into(), false), m("plan", "Read-only: analyse and propose a plan", true, PLAN_PROMPT.into(), true), m("craft", "Study a target, build your own version, check and refine", true, craft_prompt(true), false), m("general", "General-purpose subagent for multi-step tasks", false, GENERAL_PROMPT.into(), false), m("explore", "Read-only subagent for fast codebase searches", false, EXPLORE_PROMPT.into(), true), ] } pub fn modes_from_server(cfg: &Value) -> Vec { cfg["agents"] .as_array() .into_iter() .flatten() .map(|a| Mode { name: a["name"].as_str().unwrap_or("").into(), description: a["description"].as_str().unwrap_or("").into(), primary: a["mode"] == "primary", prompt: a["prompt"].as_str().unwrap_or("").into(), read_only: a["read_only"].as_bool().unwrap_or(false), tools: a["tools"].as_array().map(|t| t.iter().filter_map(|x| x.as_str().map(String::from)).collect()), model: a["model"].as_str().map(|m| format!("{}{m}", crate::models::SERVER_PREFIX)), }) .filter(|m| !m.name.is_empty()) .collect() } /// Everything the agent needs from the environment (shared, read-mostly). pub struct Env { pub cfg: Config, pub server: Option, pub server_cfg: Option, pub catalog: Catalog, pub modes: Vec, pub permissions: PermissionConfig, pub base_prompt: String, pub auto_prompt: String, pub remote_tools: Vec, pub server_skills: Vec<(String, String)>, pub memory: String, } impl Env { pub fn mode(&self, name: &str) -> Mode { self.modes.iter().find(|m| m.name == name).cloned().unwrap_or_else(|| builtin_modes().remove(0)) } } pub enum Reply { Once, Always, Reject(Option), } /// Events for the UI. pub enum UiEvent { Busy(bool), Text(String), Reasoning(String), ToolStart { id: String, name: String, input: Value }, ToolDone { id: String, content: String, is_error: bool, diff: Option }, Todos(Value), Usage { context: u64, total_cost: f64 }, Permission { tool: String, title: String, input: Value, diff: Option, reply: oneshot::Sender }, Question { questions: Vec, reply: oneshot::Sender>> }, Info(String), Error(String), /// A new step (model request) begins. Step, } pub struct Agent { pub env: Arc, pub session: LocalSession, approved: HashSet, read_files: Arc>>, shells: Arc>>>, ui: mpsc::UnboundedSender, } fn instruction_files(cwd: &Path) -> Vec<(PathBuf, String)> { let mut out = Vec::new(); let home = crate::conf::home(); let mut dirs: Vec = cwd.ancestors().take_while(|d| *d != home && d.parent().is_some()).map(Path::to_path_buf).collect(); dirs.reverse(); for d in dirs { for name in ["AGENTS.md", "CLAUDE.md"] { let p = d.join(name); if let Ok(text) = std::fs::read_to_string(&p) { out.push((p, text.chars().take(40_000).collect())); break; } } } out } impl Agent { pub fn new(env: Arc, session: LocalSession, ui: mpsc::UnboundedSender) -> Self { Self { env, session, approved: HashSet::new(), read_files: Arc::default(), shells: Arc::default(), ui } } fn emit(&self, ev: UiEvent) { let _ = self.ui.send(ev); } fn model(&self, spec: &str) -> Result { self.env.catalog.find(spec).cloned().ok_or_else(|| anyhow!("model '{spec}' is not available – choose one with /model")) } fn tools_for(&self, mode: &Mode, depth: usize) -> Vec { let mut all = tools::local_tools(); all.extend(self.env.remote_tools.iter().map(|t| ToolSpec { name: t.name.clone(), description: t.description.clone(), schema: t.schema.clone(), read_only: t.read_only, is_edit: false, remote: true })); all.into_iter() .filter(|t| !mode.read_only || t.read_only) .filter(|t| !(t.name == "task" && depth >= 2)) .filter(|t| match &mode.tools { None => true, Some(list) => list.iter().any(|a| a.eq_ignore_ascii_case(&t.name) || (t.remote && a == "mcp")), }) .collect() } fn system_prompt(&self, mode: &Mode, model: &str) -> String { let env = &self.env; let cwd = PathBuf::from(&self.session.cwd); let mut s = format!("{}\n\n{}", env.base_prompt, mode.prompt); if self.session.permission_mode == "auto" && !mode.read_only { s.push_str(&env.auto_prompt); } s.push_str(&format!( "\n\n# Environment\n- Working directory: {}\n- Git repository: {}\n- Platform: {} ({}), local machine of the user (no sandbox – commands run with the user's rights)\n- Model: {model}\n- Date: {}\n", cwd.display(), if cwd.join(".git").exists() { "yes" } else { "no" }, std::env::consts::OS, std::env::consts::ARCH, chrono::Local::now().format("%Y-%m-%d"), )); let subs: Vec<&Mode> = env.modes.iter().filter(|m| !m.primary).collect(); if !mode.read_only && !subs.is_empty() { s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type):\n"); for m in subs { s.push_str(&format!("- {}{}: {}\n", m.name, if m.read_only { " (read-only)" } else { "" }, m.description)); } } let mut skills: Vec<(String, String)> = env.server_skills.clone(); for name in tools::builtin_skill_names() { if !skills.iter().any(|(n, _)| n == name) { let desc = tools::BUILTIN .iter() .find(|(p, _)| *p == format!("skills/{name}/SKILL.md")) .map(|(_, c)| codecraft_core_frontmatter(c).0.get("description").cloned().unwrap_or_default()) .unwrap_or_default(); skills.push((name.to_string(), desc)); } } for (name, p) in tools::project_skills(&cwd) { let desc = std::fs::read_to_string(&p).map(|c| codecraft_core_frontmatter(&c).0.get("description").cloned().unwrap_or_default()).unwrap_or_default(); skills.retain(|(n, _)| n != &name); skills.push((name, desc)); } if !skills.is_empty() { s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n"); for (n, d) in &skills { s.push_str(&format!("- {n}: {d}\n")); } } if !env.memory.trim().is_empty() { s.push_str(&format!("\n# About the user (personal memory)\n{}\n", env.memory.trim())); } if let Ok(mem) = std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) { s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::())); } else if !mode.read_only { s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"); } let files = instruction_files(&cwd); if !files.is_empty() { s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n"); for (p, text) in files { s.push_str(&format!("\n\n{}\n\n", p.display(), text.trim())); } } s } /// One model request; returns the assistant message and the stop reason. async fn step(&mut self, model: &ModelInfo, system: String, messages: Vec, tools: &[ToolSpec], effort: Option, mode: &str, cancel: &CancellationToken, live: bool) -> Result<(Message, String, Usage)> { let (tx, mut rx) = mpsc::channel(256); let env = self.env.clone(); let m = model.clone(); let defs = tools.iter().map(ToolSpec::def).collect(); let sid = self.session.id.clone(); let agent = mode.to_string(); let c = cancel.clone(); let handle = tokio::spawn(async move { crate::models::stream(&env.cfg, env.server.as_ref(), &m, system, messages, defs, effort, &sid, &agent, tx, c).await }); let mut parts: Vec = Vec::new(); let mut usage = Usage::default(); let mut stop = String::new(); while let Some(ev) = rx.recv().await { match ev { StreamEvent::TextDelta(t) => { if live { self.emit(UiEvent::Text(t.clone())); } match parts.last_mut() { Some(Part::Text { text }) => text.push_str(&t), _ => parts.push(Part::Text { text: t }), } } StreamEvent::ReasoningDelta(t) => { if live { self.emit(UiEvent::Reasoning(t.clone())); } match parts.last_mut() { Some(Part::Reasoning { text, .. }) => text.push_str(&t), _ => parts.push(Part::Reasoning { text: t, signature: None }), } } StreamEvent::ReasoningSignature(sig) => { if let Some(Part::Reasoning { signature, .. }) = parts.iter_mut().rev().find(|p| matches!(p, Part::Reasoning { .. })) { *signature = Some(sig); } } StreamEvent::ToolCall { id, name, input, extra } => parts.push(Part::ToolCall { id, name, input, extra }), StreamEvent::Usage(u) => usage = u, StreamEvent::Done { stop_reason } => stop = stop_reason, } } handle.await.map_err(|e| anyhow!("{e}"))??; let mut msg = Message::new(Role::Assistant, parts); msg.model = Some(model.spec.clone()); Ok((msg, stop, usage)) } fn account(&mut self, model: &ModelInfo, usage: &Usage) { let cost = model.cost(usage); self.session.usage.input_tokens += usage.input_tokens; self.session.usage.output_tokens += usage.output_tokens; self.session.usage.cache_read_tokens += usage.cache_read_tokens; self.session.usage.cache_write_tokens += usage.cache_write_tokens; self.session.cost += cost; self.session.context_tokens = usage.context_tokens(); crate::stats::record(&self.session, &model.spec, usage, cost); self.emit(UiEvent::Usage { context: usage.context_tokens(), total_cost: self.session.cost }); } /// Run one user turn. pub async fn prompt(&mut self, text: String, images: Vec, effort: Option, cancel: CancellationToken) { self.emit(UiEvent::Busy(true)); if self.session.title.is_empty() { self.session.title = text.lines().next().unwrap_or("").chars().take(70).collect(); } let mut parts = vec![Part::Text { text }]; parts.extend(images.into_iter().map(|image| Part::Image { image })); self.session.messages.push(Message::new(Role::User, parts)); if let Err(e) = self.run_loop(effort, &cancel).await { self.emit(UiEvent::Error(format!("{e:#}"))); } let _ = self.session.save(); self.emit(UiEvent::Busy(false)); } async fn run_loop(&mut self, effort: Option, cancel: &CancellationToken) -> Result<()> { for _ in 0..MAX_STEPS { if cancel.is_cancelled() { break; } let mode = self.env.mode(&self.session.mode); let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?; // Compact before the context window overflows. if let Some(ctx) = model.config.context { if self.session.context_tokens as f64 > ctx as f64 * COMPACT_AT { self.compact(&model, cancel).await?; } } let tools = self.tools_for(&mode, 0); self.emit(UiEvent::Step); let system = self.system_prompt(&mode, &model.spec); let (msg, stop, usage) = self.step(&model, system, self.session.active(), &tools, effort.clone(), &mode.name, cancel, true).await?; self.account(&model, &usage); let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect(); self.session.messages.push(msg); let _ = self.session.save(); if calls.is_empty() { if stop == "max_tokens" || stop == "length" { self.emit(UiEvent::Error("The answer hit the output token limit.".into())); } break; } let mut results = Vec::new(); for (id, name, input) in calls { if cancel.is_cancelled() { results.push(tool_result(&id, &name, "Aborted by user".into(), true)); continue; } let part = self.execute(&tools, &id, &name, input, &mode, cancel, 0).await; results.push(part); } self.session.messages.push(Message::new(Role::User, results)); let _ = self.session.save(); } Ok(()) } fn ctx(&self, cancel: &CancellationToken) -> Ctx { Ctx { cwd: PathBuf::from(&self.session.cwd), cancel: cancel.clone(), read_files: self.read_files.clone(), shells: self.shells.clone(), server: self.env.server.clone(), server_skills: self.env.server_skills.iter().map(|(n, _)| n.clone()).collect(), } } /// Permission check, then run the tool. #[allow(clippy::too_many_arguments)] fn execute<'a>(&'a mut self, tools: &'a [ToolSpec], id: &'a str, name: &'a str, input: Value, _mode: &'a Mode, cancel: &'a CancellationToken, depth: usize) -> futures::future::BoxFuture<'a, Part> { Box::pin(async move { let Some(spec) = tools.iter().find(|t| t.name == name) else { return tool_result(id, name, format!("Unknown or unavailable tool '{name}' in this mode."), true); }; if depth == 0 { self.emit(UiEvent::ToolStart { id: id.into(), name: name.into(), input: input.clone() }); } let decision = Evaluator { cfg: &self.env.permissions, mode: &self.session.permission_mode, approved: &self.approved }.evaluate(spec, &input); match decision { Decision::Deny => return self.finish(id, name, "Denied by the permission rules.".into(), true, None, depth), Decision::Ask => { let (tx, rx) = oneshot::channel(); let cwd = PathBuf::from(&self.session.cwd); let title = match name { "bash" => input["description"].as_str().or(input["command"].as_str()).unwrap_or("").to_string(), _ => input["path"].as_str().map(|p| format!("{name} {p}")).unwrap_or_else(|| name.to_string()), }; self.emit(UiEvent::Permission { tool: name.into(), title, input: input.clone(), diff: tools::preview(name, &input, &cwd), reply: tx }); let reply = tokio::select! { r = rx => r.unwrap_or(Reply::Reject(None)), _ = cancel.cancelled() => Reply::Reject(Some("aborted".into())), }; match reply { Reply::Reject(msg) => { let mut text = "The user rejected this tool call.".to_string(); if let Some(m) = msg.filter(|m| !m.trim().is_empty()) { text.push_str(&format!(" User feedback: {m}")); } return self.finish(id, name, text, true, None, depth); } Reply::Always => { if name == "bash" { for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() { self.approved.insert(format!("bash:{}", bash_prefix(&part))); } } else { self.approved.insert(name.to_string()); } } Reply::Once => {} } } Decision::Allow => {} } match name { "ask_user" => { let questions: Vec = serde_json::from_value(input["questions"].clone()).unwrap_or_default(); let (tx, rx) = oneshot::channel(); self.emit(UiEvent::Question { questions, reply: tx }); let answers = tokio::select! { r = rx => r.ok().flatten(), _ = cancel.cancelled() => None }; match answers { Some(a) => self.finish(id, name, serde_json::to_string_pretty(&a).unwrap_or_default(), false, None, depth), None => self.finish(id, name, "The user dismissed the questions without answering.".into(), true, None, depth), } } "todo_write" => { self.session.todos = input["todos"].clone(); self.emit(UiEvent::Todos(input["todos"].clone())); let ctx = self.ctx(cancel); match tools::run(&ctx, name, &input).await { Ok(o) => self.finish(id, name, o.content, false, None, depth), Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), } } "task" => { let text = self.subagent(&input, cancel, depth + 1).await; match text { Ok(t) => self.finish(id, name, t, false, None, depth), Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), } } _ if spec.remote => { let server = self.env.server.clone(); match server { Some(s) => match s.tool(name, input).await { Ok((content, err)) => self.finish(id, name, content, err, None, depth), Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), }, None => self.finish(id, name, "not connected to the server".into(), true, None, depth), } } _ => { let ctx = self.ctx(cancel); match tools::run(&ctx, name, &input).await { Ok(o) => { let images = o.images.clone(); let mut part = self.finish(id, name, o.content, o.is_error, o.diff, depth); if let Part::ToolResult { images: imgs, .. } = &mut part { *imgs = images; } part } Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth), } } } }) } fn finish(&self, id: &str, name: &str, content: String, is_error: bool, diff: Option, depth: usize) -> Part { if depth == 0 { self.emit(UiEvent::ToolDone { id: id.into(), content: content.clone(), is_error, diff: diff.clone() }); } let mut part = tool_result(id, name, content, is_error); if let (Some(d), Part::ToolResult { meta, .. }) = (diff, &mut part) { *meta = Some(json!({"diff": d})); } part } /// Run a subagent with a fresh context; returns its final report. async fn subagent(&mut self, input: &Value, cancel: &CancellationToken, depth: usize) -> Result { let kind = input["subagent_type"].as_str().unwrap_or("general"); let parent = self.env.mode(&self.session.mode); let mut mode = self.env.modes.iter().find(|m| m.name == kind && !m.primary).cloned().unwrap_or_else(|| builtin_modes().into_iter().find(|m| m.name == "general").unwrap()); if parent.read_only && !mode.read_only { mode = builtin_modes().into_iter().find(|m| m.name == "explore").unwrap(); } let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?; let tools = self.tools_for(&mode, depth); let prompt = input["prompt"].as_str().unwrap_or("").to_string(); self.emit(UiEvent::Info(format!("subagent {kind}: {}", input["description"].as_str().unwrap_or("")))); let mut messages = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])]; let mut last = String::new(); for _ in 0..MAX_STEPS { if cancel.is_cancelled() { break; } let system = self.system_prompt(&mode, &model.spec); let (msg, _, usage) = self.step(&model, system, messages.clone(), &tools, None, &mode.name, cancel, false).await?; self.account(&model, &usage); last = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::>().join(""); let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect(); messages.push(msg); if calls.is_empty() { break; } let mut results = Vec::new(); for (id, name, input) in calls { results.push(self.execute(&tools, &id, &name, input, &mode, cancel, depth).await); } messages.push(Message::new(Role::User, results)); } Ok(if last.trim().is_empty() { "(the subagent returned no report)".into() } else { last }) } /// Replace the history with a summary. pub async fn compact(&mut self, model: &ModelInfo, cancel: &CancellationToken) -> Result<()> { self.emit(UiEvent::Info("compacting the conversation…".into())); let mut transcript = String::new(); for m in self.session.active() { let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" }; for p in &m.parts { let line = match p { Part::Text { text } | Part::Summary { text, .. } => text.clone(), Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::()), Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::()), _ => continue, }; transcript.push_str(&format!("### {who}\n{line}\n")); } } let prompt = format!("\n{transcript}\n\n\nSummarize this coding session so far so that the work can continue seamlessly from the summary alone: goals and explicit requests, decisions, files changed and their state, commands/tests and results, open problems and the exact next steps."); let msgs = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])]; let (msg, _, usage) = self.step(model, "You are a precise summarizer of software engineering sessions. Reply with the summary only.".into(), msgs, &[], None, "compact", cancel, false).await?; self.account(model, &usage); let summary: String = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect(); if summary.trim().is_empty() { return Err(anyhow!("compaction produced no summary")); } self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary, auto: false, tokens: None }])); self.session.context_tokens = 0; self.read_files.lock().unwrap().clear(); let _ = self.session.save(); self.emit(UiEvent::Info("conversation compacted".into())); Ok(()) } } fn tool_result(id: &str, name: &str, content: String, is_error: bool) -> Part { Part::ToolResult { call_id: id.into(), name: name.into(), content, images: vec![], is_error, meta: None } } /// Parse "---\nkey: value\n---\nbody" frontmatter. pub fn codecraft_core_frontmatter(text: &str) -> (BTreeMap, String) { let mut meta = BTreeMap::new(); let Some(rest) = text.strip_prefix("---\n") else { return (meta, text.to_string()) }; let Some(end) = rest.find("\n---") else { return (meta, text.to_string()) }; for line in rest[..end].lines() { if let Some((k, v)) = line.split_once(':') { meta.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string()); } } let body = rest[end + 4..].trim_start_matches('\n').to_string(); (meta, body) }