Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,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})))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user