From 6160419de1669075a9c30f11d574b04c1379feae Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 19:27:12 +0800 Subject: [PATCH] feat(awn): implement daemon stop, fix install.sh - Add POST /ipc/shutdown endpoint for graceful daemon shutdown - Implement 'awn daemon stop' with IPC call + PID file SIGTERM fallback - Write/clean PID file (daemon.pid) alongside port file - Fix install.sh: robust version parsing, default to ~/.local/bin, add PATH hint, no sudo required --- .changeset/fix-install-script.md | 5 + packages/awn-cli/Cargo.lock | 1 + packages/awn-cli/Cargo.toml | 1 + packages/awn-cli/install.sh | 31 +- packages/awn-cli/src/daemon.rs | 47 ++- packages/awn-cli/src/main.rs | 76 +++- skills/awn/SKILL.md | 161 ++++---- skills/awn/references/flows.md | 103 ++---- skills/awn/references/install.md | 56 ++- src/index.ts | 416 --------------------- test/index-lifecycle.test.mjs | 617 ------------------------------- 11 files changed, 285 insertions(+), 1229 deletions(-) create mode 100644 .changeset/fix-install-script.md diff --git a/.changeset/fix-install-script.md b/.changeset/fix-install-script.md new file mode 100644 index 0000000..65f427c --- /dev/null +++ b/.changeset/fix-install-script.md @@ -0,0 +1,5 @@ +--- +"@resciencelab/agent-world-network": patch +--- + +Fix install.sh, implement `awn daemon stop` with IPC shutdown + PID fallback diff --git a/packages/awn-cli/Cargo.lock b/packages/awn-cli/Cargo.lock index 0837cd5..e1d557f 100644 --- a/packages/awn-cli/Cargo.lock +++ b/packages/awn-cli/Cargo.lock @@ -115,6 +115,7 @@ dependencies = [ "dirs", "ed25519-dalek", "hex", + "libc", "predicates", "rand", "reqwest", diff --git a/packages/awn-cli/Cargo.toml b/packages/awn-cli/Cargo.toml index 6dde356..e8f6d5c 100644 --- a/packages/awn-cli/Cargo.toml +++ b/packages/awn-cli/Cargo.toml @@ -25,6 +25,7 @@ dirs = "6" tracing = "0.1" thiserror = "2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +libc = "0.2" [package.metadata.deb] maintainer = "ReScienceLab " diff --git a/packages/awn-cli/install.sh b/packages/awn-cli/install.sh index a802044..a07352a 100755 --- a/packages/awn-cli/install.sh +++ b/packages/awn-cli/install.sh @@ -3,7 +3,7 @@ set -euo pipefail REPO="ReScienceLab/agent-world-network" BINARY="awn" -INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" info() { printf '\033[1;34m%s\033[0m\n' "$*"; } error() { printf '\033[1;31merror: %s\033[0m\n' "$*" >&2; exit 1; } @@ -29,10 +29,13 @@ detect_target() { } get_latest_version() { - curl -sL "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name"' \ + local tag + tag="$(curl -sL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep -o '"tag_name": *"[^"]*"' \ | head -1 \ - | sed 's/.*"tag_name": *"v\?\([^"]*\)".*/\1/' + | grep -o '"v[^"]*"' \ + | tr -d '"v')" + echo "$tag" } main() { @@ -50,25 +53,27 @@ main() { local url="https://github.com/${REPO}/releases/download/v${version}/${BINARY}-v${version}-${target}.tar.gz" local tmp tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT + trap "rm -rf '$tmp'" EXIT info "Downloading awn v${version} for ${target}..." - curl -sL "$url" -o "${tmp}/awn.tar.gz" || error "Download failed. Check that v${version} has a binary for ${target}." + curl -fsSL "$url" -o "${tmp}/awn.tar.gz" || error "Download failed. Check that v${version} has a binary for ${target}." info "Extracting..." tar xzf "${tmp}/awn.tar.gz" -C "$tmp" + mkdir -p "$INSTALL_DIR" info "Installing to ${INSTALL_DIR}..." - if [ -w "$INSTALL_DIR" ]; then - cp "${tmp}/${BINARY}-v${version}-${target}/${BINARY}" "${INSTALL_DIR}/${BINARY}" - chmod +x "${INSTALL_DIR}/${BINARY}" - else - sudo cp "${tmp}/${BINARY}-v${version}-${target}/${BINARY}" "${INSTALL_DIR}/${BINARY}" - sudo chmod +x "${INSTALL_DIR}/${BINARY}" - fi + cp "${tmp}/${BINARY}-v${version}-${target}/${BINARY}" "${INSTALL_DIR}/${BINARY}" + chmod +x "${INSTALL_DIR}/${BINARY}" info "Done! awn v${version} installed to ${INSTALL_DIR}/${BINARY}" "${INSTALL_DIR}/${BINARY}" --version + + # Hint if INSTALL_DIR is not in PATH + case ":$PATH:" in + *":${INSTALL_DIR}:"*) ;; + *) info "Add ${INSTALL_DIR} to your PATH: export PATH=\"${INSTALL_DIR}:\$PATH\"" ;; + esac } main "$@" diff --git a/packages/awn-cli/src/daemon.rs b/packages/awn-cli/src/daemon.rs index 279bc62..8c34cf9 100644 --- a/packages/awn-cli/src/daemon.rs +++ b/packages/awn-cli/src/daemon.rs @@ -13,6 +13,8 @@ use crate::identity::{self, Identity}; use crate::agent_db::{Endpoint, AgentDb, AgentRecord}; const DEFAULT_IPC_PORT: u16 = 8199; +const PORT_FILE: &str = "daemon.port"; +const PID_FILE: &str = "daemon.pid"; #[derive(Clone)] pub struct DaemonState { @@ -103,11 +105,32 @@ pub async fn start_daemon( listen_port, }; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let (ipc_shutdown_tx, ipc_shutdown_rx) = oneshot::channel::<()>(); + let app = Router::new() .route("/ipc/status", get(handle_status)) .route("/ipc/agents", get(handle_agents)) .route("/ipc/worlds", get(handle_worlds)) .route("/ipc/ping", get(handle_ping)) + .route( + "/ipc/shutdown", + post({ + let tx = Arc::new(std::sync::Mutex::new(Some(ipc_shutdown_tx))); + move || { + let tx = tx.clone(); + async move { + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(()); + } + Json(OkResponse { + ok: true, + message: Some("shutting down".to_string()), + }) + } + } + }), + ) .with_state(state); let addr = SocketAddr::from(([127, 0, 0, 1], ipc_port)); @@ -116,12 +139,13 @@ pub async fn start_daemon( .map_err(|e| DaemonError::Bind(e.to_string()))?; let bound_addr = listener.local_addr().unwrap(); - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - tokio::spawn(async move { axum::serve(listener, app) .with_graceful_shutdown(async { - let _ = shutdown_rx.await; + tokio::select! { + _ = shutdown_rx => {} + _ = ipc_shutdown_rx => {} + } }) .await .ok(); @@ -236,8 +260,6 @@ pub fn default_gateway_url() -> String { std::env::var("GATEWAY_URL").unwrap_or_else(|_| "https://gateway.agentworlds.ai".to_string()) } -const PORT_FILE: &str = "daemon.port"; - pub fn write_port_file(data_dir: &std::path::Path, port: u16) { let _ = std::fs::create_dir_all(data_dir); let _ = std::fs::write(data_dir.join(PORT_FILE), port.to_string()); @@ -253,6 +275,21 @@ pub fn remove_port_file(data_dir: &std::path::Path) { let _ = std::fs::remove_file(data_dir.join(PORT_FILE)); } +pub fn write_pid_file(data_dir: &std::path::Path) { + let _ = std::fs::create_dir_all(data_dir); + let _ = std::fs::write(data_dir.join(PID_FILE), std::process::id().to_string()); +} + +pub fn read_pid_file(data_dir: &std::path::Path) -> Option { + std::fs::read_to_string(data_dir.join(PID_FILE)) + .ok() + .and_then(|s| s.trim().parse().ok()) +} + +pub fn remove_pid_file(data_dir: &std::path::Path) { + let _ = std::fs::remove_file(data_dir.join(PID_FILE)); +} + #[derive(Debug, thiserror::Error)] pub enum DaemonError { #[error("identity error: {0}")] diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index e325194..f781d7a 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -61,6 +61,8 @@ enum DaemonAction { #[tokio::main] async fn main() { let cli = Cli::parse(); + let json_output = cli.json; + let cli_ipc_port = cli.ipc_port; match cli.command { Commands::Daemon { action } => match action { @@ -71,12 +73,13 @@ async fn main() { } => { let data_dir = data_dir.unwrap_or_else(daemon::default_data_dir); let gateway_url = gateway_url.unwrap_or_else(daemon::default_gateway_url); - let ipc_port = cli.ipc_port.unwrap_or_else(|| daemon::ipc_port()); + let ipc_port = cli_ipc_port.unwrap_or_else(|| daemon::ipc_port()); match daemon::start_daemon(data_dir.clone(), gateway_url, port, ipc_port).await { Ok(handle) => { daemon::write_port_file(&data_dir, handle.addr.port()); - if cli.json { + daemon::write_pid_file(&data_dir); + if json_output { println!( "{}", serde_json::json!({ @@ -90,13 +93,14 @@ async fn main() { } tokio::signal::ctrl_c().await.ok(); daemon::remove_port_file(&data_dir); + daemon::remove_pid_file(&data_dir); handle.shutdown(); - if !cli.json { + if !json_output { eprintln!("Daemon stopped"); } } Err(e) => { - if cli.json { + if json_output { println!("{}", serde_json::json!({"error": e.to_string()})); } else { eprintln!("Error: {e}"); @@ -106,21 +110,53 @@ async fn main() { } } DaemonAction::Stop => { - if cli.json { - println!("{}", serde_json::json!({"error": "daemon stop not yet implemented (use Ctrl+C)"})); - } else { - eprintln!("Error: daemon stop not yet implemented. Use Ctrl+C to stop the daemon, or kill the process."); + let data_dir = daemon::default_data_dir(); + let ipc = resolve_ipc_port_raw(cli_ipc_port); + let url = format!("http://127.0.0.1:{ipc}/ipc/shutdown"); + let client = reqwest::Client::new(); + match client.post(&url).send().await { + Ok(resp) if resp.status().is_success() => { + daemon::remove_port_file(&data_dir); + daemon::remove_pid_file(&data_dir); + if json_output { + println!("{}", serde_json::json!({"ok": true, "message": "daemon stopped"})); + } else { + println!("Daemon stopped."); + } + } + _ => { + // Fallback: try to kill by PID + if let Some(pid) = daemon::read_pid_file(&data_dir) { + unsafe { + if libc::kill(pid as i32, libc::SIGTERM) == 0 { + daemon::remove_port_file(&data_dir); + daemon::remove_pid_file(&data_dir); + if json_output { + println!("{}", serde_json::json!({"ok": true, "message": format!("sent SIGTERM to pid {pid}")})); + } else { + println!("Sent SIGTERM to daemon (pid {pid})."); + } + return; + } + } + } + if json_output { + println!("{}", serde_json::json!({"error": "daemon not running"})); + } else { + eprintln!("Daemon not running."); + } + std::process::exit(1); + } } - std::process::exit(1); } }, Commands::Status => { - let ipc = resolve_ipc_port(&cli); + let ipc = resolve_ipc_port_raw(cli_ipc_port); let url = format!("http://127.0.0.1:{ipc}/ipc/status"); match reqwest::get(&url).await { Ok(resp) => { if let Ok(status) = resp.json::().await { - if cli.json { + if json_output { println!("{}", serde_json::to_string(&status).unwrap()); } else { println!("=== AWN Status ==="); @@ -134,7 +170,7 @@ async fn main() { } } Err(_) => { - if cli.json { + if json_output { println!("{}", serde_json::json!({"error": "AWN daemon not running. Start with: awn daemon start"})); } else { eprintln!("AWN daemon not running. Start with: awn daemon start"); @@ -144,7 +180,7 @@ async fn main() { } } Commands::Agents { ref capability } => { - let ipc = resolve_ipc_port(&cli); + let ipc = resolve_ipc_port_raw(cli_ipc_port); let mut url = format!("http://127.0.0.1:{ipc}/ipc/agents"); if let Some(cap) = capability { url = format!("{url}?capability={}", urlencoding(cap)); @@ -152,7 +188,7 @@ async fn main() { match reqwest::get(&url).await { Ok(resp) => { if let Ok(data) = resp.json::().await { - if cli.json { + if json_output { println!("{}", serde_json::to_string(&data).unwrap()); } else if data.agents.is_empty() { println!("No agents found."); @@ -176,7 +212,7 @@ async fn main() { } } Err(_) => { - if cli.json { + if json_output { println!("{}", serde_json::json!({"error": "AWN daemon not running. Start with: awn daemon start"})); } else { eprintln!("AWN daemon not running. Start with: awn daemon start"); @@ -186,12 +222,12 @@ async fn main() { } } Commands::Worlds => { - let ipc = resolve_ipc_port(&cli); + let ipc = resolve_ipc_port_raw(cli_ipc_port); let url = format!("http://127.0.0.1:{ipc}/ipc/worlds"); match reqwest::get(&url).await { Ok(resp) => { if let Ok(data) = resp.json::().await { - if cli.json { + if json_output { println!("{}", serde_json::to_string(&data).unwrap()); } else if data.worlds.is_empty() { println!("No worlds found."); @@ -206,7 +242,7 @@ async fn main() { } } Err(_) => { - if cli.json { + if json_output { println!("{}", serde_json::json!({"error": "AWN daemon not running. Start with: awn daemon start"})); } else { eprintln!("AWN daemon not running. Start with: awn daemon start"); @@ -218,8 +254,8 @@ async fn main() { } } -fn resolve_ipc_port(cli: &Cli) -> u16 { - if let Some(port) = cli.ipc_port { +fn resolve_ipc_port_raw(cli_ipc_port: Option) -> u16 { + if let Some(port) = cli_ipc_port { return port; } if let Ok(port) = std::env::var("AWN_IPC_PORT").and_then(|s| s.parse().map_err(|_| std::env::VarError::NotPresent)) { diff --git a/skills/awn/SKILL.md b/skills/awn/SKILL.md index cba8fd5..0673e4c 100644 --- a/skills/awn/SKILL.md +++ b/skills/awn/SKILL.md @@ -1,6 +1,6 @@ --- name: awn -description: Direct encrypted P2P messaging between OpenClaw agents over HTTP/TCP and QUIC. AWN enforces world-scoped delivery, while `awn_list_peers()` reflects the local discovery cache. +description: "AWN CLI — standalone binary for world-scoped P2P messaging between AI agents. Ed25519-signed, zero runtime dependencies." version: "1.4.0" metadata: openclaw: @@ -9,101 +9,132 @@ metadata: os: - darwin - linux - install: - - kind: node - package: "@resciencelab/agent-world-network" --- # AWN (Agent World Network) -Direct agent-to-agent messaging over HTTP/TCP and QUIC. Messages are Ed25519-signed, and direct delivery is only allowed between peers that share a world. +Standalone CLI for world-scoped peer-to-peer messaging between AI agents. Messages are Ed25519-signed at the application layer. Direct delivery requires shared world membership. -## Quick Reference +## Install -| Situation | Action | -|---|---| -| User asks for their own agent ID or transport status | `awn_status()` | -| User asks who they can currently reach | `awn_list_peers()` | -| User wants to find available worlds | `list_worlds()` | -| User wants to join a known world | `join_world(world_id=...)` | -| User has a direct world server address | `join_world(address=host:port)` | -| User wants to perform an action in a world | `world_action(action="say", action_params={text: "hello"})` | -| User wants to check what actions/params a world supports | `world_info(world_id=...)` | -| User wants to send a message | `awn_send_message(agent_id, message)` | -| User wants to test connectivity end-to-end | `list_worlds()` -> `join_world()` -> `world_action()` or `awn_send_message()` | -| Sending fails or connectivity looks wrong | Check `awn_status()` and `awn_list_peers()` | +```bash +curl -fsSL https://raw.githubusercontent.com/ReScienceLab/agent-world-network/main/packages/awn-cli/install.sh | bash +``` -## Gateway +Installs the latest release to `~/.local/bin/awn`. Set `INSTALL_DIR` to override. -World Servers announce directly to the Gateway. The Gateway exposes discovered worlds through its `/worlds` endpoint. +## Usage -- Agents discover worlds with `list_worlds()` -- Agents join a world with `join_world()` -- Joining a world adds its co-members to `awn_list_peers()`, but the tool can also show previously discovered cached peers +### Start the daemon -Do not promise global discovery. Reachability is scoped to joined worlds. +```bash +awn daemon start +``` -## Tool Parameters +The daemon creates an Ed25519 identity on first run (stored in `~/.awn/identity.json`), starts an IPC server on `127.0.0.1:8199`, and listens for peer connections on port `8099`. -### awn_status -No parameters. +### Check status -Returns: own agent ID, transport status, and joined worlds with full action signatures (including param types, enums, and constraints). +```bash +awn status +``` -### awn_list_peers -- `capability_prefix` (optional): capability prefix filter such as `world:` or `world:pixel-city` +Returns agent ID, version, listen port, gateway URL, known agent count, and data directory. -Returns: peer agent ID, alias, capabilities, timestamps, and known endpoints. +### List available worlds -### awn_send_message -- `agent_id` (required): recipient's agent ID -- `message` (required): text content -- `event` (optional): event type, defaults to `"chat"` +```bash +awn worlds +``` -### list_worlds -No parameters. +Queries the Gateway for registered World Servers. -Returns: available worlds from the Gateway. +### List known agents -### join_world -- `world_id` (optional): world ID returned by `list_worlds()` -- `address` (optional): direct world server address such as `example.com:8099` -- `alias` (optional): display name to present while joining +```bash +awn agents +awn agents --capability "world:" +``` -Provide either `world_id` or `address`. +### Stop the daemon -### world_info -- `world_id` (optional): world ID. Auto-selected if only one world is joined. +```bash +awn daemon stop +``` -Returns: cached manifest for a joined world including full action param schemas (types, enums, constraints), rules, and lifecycle settings. Use this to discover exactly what parameters each action requires before calling `world_action`. +### JSON output -### world_action -- `action` (required): action name from the world manifest (e.g. `say`, `set_state`, `post_memo`) -- `world_id` (optional): target world ID. Auto-selected if only one world is joined. -- `action_params` (optional): action-specific parameters object (varies by world and action). Example: `{state: "writing", detail: "coding"}` +All commands support `--json` for machine-readable output: -Returns: confirmation and optional updated world state. Use `awn_status()` to see available actions per joined world. +```bash +awn status --json +awn worlds --json +awn agents --json +``` -## Inbound Messages +## Quick Reference -Incoming messages appear automatically in the OpenClaw chat UI under the **AWN** channel. +| Task | Command | +|---|---| +| Start daemon | `awn daemon start` | +| Stop daemon | `awn daemon stop` | +| Show identity and status | `awn status` | +| Discover worlds | `awn worlds` | +| List known agents | `awn agents` | +| Filter agents by capability | `awn agents --capability "world:"` | +| JSON output | append `--json` to any command | +| Custom IPC port | `awn --ipc-port 9000 status` | + +## Architecture + +``` +┌──────────┐ IPC (HTTP) ┌──────────────┐ P2P (HTTP/TCP) ┌──────────────┐ +│ awn CLI │ ◄────────────────► │ awn daemon │ ◄──────────────────► │ other agents │ +└──────────┘ 127.0.0.1:8199 └──────────────┘ port 8099 └──────────────┘ + │ + │ HTTPS + ▼ + ┌──────────────┐ + │ Gateway │ + └──────────────┘ +``` + +- **CLI**: stateless commands that talk to the daemon via IPC +- **Daemon**: manages identity, agent DB, and peer connections +- **Gateway**: world discovery registry at `https://gateway.agentworlds.ai` + +## Data Directory + +Default: `~/.awn/` + +| File | Purpose | +|---|---| +| `identity.json` | Ed25519 keypair + agent ID | +| `agents.json` | Known agents with TOFU keys | +| `daemon.port` | IPC port (written on start, removed on stop) | +| `daemon.pid` | Daemon PID (written on start, removed on stop) | + +## Configuration + +| Environment Variable | Default | Description | +|---|---|---| +| `GATEWAY_URL` | `https://gateway.agentworlds.ai` | Gateway URL for world discovery | +| `AWN_IPC_PORT` | `8199` | IPC port for CLI-daemon communication | + +Override via CLI flags: `--ipc-port`, `--data-dir`, `--gateway-url`, `--port`. ## Error Handling | Error | Diagnosis | |---|---| -| `No worlds found` | Gateway is unreachable or no worlds registered. Retry later or join directly by address. | -| `Join world fails` | The world server is offline, the `world_id` is stale, or the direct address is invalid. | -| `Message rejected (403)` | Sender and recipient do not currently share a joined world. | -| TOFU key mismatch (403) | Peer rotated keys or was reinstalled. Wait for TTL expiry or verify the new identity out of band. | -| QUIC disabled | `advertise_address` is not configured; HTTP/TCP remains available. | +| `AWN daemon not running` | Run `awn daemon start` first | +| `No worlds found` | Gateway unreachable or no worlds registered | +| `Message rejected (403)` | Sender and recipient do not share a world | +| TOFU key mismatch (403) | Peer rotated keys. Wait for TTL expiry or verify out of band | ## Rules -- Always `join_world` before messaging a new peer. Shared world membership is required for delivery even if the peer already appears in `awn_list_peers()`. -- Never invent agent IDs or world IDs. Ask the user or fetch them from tools. -- Agent IDs in current builds are stable `aw:sha256:<64hex>` strings. -- Prefer `list_worlds()` before `join_world(world_id=...)`. -- If the user gives a direct world address, use `join_world(address=...)` instead of guessing a world ID. - -**Reference**: `references/flows.md` (interaction examples) +- Agent IDs are stable `aw:sha256:<64hex>` strings derived from the Ed25519 public key. +- Never invent agent IDs or world IDs — use `awn agents` and `awn worlds` to discover them. +- The daemon must be running for any command other than `daemon start` to work. +- All messages are Ed25519-signed. Trust is application-layer: signature + TOFU + world co-membership. diff --git a/skills/awn/references/flows.md b/skills/awn/references/flows.md index 55c7b0f..e98bb20 100644 --- a/skills/awn/references/flows.md +++ b/skills/awn/references/flows.md @@ -1,92 +1,47 @@ -# AWN — Example Interaction Flows +# AWN CLI — Example Flows -## Flow 1 — Find worlds to join +## Flow 1 — First-time setup +```bash +curl -fsSL https://raw.githubusercontent.com/ReScienceLab/agent-world-network/main/packages/awn-cli/install.sh | bash +awn daemon start +awn status ``` -User: "What worlds can I join?" -1. list_worlds() -→ "Found 3 world(s): - world:pixel-city — Pixel City [reachable] — last seen 12s ago - world:arena — Arena [reachable] — last seen 19s ago" -``` - -## Flow 2 — Join a world by ID - -``` -User: "Join pixel-city" - -1. join_world(world_id="pixel-city") -→ "Joined world 'pixel-city' (Pixel City) — 4 other member(s) - - Available actions: - move(x: number, y: number) — Move to a tile - say(text: string) — Say something" -2. awn_list_peers() -→ Show visible peers from that shared world. -``` - -## Flow 3 — Join a world by direct address - -``` -User: "Connect to the world server at world.example.com:8099" - -1. join_world(address="world.example.com:8099") -→ "Joined world 'pixel-city' (Pixel City) — 4 other member(s) - - Available actions: - move(x: number, y: number) — Move to a tile - say(text: string) — Say something" -``` +## Flow 2 — Discover worlds -## Flow 4 — User wants to share their own agent ID - -``` -User: "What is my agent's ID?" - -1. awn_status() -→ "Agent ID: aw:sha256:8a3d..." +```bash +awn worlds +# === Available Worlds (2) === +# world:pixel-city — Pixel City [reachable] — 12s ago +# world:arena — Arena [reachable] — 19s ago ``` -## Flow 5 — Send a message to a visible peer +## Flow 3 — List known agents +```bash +awn agents +awn agents --capability "world:" ``` -User: "Send 'ready' to Bob" -1. awn_list_peers() -2. awn_send_message(agent_id=, message="ready") -→ "Message sent to Bob." -``` - -## Flow 6 — Message rejected by membership enforcement - -``` -User: "Send 'hello' to aw:sha256:8a3d..." - -1. awn_send_message(agent_id="aw:sha256:8a3d...", message="hello") - → error: Not a world co-member - -→ "That peer is not currently reachable through a shared world. - Join the same world first, then try again." -``` - -## Flow 7 — First-time user - -``` -User: "How do I use AWN?" +## Flow 4 — JSON output for scripting -→ "AWN is world-scoped. Start with list_worlds(), then join_world(), - and use awn_list_peers() or awn_send_message() once you share a world." +```bash +awn status --json | jq .agent_id +awn worlds --json | jq '.worlds[].world_id' +awn agents --json | jq '.agents | length' ``` -## Flow 8 — Registry returns nothing +## Flow 5 — Stop the daemon +```bash +awn daemon stop +# Daemon stopped. ``` -User: "Find worlds" -1. list_worlds() - → "No worlds found. Use join_world with a world address to connect directly." +## Flow 6 — Custom configuration -→ "The World Registry did not return any worlds. If you have a direct - world server address, use join_world(address=...)." +```bash +awn daemon start --data-dir /tmp/awn-test --gateway-url http://localhost:3000 --port 9099 +awn --ipc-port 9199 status ``` diff --git a/skills/awn/references/install.md b/skills/awn/references/install.md index e389a8f..ca614e0 100644 --- a/skills/awn/references/install.md +++ b/skills/awn/references/install.md @@ -1,38 +1,56 @@ -# AWN Installation Guide +# AWN CLI Installation -AWN has no external binary dependencies. It runs over HTTP/TCP and optional QUIC, with Ed25519 signing built into the plugin. +## One-liner ---- +```bash +curl -fsSL https://raw.githubusercontent.com/ReScienceLab/agent-world-network/main/packages/awn-cli/install.sh | bash +``` -## Install via npm +Installs to `~/.local/bin/awn`. Set `INSTALL_DIR` to change the destination: ```bash -npm install @resciencelab/agent-world-network +INSTALL_DIR=/usr/local/bin curl -fsSL https://raw.githubusercontent.com/ReScienceLab/agent-world-network/main/packages/awn-cli/install.sh | bash ``` -Or via OpenClaw: +## Specific version ```bash -openclaw plugins install @resciencelab/agent-world-network +VERSION=x.y.z curl -fsSL https://raw.githubusercontent.com/ReScienceLab/agent-world-network/main/packages/awn-cli/install.sh | bash ``` ---- +## From GitHub Release -## After Install +Download the binary for your platform from [Releases](https://github.com/ReScienceLab/agent-world-network/releases): + +| Platform | Archive | +|---|---| +| macOS (Apple Silicon) | `awn-v{VERSION}-aarch64-apple-darwin.tar.gz` | +| macOS (Intel) | `awn-v{VERSION}-x86_64-apple-darwin.tar.gz` | +| Linux (x86_64) | `awn-v{VERSION}-x86_64-unknown-linux-gnu.tar.gz` | + +```bash +tar xzf awn-v*.tar.gz +cp awn-v*/awn ~/.local/bin/ +``` -1. Restart the OpenClaw gateway so the plugin is loaded. -2. Run `awn_status()` to confirm your agent ID and transport status. -3. Run `list_worlds()` to browse worlds, or `join_world(address=...)` if you already know a world server address. -4. After joining a world, use `awn_list_peers()` to confirm the world's co-members; the tool may also show peers already present in the local cache. +## Verify ---- +```bash +awn --version +``` + +## After Install + +```bash +awn daemon start # start the background daemon +awn status # confirm identity and transport +awn worlds # discover available worlds +``` ## Troubleshooting | Symptom | Fix | |---|---| -| `awn_status()` returns no agent ID | Gateway not restarted after install. Restart the OpenClaw gateway. | -| `list_worlds()` returns no worlds | The World Registry may be unavailable. Retry later or join directly by address. | -| `awn_list_peers()` is empty | Possible before any discovery or join activity. Run `list_worlds()` / `join_world()` or retry after discovery updates. | -| Send fails with `Not a world co-member` | Join the same world as the recipient before sending. | -| QUIC transport is unavailable | Configure `advertise_address` and optionally `advertise_port`, or use HTTP/TCP only. | +| `command not found: awn` | Add `~/.local/bin` to PATH: `export PATH="$HOME/.local/bin:$PATH"` | +| `AWN daemon not running` | Run `awn daemon start` first | +| `awn worlds` returns nothing | Gateway may be unavailable. Retry later. | diff --git a/src/index.ts b/src/index.ts index dc7985b..b944006 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,26 +19,6 @@ import { TransportManager } from "./transport" import { UDPTransport } from "./transport-quic" import { parseDirectPeerAddress } from "./address" -const AWN_TOOLS = [ - "awn_list_agents", - "awn_send_message", "awn_status", - "list_worlds", "join_world", "world_action", "world_info", -] - -function ensureToolsAllowed(config: any): void { - try { - const alsoAllow: string[] = config?.tools?.alsoAllow ?? [] - const missing = AWN_TOOLS.filter(t => !alsoAllow.includes(t)) - if (missing.length === 0) return - const merged = [...alsoAllow, ...missing] - const jsonVal = JSON.stringify(merged) - execSync(`openclaw config set tools.alsoAllow '${jsonVal}'`, { timeout: 5000, stdio: "ignore" }) - console.log(`[awn] Auto-enabled ${missing.length} AWN tool(s) in tools.alsoAllow`) - } catch { - console.warn("[awn] Could not auto-enable tools — enable manually via: openclaw config set tools.alsoAllow") - } -} - function ensurePluginAllowed(config: any): void { try { const allow: string[] | undefined = config?.plugins?.allow @@ -414,7 +394,6 @@ export default function register(api: any) { start: async () => { ensurePluginAllowed(api.config) - ensureToolsAllowed(api.config) ensureChannelConfig(api.config) const cfg: PluginConfig = api.config?.plugins?.entries?.["awn"]?.config ?? {} @@ -714,399 +693,4 @@ export default function register(api: any) { }, }) - // ── Agent tools ──────────────────────────────────────────────────────────── - api.registerTool({ - name: "awn_send_message", - description: "Send a signed AWN message to an agent by their agent ID. The agent must share a joined world with this agent.", - parameters: { - type: "object", - properties: { - agent_id: { type: "string", description: "The recipient's agent ID" }, - message: { type: "string", description: "The message content to send" }, - event: { type: "string", description: "Message event type (default 'chat'). Use 'world.join' to join a world." }, - port: { type: "integer", description: "Recipient's P2P server port (default 8099)" }, - }, - required: ["agent_id", "message"], - }, - async execute(_id: string, params: { agent_id: string; message: string; event?: string; port?: number }) { - if (!identity) { - return { content: [{ type: "text", text: "Error: AWN service not started yet." }] } - } - const event = params.event ?? "chat" - const result = await sendP2PMessage(identity, params.agent_id, event, params.message, params.port ?? 8099, 10_000, buildSendOpts(params.agent_id)) - if (result.ok) { - return { content: [{ type: "text", text: `Message delivered to ${params.agent_id} (event: ${event})` }] } - } - return { content: [{ type: "text", text: `Failed to deliver: ${result.error}` }], isError: true } - }, - }) - - api.registerTool({ - name: "awn_list_agents", - description: "List AWN agents from the local discovery cache. Optionally filter by capability prefix (e.g. 'world:' or 'world:pixel-city'). Entries may appear before you join a shared world, but direct messaging still requires world co-membership.", - parameters: { - type: "object", - properties: { - capability: { type: "string", description: "Filter agents by capability prefix (e.g. 'world:')" }, - }, - required: [], - }, - async execute(_id: string, params: { capability?: string }) { - const agents = params.capability - ? findAgentsByCapability(params.capability) - : listAgents() - if (agents.length === 0) { - return { content: [{ type: "text", text: "No agents found." }] } - } - const lines = agents.map((p) => { - const ago = Math.round((Date.now() - p.lastSeen) / 1000) - const label = p.alias ? ` — ${p.alias}` : "" - const ver = p.version ? ` [v${p.version}]` : "" - const caps = p.capabilities?.length ? ` [${p.capabilities.join(", ")}]` : "" - return `${p.agentId}${label}${ver}${caps} — last seen ${ago}s ago` - }) - return { content: [{ type: "text", text: lines.join("\n") }] } - }, - }) - - api.registerTool({ - name: "awn_status", - description: "Get this agent's AWN identity, transport mode, known agents, and joined worlds.", - parameters: { type: "object", properties: {}, required: [] }, - async execute(_id: string, _params: Record) { - if (!identity) { - return { content: [{ type: "text", text: "AWN service not started." }] } - } - const agents = listAgents() - const activeTransport = _transportManager?.active - const lines = [ - ...((_agentMeta.name) ? [`Agent name: ${_agentMeta.name}`] : []), - `Agent ID: ${identity.agentId}`, - `DID Key: ${deriveDidKey(identity.publicKey)}`, - `Active transport: ${activeTransport?.id ?? "http-only"}`, - ...(_quicTransport?.isActive() ? [`QUIC endpoint: ${_quicTransport.address}`] : []), - `Plugin version: v${_agentMeta.version}`, - `Known agents: ${agents.length}`, - `Worlds joined: ${_joinedWorlds.size}`, - ] - for (const [id, info] of _joinedWorlds) { - const label = info.slug ?? id - const name = info.manifest?.name ?? label - lines.push(` ${label} — ${name} [id ${id}]`) - const actions = info.manifest?.actions - if (actions && Object.keys(actions).length > 0) { - lines.push(" Actions:") - lines.push(formatActionsBlock(actions, " ")) - } - } - return { content: [{ type: "text", text: lines.join("\n") }] } - }, - }) - - api.registerTool({ - name: "list_worlds", - description: "List available Agent worlds from the World Registry and local cache.", - parameters: { type: "object", properties: {}, required: [] }, - async execute(_id: string, _params: Record) { - const registryWorlds = await syncWorldsFromGateway() - const allWorlds = [...listWorlds()] - for (const world of registryWorlds) { - if (!allWorlds.some((item) => item.worldId === world.worldId)) { - allWorlds.push(world) - } - } - - if (!allWorlds.length) { - return { content: [{ type: "text", text: "No worlds found. Use join_world with a world address to connect directly." }] } - } - const lines = allWorlds.map((world) => { - const ago = Math.round((Date.now() - world.lastSeen) / 1000) - const reachable = world.endpoints.length ? "reachable" : "no endpoint" - return `${world.slug} [${reachable}] — id ${world.worldId} — last seen ${ago}s ago` - }) - return { content: [{ type: "text", text: `Found ${allWorlds.length} world(s):\n${lines.join("\n")}` }] } - }, - }) - - api.registerTool({ - name: "join_world", - description: "Join an Agent world. Provide a world_id (if already known) or address (host:port) to connect directly.", - parameters: { - type: "object", - properties: { - world_id: { type: "string", description: "The protocol world ID or slug — looks up from known worlds" }, - address: { type: "string", description: "Direct address of the world server (e.g. 'example.com:8099' or '1.2.3.4:8099')" }, - alias: { type: "string", description: "Optional display name inside the world" }, - }, - required: [], - }, - async execute(_id: string, params: { world_id?: string; address?: string; alias?: string }) { - if (!identity) { - return { content: [{ type: "text", text: "AWN service not started." }] } - } - if (!params.world_id && !params.address) { - return { content: [{ type: "text", text: "Provide either world_id or address." }], isError: true } - } - - let targetAddr: string - let targetPort: number = peerPort - let worldAgentId: string | undefined - let worldPublicKey: string | undefined - let worldSlug: string | undefined - - if (params.address) { - const parsedAddress = parseDirectPeerAddress(params.address, peerPort) - targetAddr = parsedAddress.address - targetPort = parsedAddress.port - - const ping = await getAgentPingInfo(targetAddr, targetPort, 5_000) - if (!ping.ok) { - return { content: [{ type: "text", text: `World at ${params.address} is unreachable.` }], isError: true } - } - if (typeof ping.data?.agentId !== "string" || ping.data.agentId.length === 0) { - return { content: [{ type: "text", text: `World at ${params.address} did not provide a stable agent ID.` }], isError: true } - } - if (typeof ping.data?.publicKey !== "string" || ping.data.publicKey.length === 0) { - return { content: [{ type: "text", text: `World at ${params.address} did not provide a verifiable public key.` }], isError: true } - } - worldAgentId = ping.data.agentId - worldPublicKey = ping.data.publicKey - worldSlug = typeof ping.data?.slug === "string" - ? ping.data.slug - : typeof ping.data?.worldId === "string" && !ping.data.worldId.startsWith("aw:sha256:") - ? ping.data.worldId - : undefined - } else { - let world = resolveKnownWorld(params.world_id) - if (!world) { - await syncWorldsFromGateway() - world = resolveKnownWorld(params.world_id) - } - if (!world) { - return { content: [{ type: "text", text: `World '${params.world_id}' not found. Use address parameter to connect directly.` }] } - } - if ((!world.endpoints.length || !world.publicKey) && world.worldId) { - const gatewayWorld = await fetchGatewayWorldRecord(world.worldId) - if (gatewayWorld?.worldId) { - upsertWorld(gatewayWorld.worldId, { - slug: gatewayWorld.slug ?? world.slug, - publicKey: gatewayWorld.publicKey ?? world.publicKey, - endpoints: gatewayWorld.endpoints ?? world.endpoints, - source: "gateway", - }) - world = getWorld(gatewayWorld.worldId) ?? world - } - } - if (!world.endpoints.length) { - return { content: [{ type: "text", text: `World '${params.world_id}' has no reachable endpoints.` }] } - } - targetAddr = world.endpoints[0].address - targetPort = world.endpoints[0].port ?? peerPort - worldAgentId = world.worldId - worldPublicKey = world.publicKey - worldSlug = world.slug - } - - if (!worldPublicKey) { - return { content: [{ type: "text", text: "World public key is unavailable; cannot verify signed membership refreshes." }], isError: true } - } - - const myEndpoints: Endpoint[] = _agentMeta.endpoints ?? [] - if (myEndpoints.length === 0) { - return { - content: [{ - type: "text", - text: "No reachable endpoint can be advertised. Set advertise_address (for HTTP/TCP) or configure QUIC before joining a world.", - }], - isError: true, - } - } - const joinPayload = JSON.stringify({ - alias: params.alias ?? _agentMeta.name ?? identity.agentId.slice(0, 8), - endpoints: myEndpoints, - }) - - const sendOpts = buildSendOpts(worldAgentId) - sendOpts.expectedPublicKey = worldPublicKey - const result = await sendP2PMessage(identity, targetAddr, "world.join", joinPayload, targetPort, 10_000, sendOpts) - if (!result.ok) { - return { content: [{ type: "text", text: `Failed to join world: ${result.error}` }], isError: true } - } - - const worldId = worldAgentId! - const members = result.data?.members as unknown[] | undefined - const memberCount = members?.length ?? 0 - const joinedSlug = typeof result.data?.slug === "string" - ? result.data.slug - : typeof result.data?.worldId === "string" && !result.data.worldId.startsWith("aw:sha256:") - ? result.data.worldId - : worldSlug ?? worldId - const worldName = typeof result.data?.manifest === "object" && result.data?.manifest && typeof (result.data.manifest as { name?: unknown }).name === "string" - ? (result.data.manifest as { name: string }).name - : joinedSlug - - upsertWorld(worldId, { - slug: joinedSlug, - publicKey: worldPublicKey, - endpoints: [{ transport: "tcp", address: targetAddr, port: targetPort, priority: 1, ttl: 3600 }], - source: "gossip", - }) - - const joinMembers = (result.data?.members as Array<{ agentId: string; alias?: string; endpoints?: Endpoint[] }> | undefined) ?? [] - syncWorldMembers(worldId, joinMembers) - addWorldMembers(worldId, [worldAgentId!, ...joinMembers.map(m => m.agentId).filter(id => id !== identity!.agentId)]) - - // Track this world for periodic member refresh - const manifest = typeof result.data?.manifest === "object" && result.data?.manifest - ? result.data.manifest as JoinedWorldInfo["manifest"] - : undefined - _joinedWorlds.set(worldId, { agentId: worldId, slug: joinedSlug, address: targetAddr, port: targetPort, publicKey: worldPublicKey, manifest }) - _worldRefreshFailures.delete(worldId) - if (!_memberRefreshTimer) { - _memberRefreshTimer = setInterval(refreshWorldMembers, MEMBER_REFRESH_INTERVAL_MS) - } - - const lines = [`Joined world '${joinedSlug}' (${worldName}) — ${memberCount} other member(s)`] - if (manifest?.actions && Object.keys(manifest.actions).length > 0) { - lines.push("") - lines.push("Available actions:") - lines.push(formatActionsBlock(manifest.actions)) - } - return { content: [{ type: "text", text: lines.join("\n") }] } - }, - }) - - api.registerTool({ - name: "world_action", - description: "Perform an action in a joined world (e.g. say, set_state). Use awn_status() to see joined worlds and available actions.", - parameters: { - type: "object", - properties: { - world_id: { type: "string", description: "Target world ID. Auto-selected if only one world is joined." }, - action: { type: "string", description: "Action name as defined in the world manifest." }, - action_params: { type: "object", description: "Action-specific parameters (varies by world and action)." }, - }, - required: ["action"], - }, - async execute(_id: string, params: { world_id?: string; action: string; action_params?: Record }) { - if (!identity) { - return { content: [{ type: "text", text: "AWN service not started." }], isError: true } - } - if (_joinedWorlds.size === 0) { - return { content: [{ type: "text", text: "Not joined any worlds. Use join_world first." }], isError: true } - } - - let worldId = params.world_id - let info: JoinedWorldInfo | undefined - if (!worldId) { - if (_joinedWorlds.size === 1) { - ;[worldId, info] = [..._joinedWorlds.entries()][0] - } else { - const ids = [..._joinedWorlds.entries()].map(([id, joined]) => joined.slug ?? id).join(", ") - return { content: [{ type: "text", text: `Multiple worlds joined (${ids}). Specify world_id.` }], isError: true } - } - } - if (!info && worldId) { - const resolved = resolveJoinedWorld(worldId) - if (resolved) { - ;[worldId, info] = resolved - } - } - if (!info || !worldId) { - return { content: [{ type: "text", text: `Not joined world '${worldId}'.` }], isError: true } - } - - const actionPayload = JSON.stringify({ action: params.action, ...(params.action_params ?? {}) }) - const sendOpts = buildSendOpts(info.agentId) - sendOpts.expectedPublicKey = info.publicKey - const result = await sendP2PMessage(identity, info.address, "world.action", actionPayload, info.port, 10_000, sendOpts) - - if (!result.ok) { - return { content: [{ type: "text", text: `Action failed: ${result.error}` }], isError: true } - } - - const stateText = result.data?.state !== undefined - ? `\nState: ${JSON.stringify(result.data.state)}` - : "" - return { content: [{ type: "text", text: `Action '${params.action}' executed in world '${info.slug ?? worldId}'.${stateText}` }] } - }, - }) - - api.registerTool({ - name: "world_info", - description: "Show the cached manifest for a joined world: actions with full param schemas, rules, and lifecycle settings.", - parameters: { - type: "object", - properties: { - world_id: { type: "string", description: "World ID. Auto-selected if only one world is joined." }, - }, - required: [], - }, - async execute(_id: string, params: { world_id?: string }) { - if (!identity) { - return { content: [{ type: "text", text: "AWN service not started." }], isError: true } - } - if (_joinedWorlds.size === 0) { - return { content: [{ type: "text", text: "Not joined any worlds. Use join_world first." }], isError: true } - } - - let worldId = params.world_id - let info: JoinedWorldInfo | undefined - if (!worldId) { - if (_joinedWorlds.size === 1) { - ;[worldId, info] = [..._joinedWorlds.entries()][0] - } else { - const ids = [..._joinedWorlds.entries()].map(([id, joined]) => joined.slug ?? id).join(", ") - return { content: [{ type: "text", text: `Multiple worlds joined (${ids}). Specify world_id.` }], isError: true } - } - } - if (!info && worldId) { - const resolved = resolveJoinedWorld(worldId) - if (resolved) { - ;[worldId, info] = resolved - } - } - if (!info || !worldId) { - return { content: [{ type: "text", text: `Not joined world '${worldId}'.` }], isError: true } - } - - const manifest = info.manifest - const lines: string[] = [] - lines.push(`World: ${manifest?.name ?? info.slug ?? worldId} (${info.slug ?? worldId})`) - lines.push(`Protocol ID: ${worldId}`) - if (manifest?.description) lines.push(`Description: ${manifest.description}`) - if (manifest?.objective) lines.push(`Objective: ${manifest.objective}`) - if (manifest?.type) lines.push(`Type: ${manifest.type}`) - if (manifest?.theme && manifest.theme !== "default") lines.push(`Theme: ${manifest.theme}`) - - const actions = manifest?.actions - if (actions && Object.keys(actions).length > 0) { - lines.push("") - lines.push("Actions:") - lines.push(formatActionsBlock(actions)) - } - - const rules = manifest?.rules - if (rules && rules.length > 0) { - lines.push("") - lines.push("Rules:") - for (const rule of rules) { - const prefix = rule.enforced ? "[enforced]" : "[advisory]" - lines.push(` ${prefix} ${rule.text}`) - } - } - - const lifecycle = manifest?.lifecycle - if (lifecycle && Object.keys(lifecycle).length > 0) { - lines.push("") - lines.push("Lifecycle:") - for (const [key, value] of Object.entries(lifecycle)) { - lines.push(` ${key}: ${value}`) - } - } - - return { content: [{ type: "text", text: lines.join("\n") }] } - }, - }) - } diff --git a/test/index-lifecycle.test.mjs b/test/index-lifecycle.test.mjs index 718b3d6..f335a88 100644 --- a/test/index-lifecycle.test.mjs +++ b/test/index-lifecycle.test.mjs @@ -312,621 +312,4 @@ describe("plugin lifecycle", () => { harness.restore() } }) - - it("stores direct world joins under the world agent ID", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: "d29ybGQtcHVibGljLWtleQ==" } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena" }, - members: [], - }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - const result = await joinWorld.execute("tool-1", { address: "203.0.113.10:9000" }) - - assert.equal(result.isError, undefined) - - const joinCall = harness.sendCalls.find((call) => call.event === "world.join") - assert.equal(joinCall?.targetAddr, "203.0.113.10") - - const worldRecord = harness.worlds.get(worldAgentId) - assert.ok(worldRecord) - assert.deepEqual(worldRecord.endpoints, [ - { transport: "tcp", address: "203.0.113.10", port: 9000, priority: 1, ttl: 3600 }, - ]) - assert.equal(worldRecord.slug, "arena") - - await harness.service.stop() - - const leaveCall = harness.sendCalls.find((call) => call.event === "world.leave") - assert.equal(leaveCall?.targetAddr, "203.0.113.10") - assert.deepEqual(leaveCall?.opts?.endpoints, undefined) - } finally { - harness.restore() - } - }) - - it("joins a gateway-discovered world_id after resolving missing world details", async () => { - const worldAgentId = "aw:sha256:world-host" - const worldPublicKey = "d29ybGQtcHVibGljLWtleQ==" - const worldEndpoint = { transport: "tcp", address: "203.0.113.10", port: 9000, priority: 1, ttl: 3600 } - const harness = createHarness({ - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena" }, - members: [], - }, - }, - fetchImpl: async (url) => { - const requestUrl = String(url) - if (requestUrl.endsWith("/worlds")) { - return { - ok: true, - status: 200, - json: async () => ({ - worlds: [{ worldId: worldAgentId, slug: "arena", endpoints: [worldEndpoint] }], - }), - } - } - if (requestUrl.endsWith(`/worlds/${encodeURIComponent(worldAgentId)}`)) { - return { - ok: true, - status: 200, - json: async () => ({ - worldId: worldAgentId, - slug: "arena", - publicKey: worldPublicKey, - endpoints: [worldEndpoint], - }), - } - } - return { ok: true, status: 200, json: async () => ({ members: [] }) } - }, - }) - - try { - await harness.service.start() - - const listWorlds = harness.tools.get("list_worlds") - const listed = await listWorlds.execute("tool-list", {}) - assert.equal(listed.isError, undefined) - - const discoveredWorld = harness.worlds.get(worldAgentId) - assert.ok(discoveredWorld, "world should be discovered after list_worlds") - assert.deepEqual(discoveredWorld.endpoints, [worldEndpoint], "endpoints should be populated from /worlds") - - const joinWorld = harness.tools.get("join_world") - const joined = await joinWorld.execute("tool-join", { world_id: "arena" }) - assert.equal(joined.isError, undefined) - - const joinCall = harness.sendCalls.find((call) => call.event === "world.join") - assert.equal(joinCall?.targetAddr, "203.0.113.10") - assert.ok(harness.fetchCalls.some(([requestUrl]) => String(requestUrl).endsWith(`/worlds/${encodeURIComponent(worldAgentId)}`))) - - const worldRecord = harness.worlds.get(worldAgentId) - assert.ok(worldRecord) - assert.equal(worldRecord.publicKey, worldPublicKey) - assert.deepEqual(worldRecord.endpoints, [worldEndpoint]) - } finally { - harness.restore() - } - }) - - it("drops scoped members when world membership refresh is rejected", async () => { - const worldAgentId = "aw:sha256:world-host" - let refreshCalls = 0 - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: "d29ybGQtcHVibGljLWtleQ==" } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena" }, - members: [ - { - agentId: "aw:sha256:member-1", - alias: "Member One", - endpoints: [ - { transport: "tcp", address: "198.51.100.20", port: 9100, priority: 1, ttl: 3600 }, - ], - }, - ], - }, - }, - fetchImpl: async () => { - refreshCalls += 1 - return { - ok: false, - status: 403, - json: async () => ({ members: [] }), - } - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - const result = await joinWorld.execute("tool-2", { address: "203.0.113.10:9000" }) - - assert.equal(result.isError, undefined) - assert.ok(harness.agents.get("aw:sha256:member-1")) - - await harness.runIntervals() - assert.equal(refreshCalls, 1) - assert.equal(harness.agents.get("aw:sha256:member-1"), undefined) - assert.ok(harness.worlds.get(worldAgentId)) - - await harness.runIntervals() - assert.equal(refreshCalls, 1) - } finally { - harness.restore() - } - }) - - it("revokes the peer-server allowlist when repeated refresh failures drop a world", async () => { - const worldAgentId = "aw:sha256:world-host" - let refreshCalls = 0 - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: "d29ybGQtcHVibGljLWtleQ==" } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena" }, - members: [ - { - agentId: "aw:sha256:member-1", - alias: "Member One", - endpoints: [ - { transport: "tcp", address: "198.51.100.20", port: 9100, priority: 1, ttl: 3600 }, - ], - }, - ], - }, - }, - fetchImpl: async () => { - refreshCalls += 1 - return { - ok: false, - status: 500, - json: async () => ({ members: [] }), - } - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - const result = await joinWorld.execute("tool-3", { address: "203.0.113.10:9000" }) - - assert.equal(result.isError, undefined) - assert.equal(harness.agentServer.isCoMember("aw:sha256:member-1"), true) - - await harness.runIntervals() - await harness.runIntervals() - assert.equal(refreshCalls, 2) - assert.equal(harness.agentServer.isCoMember("aw:sha256:member-1"), true) - - await harness.runIntervals() - assert.equal(refreshCalls, 3) - assert.equal(harness.agentServer.isCoMember("aw:sha256:member-1"), false) - } finally { - harness.restore() - } - }) -}) - -// Base64 of "world-public-key" — test-only fixture, not a real secret -const MOCK_WORLD_PUB = "d29ybGQtcHVibGljLWtleQ==" - -describe("world_action tool", () => { - it("sends a world.action message with correct payload", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena", actions: { say: { desc: "Say something" } } }, - members: [], - }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const worldAction = harness.tools.get("world_action") - const result = await worldAction.execute("t-2", { action: "say", action_params: { text: "hello" } }) - - assert.equal(result.isError, undefined) - assert.ok(result.content[0].text.includes("say")) - - const actionCall = harness.sendCalls.find((call) => call.event === "world.action") - assert.ok(actionCall) - const payload = JSON.parse(actionCall.content) - assert.equal(payload.action, "say") - assert.equal(payload.text, "hello") - assert.equal(actionCall.targetAddr, "203.0.113.10") - assert.equal(actionCall.port, 9000) - } finally { - harness.restore() - } - }) - - it("auto-selects the only joined world when world_id is omitted", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena" }, - members: [], - }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const worldAction = harness.tools.get("world_action") - const result = await worldAction.execute("t-2", { action: "move" }) - - assert.equal(result.isError, undefined) - assert.ok(result.content[0].text.includes("arena")) - } finally { - harness.restore() - } - }) - - it("rejects when no worlds are joined", async () => { - const harness = createHarness() - - try { - await harness.service.start() - - const worldAction = harness.tools.get("world_action") - const result = await worldAction.execute("t-1", { action: "say" }) - - assert.equal(result.isError, true) - assert.ok(result.content[0].text.includes("Not joined")) - } finally { - harness.restore() - } - }) - - it("rejects ambiguous world_id when multiple worlds are joined", async () => { - const worldByAddress = { - "203.0.113.10": "aw:sha256:world-host-1", - "203.0.113.11": "aw:sha256:world-host-2", - } - let joinCount = 0 - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldByAddress["203.0.113.10"], publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena" }, - members: [], - }, - }, - }) - - // Override sendP2PMessage to return different worldIds - const agentClientMod = createRequire(import.meta.url)("../dist/agent-client.js") - const origPing = agentClientMod.getAgentPingInfo - const origSend = agentClientMod.sendP2PMessage - agentClientMod.getAgentPingInfo = async (targetAddr) => ({ - ok: true, - data: { agentId: worldByAddress[targetAddr], publicKey: MOCK_WORLD_PUB }, - }) - agentClientMod.sendP2PMessage = async (_identity, targetAddr, event, content, port, timeoutMs, opts) => { - harness.sendCalls.push({ targetAddr, event, content, port, timeoutMs, opts }) - if (event === "world.join") { - joinCount++ - return { - ok: true, - data: { - worldId: joinCount === 1 ? "arena" : "lobby", - manifest: { name: joinCount === 1 ? "Arena" : "Lobby" }, - members: [], - }, - } - } - return { ok: true } - } - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - await joinWorld.execute("t-2", { address: "203.0.113.11:9001" }) - - const worldAction = harness.tools.get("world_action") - const result = await worldAction.execute("t-3", { action: "say" }) - - assert.equal(result.isError, true) - assert.ok(result.content[0].text.includes("Multiple worlds")) - assert.ok(result.content[0].text.includes("Specify world_id")) - } finally { - agentClientMod.getAgentPingInfo = origPing - agentClientMod.sendP2PMessage = origSend - harness.restore() - } - }) - - it("awn_status includes action signatures with param schemas", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { - name: "Arena", - actions: { - say: { desc: "Say something", params: { text: { type: "string", required: true } } }, - set_state: { - desc: "Update your state", - params: { - state: { type: "string", enum: ["idle", "writing", "error"] }, - detail: { type: "string", required: false, max: 200 }, - }, - }, - }, - }, - members: [], - }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const awnStatus = harness.tools.get("awn_status") - const result = await awnStatus.execute("t-2", {}) - - const text = result.content[0].text - assert.ok(text.includes("arena")) - assert.ok(text.includes("Arena")) - assert.ok(text.includes("say(text: string)")) - assert.ok(text.includes("Say something")) - assert.ok(text.includes('"idle"|"writing"|"error"')) - assert.ok(text.includes("detail?:")) - assert.ok(text.includes("[max 200]")) - } finally { - harness.restore() - } - }) -}) - -describe("join_world action signatures", () => { - it("join_world response includes formatted action signatures", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { - worldId: "office", - manifest: { - name: "Star Office", - actions: { - set_state: { - desc: "Update agent's work status", - params: { - state: { type: "string", enum: ["idle", "writing", "researching"] }, - detail: { type: "string", required: false, max: 200 }, - }, - }, - heartbeat: { desc: "Keep-alive signal" }, - post_memo: { - desc: "Post a work memo", - params: { content: { type: "string", max: 2000 } }, - }, - }, - }, - members: [], - }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - const result = await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const text = result.content[0].text - assert.ok(text.includes("Joined world 'office' (Star Office)")) - assert.ok(text.includes("Available actions:")) - assert.ok(text.includes('"idle"|"writing"|"researching"')) - assert.ok(text.includes("detail?:")) - assert.ok(text.includes("heartbeat()")) - assert.ok(text.includes("[max 2000]")) - } finally { - harness.restore() - } - }) - - it("join_world omits actions section when manifest has no actions", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { - worldId: "arena", - manifest: { name: "Arena" }, - members: [], - }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - const result = await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const text = result.content[0].text - assert.ok(text.includes("Joined world 'arena' (Arena)")) - assert.equal(text.includes("Available actions:"), false) - } finally { - harness.restore() - } - }) -}) - -describe("world_info tool", () => { - it("returns full manifest with action param schemas", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { - worldId: "office", - manifest: { - name: "Star Office", - description: "A collaborative workspace", - objective: "Work together", - actions: { - set_state: { - desc: "Update status", - params: { - state: { type: "string", enum: ["idle", "writing"] }, - }, - }, - }, - rules: [ - { text: "Be respectful", enforced: true }, - { text: "Have fun", enforced: false }, - ], - lifecycle: { evictionPolicy: "idle", idleTimeoutMs: 300000 }, - }, - members: [], - }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const worldInfo = harness.tools.get("world_info") - const result = await worldInfo.execute("t-2", {}) - - const text = result.content[0].text - assert.ok(text.includes("World: Star Office (office)")) - assert.ok(text.includes("Description: A collaborative workspace")) - assert.ok(text.includes("Objective: Work together")) - assert.ok(text.includes("Actions:")) - assert.ok(text.includes('"idle"|"writing"')) - assert.ok(text.includes("[enforced] Be respectful")) - assert.ok(text.includes("[advisory] Have fun")) - assert.ok(text.includes("Lifecycle:")) - assert.ok(text.includes("evictionPolicy: idle")) - } finally { - harness.restore() - } - }) - - it("auto-selects single joined world", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { worldId: "arena", manifest: { name: "Arena" }, members: [] }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const worldInfo = harness.tools.get("world_info") - const result = await worldInfo.execute("t-2", {}) - - assert.equal(result.isError, undefined) - assert.ok(result.content[0].text.includes("World: Arena (arena)")) - } finally { - harness.restore() - } - }) - - it("rejects when no worlds are joined", async () => { - const harness = createHarness() - - try { - await harness.service.start() - - const worldInfo = harness.tools.get("world_info") - const result = await worldInfo.execute("t-1", {}) - - assert.equal(result.isError, true) - assert.ok(result.content[0].text.includes("Not joined")) - } finally { - harness.restore() - } - }) - - it("rejects unknown world_id", async () => { - const worldAgentId = "aw:sha256:world-host" - const harness = createHarness({ - pingInfo: { ok: true, data: { agentId: worldAgentId, publicKey: MOCK_WORLD_PUB } }, - joinResponse: { - ok: true, - data: { worldId: "arena", manifest: { name: "Arena" }, members: [] }, - }, - }) - - try { - await harness.service.start() - - const joinWorld = harness.tools.get("join_world") - await joinWorld.execute("t-1", { address: "203.0.113.10:9000" }) - - const worldInfo = harness.tools.get("world_info") - const result = await worldInfo.execute("t-2", { world_id: "nonexistent" }) - - assert.equal(result.isError, true) - assert.ok(result.content[0].text.includes("Not joined world 'nonexistent'")) - } finally { - harness.restore() - } - }) })