Agent-to-Agent Is Just Function Calls
Multi-agent systems don't need new infrastructure. They use the same patterns that connect microservices today: typed interfaces, explicit wiring, and the auth and observability stack you already have.
Software engineering has been connecting services for thirty years. RPC, REST, gRPC, microservices. The pattern that won is always the same: a function call with typed inputs and typed outputs.
Agent-to-agent is the same pattern. An agent produces structured output. Another agent consumes it. The contract between them is a type signature.
Three Agents, Two Function Calls#
Here's a multi-agent system that scores on an academic benchmark. A TravelPlannerAgent orchestrates two sub-agents to plan trips under budget and constraint requirements:
class TravelPlannerAgent(GoalSeeking):
@primitive(read_only=True)
def gather_information(self) -> GatheredData:
retrieval = RetrievalAgent(llm=self._llm_config, db=self._db)
return retrieval.gather(self._current_task)
@primitive(read_only=False)
def build_constrained_plan(self, gathered_data: GatheredData) -> str:
assembler = PlanAssemblerAgent(llm=self._llm_config)
plan = assembler.assemble_plan(gathered_data, self._current_task)
return f"Plan built with {len(plan)} days."Two function calls. Typed in, typed out.
RetrievalAgent iteratively gathers flights, restaurants, accommodations, and attractions. PlanAssemblerAgent takes that data and deterministically assembles a valid day-by-day plan. Both are complex internally. The connection between them is retrieval.gather() returning a GatheredData that assembler.assemble_plan() consumes.
The Contract Is a Type#
The interface between agents is the same interface between any two services: a schema.
class GatheredData(BaseModel):
flights: dict[str, list[Flight]] = {}
restaurants: dict[str, list[Restaurant]] = {}
accommodations: dict[str, list[Accommodation]] = {}
attractions: dict[str, list[Attraction]] = {}
distances: dict[str, DistanceInfo] = {}
cities: dict[str, list[str]] = {}RetrievalAgent produces a GatheredData. PlanAssemblerAgent consumes a GatheredData. Change the shape and your type checker catches it, your tests catch it, your CI catches it. Same contract enforcement as protobuf or OpenAPI.
class RetrievalAgent:
def gather(self, task: TravelPlannerTask) -> GatheredData: ...
class PlanAssemblerAgent:
def assemble_plan(self, data: GatheredData, task: TravelPlannerTask) -> list[dict]: ...A method, its parameters, its return type. That's the contract.
Every Topology Is Just Code#
Every multi-agent topology in the literature reduces to one of four patterns:
# Pipeline: A -> B -> C
data = agent_a.run(input)
enriched = agent_b.run(data)
result = agent_c.run(enriched)
# Fan-out: A -> [B, C, D] -> E
data = agent_a.run(input)
results = [agent.run(data) for agent in [agent_b, agent_c, agent_d]]
final = agent_e.run(merge(results))
# Conditional routing
data = agent_a.run(input)
if data.needs_review:
result = review_agent.run(data)
else:
result = fast_agent.run(data)
# Iterative loop
while not done:
plan = planner.run(context)
result = executor.run(plan)
done = evaluator.check(result)
context.update(result)Pipeline, fan-out, conditional, loop. Every architecture is one of these or a composition of them.
The TravelPlanner uses a pipeline inside an iterative loop: the orchestrator pipelines retrieval into assembly, and each sub-agent internally loops until its goal is met. Two patterns composed. Two function calls.
When the orchestrator needs to choose between agents at runtime, that's a router:
agents = {
"flight_search": FlightAgent(llm=llm),
"hotel_search": HotelAgent(llm=llm),
"restaurant_search": RestaurantAgent(llm=llm),
}
agent = agents[classify_intent(user_query)]
result = agent.run(user_query)Routing is a function. Discovery is a dictionary.
Same Infrastructure You Already Have#
This is the part that matters most. Because agents connect through the same interfaces as services, they inherit the entire infrastructure stack that already exists.
Authentication. Agents calling agents over HTTP use the same OAuth, API keys, mTLS, and JWT tokens that your services use today. No new auth model. The agent that calls an external retrieval service authenticates the same way your backend calls Stripe or Twilio.
Observability. Traces, metrics, logs. An agent calling a sub-agent is a span in your existing distributed trace. OpenTelemetry works. Datadog works. The same dashboards that show your microservice latency show your agent latency.
Rate limiting and quotas. Same middleware. Same API gateways. An agent behind a REST endpoint gets rate-limited the same way any service does.
Deployment. Agents are services. They deploy in containers, run behind load balancers, scale horizontally. No new deployment model. Your existing CI/CD pipeline, your existing Kubernetes config, your existing blue-green deploy process. It all applies.
Authorization. Which agents can call which other agents? Same RBAC, same policies, same service mesh rules you already enforce between microservices.
The point isn't that you could build new infrastructure for agents. You could. The point is that you don't have to. The infrastructure already exists. It's battle-tested. It handles the hard problems (auth, observability, rate limiting, deployment, security) that any new protocol would need to solve from scratch.
Complexity Belongs in the Agents, Not the Wiring#
The TravelPlanner's RetrievalAgent has six search primitives, two decomposition examples, a goal evaluation loop, and a backfill safety net. The PlanAssemblerAgent has dozens of deterministic primitives for filtering, ranking, cost calculation, and constraint validation.
The orchestrator that connects them is nine lines of code.
The agents are hard. The wiring is trivial. That's the right distribution of complexity. The interesting engineering is inside each agent: how it plans, how it executes, how it recovers from errors. The connection between agents is the boring part. And in software engineering, boring is good.
Read more: The Prompt Spectrum | The Anatomy of PlanExecute | Behaviour Programming vs Tool Calling
See the code: OpenSymbolicAI Core | Examples