Skip to content

Plan executor

A planner emits a dependency graph of tasks as data; the executor runs them by readiness. See the plan-as-data recipe guide.

agentflow.prebuilt.plan_executor

Plan-as-data execution: run a dependency graph of tasks the planner emits.

The engine graph is static — a conditional edge may only fan out to targets declared at compile time. This prebuilt gives you the other shape without changing the engine: a planner writes a dependency graph of tasks into state, and add_plan_executor runs them by readiness (a task runs once all its prerequisites have finished), fanning independent tasks out concurrently, collecting results, and looping until nothing is runnable.

The plan is data in a by_key channel, so it is checkpointed and a resume simply recomputes which tasks are runnable from durable state. No node is added to the graph at runtime; the handler pool is fixed at build time and a new task is new data, never a new node.

class OptState(PlanState):            # the plan channel comes from the mixin
    incumbent: Annotated[dict, last]

async def try_index(task, state, ctx) -> TaskResult:
    return TaskResult(task.id, output=evaluate(task.input))

g = Graph(OptState)
g.add_node("planner", planner)        # writes plan.tasks (your code)
add_plan_executor(g, handlers={"index": try_index}, on_idle="planner")
g.add_edge(START, "planner")
g.add_edge("planner", "dispatch")     # planner hands off to the executor

planner proposes tasks; the executor runs them; on_idle (here the planner again) is reached when no task is runnable, so the planner can observe results and propose more, or route to END to stop. The executor owns only the run mechanics (dispatch, run, collect); the propose/stop policy is yours.

Scope, stated plainly: this does not mutate the graph, is not a Send-style dynamic spawn, and is not a general scheduler — "runnable" means "dependencies satisfied", nothing more. It gives at-least-once execution like the rest of the SDK; make handlers idempotent (task.id is the stable key to pass to IdempotentOp or a downstream dedup token) for exactly-once effects.

Handler module-attribute

Handler = Callable[[Task, Mapping[str, Any], Any], Awaitable[TaskResult]]

Task dataclass

One unit of work in a plan.

id is unique within a plan and must be stable across a resume — it is the dedup key, so a re-dispatched task is recognized as the same one. Let the planner choose a meaningful id (e.g. "try_index:orders"); it doubles as a natural idempotency key. kind selects the handler. input is the payload the handler reads. prerequisites are task ids that must finish before this task becomes runnable.

Source code in agentflow/prebuilt/plan_executor.py
@dataclass(frozen=True)
class Task:
    """One unit of work in a plan.

    ``id`` is unique within a plan and must be *stable across a resume* — it is
    the dedup key, so a re-dispatched task is recognized as the same one. Let
    the planner choose a meaningful id (e.g. ``"try_index:orders"``); it doubles
    as a natural idempotency key. ``kind`` selects the handler. ``input`` is the
    payload the handler reads. ``prerequisites`` are task ids that must finish
    before this task becomes runnable.
    """

    id: str
    kind: str
    input: Any = None
    prerequisites: tuple[str, ...] = ()

    def to_dict(self) -> dict[str, Any]:
        """A JSON-serializable view, as stored in the ``plan.tasks`` channel."""
        return {
            "id": self.id,
            "kind": self.kind,
            "input": self.input,
            "prerequisites": list(self.prerequisites),
        }

    @classmethod
    def from_dict(cls, d: Mapping[str, Any]) -> Task:
        return cls(
            id=d["id"],
            kind=d["kind"],
            input=d.get("input"),
            prerequisites=tuple(d.get("prerequisites") or ()),
        )

to_dict

to_dict() -> dict[str, Any]

A JSON-serializable view, as stored in the plan.tasks channel.

Source code in agentflow/prebuilt/plan_executor.py
def to_dict(self) -> dict[str, Any]:
    """A JSON-serializable view, as stored in the ``plan.tasks`` channel."""
    return {
        "id": self.id,
        "kind": self.kind,
        "input": self.input,
        "prerequisites": list(self.prerequisites),
    }

TaskResult dataclass

A handler's result for one task.

failed marks a task that finished unsuccessfully: it still counts as done (so the plan can make progress), but it blocks its dependents — they never become runnable. error carries a message for observability.

Source code in agentflow/prebuilt/plan_executor.py
@dataclass(frozen=True)
class TaskResult:
    """A handler's result for one task.

    ``failed`` marks a task that finished unsuccessfully: it still counts as
    *done* (so the plan can make progress), but it blocks its dependents — they
    never become runnable. ``error`` carries a message for observability.
    """

    task_id: str
    output: Any = None
    failed: bool = False
    error: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """A JSON-serializable view, as stored in the ``plan.results`` channel."""
        return {
            "task_id": self.task_id,
            "output": self.output,
            "failed": self.failed,
            "error": self.error,
        }

    @classmethod
    def from_dict(cls, d: Mapping[str, Any]) -> TaskResult:
        return cls(
            task_id=d["task_id"],
            output=d.get("output"),
            failed=bool(d.get("failed", False)),
            error=d.get("error"),
        )

to_dict

to_dict() -> dict[str, Any]

A JSON-serializable view, as stored in the plan.results channel.

Source code in agentflow/prebuilt/plan_executor.py
def to_dict(self) -> dict[str, Any]:
    """A JSON-serializable view, as stored in the ``plan.results`` channel."""
    return {
        "task_id": self.task_id,
        "output": self.output,
        "failed": self.failed,
        "error": self.error,
    }

PlanState

Bases: State

Mix into a state schema to add the channel add_plan_executor needs.

One structured plan channel holds the whole execution plan, each field folded by its own reducer via :func:~agentflow.state.by_key:

  • tasks (append): the planner's proposals accumulate — the backlog. Stored as plain dicts; a planner may append ergonomic :class:Task objects and the reducer coerces them, so the plan stays JSON-serializable for a durable checkpointer.
  • results (append): one result per completed task, stored as a plain dict (a :class:TaskResult is coerced on write). Read back as dicts, or via :meth:TaskResult.from_dict.
  • done_ids: the finished task ids, unioned in and kept as a sorted list (JSON-serializable), for a fast readiness check without scanning results.
  • assigned (last): transient slot -> task-id map for the current dispatch; overwritten each round and never trusted across a resume.

The runnable frontier is derived from done_ids every super-step, never stored — which is what makes a resume need no special path.

Source code in agentflow/prebuilt/plan_executor.py
class PlanState(State):
    """Mix into a state schema to add the channel ``add_plan_executor`` needs.

    One structured ``plan`` channel holds the whole execution plan, each field
    folded by its own reducer via :func:`~agentflow.state.by_key`:

    - ``tasks`` (append): the planner's proposals accumulate — the backlog.
      Stored as plain dicts; a planner may append ergonomic :class:`Task`
      objects and the reducer coerces them, so the plan stays
      JSON-serializable for a durable checkpointer.
    - ``results`` (append): one result per completed task, stored as a plain
      dict (a :class:`TaskResult` is coerced on write). Read back as dicts, or
      via :meth:`TaskResult.from_dict`.
    - ``done_ids``: the finished task ids, unioned in and kept as a sorted
      list (JSON-serializable), for a fast readiness check without scanning
      ``results``.
    - ``assigned`` (last): transient slot -> task-id map for the current
      dispatch; overwritten each round and never trusted across a resume.

    The runnable frontier is derived from ``done_ids`` every super-step, never
    stored — which is what makes a resume need no special path.
    """

    plan: Annotated[
        dict,
        by_key(
            {
                # Tasks and results are stored as plain dicts so the plan is
                # JSON-serializable and survives a durable checkpointer. The
                # reducers coerce Task/TaskResult objects to dicts on write, so
                # a planner can still append ergonomic Task(...) objects.
                "tasks": _append_tasks,
                "results": _append_results,
                "done_ids": _union_ids,
                "assigned": last,
            }
        ),
    ]

add_plan_executor

add_plan_executor(g: Graph, *, handlers: Mapping[str, Handler], on_idle: str, dispatch: str = 'dispatch', collect: str = 'collect', max_fanout: int = 8) -> Graph

Add a plan-as-data executor to g and return g.

Creates a dispatch node, max_fanout run-slot nodes, and a collect node, wired as dispatch -> (slots) -> collect -> dispatch, with collect and dispatch routing to on_idle when no task is runnable. Your planner node (not created here) writes plan.tasks and hands off to dispatch; on_idle is where control goes when the plan has no runnable task — typically back to the planner to propose more, or :data:~agentflow.graph.END to stop.

handlers maps a task kind to an async (task, state, ctx) -> TaskResult. A task whose kind has no handler fails with an error (it does not raise, so one bad task does not abort the run — it blocks its dependents like any other failure). max_fanout is the fixed number of run slots, so at most that many tasks run concurrently per super-step; it is a compile-time ceiling, mirroring max_node_concurrency in spirit.

Mix :class:PlanState into your schema so the plan channel is declared with the right per-field reducers. Returns g for chaining.

Source code in agentflow/prebuilt/plan_executor.py
def add_plan_executor(
    g: Graph,
    *,
    handlers: Mapping[str, Handler],
    on_idle: str,
    dispatch: str = "dispatch",
    collect: str = "collect",
    max_fanout: int = 8,
) -> Graph:
    """Add a plan-as-data executor to ``g`` and return ``g``.

    Creates a ``dispatch`` node, ``max_fanout`` run-slot nodes, and a
    ``collect`` node, wired as dispatch -> (slots) -> collect -> dispatch, with
    ``collect`` and ``dispatch`` routing to ``on_idle`` when no task is
    runnable. Your planner node (not created here) writes ``plan.tasks`` and
    hands off to ``dispatch``; ``on_idle`` is where control goes when the plan
    has no runnable task — typically back to the planner to propose more, or
    :data:`~agentflow.graph.END` to stop.

    ``handlers`` maps a task ``kind`` to an async ``(task, state, ctx) ->
    TaskResult``. A task whose ``kind`` has no handler fails with an error (it
    does not raise, so one bad task does not abort the run — it blocks its
    dependents like any other failure). ``max_fanout`` is the fixed number of
    run slots, so at most that many tasks run concurrently per super-step; it is
    a compile-time ceiling, mirroring ``max_node_concurrency`` in spirit.

    Mix :class:`PlanState` into your schema so the ``plan`` channel is declared
    with the right per-field reducers. Returns ``g`` for chaining.
    """
    if max_fanout < 1:
        raise ValueError("max_fanout must be >= 1")
    for created in (dispatch, collect):
        if created in g.nodes:
            raise ValueError(f"node {created!r} already exists")
    slot_names = [f"{dispatch}__run_{i}" for i in range(max_fanout)]

    # -- dispatch: assign up to max_fanout runnable tasks to slots -----------

    async def dispatch_node(state: Mapping[str, Any], ctx: Any) -> dict[str, Any]:
        plan = _plan(state)
        runnable = _runnable(plan)[:max_fanout]
        assigned = {str(i): task.id for i, task in enumerate(runnable)}
        # overwrite the transient assignment (last reducer); nothing else moves
        return {"plan": {"assigned": assigned}}

    def dispatch_route(state: Mapping[str, Any]) -> list[str]:
        plan = _plan(state)
        assigned = plan.get("assigned") or {}
        if assigned:
            return [str(i) for i in range(len(assigned))]
        # nothing runnable: go idle (plan done, or stalled on a failed prereq)
        return ["idle"]

    # -- run slot: execute the one task assigned to this slot ----------------

    def _make_slot(index: int):
        key = str(index)

        async def run_slot(state: Mapping[str, Any], ctx: Any) -> dict[str, Any] | None:
            plan = _plan(state)
            assigned = plan.get("assigned") or {}
            task_id = assigned.get(key)
            if task_id is None:
                return None  # this slot was not used this round
            task = _tasks_by_id(plan).get(task_id)
            if task is None:  # pragma: no cover - defensive; id always present
                return None
            handler = handlers.get(task.kind)
            if handler is None:
                result = TaskResult(
                    task.id, failed=True, error=f"no handler for kind {task.kind!r}"
                )
            else:
                result = await handler(task, state, ctx)
            return {"plan": {"results": [result], "done_ids": {result.task_id}}}

        run_slot.__name__ = f"slot[{index}]"
        return run_slot

    # -- collect: fan-in, then loop or go idle -------------------------------

    async def collect_node(state: Mapping[str, Any], ctx: Any) -> dict[str, Any] | None:
        return None  # pure routing point; results already folded by the slots

    def collect_route(state: Mapping[str, Any]) -> str:
        return "more" if _runnable(_plan(state)) else "idle"

    # -- wire it together ----------------------------------------------------

    g.add_node(dispatch, dispatch_node)
    for i, name in enumerate(slot_names):
        g.add_node(name, _make_slot(i))
    g.add_node(collect, collect_node)

    slot_mapping: dict[str, Any] = {str(i): slot_names[i] for i in range(max_fanout)}
    slot_mapping["idle"] = on_idle
    g.add_conditional_edges(dispatch, dispatch_route, slot_mapping)

    for name in slot_names:
        g.add_edge(name, collect)
    g.add_conditional_edges(collect, collect_route, {"more": dispatch, "idle": on_idle})

    return g

plan_complete

plan_complete(state: Mapping[str, Any]) -> bool

True when every proposed task has finished (succeeded or failed).

Source code in agentflow/prebuilt/plan_executor.py
def plan_complete(state: Mapping[str, Any]) -> bool:
    """True when every proposed task has finished (succeeded or failed)."""
    return not _unfinished(_plan(state))

plan_stalled

plan_stalled(state: Mapping[str, Any]) -> bool

True when tasks remain unfinished but none is runnable.

This means the remaining tasks are all blocked — by a failed prerequisite or a dependency cycle — so the plan cannot progress without new tasks. A planner reaching on_idle uses this to tell "nothing left to do" from "stuck", and can propose a different approach or escalate.

Source code in agentflow/prebuilt/plan_executor.py
def plan_stalled(state: Mapping[str, Any]) -> bool:
    """True when tasks remain unfinished but none is runnable.

    This means the remaining tasks are all blocked — by a failed prerequisite
    or a dependency cycle — so the plan cannot progress without new tasks. A
    planner reaching ``on_idle`` uses this to tell "nothing left to do" from
    "stuck", and can propose a different approach or escalate.
    """
    plan = _plan(state)
    return bool(_unfinished(plan)) and not _runnable(plan)