Skip to content

Instantly share code, notes, and snippets.

@Jing-yilin
Created April 6, 2026 04:21
Show Gist options
  • Select an option

  • Save Jing-yilin/3af156c98b081b98d2180fe5bc486e50 to your computer and use it in GitHub Desktop.

Select an option

Save Jing-yilin/3af156c98b081b98d2180fe5bc486e50 to your computer and use it in GitHub Desktop.
PR #39 Plan — Agent Communication Foundations (peer listing, messaging, lifecycle, webhook, agent card)

PR #39 — Agent Communication Foundations

Date: 2026-04-06 Branch: hal/agent-communication (from compound/daemon-local-runtime-foundation) Spec refs: CLI §7.6, §7.7, §19.1, SPEC §02 §10, §05 §3.2, §10 §2-5, §11 §3 Issues: New — "Agent communication and lifecycle MVP" Priority: HIGH — Agent-to-agent interaction is the next major functional gap after trust chain completion Estimated: ~800 LoC net new across 12 files Coverage lift: Completes CLI §7.6 and §7.7; advances SPEC §02 §10 event delivery


Motivation

With PR #38 closing the trust chain gaps, the largest remaining functional gap is agent-to-agent communication. Currently:

  • Agents can join worlds, take actions, and receive SSE events — but they cannot discover who else is in the world, cannot send messages to each other, and cannot control their own online/offline presence.
  • The CLI defines 4 deferred commands (awn agents, awn ping, awn send, awn messages) and 3 agent lifecycle commands (awn agent status/offline/online) — all return "not implemented yet".
  • The spec (§02 §10) defines 4 event delivery modes (CLI/IPC, Programmatic, Webhook, A2A) but only SSE World→Agent is implemented.
  • Worlds announce an agentCardUrl to the gateway but no endpoint actually serves it.

This PR establishes the agent communication foundations: peer listing, in-world messaging relay, agent lifecycle, webhook event delivery, and the Agent Card endpoint.

Gap Analysis (from SPEC audit)

# Gap Spec Section Current State
1 No GET /v0/agents endpoint — agents can't see who's in the world §10 §3, CLI §7.6 AgentRegistry stores agents + has agent_ids(), but no HTTP endpoint exposes it
2 No in-world messaging (awn send, awn messages) CLI §7.6 CLI stubs only, no message relay in world API
3 Agent lifecycle commands (awn agent status/offline/online) not implemented CLI §7.7, §19.1 AgentCommand enum exists in CLI but dispatches to deferred message
4 No webhook event delivery §02 §10 Only SSE World→Agent exists; no webhook POST mode
5 Agent Card endpoint not served §05 §3.2, §01 §4 agentCardUrl announced to gateway, but no HTTP handler serves /.well-known/agent.json
6 awn ping <agent-id> not implemented CLI §7.6 CLI stub only

Codebase Anchor Points (verified 2026-04-06)

Component File:Line What It Provides
AgentRegistry src/world_api/server.rs:313-394 In-memory agent tracking; register(), remove(), get(), agent_ids()
AgentInfo src/world_api/server.rs:296-306 Per-agent: awn_id, public_key, subject_id, slot
SessionInfo src/world_api/endpoints.rs:266-278 Per-session: session_id, slot, joined_at, active
Deferred CLI commands src/cli/commands.rs:166 Agents, Ping, Send, Messages, Agent(...) all return deferred message
EventBus (SSE) src/world_api/sse.rs:135-150 broadcast(), send_to(agent_id) for SSE events
WorldEvent src/world_api/sse.rs:39-53 event_type, data, visibility (Public/Private/Agents), signature
EventVisibility src/world_runtime/mod.rs Public, Private(String), Agents
Gateway announcer src/daemon/announcer.rs:60-84 Generates agentCardUrl path, but no endpoint serves it
World API routes src/world_api/server.rs:1024-1036 11 endpoints; no /v0/agents or /v0/messages
SSE endpoint src/world_api/server.rs:1032 GET /v0/events — functional SSE push
CLI events (stub) src/cli/events.rs:48-53 Returns NOT_IMPLEMENTED; test version works

User Stories

US-001: In-World Agent Listing (GET /v0/agents)

Goal: Let agents see who else is in the world — enabling peer discovery for messaging and coordination.

Spec: CLI §7.6 — awn agents "inspect peers"; §10 §3 — Join response shows players, but no live listing endpoint exists.

Current State: AgentRegistry in server.rs:313 stores all joined agents and has agent_ids() method (line 391). But no HTTP endpoint exposes this data.

Implementation

Step 1: Add GET /v0/agents endpoint to the World API:

// src/world_api/endpoints.rs — new handler

/// List all agents currently joined to this world.
/// Auth required (must be joined).
async fn agents_handler(
    agent: AuthenticatedAgent,
    State(state): State<Arc<EndpointState>>,
) -> Result<Json<ApiResponse<AgentsResponse>>, ApiError> {
    // Verify caller is joined
    let sessions = state.sessions.read().await;
    if !sessions.contains_key(&agent.awn_id) {
        return Err(ApiError::new(ApiErrorCode::NotJoined, "must be joined to list agents"));
    }

    let registry = state.base.registry.agents.read().await;
    let agents: Vec<AgentSummary> = registry.values().map(|info| {
        let session = sessions.get(&info.awn_id);
        AgentSummary {
            awn_id: info.awn_id.clone(),
            subject_id: info.subject_id.clone(),
            slot: info.slot,
            joined_at: session.map(|s| s.joined_at.clone()),
            metadata: None, // Extend later with agent-provided metadata
        }
    }).collect();

    Ok(Json(ApiResponse::ok(AgentsResponse {
        agents,
        count: agents.len(),
    })))
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSummary {
    pub awn_id: String,
    pub subject_id: Option<String>,
    pub slot: u32,
    pub joined_at: Option<String>,
    pub metadata: Option<serde_json::Value>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentsResponse {
    pub agents: Vec<AgentSummary>,
    pub count: usize,
}

Step 2: Register route in server.rs:

.route("/v0/agents", get(agents_handler))

Step 3: Wire awn agents CLI command to call this endpoint:

// src/cli/commands.rs — implement Agents command
Command::Agents => {
    // Call GET /v0/agents on the currently-joined world
    // Requires: active session tracking (which world is the agent in?)
    // For MVP: accept world slug as argument
}

Files: src/world_api/endpoints.rs, src/world_api/server.rs, src/cli/commands.rs

AC:

  • GET /v0/agents returns list of joined agents with awn_id, subject_id, slot, joined_at
  • Only joined agents can call this endpoint (auth + membership check)
  • Response uses standard {ok, data} envelope
  • Rate limit: 120/min per agent (same as GET /v0/state)
  • awn agents <world> CLI command calls endpoint and displays results
  • Test: 3 agents joined → list returns all 3
  • Test: non-joined agent → 401 NOT_JOINED
  • Test: agent leaves → removed from list

US-002: In-World Message Relay (POST /v0/messages, GET /v0/messages)

Goal: Enable agents in the same world to send direct messages to each other via the world as relay.

Spec: CLI §7.6 — awn send <agent-id> <message> and awn messages"send direct messages; read inbound messages"

Current State: No messaging infrastructure exists. The EventBus supports send_to(agent_id) for SSE events, which could be extended.

Design Decision: World-Relayed vs Direct P2P

The spec doesn't mandate a specific delivery mechanism. For MVP, world-relayed messaging is the simplest and most secure approach:

  • Messages are POST'd to the world, which stores them in a per-agent inbox
  • Recipients fetch messages via GET or receive them via SSE push
  • World can enforce rate limits, content policies, and audit
  • No need for agents to know each other's endpoints

Implementation

Step 1: Add message types:

// src/world_api/endpoints.rs

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendMessageRequest {
    pub to: String,                    // Target agent awn_id
    pub content: serde_json::Value,    // Message payload (JSON)
    pub content_type: Option<String>,  // MIME type hint (default: "application/json")
}

#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Message {
    pub message_id: String,            // UUIDv7
    pub from: String,                  // Sender awn_id
    pub to: String,                    // Recipient awn_id
    pub content: serde_json::Value,    // Message payload
    pub content_type: String,          // MIME type
    pub sent_at: String,               // ISO 8601
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesResponse {
    pub messages: Vec<Message>,
    pub count: usize,
}

Step 2: Add in-memory message inbox to EndpointState:

// In EndpointState
/// Per-agent message inbox. Key = recipient awn_id.
/// Messages are kept until read or agent leaves (bounded at 100 per agent).
pub message_inbox: RwLock<HashMap<String, VecDeque<Message>>>,

Step 3: Add POST /v0/messages handler:

async fn send_message_handler(
    agent: AuthenticatedAgent,
    State(state): State<Arc<EndpointState>>,
    Json(req): Json<SendMessageRequest>,
) -> Result<Json<ApiResponse<Message>>, ApiError> {
    // 1. Verify sender is joined
    // 2. Verify recipient is joined (registry.contains(&req.to))
    // 3. Check inbox capacity (max 100 per agent)
    // 4. Create Message with UUIDv7 id
    // 5. Push to recipient's inbox
    // 6. Push SSE event to recipient: "message.received"
    // 7. Return created message
}

Step 4: Add GET /v0/messages handler:

async fn get_messages_handler(
    agent: AuthenticatedAgent,
    State(state): State<Arc<EndpointState>>,
    Query(params): Query<MessagesQuery>,
) -> Result<Json<ApiResponse<MessagesResponse>>, ApiError> {
    // 1. Verify caller is joined
    // 2. Read and drain messages from inbox (consume-on-read)
    // 3. Optional: ?peek=true to read without consuming
    // 4. Optional: ?since=<timestamp> to filter
    // 5. Return messages
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagesQuery {
    pub peek: Option<bool>,       // Don't consume messages
    pub since: Option<String>,    // Filter by timestamp
    pub limit: Option<u32>,       // Max messages (default 50)
}

Step 5: Wire CLI commands:

// awn send <world> <agent-id> <message>
Command::Send { world, agent_id, message } => {
    // POST /v0/messages with { to: agent_id, content: message }
}

// awn messages <world>
Command::Messages { world } => {
    // GET /v0/messages
}

Step 6: Push SSE notification on message receipt:

// In send_message_handler, after storing message:
state.event_bus.send_to(&req.to, WorldEvent {
    event_type: "message.received".to_string(),
    data: serde_json::json!({
        "messageId": message.message_id,
        "from": agent.awn_id,
        "contentType": message.content_type,
        "preview": truncate(&message.content.to_string(), 100),
    }),
    visibility: EventVisibility::Private(req.to.clone()),
});

Files: src/world_api/endpoints.rs, src/world_api/server.rs, src/cli/commands.rs

AC:

  • POST /v0/messages sends message to agent in same world
  • GET /v0/messages retrieves inbox (consume-on-read by default, ?peek=true to keep)
  • Sender and recipient must both be joined
  • Messages are JSON payloads (not plain text only)
  • Per-agent inbox capped at 100 messages (oldest dropped on overflow)
  • SSE message.received event pushed to recipient
  • Messages cleaned up when agent leaves
  • Rate limit: 30/min per agent for send, 60/min for read
  • awn send <world> <agent-id> <message> CLI works
  • awn messages <world> CLI works
  • Test: A sends to B → B receives via GET
  • Test: A sends to B → B gets SSE event
  • Test: send to non-joined agent → error
  • Test: inbox overflow → oldest dropped
  • Test: agent leaves → inbox cleared

US-003: Agent Lifecycle Commands (awn agent status/offline/online)

Goal: Let agents check their status and toggle online/offline presence without stopping the daemon.

Spec: CLI §7.7 and §19.1 — awn agent status shows connectivity; awn agent offline unannounces from gateway; awn agent online re-announces.

Current State: AgentCommand enum exists in src/cli/commands.rs with Status, Offline, Online variants, but all dispatch to deferred message. No announcer pause/resume exists.

Implementation

Step 1: Implement awn agent status:

// src/cli/commands.rs — AgentCommand::Status handler

// Read daemon status (PID file, socket)
let status = daemon_status(&dirs)?;

// Read identity
let identity = load_identity(&dirs)?;

// Build output:
// Agent
//   ID:          aw:sha256:abc123...
//   Status:      online | offline | daemon-stopped
//   Gateway:     connected | disconnected
//   Uptime:      2h 15m
// Worlds
//   Joined:      N
//   Hosting:     M

This builds on existing daemon_status() from src/daemon/mod.rs and identity loading from src/identity/state.rs.

Step 2: Add online/offline state to daemon:

// src/daemon/compound.rs — add state file
const AGENT_ONLINE_FILE: &str = "agent-online";

impl CompoundDaemon {
    pub fn is_online(&self) -> bool {
        self.state_dir.join(AGENT_ONLINE_FILE).exists()
    }

    pub fn set_online(&self, online: bool) -> std::io::Result<()> {
        let path = self.state_dir.join(AGENT_ONLINE_FILE);
        if online {
            std::fs::write(&path, "1")?;
            // Resume gateway announcements
            self.announcer.resume();
        } else {
            let _ = std::fs::remove_file(&path);
            // Pause gateway announcements
            self.announcer.pause();
        }
        Ok(())
    }
}

Step 3: Add pause/resume to GatewayAnnouncer:

// src/daemon/announcer.rs
pub struct GatewayAnnouncer {
    // ... existing fields ...
    paused: Arc<AtomicBool>,
}

impl GatewayAnnouncer {
    pub fn pause(&self) {
        self.paused.store(true, Ordering::SeqCst);
        // Send de-announce to gateway
    }

    pub fn resume(&self) {
        self.paused.store(false, Ordering::SeqCst);
        // Send announce to gateway
    }

    // In heartbeat loop:
    // if self.paused.load(Ordering::SeqCst) { continue; }
}

Step 4: Implement awn agent offline/online CLI commands:

// awn agent offline → write state file + call daemon to pause announcer
// awn agent online → write state file + call daemon to resume announcer

Files: src/cli/commands.rs, src/daemon/compound.rs, src/daemon/announcer.rs

AC:

  • awn agent status shows: agent ID, status (online/offline/stopped), gateway connection, uptime, joined worlds count
  • awn agent offline pauses gateway announcements, writes state file
  • awn agent online resumes gateway announcements, removes state file
  • Offline state persists across daemon restarts (state file based)
  • Joined worlds remain joined during offline (connections not dropped)
  • Test: status shows correct online/offline state
  • Test: offline → announcer paused
  • Test: online → announcer resumed

US-004: Webhook Event Delivery

Goal: Enable agents to register a webhook URL for event delivery, so the daemon POSTs events to the agent's HTTP endpoint instead of requiring SSE polling.

Spec: §02 §10 — "Webhook: HTTP POST to registered URL. Agent responds with action in HTTP response body."

Current State: Only SSE (World→Agent) exists. No webhook registration or delivery mechanism.

Implementation

Step 1: Add webhook handler registration:

// src/daemon/compound.rs or new file src/daemon/webhook.rs

pub struct WebhookConfig {
    /// Map of world_slug → webhook URL
    pub handlers: HashMap<String, WebhookHandler>,
}

pub struct WebhookHandler {
    pub url: String,
    pub world_slug: String,
    pub registered_at: String,
}

impl WebhookConfig {
    pub fn from_file(path: &Path) -> Self { /* read ~/.config/awn/webhooks.json */ }
    pub fn save(&self, path: &Path) -> Result<()> { /* atomic write */ }
    pub fn register(&mut self, world_slug: &str, url: &str) { /* add handler */ }
    pub fn unregister(&mut self, world_slug: &str) { /* remove handler */ }
}

Step 2: Add webhook delivery in SSE client:

// src/daemon/sse_client.rs — when event received:
// 1. If webhook registered for this world:
//    POST event to webhook URL
//    If response contains action → forward to world via POST /v0/action
// 2. Else: store in event buffer for CLI consumption
async fn deliver_event(&self, world_slug: &str, event: &WorldEvent) {
    if let Some(handler) = self.webhook_config.handlers.get(world_slug) {
        let payload = serde_json::json!({
            "worldId": self.world_id,
            "event": event.event_type,
            "payload": event.data,
            "timestamp": Utc::now().to_rfc3339(),
        });

        match self.client.post(&handler.url)
            .header("X-AWN-World-Id", &self.world_id)
            .header("X-AWN-Event-Id", &event.id)
            .json(&payload)
            .timeout(Duration::from_secs(5))
            .send()
            .await
        {
            Ok(resp) if resp.status().is_success() => {
                // Check if response body contains an action
                if let Ok(action_resp) = resp.json::<WebhookActionResponse>().await {
                    if let Some(action) = action_resp.action {
                        // Forward action to world
                        self.forward_action(world_slug, &action, &action_resp.params).await;
                    }
                }
            }
            Ok(resp) => {
                tracing::warn!("webhook delivery failed: HTTP {}", resp.status());
            }
            Err(e) => {
                tracing::warn!("webhook delivery failed: {e}");
            }
        }
    }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WebhookActionResponse {
    pub action: Option<String>,
    pub params: Option<serde_json::Value>,
}

Step 3: Add CLI command for webhook registration:

// awn agent set-handler <world-slug> --webhook <url>
// Persists to ~/.config/awn/webhooks.json

Files: src/daemon/webhook.rs (new), src/daemon/sse_client.rs, src/daemon/mod.rs, src/cli/commands.rs

AC:

  • awn agent set-handler <world> --webhook <url> registers webhook
  • Webhook config persisted to ~/.config/awn/webhooks.json
  • Events POST'd to webhook URL with X-AWN-World-Id and X-AWN-Event-Id headers
  • Webhook response with { "action": "...", "params": {...} } forwarded to world
  • 5-second timeout on webhook delivery
  • Failed delivery logged but doesn't block event processing
  • awn agent set-handler <world> --remove unregisters webhook
  • Test: register webhook → event delivered via POST
  • Test: webhook returns action → action forwarded to world
  • Test: webhook timeout → logged, event processing continues
  • Test: no webhook → default SSE behavior

US-005: Agent Card Endpoint (/.well-known/agent.json)

Goal: Serve an A2A-compatible Agent Card at /.well-known/agent.json on the world server, as referenced in gateway announcements.

Spec: §05 §3.2 — Announcement includes agentCardUrl; §01 §4 — Registration File format with A2A service endpoint.

Current State: announcer.rs:60-84 generates the agentCardUrl path for announcements, but no HTTP endpoint serves it. Announced URL points to nothing.

Implementation

Step 1: Add Agent Card endpoint to the World API server:

// src/world_api/server.rs

async fn agent_card_handler(
    State(state): State<Arc<WorldApiState>>,
) -> Json<serde_json::Value> {
    let manifest = &state.config;
    Json(serde_json::json!({
        "name": manifest.world_slug.as_deref().unwrap_or("unknown"),
        "description": format!("AWN World: {}", manifest.world_slug.as_deref().unwrap_or("unknown")),
        "url": format!("http://{}", manifest.listen_addr),
        "version": crate::version::PROTOCOL_VERSION,
        "capabilities": {
            "streaming": true,
            "pushNotifications": false,
            "stateTransitionHistory": false,
        },
        "defaultInputModes": ["application/json"],
        "defaultOutputModes": ["application/json"],
        "skills": [{
            "id": "world-interaction",
            "name": "World Interaction",
            "description": "Join, act, and leave this AWN World",
            "inputModes": ["application/json"],
            "outputModes": ["application/json", "text/event-stream"],
        }]
    }))
}

Step 2: Register route (unauthenticated, public):

// In server.rs route setup
.route("/.well-known/agent.json", get(agent_card_handler))

Step 3: Populate from WorldManifest when available:

// If EndpointState has manifest data, use it for richer card:
// - name from manifest.name
// - description from manifest.description
// - category from manifest.category
// - actions from manifest.actions (as skills)

Files: src/world_api/server.rs

AC:

  • GET /.well-known/agent.json returns A2A-compatible Agent Card JSON
  • Endpoint is public (no auth required)
  • Card includes: name, description, url, version, capabilities, skills
  • When manifest available: card includes category and actions as skills
  • Test: GET returns valid JSON with required fields
  • Test: Card URL matches what announcer generates

US-006: awn ping <agent-id> Implementation

Goal: Check if an agent is reachable by sending a lightweight probe through the world relay.

Spec: CLI §7.6 — awn ping <agent-id>

Current State: CLI stub only. No ping mechanism.

Design: World-Relayed Ping

Since agents don't expose direct endpoints to each other, awn ping works through the world:

  1. Sender POST's a ping message to the world
  2. World pushes SSE ping.request event to target agent
  3. If target is connected (SSE active), world returns pong within timeout
  4. If target is not connected, returns timeout

Implementation

Step 1: Add POST /v0/ping endpoint:

async fn ping_handler(
    agent: AuthenticatedAgent,
    State(state): State<Arc<EndpointState>>,
    Json(req): Json<PingRequest>,
) -> Result<Json<ApiResponse<PingResponse>>, ApiError> {
    // 1. Verify sender is joined
    // 2. Verify target is joined (registry.contains(&req.target))
    // 3. Check target's SSE connection is active (event_bus.is_connected(&req.target))
    // 4. Return pong with latency info

    let target_connected = state.event_bus.is_connected(&req.target);

    Ok(Json(ApiResponse::ok(PingResponse {
        target: req.target.clone(),
        reachable: target_connected,
        latency_ms: None,  // Would need round-trip for actual latency
    })))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PingRequest {
    pub target: String,  // Target agent awn_id
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PingResponse {
    pub target: String,
    pub reachable: bool,
    pub latency_ms: Option<u64>,
}

Step 2: Add is_connected() to EventBus:

// src/world_api/sse.rs
impl EventBus {
    pub fn is_connected(&self, agent_id: &str) -> bool {
        // Check if agent has an active SSE sender
        self.senders.read().unwrap().contains_key(agent_id)
    }
}

Step 3: Wire CLI:

// awn ping <world> <agent-id>
// → POST /v0/ping { target: agent_id }
// → Display: "Agent aw:sha256:abc123 is reachable" or "Agent unreachable"

Files: src/world_api/endpoints.rs, src/world_api/server.rs, src/world_api/sse.rs, src/cli/commands.rs

AC:

  • POST /v0/ping checks if target agent's SSE connection is active
  • Returns { reachable: true/false } — no actual round-trip yet
  • Both sender and target must be joined
  • awn ping <world> <agent-id> CLI command works
  • Test: connected agent → reachable: true
  • Test: disconnected agent → reachable: false
  • Test: non-joined target → error

Files Changed

File Change Type Description
src/world_api/endpoints.rs Modified ~200 LoC — agents_handler, send/get messages, ping
src/world_api/server.rs Modified ~40 LoC — new routes + agent_card_handler
src/world_api/sse.rs Modified ~10 LoC — add is_connected()
src/daemon/webhook.rs New ~120 LoC — WebhookConfig + delivery logic
src/daemon/sse_client.rs Modified ~50 LoC — webhook delivery integration
src/daemon/announcer.rs Modified ~30 LoC — pause/resume mechanism
src/daemon/compound.rs Modified ~30 LoC — online/offline state
src/daemon/mod.rs Modified ~5 LoC — pub mod webhook
src/cli/commands.rs Modified ~150 LoC — implement agents, ping, send, messages, agent status/offline/online
src/cli/mod.rs Modified ~5 LoC — exports

Total: ~800 LoC net new across 10 files


New Routes Added

Method Path Auth Description
GET /v0/agents AWN-Ed25519 List joined agents
POST /v0/messages AWN-Ed25519 Send message to agent
GET /v0/messages AWN-Ed25519 Read inbox
POST /v0/ping AWN-Ed25519 Check agent reachability
GET /.well-known/agent.json None (public) A2A Agent Card

Dependency Graph

US-001 (Agent listing) ─── independent, prerequisite for messaging
US-002 (Messaging) ─── depends on US-001 (needs agent list for validation)
US-003 (Agent lifecycle) ─── independent
US-004 (Webhook delivery) ─── independent
US-005 (Agent Card) ─── independent
US-006 (Ping) ─── depends on US-001 (needs agent list), depends on SSE is_connected

Recommended order:
  US-005 first (smallest, independent)
  → US-001 (foundation for messaging + ping)
  → US-006 (small, builds on US-001)
  → US-002 (largest, builds on US-001)
  → US-003 (independent, daemon changes)
  → US-004 (independent, daemon changes)

Test Plan

Unit Tests

Test Story Validates
agents_list_joined US-001 3 agents joined → list returns all 3
agents_list_requires_membership US-001 Non-joined → 401 NOT_JOINED
agents_list_after_leave US-001 Agent leaves → removed from list
send_message_success US-002 A→B message stored + SSE event
send_to_non_joined US-002 → error
get_messages_consume US-002 Read → inbox emptied
get_messages_peek US-002 ?peek=true → inbox preserved
inbox_overflow US-002 >100 messages → oldest dropped
messages_cleanup_on_leave US-002 Leave → inbox cleared
sse_message_received_event US-002 Message → SSE event to recipient
agent_status_online US-003 Status shows online when daemon running
agent_offline_pauses_announcer US-003 offline → announcer paused
agent_online_resumes_announcer US-003 online → announcer resumed
webhook_delivery_success US-004 Event → POST to webhook URL
webhook_action_forward US-004 Webhook returns action → forwarded
webhook_timeout US-004 5s timeout → logged, continues
agent_card_serves US-005 GET /.well-known/agent.json → valid JSON
agent_card_no_auth US-005 Public endpoint, no auth required
ping_reachable US-006 Connected agent → reachable: true
ping_unreachable US-006 Disconnected → reachable: false
ping_non_joined US-006 Non-joined target → error

Integration Tests

Test Story Validates
agent_communication_e2e US-001+002+006 A joins → B joins → A lists agents → A pings B → A sends message → B reads inbox
webhook_event_loop US-004 Join → action → webhook receives turn event → webhook responds with action

Out of Scope

Feature Why Where
A2A JSON-RPC 2.0 protocol Large protocol implementation; Phase 5+ Future PR
awn agent set-handler --a2a Requires A2A implementation Future PR
Daemon IPC socket (Unix domain socket) Infrastructure change; awn events needs this Future PR
Message persistence (SQLite) In-memory sufficient for MVP Future PR
Message encryption (E2E) Requires key exchange protocol Future PR
Cross-world messaging Agents must be in same world for MVP Future PR
Rich message types (files, structured data) JSON payload sufficient for MVP Future PR

Risk Assessment

Risk Level Mitigation
In-memory inbox lost on restart Medium Document as MVP limitation; add SQLite persistence in future
Message spam within world Low Rate limited (30/min send); inbox capped (100 messages)
Webhook URL validation Low Only HTTPS URLs accepted; 5s timeout prevents slow-loris
Agent Card schema evolves Low Return minimal A2A-compatible fields; extend later
CLI commands need daemon IPC Medium For MVP, CLI calls world HTTP directly (requires knowing world endpoint); full IPC in future PR

Implementation Notes for hal Agent

  1. US-001: AgentRegistry already has agent_ids(). Extend to return Vec<AgentInfo> or iterate values directly. The AgentInfo struct needs no changes.
  2. US-002: Use VecDeque for the inbox — O(1) push_back and pop_front. On overflow, pop_front() the oldest. Always push_back() new messages.
  3. US-002: The message.received SSE event should use EventVisibility::Private(to) so only the recipient sees it.
  4. US-003: For offline/online, the simplest approach is a state file in the daemon state directory. The announcer checks this on each heartbeat cycle.
  5. US-004: Keep webhook delivery async and non-blocking. Use tokio::spawn for delivery so it doesn't slow down event processing.
  6. US-005: The Agent Card format follows the A2A spec — minimal fields: name, url, version, capabilities, skills.
  7. US-006: For MVP, is_connected() checks the SSE sender map. True latency measurement (round-trip ping) can be added later.
  8. Run cargo fmt and cargo clippy before every commit. CI is strict.
  9. Rate limits for new endpoints: match spec §10 recommendations (30/min for write, 60-120/min for read).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment