workflows/plan

Source: src/workflows/plan.ts

Reading a plan an agent wrote: the prompt, the parser, the validation.

Split out from orchestrate so the prompt, the parser and the validation can be read, and tested, apart from the fan-out that follows them.

The open question this file answers is how to read what an agent decided. Three candidates were weighed: a tool call, structured output, or a parsed convention. This is a parsed convention, and the reasoning is worth keeping:

  • A tool call is possible (createAgentSession takes customTools), and it would give validated arguments for free. It also means teaching SessionPort about tool definitions, and betting the whole thing on a model that reliably emits tool calls. The weak models this library is routinely run against do not.

  • Structured output is not uniformly available across providers, and pi’s prompt() returns text either way.

  • A parsed convention costs one function, works on every provider, and the plan is validated against the known agents regardless - which is the check that actually matters. A schema would not have caught a hallucinated agent name; the name lookup does.

{@link parsePlan} is therefore lenient, and only it: its input is a language model, not a caller. Everywhere else in this library a malformed input is an error.

parsePlan

function

export function parsePlan(output: string, workers: readonly Agent[]): PlannedTask[] { /* … */ }

Reads a plan out of whatever the planner actually wrote.

Lenient by design - the input is a model. Two shapes are accepted, because both are what models really produce when asked for the first:

  • any JSON objects carrying agent and task, in the order they appear: inside an array, alone, one per line, wrapped in a code fence or in prose. Asked for an array, a real planner answered with bare objects and no brackets - so the parser collects objects rather than requiring the shape;

  • one subtask per line, agent: task.

A step naming an unknown agent is dropped, never remapped: guessing which agent a hallucinated name meant is how a workflow silently does the wrong work. An empty result means “nothing usable was said”, which the caller reports rather than papers over.

PlannedTask

type

export type PlannedTask = {
	/** Already resolved: an unknown name is dropped by the parser, never carried here. */
	agent: Agent;
	/** What that agent is asked to do, in the planner's own words. */
	task: string;
};

One step of a plan: a resolved agent - an unknown name never gets this far - and its task.

planningPrompt

function

export function planningPrompt(input: string, workers: readonly Agent[], maxTasks: number): string { /* … */ }

The question put to the planner: who is available, and how to answer.

PlanOptions

type

export type PlanOptions = WorkflowOptions & {
	/** The agent that decides the split. */
	planner: Agent;
	/** Who it may delegate to. Their `description` is what the planner reads. */
	workers: Agent[];
	/** What is to be split. The planner sees this and the roster, nothing else. */
	input: string;
	/**
	 * How many subtasks a plan may contain. Defaults to 8.
	 *
	 * Same reasoning as `loop`'s `maxIterations`: every subtask is a session and
	 * a bill, so "a plan of two hundred steps" must not be reachable by a
	 * hallucination. Exceeding it fails **before** anything is spawned - losing
	 * a run costs less than paying for a runaway one.
	 */
	maxTasks?: number;
	/** Overrides how the plan is read. See {@link parsePlan}. */
	parse?: (output: string, workers: readonly Agent[]) => PlannedTask[];
	/** Overrides the question put to the planner. See {@link planningPrompt}. */
	format?: (input: string, workers: readonly Agent[], maxTasks: number) => string;
};

Who plans, who may be delegated to, and how large a plan may get.

PlanOutcome

type

export type PlanOutcome = {
	/** What the planner asked for, after validation. Empty when nothing is runnable. */
	plan: PlannedTask[];
	/** The planner's own turn. Kept whatever happened next. */
	planning: Result;
	/** False when the planner failed, or produced nothing runnable. */
	ok: boolean;
	/** Set if and only if `ok` is false. It carries what the planner actually wrote. */
	error?: string;
};

A validated plan, or the reason there is none - decided before anything is spawned.