SDK sausage-actor; examples and agent on the SDK; agent without limits
- sausage-actor: Message, messages(), send/reply/publish, async Inbox - agent: append-only /data/history.jsonl, no caps on history, steps, waits, tool output, or reads; max_tokens only when configured (Anthropic: 32000)
This commit is contained in:
+186
-246
@@ -1,85 +1,33 @@
|
||||
//! The Sausage agent: a normal WASM actor that does inference over HTTP and
|
||||
//! grows the world through messages to `cargo` and `broker`.
|
||||
//!
|
||||
//! Configuration: `/data/config.json` (see `llm::Config`).
|
||||
//! Conversation: `/data/history.json` (one shared conversation).
|
||||
//! The Sausage agent actor.
|
||||
|
||||
mod llm;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use async_channel::{Receiver, Sender};
|
||||
use llm::{Config, Entry, Tool};
|
||||
use serde_json::{Value, json};
|
||||
use sausage_actor::{Inbox, Message, Value, json, reply, send};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::io::Write;
|
||||
use wstd::future::FutureExt;
|
||||
use wstd::io::{AsyncRead, AsyncWrite};
|
||||
use wstd::time::{Duration, Instant};
|
||||
|
||||
const SYSTEM: &str = include_str!("prompt.md");
|
||||
const CONFIG: &str = "/data/config.json";
|
||||
const HISTORY: &str = "/data/history.json";
|
||||
/// An interrupted turn (sender and message ID), resumed at the next start.
|
||||
const HISTORY: &str = "/data/history.jsonl";
|
||||
const TURN: &str = "/data/turn.json";
|
||||
const MAX_STEPS: usize = 40;
|
||||
const MAX_HISTORY: usize = 600;
|
||||
const MAX_TOOL_OUTPUT: usize = 30_000;
|
||||
const MAX_NOTES: usize = 50;
|
||||
|
||||
fn main() {
|
||||
wstd::runtime::block_on(async {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
wstd::runtime::spawn(read_stdin(tx)).detach();
|
||||
Agent::new(rx).run().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Read NDJSON messages from stdin into the channel.
|
||||
async fn read_stdin(tx: Sender<Value>) {
|
||||
let mut stdin = wstd::io::stdin();
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = vec![0u8; 16 * 1024];
|
||||
loop {
|
||||
match stdin.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(&line)
|
||||
&& tx.send(v).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.close();
|
||||
}
|
||||
|
||||
fn truncate(mut s: String, max: usize) -> String {
|
||||
if s.len() > max {
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let total = s.len();
|
||||
s.truncate(end);
|
||||
s.push_str(&format!("\n… (truncated, {total} bytes in total)"));
|
||||
}
|
||||
s
|
||||
wstd::runtime::block_on(async { Agent::new().run().await });
|
||||
}
|
||||
|
||||
fn tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "send_message",
|
||||
description: "Send a message to an actor. With wait_seconds > 0, wait for the reply (a message whose reply_to is the ID of this message). Returns the message ID, the reply (or null), and other messages that arrived meanwhile.",
|
||||
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.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {"type": "string", "description": "Actor ID"},
|
||||
"body": {"type": "object", "description": "Message body. Must have a \"type\" field."},
|
||||
"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."}
|
||||
},
|
||||
"required": ["to", "body"]
|
||||
@@ -87,7 +35,7 @@ fn tools() -> Vec<Tool> {
|
||||
},
|
||||
Tool {
|
||||
name: "wait_for_messages",
|
||||
description: "Wait up to `seconds` for messages to arrive and return them.",
|
||||
description: "Wait up to seconds for messages to arrive and return them.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"seconds": {"type": "number"}},
|
||||
@@ -102,7 +50,7 @@ fn tools() -> Vec<Tool> {
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||
"max_bytes": {"type": "integer", "description": "Default 30000"}
|
||||
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
@@ -110,15 +58,11 @@ fn tools() -> Vec<Tool> {
|
||||
Tool {
|
||||
name: "list_dir",
|
||||
description: "List a folder.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"]
|
||||
}),
|
||||
parameters: json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
|
||||
},
|
||||
Tool {
|
||||
name: "write_file",
|
||||
description: "Write a text file (only under /data or /public). Creates parent folders.",
|
||||
description: "Write a text file under /data or /public. Creates parent folders.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
||||
@@ -137,18 +81,14 @@ fn tools() -> Vec<Tool> {
|
||||
Tool {
|
||||
name: "remove_path",
|
||||
description: "Remove a file or folder under /data or /public.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"]
|
||||
}),
|
||||
parameters: json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn copy_tree(from: &str, to: &str) -> std::io::Result<u64> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
let mut n = 0;
|
||||
let mut count = 0;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
@@ -156,87 +96,94 @@ fn copy_tree(from: &str, to: &str) -> std::io::Result<u64> {
|
||||
let dst = format!("{to}/{name}");
|
||||
if entry.file_type()?.is_dir() {
|
||||
if name != "target" {
|
||||
n += copy_tree(&src, &dst)?;
|
||||
count += copy_tree(&src, &dst)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::copy(&src, &dst)?;
|
||||
n += 1;
|
||||
std::fs::copy(src, dst)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
rx: Receiver<Value>,
|
||||
stdout: wstd::io::Stdout,
|
||||
inbox: Inbox,
|
||||
history: Vec<Entry>,
|
||||
/// Text messages that start a new turn later.
|
||||
queue: VecDeque<Value>,
|
||||
/// Other messages that arrived while the agent was idle.
|
||||
notes: Vec<Value>,
|
||||
seq: u64,
|
||||
queue: VecDeque<Message>,
|
||||
notes: Vec<Message>,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
fn new(rx: Receiver<Value>) -> Self {
|
||||
fn new() -> Self {
|
||||
let history = std::fs::read_to_string(HISTORY)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str(line).ok())
|
||||
.collect();
|
||||
Self {
|
||||
rx,
|
||||
stdout: wstd::io::stdout(),
|
||||
inbox: Inbox::start(),
|
||||
history,
|
||||
queue: VecDeque::new(),
|
||||
notes: Vec::new(),
|
||||
seq: 0,
|
||||
shutdown: false,
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
eprintln!("agent: started with {} history entries", self.history.len());
|
||||
self.resume().await;
|
||||
while !self.shutdown {
|
||||
let msg = match self.queue.pop_front() {
|
||||
Some(m) => m,
|
||||
None => match self.rx.recv().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => break,
|
||||
Some(msg) => msg,
|
||||
None => match self.inbox.recv().await {
|
||||
Some(msg) => msg,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
if msg["body"]["type"] == "shutdown" {
|
||||
break;
|
||||
}
|
||||
if starts_turn(&msg) {
|
||||
self.turn(msg).await;
|
||||
} else {
|
||||
self.notes.push(msg);
|
||||
if self.notes.len() > MAX_NOTES {
|
||||
self.notes.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.save();
|
||||
eprintln!("agent: stopped");
|
||||
}
|
||||
|
||||
/// Continue a turn that a restart (usually an upgrade of the agent itself)
|
||||
/// interrupted.
|
||||
async fn resume(&mut self) {
|
||||
let Some(turn) = std::fs::read_to_string(TURN).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()) else {
|
||||
let Ok(turn) = std::fs::read_to_string(TURN) else {
|
||||
return;
|
||||
};
|
||||
let Ok(turn) = serde_json::from_str::<Value>(&turn) else {
|
||||
return;
|
||||
};
|
||||
let Ok(contents) = std::fs::read_to_string(CONFIG) else {
|
||||
return;
|
||||
};
|
||||
let Ok(cfg) = serde_json::from_str::<Config>(&contents) else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(TURN);
|
||||
let Ok(cfg) = std::fs::read_to_string(CONFIG).map_err(|_| ()).and_then(|s| serde_json::from_str::<Config>(&s).map_err(|_| ())) else {
|
||||
return;
|
||||
};
|
||||
// Every tool call needs a result, or the APIs reject the history.
|
||||
let answered: std::collections::HashSet<String> = self
|
||||
|
||||
let answered: HashSet<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
@@ -244,164 +191,164 @@ impl Agent {
|
||||
let dangling: Vec<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in dangling {
|
||||
self.history.push(Entry::ToolResult {
|
||||
self.add_history(Entry::ToolResult {
|
||||
id,
|
||||
output: json!({"error": "interrupted: the agent restarted before this tool finished"}).to_string(),
|
||||
output:
|
||||
json!({"error": "interrupted: the agent restarted before this tool finished"})
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
|
||||
self.history.push(Entry::User {
|
||||
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."
|
||||
),
|
||||
});
|
||||
eprintln!("agent: resuming an interrupted turn");
|
||||
self.steps(&cfg, turn["from"].clone(), turn["id"].clone()).await;
|
||||
let message = Message {
|
||||
id: turn["id"].as_str().unwrap_or_default().to_string(),
|
||||
from: turn["from"].as_str().unwrap_or_default().to_string(),
|
||||
reply_to: None,
|
||||
body: Value::Null,
|
||||
};
|
||||
self.steps(&cfg, &message).await;
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
if self.history.len() > MAX_HISTORY {
|
||||
let start = self.history.len() - MAX_HISTORY;
|
||||
let cut = (start..self.history.len())
|
||||
.find(|i| matches!(self.history[*i], Entry::User { .. }))
|
||||
.unwrap_or(start);
|
||||
self.history.drain(..cut);
|
||||
}
|
||||
if let Ok(s) = serde_json::to_string(&self.history)
|
||||
&& let Err(e) = std::fs::write(HISTORY, s)
|
||||
async fn turn(&mut self, message: Message) {
|
||||
let cfg = match 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()))
|
||||
{
|
||||
eprintln!("agent: cannot save history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit(&mut self, msg: Value) {
|
||||
let mut line = msg.to_string();
|
||||
line.push('\n');
|
||||
if self.stdout.write_all(line.as_bytes()).await.is_err() || self.stdout.flush().await.is_err() {
|
||||
eprintln!("agent: cannot write to stdout");
|
||||
}
|
||||
}
|
||||
|
||||
async fn reply(&mut self, to: &Value, reply_to: &Value, text: String) {
|
||||
self.emit(json!({"to": to, "reply_to": reply_to, "body": {"type": "text", "text": text}})).await;
|
||||
}
|
||||
|
||||
/// Sort a message that arrived during a turn.
|
||||
fn stash(&mut self, m: Value, others: &mut Vec<Value>) {
|
||||
if m["body"]["type"] == "shutdown" {
|
||||
self.shutdown = true;
|
||||
} else if starts_turn(&m) {
|
||||
others.push(json!({"queued_for_a_later_turn": true, "message": m}));
|
||||
self.queue.push_back(m);
|
||||
} else {
|
||||
others.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
async fn turn(&mut self, msg: Value) {
|
||||
let from = msg["from"].clone();
|
||||
let id = msg["id"].clone();
|
||||
let cfg: Config = match std::fs::read_to_string(CONFIG).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) {
|
||||
Ok(c) => c,
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
self.reply(&from, &id, format!("I am not configured: cannot read {CONFIG}: {e}")).await;
|
||||
let _ = reply(
|
||||
&message,
|
||||
json!({"type": "text", "text": format!("I am not configured: cannot read {CONFIG}: {e}")}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut text = format!(
|
||||
"Message {} from {}:\n{}",
|
||||
id.as_str().unwrap_or("?"),
|
||||
from.as_str().unwrap_or("?"),
|
||||
msg["body"]["text"].as_str().unwrap_or("")
|
||||
message.id,
|
||||
message.from,
|
||||
message.body["text"].as_str().unwrap_or("")
|
||||
);
|
||||
if !self.notes.is_empty() {
|
||||
let notes: Vec<String> = self.notes.drain(..).map(|n| n.to_string()).collect();
|
||||
text = format!("Messages that arrived while you were idle:\n{}\n\n{text}", notes.join("\n"));
|
||||
let notes = self
|
||||
.notes
|
||||
.drain(..)
|
||||
.map(|note| serde_json::to_string(¬e).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
text = format!("Messages that arrived while you were idle:\n{notes}\n\n{text}");
|
||||
}
|
||||
self.history.push(Entry::User { text });
|
||||
self.steps(&cfg, from, id).await;
|
||||
self.add_history(Entry::User { text });
|
||||
self.steps(&cfg, &message).await;
|
||||
}
|
||||
|
||||
/// Run inference and tools until the model answers. If a shutdown
|
||||
/// interrupts the turn, remember it in TURN so the next start resumes it.
|
||||
async fn steps(&mut self, cfg: &Config, from: Value, id: Value) {
|
||||
async fn steps(&mut self, cfg: &Config, message: &Message) {
|
||||
let tools = tools();
|
||||
let cfg = cfg.clone();
|
||||
for _ in 0..MAX_STEPS {
|
||||
let started = Instant::now();
|
||||
let result = llm::complete(&cfg, SYSTEM, &tools, &self.history).await;
|
||||
let entries = match result {
|
||||
Ok(e) => e,
|
||||
loop {
|
||||
let entries = match llm::complete(cfg, SYSTEM, &tools, &self.history).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
eprintln!("agent: inference failed: {e}");
|
||||
self.history.push(Entry::Assistant { text: format!("(inference failed: {e})") });
|
||||
self.save();
|
||||
self.reply(&from, &id, format!("Inference failed: {e}")).await;
|
||||
self.add_history(Entry::Assistant {
|
||||
text: format!("(inference failed: {e})"),
|
||||
});
|
||||
let _ = reply(
|
||||
message,
|
||||
json!({"type": "text", "text": format!("Inference failed: {e}")}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
eprintln!("agent: inference took {:?}", started.elapsed());
|
||||
let calls: Vec<(String, String, String)> = entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::ToolCall { id, name, arguments } => Some((id.clone(), name.clone(), arguments.clone())),
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => Some((id.clone(), name.clone(), arguments.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let answer: Vec<String> = entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::Assistant { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.history.extend(entries);
|
||||
for entry in entries {
|
||||
self.add_history(entry);
|
||||
}
|
||||
if calls.is_empty() {
|
||||
self.save();
|
||||
let answer = if answer.is_empty() { "(no answer)".to_string() } else { answer.join("\n\n") };
|
||||
self.reply(&from, &id, answer).await;
|
||||
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 (call_id, name, args) in calls {
|
||||
for (id, name, args) in calls {
|
||||
let output = self.tool(&name, &args).await;
|
||||
self.history.push(Entry::ToolResult { id: call_id, output: truncate(output.to_string(), MAX_TOOL_OUTPUT) });
|
||||
self.add_history(Entry::ToolResult {
|
||||
id,
|
||||
output: output.to_string(),
|
||||
});
|
||||
}
|
||||
self.save();
|
||||
if self.shutdown {
|
||||
let _ = std::fs::write(TURN, json!({"from": from, "id": id}).to_string());
|
||||
eprintln!("agent: turn interrupted by shutdown; it resumes after the restart");
|
||||
let _ = std::fs::write(
|
||||
TURN,
|
||||
json!({"from": message.from, "id": message.id}).to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.reply(&from, &id, format!("I stopped after {MAX_STEPS} steps. Tell me to continue if you want.")).await;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool(&mut self, name: &str, args: &str) -> Value {
|
||||
let a: Value = match serde_json::from_str(args) {
|
||||
Ok(v) => v,
|
||||
Ok(value) => value,
|
||||
Err(e) => return json!({"error": format!("arguments are not valid JSON: {e}")}),
|
||||
};
|
||||
let path = |k: &str| a[k].as_str().unwrap_or_default().to_string();
|
||||
let path = |key: &str| a[key].as_str().unwrap_or_default().to_string();
|
||||
let err = |e: std::io::Error| json!({"error": e.to_string()});
|
||||
match name {
|
||||
"send_message" => self.send_message(&a).await,
|
||||
"wait_for_messages" => {
|
||||
let secs = a["seconds"].as_f64().unwrap_or(10.0).clamp(0.0, 900.0);
|
||||
let seconds = a["seconds"].as_f64().unwrap_or(10.0).max(0.0);
|
||||
let mut others = Vec::new();
|
||||
match self.rx.recv().timeout(Duration::from_secs_f64(secs)).await {
|
||||
Ok(Ok(m)) => {
|
||||
self.stash(m, &mut others);
|
||||
while let Ok(m) = self.rx.try_recv() {
|
||||
self.stash(m, &mut others);
|
||||
match self
|
||||
.inbox
|
||||
.recv()
|
||||
.timeout(Duration::from_secs_f64(seconds))
|
||||
.await
|
||||
{
|
||||
Ok(Some(message)) => {
|
||||
self.stash(message, &mut others);
|
||||
while let Some(message) = self.inbox.try_recv() {
|
||||
self.stash(message, &mut others);
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => self.shutdown = true,
|
||||
Ok(None) => self.shutdown = true,
|
||||
Err(_) => {}
|
||||
}
|
||||
json!({"messages": others})
|
||||
@@ -411,8 +358,9 @@ impl Agent {
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
let offset = (a["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
|
||||
let max = a["max_bytes"].as_u64().unwrap_or(30_000) as usize;
|
||||
let end = (offset + max).min(bytes.len());
|
||||
let end = a["max_bytes"].as_u64().map_or(bytes.len(), |max| {
|
||||
offset.saturating_add(max as usize).min(bytes.len())
|
||||
});
|
||||
json!({
|
||||
"path": p, "size": bytes.len(), "offset": offset,
|
||||
"content": String::from_utf8_lossy(&bytes[offset..end]),
|
||||
@@ -425,12 +373,11 @@ impl Agent {
|
||||
"list_dir" => match std::fs::read_dir(path("path")) {
|
||||
Ok(rd) => {
|
||||
let mut entries: Vec<Value> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.take(500)
|
||||
.map(|e| {
|
||||
let meta = e.metadata().ok();
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| {
|
||||
let meta = entry.metadata().ok();
|
||||
json!({
|
||||
"name": e.file_name().to_string_lossy(),
|
||||
"name": entry.file_name().to_string_lossy(),
|
||||
"type": if meta.as_ref().is_some_and(|m| m.is_dir()) { "dir" } else { "file" },
|
||||
"size": meta.map(|m| m.len()),
|
||||
})
|
||||
@@ -452,13 +399,17 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
"copy_dir" => match copy_tree(&path("from"), &path("to")) {
|
||||
Ok(n) => json!({"copied_files": n}),
|
||||
Ok(count) => json!({"copied_files": count}),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"remove_path" => {
|
||||
let p = path("path");
|
||||
let r = if std::path::Path::new(&p).is_dir() { std::fs::remove_dir_all(&p) } else { std::fs::remove_file(&p) };
|
||||
match r {
|
||||
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),
|
||||
}
|
||||
@@ -467,34 +418,32 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_message(&mut self, a: &Value) -> Value {
|
||||
let Some(to) = a["to"].as_str() else { return json!({"error": "missing \"to\""}) };
|
||||
if !a["body"].is_object() {
|
||||
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"});
|
||||
}
|
||||
self.seq += 1;
|
||||
let own = format!("a{}-{}", wstd_now_ms(), self.seq);
|
||||
let full = format!("agent/{own}");
|
||||
self.emit(json!({"to": to, "id": own, "body": a["body"]})).await;
|
||||
let wait = a["wait_seconds"].as_f64().unwrap_or(0.0).clamp(0.0, 900.0);
|
||||
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.rx.recv().timeout(deadline).await {
|
||||
Ok(Ok(m)) => {
|
||||
if m["reply_to"] == full.as_str() {
|
||||
reply = m;
|
||||
break;
|
||||
}
|
||||
self.stash(m, &mut others);
|
||||
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);
|
||||
if self.shutdown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
// stdin closed: the broker stops us.
|
||||
Ok(None) => {
|
||||
self.shutdown = true;
|
||||
break;
|
||||
}
|
||||
@@ -502,19 +451,10 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"id": full, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
||||
json!({"id": id, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
||||
}
|
||||
}
|
||||
|
||||
/// A text message that is not a reply starts a turn. Replies never do, so
|
||||
/// two actors cannot keep the agent in an endless conversation.
|
||||
fn starts_turn(m: &Value) -> bool {
|
||||
m["body"]["type"] == "text" && m.get("reply_to").is_none_or(Value::is_null)
|
||||
}
|
||||
|
||||
fn wstd_now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
fn starts_turn(message: &Message) -> bool {
|
||||
message.kind() == "text" && message.reply_to.is_none()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user