Sausage spike: host (broker, supervisors, commit-on-close fs, cargo actor, console), agent actor, examples

This commit is contained in:
2026-10-15 04:47:00 +01:00
commit 83bbbb33a8
29 changed files with 6602 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
//! Inference through two API shapes: OpenAI Responses and Anthropic Messages.
//!
//! The conversation is stored in a neutral format, so the agent can switch
//! between the two shapes.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wstd::http::{Body, Client, Request};
use wstd::time::Duration;
#[derive(Deserialize, Clone)]
pub struct Config {
/// `openai-responses` or `anthropic-messages`.
pub api: String,
pub base_url: String,
pub model: String,
pub token: String,
/// Optional reasoning effort for OpenAI models ("low", "medium", ...).
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
}
fn default_max_tokens() -> u32 {
16000
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Entry {
User { text: String },
Assistant { text: String },
ToolCall { id: String, name: String, arguments: String },
ToolResult { id: String, output: String },
/// A provider item that only this API shape understands (OpenAI reasoning).
Opaque { api: String, item: Value },
}
pub struct Tool {
pub name: &'static str,
pub description: &'static str,
pub parameters: Value,
}
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
match cfg.api.as_str() {
"openai-responses" => openai(cfg, system, tools, history).await,
"anthropic-messages" => anthropic(cfg, system, tools, history).await,
other => Err(format!("unknown API shape {other:?}")),
}
}
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 mut client = Client::new();
client.set_first_byte_timeout(Duration::from_secs(600));
client.set_between_bytes_timeout(Duration::from_secs(600));
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();
if !status.is_success() {
let mut t = text;
t.truncate(2000);
return Err(format!("HTTP {status}: {t}"));
}
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]) -> Result<Vec<Entry>, String> {
let mut input = Vec::new();
for e in history {
match e {
Entry::User { text } => input.push(json!({"role": "user", "content": text})),
Entry::Assistant { text } => input.push(json!({"role": "assistant", "content": text})),
Entry::ToolCall { id, name, arguments } => {
input.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}))
}
Entry::ToolResult { id, output } => {
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
}
Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
Entry::Opaque { .. } => {}
}
}
let tools: Vec<Value> = tools
.iter()
.map(|t| json!({"type": "function", "name": t.name, "description": t.description, "parameters": t.parameters}))
.collect();
let mut body = json!({
"model": cfg.model,
"instructions": system,
"input": input,
"tools": tools,
"store": false,
"include": ["reasoning.encrypted_content"],
"max_output_tokens": cfg.max_tokens,
});
if let Some(effort) = &cfg.reasoning_effort {
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 mut out = Vec::new();
for item in resp["output"].as_array().cloned().unwrap_or_default() {
match item["type"].as_str() {
Some("message") => {
let text: String = item["content"]
.as_array()
.map(|parts| {
parts
.iter()
.filter(|p| p["type"] == "output_text")
.filter_map(|p| p["text"].as_str())
.collect()
})
.unwrap_or_default();
if !text.is_empty() {
out.push(Entry::Assistant { text });
}
}
Some("function_call") => out.push(Entry::ToolCall {
id: item["call_id"].as_str().unwrap_or_default().to_string(),
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 }),
_ => {}
}
}
if resp["status"] == "incomplete" && out.is_empty() {
return Err(format!("incomplete response: {}", resp["incomplete_details"]));
}
Ok(out)
}
async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
// Merge consecutive blocks of the same role into one message.
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
let mut push = |role: &str, block: Value| match messages.last_mut() {
Some((r, blocks)) if r == role => blocks.push(block),
_ => messages.push((role.to_string(), vec![block])),
};
for e in history {
match e {
Entry::User { text } => push("user", json!({"type": "text", "text": text})),
Entry::Assistant { text } => push("assistant", json!({"type": "text", "text": text})),
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}))
}
Entry::Opaque { .. } => {}
}
}
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}))
.collect();
let body = json!({
"model": cfg.model,
"system": system,
"messages": messages,
"tools": tools,
"max_tokens": cfg.max_tokens,
});
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())],
&body,
)
.await?;
let mut out = Vec::new();
for block in resp["content"].as_array().cloned().unwrap_or_default() {
match block["type"].as_str() {
Some("text") => {
let text = block["text"].as_str().unwrap_or_default().to_string();
if !text.is_empty() {
out.push(Entry::Assistant { text });
}
}
Some("tool_use") => out.push(Entry::ToolCall {
id: block["id"].as_str().unwrap_or_default().to_string(),
name: block["name"].as_str().unwrap_or_default().to_string(),
arguments: block["input"].to_string(),
}),
_ => {}
}
}
Ok(out)
}
+452
View File
@@ -0,0 +1,452 @@
//! 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).
mod llm;
use std::collections::VecDeque;
use async_channel::{Receiver, Sender};
use llm::{Config, Entry, Tool};
use serde_json::{Value, json};
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";
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
}
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.",
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."}
},
"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>/...",
parameters: json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
"max_bytes": {"type": "integer", "description": "Default 30000"}
},
"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 (only under /data or /public). Creates parent folders.",
parameters: json!({
"type": "object",
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
"required": ["path", "content"]
}),
},
Tool {
name: "copy_dir",
description: "Copy a folder 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"]
}),
},
]
}
fn copy_tree(from: &str, to: &str) -> std::io::Result<u64> {
std::fs::create_dir_all(to)?;
let mut n = 0;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let src = format!("{from}/{name}");
let dst = format!("{to}/{name}");
if entry.file_type()?.is_dir() {
if name != "target" {
n += copy_tree(&src, &dst)?;
}
} else {
std::fs::copy(&src, &dst)?;
n += 1;
}
}
Ok(n)
}
struct Agent {
rx: Receiver<Value>,
stdout: wstd::io::Stdout,
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,
shutdown: bool,
}
impl Agent {
fn new(rx: Receiver<Value>) -> Self {
let history = std::fs::read_to_string(HISTORY)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Self {
rx,
stdout: wstd::io::stdout(),
history,
queue: VecDeque::new(),
notes: Vec::new(),
seq: 0,
shutdown: false,
}
}
async fn run(mut self) {
eprintln!("agent: started with {} history entries", self.history.len());
while !self.shutdown {
let msg = match self.queue.pop_front() {
Some(m) => m,
None => match self.rx.recv().await {
Ok(m) => m,
Err(_) => 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");
}
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)
{
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,
Err(e) => {
self.reply(&from, &id, format!("I am not configured: cannot read {CONFIG}: {e}")).await;
return;
}
};
let mut text = format!("Message {} from {}:\n{}", id, from.as_str().unwrap_or("?"), msg["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"));
}
self.history.push(Entry::User { text });
let tools = tools();
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,
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;
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())),
_ => None,
})
.collect();
let answer: Vec<String> = entries
.iter()
.filter_map(|e| match e {
Entry::Assistant { text } => Some(text.clone()),
_ => None,
})
.collect();
self.history.extend(entries);
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;
return;
}
for (call_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.save();
if self.shutdown {
return;
}
}
self.reply(&from, &id, format!("I stopped after {MAX_STEPS} steps. Tell me to continue if you want.")).await;
}
async fn tool(&mut self, name: &str, args: &str) -> Value {
let a: Value = match serde_json::from_str(args) {
Ok(v) => v,
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 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 mut others = Vec::new();
if let Ok(Ok(m)) = self.rx.recv().timeout(Duration::from_secs_f64(secs)).await {
self.stash(m, &mut others);
while let Ok(m) = self.rx.try_recv() {
self.stash(m, &mut others);
}
}
json!({"messages": others})
}
"read_file" => {
let p = path("path");
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());
json!({
"path": p, "size": bytes.len(), "offset": offset,
"content": String::from_utf8_lossy(&bytes[offset..end]),
"truncated": end < bytes.len(),
})
}
Err(e) => err(e),
}
}
"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();
json!({
"name": e.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()),
})
})
.collect();
entries.sort_by(|x, y| x["name"].as_str().cmp(&y["name"].as_str()));
json!({"entries": entries})
}
Err(e) => err(e),
},
"write_file" => {
let p = path("path");
if let Some(parent) = std::path::Path::new(&p).parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&p, a["content"].as_str().unwrap_or_default()) {
Ok(()) => json!({"written": p}),
Err(e) => err(e),
}
}
"copy_dir" => match copy_tree(&path("from"), &path("to")) {
Ok(n) => json!({"copied_files": n}),
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 {
Ok(()) => json!({"removed": p}),
Err(e) => err(e),
}
}
other => json!({"error": format!("unknown tool {other}")}),
}
}
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() {
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 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);
if self.shutdown {
break;
}
}
_ => break,
}
}
}
json!({"id": full, "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)
}
+80
View File
@@ -0,0 +1,80 @@
You are the agent of a Sausage world. Your actor ID is `agent`.
Sausage is a system of actors. Each actor is a WASI program (a `.wasm` file). Actors talk only through messages. People talk to you through other actors, for example `console` (a terminal) or later `matrix`. You help them by using actors, and by writing, testing, and upgrading actors. You can also change your own code.
# Messages
Every message has a sender (`from`), an ID, an optional `reply_to` (the ID of the message it answers), and a JSON `body`. A body is an object with a `"type"` field. There is no request/response: a reply is a new message with `reply_to` set. Delivery is at most once.
A WASM actor reads one JSON object per line on stdin:
`{"id":"m-17","from":"console","body":{...}}`
and writes one JSON object per line on stdout:
`{"to":"console","reply_to":"m-17","body":{...}}`
Without `"to"`, the broker publishes the message to the subscribers of the sender. An actor may set its own `"id"`. The broker then uses `"<sender>/<id>"`. stdout is only for messages. Logs go to stderr. The broker sends `{"type":"shutdown"}` before it stops an actor. EOF on stdin means "stop now".
# Filesystem
Each actor sees:
- `/data`: private state, read-write. Only the actor itself sees it.
- `/public`: its public files, read-write.
- `/actor/<id>/public`: the public files of every actor, read-only.
- `/actor/<id>/source`: the source of the running version of every actor, read-only.
- `/actor/<id>/status.json` and `/actor/<id>/stderr.log`: state and stderr log of every actor.
`ls /actor` lists all actors. A write becomes visible to other actors only when the writer closes the file. Send large or binary data as a file path, not in a message.
# Built-in actors
`broker` (only accepts `.wasm` files, knows no source language):
- `{"type":"spawn","actor":"<new-id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
- `{"type":"upgrade","actor":"<id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
- `{"type":"rollback","actor":"<id>"}` (optional `"version"`)
- `{"type":"stop","actor":"<id>"}`, `{"type":"start","actor":"<id>"}`, `{"type":"remove","actor":"<id>"}` (archives, never deletes)
- `{"type":"subscribe","actor":"<id>"}`, `{"type":"unsubscribe","actor":"<id>"}`, `{"type":"list"}`
Before an upgrade or spawn, the broker tests the candidate on a copy of the actor's `/data` and `/public`, with no HTTP access. The tests are message transcripts in `<source>/tests/*.jsonl`. Each line is `{"send": <body>}` or `{"expect": <subset of the reply body>}` (optional `"to"`). Without test files, the candidate must start and stay up for 2 seconds. If a new version crashes 3 times within 30 seconds, the broker rolls it back automatically. The broker keeps the message that crashed an actor and delivers it again after the next upgrade.
`cargo` (built-in Rust compiler):
- `{"type":"build","source":"<folder with Cargo.toml>"}` → `{"type":"built","wasm":"/actor/cargo/public/builds/<n>/<name>.wasm",...}` or `{"type":"build_failed","errors":"..."}`. A build can take a minute. Wait up to 600 seconds for the reply.
# Writing an actor in Rust
1. Write the crate to `/public/drafts/<name>/`: `Cargo.toml`, `src/main.rs`, and `tests/*.jsonl`. Use `edition = "2024"`. Use `serde_json` for messages.
2. Send `build` to `cargo` with the folder path `/actor/agent/public/drafts/<name>` and wait for the reply. Fix compiler errors and build again.
3. Send `spawn` (new actor) or `upgrade` (existing actor) to `broker` with the `wasm` path from the build reply and the same `source` folder. Wait for the reply.
4. Send a message to the new actor to check it.
A simple actor that only reacts to messages can block on stdin:
```rust
use serde_json::{json, Value};
use std::io::{BufRead, Write};
fn main() {
let mut out = std::io::stdout().lock();
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
let body = &msg["body"];
let reply = match body["type"].as_str() {
Some("shutdown") => break,
Some("ping") => json!({"type": "pong"}),
_ => json!({"type": "error", "reason": "unknown message"}),
};
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
out.flush().unwrap();
}
}
```
An actor that needs HTTP or timers and must also read messages uses the async `wstd` crate (`wstd = "0.6"`): `#[wstd::main] async fn main()`, `wstd::io::stdin()` (`AsyncRead`), `wstd::io::stdout()` (`AsyncWrite`, `write_all`, `flush`), `wstd::http::{Client, Request, Body}` (`Client::new().send(Request::get(url).body(Body::empty())?).await?`, then `response.into_body().contents().await?`), `wstd::task::sleep(wstd::time::Duration::from_secs(n)).await`, and `wstd::runtime::spawn(future)`. Actors have outgoing HTTP and HTTPS only, no raw sockets. Threads are not available. Store state in `/data` so it survives restarts and upgrades. A new version must read the data format of the old version.
# Changing yourself
Your own source is at `/actor/agent/source`. To change yourself, copy it to `/public/drafts/agent`, edit the copy, build it, and upgrade `agent`. The upgrade restarts you, and the conversation continues from `/data`. Be careful: if the new version cannot answer, the person must roll you back through the console. Change yourself only when the person asks for it.
# How to work
- Use tools to look at the real state before you claim anything. Read files instead of guessing.
- Keep answers short and concrete. Say what you changed and whether it worked.
- Your final text in a turn goes back to the person who wrote to you.