The Problem
As agent systems grow, agents need to react to each other’s outputs: a planner publishes a task, an executor carries it out, a reviewer audits the result. If agents call each other directly, the graph of dependencies becomes a tangle — changing one agent’s interface breaks all its callers. Testing any single agent requires wiring up all its dependencies.
The Solution
A MessageBus decouples agents through typed Message values. Each agent subscribes under its own name and receives a dedicated buffered channel. To communicate, an agent calls bus.Publish(msg) — the bus routes the message to the addressed recipient (or broadcasts if To is empty). Agents never import each other; they only import the shared message type. In Go, channels are the natural implementation: the bus wraps a map[string]chan Message protected by a RWMutex.
Structure
Planner Publishes a Task
The PlannerAgent publishes a Message with Type=task.assigned and To=executor. The bus looks up the executor's channel and delivers the message — no direct reference to ExecutorAgent.
flowchart LR Planner["PlannerAgent"] Bus["MessageBus Publish() / Subscribe()"] Executor["ExecutorAgent"] Reviewer["ReviewerAgent"] Planner -->|"task.assigned → executor"| Bus Bus -->|"inbox"| Executor Executor -->|"task.completed → reviewer"| Bus Bus -->|"inbox"| Reviewer Reviewer -->|"review.passed → broadcast"| Bus
Implementation
package main
import "context"
// MessageType categorises a bus message so subscribers can filter.
type MessageType string
const (
MsgTaskAssigned MessageType = "task.assigned"
MsgTaskCompleted MessageType = "task.completed"
MsgReviewPassed MessageType = "review.passed"
MsgReviewFailed MessageType = "review.failed"
)
// Message is the unit of communication between agents on the bus.
type Message struct {
From string
To string // empty means broadcast
Type MessageType
Payload string
}
// Publisher sends messages to other agents.
type Publisher interface {
Publish(msg Message)
}
// Agent is any actor that can send and receive messages via a bus.
type Agent interface {
Name() string
Run(ctx context.Context, pub Publisher, inbox <-chan Message) error
} Real-World Analogy
An airport control tower: each aircraft (agent) communicates only with the tower (bus), never directly with other aircraft. The tower routes messages — “cleared for takeoff”, “hold position” — to the addressed party. Adding a new aircraft to the airspace means tuning to the tower frequency, not negotiating a direct radio link with every other plane.
Pros and Cons
| Pros | Cons |
|---|---|
| Agents are fully decoupled — adding one never requires editing others | Bus is a central point of failure; must be robust and non-blocking |
Go channels make the implementation idiomatic and race-free with RWMutex | Buffered channels drop messages silently on overflow — requires monitoring |
Typed MessageType constants enable exhaustive switch handling | Debugging message flows requires tracing across multiple goroutines |
| Broadcast messages simplify fan-out notifications | Message ordering is per-agent-inbox, not globally guaranteed |
Best Practices
- Use typed
MessageTypeconstants and document every message type — undocumented message formats become tribal knowledge. - Monitor channel buffer occupancy in production; a full inbox channel is the first sign of a stuck agent.
- Give every agent a
context.Contextso it can exit cleanly when the bus closes — avoid goroutine leaks on shutdown. - Log every message publication with
From,To,Type, and a correlation ID — multi-agent traces are impossible to debug without it. - Test agents in isolation by providing a mock
Publisherand a pre-loaded inbox channel; never require a live bus in unit tests.
When to Use
- Systems with three or more agents that react to each other’s outputs.
- Event-driven workflows where the sequence of agent activations is not fully predetermined.
- Any architecture where independent deployability of agent logic is required.
When NOT to Use
- Two-agent systems — a direct function call or a shared channel is simpler.
- Strictly sequential pipelines where the next step is always the same — use a pipeline instead of a bus.
- Systems that require guaranteed message delivery — an in-process channel bus has no persistence.