Agent-to-Agent Communication: Protocols, Patterns, and Performance
Agents rarely work in isolation. They need to share information, delegate work, and coordinate. Here’s how they do it.
The Communication Spectrum
| Pattern | Latency | Throughput | Coupling | Best For |
|---|---|---|---|---|
| Direct Call | Low | Medium | High | Tight coordination |
| Message Queue | Medium | High | Low | Decoupled workflows |
| Shared Blackboard | Variable | Medium | Medium | Coordination via state |
| Event Stream | Low-Medium | High | Low | Real-time updates |
| Request/Response RPC | Low | Medium | Medium | Synchronous delegation |
1. Direct Agent-to-Agent Call
# Agent A calls Agent B directly
result = await agent_b.run({
"task": "analyze_data",
"data": my_data,
"context": {"priority": "high"}
})
Pros
- Lowest latency
- Simple to implement
- Strong typing possible
Cons
- Tight coupling (A must know B’s interface)
- Failure propagation (B down → A blocked)
- Difficult to scale B independently
When to Use
- Agents in the same trust domain
- Low-latency requirements (<100ms)
- When you control both agents
2. Message Queue Pattern
# Agent A publishes to queue
await message_queue.publish("task.analyze_data", {
"data": my_data,
"callback_queue": "results.agent_a"
})
# Agent B consumes from queue
@message_queue.subscribe("task.analyze_data")
async def handle_analyze(data):
result = await analyze(data["data"])
await message_queue.publish(data["callback_queue"], result)
Pros
- Decoupled (A and B never touch)
- Built-in buffering and retry
- Horizontal scaling of consumers
- Dead letter queues for poison messages
Cons
- Higher latency (queue hop)
- Need to manage queue infrastructure
- At-least-once delivery requires idempotency
When to Use
- Workloads with variable demand
- When you want independent scaling
- Fault-tolerant pipelines
3. Shared Blackboard
# Shared state (could be Redis, database, or even file)
class Blackboard:
async def post(self, key: str, value: Any):
await self.redis.set(key, json.dumps(value))
async def get(self, key: str) -> Any:
data = await self.redis.get(key)
return json.loads(data) if data else None
# Agent A posts result
blackboard.post("analysis:result123", my_analysis)
# Agent B polls for result
while True:
result = await blackboard.get("analysis:result123")
if result:
break
await asyncio.sleep(0.1)
Pros
- Simple mental model
- Works across trust domains
- Natural for coordination via state
Cons
- Polling waste (unless using pub/sub)
- Potential for race conditions
- Can become bottleneck
When to Use
- Coordination via shared state
- When agents need to agree on a value
- Simple workflows with few participants
4. Event Stream (Pub/Sub)
# Agent A publishes event
await event_stream.publish("topic.analysis_complete", {
"topic_id": "abc123",
"result": my_analysis,
"timestamp": time.time()
})
# Agent B subscribes
@event_stream.subscribe("topic.analysis_complete")
async def handle_analysis(event):
await process_result(event["result"])
Pros
- Real-time propagation
- Many-to-many communication
- Built-in fan-out
- Persistent streams for replay
Cons
- More complex infrastructure
- Need to manage consumer groups
- Potential for thundering herd
When to Use
- Broadcasting state changes
- Audit trails and replay
- Multiple interested parties
5. Request/Response RPC (with Middleware)
# Using gRPC, HTTP+JSON, or MCP
class AgentClient:
async def call(self, method: str, params: dict) -> Any:
response = await self.http.post(
f"{self.agent_url}/mcp",
json={
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": self.next_id()
}
)
return response.json()["result"]
# Agent A calls Agent B via RPC
client = AgentClient("https://agent-b.example.com")
result = await client.call("analyze_topic", {
"topic_id": "abc123",
"depth": "deep"
})
Pros
- Familiar request/response semantics
- Strong contract (IDL/schema)
- Works across networks and languages
- Middleware for auth, logging, retries
Cons
- Requires network roundtrip
- More complex than direct call
- Need to handle timeouts and retries
When to Use
- Cross-service agent communication
- When you need strong typing/contracts
- Heterogeneous agent ecosystems
Performance Considerations
1. Latency Budget
Total Latency = Network + Serialization + Queue Wait + Processing
| Pattern | Typical Latency (ms) |
|---|---|
| Direct Call | 1-10 |
| RPC/HTTP | 10-100 |
| Message Queue | 50-500 |
| Event Stream | 10-200 |
| Blackboard (polling) | 100-1000+ |
2. Throughput Limits
- Direct Call: Limited by connection pool and agent concurrency
- Message Queue: Limited by queue throughput and consumer count
- Event Stream: Limited by broker throughput and partition count
- RPC: Limited by server concurrency and connection pool
3. Optimization Techniques
- Connection pooling for RPC/direct calls
- Batch processing for queues and streams
- Compression for large payloads
- Caching for frequently requested data
- Async everywhere — never block threads
Reliability Patterns
1. Retry with Exponential Backoff
async def resilient_call(func, *args, max_retries=3):
for attempt in range(max_retries):
try:
return await func(*args)
except TemporaryError:
if attempt == max_retries - 1:
raise
await asyncio.sleep(2 ** attempt + random.uniform(0, 1))
2. Circuit Breaker
class CircuitBreaker:
def __init__(self, failure_threshold=5, timeout=60):
self.failure_threshold = failure_threshold
self.timeout = timeout
self.failure_count = 0
self.last_failure = None
self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN
async def call(self, func, *args):
if self.state == "OPEN":
if time.time() - self.last_failure > self.timeout:
self.state = "HALF_OPEN"
else:
raise CircuitBreakerOpen()
try:
result = await func(*args)
if self.state == "HALF_OPEN":
self.state = "CLOSED"
self.failure_count = 0
return result
except Exception:
self.failure_count += 1
self.last_failure = time.time()
if self.failure_count >= self.failure_threshold:
self.state = "OPEN"
raise
3. Bulkhead Pattern
# Limit concurrent calls to prevent resource exhaustion
class Bulkhead:
def __init__(self, max_concurrent=10):
self.semaphore = asyncio.Semaphore(max_concurrent)
async def call(self, func, *args):
async with self.semaphore:
return await func(*args)
The Takeaway
Choose your communication pattern based on your constraints, not trends.
- Direct calls for low-latency, tightly-coupled agents
- Message queues for decoupled, scalable workloads
- Event streams for real-time fan-out and replay
- Shared blackboard for simple coordination via state
- RPC/MCP for cross-service contracts and strong typing
The pattern affects everything: latency, throughput, reliability, observability, and team autonomy. Choose wisely.
“The best agent-to-agent communication is the one you don’t need — because the agent can do the work alone.” But when you do need to communicate, make it intentional.