//! The agent loop: stream a model response, run tool calls (with permission //! checks), feed results back, repeat until the model stops. use crate::app::{App, PermissionReply}; use crate::permission::{Decision, Evaluator, bash_prefix, split_command}; use crate::prompts::{self, Agent}; use crate::provider::{ChatRequest, Provider, StreamEvent}; use crate::store::UsageRecord; use crate::tools::{self, Tool, ToolCtx}; use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms}; use anyhow::{Result, anyhow, bail}; use futures::future::join_all; use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; const MAX_STEPS: usize = 250; const MAX_SUBAGENT_DEPTH: usize = 3; /// What happened to a prompt. pub enum Submitted { Started, Queued(crate::store::QueueItem), } fn emit_queue(app: &App, session_id: &str) { let items = app.store.queue_list(session_id).unwrap_or_default(); app.emit(Event::QueueUpdated { session_id: session_id.into(), items: serde_json::json!(items) }); } /// Take all queued messages (one user message's worth of parts). fn take_queue(app: &App, session_id: &str) -> Vec { let items = app.store.queue_take(session_id).unwrap_or_default(); if !items.is_empty() { emit_queue(app, session_id); } items.into_iter().flat_map(|i| i.parts).collect() } /// Start processing a user prompt. If the session is busy, the prompt (with /// attachments) is queued and delivered at the agent's next step. pub fn start_prompt(app: Arc, session_id: String, parts: Vec) -> Result<()> { submit(app, session_id, parts, "user").map(|_| ()) } pub fn submit(app: Arc, session_id: String, parts: Vec, source: &str) -> Result { // Queueing and the end-of-run check run under the same runtime lock, so a // message is never lost or delivered twice. let cancel = CancellationToken::new(); let queued = app.with_runtime(&session_id, |r| -> Result> { if r.cancel.is_some() { let item = crate::store::QueueItem { id: new_id(), parts: parts.clone(), source: source.into(), created_at: now_ms() }; app.store.queue_add(&session_id, &item)?; Ok(Some(item)) } else { r.cancel = Some(cancel.clone()); Ok(None) } })?; if let Some(item) = queued { emit_queue(&app, &session_id); return Ok(Submitted::Queued(item)); } let text_only: String = parts .iter() .filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }) .collect::>() .join("\n"); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true }); tokio::spawn(async move { let res = async { let msg = Message::new(Role::User, parts); app.store.add_message(&session_id, &msg)?; app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg }); maybe_generate_title(app.clone(), &session_id, &text_only); run_session(app.clone(), &session_id, cancel.clone(), 0).await } .await; if let Err(e) = res { tracing::warn!("session {session_id}: {e:#}"); app.emit(Event::Error { session_id: session_id.clone(), message: format!("{e:#}") }); } // Messages queued after the last step start a new turn – unless the user // stopped the agent: then they stay queued until sent explicitly. let leftover = app.with_runtime(&session_id, |r| { r.cancel = None; if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) } }); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false }); if !leftover.is_empty() { let _ = start_prompt(app, session_id, leftover); } }); Ok(Submitted::Started) } /// Send queued messages now (after a stop or a server restart). pub fn flush_queue(app: Arc, session_id: &str) -> Result { if app.is_busy(session_id) { return Ok(false); } let parts = take_queue(&app, session_id); if parts.is_empty() { return Ok(false); } start_prompt(app, session_id.to_string(), parts)?; Ok(true) } pub fn abort(app: &App, session_id: &str) { app.with_runtime(session_id, |r| { if let Some(c) = &r.cancel { c.cancel(); } }); } /// Messages since the last compaction summary. Tool calls left without a /// result (run interrupted, e.g. by a server restart) get a synthetic result: /// providers reject histories with unanswered tool calls. fn active_history(all: Vec) -> Vec { let start = all .iter() .rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))) .unwrap_or(0); let msgs: Vec = all.into_iter().skip(start).collect(); let mut out: Vec = Vec::with_capacity(msgs.len()); for (i, m) in msgs.iter().enumerate() { out.push(m.clone()); if m.role != Role::Assistant { continue; } let answered: std::collections::HashSet<&str> = msgs .get(i + 1) .filter(|n| n.role == Role::User) .map(|n| { n.parts .iter() .filter_map(|p| if let Part::ToolResult { call_id, .. } = p { Some(call_id.as_str()) } else { None }) .collect() }) .unwrap_or_default(); let missing: Vec = m .parts .iter() .filter_map(|p| match p { Part::ToolCall { id, name, .. } if !answered.contains(id.as_str()) => { Some(tool_result(id, name, "Not executed: the run was interrupted (e.g. server restart).".into(), true, None)) } _ => None, }) .collect(); if missing.is_empty() { continue; } // Results must directly follow the call: merge into the next user message or insert one. match msgs.get(i + 1) { Some(n) if n.role == Role::User => { // The next message is pushed in the next iteration; prepend there. let mut next = n.clone(); let mut parts = missing; parts.extend(next.parts); next.parts = parts; // Replace by pushing now and skipping the original. out.push(next); } _ => out.push(Message::new(Role::User, missing)), } } // Remove duplicates created by the merge above (same id pushed twice: keep the merged one). let mut dedup: Vec = Vec::with_capacity(out.len()); for m in out { if let Some(last) = dedup.last() { if last.id == m.id { continue; } } dedup.push(m); } dedup } fn normalize_tool_name(s: &str) -> String { s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_lowercase() } pub fn available_tools(app: &App, agent: &Agent) -> Vec> { let cfg = app.config(); let mut all = tools::builtin_tools(); if !cfg.images.enabled { all.retain(|t| t.name() != "generate_image"); } let (direct, deferred) = app.mcp.tools(&cfg); all.extend(direct); if !deferred.is_empty() { all.push(Arc::new(crate::mcp::McpSearch)); all.push(Arc::new(crate::mcp::McpCall)); } all.into_iter() .filter(|t| !agent.read_only || t.read_only()) // Craft tools only where they are useful. .filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare") || agent.tools.is_some()) .filter(|t| match &agent.tools { None => true, Some(list) => { let n = normalize_tool_name(t.name()); list.iter().any(|a| { let a = normalize_tool_name(a); a == n || (t.name().starts_with("mcp") && a == "mcp") }) } }) .collect() } struct Step { message: Message, stop_reason: String, } /// Boxed with an explicit `Send` bound: subagents recurse into the agent loop, /// and the declared type breaks the cycle for the compiler. fn run_session_boxed( app: Arc, session_id: String, cancel: CancellationToken, depth: usize, ) -> futures::future::BoxFuture<'static, Result> { Box::pin(async move { run_session(app, &session_id, cancel, depth).await }) } /// Run the loop for a session until the model stops. Returns the final text. pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationToken, depth: usize) -> Result { let mut final_text = String::new(); for _ in 0..MAX_STEPS { if cancel.is_cancelled() { break; } let session = app.session(session_id)?; let cwd = PathBuf::from(&session.cwd); let roots = app.user_roots(session.owner_id.as_deref()); let root = app.root_for(session.owner_id.as_deref(), &cwd); let agent = prompts::find_agent(&app, &root, &cwd, &session.agent); let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone()); let (provider, model) = app.registry().resolve(&model_spec)?; // Auto-compaction when the last request used most of the context window. if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) { let threshold = app.config().server.auto_compact_threshold; if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold { compact(app.clone(), session_id, cancel.clone()).await?; continue; } } let tools = available_tools(&app, &agent); let history = active_history(app.store.messages(session_id)?); let req = ChatRequest { system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode), messages: history, tools: tools.iter().map(|t| t.def()).collect(), max_tokens: model.max_output.unwrap_or(16_000), reasoning: true, model: model.clone(), }; let step = stream_step(&app, &session, &agent.name, &model_spec, provider, req, &cancel).await?; final_text = step .message .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)> = step .message .parts .iter() .filter_map(|p| match p { Part::ToolCall { id, name, input, .. } => Some((id.clone(), name.clone(), input.clone())), _ => None, }) .collect(); if cancel.is_cancelled() { // Every tool call needs a result, otherwise the history is invalid. if !calls.is_empty() { let results = calls .iter() .map(|(id, name, _)| tool_result(id, name, "Aborted by user".into(), true, None)) .collect(); save_user_parts(&app, session_id, results)?; } break; } if calls.is_empty() { match step.stop_reason.as_str() { "max_tokens" | "length" => app.emit(Event::Error { session_id: session_id.into(), message: "The response hit the output token limit (max_output).".into(), }), "refusal" | "content_filter" => app.emit(Event::Error { session_id: session_id.into(), message: "The model declined to respond (refusal).".into(), }), _ => {} } break; } let base = ToolCtx { app: app.clone(), session_id: session_id.to_string(), call_id: String::new(), cwd: cwd.clone(), cancel: cancel.clone(), agent: agent.name.clone(), roots: roots.clone(), }; let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned(); let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only())); let mut results = Vec::new(); if all_read_only { let futs = calls.iter().map(|(id, name, input)| { execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth) }); results = join_all(futs).await; } else { for (id, name, input) in &calls { results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await); } } // Deliver messages the user queued while we were working (with attachments). results.extend(take_queue(&app, session_id)); save_user_parts(&app, session_id, results)?; } Ok(final_text) } fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option) -> Part { Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta } } fn save_user_parts(app: &App, session_id: &str, parts: Vec) -> Result<()> { let msg = Message::new(Role::User, parts); app.store.add_message(session_id, &msg)?; app.emit(Event::MessageAdded { session_id: session_id.into(), message: msg }); Ok(()) } #[allow(clippy::too_many_arguments)] async fn execute_call( app: &Arc, session: &Session, base: &ToolCtx, tool: Option>, call_id: &str, name: &str, input: Value, depth: usize, ) -> Part { let Some(tool) = tool else { return tool_result(call_id, name, format!("Unknown or unavailable tool '{name}' in this mode."), true, None); }; if let Some(raw) = input.get("__invalid_json") { return tool_result( call_id, name, format!("Tool input was not valid JSON (possibly truncated). Retry with a smaller input.\n{}", raw.as_str().unwrap_or("").chars().take(300).collect::()), true, None, ); } if name == "task" && depth >= MAX_SUBAGENT_DEPTH { return tool_result(call_id, name, "Maximum subagent depth reached.".into(), true, None); } let ctx = ToolCtx { app: app.clone(), session_id: base.session_id.clone(), call_id: call_id.into(), cwd: base.cwd.clone(), cancel: base.cancel.clone(), agent: base.agent.clone(), roots: base.roots.clone(), }; // mcp_call is judged (and approved) as the MCP tool it calls. let call_name = name; let (tool, input, name): (Arc, Value, String) = if name == "mcp_call" { match crate::mcp::resolve_call_target(&ctx, &input) { Ok(target) => { let args = input.get("arguments").cloned().unwrap_or(serde_json::json!({})); let n = target.full_name.clone(); (target, args, n) } Err(e) => return tool_result(call_id, call_name, format!("Error: {e:#}"), true, None), } } else { (tool, input, name.to_string()) }; let name = name.as_str(); // Permission check. Approvals are tracked on the top-level session. let root = app.root_session_id(&session.id); let cfg = app.config(); let decision = app.with_runtime(&root, |r| { Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input) }); match decision { Decision::Deny => { return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None); } Decision::Ask => { let diff = tool.preview(&input, &ctx).await; let (reply, message) = app .request_permission(&session.id, name, tool.title(&input), input.clone(), diff, base.cancel.clone()) .await; match reply { PermissionReply::Reject => { let mut text = "The user rejected this tool call.".to_string(); if let Some(m) = message.filter(|m| !m.trim().is_empty()) { text.push_str(&format!(" User feedback: {m}")); } return tool_result(call_id, call_name, text, true, None); } PermissionReply::Always => app.with_runtime(&root, |r| { if name == "bash" { let cmd = input["command"].as_str().unwrap_or(""); for part in split_command(cmd).unwrap_or_default() { r.approved.insert(format!("bash:{}", bash_prefix(&part))); } } else { r.approved.insert(name.to_string()); } }), PermissionReply::Once => {} } } Decision::Allow => {} } let res = if name == "task" { // Pass depth through the agent name channel: subagents run with depth+1. run_task(app, &ctx, input, depth).await } else { tool.run(input, &ctx).await }; match res { Ok(out) => Part::ToolResult { call_id: call_id.into(), name: call_name.into(), content: out.content, images: out.images, is_error: out.is_error, meta: out.meta, }, Err(e) => tool_result(call_id, call_name, format!("Error: {e:#}"), true, None), } } async fn run_task(app: &Arc, ctx: &ToolCtx, input: Value, depth: usize) -> Result { let prompt = tools::arg_str(&input, "prompt")?.to_string(); let description = input["description"].as_str().unwrap_or("subagent").to_string(); let agent_name = input["subagent_type"].as_str().unwrap_or("general").to_string(); let background = input["background"].as_bool().unwrap_or(false); let (child, agent) = create_child(app, ctx, &agent_name, &description, prompt)?; let meta = serde_json::json!({ "session_id": child.id, "agent": agent.name, "model": child.model, "background": background }); if background { let app = app.clone(); let parent_id = ctx.session_id.clone(); let child_id = child.id.clone(); let agent_name = agent.name.clone(); let desc = description.clone(); // Background tasks outlive the current turn; they are stopped via their own session. let cancel = CancellationToken::new(); tokio::spawn(async move { let res = run_child(app.clone(), &child, &agent, cancel, depth + 1).await; let body = match res { Ok(text) => text, Err(e) => format!("The background task failed: {e:#}"), }; let text = format!( "\n{body}\n" ); // Injected into the running turn, or starts a new turn when the parent is idle. if app.store.get_session(&parent_id).ok().flatten().is_some() { if let Err(e) = submit(app.clone(), parent_id.clone(), vec![Part::Text { text }], "background") { tracing::warn!("could not deliver background result: {e:#}"); } } }); return Ok(tools::ToolOutput::text(format!( "Started background task '{description}' with subagent '{}' (model {}). Its result will arrive \ automatically as a message when it finishes – continue with other work and do not poll.", meta["agent"].as_str().unwrap_or(""), meta["model"].as_str().unwrap_or("") )) .with_meta(meta)); } let text = run_child(app.clone(), &child, &agent, ctx.cancel.child_token(), depth + 1).await?; Ok(tools::ToolOutput::text(text).with_meta(meta)) } /// Entry point used by the task tool when called outside the runner. pub async fn run_subagent(app: Arc, ctx: &ToolCtx, agent: &str, description: &str, prompt: String) -> Result<(String, String)> { let (child, agent) = create_child(&app, ctx, agent, description, prompt)?; let text = run_child(app, &child, &agent, ctx.cancel.child_token(), 1).await?; Ok((child.id, text)) } /// Create the subagent session with its first message. fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> { let parent = app.session(&ctx.session_id)?; let root = ctx.root(); let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent); let agents = prompts::agents(app, &root, &ctx.cwd); let mut agent = agents .iter() .find(|a| a.name == agent_name && a.mode != "primary") .cloned() .ok_or_else(|| { let names: Vec<_> = agents.iter().filter(|a| a.mode != "primary").map(|a| a.name.as_str()).collect(); anyhow!("unknown subagent '{agent_name}'. Available: {}", names.join(", ")) })?; // Read-only parents may only spawn read-only subagents. if parent_agent.read_only && !agent.read_only { agent = agents.into_iter().find(|a| a.name == "explore").unwrap_or(agent); } if let Some(m) = &agent.model { app.registry().resolve(m).map_err(|e| anyhow!("subagent '{}' has an unusable model: {e:#}", agent.name))?; } let now = now_ms(); let child = Session { id: new_id(), title: description.to_string(), cwd: parent.cwd.clone(), model: agent.model.clone().unwrap_or_else(|| parent.model.clone()), agent: agent.name.clone(), permission_mode: parent.permission_mode.clone(), parent_id: Some(parent.id.clone()), owner_id: parent.owner_id.clone(), created_at: now, updated_at: now, todos: Value::Null, last_usage: None, }; let child = app.save_session(child)?; let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); app.store.add_message(&child.id, &msg)?; app.emit(Event::MessageAdded { session_id: child.id.clone(), message: msg }); Ok((child, agent)) } /// Run a subagent session to completion, honouring the subagent limits. async fn run_child(app: Arc, child: &Session, agent: &Agent, cancel: CancellationToken, depth: usize) -> Result { // Registered like a normal run so the user can stop it from its own session. app.with_runtime(&child.id, |r| r.cancel = Some(cancel.clone())); app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: true }); // Only first-level subagents count against the limits: nested subagents would // otherwise deadlock waiting for a slot their own parent holds. let _slot = if depth <= 1 { let (slot, waited) = app.subagent_slot(&agent.name, agent.max_parallel, &child.id, &cancel).await; if waited { app.emit(Event::Waiting { session_id: child.id.clone(), reason: None }); } slot } else { None }; let res = if cancel.is_cancelled() { Err(anyhow!("aborted")) } else { run_session_boxed(app.clone(), child.id.clone(), cancel, depth).await }; app.with_runtime(&child.id, |r| r.cancel = None); app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: false }); let text = res?; Ok(if text.trim().is_empty() { "(subagent returned no text)".into() } else { text }) } fn is_retryable(e: &anyhow::Error) -> bool { let s = format!("{e:#}"); s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out") } /// One model request with streaming to the UI, usage recording and retries. async fn stream_step( app: &Arc, session: &Session, agent: &str, model_spec: &str, provider: Arc, req: ChatRequest, cancel: &CancellationToken, ) -> Result { let mut attempt = 0; loop { attempt += 1; let msg_id = new_id(); let (tx, mut rx) = mpsc::channel::(256); let p = provider.clone(); let r = req.clone(); let c = cancel.clone(); // Wait for a free slot on the model/host; not counted as time to first token. let registry = app.registry(); if registry.is_saturated(model_spec) { app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("model:{model_spec}")) }); } let slot = tokio::select! { s = registry.acquire(model_spec) => s, _ = cancel.cancelled() => bail!("aborted"), }; app.emit(Event::Waiting { session_id: session.id.clone(), reason: None }); let handle = tokio::spawn(async move { let _slot = slot; p.stream(r, tx, c).await }); let started = Instant::now(); let mut ttft: Option = None; let mut parts: Vec = Vec::new(); let mut usage = Usage::default(); let mut stop_reason = String::new(); while let Some(ev) = rx.recv().await { if ttft.is_none() && !matches!(ev, StreamEvent::Usage(_) | StreamEvent::Done { .. }) { ttft = Some(started.elapsed().as_millis()); } match ev { StreamEvent::TextDelta(t) => { match parts.last_mut() { Some(Part::Text { text }) => text.push_str(&t), _ => parts.push(Part::Text { text: t.clone() }), } app.emit(Event::TextDelta { session_id: session.id.clone(), message_id: msg_id.clone(), text: t }); } StreamEvent::ReasoningDelta(t) => { match parts.last_mut() { Some(Part::Reasoning { text, signature: None }) => text.push_str(&t), _ => parts.push(Part::Reasoning { text: t.clone(), signature: None }), } app.emit(Event::ReasoningDelta { session_id: session.id.clone(), message_id: msg_id.clone(), text: t }); } StreamEvent::ReasoningSignature(sig) => match parts.last_mut() { Some(Part::Reasoning { signature, .. }) if signature.is_none() => *signature = Some(sig), // Thinking without visible text (display omitted / redacted). _ => parts.push(Part::Reasoning { text: String::new(), signature: Some(sig) }), }, StreamEvent::ToolCall { id, name, input, extra } => { app.emit(Event::ToolStarted { session_id: session.id.clone(), message_id: msg_id.clone(), call_id: id.clone(), name: name.clone(), input: input.clone(), }); parts.push(Part::ToolCall { id, name, input, extra }); } StreamEvent::Usage(u) => usage = u, StreamEvent::Done { stop_reason: s } => stop_reason = s, } } let result = handle.await.map_err(|e| anyhow!("provider task failed: {e}"))?; let duration = started.elapsed().as_millis() as u64; let (provider_name, model_id) = model_spec.split_once('/').unwrap_or(("", model_spec)); let record = |error: bool, usage: &Usage| UsageRecord { ts: now_ms(), session_id: session.id.clone(), root_session_id: app.root_session_id(&session.id), provider: provider_name.into(), model: model_id.into(), agent: agent.into(), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cache_read_tokens: usage.cache_read_tokens, cache_write_tokens: usage.cache_write_tokens, ttft_ms: ttft.unwrap_or(duration as u128) as u64, duration_ms: duration, cost: req.model.cost(usage), error, }; if let Err(e) = result { let _ = app.store.add_usage(&record(true, &usage)); if parts.is_empty() && attempt < 4 && is_retryable(&e) && !cancel.is_cancelled() { let wait = std::time::Duration::from_secs(2u64.pow(attempt)); app.emit(Event::Error { session_id: session.id.clone(), message: format!("{e:#} – retrying in {}s", wait.as_secs()), }); tokio::time::sleep(wait).await; continue; } return Err(e); } if cancel.is_cancelled() { // Keep what was streamed, but drop unfinished tool calls and unsigned thinking. parts.retain(|p| matches!(p, Part::Text { .. })); } if usage.input_tokens + usage.output_tokens > 0 || !cancel.is_cancelled() { let _ = app.store.add_usage(&record(false, &usage)); } let mut message = Message::new(Role::Assistant, parts); message.id = msg_id; message.model = Some(model_spec.to_string()); message.usage = Some(usage.clone()); if !message.parts.is_empty() { app.store.add_message(&session.id, &message)?; app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() }); } let mut s = app.session(&session.id)?; s.last_usage = Some(usage); app.save_session(s)?; return Ok(Step { message, stop_reason }); } } /// Collect a complete, tool-free answer (titles, summaries). async fn complete_text(app: &App, spec: &str, provider: Arc, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> { let slot = app.registry().acquire(spec).await; let (tx, mut rx) = mpsc::channel(256); let handle = tokio::spawn(async move { let _slot = slot; provider.stream(req, tx, cancel).await }); let mut text = String::new(); let mut usage = Usage::default(); while let Some(ev) = rx.recv().await { match ev { StreamEvent::TextDelta(t) => text.push_str(&t), StreamEvent::Usage(u) => usage = u, _ => {} } } handle.await??; Ok((text, usage)) } fn small_model(app: &App, session: &Session) -> String { let cfg = app.config(); if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() { cfg.server.small_model.clone() } else { session.model.clone() } } fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::config::ModelConfig, usage: &Usage, agent: &str) { let (p, m) = spec.split_once('/').unwrap_or(("", spec)); let _ = app.store.add_usage(&UsageRecord { ts: now_ms(), session_id: session.id.clone(), root_session_id: app.root_session_id(&session.id), provider: p.into(), model: m.into(), agent: agent.into(), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cache_read_tokens: usage.cache_read_tokens, cache_write_tokens: usage.cache_write_tokens, ttft_ms: 0, duration_ms: 0, cost: model.cost(usage), error: false, }); } fn maybe_generate_title(app: Arc, session_id: &str, text: &str) { let Ok(session) = app.session(session_id) else { return }; if session.parent_id.is_some() || !session.title.is_empty() { return; } let text: String = text.chars().take(2000).collect(); tokio::spawn(async move { let spec = small_model(&app, &session); let Ok((provider, mut model)) = app.registry().resolve(&spec) else { return }; model.effort = Some("low".into()); let req = ChatRequest { system: "Write a short title (max 6 words) for a coding session that starts with the user's message below. Use the user's language. Reply with the title only, no quotes or punctuation at the end.".into(), messages: vec![Message::new(Role::User, vec![Part::Text { text }])], tools: vec![], max_tokens: 2000, reasoning: false, model: model.clone(), }; match complete_text(&app, &spec, provider, req, CancellationToken::new()).await { Ok((title, usage)) => { record_side_usage(&app, &session, &spec, &model, &usage, "title"); let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string(); if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) { if s.title.is_empty() { s.title = title.chars().take(80).collect(); let _ = app.save_session(s); } } } Err(e) => tracing::debug!("title generation failed: {e:#}"), } }); } const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points."; /// Replace the active history with a summary (the old messages stay visible in the UI). pub async fn compact(app: Arc, session_id: &str, cancel: CancellationToken) -> Result<()> { let session = app.session(session_id)?; let history = active_history(app.store.messages(session_id)?); if history.is_empty() { return Ok(()); } let spec = session.model.clone(); let (provider, model) = app.registry().resolve(&spec)?; let mut messages = history; // Strip images to keep the summary request small. for m in &mut messages { m.parts.retain(|p| !matches!(p, Part::Image { .. })); for p in &mut m.parts { if let Part::ToolResult { images, .. } = p { images.clear(); } } } messages.push(Message::new(Role::User, vec![Part::Text { text: COMPACT_PROMPT.into() }])); let req = ChatRequest { system: "You are a precise summarizer of software engineering sessions.".into(), messages, tools: vec![], max_tokens: 16_000, reasoning: false, model: model.clone(), }; let (summary, usage) = complete_text(&app, &spec, provider, req, cancel).await?; record_side_usage(&app, &session, &spec, &model, &usage, "compaction"); if summary.trim().is_empty() { bail!("compaction produced an empty summary"); } save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?; let mut s = app.session(session_id)?; s.last_usage = None; // Files must be re-read after compaction before editing. app.with_runtime(session_id, |r| r.read_files.clear()); app.save_session(s)?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn call(id: &str) -> Part { Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None } } #[test] fn dangling_tool_calls_get_results() { let a = Message::new(Role::Assistant, vec![call("1"), call("2")]); let r = Message::new(Role::User, vec![tool_result("1", "bash", "ok".into(), false, None)]); let a2 = Message::new(Role::Assistant, vec![call("3")]); let h = active_history(vec![Message::new(Role::User, vec![Part::Text { text: "hi".into() }]), a, r, a2]); // user, assistant(1,2), user(result 2 + result 1), assistant(3), user(result 3) assert_eq!(h.len(), 5); let ids = |m: &Message| m.parts.iter().filter_map(|p| if let Part::ToolResult { call_id, .. } = p { Some(call_id.clone()) } else { None }).collect::>(); assert_eq!(ids(&h[2]), vec!["2", "1"]); assert_eq!(ids(&h[4]), vec!["3"]); } }