Write new journal entries under logs/core and merge read-only legacy history without moving SQLite/WAL files. Atomically spool failed batches, retaining unspoolable entries for event-driven retries. Journal full trap diagnostics before UTF-8-safe status display. Port session overflow archives and followable line/byte excerpts to the still-shipped embedded agent. Document retention/recovery and cover the contracts with regressions. Include one rustfmt-only normalization in the existing SDK WASI test. Card: SAU-143
269 lines
11 KiB
Rust
269 lines
11 KiB
Rust
//! 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<Tool> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
let mut entries: Vec<Value> = 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<Value> {
|
|
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<u64> {
|
|
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);
|
|
}
|
|
}
|