Two agents, one problem
Split a multi-domain problem across two specialist agents. A master agent holds both specialists as primitives and routes each part of the task to the right one.
Before you start
Some problems span two different domains. Consider:
I drove 300km at 60kph. What do km and kph mean, and how long did the trip take?
Answering it requires two unrelated skills: knowing what abbreviations mean, and doing arithmetic. This tutorial splits those into two specialist agents and wires them together with a master.
Why specialist agents?#
A single agent handling both vocabulary and arithmetic works for simple cases, but mixes two unrelated concerns. When something goes wrong it is harder to tell which part failed. When you want to extend one capability (a better abbreviation dictionary, a different calculator) you have to touch the whole agent.
Specialist agents are small and single-purpose. Each one has a narrow job it does well. The master does not know how to expand abbreviations or divide numbers. It knows which specialist handles each part and delegates accordingly. You can read each agent's plan independently and swap any specialist without touching the others.
The three agents#
AbbreviationAgent#
One primitive: replace every known abbreviation in a text with its full written form.
# abbrev_agent.py
class AbbreviationAgent(PlanExecute):
@primitive(read_only=True)
def expand_all(self, text: str) -> str:
"""Replace every known abbreviation in text with its full written form.
Example: expand_all("300km at 60kph") -> "300 kilometers at 60 kilometers per hour"
"""The abbreviation table is a plain dict in the class. Longer abbreviations are expanded first to avoid partial matches (kph before k).
CalculatorAgent#
Four primitives: add, subtract, multiply, divide. Nothing else.
# calc_agent.py
class CalculatorAgent(PlanExecute):
@primitive(read_only=True)
def divide(self, a: float, b: float) -> float:
"""Divide a by b. Example: divide(300, 60) -> 5.0"""
if b == 0:
raise ValueError("Cannot divide by zero.")
return a / bMasterAgent#
Holds both specialists as instance variables and exposes three primitives: one that delegates to AbbreviationAgent, one that delegates to CalculatorAgent, and one that assembles the final answer.
# master.py
class MasterAgent(PlanExecute):
def __init__(self, llm: LLMConfig, verbose: bool = False, **kwargs) -> None:
super().__init__(llm=llm, **kwargs)
self._abbrev = AbbreviationAgent(llm=llm)
self._calc = CalculatorAgent(llm=llm)
@primitive(read_only=True)
def expand_problem(self, text: str) -> str:
"""Send the full problem text to the abbreviation agent for expansion.
Example: expand_problem("I drove 300km at 60kph") -> "I drove 300 kilometers at 60 kilometers per hour"
"""
result = self._abbrev.run(f"Expand all abbreviations in: {text}")
return str(result.result) if result.success else text
@primitive(read_only=True)
def ask_calculator(self, expression: str) -> float:
"""Ask the calculator to evaluate an arithmetic expression.
Example: ask_calculator("300 / 60") -> 5.0
"""
result = self._calc.run(f"Calculate: {expression}")
return float(result.result) if result.success else 0.0
@primitive(read_only=True)
def report(self, expanded_problem: str, answer: float, answer_label: str) -> str:
"""Combine the expanded problem text and the numeric answer into a final result."""
return f"{expanded_problem} Answer: {answer:.2f} {answer_label}."When the master calls expand_problem, AbbreviationAgent writes its own plan and runs it. When it calls ask_calculator, CalculatorAgent does the same. Each specialist's LLM call is independent.
What the plan looks like#
For the road trip task, the master writes:
expanded = expand_problem("I drove 300km at 60kph. What do km and kph mean, and how long did the trip take?")
time = ask_calculator("300 / 60")
result = report(expanded_problem=expanded, answer=time, answer_label="hours")Three lines. The master identified the two sub-tasks and routed each to the right specialist.
Run it#
uv run main.pyOutput:
[Road trip]
task: I drove 300km at 60kph. What do km and kph mean, and how long did the trip take?
result: I drove 300 kilometers at 60 kilometers per hour. Answer: 5.00 hours.
[PASS]
[Grocery shopping]
task: I bought 2.5kg of apples at $3.20/kg. What does kg mean and what is the total cost?
result: I bought 2.5 kilograms of apples at $3.20/kilograms. Answer: 8.00 .
[PASS]
[Water bottle]
task: My bottle has 1200mL of water. I drank 500mL. What does mL mean and how much is left?
result: My bottle has 1200 milliliters of water. I drank 500 milliliters. Answer: 700.00 milliliters.
[PASS]See every agent's plan#
Set SHOW_PLANS = True in main.py to print each agent's plan as it runs:
[AbbreviationAgent plan]
result = expand_all("I drove 300km at 60kph ...")
[CalculatorAgent plan]
result = divide(300, 60)
[MasterAgent plan]
expanded = expand_problem("I drove 300km at 60kph ...")
time = ask_calculator("300 / 60")
result = report(expanded_problem=expanded, answer=time, answer_label="hours")Three separate plans, one per agent. Each shows exactly what that agent decided to do with its slice of the problem.
What to notice#
The master's primitives are thin wrappers. expand_problem calls self._abbrev.run(...) and returns the result. There is no abbreviation logic in the master at all. Swapping AbbreviationAgent for a different implementation requires changing one line in __init__.
Each specialist sees only its slice. AbbreviationAgent received a text string and returned a text string. It never saw the numbers. CalculatorAgent received "300 / 60" and returned 5.0. It never saw the words. Narrow inputs produce predictable outputs.
Every agent writes its own plan. When expand_problem runs, a second LLM call happens inside AbbreviationAgent. That agent decides how to use expand_all. The master does not tell it how. SHOW_PLANS = True makes this visible without adding any instrumentation code.
No new framework concepts. A primitive that delegates to another PlanExecute instance is just a Python method call. The sub-agent is created in __init__, stored in an instance variable, and called with .run(). The framework has no special "delegation" API; composition is ordinary object construction.