//! Tool descriptions and file operations for the agent. use crate::llm::Tool; use serde_json::{Value, json}; use std::io; #[path = "overflow.rs"] pub mod overflow; /// Human-readable, lossless text representation of tool JSON. String values /// retain real newlines, with JSON-quoted field/index labels for structure. fn render(value: &Value) -> String { fn visit(value: &Value, label: &str, out: &mut String) { match value { Value::Object(fields) => { if fields.is_empty() { out.push_str(&format!("{label}: {{}}\n")); } for (key, value) in fields { visit( value, &format!("{label}[{}]", serde_json::to_string(key).unwrap()), out, ); } } Value::Array(items) => { if items.is_empty() { out.push_str(&format!("{label}: []\n")); } for (i, value) in items.iter().enumerate() { visit(value, &format!("{label}[{i}]"), out); } } Value::String(text) => { out.push_str(&format!("{label} (string, {} bytes):\n", text.len())); out.push_str(text); out.push('\n'); } _ => { out.push_str(&format!("{label}: {value}\n")); } } } let mut out = String::new(); visit(value, "$", &mut out); out } /// Describe the five tools sent to either inference API. pub fn definitions() -> Vec { vec![ Tool { name: "call", description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Large results show an excerpt with a full-output /logs reference.", parameters: schema( json!({ "method": {"type": "string", "description": "Qualified plugin method"}, "params": {"type": "object", "description": "Named method parameters"}, "detach": {"type": "boolean", "description": "Detach the new process. Default false."} }), &["method", "params"], ), }, Tool { name: "read_file", description: "Read a text file in the world, including `/proc`. `offset`/`max_bytes` select bytes; `line_offset`/`line_limit` select zero-based lines (default 200). Large results show an excerpt with a full-output /logs reference.", parameters: schema( json!({ "path": {"type": "string"}, "offset": {"type": "integer", "description": "Byte offset (default 0)"}, "max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}, "line_offset": {"type": "integer", "description": "Zero-based starting line; overrides bytes"}, "line_limit": {"type": "integer", "description": "Number of lines; default 200"} }), &["path"], ), }, Tool { name: "write_file", description: "Write a text file in the world. Parent folders are created. Large results show an excerpt with a full-output /logs reference.", parameters: schema( json!({"path": {"type": "string"}, "content": {"type": "string"}}), &["path", "content"], ), }, Tool { name: "list_dir", description: "List entries in a world directory. Returns each name, type, and size. Large results show an excerpt with a full-output /logs reference.", parameters: schema(json!({"path": {"type": "string"}}), &["path"]), }, Tool { name: "copy_dir", description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Large results show an excerpt with a full-output /logs reference.", parameters: schema( json!({"from": {"type": "string"}, "to": {"type": "string"}}), &["from", "to"], ), }, ] } /// Create the named-parameter object required by a tool. fn schema(properties: Value, required: &[&str]) -> Value { json!({"type": "object", "properties": properties, "required": required}) } /// Execute a tool. The chat loop returns errors to the model as tool results. pub async fn run(name: &str, args: &Value) -> sausage_plugin::Result { if name == "call" { let method = args["method"] .as_str() .ok_or_else(|| sausage_plugin::Error::new(-32602, "missing method"))?; let call = sausage_plugin::call(method, args.get("params").cloned().unwrap_or(json!({}))); return if args["detach"] == true { call.detach().await } else { call.await }; } Ok(match name { "read_file" => read_file(args), "write_file" => write_file(args), "list_dir" => list_dir(args), "copy_dir" => copy_dir(args), other => Ok(json!({"error": format!("unknown tool {other}")})), }?) } /// Read an optional byte range and report the complete file size. fn read_file(args: &Value) -> io::Result { let path = args["path"].as_str().unwrap_or_default(); let bytes = std::fs::read(path)?; if path.starts_with("/logs/") { return Ok( json!({"path":path,"size":bytes.len(),"content":overflow::existing(&bytes, path, args)}), ); } if let Some(line) = args["line_offset"].as_u64() { return Ok( json!({"path":path,"size":bytes.len(),"line_offset":line,"content":overflow::read_lines(&bytes, line as usize, args["line_limit"].as_u64().unwrap_or(200) as usize)}), ); } let offset = (args["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len()); let end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| { offset.saturating_add(max as usize).min(bytes.len()) }); Ok(json!({ "path": path, "size": bytes.len(), "offset": offset, "content": String::from_utf8_lossy(&bytes[offset..end]), "truncated": end < bytes.len(), })) } /// Create parent directories and replace a text file. fn write_file(args: &Value) -> io::Result { let path = args["path"].as_str().unwrap_or_default(); if let Some(parent) = std::path::Path::new(path).parent() { let _ = std::fs::create_dir_all(parent); } std::fs::write(path, args["content"].as_str().unwrap_or_default())?; Ok(json!({"written": path})) } /// Return directory metadata in name order. fn list_dir(args: &Value) -> io::Result { let mut entries: Vec = std::fs::read_dir(args["path"].as_str().unwrap_or_default())? .filter_map(|entry| entry.ok()) .map(|entry| { let metadata = entry.metadata().ok(); json!({ "name": entry.file_name().to_string_lossy(), "type": if metadata.as_ref().is_some_and(|meta| meta.is_dir()) { "dir" } else { "file" }, "size": metadata.map(|meta| meta.len()), }) }) .collect(); entries.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str())); Ok(json!({"entries": entries})) } /// Copy a tree and report its file count. fn copy_dir(args: &Value) -> io::Result { let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?; let to = args["to"].as_str().unwrap_or_default(); std::fs::create_dir_all(to)?; if std::fs::canonicalize(to)?.starts_with(&from) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "destination is inside the source", )); } let count = copy_tree( args["from"].as_str().unwrap_or_default(), args["to"].as_str().unwrap_or_default(), )?; Ok(json!({"copied_files": count})) } /// Copy files recursively without build artifacts in target directories. fn copy_tree(from: &str, to: &str) -> io::Result { std::fs::create_dir_all(to)?; let mut count = 0; for entry in std::fs::read_dir(from)? { let entry = entry?; let name = entry.file_name().to_string_lossy().to_string(); let src = format!("{from}/{name}"); let dst = format!("{to}/{name}"); if entry.file_type()?.is_dir() { if name != "target" { count += copy_tree(&src, &dst)?; } } else { std::fs::copy(src, dst)?; count += 1; } } Ok(count) } #[cfg(test)] mod tests { #[test] fn overflow_json_can_be_followed_by_line_without_rearchiving() { let value = serde_json::json!({"stdout":"hello 😀\n".repeat(4000),"exit":1,"error":{"text":"failure\n".repeat(4000)}}); let expected = super::render(&value); let shown = super::overflow::guard(value.to_string(), "json-test", "call", true); assert!(shown.len() <= 50 * 1024 && shown.lines().count() <= 2000); let path = shown .split("Full output: ") .nth(1) .unwrap() .split(". Read with") .next() .unwrap(); assert_eq!(std::fs::read_to_string(path).unwrap(), expected); let parent = std::path::Path::new(path).parent().unwrap(); let count = std::fs::read_dir(parent).unwrap().count(); let args = serde_json::json!({"path":path,"line_offset":30,"line_limit":5}); let read = super::read_file(&args).unwrap(); let read = read["content"].as_str().unwrap(); assert!(read.contains("Showing lines 31-35")); assert!(read.contains(path)); assert_eq!(std::fs::read_dir(parent).unwrap().count(), count); assert!( super::overflow::existing( expected.as_bytes(), path, &serde_json::json!({"line_offset":u64::MAX}) ) .contains("Full output:") ); } #[test] fn copy_rejects_a_destination_inside_the_source() { let root = if cfg!(target_os = "wasi") { std::path::PathBuf::from("/copy-test") } else { std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id())) }; std::fs::create_dir_all(&root).unwrap(); let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")})); let _ = std::fs::remove_dir_all(root); assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput); } }