Files
codecraft/server/src/runner.rs
T
Richard ZinkandClaude Opus 5.5 49262c612c Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:54:08 +01:00

874 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Part> {
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<App>, session_id: String, parts: Vec<Part>) -> Result<()> {
submit(app, session_id, parts, "user").map(|_| ())
}
pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str) -> Result<Submitted> {
// 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<Option<crate::store::QueueItem>> {
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::<Vec<_>>()
.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<App>, session_id: &str) -> Result<bool> {
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<Message>) -> Vec<Message> {
let start = all
.iter()
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
.unwrap_or(0);
let msgs: Vec<Message> = all.into_iter().skip(start).collect();
let mut out: Vec<Message> = 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<Part> = 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<Message> = 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::<String>().to_lowercase()
}
pub fn available_tools(app: &App, agent: &Agent) -> Vec<Arc<dyn Tool>> {
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<App>,
session_id: String,
cancel: CancellationToken,
depth: usize,
) -> futures::future::BoxFuture<'static, Result<String>> {
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<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
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::<Vec<_>>()
.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<Value>) -> 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<Part>) -> 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<App>,
session: &Session,
base: &ToolCtx,
tool: Option<Arc<dyn Tool>>,
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::<String>()),
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<dyn Tool>, 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<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> {
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!(
"<background-task-result agent=\"{agent_name}\" session=\"{child_id}\" description=\"{desc}\">\n{body}\n</background-task-result>"
);
// 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<App>, 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<App>, 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<App>, child: &Session, agent: &Agent, cancel: CancellationToken, depth: usize) -> Result<String> {
// 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<App>,
session: &Session,
agent: &str,
model_spec: &str,
provider: Arc<dyn Provider>,
req: ChatRequest,
cancel: &CancellationToken,
) -> Result<Step> {
let mut attempt = 0;
loop {
attempt += 1;
let msg_id = new_id();
let (tx, mut rx) = mpsc::channel::<StreamEvent>(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<u128> = None;
let mut parts: Vec<Part> = 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<dyn Provider>, 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<App>, 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<App>, 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::<Vec<_>>();
assert_eq!(ids(&h[2]), vec!["2", "1"]);
assert_eq!(ids(&h[4]), vec!["3"]);
}
}