Learning outcomes
- Keep agents independent
- Define typed messages
- Use inboxes and a message bus
- Deliver asynchronously
- Deduplicate messages
- Turn messages into sourced memories
1. Communication is not shared consciousness
Eve enters the garden with her own state, goals, tools and memory. Adam may tell her that he found water at (7, 2), but he cannot write directly into her memory or force her next action. Eve may trust the report, question it, verify it or ignore it.
Communication transports information; it does not guarantee cooperation or truth.
2. Agent, bus and inbox
- Agent: produces and consumes messages while preserving its own decision process.
- talk tool: requests a message send through the usual validation pipeline.
- MessagingService: validates sender, recipient, length, limits and format.
- Inbox: stores pending messages for one recipient.
- MemoryService: decides whether a received message becomes a retained episode.
3. Define AgentMessage
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class AgentMessage(BaseModel):
"""A typed, traceable message between garden agents."""
model_config = ConfigDict(extra="forbid")
message_id: str = Field(min_length=1)
sender_id: str = Field(min_length=1)
recipient_id: str = Field(min_length=1)
kind: Literal["inform", "question", "reply"]
content: str = Field(min_length=1, max_length=300)
sent_tick: int = Field(ge=0)
deliver_tick: int = Field(ge=0)
reply_to: str | None = NonePydantic checks the structure. The application must still verify the authenticated sender, a known recipient, a unique message ID and a valid delivery policy. System-generated IDs and ticks must not be trusted to model output.
4. Treat successful sending precisely
A successful talk() result means “accepted and queued”. It does not mean delivered, read, remembered, believed or acted upon. Represent those transitions as separate events.
Adam can share the content “Water at (7, 2)”. Eve alone decides whether to store a memory with source agent_message.
5. Use queues for independent timing
Adam may be waiting for Ollama while Eve acts. A queue allows a sender and recipient to operate at different times. For a learning prototype, asyncio.Queue can model delivery. Durable delivery later requires persistence because an in-memory queue disappears when the program stops.
import asyncio
class Inbox:
"""Async inbox owned by one agent."""
def __init__(self) -> None:
self._messages: asyncio.Queue[AgentMessage] = asyncio.Queue()
async def deliver(self, message: AgentMessage) -> None:
await self._messages.put(message)
async def receive(self) -> AgentMessage:
return await self._messages.get()6. Failure, duplicates and traceability
Reject unknown recipients and oversized content without producing partial side effects. Keep processed message IDs so a retry cannot create the same memory twice. Record queued, delivered, consumed and rejected events, each correlated with the originating tool request.
Manual delivery
Send one typed message without any LLM.
Permissions
Prevent Adam from sending under Eve's identity.
Tick delay
Queue at tick 12 and deliver at tick 13.
Interpretation
Store a message-derived memory with explicit provenance.
Protocol completion
Eve receives Adam's report without any code mutating her private memory directly. Duplicate delivery is harmless, every transition is traceable and the message can remain untrusted until verified.