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

+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) })
}
}