Trim the harness to what it needs to keep going

Host:
- test first: a new version is tested where it lies; only an installed
  version goes to versions/, and instances run from there
- data/ and public/ are shared by all versions; no snapshots; rollback
  restores code only
- no re-delivery, status.json, list, remove/archive, smoke test, data/
  hiding, or stdin timeout; rollback needs a version
- supervisor.rs split out; one place for installing a version
- init only writes the agent folder; run installs it like any actor
- console: @actor, text to the agent, /help, /quit

Agent:
- every text goes out as a broadcast; 5-line prompt; 5 tools that describe
  themselves; turn.json only marks an active turn

Docs: PROTOCOL.md, README.md, DECISIONS.md, AGENTS.md rewritten.
Examples: only notes and notes-v2.
This commit is contained in:
2026-10-15 13:35:45 +01:00
parent cee862275c
commit 11144b947e
35 changed files with 909 additions and 2038 deletions
+69 -194
View File
@@ -3,8 +3,7 @@
mod llm;
use llm::{Config, Entry, Tool};
use sausage_actor::{Inbox, Message, Value, json, reply, send};
use serde::{Deserialize, Serialize};
use sausage_actor::{Inbox, Message, Value, json, publish, send};
use std::collections::{HashSet, VecDeque};
use std::io::Write;
use wstd::future::FutureExt;
@@ -24,29 +23,20 @@ fn tools() -> Vec<Tool> {
vec![
Tool {
name: "send_message",
description: "Send a message to an actor. With wait_seconds > 0, wait for its reply. Returns the message ID, the reply, and other messages that arrived meanwhile.",
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.",
parameters: json!({
"type": "object",
"properties": {
"to": {"type": "string", "description": "Actor ID"},
"body": {"type": "object", "description": "Message body. Must have a type field."},
"wait_seconds": {"type": "number", "description": "Seconds to wait for the reply. 0 = do not wait."}
"body": {"type": "object", "description": "JSON object with a type field"},
"wait_seconds": {"type": "number", "description": "Seconds to wait for a reply. Default 0."}
},
"required": ["to", "body"]
}),
},
Tool {
name: "wait_for_messages",
description: "Wait up to seconds for messages to arrive and return them.",
parameters: json!({
"type": "object",
"properties": {"seconds": {"type": "number"}},
"required": ["seconds"]
}),
},
Tool {
name: "read_file",
description: "Read a text file. Paths: /data/..., /public/..., /actor/<id>/...",
description: "Read a text file under `/data`, `/public`, or `/actor/<id>`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
parameters: json!({
"type": "object",
"properties": {
@@ -57,34 +47,29 @@ fn tools() -> Vec<Tool> {
"required": ["path"]
}),
},
Tool {
name: "list_dir",
description: "List a folder.",
parameters: json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
},
Tool {
name: "write_file",
description: "Write a text file under /data or /public. Creates parent folders.",
description: "Write text under `/data` or `/public`. Parent folders are created.",
parameters: json!({
"type": "object",
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
"required": ["path", "content"]
}),
},
Tool {
name: "list_dir",
description: "List entries in a directory under `/data`, `/public`, or `/actor/<id>`. Returns each name, type, and size.",
parameters: json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
},
Tool {
name: "copy_dir",
description: "Copy a folder tree, for example /actor/<id>/source to /public/drafts/<id>. Skips target/ folders.",
description: "Copy a directory tree, for example `/actor/<id>/source` to `/public/drafts/<id>`. Skips `target/` folders.",
parameters: json!({
"type": "object",
"properties": {"from": {"type": "string"}, "to": {"type": "string"}},
"required": ["from", "to"]
}),
},
Tool {
name: "remove_path",
description: "Remove a file or folder under /data or /public.",
parameters: json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
},
]
}
@@ -108,19 +93,13 @@ fn copy_tree(from: &str, to: &str) -> std::io::Result<u64> {
Ok(count)
}
#[derive(Default, Deserialize, Serialize)]
#[serde(default)]
struct TurnState {
active: Option<Message>,
queue: VecDeque<Message>,
notes: Vec<Message>,
}
struct Agent {
inbox: Inbox,
history: Vec<Entry>,
history_skip: usize,
pending: TurnState,
active: bool,
queue: VecDeque<Message>,
notes: Vec<Message>,
shutdown: bool,
}
@@ -135,33 +114,19 @@ impl Agent {
.ok()
.and_then(|skip| skip.trim().parse::<usize>().ok())
.unwrap_or(0);
let pending = std::fs::read_to_string(TURN)
let active = std::fs::read_to_string(TURN)
.ok()
.and_then(|contents| {
let value: Value = serde_json::from_str(&contents).ok()?;
let mut pending = serde_json::from_value::<TurnState>(value.clone()).ok()?;
if pending.active.is_none() {
if let (Some(id), Some(from)) = (value["id"].as_str(), value["from"].as_str()) {
pending.active = Some(Message {
id: id.to_string(),
from: from.to_string(),
reply_to: None,
body: Value::Null,
});
}
}
Some(pending)
})
.unwrap_or_default();
let agent = Self {
.and_then(|contents| serde_json::from_str::<bool>(&contents).ok())
.unwrap_or(false);
Self {
inbox: Inbox::start(),
history,
history_skip,
pending,
active,
queue: VecDeque::new(),
notes: Vec::new(),
shutdown: false,
};
agent.persist_turn();
agent
}
}
fn add_history(&mut self, entry: Entry) {
@@ -181,24 +146,9 @@ impl Agent {
self.history.push(entry);
}
fn persist_turn(&self) {
if self.pending.active.is_none()
&& self.pending.queue.is_empty()
&& self.pending.notes.is_empty()
{
if let Err(e) = std::fs::remove_file(TURN) {
if e.kind() != std::io::ErrorKind::NotFound {
eprintln!("agent: cannot remove turn state: {e}");
}
}
} else if let Err(e) = std::fs::write(TURN, serde_json::to_vec(&self.pending).unwrap()) {
eprintln!("agent: cannot save turn state: {e}");
}
}
fn finish_turn(&mut self) {
self.pending.active = None;
self.persist_turn();
self.active = false;
let _ = std::fs::remove_file(TURN);
}
fn repair_dangling_tools(&mut self) {
@@ -235,9 +185,8 @@ impl Agent {
message.from,
message.body["text"].as_str().unwrap_or("")
);
if !self.pending.notes.is_empty() {
if !self.notes.is_empty() {
let notes = self
.pending
.notes
.iter()
.map(|note| serde_json::to_string(note).unwrap())
@@ -248,19 +197,11 @@ impl Agent {
text
}
fn turn_user_index(&self, message: &Message) -> Option<usize> {
let marker = format!("Message {} from {}:\n", message.id, message.from);
self.history.iter().rposition(|entry| match entry {
Entry::User { text } => text.contains(&marker),
_ => false,
})
}
fn not_configured(&mut self, message: &Message, error: String) {
let _ = reply(
message,
json!({"type": "text", "text": format!("I am not configured: cannot read {CONFIG}: {error}")}),
);
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();
}
@@ -268,7 +209,7 @@ impl Agent {
self.repair_dangling_tools();
self.resume().await;
while !self.shutdown {
let message = match self.pending.queue.pop_front() {
let message = match self.queue.pop_front() {
Some(message) => message,
None => match self.inbox.recv().await {
Some(message) => message,
@@ -281,87 +222,72 @@ impl Agent {
if starts_turn(&message) {
self.turn(message).await;
} else {
self.pending.notes.push(message);
self.persist_turn();
self.notes.push(message);
}
}
self.persist_turn();
}
async fn resume(&mut self) {
let Some(message) = self.pending.active.clone() else {
if !self.active {
return;
};
if self.turn_user_index(&message).is_none() {
let text = self.turn_text(&message);
self.add_history(Entry::User { text });
self.pending.notes.clear();
self.persist_turn();
}
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}. The restart interrupted your last turn (probably your own upgrade). Check the state you need, then finish the turn and answer the original message."
"[note from your runtime] you restarted and now run version {version}. Continue the active turn."
),
});
let cfg = match load_config() {
Ok(cfg) => cfg,
Err(error) => {
self.not_configured(&message, error);
self.not_configured(error);
return;
}
};
self.steps(&cfg, &message).await;
self.steps(&cfg, current_start).await;
if !self.shutdown {
self.finish_turn();
}
}
async fn turn(&mut self, message: Message) {
self.pending.active = Some(message.clone());
self.persist_turn();
self.active = true;
let _ = std::fs::write(TURN, "true");
let cfg = match load_config() {
Ok(cfg) => cfg,
Err(error) => {
self.not_configured(&message, error);
self.not_configured(error);
return;
}
};
let text = self.turn_text(&message);
self.notes.clear();
let current_start = self.history.len();
self.add_history(Entry::User { text });
self.pending.notes.clear();
self.persist_turn();
self.steps(&cfg, &message).await;
self.steps(&cfg, current_start).await;
if !self.shutdown {
self.finish_turn();
}
}
async fn steps(&mut self, cfg: &Config, message: &Message) {
async fn steps(&mut self, cfg: &Config, current_start: usize) {
let tools = tools();
loop {
if self.inbox.is_closed() {
self.preserve_closed_messages();
self.shutdown = true;
return;
}
let current_start = self
.turn_user_index(message)
.or_else(|| {
self.history
.iter()
.rposition(|entry| matches!(entry, Entry::User { .. }))
})
.unwrap_or(0);
let start = history_start(&self.history, self.history_skip, 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;
if self.inbox.is_closed() {
self.preserve_closed_messages();
return;
}
let entries = match response {
Ok(entries) => entries,
Err(e) => {
@@ -372,14 +298,15 @@ impl Agent {
continue;
}
}
let text = format!("Inference failed: {e}");
self.add_history(Entry::Assistant {
text: format!("(inference failed: {e})"),
text: text.clone(),
phase: None,
});
let _ = reply(
message,
json!({"type": "text", "text": format!("Inference failed: {e}")}),
);
let _ = publish(json!({"type": "text", "text": text}));
if self.inbox.is_closed() {
self.shutdown = true;
}
return;
}
};
@@ -394,30 +321,24 @@ impl Agent {
_ => None,
})
.collect();
let answer: Vec<String> = entries
.iter()
.filter_map(|entry| match entry {
Entry::Assistant { text, .. } if !text.is_empty() => Some(text.clone()),
_ => None,
})
.collect();
for entry in entries {
if let Entry::Assistant { text, .. } = &entry {
if !text.is_empty() {
let _ = publish(json!({"type": "text", "text": text}));
}
}
self.add_history(entry);
}
if self.inbox.is_closed() {
self.shutdown = true;
return;
}
if calls.is_empty() {
let answer = if answer.is_empty() {
"(no answer)".to_string()
} else {
answer.join("\n\n")
};
let _ = reply(message, json!({"type": "text", "text": answer}));
return;
}
for (id, name, args) in calls {
if self.shutdown || self.inbox.is_closed() {
if !self.shutdown {
self.preserve_closed_messages();
}
self.shutdown = true;
return;
}
let output = self.tool(&name, &args).await;
@@ -425,11 +346,8 @@ impl Agent {
id,
output: output.to_string(),
});
if self.shutdown {
return;
}
if self.inbox.is_closed() {
self.preserve_closed_messages();
if self.shutdown || self.inbox.is_closed() {
self.shutdown = true;
return;
}
}
@@ -439,8 +357,7 @@ impl Agent {
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.pending.queue.push_back(message);
self.persist_turn();
self.queue.push_back(message);
} else {
others.push(serde_json::to_value(message).unwrap());
}
@@ -453,20 +370,6 @@ impl Agent {
if self.inbox.is_closed() {
self.shutdown = true;
}
self.persist_turn();
}
fn preserve_closed_messages(&mut self) {
while let Some(message) = self.inbox.try_recv() {
if starts_turn(&message) {
self.pending.queue.push_back(message);
} else {
self.pending.notes.push(message);
}
self.persist_turn();
}
self.shutdown = self.inbox.is_closed();
self.persist_turn();
}
async fn tool(&mut self, name: &str, args: &str) -> Value {
@@ -478,22 +381,6 @@ impl Agent {
let err = |e: std::io::Error| json!({"error": e.to_string()});
match name {
"send_message" => self.send_message(&a).await,
"wait_for_messages" => {
let seconds = a["seconds"].as_f64().unwrap_or(10.0).max(0.0);
let mut others = Vec::new();
match self
.inbox
.recv()
.timeout(Duration::from_secs_f64(seconds))
.await
{
Ok(Some(message)) => self.stash(message, &mut others),
Ok(None) => self.shutdown = true,
Err(_) => {}
}
self.drain_inbox(&mut others);
json!({"messages": others})
}
"read_file" => {
let p = path("path");
match std::fs::read(&p) {
@@ -543,18 +430,6 @@ impl Agent {
Ok(count) => json!({"copied_files": count}),
Err(e) => err(e),
},
"remove_path" => {
let p = path("path");
let result = if std::path::Path::new(&p).is_dir() {
std::fs::remove_dir_all(&p)
} else {
std::fs::remove_file(&p)
};
match result {
Ok(()) => json!({"removed": p}),
Err(e) => err(e),
}
}
other => json!({"error": format!("unknown tool {other}")}),
}
}