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:
+123
-306
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user