Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,640 @@
|
||||
//! 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;
|
||||
|
||||
/// Start processing a user prompt. If the session is busy the prompt is queued
|
||||
/// and injected at the next step.
|
||||
pub fn start_prompt(app: Arc<App>, session_id: String, parts: Vec<Part>) -> Result<()> {
|
||||
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");
|
||||
let queued = app.with_runtime(&session_id, |r| {
|
||||
if r.cancel.is_some() && parts.iter().all(|p| matches!(p, Part::Text { .. })) {
|
||||
r.queue.push(text_only.clone());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if queued {
|
||||
return Ok(());
|
||||
}
|
||||
if app.is_busy(&session_id) {
|
||||
bail!("session is busy");
|
||||
}
|
||||
let cancel = CancellationToken::new();
|
||||
app.with_runtime(&session_id, |r| r.cancel = Some(cancel.clone()));
|
||||
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:#}") });
|
||||
}
|
||||
// Prompts queued after the last step start a new turn.
|
||||
let leftover = app.with_runtime(&session_id, |r| {
|
||||
r.cancel = None;
|
||||
std::mem::take(&mut r.queue)
|
||||
});
|
||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
||||
if !leftover.is_empty() && !cancel.is_cancelled() {
|
||||
let _ = start_prompt(app, session_id, vec![Part::Text { text: leftover.join("\n\n") }]);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn abort(app: &App, session_id: &str) {
|
||||
app.with_runtime(session_id, |r| {
|
||||
if let Some(c) = &r.cancel {
|
||||
c.cancel();
|
||||
}
|
||||
r.queue.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// Messages since the last compaction summary.
|
||||
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);
|
||||
all.into_iter().skip(start).collect()
|
||||
}
|
||||
|
||||
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 mut all = tools::builtin_tools();
|
||||
all.extend(app.mcp.tools());
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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 agent = prompts::find_agent(&app, &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, &cwd, &model_spec),
|
||||
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(),
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Inject prompts the user sent while we were working.
|
||||
let queued = app.with_runtime(session_id, |r| std::mem::take(&mut r.queue));
|
||||
for q in queued {
|
||||
results.push(Part::Text { text: q });
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
});
|
||||
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(),
|
||||
};
|
||||
match decision {
|
||||
Decision::Deny => {
|
||||
return tool_result(call_id, 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, 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: name.into(),
|
||||
content: out.content,
|
||||
images: out.images,
|
||||
is_error: out.is_error,
|
||||
meta: out.meta,
|
||||
},
|
||||
Err(e) => tool_result(call_id, 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 = input["subagent_type"].as_str().unwrap_or("general").to_string();
|
||||
let (child, text) = spawn_subagent(app.clone(), ctx, &agent, &description, prompt, depth + 1).await?;
|
||||
Ok(tools::ToolOutput::text(text).with_meta(serde_json::json!({"session_id": child, "agent": agent})))
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
spawn_subagent(app, ctx, agent, description, prompt, 1).await
|
||||
}
|
||||
|
||||
async fn spawn_subagent(
|
||||
app: Arc<App>,
|
||||
ctx: &ToolCtx,
|
||||
agent_name: &str,
|
||||
description: &str,
|
||||
prompt: String,
|
||||
depth: usize,
|
||||
) -> Result<(String, String)> {
|
||||
let parent = app.session(&ctx.session_id)?;
|
||||
let parent_agent = prompts::find_agent(&app, &ctx.cwd, &parent.agent);
|
||||
let agents = prompts::agents(&app, &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);
|
||||
}
|
||||
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()),
|
||||
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 });
|
||||
app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: true });
|
||||
let res = Box::pin(run_session(app.clone(), &child.id, ctx.cancel.child_token(), depth)).await;
|
||||
app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: false });
|
||||
let text = res?;
|
||||
Ok((child.id, 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();
|
||||
let handle = tokio::spawn(async move { 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(provider: Arc<dyn Provider>, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> {
|
||||
let (tx, mut rx) = mpsc::channel(256);
|
||||
let handle = tokio::spawn(async move { 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(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(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(())
|
||||
}
|
||||
Reference in new issue
Block a user