agent: a plugin on the new SDK (agent.chat, tools call/read_file/write_file/list_dir/copy_dir)

Holds the history lock for a turn, 600 s grace period, interim texts as
core.notify. Tests with a fake LLM in the real core. The SDK tests of
/proc, /self, /run, and detached notifications now run.
This commit is contained in:
2026-10-23 01:42:20 +01:00
parent 8d058666dc
commit 53b90a8ecf
11 changed files with 754 additions and 387 deletions
+33 -14
View File
@@ -1,26 +1,27 @@
//! Tool descriptions and file operations for the agent.
use crate::llm::Tool;
use sausage_actor::{Value, json};
use serde_json::{Value, json};
use std::io;
/// Describe the five tools sent to either inference API.
pub fn definitions() -> Vec<Tool> {
vec![
Tool {
name: "send_message",
description: "Send an actor a JSON body with a `type`. `broker` and `cargo` are built-in actors; see `/actor/broker/public/PROTOCOL.md`. With `wait_seconds`, the reply's `reply_to` matches the returned ID. Returns the ID, reply, and other messages received.",
name: "call",
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller.",
parameters: schema(
json!({
"to": {"type": "string", "description": "Actor ID"},
"body": {"type": "object", "description": "JSON object with a type field"},
"wait_seconds": {"type": "number", "description": "Seconds to wait for a reply. Default 0."}
"method": {"type": "string", "description": "Qualified plugin method"},
"params": {"type": "object", "description": "Named method parameters"},
"detach": {"type": "boolean", "description": "Detach the new process. Default false."}
}),
&["to", "body"],
&["method", "params"],
),
},
Tool {
name: "read_file",
description: "Read a text file under `/data`, `/public`, or `/actor/<id>`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
description: "Read a text file in the world, including `/proc`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
parameters: schema(
json!({
"path": {"type": "string"},
@@ -32,7 +33,7 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "write_file",
description: "Write text under `/data` or `/public`. Parent folders are created.",
description: "Write a text file in the world. Parent folders are created.",
parameters: schema(
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
&["path", "content"],
@@ -40,12 +41,12 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "list_dir",
description: "List entries in a directory under `/data`, `/public`, or `/actor/<id>`. Returns each name, type, and size.",
description: "List entries in a world directory. Returns each name, type, and size.",
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
},
Tool {
name: "copy_dir",
description: "Copy a directory tree, for example `/actor/<id>/source` to `/public/drafts/<id>`. Skips `target/` folders.",
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders.",
parameters: schema(
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
&["from", "to"],
@@ -54,20 +55,34 @@ pub fn definitions() -> Vec<Tool> {
]
}
/// Create the named-parameter object required by a tool.
fn schema(properties: Value, required: &[&str]) -> Value {
json!({"type": "object", "properties": properties, "required": required})
}
pub fn run(name: &str, args: &Value) -> io::Result<Value> {
match name {
/// 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)?;
@@ -84,6 +99,7 @@ fn read_file(args: &Value) -> io::Result<Value> {
}))
}
/// 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() {
@@ -93,6 +109,7 @@ fn write_file(args: &Value) -> io::Result<Value> {
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())
@@ -109,6 +126,7 @@ fn list_dir(args: &Value) -> io::Result<Value> {
Ok(json!({"entries": entries}))
}
/// Copy a tree and report its file count.
fn copy_dir(args: &Value) -> io::Result<Value> {
let count = copy_tree(
args["from"].as_str().unwrap_or_default(),
@@ -117,6 +135,7 @@ fn copy_dir(args: &Value) -> io::Result<Value> {
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;