- Terminals and file managers as tabs of a chat; chat terminals keep running on the server (no idle timeout) and re-attach from any device - "Just chat" in New session and a built-in chat mode with a lean prompt and only web tools; custom modes with kind (coding/conversation), read-only and tool picker - Mode selector as a dropdown - The chat shows running compactions (automatic: tokens and share of the limit) and labels summaries - Docker image builds and serves the terminal client binaries again - Fix: atomic session updates; background title/usage writes no longer undo each other (auto compaction triggered one turn late) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
553 lines
27 KiB
Rust
553 lines
27 KiB
Rust
//! 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<Vec<String>>,
|
||
pub model: Option<String>,
|
||
}
|
||
|
||
pub fn builtin_modes() -> Vec<Mode> {
|
||
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<Mode> {
|
||
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<Server>,
|
||
pub server_cfg: Option<Value>,
|
||
pub catalog: Catalog,
|
||
pub modes: Vec<Mode>,
|
||
pub permissions: PermissionConfig,
|
||
pub base_prompt: String,
|
||
pub auto_prompt: String,
|
||
pub remote_tools: Vec<ToolSpec>,
|
||
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<String>),
|
||
}
|
||
|
||
/// 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<String> },
|
||
Todos(Value),
|
||
Usage { context: u64, total_cost: f64 },
|
||
Permission { tool: String, title: String, input: Value, diff: Option<String>, reply: oneshot::Sender<Reply> },
|
||
Question { questions: Vec<Question>, reply: oneshot::Sender<Option<serde_json::Map<String, Value>>> },
|
||
Info(String),
|
||
Error(String),
|
||
/// A new step (model request) begins.
|
||
Step,
|
||
}
|
||
|
||
pub struct Agent {
|
||
pub env: Arc<Env>,
|
||
pub session: LocalSession,
|
||
approved: HashSet<String>,
|
||
read_files: Arc<Mutex<HashSet<PathBuf>>>,
|
||
shells: Arc<Mutex<BTreeMap<String, Arc<tools::Shell>>>>,
|
||
ui: mpsc::UnboundedSender<UiEvent>,
|
||
}
|
||
|
||
fn instruction_files(cwd: &Path) -> Vec<(PathBuf, String)> {
|
||
let mut out = Vec::new();
|
||
let home = crate::conf::home();
|
||
let mut dirs: Vec<PathBuf> = 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<Env>, session: LocalSession, ui: mpsc::UnboundedSender<UiEvent>) -> 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<ModelInfo> {
|
||
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<ToolSpec> {
|
||
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::<String>()));
|
||
} 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<instructions path=\"{}\">\n{}\n</instructions>\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<Message>, tools: &[ToolSpec], effort: Option<String>, 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<Part> = 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<ImageData>, effort: Option<String>, 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<String>, 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<Question> = 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<String>, 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<String> {
|
||
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::<Vec<_>>().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::<String>()),
|
||
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
|
||
_ => continue,
|
||
};
|
||
transcript.push_str(&format!("### {who}\n{line}\n"));
|
||
}
|
||
}
|
||
let prompt = format!("<transcript>\n{transcript}\n</transcript>\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, String>, 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)
|
||
}
|