diff --git a/.changeset/feat-awn-action-command.md b/.changeset/feat-awn-action-command.md new file mode 100644 index 0000000..1eba70b --- /dev/null +++ b/.changeset/feat-awn-action-command.md @@ -0,0 +1,15 @@ +--- +"@resciencelab/agent-world-network": patch +--- + +feat(awn-cli): add `awn action` command for calling world actions + +Adds a new CLI command to call actions on joined worlds: + +```bash +awn action [params_json] +awn action pixel-city set_state '{"state":"idle","detail":"Working"}' +awn action pixel-city heartbeat +``` + +This allows agents to interact with world servers by sending signed `world.action` messages. diff --git a/packages/awn-cli/src/daemon.rs b/packages/awn-cli/src/daemon.rs index a235aea..8904808 100644 --- a/packages/awn-cli/src/daemon.rs +++ b/packages/awn-cli/src/daemon.rs @@ -158,6 +158,13 @@ pub struct SendMessageBody { pub message: String, } +#[derive(Deserialize)] +pub struct WorldActionBody { + pub world_id: String, + pub action: String, + pub params: serde_json::Value, +} + pub struct DaemonHandle { shutdown_tx: oneshot::Sender<()>, pub addr: SocketAddr, @@ -216,6 +223,7 @@ pub async fn start_daemon( .route("/ipc/leave/{world_id}", post(handle_leave_world)) .route("/ipc/peer/ping/{agent_id}", get(handle_ping_agent)) .route("/ipc/send", post(handle_send_message)) + .route("/ipc/action", post(handle_world_action)) .route("/ipc/messages", get(handle_messages)) .route( "/ipc/shutdown", @@ -975,6 +983,91 @@ async fn handle_send_message( Err(StatusCode::BAD_GATEWAY) } +async fn handle_world_action( + State(state): State, + Json(body): Json, +) -> Result, StatusCode> { + // Find the joined world by world_id or slug + let world = { + let worlds = state.joined_worlds.lock().unwrap(); + worlds + .get(&body.world_id) + .cloned() + .or_else(|| { + worlds + .values() + .find(|w| w.slug.as_deref() == Some(&body.world_id)) + .cloned() + }) + }; + + let world = world.ok_or(StatusCode::NOT_FOUND)?; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + + // Build action content - spread params at top level alongside action + let mut content_obj = serde_json::json!({ + "action": body.action + }); + if let serde_json::Value::Object(params) = body.params { + if let serde_json::Value::Object(ref mut obj) = content_obj { + for (k, v) in params { + obj.insert(k, v); + } + } + } + let content = content_obj.to_string(); + + // Build signed P2P message with event "world.action" + let msg = build_signed_p2p_message(&state.identity, "world.action", &content); + let msg_body = msg.to_string(); + + // Send to world server + let is_ipv6 = world.address.contains(':') && !world.address.contains('.'); + let host = if is_ipv6 { + format!("[{}]:{}", world.address, world.port) + } else { + format!("{}:{}", world.address, world.port) + }; + let url = format!("http://{}/peer/message", host); + let headers = sign_http_request(&state.identity, "POST", &host, "/peer/message", &msg_body); + + let resp = client + .post(&url) + .header("Content-Type", "application/json") + .header("X-AgentWorld-Version", &headers.version) + .header("X-AgentWorld-From", &headers.from_agent) + .header("X-AgentWorld-KeyId", &headers.key_id) + .header("X-AgentWorld-Timestamp", &headers.timestamp) + .header("Content-Digest", &headers.content_digest) + .header("X-AgentWorld-Signature", &headers.signature) + .body(msg_body) + .send() + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + if !resp.status().is_success() { + let status = resp.status(); + let error_text = resp.text().await.unwrap_or_default(); + return Ok(Json(serde_json::json!({ + "ok": false, + "error": format!("World server returned status {}: {}", status, error_text) + }))); + } + + let resp_data: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({})); + + Ok(Json(serde_json::json!({ + "ok": true, + "worldId": world.world_id, + "action": body.action, + "result": resp_data + }))) +} + /// Resolve a world identifier (worldId or slug or direct address) to /// (address, port, publicKey, worldId, slug). async fn resolve_world( diff --git a/packages/awn-cli/src/main.rs b/packages/awn-cli/src/main.rs index f62a248..6e82f4c 100644 --- a/packages/awn-cli/src/main.rs +++ b/packages/awn-cli/src/main.rs @@ -67,6 +67,16 @@ enum Commands { /// Message text message: String, }, + /// Call an action on a joined world + Action { + /// World ID or slug + world_id: String, + /// Action name (e.g. set_state, post_memo, heartbeat) + action: String, + /// Action parameters as JSON (e.g. '{"state":"idle","detail":"Hello"}') + #[arg(default_value = "{}")] + params: String, + }, } #[derive(Subcommand)] @@ -454,6 +464,60 @@ async fn main() { } } } + Commands::Action { ref world_id, ref action, ref params } => { + let ipc = resolve_ipc_port_raw(cli_ipc_port); + let url = format!("http://127.0.0.1:{ipc}/ipc/action"); + let client = reqwest::Client::new(); + + // Parse params as JSON + let params_json: serde_json::Value = serde_json::from_str(params) + .unwrap_or_else(|_| serde_json::json!({})); + + let body = serde_json::json!({ + "world_id": world_id, + "action": action, + "params": params_json + }); + + match client.post(&url).json(&body).send().await { + Ok(resp) => { + if resp.status().is_success() { + if let Ok(data) = resp.json::().await { + if json_output { + println!("{}", data); + } else { + if data.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + println!("Action '{}' executed successfully on world '{}'.", action, world_id); + if let Some(result) = data.get("result") { + println!("Result: {}", serde_json::to_string_pretty(result).unwrap_or_default()); + } + } else { + let err = data.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error"); + eprintln!("Action failed: {}", err); + std::process::exit(1); + } + } + } + } else { + let status = resp.status(); + if json_output { + println!("{}", serde_json::json!({"error": format!("Request failed with status {}", status)})); + } else { + eprintln!("Failed to execute action. Status: {}", status); + } + std::process::exit(1); + } + } + Err(_) => { + if json_output { + println!("{}", serde_json::json!({"error": "AWN daemon not running"})); + } else { + eprintln!("AWN daemon not running. Start with: awn daemon start"); + } + std::process::exit(1); + } + } + } Commands::World { ref world_id } => { let ipc = resolve_ipc_port_raw(cli_ipc_port); let encoded_id = urlencoding(world_id); diff --git a/skills/awn/SKILL.md b/skills/awn/SKILL.md index 1cbacc2..0c4addb 100644 --- a/skills/awn/SKILL.md +++ b/skills/awn/SKILL.md @@ -87,6 +87,22 @@ awn send "hello" Sends an Ed25519-signed P2P message directly to the agent. Both agents must share a joined world. +### Call a world action + +```bash +awn action [params_json] +awn action pixel-city set_state '{"state":"idle","detail":"Working on code"}' +awn action pixel-city heartbeat +awn action pixel-city post_memo '{"content":"Finished the feature!"}' +``` + +Calls an action on a joined world. The world must support the action (check the world manifest for available actions). Common actions include: + +- `set_state` — Update agent status (idle, writing, researching, executing, syncing, error) +- `heartbeat` — Keep-alive signal to prevent idle eviction +- `post_memo` — Post a work memo entry +- `clear_error` — Clear error state and return to idle + ### List known agents ```bash @@ -125,6 +141,7 @@ awn ping --json | Leave a world | `awn leave ` | | Ping an agent | `awn ping ` | | Send a message | `awn send "message"` | +| Call world action | `awn action [params]` | | List known agents | `awn agents` | | Filter agents by capability | `awn agents --capability "world:"` | | JSON output | append `--json` to any command |