For multi-step work, splitting planning from execution improves both cost and debuggability. A strong model writes a typed plan once; a cheap model (or plain code) runs each step.
ts
const Plan = z.object({
steps: z.array(z.object({
id: z.string(),
tool: z.enum(["search", "fetch_page", "summarize"]),
args: z.record(z.string()),
depends_on: z.array(z.string()).default([]),
})),
});
const plan = Plan.parse(await planWithStrongModel(goal)); // one expensive call
const done: Record<string, unknown> = {};
for (const step of topoSort(plan.steps)) { // deterministic execution
done[step.id] = await tools.run(step.tool, resolve(step.args, done));
}Benefits: the plan is inspectable before anything runs, steps without dependencies run in parallel, and a failed step can be retried without re-planning the whole task.
When not to use it: highly interactive tasks where each result changes the goal. There, a plain ReAct loop adapts better than a frozen plan. A common hybrid is *re-plan after every N steps*.
Exercise: add `depends_on` handling and run independent steps with `Promise.all`. Measure wall time against the sequential version.