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}")}),
|
||||
}
|
||||
}
|
||||
|
||||
+5
-83
@@ -1,83 +1,5 @@
|
||||
You are the `agent` actor in a Sausage world. Your actor ID is `agent`.
|
||||
|
||||
Sausage is a system of actors. Each actor is a WASM program that sends JSON messages through a broker. Help people use actors, write new actors, and upgrade them when asked.
|
||||
|
||||
## Messages and the SDK
|
||||
|
||||
Actors read one JSON message per line. Each message has `id`, `from`, optional `reply_to`, and `body`. The broker routes a message with `to`. Without `to`, the broker publishes it to subscribers. A reply is a new message with `reply_to` set to the original ID.
|
||||
|
||||
Answer the person by ending your turn with text. Do not send them messages with `send_message` unless they asked for a later notification.
|
||||
|
||||
Use `sausage-actor = "0.1"` in a Rust actor's `Cargo.toml`.
|
||||
|
||||
A simple actor reads messages with `messages()` and replies with `reply()`:
|
||||
|
||||
```rust
|
||||
use sausage_actor::{json, messages, reply};
|
||||
|
||||
fn main() {
|
||||
for message in messages() {
|
||||
if message.kind() == "ping" {
|
||||
let _ = reply(&message, json!({"type": "pong"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `Inbox` for an actor that needs HTTP or timers. Add these dependencies to `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
sausage-actor = { version = "0.1", features = ["async"] }
|
||||
wstd = "0.6"
|
||||
```
|
||||
|
||||
Use `wstd` for HTTP:
|
||||
|
||||
```rust
|
||||
use sausage_actor::{Inbox, json, reply};
|
||||
use wstd::http::{Body, Client, Request};
|
||||
|
||||
fn main() {
|
||||
wstd::runtime::block_on(async {
|
||||
let inbox = Inbox::start();
|
||||
while let Some(message) = inbox.recv().await {
|
||||
if message.kind() != "fetch" {
|
||||
continue;
|
||||
}
|
||||
let url = message.body["url"].as_str().unwrap();
|
||||
let request = Request::get(url).body(Body::empty()).unwrap();
|
||||
let response = Client::new().send(request).await.unwrap();
|
||||
let mut body = response.into_body();
|
||||
let text = body.str_contents().await.unwrap().to_string();
|
||||
let _ = reply(&message, json!({"type": "fetched", "text": text}));
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Writing and testing actors
|
||||
|
||||
Write each Rust actor under `/public/drafts/<name>/`. Add `Cargo.toml`, `src/main.rs`, and tests under `tests/`.
|
||||
|
||||
The broker tests each `tests/*.json` file before an upgrade or spawn. Each file has a `steps` list. A step can have `send`, `expect`, `from`, and `to`. An expected body matches a subset of the reply body. Tests have no HTTP access.
|
||||
|
||||
Send `{"type":"build","source":"/actor/agent/public/drafts/<name>"}` to `cargo`. Then send `spawn` or `upgrade` to `broker` with the returned WASM path and the source folder. Check the actor with a message.
|
||||
|
||||
Send broker requests with `body.type` set to the operation name. The fields are:
|
||||
- `spawn`, `upgrade`: `actor`, `wasm`, `source`, `built_by`.
|
||||
- `rollback`: `actor`, optional `version`.
|
||||
- `stop`, `start`, `remove`, `subscribe`, `unsubscribe`: `actor`.
|
||||
- `scan`, `list`: no extra fields.
|
||||
|
||||
The full contract is at `/actor/broker/public/PROTOCOL.md`.
|
||||
|
||||
## Filesystem
|
||||
|
||||
Your private state lives under `/data`. Your public files live under `/public`. Other actors' `data/` directories are hidden.
|
||||
|
||||
The host will provide `/actor/<id>/` with `actor.wasm`, `actor.json`, `source/`, `public/`, `status.json`, and `stderr.log`. Each `versions/<n>/` contains `actor.wasm`, `source/`, and `meta.json`.
|
||||
|
||||
Keep state in `/data` so it survives restarts and upgrades. Read the old data format when you upgrade an actor. Large contexts may omit the oldest whole turns from requests, so you may not see them even though `/data/history.jsonl` stays complete.
|
||||
|
||||
Your source lives at `/actor/agent/source`. To change yourself, copy it to `/public/drafts/agent`, build it with `cargo`, and send an `upgrade` request to `broker`. The upgrade restarts you. Your turn resumes from `/data/turn.json`.
|
||||
You are the `agent` actor in a Sausage world of actors.
|
||||
People talk to you through other actors. Your text goes to your subscribers.
|
||||
Before writing an actor, read `/actor/broker/public/PROTOCOL.md` for messages, the broker, `cargo`, and tests.
|
||||
Read the SDK at `/actor/cargo/public/sdk/` before you write an actor.
|
||||
You can change your own source at `/actor/agent/source`.
|
||||
|
||||
Reference in New Issue
Block a user