mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 00:22:31 +00:00
feat(mailbox): add multi-agent mailbox channel plugin
File-system-based mailbox channel for inter-agent communication. Zero modifications to existing code — pure channel plugin. - MailboxManager: atomic file I/O, registry CRUD, message send/poll - MailboxConfig: Pydantic config with camelCase aliasing - MailboxChannel: polls inbox, routes messages via bus, callback routing - Anti-loop protection: TTL decrement + circular trace detection - allowFrom access control inherited from BaseChannel - 29 unit/integration tests + standalone smoke test script Design: docs/superpowers/specs/2026-04-26-multi-agent-mailbox-design.md Plan: docs/superpowers/plans/2026-04-26-multi-agent-mailbox.md
This commit is contained in:
parent
82b8a3af7e
commit
6daf91c620
1198
docs/superpowers/plans/2026-04-26-multi-agent-mailbox.md
Normal file
1198
docs/superpowers/plans/2026-04-26-multi-agent-mailbox.md
Normal file
File diff suppressed because it is too large
Load Diff
430
docs/superpowers/specs/2026-04-26-multi-agent-mailbox-design.md
Normal file
430
docs/superpowers/specs/2026-04-26-multi-agent-mailbox-design.md
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
# Multi-Agent Mailbox Communication Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Design a simple, zero-side-effect mechanism for multiple independent nanobot agent instances to communicate with each other. The mechanism uses a file-system-based mailbox system implemented as a standard nanobot channel plugin, requiring no modifications to existing code.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Multiple independent agent processes can discover and communicate with each other
|
||||||
|
- Peer-to-peer (fully connected) topology
|
||||||
|
- Asynchronous event-driven messaging
|
||||||
|
- "Boss experience": agents auto-delegate, auto-report progress, user only interacts through their normal channel (e.g., Feishu)
|
||||||
|
- Zero side effects: only new files added, no modifications to existing code
|
||||||
|
- Incorporate concepts from Google's A2A protocol (Agent Cards, Task lifecycle, Message Parts)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Storage: File-System Mailbox
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.nanobot/mailboxes/
|
||||||
|
├── _registry.json # Agent Cards (discovery)
|
||||||
|
├── researcher/
|
||||||
|
│ ├── inbox/ # Pending messages
|
||||||
|
│ │ └── 1745659200_coder.msg.json
|
||||||
|
│ └── processed/ # Archived messages
|
||||||
|
│ └── 1745659000_coder.msg.json
|
||||||
|
└── coder/
|
||||||
|
├── inbox/
|
||||||
|
└── processed/
|
||||||
|
```
|
||||||
|
|
||||||
|
Global path `~/.nanobot/mailboxes/` is used because different agents may have different working directories.
|
||||||
|
|
||||||
|
### Message File Naming
|
||||||
|
|
||||||
|
`{unix_timestamp}_{from_agent_id}.msg.json`
|
||||||
|
|
||||||
|
Atomic writes: write to `.tmp` file first, then `os.rename()` to prevent reading half-written messages.
|
||||||
|
|
||||||
|
### Agent Discovery (A2A Agent Card)
|
||||||
|
|
||||||
|
Each agent registers itself in `_registry.json` on startup and updates `last_heartbeat` on every poll cycle.
|
||||||
|
|
||||||
|
The registry contains two types of data:
|
||||||
|
- **Agent identity** (`description`, `capabilities`) — configured by the user in `channels.mailbox` config section. Optional; if not set, other agents can only see `agent_id` and `status`.
|
||||||
|
- **Runtime state** (`status`, `current_tasks`, `last_heartbeat`) — computed automatically by the mailbox channel.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"researcher": {
|
||||||
|
"agent_id": "researcher",
|
||||||
|
"description": "负责信息检索和分析的 agent",
|
||||||
|
"capabilities": ["web_search", "code_analysis", "summarization"],
|
||||||
|
"status": "idle",
|
||||||
|
"allow_from": ["coder", "writer"],
|
||||||
|
"max_concurrent_tasks": 3,
|
||||||
|
"current_tasks": ["msg_1745659200_coder"],
|
||||||
|
"registered_at": "2026-04-26T10:00:00Z",
|
||||||
|
"last_heartbeat": "2026-04-26T10:05:00Z"
|
||||||
|
},
|
||||||
|
"coder": {
|
||||||
|
"agent_id": "coder",
|
||||||
|
"description": "负责代码编写和修改的 agent",
|
||||||
|
"capabilities": ["code_write", "test_run", "review"],
|
||||||
|
"status": "idle",
|
||||||
|
"allow_from": ["*"],
|
||||||
|
"max_concurrent_tasks": 3,
|
||||||
|
"current_tasks": [],
|
||||||
|
"registered_at": "2026-04-26T10:00:30Z",
|
||||||
|
"last_heartbeat": "2026-04-26T10:05:30Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent States
|
||||||
|
|
||||||
|
| State | Accept New Tasks? | Description |
|
||||||
|
|-------|-------------------|-------------|
|
||||||
|
| `idle` | Yes | Available, accepts immediately |
|
||||||
|
| `busy` | If quota allows | `current_tasks` < `max_concurrent_tasks` then queue, otherwise reject |
|
||||||
|
| `offline` | Messages queue | Messages stay in inbox, processed when agent comes online |
|
||||||
|
|
||||||
|
State transitions:
|
||||||
|
- Startup → `idle`
|
||||||
|
- Receive task + accept → `busy`
|
||||||
|
- All tasks completed → `idle`
|
||||||
|
- Heartbeat timeout → `offline`
|
||||||
|
|
||||||
|
## Communication Protocol
|
||||||
|
|
||||||
|
### Message Types
|
||||||
|
|
||||||
|
1. **message** — Instant notification, Q&A, chat
|
||||||
|
2. **task** — Work request requiring execution and result return
|
||||||
|
3. **task_update** — Status update for a previously sent task
|
||||||
|
|
||||||
|
### Task Lifecycle (A2A Task Concept)
|
||||||
|
|
||||||
|
```
|
||||||
|
pending → accepted → working → completed
|
||||||
|
\→ rejected
|
||||||
|
\→ failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Message Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "msg_1745659200_researcher",
|
||||||
|
"from": "researcher",
|
||||||
|
"to": "coder",
|
||||||
|
"timestamp": "2026-04-26T10:00:00Z",
|
||||||
|
"type": "task | message | task_update",
|
||||||
|
"ttl": 3,
|
||||||
|
"trace": ["researcher"],
|
||||||
|
"task": {
|
||||||
|
"id": "original_task_id",
|
||||||
|
"state": "pending | accepted | working | completed | failed | rejected",
|
||||||
|
"deadline": "2026-04-26T11:00:00Z"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{"type": "text", "text": "..."},
|
||||||
|
{"type": "data", "data": {}},
|
||||||
|
{"type": "file", "path": "/path/to/file"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"callback": {
|
||||||
|
"session_id": "feishu:user_123",
|
||||||
|
"channel": "feishu"
|
||||||
|
},
|
||||||
|
"reply_to": "replied_message_id | null",
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `ttl` — Time-to-live hop count. Decremented on each relay. Default 3. At 0, agent must handle itself, cannot delegate.
|
||||||
|
- `trace` — List of agent_ids this message has passed through. Prevents circular routing.
|
||||||
|
- `task` — Present for `task` and `task_update` types. Contains lifecycle state and optional deadline.
|
||||||
|
- `content.parts` — Structured content (A2A Message Parts concept). Supports text, data, and file types.
|
||||||
|
- `callback` — Original session info from the initiating channel. Carried through the task lifecycle so results route back to the correct user conversation.
|
||||||
|
|
||||||
|
### Task Acceptance Criteria
|
||||||
|
|
||||||
|
All conditions must be met for an agent to accept a task:
|
||||||
|
1. Sender is in `allow_from` list (`"*"` = accept all)
|
||||||
|
2. `current_tasks` count < `max_concurrent_tasks`
|
||||||
|
3. `deadline` has not expired (if present)
|
||||||
|
4. LLM judges it has the capability to complete the task
|
||||||
|
|
||||||
|
Decision outcomes:
|
||||||
|
- Accept → reply `task_update {state: "accepted"}`, add to `current_tasks`
|
||||||
|
- Reject → reply `task_update {state: "rejected"}` with reason
|
||||||
|
|
||||||
|
### Anti-Loop Mechanism
|
||||||
|
|
||||||
|
Two fields prevent infinite agent-to-agent conversations:
|
||||||
|
|
||||||
|
1. **TTL**: Decremented on each relay. At 0, no further delegation allowed. Default = 3 (max 3 hops: A→B→C→D).
|
||||||
|
2. **Trace**: Append agent_id on each relay. Reject forwarding to any agent already in trace.
|
||||||
|
|
||||||
|
| Scenario | Protection |
|
||||||
|
|----------|-----------|
|
||||||
|
| A↔B mutual ping | Trace: B sees A in trace, rejects |
|
||||||
|
| A→B→C→A cycle | Trace: C sees A in trace, rejects |
|
||||||
|
| A→B→C→D→... infinite chain | TTL: reaches 0, stops |
|
||||||
|
|
||||||
|
### User Experience: End-to-End Flow
|
||||||
|
|
||||||
|
The user never interacts with mailbox directly. They communicate through their normal channel (Feishu, WeChat, etc.). The mailbox is invisible infrastructure.
|
||||||
|
|
||||||
|
```
|
||||||
|
User (Feishu) Agent A (researcher) Agent B (coder)
|
||||||
|
│ │ │
|
||||||
|
│ "帮我写排序函数" │ │
|
||||||
|
│ ──────────────────→ │ │
|
||||||
|
│ │ LLM decides to delegate │
|
||||||
|
│ │ │
|
||||||
|
│ "我让 coder 去处理, │ │
|
||||||
|
│ 完成后通知你" │ │
|
||||||
|
│ ←────────────────── │ │
|
||||||
|
│ │ │
|
||||||
|
│ ...time passes...│ │
|
||||||
|
│ │ │
|
||||||
|
│ │ task { │
|
||||||
|
│ │ callback: { │
|
||||||
|
│ │ session_id: "feishu:user_123",
|
||||||
|
│ │ channel: "feishu" │
|
||||||
|
│ │ } │
|
||||||
|
│ │ } │
|
||||||
|
│ │ ──────────────────────→ │
|
||||||
|
│ │ │ B processes...
|
||||||
|
│ │ │
|
||||||
|
│ │ task_update {completed} │
|
||||||
|
│ │ ←────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ MailboxChannel polls task_update │
|
||||||
|
│ Routes to session "feishu:user_123" │
|
||||||
|
│ LLM sees result in original conversation context │
|
||||||
|
│ │ │
|
||||||
|
│ "排序函数已完成: │ │
|
||||||
|
│ sort_by_mtime()..." │ │
|
||||||
|
│ ←────────────────── │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
The `callback` field carries the original channel session info through the entire task lifecycle. When the task_update arrives at Agent A's mailbox, the MailboxChannel restores the original `session_id` and `channel`, so the AgentLoop processes it in the correct conversation context and the LLM naturally responds to the user via Feishu.
|
||||||
|
|
||||||
|
### Error Scenarios
|
||||||
|
|
||||||
|
| Scenario | Handling |
|
||||||
|
|----------|----------|
|
||||||
|
| Target agent offline | Messages queue in inbox; processed when agent comes online |
|
||||||
|
| Heartbeat timeout | Registry marks agent `offline`; visible to other agents on next registry read |
|
||||||
|
| Agent crash during task | No `completed`/`failed` sent; sender can use `deadline` to detect timeout |
|
||||||
|
| Task rejected | Sender receives `rejected` with reason; decides next action |
|
||||||
|
| Task deadline expired | Receiver checks deadline on processing; rejects if expired |
|
||||||
|
| allow_from mismatch | Receiver discards message; optionally replies `rejected` with "unauthorized" |
|
||||||
|
| Registry concurrent write | Atomic writes (.tmp → rename); each agent only writes its own entry |
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### New Components
|
||||||
|
|
||||||
|
| Component | Type | Modifies Existing Code |
|
||||||
|
|-----------|------|----------------------|
|
||||||
|
| `MailboxManager` | New file (`nanobot/channels/mailbox_manager.py`) | No |
|
||||||
|
| `MailboxChannel` | New file (`nanobot/channels/mailbox.py`) | No |
|
||||||
|
| `MailboxConfig` | Pydantic model inside mailbox.py | No |
|
||||||
|
| Config section `channels.mailbox` | User config.json | No (additive) |
|
||||||
|
|
||||||
|
### MailboxManager
|
||||||
|
|
||||||
|
Low-level file operations with no nanobot dependencies:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MailboxManager:
|
||||||
|
def __init__(self, mailboxes_root: Path): ...
|
||||||
|
|
||||||
|
def register(self, agent_id: str, card: dict) -> None:
|
||||||
|
"""Atomic write to registry (.tmp → rename)"""
|
||||||
|
|
||||||
|
def heartbeat(self, agent_id: str) -> None:
|
||||||
|
"""Update last_heartbeat in registry"""
|
||||||
|
|
||||||
|
def update_status(self, agent_id: str, status: str, current_tasks: list[str] | None = None) -> None:
|
||||||
|
"""Update agent status and current task list"""
|
||||||
|
|
||||||
|
def send(self, from_id: str, to_id: str, msg: dict) -> None:
|
||||||
|
"""Atomic write to to_id/inbox/{timestamp}_{from_id}.msg.json"""
|
||||||
|
|
||||||
|
def poll(self, agent_id: str) -> list[dict]:
|
||||||
|
"""Scan inbox/, return new messages sorted by timestamp"""
|
||||||
|
|
||||||
|
def mark_processed(self, agent_id: str, filename: str) -> None:
|
||||||
|
"""Move from inbox/ to processed/"""
|
||||||
|
|
||||||
|
def list_online_agents(self) -> list[dict]:
|
||||||
|
"""Read all online agents from registry"""
|
||||||
|
|
||||||
|
def get_agent(self, agent_id: str) -> dict | None:
|
||||||
|
"""Read single agent info from registry"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### MailboxChannel
|
||||||
|
|
||||||
|
Follows existing channel pattern (like telegram.py, feishu.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MailboxChannel(BaseChannel):
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = MailboxConfig.model_validate(config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self.config: MailboxConfig = config
|
||||||
|
self.manager = MailboxManager(Path(self.config.mailboxes_root).expanduser())
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def _build_card(self) -> dict:
|
||||||
|
"""Build Agent Card from mailbox config. Identity fields are user-configured."""
|
||||||
|
return {
|
||||||
|
"agent_id": self.config.agent_id,
|
||||||
|
"description": self.config.description or "",
|
||||||
|
"capabilities": self.config.capabilities or [],
|
||||||
|
"status": "idle",
|
||||||
|
"allow_from": self.config.allow_from,
|
||||||
|
"max_concurrent_tasks": self.config.max_concurrent_tasks,
|
||||||
|
"current_tasks": [],
|
||||||
|
"registered_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"last_heartbeat": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Register agent + start poll loop"""
|
||||||
|
self.manager.register(self.config.agent_id, self._build_card())
|
||||||
|
self._running = True
|
||||||
|
asyncio.create_task(self._poll_loop())
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
"""Periodically scan inbox, inject new messages into bus"""
|
||||||
|
while self._running:
|
||||||
|
messages = self.manager.poll(self.config.agent_id)
|
||||||
|
for msg in messages:
|
||||||
|
inbound = self._to_inbound(msg)
|
||||||
|
await self.bus.publish_inbound(inbound)
|
||||||
|
self.manager.mark_processed(self.config.agent_id, msg["filename"])
|
||||||
|
self.manager.heartbeat(self.config.agent_id)
|
||||||
|
await asyncio.sleep(self.config.poll_interval)
|
||||||
|
|
||||||
|
async def send_message(self, to_id: str, msg: dict) -> None:
|
||||||
|
"""Send message to another agent's mailbox"""
|
||||||
|
# Anti-loop checks
|
||||||
|
if to_id in msg.get("trace", []):
|
||||||
|
logger.warning(f"Rejecting circular route: {to_id} already in trace")
|
||||||
|
return
|
||||||
|
if msg.get("ttl", 0) <= 0:
|
||||||
|
logger.warning("TTL exhausted, cannot forward")
|
||||||
|
return
|
||||||
|
msg["ttl"] = msg.get("ttl", 3) - 1
|
||||||
|
msg.setdefault("trace", []).append(self.config.agent_id)
|
||||||
|
self.manager.send(self.config.agent_id, to_id, msg)
|
||||||
|
|
||||||
|
def _to_inbound(self, msg: dict) -> InboundMessage:
|
||||||
|
"""Convert mailbox JSON to standard InboundMessage"""
|
||||||
|
callback = msg.get("callback", {})
|
||||||
|
return InboundMessage(
|
||||||
|
channel=callback.get("channel", "mailbox"),
|
||||||
|
sender=msg["from"],
|
||||||
|
content=self._extract_text(msg),
|
||||||
|
session_id=callback.get("session_id") or f"mailbox:{msg['from']}",
|
||||||
|
metadata={
|
||||||
|
"mailbox_type": msg["type"],
|
||||||
|
"mailbox_task": msg.get("task"),
|
||||||
|
"mailbox_parts": msg.get("content", {}).get("parts"),
|
||||||
|
"mailbox_ttl": msg.get("ttl"),
|
||||||
|
"mailbox_trace": msg.get("trace"),
|
||||||
|
"reply_to": msg.get("reply_to"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
self.manager.update_status(self.config.agent_id, "offline")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return MailboxConfig().model_dump(by_alias=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### MailboxConfig
|
||||||
|
|
||||||
|
All settings are self-contained within the mailbox channel config.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MailboxConfig(Base):
|
||||||
|
enabled: bool = False
|
||||||
|
agent_id: str = ""
|
||||||
|
description: str = "" # optional, for agent discovery
|
||||||
|
capabilities: list[str] = [] # optional, for agent discovery
|
||||||
|
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
max_concurrent_tasks: int = 3
|
||||||
|
poll_interval: float = 5.0
|
||||||
|
mailboxes_root: str = "~/.nanobot/mailboxes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
In `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"feishu": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "...",
|
||||||
|
"appSecret": "..."
|
||||||
|
},
|
||||||
|
"mailbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"agentId": "coder",
|
||||||
|
"description": "负责代码编写和修改的 agent",
|
||||||
|
"capabilities": ["code_write", "test_run", "review"],
|
||||||
|
"allowFrom": ["researcher"],
|
||||||
|
"maxConcurrentTasks": 3,
|
||||||
|
"pollInterval": 5,
|
||||||
|
"mailboxesRoot": "~/.nanobot/mailboxes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two agents running independently:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Agent A config — researcher
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"mailbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"agentId": "researcher",
|
||||||
|
"description": "负责信息检索和分析的 agent",
|
||||||
|
"capabilities": ["web_search", "code_analysis", "summarization"],
|
||||||
|
"allowFrom": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent B config — coder
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"mailbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"agentId": "coder",
|
||||||
|
"description": "负责代码编写和修改的 agent",
|
||||||
|
"capabilities": ["code_write", "test_run", "review"],
|
||||||
|
"allowFrom": ["researcher"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- **Zero side effects**: Only new files, no modifications to existing code
|
||||||
|
- **Channel plugin pattern**: MailboxChannel follows the same interface as all other channels
|
||||||
|
- **Bus integration**: Mailbox messages become standard `InboundMessage` objects; AgentLoop is unaware of mailbox
|
||||||
|
- **Callback routing**: Original channel session is preserved through the task lifecycle for seamless user experience
|
||||||
|
- **Anti-loop by default**: TTL + trace prevents runaway agent conversations without configuration
|
||||||
|
- **Best-effort deadlines**: Optional `deadline` field for task timeout, not a blocking mechanism
|
||||||
291
nanobot/channels/mailbox.py
Normal file
291
nanobot/channels/mailbox.py
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
"""Mailbox channel for inter-agent communication via filesystem."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MailboxManager — low-level file operations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxManager:
|
||||||
|
"""File-system operations for agent mailboxes."""
|
||||||
|
|
||||||
|
def __init__(self, mailboxes_root: Path) -> None:
|
||||||
|
self.root = Path(mailboxes_root)
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# -- registry --
|
||||||
|
|
||||||
|
def _registry_path(self) -> Path:
|
||||||
|
return self.root / "_registry.json"
|
||||||
|
|
||||||
|
def _read_registry(self) -> dict:
|
||||||
|
path = self._registry_path()
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def _write_registry(self, data: dict) -> None:
|
||||||
|
path = self._registry_path()
|
||||||
|
tmp = path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
def _ensure_dirs(self, agent_id: str) -> None:
|
||||||
|
agent_dir = self.root / agent_id
|
||||||
|
(agent_dir / "inbox").mkdir(parents=True, exist_ok=True)
|
||||||
|
(agent_dir / "processed").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def register(self, agent_id: str, card: dict) -> None:
|
||||||
|
self._ensure_dirs(agent_id)
|
||||||
|
registry = self._read_registry()
|
||||||
|
card = {**card}
|
||||||
|
card.setdefault("registered_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
card["last_heartbeat"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
registry[agent_id] = card
|
||||||
|
self._write_registry(registry)
|
||||||
|
|
||||||
|
def heartbeat(self, agent_id: str) -> None:
|
||||||
|
registry = self._read_registry()
|
||||||
|
if agent_id in registry:
|
||||||
|
registry[agent_id]["last_heartbeat"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._write_registry(registry)
|
||||||
|
|
||||||
|
def update_status(
|
||||||
|
self, agent_id: str, status: str, current_tasks: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
registry = self._read_registry()
|
||||||
|
if agent_id in registry:
|
||||||
|
registry[agent_id]["status"] = status
|
||||||
|
if current_tasks is not None:
|
||||||
|
registry[agent_id]["current_tasks"] = current_tasks
|
||||||
|
self._write_registry(registry)
|
||||||
|
|
||||||
|
# -- message I/O --
|
||||||
|
|
||||||
|
def send(self, from_id: str, to_id: str, msg: dict) -> None:
|
||||||
|
self._ensure_dirs(to_id)
|
||||||
|
msg = {**msg}
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
unique = uuid.uuid4().hex[:8]
|
||||||
|
filename = f"{ts}_{from_id}_{unique}.msg.json"
|
||||||
|
msg.setdefault("id", f"msg_{ts}_{from_id}")
|
||||||
|
msg.setdefault("from", from_id)
|
||||||
|
msg.setdefault("to", to_id)
|
||||||
|
msg.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
|
||||||
|
filepath = self.root / to_id / "inbox" / filename
|
||||||
|
tmp = filepath.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(msg, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(tmp, filepath)
|
||||||
|
|
||||||
|
def poll(self, agent_id: str) -> list[dict]:
|
||||||
|
inbox = self.root / agent_id / "inbox"
|
||||||
|
if not inbox.is_dir():
|
||||||
|
return []
|
||||||
|
messages = []
|
||||||
|
for f in sorted(inbox.glob("*.msg.json")):
|
||||||
|
data = json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
data["_filename"] = f.name
|
||||||
|
messages.append(data)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def mark_processed(self, agent_id: str, filename: str) -> None:
|
||||||
|
src = self.root / agent_id / "inbox" / filename
|
||||||
|
dst = self.root / agent_id / "processed" / filename
|
||||||
|
if src.exists():
|
||||||
|
os.replace(src, dst)
|
||||||
|
|
||||||
|
# -- discovery --
|
||||||
|
|
||||||
|
def list_online_agents(self) -> list[dict]:
|
||||||
|
registry = self._read_registry()
|
||||||
|
return [card for card in registry.values() if card.get("status") != "offline"]
|
||||||
|
|
||||||
|
def get_agent(self, agent_id: str) -> dict | None:
|
||||||
|
return self._read_registry().get(agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MailboxConfig — channel configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxConfig(Base):
|
||||||
|
"""Mailbox channel configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
agent_id: str = ""
|
||||||
|
description: str = ""
|
||||||
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
max_concurrent_tasks: int = 3
|
||||||
|
poll_interval: float = 5.0
|
||||||
|
mailboxes_root: str = "~/.nanobot/mailboxes"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MailboxChannel — channel plugin
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxChannel(BaseChannel):
|
||||||
|
"""Channel plugin for inter-agent communication via filesystem mailboxes.
|
||||||
|
|
||||||
|
The LLM sends messages to other agents using the existing MessageTool:
|
||||||
|
MessageTool(channel='mailbox', chat_id='<target_agent_id>', content='...')
|
||||||
|
|
||||||
|
This produces an OutboundMessage routed to MailboxChannel.send(),
|
||||||
|
which writes to the target agent's mailbox.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "mailbox"
|
||||||
|
display_name = "Mailbox"
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: Any) -> None:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = MailboxConfig.model_validate(config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self.config: MailboxConfig = config
|
||||||
|
root = Path(self.config.mailboxes_root).expanduser()
|
||||||
|
self.manager = MailboxManager(root)
|
||||||
|
self._running = False
|
||||||
|
self._poll_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self.manager.register(self.config.agent_id, self._build_card())
|
||||||
|
self._running = True
|
||||||
|
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
while self._running:
|
||||||
|
await self._poll_once()
|
||||||
|
self.manager.heartbeat(self.config.agent_id)
|
||||||
|
await asyncio.sleep(self.config.poll_interval)
|
||||||
|
|
||||||
|
async def _poll_once(self) -> None:
|
||||||
|
messages = self.manager.poll(self.config.agent_id)
|
||||||
|
for msg in messages:
|
||||||
|
filename = msg.pop("_filename", "")
|
||||||
|
try:
|
||||||
|
callback = msg.get("callback")
|
||||||
|
if callback and callback.get("channel") and callback.get("chat_id"):
|
||||||
|
await self._handle_callback_message(msg, callback)
|
||||||
|
else:
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=msg["from"],
|
||||||
|
chat_id=msg["from"],
|
||||||
|
content=self._extract_text(msg),
|
||||||
|
metadata=self._build_metadata(msg),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error processing mailbox message {}", filename)
|
||||||
|
if filename:
|
||||||
|
self.manager.mark_processed(self.config.agent_id, filename)
|
||||||
|
|
||||||
|
async def _handle_callback_message(self, msg: dict, callback: dict) -> None:
|
||||||
|
if not self.is_allowed(msg["from"]):
|
||||||
|
logger.warning("Mailbox: access denied from {}", msg["from"])
|
||||||
|
return
|
||||||
|
inbound = InboundMessage(
|
||||||
|
channel=callback["channel"],
|
||||||
|
sender_id=msg["from"],
|
||||||
|
chat_id=callback["chat_id"],
|
||||||
|
content=self._extract_text(msg),
|
||||||
|
metadata=self._build_metadata(msg),
|
||||||
|
session_key_override=callback.get("session_id"),
|
||||||
|
)
|
||||||
|
await self.bus.publish_inbound(inbound)
|
||||||
|
|
||||||
|
def _extract_text(self, msg: dict) -> str:
|
||||||
|
parts = msg.get("content", {}).get("parts", [])
|
||||||
|
texts = [p["text"] for p in parts if p.get("type") == "text" and "text" in p]
|
||||||
|
return "\n".join(texts) if texts else ""
|
||||||
|
|
||||||
|
def _build_metadata(self, msg: dict) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mailbox_type": msg.get("type", "message"),
|
||||||
|
"mailbox_task": msg.get("task"),
|
||||||
|
"mailbox_parts": msg.get("content", {}).get("parts"),
|
||||||
|
"mailbox_ttl": msg.get("ttl"),
|
||||||
|
"mailbox_trace": msg.get("trace"),
|
||||||
|
"reply_to": msg.get("reply_to"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
target = msg.chat_id
|
||||||
|
meta = msg.metadata or {}
|
||||||
|
trace: list[str] = list(meta.get("mailbox_trace", []))
|
||||||
|
ttl: int = meta.get("mailbox_ttl", 3)
|
||||||
|
|
||||||
|
# Anti-loop: check circular route
|
||||||
|
if target in trace:
|
||||||
|
logger.warning("Rejecting circular route: {} already in trace", target)
|
||||||
|
return
|
||||||
|
# Anti-loop: check TTL exhausted
|
||||||
|
if ttl <= 0:
|
||||||
|
logger.warning("TTL exhausted, cannot forward to {}", target)
|
||||||
|
return
|
||||||
|
|
||||||
|
trace.append(self.config.agent_id)
|
||||||
|
ttl -= 1
|
||||||
|
|
||||||
|
mailbox_msg: dict[str, Any] = {
|
||||||
|
"type": meta.get("mailbox_type", "message"),
|
||||||
|
"from": self.config.agent_id,
|
||||||
|
"to": target,
|
||||||
|
"content": {"parts": [{"type": "text", "text": msg.content}]},
|
||||||
|
"ttl": ttl,
|
||||||
|
"trace": trace,
|
||||||
|
}
|
||||||
|
if meta.get("mailbox_task"):
|
||||||
|
mailbox_msg["task"] = meta["mailbox_task"]
|
||||||
|
if meta.get("mailbox_callback"):
|
||||||
|
mailbox_msg["callback"] = meta["mailbox_callback"]
|
||||||
|
self.manager.send(self.config.agent_id, target, mailbox_msg)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
self._running = False
|
||||||
|
if self._poll_task:
|
||||||
|
self._poll_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._poll_task = None
|
||||||
|
self.manager.update_status(self.config.agent_id, "offline")
|
||||||
|
|
||||||
|
def _build_card(self) -> dict:
|
||||||
|
return {
|
||||||
|
"agent_id": self.config.agent_id,
|
||||||
|
"description": self.config.description,
|
||||||
|
"capabilities": self.config.capabilities,
|
||||||
|
"status": "idle",
|
||||||
|
"allow_from": self.config.allow_from,
|
||||||
|
"max_concurrent_tasks": self.config.max_concurrent_tasks,
|
||||||
|
"current_tasks": [],
|
||||||
|
"registered_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"last_heartbeat": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return MailboxConfig().model_dump(by_alias=True)
|
||||||
370
scripts/smoke_test_mailbox.py
Normal file
370
scripts/smoke_test_mailbox.py
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
"""Smoke test for the mailbox channel plugin.
|
||||||
|
|
||||||
|
Tests the full lifecycle of two agents communicating via filesystem mailbox,
|
||||||
|
without requiring LLM or API keys.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/smoke_test_mailbox.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.channels.mailbox import MailboxChannel, MailboxManager
|
||||||
|
|
||||||
|
|
||||||
|
class BusMock:
|
||||||
|
"""Mock bus that captures published InboundMessages."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
self.messages: list = []
|
||||||
|
|
||||||
|
async def publish_inbound(self, msg):
|
||||||
|
self.messages.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def make_channel(bus: BusMock, root: Path, agent_id: str, **overrides) -> MailboxChannel:
|
||||||
|
cfg = {
|
||||||
|
"enabled": True,
|
||||||
|
"agentId": agent_id,
|
||||||
|
"description": f"{agent_id} agent for smoke test",
|
||||||
|
"capabilities": ["test"],
|
||||||
|
"allowFrom": ["*"],
|
||||||
|
"maxConcurrentTasks": 3,
|
||||||
|
"pollInterval": 0.05,
|
||||||
|
"mailboxesRoot": str(root),
|
||||||
|
}
|
||||||
|
cfg.update(overrides)
|
||||||
|
return MailboxChannel(cfg, bus)
|
||||||
|
|
||||||
|
|
||||||
|
def header(text: str) -> None:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" {text}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
def ok(test_name: str) -> None:
|
||||||
|
print(f" [PASS] {test_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(test_name: str, detail: str = "") -> None:
|
||||||
|
print(f" [FAIL] {test_name}")
|
||||||
|
if detail:
|
||||||
|
print(f" {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_smoke_tests() -> bool:
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="mailbox_smoke_")
|
||||||
|
root = Path(tmpdir)
|
||||||
|
all_passed = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("1. Agent Registration")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
bus_a = BusMock("researcher")
|
||||||
|
bus_b = BusMock("coder")
|
||||||
|
ch_a = make_channel(bus_a, root, "researcher")
|
||||||
|
ch_b = make_channel(bus_b, root, "coder")
|
||||||
|
|
||||||
|
await ch_a.start()
|
||||||
|
await ch_b.start()
|
||||||
|
|
||||||
|
mgr = MailboxManager(root)
|
||||||
|
registry = mgr._read_registry()
|
||||||
|
|
||||||
|
if "researcher" in registry and "coder" in registry:
|
||||||
|
ok("Both agents registered in _registry.json")
|
||||||
|
else:
|
||||||
|
fail("Agent registration", f"Got keys: {list(registry.keys())}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
agents = mgr.list_online_agents()
|
||||||
|
ids = {a["agent_id"] for a in agents}
|
||||||
|
if ids == {"researcher", "coder"}:
|
||||||
|
ok("list_online_agents returns both agents")
|
||||||
|
else:
|
||||||
|
fail("list_online_agents", f"Got: {ids}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("2. Agent A → Agent B messaging")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
outbound = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="coder",
|
||||||
|
content="Please write a sort function for the project.",
|
||||||
|
)
|
||||||
|
await ch_a.send(outbound)
|
||||||
|
|
||||||
|
# Verify file in B's inbox
|
||||||
|
inbox_files = list((root / "coder" / "inbox").glob("*.msg.json"))
|
||||||
|
if len(inbox_files) == 1:
|
||||||
|
ok("Message file created in coder's inbox")
|
||||||
|
else:
|
||||||
|
fail("Message file", f"Expected 1 file, got {len(inbox_files)}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# Verify message content
|
||||||
|
data = json.loads(inbox_files[0].read_text())
|
||||||
|
if data["from"] == "researcher" and "sort function" in data["content"]["parts"][0]["text"]:
|
||||||
|
ok("Message content correct (from + text)")
|
||||||
|
else:
|
||||||
|
fail("Message content", f"Got: {data}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# B polls and receives
|
||||||
|
await ch_b._poll_once()
|
||||||
|
if len(bus_b.messages) == 1:
|
||||||
|
msg = bus_b.messages[0]
|
||||||
|
if msg.sender_id == "researcher" and "sort function" in msg.content:
|
||||||
|
ok("Agent B received InboundMessage with correct content")
|
||||||
|
else:
|
||||||
|
fail("InboundMessage content", f"sender={msg.sender_id}, content={msg.content}")
|
||||||
|
all_passed = False
|
||||||
|
else:
|
||||||
|
fail("Agent B receive", f"Expected 1 message, got {len(bus_b.messages)}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# Verify inbox processed
|
||||||
|
if len(list((root / "coder" / "inbox").glob("*.msg.json"))) == 0:
|
||||||
|
ok("Inbox cleared after processing")
|
||||||
|
else:
|
||||||
|
fail("Inbox not cleared")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("3. Agent B → Agent A response")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
bus_b.messages.clear()
|
||||||
|
|
||||||
|
response = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="researcher",
|
||||||
|
content="Sort function completed: sort_by_mtime() at utils.py:42",
|
||||||
|
)
|
||||||
|
await ch_b.send(response)
|
||||||
|
await ch_a._poll_once()
|
||||||
|
|
||||||
|
if len(bus_a.messages) == 1:
|
||||||
|
msg = bus_a.messages[0]
|
||||||
|
if msg.sender_id == "coder" and "sort_by_mtime" in msg.content:
|
||||||
|
ok("Agent A received response from coder")
|
||||||
|
else:
|
||||||
|
fail("Response content", f"sender={msg.sender_id}, content={msg.content}")
|
||||||
|
all_passed = False
|
||||||
|
else:
|
||||||
|
fail("Agent A response", f"Expected 1, got {len(bus_a.messages)}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("4. Callback routing (Feishu session)")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
bus_a.messages.clear()
|
||||||
|
bus_b.messages.clear()
|
||||||
|
|
||||||
|
# A sends task with callback
|
||||||
|
task_msg = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="coder",
|
||||||
|
content="Analyze the codebase",
|
||||||
|
metadata={
|
||||||
|
"mailbox_callback": {
|
||||||
|
"channel": "feishu",
|
||||||
|
"chat_id": "user_feishu_123",
|
||||||
|
"session_id": "feishu:user_feishu_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ch_a.send(task_msg)
|
||||||
|
|
||||||
|
# B receives
|
||||||
|
await ch_b._poll_once()
|
||||||
|
if len(bus_b.messages) == 1:
|
||||||
|
ok("Agent B received task with callback")
|
||||||
|
else:
|
||||||
|
fail("Task receive")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# B responds with callback forwarded
|
||||||
|
bus_b.messages.clear()
|
||||||
|
response_with_callback = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="researcher",
|
||||||
|
content="Analysis complete: 3 modules, 1500 lines",
|
||||||
|
metadata={
|
||||||
|
"mailbox_callback": {
|
||||||
|
"channel": "feishu",
|
||||||
|
"chat_id": "user_feishu_123",
|
||||||
|
"session_id": "feishu:user_feishu_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ch_b.send(response_with_callback)
|
||||||
|
await ch_a._poll_once()
|
||||||
|
|
||||||
|
if len(bus_a.messages) == 1:
|
||||||
|
update = bus_a.messages[0]
|
||||||
|
if (update.channel == "feishu"
|
||||||
|
and update.chat_id == "user_feishu_123"
|
||||||
|
and update.session_key_override == "feishu:user_feishu_123"):
|
||||||
|
ok("Callback routes to Feishu session correctly")
|
||||||
|
else:
|
||||||
|
fail("Callback routing", f"channel={update.channel}, chat_id={update.chat_id}")
|
||||||
|
all_passed = False
|
||||||
|
else:
|
||||||
|
fail("Callback receive", f"Expected 1, got {len(bus_a.messages)}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("5. Anti-loop: TTL and trace")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
bus_a.messages.clear()
|
||||||
|
|
||||||
|
# Send a message, then check that ttl is decremented and trace populated
|
||||||
|
loop_outbound = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="coder",
|
||||||
|
content="test anti-loop",
|
||||||
|
metadata={"mailbox_ttl": 3, "mailbox_trace": []},
|
||||||
|
)
|
||||||
|
await ch_a.send(loop_outbound)
|
||||||
|
|
||||||
|
inbox_files = list((root / "coder" / "inbox").glob("*.msg.json"))
|
||||||
|
latest = sorted(inbox_files)[-1]
|
||||||
|
data = json.loads(latest.read_text())
|
||||||
|
if data["ttl"] == 2 and "researcher" in data["trace"]:
|
||||||
|
ok("TTL decremented (3→2) and trace populated")
|
||||||
|
else:
|
||||||
|
fail("Anti-loop", f"ttl={data['ttl']}, trace={data['trace']}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# Test circular route rejection
|
||||||
|
circular_outbound = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="researcher",
|
||||||
|
content="should be rejected",
|
||||||
|
metadata={"mailbox_ttl": 3, "mailbox_trace": ["researcher"]},
|
||||||
|
)
|
||||||
|
before_count = len(list((root / "researcher" / "inbox").glob("*.msg.json")))
|
||||||
|
await ch_a.send(circular_outbound)
|
||||||
|
after_count = len(list((root / "researcher" / "inbox").glob("*.msg.json")))
|
||||||
|
if after_count == before_count:
|
||||||
|
ok("Circular route rejected (trace contains target)")
|
||||||
|
else:
|
||||||
|
fail("Circular route", "Message was written despite circular trace")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# Test TTL=0 rejection
|
||||||
|
exhausted_outbound = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="coder",
|
||||||
|
content="should be rejected",
|
||||||
|
metadata={"mailbox_ttl": 0, "mailbox_trace": []},
|
||||||
|
)
|
||||||
|
before_count = len(list((root / "coder" / "inbox").glob("*.msg.json")))
|
||||||
|
await ch_a.send(exhausted_outbound)
|
||||||
|
after_count = len(list((root / "coder" / "inbox").glob("*.msg.json")))
|
||||||
|
if after_count == before_count:
|
||||||
|
ok("TTL=0 rejected (exhausted hop count)")
|
||||||
|
else:
|
||||||
|
fail("TTL=0", "Message was written despite TTL=0")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("6. allowFrom access control")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
bus_c = BusMock("restricted")
|
||||||
|
ch_c = make_channel(bus_c, root, "restricted", allowFrom=["researcher"])
|
||||||
|
await ch_c.start()
|
||||||
|
|
||||||
|
# Stranger sends message — should be blocked
|
||||||
|
mgr.send("stranger", "restricted", {
|
||||||
|
"type": "message",
|
||||||
|
"content": {"parts": [{"type": "text", "text": "should be blocked"}]},
|
||||||
|
})
|
||||||
|
await ch_c._poll_once()
|
||||||
|
|
||||||
|
if len(bus_c.messages) == 0:
|
||||||
|
ok("Stranger blocked by allowFrom=['researcher']")
|
||||||
|
else:
|
||||||
|
fail("allowFrom", f"Got {len(bus_c.messages)} messages, expected 0")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# Researcher sends message — should pass
|
||||||
|
bus_c.messages.clear()
|
||||||
|
mgr.send("researcher", "restricted", {
|
||||||
|
"type": "message",
|
||||||
|
"content": {"parts": [{"type": "text", "text": "should pass"}]},
|
||||||
|
})
|
||||||
|
await ch_c._poll_once()
|
||||||
|
|
||||||
|
if len(bus_c.messages) == 1:
|
||||||
|
ok("Researcher allowed by allowFrom=['researcher']")
|
||||||
|
else:
|
||||||
|
fail("allowFrom allow", f"Got {len(bus_c.messages)} messages, expected 1")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
await ch_c.stop()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("7. Heartbeat and status")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
before_hb = mgr._read_registry()["researcher"]["last_heartbeat"]
|
||||||
|
time.sleep(0.02)
|
||||||
|
mgr.heartbeat("researcher")
|
||||||
|
after_hb = mgr._read_registry()["researcher"]["last_heartbeat"]
|
||||||
|
if after_hb > before_hb:
|
||||||
|
ok("Heartbeat updates timestamp")
|
||||||
|
else:
|
||||||
|
fail("Heartbeat")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
header("8. Stop and offline")
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
await ch_a.stop()
|
||||||
|
await ch_b.stop()
|
||||||
|
|
||||||
|
registry = mgr._read_registry()
|
||||||
|
if registry["researcher"]["status"] == "offline" and registry["coder"]["status"] == "offline":
|
||||||
|
ok("Both agents marked offline after stop()")
|
||||||
|
else:
|
||||||
|
fail("Offline status", f"researcher={registry['researcher']['status']}, coder={registry['coder']['status']}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
online = mgr.list_online_agents()
|
||||||
|
agent_ids = {a["agent_id"] for a in online}
|
||||||
|
if "researcher" not in agent_ids and "coder" not in agent_ids:
|
||||||
|
ok("Offline agents excluded from list_online_agents()")
|
||||||
|
else:
|
||||||
|
fail("Online list", f"Still shows: {agent_ids}")
|
||||||
|
all_passed = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
if all_passed:
|
||||||
|
print(" ALL SMOKE TESTS PASSED")
|
||||||
|
else:
|
||||||
|
print(" SOME TESTS FAILED — see above for details")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
return all_passed
|
||||||
|
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
passed = asyncio.run(run_smoke_tests())
|
||||||
|
sys.exit(0 if passed else 1)
|
||||||
548
tests/channels/test_mailbox.py
Normal file
548
tests/channels/test_mailbox.py
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
"""Tests for MailboxManager (file operations) and MailboxChannel."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
|
from nanobot.channels.mailbox import MailboxManager, MailboxChannel, MailboxConfig
|
||||||
|
|
||||||
|
|
||||||
|
# --- MailboxManager Tests ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def root(tmp_path: Path) -> Path:
|
||||||
|
mailboxes = tmp_path / "mailboxes"
|
||||||
|
mailboxes.mkdir()
|
||||||
|
return mailboxes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mgr(root: Path) -> MailboxManager:
|
||||||
|
return MailboxManager(root)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegister:
|
||||||
|
def test_register_creates_agent_entry(self, mgr: MailboxManager, root: Path):
|
||||||
|
card = {"agent_id": "researcher", "description": "test agent"}
|
||||||
|
mgr.register("researcher", card)
|
||||||
|
registry = json.loads((root / "_registry.json").read_text())
|
||||||
|
assert "researcher" in registry
|
||||||
|
assert registry["researcher"]["agent_id"] == "researcher"
|
||||||
|
|
||||||
|
def test_register_creates_directories(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("coder", {"agent_id": "coder"})
|
||||||
|
assert (root / "coder" / "inbox").is_dir()
|
||||||
|
assert (root / "coder" / "processed").is_dir()
|
||||||
|
|
||||||
|
def test_register_overwrite(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("coder", {"agent_id": "coder", "status": "idle"})
|
||||||
|
mgr.register("coder", {"agent_id": "coder", "status": "busy"})
|
||||||
|
registry = json.loads((root / "_registry.json").read_text())
|
||||||
|
assert registry["coder"]["status"] == "busy"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeat:
|
||||||
|
def test_heartbeat_updates_timestamp(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("coder", {"agent_id": "coder"})
|
||||||
|
before = json.loads((root / "_registry.json").read_text())["coder"]["last_heartbeat"]
|
||||||
|
time.sleep(0.01)
|
||||||
|
mgr.heartbeat("coder")
|
||||||
|
after = json.loads((root / "_registry.json").read_text())["coder"]["last_heartbeat"]
|
||||||
|
assert after >= before
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateStatus:
|
||||||
|
def test_update_status(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("coder", {"agent_id": "coder", "status": "idle"})
|
||||||
|
mgr.update_status("coder", "busy", current_tasks=["task_1"])
|
||||||
|
registry = json.loads((root / "_registry.json").read_text())
|
||||||
|
assert registry["coder"]["status"] == "busy"
|
||||||
|
assert registry["coder"]["current_tasks"] == ["task_1"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendAndPoll:
|
||||||
|
def test_send_creates_message_file(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("coder", {"agent_id": "coder"})
|
||||||
|
mgr.register("researcher", {"agent_id": "researcher"})
|
||||||
|
msg = {"type": "message", "content": {"parts": [{"type": "text", "text": "hello"}]}}
|
||||||
|
mgr.send("researcher", "coder", msg)
|
||||||
|
files = list((root / "coder" / "inbox").glob("*.msg.json"))
|
||||||
|
assert len(files) == 1
|
||||||
|
data = json.loads(files[0].read_text())
|
||||||
|
assert data["from"] == "researcher"
|
||||||
|
assert data["to"] == "coder"
|
||||||
|
|
||||||
|
def test_poll_returns_new_messages_sorted(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("coder", {"agent_id": "coder"})
|
||||||
|
mgr.register("a1", {"agent_id": "a1"})
|
||||||
|
mgr.register("a2", {"agent_id": "a2"})
|
||||||
|
mgr.send("a1", "coder", {"type": "message", "content": {"parts": []}})
|
||||||
|
time.sleep(0.01)
|
||||||
|
mgr.send("a2", "coder", {"type": "task", "content": {"parts": []}})
|
||||||
|
messages = mgr.poll("coder")
|
||||||
|
assert len(messages) == 2
|
||||||
|
assert messages[0]["from"] == "a1"
|
||||||
|
assert messages[1]["from"] == "a2"
|
||||||
|
|
||||||
|
def test_mark_processed_moves_file(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("coder", {"agent_id": "coder"})
|
||||||
|
mgr.register("researcher", {"agent_id": "researcher"})
|
||||||
|
mgr.send("researcher", "coder", {"type": "message", "content": {"parts": []}})
|
||||||
|
messages = mgr.poll("coder")
|
||||||
|
mgr.mark_processed("coder", messages[0]["_filename"])
|
||||||
|
assert len(list((root / "coder" / "inbox").glob("*.msg.json"))) == 0
|
||||||
|
assert len(list((root / "coder" / "processed").glob("*.msg.json"))) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAndGetAgents:
|
||||||
|
def test_list_online_agents(self, mgr: MailboxManager, root: Path):
|
||||||
|
mgr.register("researcher", {"agent_id": "researcher", "status": "idle"})
|
||||||
|
mgr.register("coder", {"agent_id": "coder", "status": "busy"})
|
||||||
|
agents = mgr.list_online_agents()
|
||||||
|
ids = {a["agent_id"] for a in agents}
|
||||||
|
assert ids == {"researcher", "coder"}
|
||||||
|
|
||||||
|
def test_get_agent_not_found(self, mgr: MailboxManager):
|
||||||
|
assert mgr.get_agent("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- MailboxChannel & integration Tests ---
|
||||||
|
|
||||||
|
def _make_channel(bus, root, agent_id, **overrides):
|
||||||
|
"""Create a MailboxChannel with sensible defaults."""
|
||||||
|
cfg = {
|
||||||
|
"enabled": True,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"description": f"Test agent {agent_id}",
|
||||||
|
"mailboxes_root": str(root),
|
||||||
|
"poll_interval": 0.05,
|
||||||
|
"allow_from": ["*"],
|
||||||
|
}
|
||||||
|
cfg.update(overrides)
|
||||||
|
return MailboxChannel(cfg, bus)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
def test_default_config(self):
|
||||||
|
cfg = MailboxConfig()
|
||||||
|
assert cfg.enabled is False
|
||||||
|
assert cfg.agent_id == ""
|
||||||
|
assert cfg.allow_from == ["*"]
|
||||||
|
|
||||||
|
def test_config_from_dict(self):
|
||||||
|
"""Validate from dict with camelCase aliases."""
|
||||||
|
cfg = MailboxConfig.model_validate({
|
||||||
|
"enabled": True,
|
||||||
|
"agentId": "coder",
|
||||||
|
"allowFrom": ["researcher"],
|
||||||
|
"pollInterval": 2.0,
|
||||||
|
})
|
||||||
|
assert cfg.enabled is True
|
||||||
|
assert cfg.agent_id == "coder"
|
||||||
|
assert cfg.allow_from == ["researcher"]
|
||||||
|
assert cfg.poll_interval == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelAttributes:
|
||||||
|
def test_name(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
ch = _make_channel(bus, root, "test")
|
||||||
|
assert ch.name == "mailbox"
|
||||||
|
|
||||||
|
def test_display_name(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
ch = _make_channel(bus, root, "test")
|
||||||
|
assert ch.display_name == "Mailbox"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartAndStop:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_registers_agent(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder", description="A coding agent")
|
||||||
|
try:
|
||||||
|
await ch.start()
|
||||||
|
card = ch.manager.get_agent("coder")
|
||||||
|
assert card is not None
|
||||||
|
assert card["agent_id"] == "coder"
|
||||||
|
assert card["description"] == "A coding agent"
|
||||||
|
finally:
|
||||||
|
ch._running = False
|
||||||
|
if ch._poll_task:
|
||||||
|
ch._poll_task.cancel()
|
||||||
|
try:
|
||||||
|
await ch._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_marks_offline(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder")
|
||||||
|
await ch.start()
|
||||||
|
await ch.stop()
|
||||||
|
card = ch.manager.get_agent("coder")
|
||||||
|
assert card["status"] == "offline"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_idempotent(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder")
|
||||||
|
await ch.start()
|
||||||
|
await ch.stop()
|
||||||
|
# Calling stop() a second time should not raise
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollAndInbound:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_delivers_inbound_message(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder")
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
# Simulate researcher sending a message to coder
|
||||||
|
ch.manager.send("researcher", "coder", {
|
||||||
|
"type": "message",
|
||||||
|
"content": {"parts": [{"type": "text", "text": "Hello from researcher"}]},
|
||||||
|
})
|
||||||
|
# Manually trigger a poll
|
||||||
|
await ch._poll_once()
|
||||||
|
bus.publish_inbound.assert_awaited_once()
|
||||||
|
inbound: InboundMessage = bus.publish_inbound.call_args[0][0]
|
||||||
|
assert inbound.channel == "mailbox"
|
||||||
|
assert inbound.sender_id == "researcher"
|
||||||
|
assert inbound.chat_id == "researcher"
|
||||||
|
assert "Hello from researcher" in inbound.content
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_with_callback_routes_to_original_session(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder")
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
# Message with callback routing info
|
||||||
|
ch.manager.send("researcher", "coder", {
|
||||||
|
"type": "message",
|
||||||
|
"from": "researcher",
|
||||||
|
"content": {"parts": [{"type": "text", "text": "Task result"}]},
|
||||||
|
"callback": {
|
||||||
|
"channel": "feishu",
|
||||||
|
"chat_id": "oc_abc123",
|
||||||
|
"session_id": "sess_xyz",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await ch._poll_once()
|
||||||
|
bus.publish_inbound.assert_awaited_once()
|
||||||
|
inbound: InboundMessage = bus.publish_inbound.call_args[0][0]
|
||||||
|
assert inbound.channel == "feishu"
|
||||||
|
assert inbound.chat_id == "oc_abc123"
|
||||||
|
assert inbound.session_key_override == "sess_xyz"
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_respects_allow_from(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder", allow_from=["researcher"])
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
# Message from a stranger should be blocked
|
||||||
|
ch.manager.send("stranger", "coder", {
|
||||||
|
"type": "message",
|
||||||
|
"content": {"parts": [{"type": "text", "text": "Hi"}]},
|
||||||
|
})
|
||||||
|
await ch._poll_once()
|
||||||
|
bus.publish_inbound.assert_not_awaited()
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_marks_processed(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder")
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
ch.manager.send("researcher", "coder", {
|
||||||
|
"type": "message",
|
||||||
|
"content": {"parts": [{"type": "text", "text": "hello"}]},
|
||||||
|
})
|
||||||
|
await ch._poll_once()
|
||||||
|
# Inbox should be empty
|
||||||
|
assert len(ch.manager.poll("coder")) == 0
|
||||||
|
# Processed dir should have the file
|
||||||
|
processed = list((root / "coder" / "processed").glob("*.msg.json"))
|
||||||
|
assert len(processed) == 1
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSend:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_writes_to_target_mailbox(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder")
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="researcher",
|
||||||
|
content="Hello researcher",
|
||||||
|
)
|
||||||
|
await ch.send(msg)
|
||||||
|
# Check target inbox
|
||||||
|
files = list((root / "researcher" / "inbox").glob("*.msg.json"))
|
||||||
|
assert len(files) == 1
|
||||||
|
data = json.loads(files[0].read_text())
|
||||||
|
assert data["from"] == "coder"
|
||||||
|
assert data["to"] == "researcher"
|
||||||
|
assert data["ttl"] == 2 # started at 3, decremented to 2
|
||||||
|
assert data["trace"] == ["coder"]
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_uses_existing_message_tool(self, root: Path):
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
ch = _make_channel(bus, root, "coder")
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
# Simulate what MessageTool would produce — metadata carrying task info
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="researcher",
|
||||||
|
content="Here is the result",
|
||||||
|
metadata={
|
||||||
|
"mailbox_task": "analyze_code",
|
||||||
|
"mailbox_type": "task_result",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ch.send(msg)
|
||||||
|
files = list((root / "researcher" / "inbox").glob("*.msg.json"))
|
||||||
|
assert len(files) == 1
|
||||||
|
data = json.loads(files[0].read_text())
|
||||||
|
assert data["type"] == "task_result"
|
||||||
|
assert data["task"] == "analyze_code"
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTwoAgentCommunication:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_a_sends_agent_b_receives(self, root: Path):
|
||||||
|
bus_a = MagicMock()
|
||||||
|
bus_a.publish_inbound = AsyncMock()
|
||||||
|
bus_b = MagicMock()
|
||||||
|
bus_b.publish_inbound = AsyncMock()
|
||||||
|
|
||||||
|
ch_a = _make_channel(bus_a, root, "alpha")
|
||||||
|
ch_b = _make_channel(bus_b, root, "beta")
|
||||||
|
await ch_a.start()
|
||||||
|
await ch_b.start()
|
||||||
|
try:
|
||||||
|
# Alpha sends to Beta
|
||||||
|
out = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="beta",
|
||||||
|
content="Hello Beta, this is Alpha",
|
||||||
|
)
|
||||||
|
await ch_a.send(out)
|
||||||
|
|
||||||
|
# Beta polls and receives
|
||||||
|
await ch_b._poll_once()
|
||||||
|
bus_b.publish_inbound.assert_awaited_once()
|
||||||
|
inbound: InboundMessage = bus_b.publish_inbound.call_args[0][0]
|
||||||
|
assert inbound.channel == "mailbox"
|
||||||
|
assert inbound.sender_id == "alpha"
|
||||||
|
assert "Hello Beta" in inbound.content
|
||||||
|
finally:
|
||||||
|
await ch_a.stop()
|
||||||
|
await ch_b.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_b_sends_response_back(self, root: Path):
|
||||||
|
bus_a = MagicMock()
|
||||||
|
bus_a.publish_inbound = AsyncMock()
|
||||||
|
bus_b = MagicMock()
|
||||||
|
bus_b.publish_inbound = AsyncMock()
|
||||||
|
|
||||||
|
ch_a = _make_channel(bus_a, root, "alpha")
|
||||||
|
ch_b = _make_channel(bus_b, root, "beta")
|
||||||
|
await ch_a.start()
|
||||||
|
await ch_b.start()
|
||||||
|
try:
|
||||||
|
# Beta sends to Alpha
|
||||||
|
out = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="alpha",
|
||||||
|
content="Response from Beta",
|
||||||
|
)
|
||||||
|
await ch_b.send(out)
|
||||||
|
|
||||||
|
# Alpha polls and receives
|
||||||
|
await ch_a._poll_once()
|
||||||
|
bus_a.publish_inbound.assert_awaited_once()
|
||||||
|
inbound: InboundMessage = bus_a.publish_inbound.call_args[0][0]
|
||||||
|
assert inbound.sender_id == "beta"
|
||||||
|
assert "Response from Beta" in inbound.content
|
||||||
|
finally:
|
||||||
|
await ch_a.stop()
|
||||||
|
await ch_b.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_routes_to_original_feishu_session(self, root: Path):
|
||||||
|
"""A sends task with callback -> B receives -> B responds with callback -> A receives routed to feishu session."""
|
||||||
|
bus_a = MagicMock()
|
||||||
|
bus_a.publish_inbound = AsyncMock()
|
||||||
|
bus_b = MagicMock()
|
||||||
|
bus_b.publish_inbound = AsyncMock()
|
||||||
|
|
||||||
|
ch_a = _make_channel(bus_a, root, "alpha")
|
||||||
|
ch_b = _make_channel(bus_b, root, "beta")
|
||||||
|
await ch_a.start()
|
||||||
|
await ch_b.start()
|
||||||
|
try:
|
||||||
|
# Alpha sends a task to Beta with a callback to feishu
|
||||||
|
out = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="beta",
|
||||||
|
content="Analyze this code",
|
||||||
|
metadata={
|
||||||
|
"mailbox_task": "code_review",
|
||||||
|
"mailbox_callback": {
|
||||||
|
"channel": "feishu",
|
||||||
|
"chat_id": "oc_feishu_chat",
|
||||||
|
"session_id": "sess_feishu_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ch_a.send(out)
|
||||||
|
|
||||||
|
# Beta polls and receives
|
||||||
|
await ch_b._poll_once()
|
||||||
|
bus_b.publish_inbound.assert_awaited_once()
|
||||||
|
inbound_b: InboundMessage = bus_b.publish_inbound.call_args[0][0]
|
||||||
|
assert "Analyze this code" in inbound_b.content
|
||||||
|
|
||||||
|
# Beta responds, echoing the callback
|
||||||
|
out_b = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="alpha",
|
||||||
|
content="Code review complete",
|
||||||
|
metadata={
|
||||||
|
"mailbox_task": "code_review",
|
||||||
|
"mailbox_callback": {
|
||||||
|
"channel": "feishu",
|
||||||
|
"chat_id": "oc_feishu_chat",
|
||||||
|
"session_id": "sess_feishu_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ch_b.send(out_b)
|
||||||
|
|
||||||
|
# Alpha polls — the callback routes to feishu channel
|
||||||
|
await ch_a._poll_once()
|
||||||
|
bus_a.publish_inbound.assert_awaited_once()
|
||||||
|
inbound_a: InboundMessage = bus_a.publish_inbound.call_args[0][0]
|
||||||
|
assert inbound_a.channel == "feishu"
|
||||||
|
assert inbound_a.chat_id == "oc_feishu_chat"
|
||||||
|
assert inbound_a.session_key_override == "sess_feishu_123"
|
||||||
|
assert "Code review complete" in inbound_a.content
|
||||||
|
finally:
|
||||||
|
await ch_a.stop()
|
||||||
|
await ch_b.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anti_loop_trace(self, root: Path):
|
||||||
|
"""Message with existing trace preserved in metadata."""
|
||||||
|
bus_a = MagicMock()
|
||||||
|
bus_a.publish_inbound = AsyncMock()
|
||||||
|
|
||||||
|
ch_a = _make_channel(bus_a, root, "alpha")
|
||||||
|
await ch_a.start()
|
||||||
|
try:
|
||||||
|
# Send with pre-existing trace
|
||||||
|
out = OutboundMessage(
|
||||||
|
channel="mailbox",
|
||||||
|
chat_id="beta",
|
||||||
|
content="Forwarding along",
|
||||||
|
metadata={
|
||||||
|
"mailbox_trace": ["origin", "relay1"],
|
||||||
|
"mailbox_ttl": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await ch_a.send(out)
|
||||||
|
files = list((root / "beta" / "inbox").glob("*.msg.json"))
|
||||||
|
assert len(files) == 1
|
||||||
|
data = json.loads(files[0].read_text())
|
||||||
|
# Trace should be: origin, relay1, alpha
|
||||||
|
assert data["trace"] == ["origin", "relay1", "alpha"]
|
||||||
|
assert data["ttl"] == 4 # decremented from 5
|
||||||
|
finally:
|
||||||
|
await ch_a.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allow_from_blocks_unauthorized(self, root: Path):
|
||||||
|
"""allowFrom=['coder'] blocks stranger."""
|
||||||
|
bus = MagicMock()
|
||||||
|
bus.publish_inbound = AsyncMock()
|
||||||
|
|
||||||
|
ch = _make_channel(bus, root, "coder", allow_from=["coder"])
|
||||||
|
await ch.start()
|
||||||
|
try:
|
||||||
|
# Stranger sends to coder
|
||||||
|
ch.manager.send("stranger", "coder", {
|
||||||
|
"type": "message",
|
||||||
|
"content": {"parts": [{"type": "text", "text": "Spam"}]},
|
||||||
|
})
|
||||||
|
await ch._poll_once()
|
||||||
|
bus.publish_inbound.assert_not_awaited()
|
||||||
|
finally:
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registry_discovery(self, root: Path):
|
||||||
|
"""Both agents register and can discover each other."""
|
||||||
|
bus_a = MagicMock()
|
||||||
|
bus_a.publish_inbound = AsyncMock()
|
||||||
|
bus_b = MagicMock()
|
||||||
|
bus_b.publish_inbound = AsyncMock()
|
||||||
|
|
||||||
|
ch_a = _make_channel(bus_a, root, "alpha", description="Agent Alpha")
|
||||||
|
ch_b = _make_channel(bus_b, root, "beta", description="Agent Beta")
|
||||||
|
await ch_a.start()
|
||||||
|
await ch_b.start()
|
||||||
|
try:
|
||||||
|
agents = ch_a.manager.list_online_agents()
|
||||||
|
ids = {a["agent_id"] for a in agents}
|
||||||
|
assert "alpha" in ids
|
||||||
|
assert "beta" in ids
|
||||||
|
|
||||||
|
# Alpha can look up Beta's card
|
||||||
|
beta_card = ch_a.manager.get_agent("beta")
|
||||||
|
assert beta_card is not None
|
||||||
|
assert beta_card["description"] == "Agent Beta"
|
||||||
|
|
||||||
|
# Beta can look up Alpha's card
|
||||||
|
alpha_card = ch_b.manager.get_agent("alpha")
|
||||||
|
assert alpha_card is not None
|
||||||
|
assert alpha_card["description"] == "Agent Alpha"
|
||||||
|
finally:
|
||||||
|
await ch_a.stop()
|
||||||
|
await ch_b.stop()
|
||||||
Loading…
x
Reference in New Issue
Block a user