Designing Agentic AI Systems, Part 3: Agent to Agent Interactions

In Part 2, we looked at the design principle of modularity. We talked through strategies to decompose agentic systems by borrowing the microservices concept of bounded context to decide on the scope of each subagent.
We also hinted that modularity introduces the need for a well thought out interaction model between agents and subagents.

Today we’ll dig deeper into the request dispatching pattern that can help create predictable mechanisms to dispatch requests to sub-agents and for those agents to communicate the result back to the dispatcher.
Uniform Dispatching / Callback Mechanisms
When multiple agents need to coordinate within an agentic system, you risk creating a tangle of ad hoc calls and mismatched data structures. By standardizing how each agent calls (or dispatches to) others, and how those agents respond, you reduce confusion, errors, and maintenance overhead. A consistent interface forces each agent to speak the same “language” when making requests or returning results.

The motivation for uniform interfaces arises from the reality that one agent is rarely enough to handle all aspects of a user request in complex agentic systems. The user might ask about tracking a package, initiating a return, and checking the warranty status—all in the same conversation. If your system simply delegates to whichever sub-agent the LLM chooses, you need a uniform way to pass the request data along and retrieve a structured response. By treating these agent handoffs as function calls with a strict schema, you ensure that every agent, whether parent or child, exchanges information in a predictable manner.
Without this uniformity, the parent might expect one data representation while a child agent returns something else entirely. Or you might find a mismatch when a sub-agent tries to call yet another sub-agent. Each small inconsistency can cascade into confusing errors that are difficult to troubleshoot, especially under the dynamic behavior of LLM-driven systems. Consistency in data shapes and parameter names is key to enabling the LLM to reason reliably about which function to call and what data it must provide.
Example in Python
The parent agent needs to know how to properly delegate tasks to each child agent. You do this by exposing functions (in the sense of function calling to the LLM) that are responsible for specific domains. For example:
tools = [
{
"type": "function",
"function": {
"name": "handoff_to_OrdersAgent",
"description": "Handles order-related queries such as tracking or managing orders.",
"parameters": {
"type": "object",
"properties": {
"user_id": {"type": "string", "description": "The unique ID of the user."},
"message": {"type": "string", "description": "The user's query."}
},
"required": ["user_id", "message"]
}
}
},
{
"type": "function",
"function": {
"name": "handoff_to_ReturnsAgent",
"description": "Handles return-related tasks, such as authorizing or tracking a return.",
"parameters": {
"type": "object",
"properties": {
"user_id": {"type": "string", "description": "The unique ID of the user."},
"message": {"type": "string", "description": "The user's query."}
},
"required": ["user_id", "message"]
}
}
}
]
When the LLM decides it needs to handle an order-related issue, it can call handoff_to_OrdersAgent
with the necessary parameters. The parent agent then dispatches the request accordingly:
def dispatch_request(self, function_call):
fn_name = function_call["name"]
arguments = json.loads(function_call["arguments"])
if fn_name == "handoff_to_OrdersAgent":
result = self.child_agents["OrdersAgent"].process_request(arguments)
elif fn_name == "handoff_to_ReturnsAgent":
result = self.child_agents["ReturnsAgent"].process_request(arguments)
else:
result = {"status": "error", "message": f"Unknown function {fn_name}"}
return result
This approach allows the parent to focus on routing, while each child agent focuses on its domain (orders, returns, product questions, etc.).
Inside the child agent you can define functions relevant to its specific tasks. For instance, an OrdersAgent
might expose lookupOrder
or searchOrders
functions. The child agent’s own reasoning loop is constrained to that domain, which helps avoid confusion and giant prompt contexts.
class OrdersAgent:
def __init__(self):
self.functions = [
{
"type": "function",
"function": {
"name": "lookupOrder",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The order ID."}
},
"required": ["order_id"]
}
}
},
{
"type": "function",
"function": {
"name": "searchOrders",
"parameters": {
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "The customer ID."}
},
"required": ["customer_id"]
}
}
}
]
def process_request(self, payload):
self.message_history.append({"role": "user", "content": payload["message"]})
for _ in range(3): # Limit recursive calls
response = self.run_llm_cycle(self.functions)
if "function_call" in response:
function_call = response["function_call"]
result = self.handle_function_call(function_call)
if result["status"] == "success":
return result
elif result["status"] == "escalate":
return {"status": "escalate", "message": result["message"]}
else:
return {"status": "success", "data": response["content"]}
return {"status": "error", "message": "Exceeded reasoning steps"}
def handle_function_call(self, function_call):
if function_call["name"] == "lookupOrder":
return {"status": "success", "data": "Order details found..."}
elif function_call["name"] == "searchOrders":
return {"status": "success", "data": "Searching orders..."}
else:
return {"status": "escalate", "message": f"Function {function_call['name']} not supported"}
Once the child agent finishes, it sends the result back to the parent agent in a consistent format. This is the callback. The parent agent can then either:
- Pass the response back to the user (if everything went well).
- Retry the request with another child agent.
- Escalate the issue to a human (if the system can’t handle it automatically).
For example:
response = orders_agent.handle_request(payload)
if response["status"] == "success":
parent_agent.add_message(role="assistant", content=response["data"])
elif response["status"] == "escalate":
parent_agent.add_message(role="system", content="OrdersAgent could not complete the request.")
# Optionally retry with another agent
In any real-world system, certain queries will fail for reasons that are unforeseen—API down, missing data, or functionality your child agent doesn’t support. When this happens, the child agent returns an “escalate” status:
def handle_function_call(self, function_call):
if function_call["name"] == "unsupported_function":
return {"status": "escalate", "message": "Unsupported function"}
The parent agent can catch this and decide whether to retry, escalate to another agent, or ultimately respond with an error message to the user.
Looking Ahead
Between part 2 and part 3, we can see how agentic systems can be decomposed into a series of agents/sub-agents with a uniform communication model to broker interactions throughout the agent hierarchy.
However, these agents don’t live in isolation. They need access to external tooling, especially data. In part 4, we’ll cover the nuances of data retrieval for agentic systems and explore data requirements that are unique to agentic systems.