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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+640
View File
@@ -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(())
}