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

+2968
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "codecraft"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Web-based coding harness for AI models"
[dependencies]
anyhow = "1"
async-trait = "0.1"
axum = { version = "0.8", features = ["ws", "macros"] }
bytes = "1"
chrono = { version = "0.4", features = ["serde"] }
eventsource-stream = "0.2"
futures = "0.3"
globset = "0.4"
html2text = "0.15"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
base64 = "0.22"
shell-words = "1"
libc = "0.2"
ignore = "0.4"
regex = "1"
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
rusqlite = { version = "0.41", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
similar = "2"
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
toml = "1"
toml_edit = "0.25"
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "v7"] }
+524
View File
@@ -0,0 +1,524 @@
//! HTTP + WebSocket API.
use crate::app::{App, PermissionReply};
use crate::config::{self, ConfigPatch};
use crate::prompts;
use crate::runner;
use crate::types::{Event, ImageData, Part, Session, new_id, now_ms};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
type AppState = Arc<App>;
pub struct ApiError(StatusCode, String);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(json!({"error": self.1}))).into_response()
}
}
impl<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(e: E) -> Self {
ApiError(StatusCode::BAD_REQUEST, format!("{:#}", e.into()))
}
}
type ApiResult<T = Json<Value>> = Result<T, ApiError>;
fn not_found(what: &str) -> ApiError {
ApiError(StatusCode::NOT_FOUND, format!("{what} not found"))
}
pub fn router(app: AppState) -> Router {
let api = Router::new()
.route("/info", get(info))
.route("/config", get(get_config).patch(patch_config))
.route("/config/raw", get(get_config_raw).put(put_config_raw))
.route("/providers/reload", post(reload_providers))
.route("/models", get(models))
.route("/agents", get(agents))
.route("/commands", get(commands))
.route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/rewind", post(rewind))
.route("/sessions/{id}/stats", get(session_stats))
.route("/permissions/{id}", post(reply_permission))
.route("/questions/{id}", post(answer_question))
.route("/pending", get(pending))
.route("/mcp", get(mcp_status))
.route("/mcp/reload", post(mcp_reload))
.route("/fs/dirs", get(list_dirs))
.route("/stats", get(stats))
.route("/ws", get(ws))
.layer(middleware::from_fn_with_state(app.clone(), auth));
let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| {
["../web/dist", "./web/dist", "/app/web"]
.iter()
.map(PathBuf::from)
.find(|p| p.join("index.html").exists())
.unwrap_or_else(|| PathBuf::from("./web/dist"))
});
let index = web_dir.join("index.html");
let static_files = tower_http::services::ServeDir::new(&web_dir)
.fallback(tower_http::services::ServeFile::new(index));
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
}
/// Bearer token, `token` query parameter (WebSocket) or `codecraft_token` cookie.
async fn auth(State(app): State<AppState>, req: Request, next: Next) -> Response {
let expected = app.config().auth_token();
if expected.is_empty() || req.uri().path().ends_with("/info") {
return next.run(req).await;
}
let header_token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(String::from);
let query_token = req.uri().query().and_then(|q| {
q.split('&').find_map(|kv| kv.strip_prefix("token=")).map(String::from)
});
let cookie_token = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("codecraft_token=")).map(String::from));
let ok = [header_token, query_token, cookie_token]
.into_iter()
.flatten()
.any(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()));
if ok {
next.run(req).await
} else {
ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response()
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
// --- info & config ----------------------------------------------------------
async fn info(State(app): State<AppState>) -> Json<Value> {
let cfg = app.config();
Json(json!({
"name": "CodeCraft",
"version": env!("CARGO_PKG_VERSION"),
"auth_required": !cfg.auth_token().is_empty(),
}))
}
async fn get_config(State(app): State<AppState>) -> ApiResult {
let cfg = app.config();
Ok(Json(json!({
"config": config::redacted_json(&cfg),
"workspace": app.workspace(),
"default_model": if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) },
"currency": cfg.server.currency,
})))
}
#[derive(Deserialize)]
struct PatchBody {
patches: Vec<ConfigPatch>,
}
async fn patch_config(State(app): State<AppState>, Json(body): Json<PatchBody>) -> ApiResult {
// Masked secrets coming back from the UI must not overwrite real values.
let patches: Vec<ConfigPatch> = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect();
app.patch_config(&patches).await?;
Ok(Json(json!({"ok": true})))
}
async fn get_config_raw(State(app): State<AppState>) -> ApiResult {
Ok(Json(json!({"text": app.config_text()?})))
}
#[derive(Deserialize)]
struct RawBody {
text: String,
}
async fn put_config_raw(State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
app.save_config(&body.text).await?;
Ok(Json(json!({"ok": true})))
}
async fn reload_providers(State(app): State<AppState>) -> ApiResult {
app.reload_providers().await;
Ok(Json(app.registry().models_json()))
}
async fn models(State(app): State<AppState>) -> Json<Value> {
Json(app.registry().models_json())
}
#[derive(Deserialize)]
struct CwdQuery {
cwd: Option<String>,
}
fn cwd_or_workspace(app: &App, cwd: Option<String>) -> PathBuf {
cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace())
}
async fn agents(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let cwd = cwd_or_workspace(&app, q.cwd);
Json(json!(prompts::agents(&app, &cwd)))
}
async fn commands(State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let cwd = cwd_or_workspace(&app, q.cwd);
Json(json!({
"commands": prompts::commands(&app, &cwd),
"skills": prompts::skills(&app, &cwd),
}))
}
// --- sessions ---------------------------------------------------------------
async fn list_sessions(State(app): State<AppState>) -> ApiResult {
let sessions = app.store.list_sessions()?;
let list: Vec<Value> = sessions
.into_iter()
.map(|s| {
let busy = app.is_busy(&s.id);
let mut v = json!(s);
v["busy"] = json!(busy);
v
})
.collect();
Ok(Json(json!(list)))
}
#[derive(Deserialize)]
struct CreateSession {
cwd: Option<String>,
model: Option<String>,
agent: Option<String>,
permission_mode: Option<String>,
}
fn validate_cwd(app: &App, cwd: Option<String>) -> Result<PathBuf, ApiError> {
let ws = app.workspace();
let p = match cwd.filter(|c| !c.trim().is_empty()) {
Some(c) if std::path::Path::new(&c).is_absolute() => PathBuf::from(c),
Some(c) => ws.join(c),
None => ws.clone(),
};
let p = std::fs::canonicalize(&p).map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("directory does not exist: {}", p.display())))?;
if !p.starts_with(&ws) || !p.is_dir() {
return Err(ApiError(StatusCode::BAD_REQUEST, "directory must be inside the workspace".into()));
}
Ok(p)
}
async fn create_session(State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
let cwd = validate_cwd(&app, body.cwd)?;
let cfg = app.config();
let model = body
.model
.filter(|m| !m.is_empty())
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
.or_else(|| app.registry().first_model())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – add a provider in the settings".into()))?;
let now = now_ms();
let s = Session {
id: new_id(),
title: String::new(),
cwd: cwd.display().to_string(),
model,
agent: body.agent.unwrap_or_else(|| "build".into()),
permission_mode: body.permission_mode.unwrap_or_else(|| "default".into()),
parent_id: None,
created_at: now,
updated_at: now,
todos: Value::Null,
last_usage: None,
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
}
async fn get_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
let messages = app.store.messages(&id)?;
let busy = app.is_busy(&id);
Ok(Json(json!({"session": s, "messages": messages, "busy": busy})))
}
#[derive(Deserialize)]
struct UpdateSession {
title: Option<String>,
model: Option<String>,
agent: Option<String>,
permission_mode: Option<String>,
}
async fn update_session(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
let mut s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
if let Some(t) = body.title {
s.title = t;
}
if let Some(m) = body.model {
app.registry().resolve(&m)?;
s.model = m;
}
if let Some(a) = body.agent {
s.agent = a;
}
if let Some(p) = body.permission_mode {
if !["default", "accept_edits", "bypass"].contains(&p.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
s.permission_mode = p;
}
Ok(Json(json!(app.save_session(s)?)))
}
async fn delete_session(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
runner::abort(&app, &id);
app.store.delete_session(&id)?;
app.emit(Event::SessionDeleted { session_id: id });
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct PromptBody {
text: String,
#[serde(default)]
images: Vec<ImageData>,
}
async fn prompt(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
let session = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
let text = body.text.trim().to_string();
// Built-in commands.
if text == "/compact" {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(Json(json!({"ok": true, "command": "compact"})));
}
let cwd = PathBuf::from(&session.cwd);
let mut prompt_text = text.clone();
if text.starts_with('/') {
if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) {
prompt_text = expanded;
if let Some(a) = agent {
let mut s = session.clone();
s.agent = a;
app.save_session(s)?;
}
}
}
let mut parts = vec![Part::Text { text: prompt_text }];
for image in body.images {
parts.push(Part::Image { image });
}
runner::start_prompt(app.clone(), id, parts)?;
Ok(Json(json!({"ok": true})))
}
async fn do_compact(app: AppState, id: String) {
let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
}
app.with_runtime(&id, |r| r.cancel = None);
app.emit(Event::SessionStatus { session_id: id, busy: false });
}
async fn abort(State(app): State<AppState>, Path(id): Path<String>) -> Json<Value> {
runner::abort(&app, &id);
Json(json!({"ok": true}))
}
async fn compact(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
tokio::spawn(do_compact(app.clone(), id));
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct RewindBody {
message_id: String,
}
/// Remove a message and everything after it (edit & resend). Files are not restored.
async fn rewind(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<RewindBody>) -> ApiResult {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
app.store.truncate_from(&id, &body.message_id)?;
let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?;
let mut s = s;
s.last_usage = None;
app.save_session(s)?;
Ok(Json(json!({"ok": true})))
}
async fn session_stats(State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let mut v = app.store.session_stats(&id)?;
v["currency"] = json!(app.config().server.currency);
Ok(Json(v))
}
// --- interaction ------------------------------------------------------------
#[derive(Deserialize)]
struct PermissionBody {
reply: PermissionReply,
message: Option<String>,
}
async fn reply_permission(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
app.reply_permission(&id, body.reply, body.message)?;
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct QuestionBody {
/// question text → answer (string or array of strings); null = dismissed
answers: Option<Map<String, Value>>,
}
async fn answer_question(State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<QuestionBody>) -> ApiResult {
app.answer_question(&id, body.answers)?;
Ok(Json(json!({"ok": true})))
}
async fn pending(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.pending_events()))
}
async fn mcp_status(State(app): State<AppState>) -> Json<Value> {
Json(app.mcp.status_json())
}
async fn mcp_reload(State(app): State<AppState>) -> Json<Value> {
let mcp = app.mcp.clone();
let servers = app.config().mcp.clone();
let weak = Arc::downgrade(&app);
mcp.start_all(weak, servers).await;
Json(app.mcp.status_json())
}
// --- files ------------------------------------------------------------------
#[derive(Deserialize)]
struct DirQuery {
path: Option<String>,
}
/// Subdirectories for the project picker (inside the workspace only).
async fn list_dirs(State(app): State<AppState>, Query(q): Query<DirQuery>) -> ApiResult {
let dir = validate_cwd(&app, q.path)?;
let mut dirs = Vec::new();
for e in std::fs::read_dir(&dir)?.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') || !e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
dirs.push(json!({"name": name, "path": e.path(), "git": e.path().join(".git").exists()}));
}
dirs.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let ws = app.workspace();
Ok(Json(json!({
"path": dir,
"parent": if dir == ws { Value::Null } else { json!(dir.parent()) },
"workspace": ws,
"dirs": dirs,
})))
}
// --- statistics -------------------------------------------------------------
#[derive(Deserialize)]
struct StatsQuery {
from: Option<i64>,
to: Option<i64>,
/// Minutes east of UTC (JS: -new Date().getTimezoneOffset()).
tz: Option<i64>,
}
async fn stats(State(app): State<AppState>, Query(q): Query<StatsQuery>) -> ApiResult {
let to = q.to.unwrap_or_else(|| now_ms() + 1);
let from = q.from.unwrap_or(to - 30 * 86_400_000);
let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0))?;
v["currency"] = json!(app.config().server.currency);
v["from"] = json!(from);
v["to"] = json!(to);
Ok(Json(v))
}
// --- websocket --------------------------------------------------------------
async fn ws(State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
upgrade.on_upgrade(move |socket| ws_loop(app, socket))
}
async fn ws_loop(app: AppState, mut socket: WebSocket) {
let mut rx = app.subscribe();
for ev in app.pending_events() {
if send_event(&mut socket, &ev).await.is_err() {
return;
}
}
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
loop {
tokio::select! {
ev = rx.recv() => match ev {
Ok(ev) => {
if send_event(&mut socket, &ev).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
// Client fell behind: tell it to resync.
let msg = json!({"type": "resync", "skipped": n}).to_string();
if socket.send(WsMessage::Text(msg.into())).await.is_err() {
break;
}
}
Err(_) => break,
},
msg = socket.recv() => match msg {
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
_ => {}
},
_ = ping.tick() => {
if socket.send(WsMessage::Ping(Vec::new().into())).await.is_err() {
break;
}
}
}
}
}
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
let text = serde_json::to_string(ev).unwrap_or_default();
socket.send(WsMessage::Text(text.into())).await
}
+274
View File
@@ -0,0 +1,274 @@
//! Shared application state.
use crate::config::{self, Config};
use crate::mcp::McpManager;
use crate::provider::Registry;
use crate::store::Store;
use crate::types::{Event, Question, Session, now_ms};
use anyhow::{Result, anyhow};
use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use tokio::sync::{broadcast, oneshot};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionReply {
/// Allow this one call.
Once,
/// Allow this tool (or bash command prefix) for the rest of the session.
Always,
Reject,
}
struct PendingPermission {
event: Event,
tx: oneshot::Sender<(PermissionReply, Option<String>)>,
}
struct PendingQuestion {
event: Event,
tx: oneshot::Sender<Option<Map<String, Value>>>,
}
/// Per-session runtime state (not persisted).
#[derive(Default)]
pub struct Runtime {
pub cancel: Option<CancellationToken>,
pub read_files: HashSet<PathBuf>,
pub approved: HashSet<String>,
/// Prompts sent while the agent was busy; injected at the next step.
pub queue: Vec<String>,
}
pub struct App {
config_path: PathBuf,
config: RwLock<Arc<Config>>,
registry: RwLock<Arc<Registry>>,
pub store: Store,
events: broadcast::Sender<Event>,
pub mcp: Arc<McpManager>,
runtime: Mutex<HashMap<String, Runtime>>,
permissions: Mutex<HashMap<String, PendingPermission>>,
questions: Mutex<HashMap<String, PendingQuestion>>,
}
impl App {
pub async fn new(config_path: PathBuf, cfg: Config) -> Result<Arc<Self>> {
let data = cfg.data_dir();
std::fs::create_dir_all(&data)?;
let store = Store::open(&data.join("codecraft.db"))?;
let registry = Registry::build(&cfg).await;
let (events, _) = broadcast::channel(4096);
let app = Arc::new(Self {
config_path,
config: RwLock::new(Arc::new(cfg)),
registry: RwLock::new(Arc::new(registry)),
store,
events,
mcp: Arc::new(McpManager::default()),
runtime: Mutex::default(),
permissions: Mutex::default(),
questions: Mutex::default(),
});
app.clone().start_mcp();
Ok(app)
}
fn start_mcp(self: Arc<Self>) {
let servers = self.config().mcp.clone();
let mcp = self.mcp.clone();
let weak = Arc::downgrade(&self);
tokio::spawn(async move { mcp.start_all(weak, servers).await });
}
pub fn config(&self) -> Arc<Config> {
self.config.read().unwrap().clone()
}
pub fn registry(&self) -> Arc<Registry> {
self.registry.read().unwrap().clone()
}
pub fn workspace(&self) -> PathBuf {
let ws = self.config().workspace();
std::fs::canonicalize(&ws).unwrap_or(ws)
}
pub fn data_dir(&self) -> PathBuf {
self.config().data_dir()
}
pub fn config_text(&self) -> Result<String> {
Ok(std::fs::read_to_string(&self.config_path)?)
}
/// Validate, write and apply a new config text. Providers and MCP servers reload.
pub async fn save_config(self: &Arc<Self>, text: &str) -> Result<()> {
let cfg = Config::parse(text)?;
let tmp = self.config_path.with_extension("toml.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &self.config_path)?;
let registry = Registry::build(&cfg).await;
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
*self.config.write().unwrap() = Arc::new(cfg);
*self.registry.write().unwrap() = Arc::new(registry);
if mcp_changed {
self.clone().start_mcp();
}
Ok(())
}
pub async fn patch_config(self: &Arc<Self>, patches: &[config::ConfigPatch]) -> Result<()> {
let text = config::apply_patches(&self.config_text()?, patches)?;
self.save_config(&text).await
}
pub async fn reload_providers(&self) {
let registry = Registry::build(&self.config()).await;
*self.registry.write().unwrap() = Arc::new(registry);
}
// --- events ---------------------------------------------------------
pub fn emit(&self, ev: Event) {
let _ = self.events.send(ev);
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.events.subscribe()
}
/// Pending permission and question requests, re-sent to newly connected clients.
pub fn pending_events(&self) -> Vec<Event> {
let mut out: Vec<Event> = self.permissions.lock().unwrap().values().map(|p| p.event.clone()).collect();
out.extend(self.questions.lock().unwrap().values().map(|q| q.event.clone()));
out
}
// --- runtime --------------------------------------------------------
pub fn with_runtime<R>(&self, session_id: &str, f: impl FnOnce(&mut Runtime) -> R) -> R {
let mut rt = self.runtime.lock().unwrap();
f(rt.entry(session_id.to_string()).or_default())
}
pub fn is_busy(&self, session_id: &str) -> bool {
self.with_runtime(session_id, |r| r.cancel.is_some())
}
pub fn mark_read(&self, session_id: &str, path: &Path) {
self.with_runtime(session_id, |r| r.read_files.insert(path.to_path_buf()));
}
pub fn was_read(&self, session_id: &str, path: &Path) -> bool {
self.with_runtime(session_id, |r| r.read_files.contains(path))
}
pub fn session(&self, id: &str) -> Result<Session> {
self.store.get_session(id)?.ok_or_else(|| anyhow!("session not found"))
}
pub fn save_session(&self, mut s: Session) -> Result<Session> {
s.updated_at = now_ms();
self.store.save_session(&s)?;
self.emit(Event::SessionUpdated { session: s.clone() });
Ok(s)
}
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
let mut s = self.session(session_id)?;
s.todos = todos;
self.save_session(s)?;
Ok(())
}
/// Walk up the parent chain to the top-level session.
pub fn root_session_id(&self, session_id: &str) -> String {
let mut id = session_id.to_string();
for _ in 0..16 {
match self.store.get_session(&id).ok().flatten().and_then(|s| s.parent_id) {
Some(p) => id = p,
None => break,
}
}
id
}
// --- user interaction -------------------------------------------------
/// Forward questions to the UI and wait. None = dismissed.
pub async fn ask_user(
&self,
session_id: &str,
source: &str,
questions: Vec<Question>,
cancel: CancellationToken,
) -> Result<Option<Map<String, Value>>> {
let id = crate::types::new_id();
let event = Event::QuestionRequest {
id: id.clone(),
session_id: session_id.to_string(),
root_session_id: self.root_session_id(session_id),
source: source.to_string(),
questions,
};
let (tx, rx) = oneshot::channel();
self.questions.lock().unwrap().insert(id.clone(), PendingQuestion { event: event.clone(), tx });
self.emit(event);
let res = tokio::select! {
r = rx => r.unwrap_or(None),
_ = cancel.cancelled() => None,
};
self.questions.lock().unwrap().remove(&id);
self.emit(Event::QuestionResolved { id, session_id: session_id.to_string() });
Ok(res)
}
pub fn answer_question(&self, id: &str, answers: Option<Map<String, Value>>) -> Result<()> {
let p = self.questions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("question not pending"))?;
let _ = p.tx.send(answers);
Ok(())
}
/// Ask the user to approve a tool call. Returns the reply and an optional
/// message (feedback on rejection).
pub async fn request_permission(
&self,
session_id: &str,
tool: &str,
title: String,
input: Value,
diff: Option<String>,
cancel: CancellationToken,
) -> (PermissionReply, Option<String>) {
let id = crate::types::new_id();
let event = Event::PermissionRequest {
id: id.clone(),
session_id: session_id.to_string(),
root_session_id: self.root_session_id(session_id),
tool: tool.to_string(),
title,
input,
diff,
};
let (tx, rx) = oneshot::channel();
self.permissions.lock().unwrap().insert(id.clone(), PendingPermission { event: event.clone(), tx });
self.emit(event);
let res = tokio::select! {
r = rx => r.unwrap_or((PermissionReply::Reject, None)),
_ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into())),
};
self.permissions.lock().unwrap().remove(&id);
self.emit(Event::PermissionResolved { id, session_id: session_id.to_string() });
res
}
pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option<String>) -> Result<()> {
let p = self.permissions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("permission not pending"))?;
let _ = p.tx.send((reply, message));
Ok(())
}
}
+424
View File
@@ -0,0 +1,424 @@
//! Configuration: a commented TOML file that the web UI edits in place.
//!
//! Parsing goes through `toml` into typed structs; writes go through
//! `toml_edit` so comments and formatting survive UI edits.
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml");
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct Config {
pub server: ServerConfig,
pub providers: BTreeMap<String, ProviderConfig>,
pub mcp: BTreeMap<String, McpConfig>,
pub permissions: PermissionConfig,
pub agents: BTreeMap<String, AgentConfig>,
pub craft: CraftConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ServerConfig {
pub bind: String,
pub workspace: String,
pub data_dir: String,
pub auth_token: String,
pub default_model: String,
pub small_model: String,
pub auto_compact_threshold: f64,
/// Currency label for cost statistics (display only).
pub currency: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1:8787".into(),
workspace: ".".into(),
data_dir: "./data".into(),
auth_token: String::new(),
default_model: String::new(),
small_model: String::new(),
auto_compact_threshold: 0.85,
currency: "USD".into(),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProviderKind {
Anthropic,
Openai,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
pub kind: ProviderKind,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub auth_header: Option<String>,
#[serde(default)]
pub auth_prefix: Option<String>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub models: Vec<ModelEntry>,
#[serde(default = "yes")]
pub enabled: bool,
}
fn yes() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ModelEntry {
Id(String),
Full(ModelConfig),
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ModelConfig {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub context: Option<u64>,
#[serde(default)]
pub max_output: Option<u64>,
#[serde(default)]
pub reasoning: bool,
#[serde(default)]
pub vision: bool,
/// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high).
#[serde(default)]
pub effort: Option<String>,
/// Prices in currency units per 1M tokens (used for cost statistics).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
}
impl ModelConfig {
/// Cost of one request. Cache prices default to the input price.
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
let inp = self.input_price.unwrap_or(0.0);
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
per(u.input_tokens, inp)
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
}
}
impl ModelEntry {
pub fn resolved(&self) -> ModelConfig {
match self {
ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() },
ModelEntry::Full(m) => m.clone(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpConfig {
#[serde(rename = "type")]
pub kind: McpKind,
#[serde(default)]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub url: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default = "yes")]
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum McpKind {
Stdio,
Http,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PermissionConfig {
#[serde(default)]
pub bash_rules: BTreeMap<String, String>,
#[serde(flatten)]
pub tools: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct AgentConfig {
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub tools: Option<Vec<String>>,
#[serde(default)]
pub prompt: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CraftConfig {
pub chromium: String,
pub widths: Vec<u32>,
pub clean_room: bool,
}
impl Default for CraftConfig {
fn default() -> Self {
Self { chromium: "chromium".into(), widths: vec![390, 768, 1440], clean_room: true }
}
}
/// Replace `${NAME}` with the environment variable (empty if unset).
pub fn expand_env(s: &str) -> String {
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
re.replace_all(s, |c: &regex::Captures| std::env::var(&c[1]).unwrap_or_default())
.into_owned()
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let cfg: Config = toml::from_str(text).context("invalid config")?;
for (name, p) in &cfg.providers {
if name.contains('/') {
bail!("provider name must not contain '/': {name}");
}
if p.base_url.is_empty() && p.kind == ProviderKind::Openai {
bail!("provider {name}: base_url is required");
}
}
Ok(cfg)
}
pub fn workspace(&self) -> PathBuf {
PathBuf::from(expand_env(&self.server.workspace))
}
pub fn data_dir(&self) -> PathBuf {
PathBuf::from(expand_env(&self.server.data_dir))
}
pub fn auth_token(&self) -> String {
expand_env(&self.server.auth_token)
}
}
/// Locate the config file: $CODECRAFT_CONFIG, else ./data/config.toml (or /data in Docker).
pub fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("CODECRAFT_CONFIG") {
return PathBuf::from(p);
}
if Path::new("/data").is_dir() {
return PathBuf::from("/data/config.toml");
}
PathBuf::from("./data/config.toml")
}
/// Load the config, writing the commented example on first start.
pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
if !path.exists() {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, EXAMPLE_CONFIG)?;
tracing::info!("wrote default config to {}", path.display());
}
let text = std::fs::read_to_string(path)?;
let cfg = Config::parse(&text)?;
Ok((cfg, text))
}
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
#[derive(Debug, Deserialize)]
pub struct ConfigPatch {
/// Dotted path into the document, e.g. ["providers", "gemini", "enabled"].
pub path: Vec<String>,
/// New value as JSON; `null` removes the key.
pub value: serde_json::Value,
}
pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
for patch in patches {
if patch.path.is_empty() {
bail!("empty patch path");
}
let (last, parents) = patch.path.split_last().unwrap();
let mut table: &mut toml_edit::Table = doc.as_table_mut();
for key in parents {
let entry = table
.entry(key)
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()));
// Inline tables are converted so we can descend into them.
if let Some(inline) = entry.as_inline_table().cloned() {
*entry = toml_edit::Item::Table(inline.into_table());
}
table = entry
.as_table_mut()
.with_context(|| format!("'{key}' is not a table"))?;
}
if patch.value.is_null() {
table.remove(last);
} else if patch.value.is_object() && parents.len() < 2 && table.get(last).is_none_or(|i| i.is_table()) {
// New sections (e.g. [providers.foo]) become real tables, not inline ones.
let mut t = toml_edit::Table::new();
for (k, v) in patch.value.as_object().unwrap() {
if let Some(v) = json_to_toml(v) {
t.insert(k, toml_edit::Item::Value(v));
}
}
table.insert(last, toml_edit::Item::Table(t));
} else {
let v = json_to_toml(&patch.value).context("unsupported value")?;
match table.get_mut(last) {
// Keep the existing decoration (trailing comments) of the value.
Some(toml_edit::Item::Value(old)) => {
let decor = old.decor().clone();
*old = v;
*old.decor_mut() = decor;
}
_ => {
table.insert(last, toml_edit::Item::Value(v));
}
}
}
}
let out = doc.to_string();
Config::parse(&out)?;
Ok(out)
}
fn json_to_toml(v: &serde_json::Value) -> Option<toml_edit::Value> {
use serde_json::Value as J;
Some(match v {
J::Null => return None,
J::Bool(b) => (*b).into(),
J::Number(n) => {
if let Some(i) = n.as_i64() {
i.into()
} else {
n.as_f64()?.into()
}
}
J::String(s) => s.as_str().into(),
J::Array(a) => {
let mut arr = toml_edit::Array::new();
for x in a {
if let Some(t) = json_to_toml(x) {
arr.push(t);
}
}
if a.iter().any(|x| x.is_object()) {
arr.set_trailing_comma(true);
for item in arr.iter_mut() {
item.decor_mut().set_prefix("\n ");
}
arr.set_trailing("\n");
}
toml_edit::Value::Array(arr)
}
J::Object(o) => {
let mut t = toml_edit::InlineTable::new();
for (k, x) in o {
if let Some(tv) = json_to_toml(x) {
t.insert(k, tv);
}
}
toml_edit::Value::InlineTable(t)
}
})
}
/// JSON view of the config for the UI. Secret values are masked unless they are
/// env references, so the UI can show "${FOO}" but never a literal key.
pub fn redacted_json(cfg: &Config) -> serde_json::Value {
let mut v = serde_json::to_value(cfg).unwrap_or_default();
let mask = |s: &mut serde_json::Value| {
if let Some(text) = s.as_str() {
if !text.is_empty() && !text.starts_with("${") {
*s = serde_json::Value::String("••••••••".into());
}
}
};
if let Some(ps) = v.get_mut("providers").and_then(|p| p.as_object_mut()) {
for p in ps.values_mut() {
if let Some(k) = p.get_mut("api_key") {
mask(k);
}
}
}
if let Some(t) = v.pointer_mut("/server/auth_token") {
mask(t);
}
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn example_parses() {
let cfg = Config::parse(EXAMPLE_CONFIG).unwrap();
assert!(cfg.providers.contains_key("anthropic"));
assert_eq!(cfg.permissions.tools.get("read").map(String::as_str), Some("allow"));
assert!(cfg.permissions.bash_rules.contains_key("git status*"));
}
#[test]
fn patch_keeps_comments() {
let out = apply_patches(
EXAMPLE_CONFIG,
&[ConfigPatch {
path: vec!["providers".into(), "gemini".into(), "enabled".into()],
value: serde_json::json!(true),
}],
)
.unwrap();
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available"));
let cfg = Config::parse(&out).unwrap();
assert!(cfg.providers["gemini"].enabled);
}
#[test]
fn patch_adds_provider_section() {
let out = apply_patches(
EXAMPLE_CONFIG,
&[ConfigPatch {
path: vec!["providers".into(), "vllm".into()],
value: serde_json::json!({"kind": "openai", "base_url": "http://x/v1"}),
}],
)
.unwrap();
assert!(out.contains("[providers.vllm]"));
Config::parse(&out).unwrap();
}
}
+48
View File
@@ -0,0 +1,48 @@
//! CodeCraft – web-based coding harness for AI models.
mod api;
mod app;
mod config;
mod mcp;
mod permission;
mod prompts;
mod provider;
mod runner;
mod store;
mod tools;
mod types;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,codecraft=info")))
.init();
// In Docker, HOME lives on the data volume; make sure it exists for git/npm.
if let Ok(home) = std::env::var("HOME") {
let _ = std::fs::create_dir_all(home);
}
let path = config::config_path();
let (cfg, _) = config::load_or_init(&path)?;
tracing::info!("config: {}", path.display());
tracing::info!("workspace: {}", cfg.workspace().display());
if cfg.auth_token().is_empty() {
tracing::warn!("auth_token is empty – the API is unprotected. Only use this on localhost.");
}
let bind = cfg.server.bind.clone();
let app = app::App::new(path, cfg).await?;
let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("listening on http://{bind}");
axum::serve(listener, router).with_graceful_shutdown(shutdown()).await?;
Ok(())
}
async fn shutdown() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
}
+552
View File
@@ -0,0 +1,552 @@
//! Minimal MCP client: JSON-RPC over stdio or Streamable HTTP.
//!
//! Supports tools (list/call, list_changed), roots and elicitation – server
//! questions are forwarded to the user through the same UI as agent questions.
use crate::app::App;
use crate::config::{McpConfig, McpKind, expand_env};
use crate::tools::{Tool, ToolCtx, ToolOutput};
use crate::types::{ImageData, Question, QuestionOption};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde::Serialize;
use serde_json::{Map, Value, json};
use std::collections::{BTreeMap, HashMap};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex as AsyncMutex, oneshot};
use tokio_util::sync::CancellationToken;
const PROTOCOL_VERSION: &str = "2025-06-18";
const CALL_TIMEOUT: Duration = Duration::from_secs(600);
#[derive(Debug, Clone, Serialize)]
pub struct McpToolInfo {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum McpState {
Disabled,
Connecting,
Connected { tools: usize },
Failed { error: String },
}
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>>;
enum Transport {
Stdio {
stdin: AsyncMutex<tokio::process::ChildStdin>,
pending: Pending,
_child: AsyncMutex<tokio::process::Child>,
},
Http {
client: reqwest::Client,
url: String,
headers: BTreeMap<String, String>,
session: Mutex<Option<String>>,
protocol: Mutex<Option<String>>,
},
}
pub struct McpServer {
pub name: String,
transport: Transport,
next_id: AtomicU64,
app: Weak<App>,
/// Session that triggered the current tool call (for elicitation routing).
current_session: Mutex<Option<String>>,
pub tools: Mutex<Vec<McpToolInfo>>,
}
fn rpc(id: u64, method: &str, params: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
}
impl McpServer {
pub async fn connect(name: &str, cfg: &McpConfig, app: Weak<App>) -> Result<Arc<Self>> {
let transport = match cfg.kind {
McpKind::Stdio => {
if cfg.command.is_empty() {
bail!("command is required for stdio servers");
}
let mut cmd = tokio::process::Command::new(expand_env(&cfg.command));
cmd.args(cfg.args.iter().map(|a| expand_env(a)))
.envs(cfg.env.iter().map(|(k, v)| (k.clone(), expand_env(v))))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(app) = app.upgrade() {
cmd.current_dir(app.workspace());
}
let mut child = cmd.spawn().with_context(|| format!("failed to start {}", cfg.command))?;
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let server_name = name.to_string();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(l)) = lines.next_line().await {
tracing::debug!("mcp {server_name} stderr: {l}");
}
});
let pending: Pending = Arc::default();
let server = Arc::new(Self {
name: name.to_string(),
transport: Transport::Stdio {
stdin: AsyncMutex::new(stdin),
pending: pending.clone(),
_child: AsyncMutex::new(child),
},
next_id: AtomicU64::new(1),
app,
current_session: Mutex::new(None),
tools: Mutex::new(Vec::new()),
});
let weak = Arc::downgrade(&server);
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
let Some(server) = weak.upgrade() else { break };
// Server requests (elicitation) may wait for the user: never block the reader.
tokio::spawn(async move { server.handle_incoming(msg).await });
}
// Process ended: fail all pending requests.
for (_, tx) in pending.lock().unwrap().drain() {
let _ = tx.send(Err(anyhow!("MCP server exited")));
}
});
server
}
McpKind::Http => Arc::new(Self {
name: name.to_string(),
transport: Transport::Http {
client: crate::provider::http_client(),
url: expand_env(&cfg.url),
headers: cfg.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
session: Mutex::new(None),
protocol: Mutex::new(None),
},
next_id: AtomicU64::new(1),
app,
current_session: Mutex::new(None),
tools: Mutex::new(Vec::new()),
}),
};
let server = transport;
server.initialize().await?;
server.refresh_tools().await?;
Ok(server)
}
async fn initialize(&self) -> Result<()> {
let res = self
.request(
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
}),
)
.await?;
if let Transport::Http { protocol, .. } = &self.transport {
*protocol.lock().unwrap() =
Some(res["protocolVersion"].as_str().unwrap_or(PROTOCOL_VERSION).to_string());
}
self.notify("notifications/initialized", json!({})).await?;
Ok(())
}
pub async fn refresh_tools(&self) -> Result<()> {
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = match &cursor {
Some(c) => json!({"cursor": c}),
None => json!({}),
};
let res = self.request("tools/list", params).await?;
for t in res["tools"].as_array().cloned().unwrap_or_default() {
tools.push(McpToolInfo {
name: t["name"].as_str().unwrap_or_default().to_string(),
description: t["description"].as_str().unwrap_or_default().to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or(json!({"type": "object"})),
});
}
cursor = res["nextCursor"].as_str().map(String::from);
if cursor.is_none() {
break;
}
}
*self.tools.lock().unwrap() = tools;
Ok(())
}
pub async fn call_tool(&self, session_id: &str, name: &str, args: Value, cancel: CancellationToken) -> Result<ToolOutput> {
*self.current_session.lock().unwrap() = Some(session_id.to_string());
let res = tokio::select! {
r = tokio::time::timeout(CALL_TIMEOUT, self.request("tools/call", json!({"name": name, "arguments": args}))) => {
r.map_err(|_| anyhow!("MCP tool call timed out"))?
}
_ = cancel.cancelled() => bail!("aborted"),
}?;
let mut out = ToolOutput { is_error: res["isError"].as_bool().unwrap_or(false), ..Default::default() };
let mut texts = Vec::new();
for c in res["content"].as_array().cloned().unwrap_or_default() {
match c["type"].as_str().unwrap_or_default() {
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
"image" => out.images.push(ImageData {
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
data: c["data"].as_str().unwrap_or_default().into(),
}),
"resource" => {
let r = &c["resource"];
texts.push(match r["text"].as_str() {
Some(t) => format!("[resource {}]\n{t}", r["uri"].as_str().unwrap_or("")),
None => format!("[binary resource {}]", r["uri"].as_str().unwrap_or("")),
});
}
"resource_link" => texts.push(format!("[resource link {}]", c["uri"].as_str().unwrap_or(""))),
other => texts.push(format!("[{other} content]")),
}
}
if texts.is_empty() {
if let Some(s) = res.get("structuredContent") {
texts.push(serde_json::to_string_pretty(s)?);
}
}
out.content = crate::tools::truncate(&texts.join("\n"), 80_000);
Ok(out)
}
// --- transport -------------------------------------------------------
async fn request(&self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let msg = rpc(id, method, params);
match &self.transport {
Transport::Stdio { stdin, pending, .. } => {
let (tx, rx) = oneshot::channel();
pending.lock().unwrap().insert(id, tx);
self.write_stdio(stdin, &msg).await?;
let res = tokio::time::timeout(CALL_TIMEOUT, rx)
.await
.map_err(|_| anyhow!("MCP request {method} timed out"))?
.map_err(|_| anyhow!("MCP server closed the connection"))?;
res
}
Transport::Http { .. } => self.http_send(&msg, Some(id)).await?.ok_or_else(|| anyhow!("no response to {method}")),
}
}
async fn notify(&self, method: &str, params: Value) -> Result<()> {
let msg = json!({"jsonrpc": "2.0", "method": method, "params": params});
self.send_raw(&msg).await
}
async fn send_raw(&self, msg: &Value) -> Result<()> {
match &self.transport {
Transport::Stdio { stdin, .. } => self.write_stdio(stdin, msg).await,
Transport::Http { .. } => self.http_send(msg, None).await.map(|_| ()),
}
}
async fn write_stdio(&self, stdin: &AsyncMutex<tokio::process::ChildStdin>, msg: &Value) -> Result<()> {
let mut line = serde_json::to_vec(msg)?;
line.push(b'\n');
let mut w = stdin.lock().await;
w.write_all(&line).await?;
w.flush().await?;
Ok(())
}
/// POST a message. For requests, waits for the matching response, handling
/// server requests (elicitation) that arrive on the same SSE stream.
async fn http_send(&self, msg: &Value, want_id: Option<u64>) -> Result<Option<Value>> {
let Transport::Http { client, url, headers, session, protocol } = &self.transport else { unreachable!() };
let mut rb = client
.post(url)
.header("Accept", "application/json, text/event-stream")
.header("Content-Type", "application/json")
.json(msg);
for (k, v) in headers {
rb = rb.header(k, v);
}
if let Some(s) = session.lock().unwrap().clone() {
rb = rb.header("Mcp-Session-Id", s);
}
if let Some(p) = protocol.lock().unwrap().clone() {
rb = rb.header("MCP-Protocol-Version", p);
}
let resp = rb.send().await?;
if let Some(s) = resp.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()) {
*session.lock().unwrap() = Some(s.to_string());
}
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("MCP HTTP {status}: {}", body.chars().take(400).collect::<String>());
}
let Some(want) = want_id else { return Ok(None) };
let ctype = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
if ctype.contains("text/event-stream") {
let mut events = resp.bytes_stream().eventsource();
while let Some(ev) = events.next().await {
let ev = ev.map_err(|e| anyhow!("MCP stream error: {e}"))?;
let Ok(v) = serde_json::from_str::<Value>(&ev.data) else { continue };
if v.get("id").and_then(|i| i.as_u64()) == Some(want) && v.get("method").is_none() {
return Ok(Some(Self::result_of(v)?));
}
// Server→client requests/notifications on the stream.
Box::pin(self.handle_incoming(v)).await;
}
bail!("MCP stream ended without a response");
}
let v: Value = resp.json().await?;
Ok(Some(Self::result_of(v)?))
}
fn result_of(v: Value) -> Result<Value> {
if let Some(e) = v.get("error") {
bail!("MCP error {}: {}", e["code"], e["message"].as_str().unwrap_or(""));
}
Ok(v.get("result").cloned().unwrap_or(Value::Null))
}
async fn handle_incoming(&self, msg: Value) {
let method = msg.get("method").and_then(|m| m.as_str()).map(String::from);
let id = msg.get("id").cloned();
match (method, id) {
// Response to one of our requests (stdio).
(None, Some(id)) => {
if let (Transport::Stdio { pending, .. }, Some(id)) = (&self.transport, id.as_u64()) {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let _ = tx.send(Self::result_of(msg));
}
}
}
// Server → client request.
(Some(method), Some(id)) => {
let result = match method.as_str() {
"ping" => Ok(json!({})),
"roots/list" => {
let root = self.app.upgrade().map(|a| a.workspace()).unwrap_or_default();
Ok(json!({"roots": [{"uri": format!("file://{}", root.display()), "name": "workspace"}]}))
}
"elicitation/create" => self.elicit(&msg["params"]).await,
other => Err(anyhow!("method not supported: {other}")),
};
let reply = match result {
Ok(r) => json!({"jsonrpc": "2.0", "id": id, "result": r}),
Err(e) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": e.to_string()}}),
};
if let Err(e) = self.send_raw(&reply).await {
tracing::warn!("mcp {}: failed to reply: {e:#}", self.name);
}
}
(Some(method), None) => {
if method == "notifications/tools/list_changed" {
if let Err(e) = self.refresh_tools().await {
tracing::warn!("mcp {}: tool refresh failed: {e:#}", self.name);
}
}
}
_ => {}
}
}
/// Turn an elicitation form into UI questions and the answers back into content.
async fn elicit(&self, params: &Value) -> Result<Value> {
let app = self.app.upgrade().ok_or_else(|| anyhow!("app gone"))?;
let session = self
.current_session
.lock()
.unwrap()
.clone()
.ok_or_else(|| anyhow!("no active session for elicitation"))?;
let message = params["message"].as_str().unwrap_or("").to_string();
let props = params["requestedSchema"]["properties"].as_object().cloned().unwrap_or_default();
let mut questions = Vec::new();
let mut keys = Vec::new();
for (key, schema) in &props {
let title = schema["title"].as_str().unwrap_or(key);
let desc = schema["description"].as_str().unwrap_or("");
let mut options: Vec<QuestionOption> = Vec::new();
if let Some(e) = schema["enum"].as_array() {
let names = schema["enumNames"].as_array();
for (i, v) in e.iter().enumerate() {
let label = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());
let description = names.and_then(|n| n.get(i)).and_then(|n| n.as_str()).unwrap_or("").to_string();
options.push(QuestionOption { label, description });
}
} else if schema["type"] == "boolean" {
options = vec![
QuestionOption { label: "true".into(), description: "Yes".into() },
QuestionOption { label: "false".into(), description: "No".into() },
];
}
let question = if props.len() == 1 && !message.is_empty() {
format!("{message}\n\n{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") })
} else {
format!("{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") })
};
questions.push(Question { question, header: key.chars().take(12).collect(), options, multi_select: false });
keys.push((key.clone(), schema.clone()));
}
if questions.is_empty() {
questions.push(Question {
question: message.clone(),
header: "Confirm".into(),
options: vec![
QuestionOption { label: "accept".into(), description: String::new() },
QuestionOption { label: "decline".into(), description: String::new() },
],
multi_select: false,
});
}
let source = format!("mcp:{}", self.name);
let answers = app.ask_user(&session, &source, questions.clone(), CancellationToken::new()).await?;
let Some(answers) = answers else { return Ok(json!({"action": "cancel"})) };
if keys.is_empty() {
let a = answers.get(&questions[0].question).and_then(|v| v.as_str()).unwrap_or("decline");
return Ok(json!({"action": if a == "accept" { "accept" } else { "decline" }}));
}
let mut content = Map::new();
for ((key, schema), q) in keys.iter().zip(&questions) {
let Some(raw) = answers.get(&q.question).and_then(|v| v.as_str()) else { continue };
let v = match schema["type"].as_str() {
Some("boolean") => json!(raw == "true" || raw.eq_ignore_ascii_case("yes")),
Some("integer") => raw.trim().parse::<i64>().map(|n| json!(n)).unwrap_or(json!(raw)),
Some("number") => raw.trim().parse::<f64>().map(|n| json!(n)).unwrap_or(json!(raw)),
_ => json!(raw),
};
content.insert(key.clone(), v);
}
Ok(json!({"action": "accept", "content": content}))
}
}
// ---------------------------------------------------------------------------
/// All configured MCP servers with their connection state.
#[derive(Default)]
pub struct McpManager {
servers: Mutex<BTreeMap<String, Arc<McpServer>>>,
states: Mutex<BTreeMap<String, McpState>>,
}
impl McpManager {
pub fn status_json(&self) -> Value {
let states = self.states.lock().unwrap();
let servers = self.servers.lock().unwrap();
let mut out = Map::new();
for (name, st) in states.iter() {
let tools: Vec<Value> = servers
.get(name)
.map(|s| s.tools.lock().unwrap().iter().map(|t| json!({"name": t.name, "description": t.description})).collect())
.unwrap_or_default();
out.insert(name.clone(), json!({"status": st, "tools": tools}));
}
Value::Object(out)
}
/// (Re)connect all servers from config. Runs connections concurrently.
pub async fn start_all(self: &Arc<Self>, app: Weak<App>, configs: BTreeMap<String, McpConfig>) {
self.servers.lock().unwrap().clear();
self.states.lock().unwrap().clear();
let mut tasks = Vec::new();
for (name, cfg) in configs {
if !cfg.enabled {
self.states.lock().unwrap().insert(name, McpState::Disabled);
continue;
}
self.states.lock().unwrap().insert(name.clone(), McpState::Connecting);
let me = self.clone();
let app = app.clone();
tasks.push(tokio::spawn(async move {
let res = tokio::time::timeout(Duration::from_secs(60), McpServer::connect(&name, &cfg, app.clone())).await;
let state = match res {
Ok(Ok(server)) => {
let n = server.tools.lock().unwrap().len();
me.servers.lock().unwrap().insert(name.clone(), server);
McpState::Connected { tools: n }
}
Ok(Err(e)) => McpState::Failed { error: format!("{e:#}") },
Err(_) => McpState::Failed { error: "connection timed out".into() },
};
tracing::info!("mcp {name}: {state:?}");
me.states.lock().unwrap().insert(name, state);
if let Some(app) = app.upgrade() {
app.emit(crate::types::Event::McpStatus { servers: me.status_json() });
}
}));
}
for t in tasks {
let _ = t.await;
}
}
pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
let servers = self.servers.lock().unwrap();
let mut out: Vec<Arc<dyn Tool>> = Vec::new();
for (sname, server) in servers.iter() {
for t in server.tools.lock().unwrap().iter() {
out.push(Arc::new(McpTool {
full_name: tool_name(sname, &t.name),
server: server.clone(),
info: t.clone(),
}));
}
}
out
}
}
/// Tool names must match ^[a-zA-Z0-9_-]{1,64}$ for most providers.
pub fn tool_name(server: &str, tool: &str) -> String {
let clean = |s: &str| s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect::<String>();
let mut n = format!("mcp__{}__{}", clean(server), clean(tool));
n.truncate(64);
n
}
pub struct McpTool {
full_name: String,
server: Arc<McpServer>,
info: McpToolInfo,
}
#[async_trait]
impl Tool for McpTool {
fn name(&self) -> &str {
&self.full_name
}
fn description(&self) -> String {
format!("[MCP {}] {}", self.server.name, self.info.description)
}
fn schema(&self) -> Value {
let mut s = self.info.input_schema.clone();
if s.get("type").is_none() {
s["type"] = json!("object");
}
s
}
fn title(&self, _input: &Value) -> String {
format!("{} → {}", self.server.name, self.info.name)
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
self.server.call_tool(&ctx.session_id, &self.info.name, input, ctx.cancel.clone()).await
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Permission evaluation: config rules, session permission modes and
//! "always allow" approvals given in the UI.
use crate::config::PermissionConfig;
use crate::tools::Tool;
use serde_json::Value;
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Ask,
Deny,
}
fn parse(s: &str) -> Option<Decision> {
match s {
"allow" => Some(Decision::Allow),
"ask" => Some(Decision::Ask),
"deny" => Some(Decision::Deny),
_ => None,
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
globset::GlobBuilder::new(pattern)
.literal_separator(false)
.build()
.map(|g| g.compile_matcher().is_match(text))
.unwrap_or(false)
}
/// Split a shell command into simple commands on && || ; | and newlines.
/// Returns None when the command contains constructs we cannot judge safely
/// (command substitution, backticks).
pub fn split_command(cmd: &str) -> Option<Vec<String>> {
if cmd.contains("$(") || cmd.contains('`') || cmd.contains("<(") || cmd.contains(">(") {
return None;
}
let mut parts = Vec::new();
let mut cur = String::new();
let mut chars = cmd.chars().peekable();
let (mut sq, mut dq) = (false, false);
while let Some(c) = chars.next() {
match c {
'\'' if !dq => {
sq = !sq;
cur.push(c)
}
'"' if !sq => {
dq = !dq;
cur.push(c)
}
'\\' if !sq => {
cur.push(c);
if let Some(n) = chars.next() {
cur.push(n);
}
}
';' | '\n' | '|' | '&' if !sq && !dq => {
// "&" alone (background) and "&&", "||", "|", ";" all separate commands.
if (c == '|' || c == '&') && chars.peek() == Some(&c) {
chars.next();
}
// Redirections like 2>&1 are not separators.
if c == '&' && cur.ends_with('>') {
cur.push(c);
continue;
}
parts.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
parts.push(cur);
Some(parts.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
}
/// Prefix stored for "always allow" on a bash command: the first two words.
pub fn bash_prefix(cmd: &str) -> String {
cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ")
}
pub struct Evaluator<'a> {
pub cfg: &'a PermissionConfig,
pub mode: &'a str,
/// Session approvals: tool names, or "bash:<prefix>".
pub approved: &'a HashSet<String>,
}
impl Evaluator<'_> {
pub fn evaluate(&self, tool: &dyn Tool, input: &Value) -> Decision {
let name = tool.name();
let base = self.tool_rule(name).unwrap_or(if tool.read_only() { Decision::Allow } else { Decision::Ask });
if name == "bash" {
let cmd = input["command"].as_str().unwrap_or("");
let bash = self.bash_decision(cmd);
if bash == Some(Decision::Deny) || base == Decision::Deny {
return Decision::Deny;
}
if self.mode == "bypass" {
return Decision::Allow;
}
return bash.unwrap_or(base);
}
if base == Decision::Deny {
return Decision::Deny;
}
if self.mode == "bypass" || self.approved.contains(name) {
return Decision::Allow;
}
if self.mode == "accept_edits" && tool.is_edit() {
return Decision::Allow;
}
base
}
fn tool_rule(&self, name: &str) -> Option<Decision> {
if let Some(d) = self.cfg.tools.get(name).and_then(|s| parse(s)) {
return Some(d);
}
// Glob rules, most specific (longest) first.
let mut globs: Vec<(&String, &String)> =
self.cfg.tools.iter().filter(|(k, _)| k.contains('*')).collect();
globs.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
globs.into_iter().find(|(k, _)| glob_match(k, name)).and_then(|(_, v)| parse(v))
}
/// Some(Allow) only if every simple command is allowed; Some(Deny) if any is denied.
fn bash_decision(&self, cmd: &str) -> Option<Decision> {
// Deny rules are checked against the full command as well as each part.
let deny_hit = |text: &str| {
self.cfg.bash_rules.iter().any(|(p, d)| d == "deny" && glob_match(p, text))
};
if deny_hit(cmd.trim()) {
return Some(Decision::Deny);
}
let parts = split_command(cmd)?;
if parts.iter().any(|p| deny_hit(p)) {
return Some(Decision::Deny);
}
let all_allowed = !parts.is_empty()
&& parts.iter().all(|p| {
self.approved.contains(&format!("bash:{}", bash_prefix(p)))
|| self.cfg.bash_rules.iter().any(|(pat, d)| d == "allow" && glob_match(pat, p))
});
if all_allowed { Some(Decision::Allow) } else { None }
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
struct Fake(&'static str, bool);
#[async_trait::async_trait]
impl Tool for Fake {
fn name(&self) -> &str {
self.0
}
fn description(&self) -> String {
String::new()
}
fn schema(&self) -> Value {
Value::Null
}
fn read_only(&self) -> bool {
self.1
}
async fn run(&self, _: Value, _: &crate::tools::ToolCtx) -> anyhow::Result<crate::tools::ToolOutput> {
unreachable!()
}
}
fn cfg() -> PermissionConfig {
let mut bash_rules = BTreeMap::new();
bash_rules.insert("git status*".into(), "allow".into());
bash_rules.insert("ls*".into(), "allow".into());
bash_rules.insert("rm -rf /*".into(), "deny".into());
let mut tools = BTreeMap::new();
tools.insert("bash".into(), "ask".into());
tools.insert("mcp__*".into(), "ask".into());
tools.insert("mcp__trilium__*".into(), "allow".into());
PermissionConfig { bash_rules, tools }
}
#[test]
fn compound_commands_need_every_part_allowed() {
let c = cfg();
let approved = HashSet::new();
let e = Evaluator { cfg: &c, mode: "default", approved: &approved };
let bash = Fake("bash", false);
let cmd = |s: &str| serde_json::json!({"command": s});
assert_eq!(e.evaluate(&bash, &cmd("git status")), Decision::Allow);
assert_eq!(e.evaluate(&bash, &cmd("git status && ls -la")), Decision::Allow);
assert_eq!(e.evaluate(&bash, &cmd("git status; rm -rf build")), Decision::Ask);
assert_eq!(e.evaluate(&bash, &cmd("ls $(rm -rf x)")), Decision::Ask);
assert_eq!(e.evaluate(&bash, &cmd("git status 2>&1 | ls")), Decision::Allow);
assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
}
#[test]
fn modes_and_globs() {
let c = cfg();
let approved: HashSet<String> = ["bash:npm test".to_string()].into();
let bypass = Evaluator { cfg: &c, mode: "bypass", approved: &approved };
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
let bash = Fake("bash", false);
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "make"})), Decision::Allow);
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "rm -rf /"})), Decision::Deny);
assert_eq!(def.evaluate(&bash, &serde_json::json!({"command": "npm test -- --watch"})), Decision::Allow);
assert_eq!(def.evaluate(&Fake("mcp__trilium__search", false), &Value::Null), Decision::Allow);
assert_eq!(def.evaluate(&Fake("mcp__other__x", false), &Value::Null), Decision::Ask);
assert_eq!(def.evaluate(&Fake("read", true), &Value::Null), Decision::Allow);
}
}
+334
View File
@@ -0,0 +1,334 @@
//! Agents (modes), system prompts, project instructions, slash commands and skills.
//!
//! Compatible with Claude Code / opencode layouts: AGENTS.md, CLAUDE.md,
//! .claude/{agents,commands,skills}, .codecraft/{agents,commands,skills}.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
use anyhow::Result;
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct Agent {
pub name: String,
pub description: String,
/// "primary" (selectable mode) or "subagent" (task tool only).
pub mode: String,
#[serde(skip)]
pub prompt: String,
/// Allowed tools; None = all.
pub tools: Option<Vec<String>>,
pub read_only: bool,
pub model: Option<String>,
}
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
# Working style
- Be direct and concise. Lead with what you did or found; no filler.
- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns.
- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output.
- Prefer editing existing files over creating new ones. Never create documentation files unless asked.
- Use todo_write to plan and track multi-step work; keep exactly one item in progress.
- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response.
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
- Reference code as path:line.
- Answer in the language the user writes in."#;
const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project.";
const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits.
Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#;
const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase).
1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly.
2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code.
3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width.
4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes.
Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#;
const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts for the user's private use.";
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF });
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None },
]
}
/// Parse "---\nkey: value\n---\nbody" frontmatter (flat keys only).
pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
let mut meta = BTreeMap::new();
let t = text.trim_start_matches('\u{feff}');
if let Some(rest) = t.strip_prefix("---") {
if let Some(end) = rest.find("\n---") {
for line in rest[..end].lines() {
if let Some((k, v)) = line.split_once(':') {
meta.insert(k.trim().to_string(), v.trim().trim_matches('"').trim_matches('\'').to_string());
}
}
let body = rest[end + 4..].trim_start_matches(['-', '\n', '\r']).to_string();
return (meta, body);
}
}
(meta, t.to_string())
}
/// Resource directories, lowest priority first: data dir, workspace, project.
fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec<PathBuf> {
let mut dirs = vec![app.data_dir().join(kind)];
let ws = app.workspace();
let mut chain = vec![ws.clone()];
if cwd != ws {
chain.push(cwd.to_path_buf());
}
for base in chain {
dirs.push(base.join(".claude").join(kind));
dirs.push(base.join(".codecraft").join(kind));
}
dirs
}
pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
let cfg = app.config();
let mut map: BTreeMap<String, Agent> =
builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect();
// Markdown agents (Claude Code format).
for dir in resource_dirs(app, cwd, "agents") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, body) = frontmatter(&text);
let name = meta.get("name").cloned().unwrap_or_else(|| p.file_stem().unwrap().to_string_lossy().into());
let tools = meta.get("tools").map(|t| t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect());
map.insert(name.clone(), Agent {
name,
description: meta.get("description").cloned().unwrap_or_default(),
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
prompt: body,
tools,
read_only: false,
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
});
}
}
// Config agents override everything.
for (name, a) in &cfg.agents {
let base = map.get(name).cloned();
map.insert(name.clone(), Agent {
name: name.clone(),
description: a.description.clone().or(base.as_ref().map(|b| b.description.clone())).unwrap_or_default(),
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
read_only: base.as_ref().is_some_and(|b| b.read_only),
model: a.model.clone().or(base.and_then(|b| b.model)),
});
}
map.into_values().collect()
}
pub fn find_agent(app: &App, cwd: &Path, name: &str) -> Agent {
agents(app, cwd)
.into_iter()
.find(|a| a.name == name)
.unwrap_or_else(|| builtin_agents(true).remove(0))
}
/// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd.
fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> {
let mut dirs = vec![app.data_dir()];
let ws = app.workspace();
let mut d = cwd.to_path_buf();
let mut chain = Vec::new();
loop {
chain.push(d.clone());
if d == ws || !d.starts_with(&ws) || !d.pop() {
break;
}
}
chain.reverse();
dirs.extend(chain);
let mut out = Vec::new();
for dir in dirs {
for name in ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"] {
let p = dir.join(name);
if let Ok(text) = std::fs::read_to_string(&p) {
if !text.trim().is_empty() {
out.push((p, text));
}
}
}
}
out
}
pub fn system_prompt(app: &App, agent: &Agent, cwd: &Path, model: &str) -> String {
let mut s = String::from(BASE_PROMPT);
s.push_str("\n\n");
s.push_str(&agent.prompt);
let git = cwd.join(".git").exists();
s.push_str(&format!(
"\n\n# Environment\n- Working directory: {}\n- Workspace root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
cwd.display(),
app.workspace().display(),
if git { "yes" } else { "no" },
std::env::consts::OS,
std::env::consts::ARCH,
chrono::Local::now().format("%Y-%m-%d"),
));
let skills = skills(app, cwd);
if !skills.is_empty() {
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
for sk in &skills {
s.push_str(&format!("- {}: {}\n", sk.name, sk.description));
}
}
let files = instruction_files(app, cwd);
if !files.is_empty() {
s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n");
for (p, text) in files {
s.push_str(&format!("\n<instructions path=\"{}\">\n{}\n</instructions>\n", p.display(), text.trim()));
}
}
s
}
// ---------------------------------------------------------------------------
// Slash commands
#[derive(Debug, Clone, Serialize)]
pub struct Command {
pub name: String,
pub description: String,
#[serde(skip)]
pub body: String,
pub agent: Option<String>,
}
pub fn commands(app: &App, cwd: &Path) -> Vec<Command> {
let mut map = BTreeMap::new();
for dir in resource_dirs(app, cwd, "commands") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, body) = frontmatter(&text);
let name = p.file_stem().unwrap().to_string_lossy().to_string();
let description = meta
.get("description")
.cloned()
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned() });
}
}
map.into_values().collect()
}
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
pub fn expand_command(app: &App, cwd: &Path, text: &str) -> Option<(String, Option<String>)> {
let rest = text.strip_prefix('/')?;
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let cmd = commands(app, cwd).into_iter().find(|c| c.name == name)?;
let args = args.trim();
let mut body = cmd.body.replace("$ARGUMENTS", args);
for (i, a) in args.split_whitespace().enumerate() {
body = body.replace(&format!("${}", i + 1), a);
}
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
body.push_str(&format!("\n\n{args}"));
}
Some((body, cmd.agent))
}
// ---------------------------------------------------------------------------
// Skills
#[derive(Debug, Clone, Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: PathBuf,
}
pub fn skills(app: &App, cwd: &Path) -> Vec<Skill> {
let mut map = BTreeMap::new();
for dir in resource_dirs(app, cwd, "skills") {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path().join("SKILL.md");
let Ok(text) = std::fs::read_to_string(&p) else { continue };
let (meta, _) = frontmatter(&text);
let name = meta.get("name").cloned().unwrap_or_else(|| e.file_name().to_string_lossy().into());
map.insert(name.clone(), Skill { name, description: meta.get("description").cloned().unwrap_or_default(), path: p });
}
}
map.into_values().collect()
}
pub struct SkillTool;
#[async_trait]
impl Tool for SkillTool {
fn name(&self) -> &str {
"skill"
}
fn description(&self) -> String {
"Load a skill (packaged instructions for a kind of task) by name. Follow the returned instructions.".into()
}
fn schema(&self) -> Value {
json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let name = arg_str(&input, "name")?;
let Some(sk) = skills(&ctx.app, &ctx.cwd).into_iter().find(|s| s.name == name) else {
return Ok(ToolOutput::error(format!("unknown skill '{name}'")));
};
let text = std::fs::read_to_string(&sk.path)?;
let (_, body) = frontmatter(&text);
let dir = sk.path.parent().unwrap_or(Path::new("."));
Ok(ToolOutput::text(format!("Skill '{name}' (base directory: {}):\n\n{body}", dir.display())))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_frontmatter() {
let (m, b) = frontmatter("---\nname: x\ndescription: \"does y\"\n---\nBody here\n");
assert_eq!(m["name"], "x");
assert_eq!(m["description"], "does y");
assert_eq!(b.trim(), "Body here");
let (m2, b2) = frontmatter("no frontmatter");
assert!(m2.is_empty());
assert_eq!(b2, "no frontmatter");
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Anthropic Messages API (streaming). Also works with Anthropic-compatible
//! third-party endpoints; first-party-only features are gated on the host.
use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error};
use crate::config::ModelConfig;
use crate::types::{Message, Part, Role, Usage};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const API_VERSION: &str = "2026-10-21";
pub struct Anthropic {
client: reqwest::Client,
ep: Endpoint,
}
impl Anthropic {
pub fn new(client: reqwest::Client, ep: Endpoint) -> Self {
Self { client, ep }
}
fn first_party(&self) -> bool {
self.ep.base_url.contains("api.anthropic.com")
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let mut rb = self.client.request(method, format!("{}{}", self.ep.base_url, path));
if !self.ep.headers.keys().any(|k| k.eq_ignore_ascii_case("anthropic-version")) {
rb = rb.header("anthropic-version", API_VERSION);
}
self.ep.apply(rb)
}
}
/// Convert our neutral history into Anthropic message params.
pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for m in messages {
let mut content = Vec::new();
for p in &m.parts {
match p {
Part::Text { text } if !text.is_empty() => {
content.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => content.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
Part::Image { image } => content.push(json!({
"type": "image",
"source": {"type": "base64", "media_type": image.media_type, "data": image.data}
})),
// Thinking blocks are replayed unchanged (assistant turns only).
Part::Reasoning { text, signature } if m.role == Role::Assistant => {
match signature.as_deref() {
Some(sig) if sig.starts_with("redacted:") => content.push(json!({
"type": "redacted_thinking",
"data": &sig["redacted:".len()..]
})),
Some(sig) => content.push(json!({
"type": "thinking", "thinking": text, "signature": sig
})),
// Reasoning from another provider has no signature – drop it.
None => {}
}
}
Part::ToolCall { id, name, input, .. } => content.push(json!({
"type": "tool_use", "id": id, "name": name, "input": input
})),
Part::ToolResult { call_id, content: text, images, is_error, .. } => {
let mut blocks = vec![json!({"type": "text", "text": if text.is_empty() { "(no output)" } else { text }})];
for img in images {
blocks.push(json!({
"type": "image",
"source": {"type": "base64", "media_type": img.media_type, "data": img.data}
}));
}
content.push(json!({
"type": "tool_result", "tool_use_id": call_id,
"content": blocks, "is_error": is_error
}))
}
_ => {}
}
}
if content.is_empty() {
continue;
}
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
};
// Merge consecutive messages of the same role (the API requires alternation).
if let Some(last) = out.last_mut() {
if last["role"] == role {
last["content"].as_array_mut().unwrap().extend(content);
continue;
}
}
out.push(json!({"role": role, "content": content}));
}
// tool_result blocks must come first in a user message.
for msg in out.iter_mut().filter(|m| m["role"] == "user") {
let arr = msg["content"].as_array_mut().unwrap();
arr.sort_by_key(|b| if b["type"] == "tool_result" { 0 } else { 1 });
}
out
}
#[derive(Default)]
struct BlockState {
kind: String,
id: String,
name: String,
json: String,
signature: String,
}
#[async_trait]
impl Provider for Anthropic {
async fn stream(
&self,
req: ChatRequest,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()> {
let first_party = self.first_party();
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| {
let mut v = json!({
"name": t.name, "description": t.description, "input_schema": t.input_schema
});
if first_party {
v["eager_input_streaming"] = json!(true);
}
v
})
.collect();
let mut body = json!({
"model": req.model.id,
"max_tokens": req.max_tokens,
"stream": true,
"system": [{"type": "text", "text": req.system, "cache_control": {"type": "ephemeral"}}],
"messages": to_wire_messages(&req.messages),
});
if !tools.is_empty() {
body["tools"] = Value::Array(tools);
}
if first_party {
// Automatic breakpoint on the last cacheable block (conversation prefix).
body["cache_control"] = json!({"type": "ephemeral"});
}
if req.reasoning && req.model.reasoning {
body["thinking"] = json!({"type": "adaptive", "display": "summarized"});
}
if let Some(effort) = &req.model.effort {
body["output_config"] = json!({"effort": effort});
}
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let mut events = resp.bytes_stream().eventsource();
let mut blocks: BTreeMap<u64, BlockState> = BTreeMap::new();
let mut usage = Usage::default();
let mut stop_reason = String::from("end_turn");
loop {
let ev = tokio::select! {
_ = cancel.cancelled() => return Ok(()),
ev = events.next() => ev,
};
let Some(ev) = ev else { break };
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
if ev.data.is_empty() {
continue;
}
let data: Value = serde_json::from_str(&ev.data)?;
match data["type"].as_str().unwrap_or_default() {
"message_start" => {
let u = &data["message"]["usage"];
usage.input_tokens = u["input_tokens"].as_u64().unwrap_or(0);
usage.cache_read_tokens = u["cache_read_input_tokens"].as_u64().unwrap_or(0);
usage.cache_write_tokens = u["cache_creation_input_tokens"].as_u64().unwrap_or(0);
}
"content_block_start" => {
let idx = data["index"].as_u64().unwrap_or(0);
let cb = &data["content_block"];
let mut st = BlockState {
kind: cb["type"].as_str().unwrap_or_default().to_string(),
..Default::default()
};
match st.kind.as_str() {
"tool_use" => {
st.id = cb["id"].as_str().unwrap_or_default().into();
st.name = cb["name"].as_str().unwrap_or_default().into();
}
"redacted_thinking" => {
st.signature = format!("redacted:{}", cb["data"].as_str().unwrap_or_default());
}
"text" => {
if let Some(t) = cb["text"].as_str().filter(|t| !t.is_empty()) {
tx.send(StreamEvent::TextDelta(t.into())).await.ok();
}
}
_ => {}
}
blocks.insert(idx, st);
}
"content_block_delta" => {
let idx = data["index"].as_u64().unwrap_or(0);
let d = &data["delta"];
let st = blocks.entry(idx).or_default();
match d["type"].as_str().unwrap_or_default() {
"text_delta" => {
tx.send(StreamEvent::TextDelta(d["text"].as_str().unwrap_or_default().into())).await.ok();
}
"thinking_delta" => {
tx.send(StreamEvent::ReasoningDelta(d["thinking"].as_str().unwrap_or_default().into())).await.ok();
}
"signature_delta" => st.signature.push_str(d["signature"].as_str().unwrap_or_default()),
"input_json_delta" => st.json.push_str(d["partial_json"].as_str().unwrap_or_default()),
_ => {}
}
}
"content_block_stop" => {
let idx = data["index"].as_u64().unwrap_or(0);
if let Some(st) = blocks.remove(&idx) {
match st.kind.as_str() {
"tool_use" => {
let input = if st.json.trim().is_empty() {
json!({})
} else {
// Invalid JSON (e.g. truncated) is passed on as a marker; the
// agent loop answers it with an error tool_result.
serde_json::from_str(&st.json)
.unwrap_or_else(|_| json!({"__invalid_json": st.json}))
};
tx.send(StreamEvent::ToolCall { id: st.id, name: st.name, input, extra: None }).await.ok();
}
"thinking" | "redacted_thinking" => {
tx.send(StreamEvent::ReasoningSignature(st.signature)).await.ok();
}
_ => {}
}
}
}
"message_delta" => {
if let Some(r) = data["delta"]["stop_reason"].as_str() {
stop_reason = r.to_string();
}
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
usage.output_tokens = o;
}
}
"error" => {
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
return Err(anyhow!("provider error: {msg}"));
}
_ => {}
}
}
tx.send(StreamEvent::Usage(usage)).await.ok();
tx.send(StreamEvent::Done { stop_reason }).await.ok();
Ok(())
}
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
let resp = self.request(reqwest::Method::GET, "/models?limit=100").send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let v: Value = resp.json().await?;
Ok(v["data"]
.as_array()
.map(|a| {
a.iter()
.map(|m| ModelConfig {
id: m["id"].as_str().unwrap_or_default().into(),
name: m["display_name"].as_str().map(String::from),
context: m["max_input_tokens"].as_u64(),
max_output: m["max_tokens"].as_u64(),
reasoning: true,
vision: true,
..Default::default()
})
.collect()
})
.unwrap_or_default())
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Model providers. Two wire protocols cover all targets:
//! Anthropic Messages and OpenAI-compatible chat completions.
pub mod anthropic;
pub mod openai;
use crate::config::{Config, ModelConfig, ProviderConfig, ProviderKind, expand_env};
use crate::types::{Message, Usage};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Serialize)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone)]
pub struct ChatRequest {
pub model: ModelConfig,
pub system: String,
pub messages: Vec<Message>,
pub tools: Vec<ToolDef>,
pub max_tokens: u64,
/// Enable extended thinking / reasoning when the model supports it.
pub reasoning: bool,
}
#[derive(Debug, Clone)]
pub enum StreamEvent {
TextDelta(String),
ReasoningDelta(String),
ReasoningSignature(String),
ToolCall { id: String, name: String, input: Value, extra: Option<Value> },
Usage(Usage),
Done { stop_reason: String },
}
#[async_trait]
pub trait Provider: Send + Sync {
async fn stream(
&self,
req: ChatRequest,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()>;
async fn list_models(&self) -> Result<Vec<ModelConfig>>;
}
/// Connection settings shared by both protocol implementations.
#[derive(Debug, Clone)]
pub struct Endpoint {
pub base_url: String,
pub api_key: String,
pub auth_header: String,
pub auth_prefix: String,
pub headers: BTreeMap<String, String>,
}
impl Endpoint {
fn from_config(p: &ProviderConfig) -> Self {
let (default_header, default_prefix) = match p.kind {
ProviderKind::Anthropic => ("x-api-key", ""),
ProviderKind::Openai => ("Authorization", "Bearer "),
};
let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic {
"https://api.anthropic.com/v1".to_string()
} else {
expand_env(&p.base_url)
};
Self {
base_url: base.trim_end_matches('/').to_string(),
api_key: expand_env(&p.api_key),
auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()),
auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()),
headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
}
}
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if !self.api_key.is_empty() {
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
}
for (k, v) in &self.headers {
rb = rb.header(k, v);
}
rb
}
}
pub struct ProviderEntry {
pub name: String,
pub provider: Arc<dyn Provider>,
pub models: Vec<ModelConfig>,
}
#[derive(Default)]
pub struct Registry {
pub providers: BTreeMap<String, ProviderEntry>,
}
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
.build()
.expect("http client")
}
impl Registry {
/// Build providers from config. Providers with an empty model list are queried
/// for their models; failures are logged and leave the list empty.
pub async fn build(cfg: &Config) -> Self {
let client = http_client();
let mut providers = BTreeMap::new();
for (name, pc) in &cfg.providers {
if !pc.enabled {
continue;
}
let ep = Endpoint::from_config(pc);
let provider: Arc<dyn Provider> = match pc.kind {
ProviderKind::Anthropic => Arc::new(anthropic::Anthropic::new(client.clone(), ep)),
ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)),
};
let mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
if models.is_empty() {
match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await {
Ok(Ok(m)) => models = m,
Ok(Err(e)) => tracing::warn!("provider {name}: model discovery failed: {e:#}"),
Err(_) => tracing::warn!("provider {name}: model discovery timed out"),
}
}
providers.insert(
name.clone(),
ProviderEntry { name: name.clone(), provider, models },
);
}
Self { providers }
}
/// Resolve "provider/model". Unknown models of a known provider are allowed
/// with default settings so users can type any model id.
pub fn resolve(&self, spec: &str) -> Result<(Arc<dyn Provider>, ModelConfig)> {
let (pname, mid) = spec
.split_once('/')
.ok_or_else(|| anyhow!("model must be 'provider/model', got '{spec}'"))?;
let entry = self
.providers
.get(pname)
.ok_or_else(|| anyhow!("unknown or disabled provider '{pname}'"))?;
let model = entry
.models
.iter()
.find(|m| m.id == mid)
.cloned()
.unwrap_or_else(|| ModelConfig { id: mid.to_string(), ..Default::default() });
Ok((entry.provider.clone(), model))
}
pub fn first_model(&self) -> Option<String> {
self.providers
.values()
.find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id)))
}
pub fn models_json(&self) -> Value {
let list: Vec<Value> = self
.providers
.values()
.map(|e| {
serde_json::json!({
"provider": e.name,
"models": e.models.iter().map(|m| serde_json::json!({
"id": m.id,
"name": m.name.clone().unwrap_or_else(|| m.id.clone()),
"context": m.context,
"reasoning": m.reasoning,
"vision": m.vision,
})).collect::<Vec<_>>()
})
})
.collect();
Value::Array(list)
}
}
/// Turn an HTTP error response into a readable error.
pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let detail = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| {
v.pointer("/error/message")
.or_else(|| v.pointer("/0/error/message"))
.or_else(|| v.get("message"))
.and_then(|m| m.as_str().map(String::from))
})
.unwrap_or_else(|| body.chars().take(500).collect());
anyhow!("provider returned HTTP {status}: {detail}")
}
+251
View File
@@ -0,0 +1,251 @@
//! OpenAI-compatible chat completions (streaming). Covers Gemini (OpenAI compat),
//! opencode Go, fal/OpenRouter, Ollama, vLLM, llama.cpp, LM Studio, ...
use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error};
use crate::config::ModelConfig;
use crate::types::{Message, Part, Role, Usage};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub struct OpenAi {
client: reqwest::Client,
ep: Endpoint,
}
impl OpenAi {
pub fn new(client: reqwest::Client, ep: Endpoint) -> Self {
Self { client, ep }
}
}
fn data_url(img: &crate::types::ImageData) -> String {
format!("data:{};base64,{}", img.media_type, img.data)
}
pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
let mut out = vec![json!({"role": "system", "content": system})];
for m in messages {
match m.role {
Role::User => {
let mut parts = Vec::new();
let mut tool_images = Vec::new();
for p in &m.parts {
match p {
Part::Text { text } if !text.is_empty() => {
parts.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => parts.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
Part::Image { image } => parts.push(json!({
"type": "image_url", "image_url": {"url": data_url(image)}
})),
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
out.push(json!({
"role": "tool", "tool_call_id": call_id, "name": name,
"content": if text.is_empty() { "(no output)".into() } else { text }
}));
tool_images.extend(images.iter().cloned());
}
_ => {}
}
}
// Tool messages cannot carry images: send them as a user message.
if !tool_images.is_empty() {
let mut c = vec![json!({"type": "text", "text": "Images returned by the tool calls above:"})];
c.extend(tool_images.iter().map(|i| json!({"type": "image_url", "image_url": {"url": data_url(i)}})));
out.push(json!({"role": "user", "content": c}));
}
if !parts.is_empty() {
out.push(json!({"role": "user", "content": parts}));
}
}
Role::Assistant => {
let mut text = String::new();
let mut reasoning = String::new();
let mut calls = Vec::new();
for p in &m.parts {
match p {
Part::Text { text: t } => text.push_str(t),
Part::Reasoning { text: t, .. } => reasoning.push_str(t),
Part::ToolCall { id, name, input, extra } => {
let mut c = json!({
"id": id, "type": "function",
"function": {"name": name, "arguments": input.to_string()}
});
if let Some(e) = extra {
c["extra_content"] = e.clone();
}
calls.push(c);
}
_ => {}
}
}
let mut msg = json!({"role": "assistant", "content": if text.is_empty() { Value::Null } else { Value::String(text) }});
if !calls.is_empty() {
msg["tool_calls"] = Value::Array(calls);
}
// Some reasoning models (DeepSeek, Kimi) expect their reasoning back during tool loops.
if !reasoning.is_empty() {
msg["reasoning_content"] = Value::String(reasoning);
}
if msg["content"].is_null() && msg.get("tool_calls").is_none() {
continue;
}
out.push(msg);
}
}
}
out
}
#[derive(Default)]
struct PendingCall {
id: String,
name: String,
args: String,
extra: Option<Value>,
}
#[async_trait]
impl Provider for OpenAi {
async fn stream(
&self,
req: ChatRequest,
tx: mpsc::Sender<StreamEvent>,
cancel: CancellationToken,
) -> Result<()> {
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| json!({
"type": "function",
"function": {"name": t.name, "description": t.description, "parameters": t.input_schema}
}))
.collect();
let mut body = json!({
"model": req.model.id,
"messages": to_wire_messages(&req.system, &req.messages),
"stream": true,
"stream_options": {"include_usage": true},
"max_tokens": req.max_tokens,
});
if !tools.is_empty() {
body["tools"] = Value::Array(tools);
}
if let Some(effort) = &req.model.effort {
body["reasoning_effort"] = json!(effort);
}
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
let resp = self.ep.apply(rb).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let mut events = resp.bytes_stream().eventsource();
let mut calls: BTreeMap<u64, PendingCall> = BTreeMap::new();
let mut usage = Usage::default();
let mut stop_reason = String::from("stop");
loop {
let ev = tokio::select! {
_ = cancel.cancelled() => return Ok(()),
ev = events.next() => ev,
};
let Some(ev) = ev else { break };
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
if ev.data.trim() == "[DONE]" {
break;
}
if ev.data.trim().is_empty() {
continue;
}
let data: Value = serde_json::from_str(&ev.data)?;
if let Some(err) = data.get("error") {
return Err(anyhow!("provider error: {}", err["message"].as_str().unwrap_or(&err.to_string())));
}
if let Some(u) = data.get("usage").filter(|u| u.is_object()) {
let cached = u["prompt_tokens_details"]["cached_tokens"].as_u64().unwrap_or(0);
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
usage.cache_read_tokens = cached;
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
}
let Some(choice) = data["choices"].get(0) else { continue };
let d = &choice["delta"];
if let Some(t) = d["content"].as_str().filter(|t| !t.is_empty()) {
tx.send(StreamEvent::TextDelta(t.into())).await.ok();
}
let r = d["reasoning_content"].as_str().or_else(|| d["reasoning"].as_str());
if let Some(r) = r.filter(|r| !r.is_empty()) {
tx.send(StreamEvent::ReasoningDelta(r.into())).await.ok();
}
if let Some(tcs) = d["tool_calls"].as_array() {
for (pos, tc) in tcs.iter().enumerate() {
let idx = tc["index"].as_u64().unwrap_or(pos as u64);
let pc = calls.entry(idx).or_default();
if let Some(id) = tc["id"].as_str().filter(|s| !s.is_empty()) {
pc.id = id.into();
}
if let Some(n) = tc["function"]["name"].as_str() {
pc.name.push_str(n);
}
if let Some(a) = tc["function"]["arguments"].as_str() {
pc.args.push_str(a);
}
if let Some(e) = tc.get("extra_content") {
pc.extra = Some(e.clone());
}
}
}
if let Some(f) = choice["finish_reason"].as_str() {
stop_reason = f.to_string();
}
}
for (i, pc) in calls {
let input = if pc.args.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&pc.args).unwrap_or_else(|_| json!({"__invalid_json": pc.args}))
};
let id = if pc.id.is_empty() { format!("call_{}_{i}", crate::types::new_id()) } else { pc.id };
tx.send(StreamEvent::ToolCall { id, name: pc.name, input, extra: pc.extra }).await.ok();
}
tx.send(StreamEvent::Usage(usage)).await.ok();
tx.send(StreamEvent::Done { stop_reason }).await.ok();
Ok(())
}
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
let rb = self.client.get(format!("{}/models", self.ep.base_url));
let resp = self.ep.apply(rb).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
let v: Value = resp.json().await?;
let mut models: Vec<ModelConfig> = v["data"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|m| m["id"].as_str())
// Gemini lists "models/<id>"; the chat endpoint wants the bare id.
.map(|id| ModelConfig {
id: id.trim_start_matches("models/").to_string(),
..Default::default()
})
.collect()
})
.unwrap_or_default();
models.sort_by(|a, b| a.id.cmp(&b.id));
Ok(models)
}
}
+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(())
}
+317
View File
@@ -0,0 +1,317 @@
//! SQLite persistence for sessions and messages.
use crate::types::{Message, Session, now_ms};
use serde::Serialize;
use serde_json::{Value, json};
use anyhow::Result;
use rusqlite::{Connection, OptionalExtension, params};
use std::path::Path;
use std::sync::Mutex;
pub struct Store {
conn: Mutex<Connection>,
}
impl Store {
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
parent_id TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id, seq);
-- One row per model request. Kept when sessions are deleted (statistics).
CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
session_id TEXT NOT NULL,
root_session_id TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
agent TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cache_read_tokens INTEGER NOT NULL,
cache_write_tokens INTEGER NOT NULL,
ttft_ms INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
cost REAL NOT NULL,
error INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS usage_ts ON usage(ts);
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);",
)?;
Ok(Self { conn: Mutex::new(conn) })
}
pub fn save_session(&self, s: &Session) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sessions (id, data, parent_id, updated_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at],
)?;
Ok(())
}
pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
let conn = self.conn.lock().unwrap();
let data: Option<String> = conn
.query_row("SELECT data FROM sessions WHERE id = ?1", [id], |r| r.get(0))
.optional()?;
Ok(data.map(|d| serde_json::from_str(&d)).transpose()?)
}
/// Top-level sessions (subagent sessions are reachable through their parent).
pub fn list_sessions(&self) -> Result<Vec<Session>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT data FROM sessions WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 500",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for row in rows {
out.push(serde_json::from_str(&row?)?);
}
Ok(out)
}
pub fn delete_session(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
// Child sessions (subagents) go with their parent.
conn.execute("DELETE FROM sessions WHERE parent_id = ?1", [id])?;
conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
Ok(())
}
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
params![m.id, session_id, serde_json::to_string(m)?],
)?;
conn.execute(
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
params![session_id, now_ms()],
)?;
Ok(())
}
pub fn messages(&self, session_id: &str) -> Result<Vec<Message>> {
let conn = self.conn.lock().unwrap();
let mut stmt =
conn.prepare("SELECT data FROM messages WHERE session_id = ?1 ORDER BY seq")?;
let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for row in rows {
out.push(serde_json::from_str(&row?)?);
}
Ok(out)
}
/// Remove all messages after (and including) the given one – used for "edit & resend".
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM messages WHERE session_id = ?1 AND seq >=
(SELECT seq FROM messages WHERE id = ?2 AND session_id = ?1)",
params![session_id, message_id],
)?;
Ok(())
}
}
/// One model request, as recorded for statistics.
#[derive(Debug, Clone, Serialize)]
pub struct UsageRecord {
pub ts: i64,
pub session_id: String,
pub root_session_id: String,
pub provider: String,
pub model: String,
pub agent: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
/// Time to first token (prefill + queueing), milliseconds.
pub ttft_ms: u64,
pub duration_ms: u64,
pub cost: f64,
pub error: bool,
}
/// Aggregation SQL shared by all statistics queries.
const AGG: &str = "COUNT(*) AS requests,
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(cache_read_tokens),0), COALESCE(SUM(cache_write_tokens),0),
COALESCE(SUM(cost),0.0), COALESCE(SUM(ttft_ms),0), COALESCE(SUM(duration_ms),0),
COALESCE(SUM(error),0)";
fn agg_row(r: &rusqlite::Row, offset: usize) -> rusqlite::Result<Value> {
let requests: i64 = r.get(offset)?;
let input: i64 = r.get(offset + 1)?;
let output: i64 = r.get(offset + 2)?;
let cache_read: i64 = r.get(offset + 3)?;
let cache_write: i64 = r.get(offset + 4)?;
let cost: f64 = r.get(offset + 5)?;
let ttft: i64 = r.get(offset + 6)?;
let duration: i64 = r.get(offset + 7)?;
let errors: i64 = r.get(offset + 8)?;
let gen_ms = (duration - ttft).max(0);
let prompt = input + cache_read + cache_write;
Ok(json!({
"requests": requests,
"input_tokens": input,
"output_tokens": output,
"cache_read_tokens": cache_read,
"cache_write_tokens": cache_write,
"cost": cost,
"errors": errors,
"avg_ttft_ms": if requests > 0 { ttft / requests } else { 0 },
// Output speed: tokens generated per second after the first token.
"output_tps": if gen_ms > 0 { output as f64 * 1000.0 / gen_ms as f64 } else { 0.0 },
// Prefill speed: prompt tokens processed per second until the first token.
"prefill_tps": if ttft > 0 { prompt as f64 * 1000.0 / ttft as f64 } else { 0.0 },
"cache_hit_rate": if prompt > 0 { cache_read as f64 / prompt as f64 } else { 0.0 },
"duration_ms": duration,
}))
}
impl Store {
pub fn add_usage(&self, u: &UsageRecord) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
ttft_ms, duration_ms, cost, error)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
params![
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64
],
)?;
Ok(())
}
/// Statistics for one chat (including its subagents), totals and per model.
pub fn session_stats(&self, root_session_id: &str) -> Result<Value> {
let conn = self.conn.lock().unwrap();
let total = conn.query_row(
&format!("SELECT {AGG} FROM usage WHERE root_session_id = ?1"),
[root_session_id],
|r| agg_row(r, 0),
)?;
let mut stmt = conn.prepare(&format!(
"SELECT provider || '/' || model, {AGG} FROM usage WHERE root_session_id = ?1
GROUP BY 1 ORDER BY 1"
))?;
let by_model = stmt
.query_map([root_session_id], |r| {
let mut v = agg_row(r, 1)?;
v["model"] = json!(r.get::<_, String>(0)?);
Ok(v)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(json!({"total": total, "by_model": by_model}))
}
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
/// into the viewer's local time.
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64) -> Result<Value> {
let conn = self.conn.lock().unwrap();
let shift = tz_offset_min * 60_000;
let total = conn.query_row(
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2"),
params![from, to],
|r| agg_row(r, 0),
)?;
let grouped = |key_sql: &str, key_name: &str| -> Result<Vec<Value>> {
let mut stmt = conn.prepare(&format!(
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 GROUP BY 1 ORDER BY 1"
))?;
let rows = stmt
.query_map(params![from, to, shift], |r| {
let mut v = agg_row(r, 1)?;
v[key_name] = match r.get_ref(0)? {
rusqlite::types::ValueRef::Integer(i) => json!(i),
rusqlite::types::ValueRef::Text(t) => json!(String::from_utf8_lossy(t)),
_ => Value::Null,
};
Ok(v)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
};
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
let by_day = grouped(day_sql, "day")?;
let by_model = grouped("provider || '/' || model", "model")?;
let by_provider = grouped("provider", "provider")?;
let by_agent = grouped("agent", "agent")?;
// Heatmap: weekday (0 = Sunday) x hour of day.
let heatmap = grouped(
"CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \
CAST(strftime('%H', (ts + ?3) / 1000, 'unixepoch') AS INTEGER)",
"slot",
)?;
let by_day_model = {
let mut stmt = conn.prepare(&format!(
"SELECT {day_sql}, provider || '/' || model, SUM(cost),
SUM(input_tokens + cache_read_tokens + cache_write_tokens), SUM(output_tokens)
FROM usage WHERE ts >= ?1 AND ts < ?2 GROUP BY 1, 2 ORDER BY 1"
))?;
stmt.query_map(params![from, to, shift], |r| {
Ok(json!({
"day": r.get::<_, String>(0)?,
"model": r.get::<_, String>(1)?,
"cost": r.get::<_, f64>(2)?,
"input_tokens": r.get::<_, i64>(3)?,
"output_tokens": r.get::<_, i64>(4)?,
}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
// Most expensive chats in the range.
let top_sessions = {
let mut stmt = conn.prepare(&format!(
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
WHERE u.ts >= ?1 AND u.ts < ?2
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
))?;
stmt.query_map(params![from, to], |r| {
let mut v = agg_row(r, 2)?;
v["session_id"] = json!(r.get::<_, String>(0)?);
v["title"] = json!(r.get::<_, String>(1)?);
Ok(v)
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok(json!({
"total": total,
"by_day": by_day,
"by_day_model": by_day_model,
"by_model": by_model,
"by_provider": by_provider,
"by_agent": by_agent,
"heatmap": heatmap,
"top_sessions": top_sessions,
}))
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Tools that interact with the harness itself: todo list, questions to the
//! user and subagents.
use super::{Tool, ToolCtx, ToolOutput, arg_str};
use crate::types::Question;
use anyhow::{Result, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
pub struct TodoWrite;
#[async_trait]
impl Tool for TodoWrite {
fn name(&self) -> &str {
"todo_write"
}
fn description(&self) -> String {
"Maintain a task list for the current work, shown live to the user. Send the complete \
list each time. Use it for multi-step tasks: exactly one item in_progress at a time, \
mark items completed as soon as they are done."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
},
"required": ["content", "status"]
}
}
},
"required": ["todos"]
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let todos = input.get("todos").cloned().unwrap_or(json!([]));
if !todos.is_array() {
bail!("todos must be an array");
}
ctx.app.set_todos(&ctx.session_id, todos.clone())?;
let open = todos.as_array().unwrap().iter().filter(|t| t["status"] != "completed").count();
Ok(ToolOutput::text(format!("Todo list updated ({open} open).")).with_meta(json!({"todos": todos})))
}
}
// ---------------------------------------------------------------------------
pub struct AskUser;
#[async_trait]
impl Tool for AskUser {
fn name(&self) -> &str {
"ask_user"
}
fn description(&self) -> String {
"Ask the user one or more questions and wait for the answers. Use this only when you are \
blocked on a decision that is genuinely the user's (requirements, preferences, choices \
between valid approaches) – not for things you can find out yourself. Offer 2-4 concrete \
options per question; the user can always answer with free text instead."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"questions": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "object",
"properties": {
"question": {"type": "string"},
"header": {"type": "string", "description": "Very short label (max 12 chars)"},
"multi_select": {"type": "boolean"},
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"description": {"type": "string"}
},
"required": ["label"]
}
}
},
"required": ["question"]
}
}
},
"required": ["questions"]
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let questions: Vec<Question> = serde_json::from_value(input["questions"].clone())?;
if questions.is_empty() {
bail!("at least one question is required");
}
let answers = ctx
.app
.ask_user(&ctx.session_id, "agent", questions.clone(), ctx.cancel.clone())
.await?;
let Some(answers) = answers else {
return Ok(ToolOutput::error("The user dismissed the questions without answering."));
};
let mut out = String::from("The user answered:\n");
for q in &questions {
let a = answers.get(&q.question).map(|v| match v {
Value::String(s) => s.clone(),
Value::Array(a) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
other => other.to_string(),
});
out.push_str(&format!("- {}\n → {}\n", q.question, a.unwrap_or_else(|| "(no answer)".into())));
}
Ok(ToolOutput::text(out).with_meta(json!({"answers": answers})))
}
}
// ---------------------------------------------------------------------------
pub struct Task;
#[async_trait]
impl Tool for Task {
fn name(&self) -> &str {
"task"
}
fn description(&self) -> String {
"Launch a subagent to handle an independent, multi-step task in its own context (e.g. a \
broad codebase search, a focused review). The subagent only knows what you put in the \
prompt – write a complete, self-contained brief. Its final message is returned to you. \
Several task calls in one response run in parallel."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"description": {"type": "string", "description": "Short (3-5 words) task description"},
"prompt": {"type": "string", "description": "The full brief for the subagent"},
"subagent_type": {"type": "string", "description": "Subagent to use (default: general)"}
},
"required": ["description", "prompt"]
})
}
fn read_only(&self) -> bool {
// Read-only agents (plan, explore) can spawn read-only subagents; the runner enforces it.
true
}
fn title(&self, input: &Value) -> String {
format!("Subagent: {}", input["description"].as_str().unwrap_or("task"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let prompt = 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_id, result) =
crate::runner::run_subagent(ctx.app.clone(), ctx, &agent, &description, prompt).await?;
Ok(ToolOutput::text(result).with_meta(json!({"session_id": child_id, "agent": agent})))
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Shell command execution with timeout, cancellation and live output.
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_OUTPUT: usize = 30_000;
pub struct Bash;
#[async_trait]
impl Tool for Bash {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> String {
"Run a shell command (bash) in the session working directory. stdout and stderr are \
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
dedicated read/edit/glob/grep tools over cat/sed/find/grep."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {"type": "string"},
"description": {"type": "string", "description": "5-10 word description of what the command does"},
"timeout_ms": {"type": "integer"}
},
"required": ["command"]
})
}
fn title(&self, input: &Value) -> String {
input["description"]
.as_str()
.map(String::from)
.unwrap_or_else(|| format!("Run: {}", input["command"].as_str().unwrap_or("?")))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let command = arg_str(&input, "command")?;
let timeout = Duration::from_millis(
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
);
let mut cmd = Command::new("bash");
cmd.arg("-c")
.arg(format!("{command} 2>&1"))
.current_dir(&ctx.cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.env("CODECRAFT", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("PAGER", "cat")
.kill_on_drop(true);
// Own process group so the whole tree can be killed on abort/timeout.
cmd.process_group(0);
let mut child = cmd.spawn()?;
let pid = child.id();
let stdout = child.stdout.take().unwrap();
// split() instead of lines(): non-UTF-8 output must not end the read loop.
let mut lines = BufReader::new(stdout).split(b'\n');
let mut output = String::new();
let started = Instant::now();
let mut last_progress = Instant::now();
let deadline = tokio::time::sleep(timeout);
tokio::pin!(deadline);
let kill_group = |pid: Option<u32>| {
if let Some(pid) = pid {
// SAFETY: plain syscall; negative pid targets the process group.
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
};
let status = loop {
tokio::select! {
line = lines.next_segment() => match line {
Ok(Some(l)) => {
if output.len() < MAX_OUTPUT * 4 {
output.push_str(&String::from_utf8_lossy(&l));
output.push('\n');
}
if last_progress.elapsed() > Duration::from_millis(300) {
ctx.progress(tail(&output, 4000));
last_progress = Instant::now();
}
}
Ok(None) | Err(_) => break child.wait().await?,
},
_ = &mut deadline => {
kill_group(pid);
let _ = child.wait().await;
output.push_str(&format!("\n[command timed out after {}s and was killed]", timeout.as_secs()));
return Ok(ToolOutput::error(truncate(&output, MAX_OUTPUT)));
}
_ = ctx.cancel.cancelled() => {
kill_group(pid);
let _ = child.wait().await;
return Ok(ToolOutput::error("[command aborted by user]"));
}
}
};
let code = status.code().unwrap_or(-1);
let mut text = truncate(output.trim_end(), MAX_OUTPUT);
if text.is_empty() {
text = "(no output)".into();
}
if code != 0 {
text.push_str(&format!("\n[exit code {code}]"));
}
Ok(ToolOutput {
content: text,
is_error: code != 0,
meta: Some(json!({"exit_code": code, "duration_ms": started.elapsed().as_millis() as u64})),
..Default::default()
})
}
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut start = s.len() - max;
while !s.is_char_boundary(start) {
start += 1;
}
s[start..].to_string()
}
+244
View File
@@ -0,0 +1,244 @@
//! Craft mode tools: study a target in a headless browser and compare a build
//! with the original visually.
use super::{Tool, ToolCtx, ToolOutput, arg_str};
use crate::types::ImageData;
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use base64::Engine;
use image::{GenericImageView, ImageFormat, Rgba, RgbaImage};
use serde_json::{Value, json};
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::process::Command;
const SNAPSHOT_HEIGHT: u32 = 2000;
/// Long-side limit for images sent to the model.
const MAX_IMAGE_SIDE: u32 = 1568;
fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
let (w, h) = img.dimensions();
let img = if w.max(h) > MAX_IMAGE_SIDE {
img.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, image::imageops::FilterType::Triangle)
} else {
img.clone()
};
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
Ok(ImageData {
media_type: "image/png".into(),
data: base64::engine::general_purpose::STANDARD.encode(&buf),
})
}
fn slug(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect();
while out.contains("--") {
out = out.replace("--", "-");
}
out.trim_matches('-').chars().take(60).collect()
}
async fn chromium(ctx: &ToolCtx, args: &[String]) -> Result<std::process::Output> {
let bin = ctx.app.config().craft.chromium.clone();
let fut = Command::new(&bin)
.args([
"--headless=new",
"--no-sandbox",
"--disable-gpu",
"--hide-scrollbars",
"--mute-audio",
"--disable-dev-shm-usage",
"--virtual-time-budget=8000",
])
.args(args)
.kill_on_drop(true)
.output();
tokio::select! {
r = tokio::time::timeout(Duration::from_secs(60), fut) => {
r.context("chromium timed out")?.with_context(|| format!("could not start '{bin}' (set craft.chromium in the config)"))
}
_ = ctx.cancel.cancelled() => bail!("aborted"),
}
}
pub struct BrowserSnapshot;
#[async_trait]
impl Tool for BrowserSnapshot {
fn name(&self) -> &str {
"browser_snapshot"
}
fn description(&self) -> String {
"Open a URL in a headless browser and capture what a visitor sees: screenshots at the \
configured screen widths (or the given ones) plus the visible text. Works for public \
sites and for local builds (http://localhost:PORT or file:///path). Screenshots are saved \
under .craft/snapshots/<label>-<width>.png in the working directory for image_compare."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"url": {"type": "string"},
"label": {"type": "string", "description": "Name for the saved files, e.g. 'original-home' or 'build-home'"},
"widths": {"type": "array", "items": {"type": "integer"}},
"text": {"type": "boolean", "description": "Include visible text (default true)"}
},
"required": ["url"]
})
}
fn read_only(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Snapshot {}", input["url"].as_str().unwrap_or("?"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let url = arg_str(&input, "url")?.to_string();
let label = slug(input["label"].as_str().unwrap_or(&url));
let widths: Vec<u32> = input["widths"]
.as_array()
.map(|a| a.iter().filter_map(|w| w.as_u64().map(|w| w as u32)).collect())
.filter(|v: &Vec<u32>| !v.is_empty())
.unwrap_or_else(|| ctx.app.config().craft.widths.clone());
let dir = ctx.cwd.join(".craft/snapshots");
tokio::fs::create_dir_all(&dir).await?;
let mut images = Vec::new();
let mut files = Vec::new();
for w in widths.iter().take(4) {
let path = dir.join(format!("{label}-{w}.png"));
let out = chromium(
ctx,
&[
format!("--window-size={w},{SNAPSHOT_HEIGHT}"),
format!("--screenshot={}", path.display()),
url.clone(),
],
)
.await?;
if !path.exists() {
bail!("screenshot failed: {}", String::from_utf8_lossy(&out.stderr).chars().take(800).collect::<String>());
}
let img = image::open(&path)?;
images.push(png_for_model(&img)?);
files.push(path.strip_prefix(&ctx.cwd).unwrap_or(&path).display().to_string());
}
let mut text = format!("Snapshots of {url}:\n");
for (f, w) in files.iter().zip(&widths) {
text.push_str(&format!("- {w}px → {f}\n"));
}
if input["text"].as_bool().unwrap_or(true) {
let out = chromium(ctx, &["--dump-dom".into(), url.clone()]).await?;
let dom = String::from_utf8_lossy(&out.stdout);
let visible = html2text::from_read(dom.as_bytes(), 120).unwrap_or_default();
text.push_str("\nVisible text:\n");
text.push_str(&super::truncate(&visible, 20_000));
}
Ok(ToolOutput { content: text, images, meta: Some(json!({"files": files})), ..Default::default() })
}
}
// ---------------------------------------------------------------------------
pub struct ImageCompare;
/// Similarity in [0, 1] and a diff image (differences in red over a faded original).
pub fn compare(a: &image::DynamicImage, b: &image::DynamicImage) -> (f64, RgbaImage) {
let (w, h) = a.dimensions();
let b = if b.dimensions() != (w, h) {
b.resize_exact(w, h, image::imageops::FilterType::Triangle)
} else {
b.clone()
};
let a = a.to_rgba8();
let b = b.to_rgba8();
let mut diff = RgbaImage::new(w, h);
let mut total = 0f64;
for (x, y, pa) in a.enumerate_pixels() {
let pb = b.get_pixel(x, y);
let d = (0..3).map(|i| (pa[i] as f64 - pb[i] as f64).abs()).sum::<f64>() / (3.0 * 255.0);
total += d;
let gray = ((pa[0] as u32 + pa[1] as u32 + pa[2] as u32) / 3) as u8;
let faded = 200 + gray / 5;
diff.put_pixel(
x,
y,
if d > 0.1 { Rgba([255, (255.0 * (1.0 - d)) as u8 / 2, 0, 255]) } else { Rgba([faded, faded, faded, 255]) },
);
}
let px = (w as f64 * h as f64).max(1.0);
(1.0 - total / px, diff)
}
#[async_trait]
impl Tool for ImageCompare {
fn name(&self) -> &str {
"image_compare"
}
fn description(&self) -> String {
"Compare two images (e.g. the original's and the build's snapshot at the same width). \
Returns a similarity score (0-100 %) and a diff image where differences are red."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"original": {"type": "string", "description": "Path of the reference image"},
"candidate": {"type": "string", "description": "Path of the image to check"}
},
"required": ["original", "candidate"]
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let pa: PathBuf = ctx.resolve(arg_str(&input, "original")?)?;
let pb: PathBuf = ctx.resolve(arg_str(&input, "candidate")?)?;
let (score, diff) = tokio::task::spawn_blocking(move || -> Result<_> {
let a = image::open(&pa).with_context(|| format!("cannot open {}", pa.display()))?;
let b = image::open(&pb).with_context(|| format!("cannot open {}", pb.display()))?;
Ok(compare(&a, &b))
})
.await??;
let diff_path = ctx.cwd.join(".craft/snapshots").join(format!(
"diff-{}.png",
Path::new(input["candidate"].as_str().unwrap_or("x")).file_stem().and_then(|s| s.to_str()).unwrap_or("x")
));
if let Some(d) = diff_path.parent() {
tokio::fs::create_dir_all(d).await?;
}
diff.save(&diff_path)?;
let img = image::DynamicImage::ImageRgba8(diff);
let pct = (score * 1000.0).round() / 10.0;
Ok(ToolOutput {
content: format!("Similarity: {pct} %\nDiff image: {}", diff_path.display()),
images: vec![png_for_model(&img)?],
meta: Some(json!({"similarity": pct})),
..Default::default()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_images_are_fully_similar() {
let img = image::DynamicImage::ImageRgba8(RgbaImage::from_pixel(10, 10, Rgba([10, 20, 30, 255])));
let (s, _) = compare(&img, &img);
assert!((s - 1.0).abs() < 1e-9);
let other = image::DynamicImage::ImageRgba8(RgbaImage::from_pixel(10, 10, Rgba([255, 255, 255, 255])));
let (s2, _) = compare(&img, &other);
assert!(s2 < 0.2);
}
}
+477
View File
@@ -0,0 +1,477 @@
//! File system tools: read, write, edit, list, glob, grep.
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use crate::types::ImageData;
use anyhow::{Result, bail};
use async_trait::async_trait;
use base64::Engine;
use serde_json::{Value, json};
use std::path::Path;
const MAX_LINES: usize = 2000;
const MAX_LINE_LEN: usize = 2000;
const MAX_OUTPUT: usize = 60_000;
fn image_type(path: &Path) -> Option<&'static str> {
match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
pub fn unified_diff(path: &str, old: &str, new: &str) -> String {
similar::TextDiff::from_lines(old, new)
.unified_diff()
.context_radius(3)
.header(&format!("a/{path}"), &format!("b/{path}"))
.to_string()
}
fn rel(ctx: &ToolCtx, p: &Path) -> String {
p.strip_prefix(&ctx.cwd).unwrap_or(p).display().to_string()
}
// ---------------------------------------------------------------------------
pub struct Read;
#[async_trait]
impl Tool for Read {
fn name(&self) -> &str {
"read"
}
fn description(&self) -> String {
"Read a file. Returns numbered lines (cat -n style). Use offset/limit for large files \
(default: first 2000 lines). Images (png, jpg, gif, webp) are returned as images. \
Always read a file before editing or overwriting it."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, absolute or relative to the working directory"},
"offset": {"type": "integer", "description": "1-based line to start from"},
"limit": {"type": "integer", "description": "Number of lines to read"}
},
"required": ["path"]
})
}
fn read_only(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Read {}", input["path"].as_str().unwrap_or("?"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let path = ctx.resolve(arg_str(&input, "path")?)?;
if path.is_dir() {
bail!("{} is a directory – use the list tool", path.display());
}
let bytes = tokio::fs::read(&path).await?;
ctx.app.mark_read(&ctx.session_id, &path);
if let Some(mt) = image_type(&path) {
return Ok(ToolOutput {
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
images: vec![ImageData {
media_type: mt.into(),
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
}],
..Default::default()
});
}
if bytes.iter().take(8000).any(|b| *b == 0) {
return Ok(ToolOutput::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len())));
}
let text = String::from_utf8_lossy(&bytes);
if text.is_empty() {
return Ok(ToolOutput::text("(file is empty)"));
}
let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize;
let limit = input["limit"].as_u64().map(|l| l as usize).unwrap_or(MAX_LINES);
let total = text.lines().count();
let mut out = String::new();
for (i, line) in text.lines().enumerate().skip(offset - 1).take(limit) {
let line = if line.len() > MAX_LINE_LEN {
let mut cut = MAX_LINE_LEN;
while !line.is_char_boundary(cut) {
cut -= 1;
}
format!("{}…", &line[..cut])
} else {
line.to_string()
};
out.push_str(&format!("{:>6}\t{}\n", i + 1, line));
}
let shown_end = (offset - 1 + limit).min(total);
if shown_end < total {
out.push_str(&format!("\n[showing lines {offset}-{shown_end} of {total}; use offset to read more]\n"));
}
Ok(ToolOutput::text(truncate(&out, MAX_OUTPUT)))
}
}
// ---------------------------------------------------------------------------
pub struct Write;
#[async_trait]
impl Tool for Write {
fn name(&self) -> &str {
"write"
}
fn description(&self) -> String {
"Create or overwrite a file with the given content. Existing files must be read first. \
Prefer the edit tool for changes to existing files."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"}
},
"required": ["path", "content"]
})
}
fn is_edit(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Write {}", input["path"].as_str().unwrap_or("?"))
}
async fn preview(&self, input: &Value, ctx: &ToolCtx) -> Option<String> {
let path = ctx.resolve(input["path"].as_str()?).ok()?;
let old = tokio::fs::read_to_string(&path).await.unwrap_or_default();
Some(unified_diff(&rel(ctx, &path), &old, input["content"].as_str()?))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let path = ctx.resolve(arg_str(&input, "path")?)?;
let content = arg_str(&input, "content")?;
let old = if path.exists() {
if !ctx.app.was_read(&ctx.session_id, &path) {
return Ok(ToolOutput::error("File exists but has not been read in this session. Read it first."));
}
tokio::fs::read_to_string(&path).await.unwrap_or_default()
} else {
String::new()
};
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await?;
}
tokio::fs::write(&path, content).await?;
ctx.app.mark_read(&ctx.session_id, &path);
let diff = unified_diff(&rel(ctx, &path), &old, content);
Ok(ToolOutput::text(format!("Wrote {} ({} lines)", path.display(), content.lines().count()))
.with_meta(json!({"diff": diff, "path": path})))
}
}
// ---------------------------------------------------------------------------
pub struct Edit;
#[async_trait]
impl Tool for Edit {
fn name(&self) -> &str {
"edit"
}
fn description(&self) -> String {
"Replace text in a file. old_string must match the file exactly (including indentation) \
and be unique unless replace_all is true. The file must have been read first."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"old_string": {"type": "string", "description": "Exact text to replace"},
"new_string": {"type": "string", "description": "Replacement text"},
"replace_all": {"type": "boolean", "description": "Replace every occurrence"}
},
"required": ["path", "old_string", "new_string"]
})
}
fn is_edit(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Edit {}", input["path"].as_str().unwrap_or("?"))
}
async fn preview(&self, input: &Value, ctx: &ToolCtx) -> Option<String> {
let path = ctx.resolve(input["path"].as_str()?).ok()?;
let old = tokio::fs::read_to_string(&path).await.ok()?;
let new = apply_edit(&old, input).ok()?;
Some(unified_diff(&rel(ctx, &path), &old, &new))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let path = ctx.resolve(arg_str(&input, "path")?)?;
if !ctx.app.was_read(&ctx.session_id, &path) {
return Ok(ToolOutput::error("File has not been read in this session. Read it first."));
}
let old = tokio::fs::read_to_string(&path).await?;
let new = match apply_edit(&old, &input) {
Ok(n) => n,
Err(e) => return Ok(ToolOutput::error(e.to_string())),
};
tokio::fs::write(&path, &new).await?;
let diff = unified_diff(&rel(ctx, &path), &old, &new);
Ok(ToolOutput::text(format!("Edited {}", path.display()))
.with_meta(json!({"diff": diff, "path": path})))
}
}
fn apply_edit(old: &str, input: &Value) -> Result<String> {
let from = arg_str(input, "old_string")?;
let to = arg_str(input, "new_string")?;
if from.is_empty() {
bail!("old_string must not be empty (use write to create files)");
}
if from == to {
bail!("old_string and new_string are identical");
}
let count = old.matches(from).count();
let all = input["replace_all"].as_bool().unwrap_or(false);
match count {
0 => bail!("old_string not found in file. Re-read the file and match it exactly."),
1 => Ok(old.replacen(from, to, 1)),
n if all => {
let _ = n;
Ok(old.replace(from, to))
}
n => bail!("old_string occurs {n} times. Add surrounding context to make it unique or set replace_all."),
}
}
// ---------------------------------------------------------------------------
pub struct List;
#[async_trait]
impl Tool for List {
fn name(&self) -> &str {
"list"
}
fn description(&self) -> String {
"List a directory (respects .gitignore). Directories end with '/'.".into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory, default: working directory"},
"depth": {"type": "integer", "description": "Recursion depth (default 1, max 5)"}
}
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let dir = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let depth = input["depth"].as_u64().unwrap_or(1).clamp(1, 5) as usize;
let cwd = ctx.cwd.clone();
let out = tokio::task::spawn_blocking(move || {
let mut lines = Vec::new();
for e in ignore::WalkBuilder::new(&dir).max_depth(Some(depth)).hidden(false).build().flatten() {
if e.depth() == 0 || e.path().components().any(|c| c.as_os_str() == ".git") {
continue;
}
let p = e.path().strip_prefix(&dir).unwrap_or(e.path());
let is_dir = e.file_type().is_some_and(|t| t.is_dir());
lines.push(format!("{}{}", p.display(), if is_dir { "/" } else { "" }));
if lines.len() >= 1000 {
lines.push("[... truncated at 1000 entries ...]".into());
break;
}
}
lines.sort();
let _ = cwd;
lines.join("\n")
})
.await?;
Ok(ToolOutput::text(if out.is_empty() { "(empty directory)".into() } else { out }))
}
}
// ---------------------------------------------------------------------------
pub struct Glob;
#[async_trait]
impl Tool for Glob {
fn name(&self) -> &str {
"glob"
}
fn description(&self) -> String {
"Find files by glob pattern (e.g. \"**/*.rs\", \"src/**/test_*.py\"). Respects .gitignore. \
Results are sorted by modification time, newest first."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {"type": "string"},
"path": {"type": "string", "description": "Directory to search, default: working directory"}
},
"required": ["pattern"]
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let pattern = arg_str(&input, "pattern")?.to_string();
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let out = tokio::task::spawn_blocking(move || -> Result<String> {
let matcher = globset::GlobBuilder::new(&pattern).literal_separator(true).build()?.compile_matcher();
let mut hits = Vec::new();
for e in ignore::WalkBuilder::new(&root).hidden(false).build().flatten() {
if !e.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let relp = e.path().strip_prefix(&root).unwrap_or(e.path());
if relp.components().any(|c| c.as_os_str() == ".git") {
continue;
}
if matcher.is_match(relp) {
let mtime = e.metadata().ok().and_then(|m| m.modified().ok());
hits.push((mtime, e.path().display().to_string()));
}
}
hits.sort_by(|a, b| b.0.cmp(&a.0));
let n = hits.len();
let mut s: Vec<String> = hits.into_iter().take(500).map(|h| h.1).collect();
if n > 500 {
s.push(format!("[... {} more files ...]", n - 500));
}
Ok(if s.is_empty() { "No files found".into() } else { s.join("\n") })
})
.await??;
Ok(ToolOutput::text(out))
}
}
// ---------------------------------------------------------------------------
pub struct Grep;
#[async_trait]
impl Tool for Grep {
fn name(&self) -> &str {
"grep"
}
fn description(&self) -> String {
"Search file contents with a regular expression (Rust regex syntax). Respects .gitignore. \
output_mode: \"files\" (default, matching file paths), \"content\" (matching lines with \
line numbers), \"count\"."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {"type": "string"},
"path": {"type": "string", "description": "File or directory, default: working directory"},
"glob": {"type": "string", "description": "Only search files matching this glob, e.g. \"*.ts\""},
"output_mode": {"type": "string", "enum": ["files", "content", "count"]},
"case_insensitive": {"type": "boolean"},
"context": {"type": "integer", "description": "Lines of context around matches (content mode)"}
},
"required": ["pattern"]
})
}
fn read_only(&self) -> bool {
true
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let re = regex::RegexBuilder::new(arg_str(&input, "pattern")?)
.case_insensitive(input["case_insensitive"].as_bool().unwrap_or(false))
.build()?;
let root = ctx.resolve(input["path"].as_str().unwrap_or("."))?;
let glob = input["glob"]
.as_str()
.map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
.transpose()?;
let mode = input["output_mode"].as_str().unwrap_or("files").to_string();
let context = input["context"].as_u64().unwrap_or(0) as usize;
let cancel = ctx.cancel.clone();
let out = tokio::task::spawn_blocking(move || {
let mut out = String::new();
let mut files = 0usize;
for e in ignore::WalkBuilder::new(&root).hidden(false).build().flatten() {
if cancel.is_cancelled() || out.len() > MAX_OUTPUT {
break;
}
if !e.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let p = e.path();
if p.components().any(|c| c.as_os_str() == ".git") {
continue;
}
if let Some(g) = &glob {
let name = p.file_name().map(Path::new).unwrap_or(p);
if !g.is_match(name) && !g.is_match(p.strip_prefix(&root).unwrap_or(p)) {
continue;
}
}
let Ok(bytes) = std::fs::read(p) else { continue };
if bytes.iter().take(8000).any(|b| *b == 0) {
continue;
}
let text = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = text.lines().collect();
let hits: Vec<usize> = lines.iter().enumerate().filter(|(_, l)| re.is_match(l)).map(|(i, _)| i).collect();
if hits.is_empty() {
continue;
}
files += 1;
match mode.as_str() {
"count" => out.push_str(&format!("{}:{}\n", p.display(), hits.len())),
"content" => {
let mut last_printed: Option<usize> = None;
for &h in &hits {
let start = h.saturating_sub(context);
let end = (h + context).min(lines.len().saturating_sub(1));
for i in start..=end {
if last_printed.is_some_and(|l| i <= l) {
continue;
}
let sep = if i == h { ':' } else { '-' };
let line: String = lines[i].chars().take(MAX_LINE_LEN).collect();
out.push_str(&format!("{}{sep}{}{sep}{}\n", p.display(), i + 1, line));
last_printed = Some(i);
}
}
}
_ => out.push_str(&format!("{}\n", p.display())),
}
}
if files == 0 { "No matches found".to_string() } else { out }
})
.await?;
Ok(ToolOutput::text(truncate(&out, MAX_OUTPUT)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edit_requires_unique_match() {
let input = json!({"old_string": "a", "new_string": "b"});
assert!(apply_edit("a a", &input).is_err());
assert_eq!(apply_edit("a c", &input).unwrap(), "b c");
let all = json!({"old_string": "a", "new_string": "b", "replace_all": true});
assert_eq!(apply_edit("a a", &all).unwrap(), "b b");
assert!(apply_edit("xyz", &input).is_err());
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Built-in tools. Each tool declares a JSON schema, whether it is read-only
//! (allowed in plan mode) and how to run it.
pub mod agent;
pub mod bash;
pub mod craft;
pub mod fs;
pub mod web;
use crate::app::App;
use crate::provider::ToolDef;
use crate::types::ImageData;
use anyhow::{Result, bail};
use async_trait::async_trait;
use serde_json::Value;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Default)]
pub struct ToolOutput {
pub content: String,
pub images: Vec<ImageData>,
pub meta: Option<Value>,
pub is_error: bool,
}
impl ToolOutput {
pub fn text(s: impl Into<String>) -> Self {
Self { content: s.into(), ..Default::default() }
}
pub fn error(s: impl Into<String>) -> Self {
Self { content: s.into(), is_error: true, ..Default::default() }
}
pub fn with_meta(mut self, meta: Value) -> Self {
self.meta = Some(meta);
self
}
}
pub struct ToolCtx {
pub app: Arc<App>,
pub session_id: String,
pub call_id: String,
pub cwd: PathBuf,
pub cancel: CancellationToken,
/// Agent mode running this tool (subagents inherit restrictions).
pub agent: String,
}
impl ToolCtx {
/// Resolve a user/model supplied path against the session cwd and make sure
/// it stays inside the workspace.
pub fn resolve(&self, p: &str) -> Result<PathBuf> {
let p = p.trim();
let raw = if Path::new(p).is_absolute() { PathBuf::from(p) } else { self.cwd.join(p) };
let norm = normalize(&raw);
let ws = normalize(&self.app.workspace());
if !norm.starts_with(&ws) && !norm.starts_with("/tmp") {
bail!("path {} is outside the workspace {}", norm.display(), ws.display());
}
Ok(norm)
}
pub fn progress(&self, output: impl Into<String>) {
self.app.emit(crate::types::Event::ToolProgress {
session_id: self.session_id.clone(),
call_id: self.call_id.clone(),
output: output.into(),
});
}
}
/// Lexical normalization (no symlink resolution, works for non-existing files).
pub fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> String;
fn schema(&self) -> Value;
/// Read-only tools are available in plan mode and never modify state.
fn read_only(&self) -> bool {
false
}
/// Category used by the "accept_edits" permission mode.
fn is_edit(&self) -> bool {
false
}
/// Short human title for permission prompts.
fn title(&self, input: &Value) -> String {
let _ = input;
self.name().to_string()
}
/// Optional unified diff shown in permission prompts.
async fn preview(&self, _input: &Value, _ctx: &ToolCtx) -> Option<String> {
None
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
fn def(&self) -> ToolDef {
ToolDef { name: self.name().into(), description: self.description(), input_schema: self.schema() }
}
}
pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
vec![
Arc::new(fs::Read),
Arc::new(fs::Write),
Arc::new(fs::Edit),
Arc::new(fs::List),
Arc::new(fs::Glob),
Arc::new(fs::Grep),
Arc::new(bash::Bash),
Arc::new(web::WebFetch),
Arc::new(agent::TodoWrite),
Arc::new(agent::AskUser),
Arc::new(agent::Task),
Arc::new(craft::BrowserSnapshot),
Arc::new(craft::ImageCompare),
Arc::new(crate::prompts::SkillTool),
]
}
/// Helper for reading typed arguments.
pub fn arg_str<'a>(input: &'a Value, key: &str) -> Result<&'a str> {
input
.get(key)
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required string argument '{key}'"))
}
pub fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut cut = max;
while !s.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... output truncated: {} of {} bytes shown ...]", &s[..cut], cut, s.len())
}
+75
View File
@@ -0,0 +1,75 @@
//! Fetch a URL and return readable text.
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::{Result, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::time::Duration;
const MAX_BYTES: usize = 5 * 1024 * 1024;
const MAX_OUTPUT: usize = 80_000;
pub struct WebFetch;
#[async_trait]
impl Tool for WebFetch {
fn name(&self) -> &str {
"web_fetch"
}
fn description(&self) -> String {
"Fetch a URL (http/https). HTML is converted to readable text; JSON and plain text are \
returned as-is. Set raw=true to get the unconverted body (e.g. HTML source)."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"url": {"type": "string"},
"raw": {"type": "boolean"}
},
"required": ["url"]
})
}
fn read_only(&self) -> bool {
true
}
fn title(&self, input: &Value) -> String {
format!("Fetch {}", input["url"].as_str().unwrap_or("?"))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let url = arg_str(&input, "url")?;
if !(url.starts_with("http://") || url.starts_with("https://")) {
bail!("only http(s) URLs are supported");
}
let raw = input["raw"].as_bool().unwrap_or(false);
if raw && ctx.agent == "craft" && ctx.app.config().craft.clean_room {
return Ok(ToolOutput::error(
"Clean room mode: page source may not be read. Use browser_snapshot, or switch \
craft.clean_room to false (deep mode) in the settings.",
));
}
let resp = crate::provider::http_client()
.get(url)
.timeout(Duration::from_secs(30))
.send()
.await?;
let status = resp.status();
let ctype = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = resp.bytes().await?;
let bytes = &bytes[..bytes.len().min(MAX_BYTES)];
let body = String::from_utf8_lossy(bytes);
let text = if !raw && ctype.contains("html") {
html2text::from_read(body.as_bytes(), 120).unwrap_or_else(|_| body.to_string())
} else {
body.to_string()
};
let out = format!("HTTP {status} ({ctype})\n\n{}", truncate(&text, MAX_OUTPUT));
Ok(if status.is_success() { ToolOutput::text(out) } else { ToolOutput::error(out) })
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Provider-neutral message model and the event stream sent to clients.
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImageData {
pub media_type: String,
/// Base64-encoded bytes.
pub data: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Part {
Text {
text: String,
},
Image {
image: ImageData,
},
Reasoning {
text: String,
/// Provider-specific signature needed to replay thinking blocks (Anthropic).
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolCall {
id: String,
name: String,
input: Value,
/// Opaque provider data that must be echoed back (e.g. Gemini thought signatures).
#[serde(default, skip_serializing_if = "Option::is_none")]
extra: Option<Value>,
},
ToolResult {
call_id: String,
/// Tool name, kept for providers that need it (Gemini via OpenAI compat) and the UI.
#[serde(default)]
name: String,
content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageData>,
#[serde(default)]
is_error: bool,
/// Structured data for the UI (diffs, todo lists, ...). Never sent to the model.
#[serde(default, skip_serializing_if = "Option::is_none")]
meta: Option<Value>,
},
/// Marks the start of a compacted history; the text is the summary.
Summary {
text: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: String,
pub role: Role,
pub parts: Vec<Part>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
pub created_at: i64,
}
impl Message {
pub fn new(role: Role, parts: Vec<Part>) -> Self {
Self {
id: new_id(),
role,
parts,
model: None,
usage: None,
created_at: now_ms(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default)]
pub cache_read_tokens: u64,
#[serde(default)]
pub cache_write_tokens: u64,
}
impl Usage {
/// Total tokens of context the last request consumed.
pub fn context_tokens(&self) -> u64 {
self.input_tokens + self.cache_read_tokens + self.cache_write_tokens + self.output_tokens
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub title: String,
pub cwd: String,
/// "provider/model"
pub model: String,
/// Active agent mode (build, plan, craft, ...).
pub agent: String,
/// Permission mode: default | accept_edits | bypass
pub permission_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
#[serde(default)]
pub todos: Value,
#[serde(default)]
pub last_usage: Option<Usage>,
}
/// Question forwarded from an agent (or an MCP server) to the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Question {
pub question: String,
#[serde(default)]
pub header: String,
#[serde(default)]
pub options: Vec<QuestionOption>,
#[serde(default)]
pub multi_select: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(default)]
pub description: String,
}
/// Events broadcast to all connected clients. Clients filter by session id.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
SessionUpdated { session: Session },
SessionDeleted { session_id: String },
SessionStatus { session_id: String, busy: bool },
MessageAdded { session_id: String, message: Message },
/// Streaming deltas for the assistant message currently being generated.
TextDelta { session_id: String, message_id: String, text: String },
ReasoningDelta { session_id: String, message_id: String, text: String },
ToolStarted { session_id: String, message_id: String, call_id: String, name: String, input: Value },
ToolProgress { session_id: String, call_id: String, output: String },
PermissionRequest { id: String, session_id: String, root_session_id: String, tool: String, title: String, input: Value, #[serde(skip_serializing_if = "Option::is_none")] diff: Option<String> },
PermissionResolved { id: String, session_id: String },
QuestionRequest { id: String, session_id: String, root_session_id: String, source: String, questions: Vec<Question> },
QuestionResolved { id: String, session_id: String },
Error { session_id: String, message: String },
McpStatus { servers: Value },
}
pub fn new_id() -> String {
uuid::Uuid::now_v7().simple().to_string()
}
pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}