PlanAndExecute¶
Ask the model for a plan (a dependency graph of steps), run the steps as parallel child runs, repair the plan if a step fails, then synthesize a final answer.
class PlanAndExecute:
def __init__(
self,
executor,
*,
model=None,
max_parallel=3,
max_replans=1,
synthesize=True,
planner_system=...,
): ...
"""Plan once, run the steps as parallel child runs honoring dependencies, then synthesize."""
import asyncio
import json
from aaa.algos import PlanAndExecute, ToolLoop
from aaa.llm import FakeLLMClient, LLMHandler
from aaa.llm.fake import text, tool_call
from aaa.runtime import Engine
from aaa.tools import ToolHandler, ToolRegistry, tool
@tool
def population(city: str) -> int:
"""Population of a city (millions)."""
return {"Tokyo": 14, "Delhi": 32, "Lima": 10}[city]
PLAN = {
"steps": [
{"id": "a", "description": "population of Tokyo", "depends_on": []},
{"id": "b", "description": "population of Delhi", "depends_on": []},
{"id": "c", "description": "add the two populations", "depends_on": ["a", "b"]},
]
}
def model(request):
system = request.messages[0].content if request.messages[0].role == "system" else ""
last = request.messages[-1]
if "planner" in system:
return text(json.dumps(PLAN))
if "Combine" in system:
return text("Tokyo + Delhi = 46 million people")
if last.role == "user": # an executor child: do its one step
step = last.content.split("Your step (")[1].split(")")[0]
if step in ("a", "b"):
return tool_call("population", city="Tokyo" if step == "a" else "Delhi")
return text("46")
return text(last.content)
async def main() -> None:
registry = ToolRegistry([population])
async with Engine() as engine:
LLMHandler(FakeLLMClient(model)).attach(engine)
ToolHandler(registry).attach(engine)
agent = PlanAndExecute(ToolLoop(registry), max_parallel=2)
run = await engine.start(agent, {"task": "Total population of Tokyo and Delhi?"})
outcome = await run.result()
log = await engine.store.read(run.run_id)
print(outcome.value["text"])
print("steps run:", [e.data["step_id"] for e in log if e.type == "aaa.task.started"])
asyncio.run(main())
How it works¶
flowchart LR
T[task] --> PL[Planner LLM<br/>structured JSON plan]
PL --> V{valid DAG?}
V -->|no| F1[failed: invalid plan]
V -->|yes| S[Scheduler]
S --> A[step a]
S --> B[step b]
A --> C[step c<br/>depends on a, b]
B --> C
C --> SY[Synthesis LLM]
SY --> OUT[success]
A -.fails.-> RP[Replan<br/>max_replans]
RP --> S
- Plan. One LLM call with a JSON schema asks for
{"steps": [{"id", "description", "depends_on": []}]}. - Validate. Duplicate ids, unknown dependencies and cycles are rejected (
failed, reasoninvalid plan: ...). A plan is code-checked before any step runs. - Execute. Ready steps (all dependencies done) start as child runs of
executor(any algorithm, typically aToolLoop), at mostmax_parallelat once. Each child receives the overall task, its step, and the results of its prerequisites. - Replan. If a step fails and replans remain, the planner is asked for the remaining steps given what is
done and what failed. New step ids get an
r<n>-prefix. Beyondmax_replans, the run fails with the failing step's reason. - Synthesize. One more call combines all step results into the answer.
The plan itself is in the log (aaa.plan.created, aaa.plan.revised, aaa.task.started), so it is inspectable,
resumable and replayable.
Parameters¶
| Parameter | Default | |
|---|---|---|
executor |
required | The algorithm that runs each step. Its name must be unique and registered on every worker. |
model |
None |
Model for planning and synthesis. |
max_parallel |
3 |
Concurrent step runs. |
max_replans |
1 |
How many times a failure may trigger replanning. 0 disables. |
synthesize |
True |
False skips the final call and returns {"results": {...}}. |
planner_system |
built-in | Override the planner prompt. Keep the JSON shape. |
Result: {"text": <synthesis>, "results": {step_id: result}}.
Budgets and children¶
Child runs have their own outcomes and their spend rolls up into the parent's budget, so one
Budget(max_cost=1.0) on the parent bounds the whole tree. If the parent finishes or is cancelled, running children are cancelled.
When not to use it¶
For a task needing two or three tool calls in sequence, a ToolLoop is simpler and cheaper: planning and synthesis add
two LLM calls. Choose PlanAndExecute when steps are independent and can run in parallel, are individually long, or when
you want the plan itself as an auditable artifact.