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:
+69
-194
@@ -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}")}),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user