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
+2
View File
@@ -2,6 +2,7 @@
use crate::llm::Entry;
/// Recognize provider errors that permit a retry with fewer complete turns.
pub fn is_too_large(error: &str) -> bool {
let Some((status, body)) = error
.strip_prefix("HTTP ")
@@ -23,6 +24,7 @@ pub fn is_too_large(error: &str) -> bool {
.any(|term| body.contains(term)))
}
/// Find a user-turn boundary without discarding any of the current turn.
pub fn next_turn_start(history: &[Entry], from: usize, current: usize) -> Option<usize> {
let end = current.min(history.len().checked_sub(1)?);
(from..=end).find(|&index| matches!(&history[index], Entry::User { .. }))
+168 -34
View File
@@ -5,18 +5,24 @@
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[cfg(target_os = "wasi")]
use wstd::http::{Body, Client, Request};
/// Inference configuration from `/config/agent.json`.
#[derive(Deserialize, Clone)]
pub struct Config {
/// `openai-responses` or `anthropic-messages`.
pub api: String,
/// The API origin, without its versioned endpoint path.
pub base_url: String,
/// The provider's model identifier.
pub model: String,
/// The API credential sent in the provider's authentication header.
pub token: String,
/// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
#[serde(default)]
pub reasoning_effort: Option<String>,
/// An optional cap on generated tokens.
#[serde(default)]
pub max_tokens: Option<u32>,
/// Thinking effort for both APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none.
@@ -32,8 +38,17 @@ pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not
/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default).
fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String> {
let listed: Vec<&str> = error.split('\'').skip(1).step_by(2).filter(|w| LEVELS.contains(w)).collect();
let pool = if listed.iter().any(|l| *l != want) { listed } else { LEVELS.to_vec() };
let listed: Vec<&str> = error
.split('\'')
.skip(1)
.step_by(2)
.filter(|w| LEVELS.contains(w))
.collect();
let pool = if listed.iter().any(|l| *l != want) {
listed
} else {
LEVELS.to_vec()
};
let at = LEVELS.iter().position(|l| *l == want).unwrap_or(1) as i32;
pool.into_iter()
.filter(|l| *l != want && !tried.iter().any(|t| t == l))
@@ -50,39 +65,90 @@ mod tests {
#[test]
fn nearest_accepted_level() {
let anthropic = "HTTP 400: output_config.effort: Input should be 'low', 'medium', 'high' or 'max'";
assert_eq!(fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(), Some("high"));
let anthropic =
"HTTP 400: output_config.effort: Input should be 'low', 'medium', 'high' or 'max'";
assert_eq!(
fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(),
Some("high")
);
let openai = "HTTP 400: Invalid value: 'ultra'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'.";
assert_eq!(fallback_effort("ultra", openai, &["ultra".into()]).as_deref(), Some("medium"));
assert_eq!(
fallback_effort("ultra", openai, &["ultra".into()]).as_deref(),
Some("medium")
);
// No list in the error: step to the neighbour, never to off.
assert_eq!(fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()]).as_deref(), Some("xhigh"));
assert_eq!(
fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()])
.as_deref(),
Some("xhigh")
);
}
}
/// One append-only entry in the provider-neutral conversation.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Entry {
User { text: String },
Assistant {
/// A person's contribution starts a new turn.
User {
/// The complete contribution.
text: String,
},
/// Text produced by the model.
Assistant {
/// The generated text.
text: String,
/// The optional provider phase, preserved for subsequent requests.
#[serde(default, skip_serializing_if = "Option::is_none")]
phase: Option<String>,
},
ToolCall { id: String, name: String, arguments: String },
ToolResult { id: String, output: String },
/// A requested tool operation.
ToolCall {
/// The provider's correlation identifier.
id: String,
/// The tool name.
name: String,
/// The JSON parameter object as text.
arguments: String,
},
/// The outcome of a tool operation, including tool errors.
ToolResult {
/// The correlation identifier of the tool call.
id: String,
/// The JSON outcome as text.
output: String,
},
/// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking).
Opaque { api: String, item: Value },
Opaque {
/// The API shape that owns this entry.
api: String,
/// The original provider object.
item: Value,
},
}
/// A tool contract shared by both API shapes.
pub struct Tool {
/// The function identifier exposed to the model.
pub name: &'static str,
/// The explanation of the tool's behavior.
pub description: &'static str,
/// The JSON Schema for named parameters.
pub parameters: Value,
}
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
/// Request the next model step, with provider-specific reasoning items intact.
pub async fn complete(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
) -> Result<Vec<Entry>, String> {
let want = match cfg.api.as_str() {
"openai-responses" => cfg.reasoning_effort.clone().or_else(|| cfg.thinking.clone()),
"openai-responses" => cfg
.reasoning_effort
.clone()
.or_else(|| cfg.thinking.clone()),
_ => cfg.thinking.clone(),
}
.filter(|t| t != "off");
@@ -91,7 +157,17 @@ pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Ent
loop {
let result = match cfg.api.as_str() {
"openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await,
"anthropic-messages" => anthropic(cfg, system, tools, history, want.is_some(), effort.as_deref()).await,
"anthropic-messages" => {
anthropic(
cfg,
system,
tools,
history,
want.is_some(),
effort.as_deref(),
)
.await
}
other => return Err(format!("unknown API shape {other:?}")),
};
match (result, effort.clone()) {
@@ -99,31 +175,58 @@ pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Ent
(Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => {
tried.push(current.clone());
effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried);
eprintln!("agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}", cfg.model);
eprintln!(
"agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}",
cfg.model
);
}
(result, _) => return result,
}
}
}
/// Native unit tests exercise history logic without an HTTP runtime.
#[cfg(not(target_os = "wasi"))]
async fn post(_url: &str, _headers: &[(&str, String)], _body: &Value) -> Result<Value, String> {
Err("inference requires the WASI HTTP runtime".into())
}
/// Send JSON through wasi:http and retain error bodies for context-size detection.
#[cfg(target_os = "wasi")]
async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> {
let mut req = Request::post(url).header("content-type", "application/json");
for (k, v) in headers {
req = req.header(*k, v.as_str());
}
let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
let req = req
.body(Body::from(body.to_string()))
.map_err(|e| format!("request: {e}"))?;
let client = Client::new();
let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
let resp = client
.send(req)
.await
.map_err(|e| format!("HTTP request failed: {e:?}"))?;
let status = resp.status();
let mut body = resp.into_body();
let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
let text = body
.str_contents()
.await
.map_err(|e| format!("reading the response failed: {e:?}"))?
.to_string();
if !status.is_success() {
return Err(format!("HTTP {status}: {text}"));
}
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
}
async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], effort: Option<&str>) -> Result<Vec<Entry>, String> {
/// Translate neutral history to OpenAI Responses and preserve returned reasoning items.
async fn openai(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
effort: Option<&str>,
) -> Result<Vec<Entry>, String> {
let mut input = Vec::new();
for e in history {
match e {
@@ -164,7 +267,12 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], e
body["reasoning"] = json!({"effort": effort});
}
let url = format!("{}/v1/responses", cfg.base_url);
let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &body).await?;
let resp = post(
&url,
&[("authorization", format!("Bearer {}", cfg.token))],
&body,
)
.await?;
let mut out = Vec::new();
for item in resp["output"].as_array().cloned().unwrap_or_default() {
match item["type"].as_str() {
@@ -189,16 +297,23 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], e
name: item["name"].as_str().unwrap_or_default().to_string(),
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
}),
Some("reasoning") => out.push(Entry::Opaque { api: "openai-responses".into(), item }),
Some("reasoning") => out.push(Entry::Opaque {
api: "openai-responses".into(),
item,
}),
_ => {}
}
}
if resp["status"] == "incomplete" && out.is_empty() {
return Err(format!("incomplete response: {}", resp["incomplete_details"]));
return Err(format!(
"incomplete response: {}",
resp["incomplete_details"]
));
}
Ok(out)
}
/// Translate neutral history to Anthropic Messages and merge consecutive roles.
async fn anthropic(
cfg: &Config,
system: &str,
@@ -219,22 +334,37 @@ async fn anthropic(
Entry::Assistant { text, .. } => {
push("assistant", json!({"type": "text", "text": text}))
}
Entry::ToolCall { id, name, arguments } => {
Entry::ToolCall {
id,
name,
arguments,
} => {
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
}
Entry::ToolResult { id, output } => {
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
push(
"assistant",
json!({"type": "tool_use", "id": id, "name": name, "input": input}),
)
}
Entry::ToolResult { id, output } => push(
"user",
json!({"type": "tool_result", "tool_use_id": id, "content": output}),
),
// Thinking blocks go back unchanged (with their signature): required next to tool use.
Entry::Opaque { api, item } if api == "anthropic-messages" && thinking => push("assistant", item.clone()),
Entry::Opaque { api, item } if api == "anthropic-messages" && thinking => {
push("assistant", item.clone())
}
Entry::Opaque { .. } => {}
}
}
let messages: Vec<Value> = messages.into_iter().map(|(role, content)| json!({"role": role, "content": content})).collect();
let messages: Vec<Value> = messages
.into_iter()
.map(|(role, content)| json!({"role": role, "content": content}))
.collect();
let tools: Vec<Value> = tools
.iter()
.map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
.map(
|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}),
)
.collect();
let mut body = json!({
"model": cfg.model,
@@ -252,7 +382,10 @@ async fn anthropic(
let url = format!("{}/v1/messages", cfg.base_url);
let resp = post(
&url,
&[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())],
&[
("x-api-key", cfg.token.clone()),
("anthropic-version", "2023-06-01".to_string()),
],
&body,
)
.await?;
@@ -270,9 +403,10 @@ async fn anthropic(
name: block["name"].as_str().unwrap_or_default().to_string(),
arguments: block["input"].to_string(),
}),
Some("thinking") | Some("redacted_thinking") => {
out.push(Entry::Opaque { api: "anthropic-messages".into(), item: block })
}
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
api: "anthropic-messages".into(),
item: block,
}),
_ => {}
}
}
+123 -306
View File
@@ -1,339 +1,156 @@
//! The Sausage agent actor.
//! Answer chat calls with an LLM and tools, under one lock for the complete turn.
mod history;
mod llm;
mod tools;
use history::{is_too_large, next_turn_start};
use llm::{Config, Entry};
use sausage_actor::{Inbox, Message, Value, json, publish, send};
use std::collections::{HashSet, VecDeque};
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
use serde_json::json;
use std::io::Write;
use wstd::future::FutureExt;
use wstd::time::{Duration, Instant};
/// The instructions sent with every inference request.
const SYSTEM: &str = include_str!("prompt.md");
const CONFIG: &str = "/data/config.json";
const HISTORY: &str = "/data/history.jsonl";
const HISTORY_SKIP: &str = "/data/history.skip";
const TURN: &str = "/data/turn.json";
fn main() {
wstd::runtime::block_on(async { Agent::new().run().await });
}
struct Agent {
inbox: Inbox,
history: Vec<Entry>,
history_skip: usize,
active: bool,
queue: VecDeque<Message>,
notes: Vec<Message>,
/// The shared, append-only conversation.
const HISTORY: &str = "/state/agent/history.jsonl";
/// The first retained turn after a context-size rejection.
const SKIP: &str = "/state/agent/history.skip";
/// Each chat process reads the shared history after it acquires the lock.
struct Agent;
/// The person's next contribution to the conversation.
#[schema]
struct ChatParams {
/// The complete text of the person's request.
text: String,
}
#[rpc(grace_seconds = 600)]
impl Agent {
fn new() -> Self {
let history = std::fs::read_to_string(HISTORY)
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
let history_skip = std::fs::read_to_string(HISTORY_SKIP)
/// Answer a person, with interim text notifications and a final text result.
///
/// The history lock covers inference and tools, so concurrent chats cannot interleave turns.
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
let _guard = lock(HISTORY).await?;
std::fs::create_dir_all("/state/agent")?;
let cfg: Config = serde_json::from_slice(&std::fs::read("/config/agent.json")?)?;
let mut entries = load_history()?;
repair_tools(&mut entries)?;
let current = entries.len();
append(&mut entries, Entry::User { text: p.text })?;
let mut start = std::fs::read_to_string(SKIP)
.ok()
.and_then(|skip| skip.trim().parse::<usize>().ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let active = std::fs::read_to_string(TURN)
.ok()
.and_then(|contents| serde_json::from_str::<bool>(&contents).ok())
.unwrap_or(false);
Self {
inbox: Inbox::start(),
history,
history_skip,
active,
queue: VecDeque::new(),
notes: Vec::new(),
}
}
fn add_history(&mut self, entry: Entry) {
let line = serde_json::to_string(&entry).unwrap();
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY)
{
Ok(mut file) => {
if let Err(e) = writeln!(file, "{line}") {
eprintln!("agent: cannot append history: {e}");
}
}
Err(e) => eprintln!("agent: cannot append history: {e}"),
}
self.history.push(entry);
}
fn finish_turn(&mut self) {
self.active = false;
let _ = std::fs::remove_file(TURN);
}
fn closing(&self) -> bool {
self.inbox.is_closed()
}
fn repair_dangling_tools(&mut self) {
let answered: HashSet<String> = self
.history
.iter()
.filter_map(|entry| match entry {
Entry::ToolResult { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
let dangling: Vec<String> = self
.history
.iter()
.filter_map(|entry| match entry {
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
_ => None,
})
.collect();
for id in dangling {
self.add_history(Entry::ToolResult {
id,
output:
json!({"error": "interrupted: the agent restarted before this tool finished"})
.to_string(),
});
}
}
fn turn_text(&self, message: &Message) -> String {
let mut text = format!(
"Message {} from {}:\n{}",
message.id,
message.from,
message.body["text"].as_str().unwrap_or("")
);
if !self.notes.is_empty() {
let notes = self
.notes
.iter()
.map(|note| serde_json::to_string(note).unwrap())
.collect::<Vec<_>>()
.join("\n");
text = format!("Messages that arrived while you were idle:\n{notes}\n\n{text}");
}
text
}
fn not_configured(&mut self, error: String) {
let _ = publish(json!({
"type": "text",
"text": format!("I am not configured: cannot read {CONFIG}: {error}")
}));
self.finish_turn();
}
async fn run(mut self) {
self.repair_dangling_tools();
if self.active {
self.turn(None).await;
if self.closing() && self.active {
return;
}
}
start = next_turn_start(&entries, start, current).unwrap_or(current);
let definitions = tools::definitions();
loop {
let message = match self.queue.pop_front() {
Some(message) => message,
None => match self.inbox.recv().await {
Some(message) => message,
None => break,
},
};
if starts_turn(&message) {
self.turn(Some(message)).await;
if self.closing() && self.active {
break;
}
} else {
self.notes.push(message);
}
}
}
async fn turn(&mut self, message: Option<Message>) {
let current_start = if message.is_some() {
self.active = true;
let _ = std::fs::write(TURN, "true");
self.history.len()
} else {
let current_start = self
.history
.iter()
.rposition(|entry| matches!(entry, Entry::User { text } if !text.starts_with("[note from your runtime]")))
.unwrap_or(self.history.len());
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
self.add_history(Entry::User {
text: format!(
"[note from your runtime] you restarted and now run version {version}. Continue the active turn."
),
});
current_start
};
let cfg = match load_config() {
Ok(cfg) => cfg,
Err(error) => {
self.not_configured(error);
return;
}
};
if let Some(message) = message {
let text = self.turn_text(&message);
self.notes.clear();
self.add_history(Entry::User { text });
}
self.steps(&cfg, current_start).await;
if !self.closing() {
self.finish_turn();
}
}
async fn steps(&mut self, cfg: &Config, current_start: usize) {
let tools = tools::definitions();
loop {
if self.closing() {
return;
}
let start = next_turn_start(&self.history, self.history_skip, current_start)
.unwrap_or(current_start);
if start != self.history_skip {
self.history_skip = start;
let _ = std::fs::write(HISTORY_SKIP, start.to_string());
}
let response = llm::complete(cfg, SYSTEM, &tools, &self.history[start..]).await;
let entries = match response {
Ok(entries) => entries,
Err(e) => {
if is_too_large(&e) {
if let Some(next) =
next_turn_start(&self.history, start.saturating_add(1), current_start)
{
self.history_skip = next;
let _ = std::fs::write(HISTORY_SKIP, next.to_string());
continue;
}
let response = match llm::complete(&cfg, SYSTEM, &definitions, &entries[start..]).await
{
Ok(response) => response,
Err(error) => {
if is_too_large(&error)
&& let Some(next) = next_turn_start(&entries, start + 1, current)
{
start = next;
std::fs::write(SKIP, start.to_string())?;
continue;
}
let text = format!("Inference failed: {e}");
self.add_history(Entry::Assistant {
text: text.clone(),
phase: None,
});
let _ = publish(json!({"type": "text", "text": text}));
return;
return Err(Error::new(-32603, error));
}
};
let calls: Vec<(String, String, String)> = entries
let has_tools = response
.iter()
.filter_map(|entry| match entry {
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
let mut final_text = String::new();
let mut calls = Vec::new();
for entry in response {
match &entry {
Entry::Assistant { text, phase }
if has_tools || phase.as_deref() == Some("commentary") =>
{
ctx.notify("text", json!({"text":text}))?
}
Entry::Assistant { text, .. } => final_text.push_str(text),
Entry::ToolCall {
id,
name,
arguments,
} => Some((id.clone(), name.clone(), arguments.clone())),
_ => None,
})
.collect();
for entry in entries {
if let Entry::Assistant { text, .. } = &entry {
if !text.is_empty() {
let _ = publish(json!({"type": "text", "text": text}));
}
} => calls.push((id.clone(), name.clone(), arguments.clone())),
_ => {}
}
self.add_history(entry);
append(&mut entries, entry)?;
}
if self.closing() {
return;
if !has_tools {
return Ok(final_text);
}
if calls.is_empty() {
return;
}
for (id, name, args) in calls {
if self.closing() {
return;
}
let output = self.tool(&name, &args).await;
self.add_history(Entry::ToolResult {
id,
output: output.to_string(),
});
for (id, name, arguments) in calls {
let output = match serde_json::from_str(&arguments) {
Ok(args) => tools::run(&name, &args)
.await
.unwrap_or_else(|error| json!({"error":error.to_string()})),
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
};
append(
&mut entries,
Entry::ToolResult {
id,
output: output.to_string(),
},
)?;
}
}
}
fn stash(&mut self, message: Message, others: &mut Vec<Value>) {
if starts_turn(&message) {
others.push(json!({"queued_for_a_later_turn": true, "message": message}));
self.queue.push_back(message);
} else {
others.push(serde_json::to_value(message).unwrap());
}
}
fn drain_inbox(&mut self, others: &mut Vec<Value>) {
while let Some(message) = self.inbox.try_recv() {
self.stash(message, others);
}
}
async fn tool(&mut self, name: &str, args: &str) -> Value {
let args: Value = match serde_json::from_str(args) {
Ok(args) => args,
Err(error) => return json!({"error": format!("arguments are not valid JSON: {error}")}),
};
if name == "send_message" {
return self.send_message(&args).await;
}
tools::run(name, &args).unwrap_or_else(|error| json!({"error": error.to_string()}))
}
async fn send_message(&mut self, args: &Value) -> Value {
let Some(to) = args["to"].as_str() else {
return json!({"error": "missing \"to\""});
};
if !args["body"].is_object() {
return json!({"error": "\"body\" must be a JSON object"});
}
let id = send(to, args["body"].clone());
let wait = args["wait_seconds"].as_f64().unwrap_or(0.0).max(0.0);
let mut reply = Value::Null;
let mut others = Vec::new();
if wait > 0.0 {
let deadline = Instant::now() + Duration::from_secs_f64(wait);
loop {
match self.inbox.recv().timeout(deadline).await {
Ok(Some(message)) if message.reply_to.as_deref() == Some(&id) => {
reply = serde_json::to_value(message).unwrap();
break;
}
Ok(Some(message)) => self.stash(message, &mut others),
Ok(None) => break,
Err(_) => break,
}
}
}
self.drain_inbox(&mut others);
json!({"id": id, "reply": reply, "waited": wait > 0.0, "other_messages": others})
}
}
fn load_config() -> Result<Config, String> {
std::fs::read_to_string(CONFIG)
.map_err(|e| e.to_string())
.and_then(|contents| serde_json::from_str(&contents).map_err(|e| e.to_string()))
/// Read persisted entries in their original order. A new world has no history file.
fn load_history() -> Result<Vec<Entry>> {
match std::fs::read_to_string(HISTORY) {
Ok(text) => text
.lines()
.map(|line| serde_json::from_str(line).map_err(Into::into))
.collect(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error.into()),
}
}
fn starts_turn(message: &Message) -> bool {
message.kind() == "text" && message.reply_to.is_none()
/// Persist an entry before it enters the current inference context.
fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY)?;
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
history.push(entry);
Ok(())
}
/// Complete interrupted tool pairs before a new inference request.
fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
let answered: std::collections::HashSet<_> = history
.iter()
.filter_map(|entry| match entry {
Entry::ToolResult { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
let dangling: Vec<_> = history
.iter()
.filter_map(|entry| match entry {
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
_ => None,
})
.collect();
for id in dangling {
append(
history,
Entry::ToolResult {
id,
output: json!({"error":"interrupted before the tool finished"}).to_string(),
},
)?;
}
Ok(())
}
/// Start the plugin's JSON-RPC transport.
fn main() {
sausage_plugin::run(Agent);
}
+6 -6
View File
@@ -1,7 +1,7 @@
You are the `agent` actor in a Sausage world of actors.
People talk to you through other actors. Your text goes to your subscribers.
Before writing an actor, read `/actor/broker/public/PROTOCOL.md` for messages, the broker, `cargo`, and tests.
Read the SDK at `/actor/cargo/public/sdk/` before you write an actor.
You can change your own source at `/actor/agent/source`.
You are the Sausage agent. Answer the person. Use tools to inspect or change the world.
The global filesystem is readable by every actor, including every actor's `/data` at `/actor/<id>/data/`. An actor can write only its own `/data` and `/public`. `/data` is not private: any unprotected secret file there is readable by every actor.
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
Write logs to stderr. Use call results and notifications for communication.
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
+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;