pub mod ask;
pub mod builtin;
pub mod recall;
pub mod skill;
pub mod todo;
use crate::config::{PermissionMode, SecurityConfig, ToolsConfig};
use crate::message::ToolSpec;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct ToolOutput {
pub content: String,
pub is_error: bool,
pub external: bool,
pub refusal: bool,
}
impl ToolOutput {
pub fn ok(content: impl Into<String>) -> Self {
ToolOutput {
content: content.into(),
is_error: false,
external: false,
refusal: false,
}
}
pub fn err(content: impl Into<String>) -> Self {
ToolOutput {
content: content.into(),
is_error: true,
external: false,
refusal: false,
}
}
pub fn refusal(content: impl Into<String>) -> Self {
ToolOutput {
content: content.into(),
is_error: true,
external: false,
refusal: true,
}
}
pub fn from_outside(mut self) -> Self {
self.external = true;
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Capabilities {
pub private_data: bool,
pub untrusted_input: bool,
pub external_send: bool,
pub destructive: bool,
}
impl Capabilities {
pub fn private(mut self) -> Self {
self.private_data = true;
self
}
pub fn untrusted(mut self) -> Self {
self.untrusted_input = true;
self
}
pub fn sends(mut self) -> Self {
self.external_send = true;
self
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
pub fn union(self, other: Capabilities) -> Self {
Capabilities {
private_data: self.private_data || other.private_data,
untrusted_input: self.untrusted_input || other.untrusted_input,
external_send: self.external_send || other.external_send,
destructive: self.destructive || other.destructive,
}
}
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> Value;
fn read_only(&self) -> bool {
false
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
fn carried_state(&self, ctx: &ToolCtx) -> Option<CarriedState> {
let _ = ctx;
None
}
fn denial_remedy(&self) -> Option<String> {
None
}
fn fixed_workspace(&self) -> Option<PathBuf> {
None
}
fn narrows_surface_to(&self) -> Option<Vec<String>> {
None
}
fn runs_a_fresh_conversation(&self) -> bool {
false
}
fn forget_conversation_state(&self) {}
fn guards_closures(&self) -> bool {
false
}
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name().to_string(),
description: self.description().to_string(),
input_schema: self.input_schema(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CarriedState {
pub label: String,
pub body: String,
}
#[derive(Debug, Clone)]
pub struct ToolCtx {
pub workspace: PathBuf,
pub shell_timeout: std::time::Duration,
pub security: SecurityConfig,
pub output_budget_bytes: usize,
pub spill_dir: Option<PathBuf>,
pub events: Option<tokio::sync::mpsc::UnboundedSender<crate::agent::AgentEvent>>,
pub cancel: Option<tokio_util::sync::CancellationToken>,
pub phase: crate::agent::Phase,
pub withheld: std::sync::Arc<[String]>,
pub call_id: Option<String>,
pub taint: Option<crate::agent::Taint>,
pub context: Option<crate::pressure::Forecast>,
pub work: Option<crate::step::Work>,
pub compact_requested: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
pub step_escalation:
Option<std::sync::Arc<std::sync::Mutex<Option<crate::step::StepEscalation>>>>,
}
impl Default for ToolCtx {
fn default() -> Self {
ToolCtx {
workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
shell_timeout: std::time::Duration::from_secs(120),
security: SecurityConfig::default(),
output_budget_bytes: 24_000,
spill_dir: fresh_spill_dir(),
events: None,
cancel: None,
phase: crate::agent::Phase::default(),
withheld: std::sync::Arc::from(Vec::new()),
call_id: None,
taint: None,
context: None,
work: None,
compact_requested: None,
step_escalation: None,
}
}
}
fn fresh_spill_dir() -> Option<PathBuf> {
Some(std::env::temp_dir().join(format!("mecha-spill-{}", uuid::Uuid::new_v4())))
}
impl ToolCtx {
pub fn with_workspace(&self, workspace: impl Into<PathBuf>) -> Self {
ToolCtx {
workspace: workspace.into(),
spill_dir: fresh_spill_dir(),
..self.clone()
}
}
pub fn resolve(&self, raw: &str) -> Result<PathBuf> {
let candidate = {
let p = Path::new(raw);
if p.is_absolute() {
p.to_path_buf()
} else {
self.workspace.join(p)
}
};
let mut existing = candidate.as_path();
let mut trailing = Vec::new();
let canonical_root = loop {
match existing.canonicalize() {
Ok(c) => break c,
Err(_) => match existing.parent() {
Some(parent) => {
if let Some(name) = existing.file_name() {
trailing.push(name.to_owned());
}
existing = parent;
}
None => anyhow::bail!("cannot resolve path {raw:?}"),
},
}
};
let mut resolved = canonical_root;
for part in trailing.iter().rev() {
resolved.push(part);
}
let root = self
.workspace
.canonicalize()
.unwrap_or_else(|_| self.workspace.clone());
if resolved.starts_with(&root) {
return Ok(resolved);
}
if let Some(spill) = &self.spill_dir {
let spill_root = spill.canonicalize().unwrap_or_else(|_| spill.clone());
if resolved.starts_with(&spill_root) {
return Ok(resolved);
}
}
anyhow::bail!(
"path {raw:?} resolves outside the workspace ({})",
root.display()
)
}
}
pub const SPILL_FLOOR_BYTES: usize = 4_096;
pub fn cap_result(
content: String,
cap: usize,
spill_dir: Option<&Path>,
tool: &str,
id: &str,
) -> String {
if content.len() <= cap {
return content;
}
let mut cut = cap;
while cut > 0 && !content.is_char_boundary(cut) {
cut -= 1;
}
let head = &content[..cut];
let line = head.matches('\n').count() + 1;
let total = content.len();
let saved = spill_dir.and_then(|dir| {
crate::create_private_dir(dir).ok()?;
let tag = &uuid::Uuid::new_v4().to_string()[..8];
let file = dir.join(format!("{}-{}-{tag}.txt", safe_name(tool), safe_name(id)));
std::fs::write(&file, &content).ok()?;
Some(file)
});
match saved {
Some(path) => format!(
"{head}\n\n[truncated by the harness: showing the first {cut} of {total} bytes; \
the rest begins on line {line}. The full output is saved at {path} — continue \
with fs_read {{\"path\": \"{path}\", \"offset\": {line}}}, or search it with \
grep.]",
path = path.display()
),
None => format!(
"{head}\n\n[truncated by the harness: {omitted} of {total} bytes were dropped \
from line {line} on, and the full output could not be saved. Narrow the \
request and re-run the tool if the rest is needed.]",
omitted = total - cut
),
}
}
fn safe_name(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
#[derive(Debug, Clone)]
pub enum Decision {
Allow,
Deny(String),
Blocked(String),
}
#[async_trait]
pub trait Approver: Send + Sync {
async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision;
}
pub struct ModeApprover {
pub mode: PermissionMode,
}
#[async_trait]
impl Approver for ModeApprover {
async fn approve(&self, tool: &dyn Tool, _input: &Value) -> Decision {
match self.mode {
PermissionMode::Allow => Decision::Allow,
PermissionMode::ReadOnly if tool.read_only() => Decision::Allow,
PermissionMode::ReadOnly => Decision::Blocked(format!(
"`{}` modifies state and this run is read-only",
tool.name()
)),
PermissionMode::Ask => Decision::Blocked(format!(
"`{}` needs approval and this run is non-interactive (use --yes to allow)",
tool.name()
)),
}
}
}
#[derive(Default)]
pub struct Registry {
tools: BTreeMap<String, Arc<dyn Tool>>,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, tool: Arc<dyn Tool>) {
self.tools.insert(tool.name().to_string(), tool);
}
pub fn remove(&mut self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.remove(name)
}
pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
self.tools.get(name)
}
pub fn available(&self, name: &str) -> Option<&Arc<dyn Tool>> {
let tool = self.tools.get(name)?;
match self.surface_restriction() {
Some(allowed) if !allowed.contains(name) => None,
_ => Some(tool),
}
}
pub fn available_names(&self) -> Vec<&str> {
let restriction = self.surface_restriction();
self.tools
.values()
.map(|t| t.name())
.filter(|n| {
restriction
.as_ref()
.is_none_or(|allowed| allowed.contains(*n))
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
pub fn len(&self) -> usize {
self.tools.len()
}
pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn Tool>> {
self.tools.values()
}
pub fn carried_state(&self, ctx: &ToolCtx) -> Vec<CarriedState> {
self.tools
.values()
.filter_map(|t| t.carried_state(ctx))
.collect()
}
pub fn specs(&self) -> Vec<ToolSpec> {
self.tools.values().map(|t| t.spec()).collect()
}
pub fn specs_for(&self, phase: crate::agent::Phase) -> Vec<ToolSpec> {
let restriction = self.surface_restriction();
self.tools
.values()
.filter(|t| phase.allows(t.read_only()))
.filter(|t| {
restriction
.as_ref()
.is_none_or(|allowed| allowed.contains(t.name()))
})
.map(|t| t.spec())
.collect()
}
pub fn forget_conversation_state(&self) {
for tool in self.tools.values() {
tool.forget_conversation_state();
}
}
pub fn surface_restriction(&self) -> Option<BTreeSet<String>> {
let mut allowed: Option<BTreeSet<String>> = None;
for tool in self.tools.values() {
let Some(names) = tool.narrows_surface_to() else {
continue;
};
let set = allowed.get_or_insert_with(BTreeSet::new);
set.extend(names);
set.insert(tool.name().to_string());
}
allowed
}
pub fn with_builtins(
mut self,
cfg: &ToolsConfig,
sandbox: Arc<crate::sandbox::Sandbox>,
) -> Self {
for tool in builtin::all(sandbox) {
let name = tool.name();
let allowed = cfg.enabled.is_empty() || cfg.enabled.iter().any(|e| e == name);
let blocked = cfg.disabled.iter().any(|d| d == name);
if allowed && !blocked {
self.insert(tool);
}
}
self
}
}
#[cfg(test)]
mod cap_tests {
use super::*;
use serde_json::json;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mecha-cap-{name}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_result_under_the_cap_is_untouched() {
let out = cap_result("short".into(), 100, None, "shell", "t1");
assert_eq!(out, "short");
}
#[test]
fn an_oversized_result_is_spilled_whole_and_the_marker_names_the_recovery() {
let dir = scratch("spill");
let body: String = (1..=100).map(|i| format!("line {i}\n")).collect();
let out = cap_result(body.clone(), 200, Some(&dir), "shell", "t1");
assert!(out.len() < body.len());
assert!(out.starts_with("line 1\n"));
let file = std::fs::read_dir(&dir)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
assert!(file
.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("shell-t1-"));
assert_eq!(std::fs::read_to_string(&file).unwrap(), body);
let line = body[..200].matches('\n').count() + 1;
assert!(out.contains(&file.display().to_string()), "{out}");
assert!(out.contains(&format!("\"offset\": {line}")), "{out}");
assert!(out.contains("fs_read"), "the recovery must be named: {out}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_failed_spill_degrades_to_a_cut_that_admits_the_loss() {
let impossible = PathBuf::from("/dev/null/not-a-dir");
let body = "x".repeat(1000);
let out = cap_result(body, 100, Some(&impossible), "shell", "t1");
assert!(out.contains("could not be saved"), "{out}");
assert!(
out.contains("re-run the tool"),
"the fallback still names a recovery: {out}"
);
assert!(
!out.contains("/dev/null"),
"no path is promised that does not exist"
);
}
#[test]
fn the_cut_lands_on_a_char_boundary() {
let body = "é".repeat(100); let out = cap_result(body, 33, None, "shell", "t1");
assert!(out.starts_with(&"é".repeat(16)));
}
#[test]
fn the_jail_admits_the_spill_directory_and_nothing_else_new() {
let workspace = scratch("ws");
let spill = scratch("spilldir");
let ctx = ToolCtx {
workspace: workspace.clone(),
spill_dir: Some(spill.clone()),
..ToolCtx::default()
};
std::fs::write(spill.join("shell-t1.txt"), "spilled").unwrap();
let resolved = ctx
.resolve(&spill.join("shell-t1.txt").display().to_string())
.unwrap();
assert!(resolved.ends_with("shell-t1.txt"));
let elsewhere = std::env::temp_dir().join("mecha-cap-elsewhere.txt");
std::fs::write(&elsewhere, "no").unwrap();
assert!(ctx.resolve(&elsewhere.display().to_string()).is_err());
let no_spill = ToolCtx {
workspace,
spill_dir: None,
..ToolCtx::default()
};
assert!(no_spill
.resolve(&spill.join("shell-t1.txt").display().to_string())
.is_err());
std::fs::remove_dir_all(&spill).ok();
std::fs::remove_file(&elsewhere).ok();
}
#[test]
fn a_rerooted_context_gets_its_own_spill_directory() {
let ctx = ToolCtx::default();
let rerooted = ctx.with_workspace(std::env::temp_dir());
assert_ne!(ctx.spill_dir, rerooted.spill_dir);
}
struct Narrowing(&'static str, Option<Vec<String>>);
#[async_trait]
impl Tool for Narrowing {
fn name(&self) -> &str {
self.0
}
fn description(&self) -> &str {
"test"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn read_only(&self) -> bool {
true
}
fn narrows_surface_to(&self) -> Option<Vec<String>> {
self.1.clone()
}
async fn call(&self, _i: Value, _c: &ToolCtx) -> Result<ToolOutput> {
Ok(ToolOutput::ok(""))
}
}
fn registry_with(tools: Vec<Arc<dyn Tool>>) -> Registry {
let mut r = Registry::new();
for t in tools {
r.insert(t);
}
r
}
#[test]
fn nothing_narrows_until_something_says_so() {
let r = registry_with(vec![
Arc::new(Narrowing("a", None)),
Arc::new(Narrowing("b", None)),
]);
assert!(r.surface_restriction().is_none());
assert_eq!(r.specs_for(crate::agent::Phase::Execute).len(), 2);
}
#[test]
fn a_restriction_can_never_widen_the_surface() {
let r = registry_with(vec![
Arc::new(Narrowing("a", None)),
Arc::new(Narrowing("b", None)),
Arc::new(Narrowing(
"gate",
Some(vec!["a".into(), "not_registered".into()]),
)),
]);
let names: Vec<String> = r
.specs_for(crate::agent::Phase::Execute)
.into_iter()
.map(|s| s.name)
.collect();
assert!(names.contains(&"a".to_string()));
assert!(!names.contains(&"b".to_string()), "b was narrowed away");
assert!(
!names.iter().any(|n| n == "not_registered"),
"a name nothing matches adds nothing: {names:?}"
);
assert!(
names.contains(&"gate".to_string()),
"the tool doing the narrowing stays reachable, or it eats its own mechanism"
);
}
#[test]
fn a_narrowed_tool_is_out_of_reach_for_dispatch_and_not_merely_unlisted() {
let r = registry_with(vec![
Arc::new(Narrowing("a", None)),
Arc::new(Narrowing("b", None)),
Arc::new(Narrowing("gate", Some(vec!["a".into()]))),
]);
assert!(r.available("a").is_some());
assert!(r.available("b").is_none(), "narrowed away, so unreachable");
assert!(
r.get("b").is_some(),
"still registered — `get` is a lookup, `available` is the gate"
);
assert!(!r.available_names().contains(&"b"));
}
#[test]
fn two_restrictions_union_rather_than_intersect() {
let r = registry_with(vec![
Arc::new(Narrowing("a", None)),
Arc::new(Narrowing("b", None)),
Arc::new(Narrowing("c", None)),
Arc::new(Narrowing("g1", Some(vec!["a".into()]))),
Arc::new(Narrowing("g2", Some(vec!["b".into()]))),
]);
let allowed = r.surface_restriction().unwrap();
assert!(allowed.contains("a") && allowed.contains("b"));
assert!(!allowed.contains("c"), "still a subset: {allowed:?}");
}
}