See It Flow

AI Agents · Module 5

Planning & Task Decomposition

How AI agents decide what to do first, what can run in parallel, and when a deterministic workflow is the better choice.

Mangesh MandlikLast updated:

Visual Experience

How An Agent Plans A Complex Task

Watch an agent turn a broad objective into smaller tasks, execute the work in stages, and adjust the plan when the environment changes.

AI Agents · Module 5

Planning & Task Decomposition

How agents transform complex goals into executable steps

Mute narration
1 / 26

01 ·User Goal

User goal: "Launch a new SaaS product."

Goal

🧑Launch a new SaaS product.

"Some goals are too large to attack head-on."

Previous scene
Play
Next scene
Mute narration
Replay from Scene 1

AI Agent Planning: How Agents Break Down Tasks Into a Task Graph

AI agent planning means turning a goal into a task graph and executing it: identify the goal, build the graph, validate it, execute ready tasks, observe the results, and replan when an observation breaks an assumption. The graph is a set of small tasks, each with its own inputs, expected outputs, tool, risk class, and the tasks it depends on. Tasks with no unmet dependencies can run in parallel; tasks that need another task's output wait for it. Time to finish is set by the longest chain of dependent tasks (the critical path), not by the number of tasks. A model may propose or revise the graph, but the agent runtime validates it, schedules it, enforces permissions, persists progress, and decides what happens when an observation makes part of the plan wrong.

A list of steps is not a plan. A plan also says what each step needs, what can overlap, what must never run twice, which steps need a human, and what to do when the world disagrees with the assumptions that produced it. The rest of this chapter builds that artifact for one concrete request, then breaks it on purpose.

Why 'Resolve the Customer's Problem' Is Not Executable

Take the request that runs through this whole track: "Order 8472 was charged twice, and my refund has not arrived. Please investigate and resolve it." A person on the support team would silently do many things: find the order, check what the payment system recorded, look for a refund already in flight, read the refund policy, decide whether a refund is owed, get a manager to sign off if the amount is large, issue it, and write back. The instruction "investigate and resolve" names none of that. It cannot be handed to a tool, it cannot be scheduled, and nobody can tell whether it is half done.

Three things are missing from a bare goal. First, granularity: nothing is small enough to map onto one tool call, one retrieval, one computation, or one human decision. Second, order: some steps are impossible until others finish (you cannot ask the payment system about a refund before you know which payment belongs to the order), while others are independent. Third, checkpoints: there is no place to verify progress, stop, or resume. Decomposition supplies all three.

This is different from the agentic loop, which decides one next action at a time from the current state. A loop with no plan can work for short tasks, but for a goal like this one it tends to re-derive the strategy on every turn, spend tokens re-reading its own history (see the context window lesson), and wander. Planning gives the loop a structure to move along while still letting it deviate when evidence demands it.

From Goal to Constraints to Tasks

Decomposing before you have pinned down the problem produces confident nonsense, so the first artifact is not a task list. It is a run specification: the objective, how you will know it is achieved, what the run is allowed to do, and how much it may spend. The runtime builds this from the request and from trusted configuration, not from anything the model says.

Run specification for req-8472 (illustrative JSON)
{
  "request_id": "req-8472",
  "objective": "Determine whether order 8472 was double-charged and resolve the customer's missing refund",
  "success_criteria": [
    "duplicate capture confirmed or ruled out from payment-system records",
    "existing refund state for order 8472 known",
    "any refund issued at most once, within policy POL-REF-004 v4",
    "customer reply states only facts returned by tools"
  ],
  "permissions": {
    "granted_scopes": ["orders:read", "payments:read", "policy:read", "refunds:write"],
    "write_requires_approval_above_inr": 1000
  },
  "limits": { "deadline_s": 120, "max_tasks": 12, "max_parallel": 3, "max_llm_calls": 8 }
}

Success criteria are checkable against tool output. Permissions come from the runtime's configuration for this user and session, never from the plan the model writes.

Each field constrains the decomposition. The permissions mean a plan that mentions a tool outside the granted scopes is rejected before running. The deadline and max_llm_calls mean the planner cannot spend unlimited model calls polishing a plan. The success criteria are the "done" condition against which the final state is checked, and they imply tasks: "existing refund state known" is the reason a refund-status lookup exists at all. The ₹1,000 approval threshold stands in for a rule from refund policy POL-REF-004 (illustrative); it is stored as configuration the runtime enforces, because a number the model merely remembers from a prompt is not an enforced control.

The model does not grant itself permissions
If the model's plan contains "scope": "refunds:write" on a task, that is a claim, not a credential. The runtime compares tasks against the scopes in the run specification and rejects the rest. See HITL & Guardrails for how approvals and authorization are bound to exact actions.

Four Ways to Produce the Decomposition

Where the task list comes from is a design decision with real consequences for predictability, cost, and flexibility. There are four common sources, and real systems mix them.

Deterministic templates

For a request class whose steps are known, a template is written by engineers: "duplicate-charge investigation" always begins by fetching the order and the policy, then the payment status, and so on. The model, if involved at all, classifies the request and fills slots (the order ID). Templates are testable, reviewable, cheap, and immune to a model inventing a step. Their cost is rigidity: a request that does not fit the template either fails to match or is forced into the wrong shape.

Model-generated plans

The model is given the goal, the run specification, and the catalog of tools, and returns a graph. This is the right choice when requests vary too much to template: a free-form complaint that might involve shipping, billing, or account access. The risks are those of any model output. It can name a tool that does not exist, omit a prerequisite, reference an order ID that is not the customer's, or produce a graph with a cycle. So the output is treated as a proposal and constrained at both ends. Before generation, restrict the output format to a schema: tool names come from an enum of the available tools, each task declares its dependencies and outputs, and the number of tasks is capped. After generation, run it through the validator described below. Constrained decoding or structured-output features help produce well-formed JSON, but they do not prove the plan is correct; validation does.

Hierarchical decomposition

A large goal is split into a few coarse stages ("establish facts", "decide", "act", "communicate"), and each stage is expanded only when reached. Expanding lazily matters because later stages depend on facts that do not exist yet: what the "act" stage looks like depends on whether a refund is owed. Hierarchy keeps each planning call small and focused and keeps the top-level structure stable while details change.

Iterative (rolling-horizon) planning

The agent plans only the next few tasks, executes them, and plans again with the observations in hand. This is the loop with a short lookahead. It suits exploratory work where the next step genuinely depends on what was just found. It is also the most expensive in model calls, hardest to bound, and hardest to audit, because there is no complete plan to review in advance.

A practical refund agent usually combines them: a template selected for the request class provides the skeleton (a fixed DAG with the approval and write steps pinned), a model fills or extends optional investigation branches within a size limit, and the runtime replans iteratively only when an observation invalidates the graph. The more irreversible the task, the more of it should come from the template side.

The Plan as a Directed Acyclic Graph

A plan is represented as a DAG: nodes are tasks, and an edge from A to B means B needs A's output, or must not start until A is done. Acyclic matters, because a task that transitively waits on itself can never start. The graph form is what lets a scheduler ask "what can run now?" and "what is invalidated if this task's result changes?" Neither question can be answered from an ordered list.

Each node carries enough fields for the runtime to execute, authorize, and audit it without asking the model again: an id; a tool or executor (a real tool, a deterministic runtime function, an LLM call, or a human task); inputs, which are literals or references to upstream outputs; declared outputs; deps; a risk class that drives approval and retry behaviour; an optional when condition; and a status that moves through pending → running → done (or failed, skipped, invalidated).

Plan v1 for req-8472 (illustrative JSON; 8 tasks)
{
  "plan_id": "plan-8472", "version": 1, "request_id": "req-8472",
  "tasks": [
    { "id": "t1", "name": "get_order", "executor": "tool", "tool": "orders.get", "risk": "read",
      "deps": [], "inputs": { "order_id": "8472" },
      "outputs": ["order"], "status": "pending" },

    { "id": "t2", "name": "retrieve_policy", "executor": "tool", "tool": "policy.search", "risk": "read",
      "deps": [], "inputs": { "query": "duplicate charge refund eligibility", "family": "POL-REF" },
      "outputs": ["policy_passages"], "status": "pending" },

    { "id": "t3", "name": "get_payment_status", "executor": "tool", "tool": "payments.lookup", "risk": "read",
      "deps": ["t1"], "inputs": { "payment_ref": "$t1.order" },
      "outputs": ["captures", "refunds"], "status": "pending" },

    { "id": "t4", "name": "check_duplicate_charge", "executor": "runtime", "tool": "runtime.compare_captures", "risk": "read",
      "deps": ["t1", "t3"], "inputs": { "order": "$t1.order", "captures": "$t3.captures" },
      "outputs": ["finding"], "status": "pending" },

    { "id": "t5", "name": "decide_eligibility", "executor": "llm", "tool": "llm.decide", "risk": "read",
      "deps": ["t2", "t4"], "inputs": { "policy": "$t2.policy_passages", "finding": "$t4.finding" },
      "outputs": ["decision"], "status": "pending" },

    { "id": "t6", "name": "request_approval", "executor": "human", "tool": "approvals.request", "risk": "approval",
      "deps": ["t5"], "when": "$t5.decision.refund_inr > 1000", "inputs": { "proposal": "$t5.decision" },
      "outputs": ["approval"], "status": "pending" },

    { "id": "t7", "name": "issue_refund", "executor": "tool", "tool": "payments.issue_refund", "risk": "write",
      "deps": ["t4", "t5", "t6"], "idempotency_key": "refund:req-8472:t7",
      "preconditions": ["no ISSUED refund exists for the duplicate capture"],
      "inputs": { "order_id": "8472", "amount_inr": "$t5.decision", "approval": "$t6.approval" },
      "outputs": ["refund_receipt"], "status": "pending" },

    { "id": "t8", "name": "draft_reply", "executor": "llm", "tool": "llm.draft", "risk": "read",
      "deps": ["t5", "t7"], "inputs": { "decision": "$t5.decision", "receipt": "$t7.refund_receipt" },
      "outputs": ["reply"], "status": "pending" }
  ]
}

Illustrative IDs, tool names and values. Inputs beginning with $ are references resolved by the runtime from upstream task outputs. The write task carries the idempotency key the runtime will pass to the payments tool.

Plan v1 as a dependency graphEight tasks. Order lookup t1 feeds payment status t3 and the duplicate check t4. Policy retrieval t2 has no prerequisites and joins at the eligibility decision t5. Approval t6 gates the write task t7, which feeds the reply t8. Edges implied by transitivity, such as t1 to t4 and t4 to t7, are omitted for legibility.t1 get_orderreadt3 pay_statusreadt4 dup_checkruntime computet5 decideLLM callt6 approvalhuman, if > ₹1,000t7 issue_refundwrite, idempotentt8 draft_replyLLM callt2 policyread, no deps
Plan v1 as a dependency graph. Eight tasks. Order lookup t1 feeds payment status t3 and the duplicate check t4. Policy retrieval t2 has no prerequisites and joins at the eligibility decision t5. Approval t6 gates the write task t7, which feeds the reply t8. Edges implied by transitivity, such as t1 to t4 and t4 to t7, are omitted for legibility.

The same task dependencies in text, one line per task: t1 get_order (no dependencies); t2 retrieve_policy (none); t3 get_payment_status (needs t1); t4 check_duplicate_charge (needs t1, t3); t5 decide_eligibility (needs t2, t4); t6 request_approval (needs t5, only above ₹1,000); t7 issue_refund (needs t4, t5, t6); t8 draft_reply (needs t5, t7).

Read the graph for what it forbids and permits. t7 cannot start until the duplicate check, the decision, and the approval step have resolved, so the dangerous write is structurally downstream of its evidence. t1 and t2 share no edge, so a scheduler may run them together. t6 has a when clause: if the decided amount is at or below ₹1,000 it is skipped, and a skipped task counts as satisfied for its dependents. Note that t5 is an LLM call and t4 is ordinary code; the graph treats both as tasks, but only the first needs a model. Tasks that can be computed deterministically should be.

One more property: the plan is data. It can be validated, stored, diffed between versions, and shown to a reviewer. A plan that exists only as free text in the model's context cannot be checkpointed or audited.

Parallelism and the Critical Path

Dependencies decide what can overlap. In plan v1, t1 (order lookup) and t2 (policy retrieval) are independent, so they start together. The payment-status lookup t3 needs the payment reference that t1 returns, so it waits for t1 but not for t2.

Here are the illustrative latencies for these three read tasks: order lookup 150 ms, policy lookup 250 ms, refund-status lookup 180 ms. These are made-up numbers chosen to make the arithmetic visible, not measurements. We also assume that the three tasks use independent backends that do not slow each other down, and that the concurrency limit is at least 2. The model call for the decision, the deterministic check, the approval wait and the write are excluded: the human wait is unbounded by machine latency and would swamp the comparison, and the rest are measured separately.

  1. 1

    Sequential baseline

    One task at a time: 150 + 250 + 180 = 580 ms.
  2. 2

    Earliest start and finish, parallel

    t1 starts at 0, finishes at 150. t2 starts at 0, finishes at 250. t3 may start when t1 finishes, so it starts at 150 and finishes at 150 + 180 = 330.
  3. 3

    Makespan

    The phase ends when the last task finishes: max(150 + 180, 250) = max(330, 250) = 330 ms.
  4. 4

    Slack

    The path t1 → t3 sets the finish time, so it is the critical path and has zero slack. t2 could finish as late as 330 without delaying anything, so it has 330 − 250 = 80 ms of slack.
  5. 5

    What parallelism saved

    580 − 330 = 250 ms, exactly t2's duration, because it was hidden entirely behind the critical path.
Sequential vs parallel schedule for the read phaseTop three bars: sequential order, policy, refund-status in series ending at 580 ms. Bottom three bars: order and policy start together at 0; refund-status starts at 150 after order finishes. The critical path is order then refund-status, ending at 330 ms; policy ends at 250 ms and has 80 ms of slack. All latencies are illustrative.0100150250330400580ms (illustrative)sequential: get_ordersequential: policysequential: refund_status580 msparallel: get_order (critical)parallel: policy (80 ms slack)slack to 330parallel: refund_status (critical)330 ms
Sequential vs parallel schedule for the read phase. Top three bars: sequential order, policy, refund-status in series ending at 580 ms. Bottom three bars: order and policy start together at 0; refund-status starts at 150 after order finishes. The critical path is order then refund-status, ending at 330 ms; policy ends at 250 ms and has 80 ms of slack. All latencies are illustrative.

Two lessons follow. First, concurrency does not divide the work by the number of workers. It removes only time that is off the critical path; here the policy lookup disappears from the total, but nothing could speed up the order-then-status chain except making those two calls faster or removing the dependency. Second, the critical path can move. If the policy index were slow, say 400 ms, then max(330, 400) = 400 ms and the policy lookup would be critical while the order chain would carry 70 ms of slack. Optimisation effort belongs on whatever is critical now.

Move the policy latency in the figure below to watch the critical path switch, and set the concurrency limit to 1 to see the overlap disappear.

250 ms
Concurrency limitConcurrency limit
Read-phase makespan for plan v1With order lookup 150 ms, refund-status lookup 180 ms and policy lookup 250 ms (illustrative), the read phase finishes in 330 ms. Critical path: get_order then refund_status.0150330ms (illustrative)get_orderpolicy250 ms, slack 80refund_status330 ms, critical
Read-phase makespan for plan v1. With order lookup 150 ms, refund-status lookup 180 ms and policy lookup 250 ms (illustrative), the read phase finishes in 330 ms. Critical path: get_order then refund_status.

The arithmetic is a forward pass (earliest finish = latest prerequisite finish + own duration) and a backward pass for slack. It is short enough to run:

Critical path and slack (runnable Python; same illustrative numbers)
from functools import cache

ms = {"get_order": 150, "retrieve_policy": 250, "lookup_refund_status": 180}
deps = {"get_order": [], "retrieve_policy": [], "lookup_refund_status": ["get_order"]}

@cache
def finish(task):
    start = max((finish(d) for d in deps[task]), default=0)
    return start + ms[task]

makespan = max(finish(t) for t in ms)          # 330
sequential = sum(ms.values())                  # 580

@cache
def latest_finish(task):
    users = [u for u in ms if task in deps[u]]
    return min((latest_finish(u) - ms[u] for u in users), default=makespan)

for t in ms:
    print(t, "start", finish(t) - ms[t], "finish", finish(t),
          "slack", latest_finish(t) - finish(t))
# get_order 0 150 slack 0 | retrieve_policy 0 250 slack 80 | lookup_refund_status 150 330 slack 0

Running this prints the start, finish and slack used in the figure.

In production the bound is rarely the dependency graph alone. A concurrency limit caps how many tasks run at once (a limit of 1 collapses the schedule back to 580 ms), rate limits on downstream APIs can serialize calls you hoped to overlap, and each extra parallel LLM call adds cost. Parallelism is also a correctness hazard when tasks have side effects: two tasks that both write to the same order must have an explicit edge between them even if neither consumes the other's output.

Validating a Plan Before Anything Runs

A model-proposed plan is untrusted input to the scheduler. Validation is ordinary code that runs before the first task starts and again after every revision, and it checks the things a model is known to get wrong. This is the same discipline as validating a tool-call proposal (see Tools & Tool Calling), applied to the whole graph.

  • Structure. Unique IDs, every dependency exists, and there is no cycle.
  • Authorization. Every task's tool is registered and its scope is among the scopes granted to this run. The plan cannot widen them.
  • Resource IDs. Literal identifiers match the request context. A plan for order 8472 that fetches order 8473 is rejected, not trusted.
  • Data contracts. If t5 reads $t4.finding, then t4 must be an upstream dependency and must declare finding as an output. A task reading an output nobody produces is a defect that would otherwise surface mid-run.
  • Policy shape. For example: any write task must depend on an approval task or satisfy the no-approval rule, and must carry an idempotency key.
Plan validator with cycle detection (runnable Python)
import re
from collections import deque

REF = re.compile(r"^\$(t\d+)\.([a-z_]+)")


def validate_plan(plan, tool_registry, granted_scopes, request):
    """Return a list of human-readable errors; an empty list means the plan may run."""
    errors = []
    tasks = {t["id"]: t for t in plan["tasks"]}
    if len(tasks) != len(plan["tasks"]):
        errors.append("duplicate task id")

    for t in tasks.values():
        tool = tool_registry.get(t["tool"])
        if tool is None:
            errors.append(f'{t["id"]}: unknown tool {t["tool"]}')
        elif tool["scope"] not in granted_scopes:
            errors.append(f'{t["id"]}: scope {tool["scope"]} not granted to this run')

        for dep in t["deps"]:
            if dep not in tasks:
                errors.append(f'{t["id"]}: depends on missing task {dep}')

        for name, value in t["inputs"].items():
            m = REF.match(value) if isinstance(value, str) else None
            if m:
                src, field = m.groups()
                if src not in t["deps"]:
                    errors.append(f'{t["id"]}.{name}: reads {src} but does not depend on it')
                elif src in tasks and field not in tasks[src]["outputs"]:
                    errors.append(f'{t["id"]}.{name}: {src} never produces "{field}"')
            elif name == "order_id" and value != request["order_id"]:
                errors.append(f'{t["id"]}: order_id {value} is not the order in the request')

    errors += find_cycle(tasks)
    return errors


def find_cycle(tasks):
    """Kahn's algorithm: tasks left unprocessed are on, or behind, a cycle."""
    indegree = {i: sum(d in tasks for d in t["deps"]) for i, t in tasks.items()}
    children = {i: [] for i in tasks}
    for i, t in tasks.items():
        for d in t["deps"]:
            if d in tasks:
                children[d].append(i)
    queue = deque(i for i, n in indegree.items() if n == 0)
    seen = 0
    while queue:
        node = queue.popleft()
        seen += 1
        for child in children[node]:
            indegree[child] -= 1
            if indegree[child] == 0:
                queue.append(child)
    if seen == len(tasks):
        return []
    stuck = sorted(i for i, n in indegree.items() if n > 0)
    return [f"cycle among or behind: {', '.join(stuck)}"]

Run against a deliberately broken plan this reports: wrong order_id, a missing dependency, an output that is never produced, an ungranted scope, an unknown tool, a read without a dependency, and a cycle. The scope check uses the run specification's granted scopes, not anything in the plan.

Cycle detection deserves its own note because a cycle is easy for a model to produce when it writes dependencies loosely (for example "decide needs the reply, and the reply needs the decision"). Kahn's algorithm repeatedly removes tasks that have no unmet prerequisites; if tasks remain, those tasks are on a cycle or wait behind one, and the plan is rejected with a message that can be fed back to the planner for repair. A rejected plan is regenerated a bounded number of times (two or three), after which the run fails closed or escalates to a template.

Validation proves a plan is well-formed and permitted. It does not prove it is a good plan: a valid graph can still omit the refund-status check, which is why evaluation (below) measures plans against outcomes.

Who Plans and Who Executes

The most important boundary in this chapter is between planning and execution. The model is good at the first and unreliable at the second. Planning is a judgement about structure: which steps, which tools, in what dependency order. Execution is bookkeeping with consequences: dispatching calls, bounding concurrency, enforcing timeouts, retrying safely, recording results, authorizing writes. The agent runtime owns execution, as described in Agent Components. The LLM inference call returns text; it cannot hold a scheduler's state across a crash.

Concretely, the model proposes a task graph or a patch to it. The runtime validates the proposal, stores it as a versioned plan, runs ready tasks, and feeds task results back to the model only when a decision is needed (the decide_eligibility task) or when an observation threatens the plan. Tool outputs that come back are observations: data to reason over, never instructions to follow, even if a document in the policy search says "ignore the approval step".

Division of labour between model and runtimeThe model proposes a plan or patch; the runtime validates it, stores a versioned plan, schedules ready tasks, executes them through tools, records results in the checkpoint store, and returns observations to the model only when a decision or replan is needed.Modelproposes plan / patchValidatorstructure, scopes, IDsSchedulerready set, concurrencyExecutortools, retries, authCheckpoint storeplan version, outputsObservationdata, not instructionsproposalvalid plandispatchresultpersistnext readyreplan trigger
Division of labour between model and runtime. The model proposes a plan or patch; the runtime validates it, stores a versioned plan, schedules ready tasks, executes them through tools, records results in the checkpoint store, and returns observations to the model only when a decision or replan is needed.

A DAG Scheduler, Step by Step

The scheduler is a loop over three questions: which tasks are ready, how many may run now, and what to do with each result. The version below is illustrative pseudocode in Python style, with the helper functions left abstract; the structure is what matters.

DAG scheduler (illustrative pseudocode)
async def run_plan(run, store, max_parallel=3):
    plan = store.load_plan(run.id)
    running = {}                                   # task_id -> asyncio.Task

    while not plan.all_terminal():
        # 1. ready-task selection: pending, and every dependency done or skipped
        ready = [t for t in plan.tasks
                 if t.status == "pending"
                 and all(plan.status(d) in ("done", "skipped") for d in t.deps)]

        # skip tasks whose 'when' condition is false (counts as satisfied)
        for t in list(ready):
            if t.when and not evaluate(t.when, plan.outputs()):
                store.mark(t, "skipped"); ready.remove(t)

        # prefer tasks on the longest remaining chain, so slack tasks wait
        ready.sort(key=lambda t: -plan.remaining_path_ms(t))

        # 2. bounded concurrency
        for t in ready:
            if len(running) >= max_parallel:
                break
            if t.risk in ("write", "approval") and not authorized(run, t):
                store.mark(t, "failed", reason="not_authorized"); continue
            store.mark(t, "running")               # checkpoint BEFORE dispatch
            running[t.id] = asyncio.create_task(execute(run, t))

        if not running:
            raise Deadlock(plan)                   # pending tasks, nothing runnable

        done, _ = await asyncio.wait(running.values(),
                                     return_when=asyncio.FIRST_COMPLETED)
        for fut in done:
            t = plan.task_for(fut); del running[t.id]
            outcome = fut.result()                 # Result | Failure | Unknown

            # 3. failure policy
            if outcome.is_failure:
                if outcome.retryable and t.attempts < policy_for(t).max_attempts:
                    store.requeue(t, backoff=policy_for(t).backoff(t.attempts)); continue
                match t.on_failure:                # declared per task
                    case "fail_plan":       return finish(run, "failed", t)
                    case "skip_dependents": plan.skip_downstream(t)
                    case "replan":          await replan(run, plan, reason=outcome)
            elif outcome.is_unknown:               # e.g. write timed out
                outcome = await reconcile(run, t)  # look up by idempotency key

            # 4. checkpoint: task state + output + plan version in ONE transaction
            store.commit(t, outcome)

            # 5. replanning trigger: observation contradicts a plan assumption
            if invalidates_assumption(plan, t, outcome):
                await replan(run, plan, reason=outcome)
    return finish(run, "done")

Reads and writes use different failure paths: a failed read is retried with backoff; a write whose outcome is unknown is reconciled, never blindly re-sent.

The figure below steps plan v1 through that loop, using the latencies from the parallelism section.

Scheduler state after each completionStep 1: running t1, t2; done none. Grey tasks are pending, indigo are running, green are done.t1 get_orderrunningt3 pay_statuspendingt4 dup_checkpendingt5 decidependingt2 policyrunningt6 approvalpendingt7 issue_refundpendingt8 draft_replypending
Scheduler state after each completion. Step 1: running t1, t2; done none. Grey tasks are pending, indigo are running, green are done.
Step 1 of 8: Start (0 ms)

Four design decisions in that code are worth defending. First, ready-task selection is computed from persisted status, not from memory, which is what makes the same loop usable after a restart. Ordering ready tasks by remaining path length is a simple critical-path heuristic: when the concurrency limit is smaller than the number of ready tasks, you start the ones that would otherwise delay the finish, as the 80 ms slack on t2 showed.

Second, bounded concurrency protects downstream systems and cost. The limit is a runtime parameter (max_parallel in the run specification), not something the model chooses. Third, failure policy is declared per task, because the right answer differs. Failing to fetch the policy is retryable and critical; failing a "nice-to-have" shipping lookup might be skippable; failing a refund write must never trigger an automatic second write. Fourth, the commit happens after every task, so progress is durable at task granularity.

Retries and writes
A timeout on payments.issue_refund leaves three possibilities: the request never arrived, it arrived and failed, or it succeeded and the response was lost. The runtime cannot tell which from the timeout. So it re-queries by the task's idempotency key (refund:req-8472:t7) or reconciles with a read tool before deciding, and passes the same key on any resend so the payment system deduplicates. See Production Agent Architecture for the durable-execution view.

Checkpointing and Partial Completion

An agent run can last seconds, or days if it waits for an approval. Processes crash, deploys roll, and workers are rescheduled. Without durable state, a restart means starting over, which for a graph with a refund write means possibly refunding twice. The checkpoint is the plan itself plus enough state to continue: task statuses, task outputs, the approval record, the plan version, and attempt counts.

Checkpoint snapshot (illustrative JSON), taken while t9 is in flight
{
  "run_id": "run-8472-a", "request_id": "req-8472", "plan_version": 2,
  "tasks": {
    "t1": { "status": "done",        "attempts": 1, "output_ref": "obj://out/t1.json" },
    "t2": { "status": "done",        "attempts": 1, "output_ref": "obj://out/t2.json" },
    "t3": { "status": "done",        "attempts": 1, "output_ref": "obj://out/t3.json" },
    "t4": { "status": "done",        "attempts": 1, "output_ref": "obj://out/t4.json" },
    "t9": { "status": "running",     "attempts": 1, "started_at": "2026-10-28T11:20:04Z" },
    "t5": { "status": "pending",     "waiting_on": ["t9"] },
    "t6": { "status": "invalidated", "reason": "refund already issued (obs: t3)" },
    "t7": { "status": "invalidated", "reason": "refund already issued (obs: t3)" },
    "t8": { "status": "pending",     "waiting_on": ["t5"] }
  },
  "approvals": [],
  "side_effects": []
}

Task outputs are stored by reference so the snapshot stays small. 'side_effects' lists writes with their idempotency key and result; here nothing has been written.

On resume, the runtime reloads this snapshot and rebuilds the ready set. Tasks marked done are not re-executed; their stored outputs feed the downstream tasks. A task found in running is the interesting case, because the crash may have happened mid-call. The rule depends on risk: a read is simply re-run, since repeating it has no side effect; a write is reconciled first. The runtime asks the payments system whether a refund with key refund:req-8472:t7 exists, and if so records its result and marks the task done without calling again.

Two details matter. The status change to running is written before dispatch (as in the pseudocode), so after a crash the runtime knows that a call might be outstanding; the result and status change to done are committed together, so a recorded output never lacks its status. And approvals are persisted with the exact action they approved (here, none yet). If a run waits two days for a human, resumes, and the plan has since changed, the approval is revalidated against the current proposal rather than assumed to still apply.

Replanning When an Observation Breaks the Plan

Plan v1 assumes that no refund exists yet. That assumption is checked by task t3, and here is the run where it fails. The payment system returns:

Observation from t3 (illustrative tool result, trace tr-8472-03)
{
  "order_id": "8472",
  "captures": [
    { "capture_id": "cap-9001", "amount_inr": 2000, "status": "SETTLED" },
    { "capture_id": "cap-9002", "amount_inr": 2000, "status": "SETTLED" }
  ],
  "refunds": [
    { "refund_id": "rf-5531", "capture_id": "cap-9002", "amount_inr": 2000,
      "status": "ISSUED", "settlement": "PENDING_WITH_ACQUIRER" }
  ]
}

Two settled captures confirm the duplicate. But a refund of ₹2,000 against the second capture is already ISSUED, with settlement still pending.

Two facts coexist. The duplicate is real (t4 will confirm it), and the customer's complaint that "my refund has not arrived" is likely about settlement, not issuance. Plan v1 is now wrong in a specific way: t7 issue_refund would send a second ₹2,000 refund for the same duplicate charge, and t6 would ask a human to approve an action that is no longer needed. Running the plan on the grounds that "it was already authored" would be a harmful bug, and so would restarting from scratch and losing the facts already gathered.

The trigger for replanning is mechanical: invalidates_assumption in the scheduler matches the observation against declared assumptions of pending tasks (here, the declared precondition of t7 is "no ISSUED refund exists for the duplicate capture"). The model is then asked for a patch, not a whole new plan, so completed work stays valid:

Plan patch proposed by the model (illustrative JSON), validated before use
{
  "base_version": 1,
  "reason": "t3 shows refund rf-5531 already ISSUED for cap-9002 (settlement pending); a second refund would duplicate it",
  "evidence": ["tr-8472-03"],
  "invalidate": ["t6", "t7"],
  "add": [
    { "id": "t9", "name": "trace_settlement", "executor": "tool", "tool": "payments.settlement_trace", "risk": "read",
      "deps": ["t3"], "inputs": { "refunds": "$t3.refunds" },
      "outputs": ["settlement_trace"], "status": "pending" }
  ],
  "update": [
    { "id": "t8", "deps": ["t5"], "inputs": { "decision": "$t5.decision" } },
    { "id": "t5", "name": "decide_next_action", "deps": ["t2", "t4", "t9"],
      "inputs": { "policy": "$t2.policy_passages", "finding": "$t4.finding", "trace": "$t9.settlement_trace" } }
  ]
}

The patch invalidates the approval and write tasks, adds a read-only settlement trace, re-scopes the decision to consume it, and detaches the reply from the invalidated write. Nothing in the patch grants a new scope: payments.settlement_trace needs payments:read, already in the run specification.

Before replanning: plan v1 downstream of t3Plan v1 after t3 returns: the remaining tasks t4, t5, t6, t7, t8 are pending and lead toward issuing a refund.t3 pay_statusdonet4 dup_checkpendingt5 decidependingt6 approvalpendingt7 issue_refundpendingt8 draft_replypending
Before replanning: plan v1 downstream of t3. Plan v1 after t3 returns: the remaining tasks t4, t5, t6, t7, t8 are pending and lead toward issuing a refund.
After replanning: plan v2Plan v2: t3 feeds both the duplicate check t4 and the new settlement trace t9. The revised decision t5 consumes both and leads directly to the reply t8. Approval t6 and issue_refund t7 are invalidated and drawn dashed, with no path to the reply.t3 pay_statusdonet4 dup_checkpendingt9 settle_traceNEW, readt5 decide_nextrevisedt8 draft_replypendingt6 approvalinvalidatedt7 issue_refundinvalidated
After replanning: plan v2. Plan v2: t3 feeds both the duplicate check t4 and the new settlement trace t9. The revised decision t5 consumes both and leads directly to the reply t8. Approval t6 and issue_refund t7 are invalidated and drawn dashed, with no path to the reply.

The rationale is recorded with the patch (reason, evidence pointing at the trace), so an auditor can see why the graph changed and which observation caused it. The invalidated tasks are not deleted: their history stays visible, and the runtime refuses to run them. Tasks t1–t3 keep their outputs. The validator runs on the patched plan like any other: it confirms there is no cycle, t9 reads only from t3's declared outputs, and the new task does not need a scope the run lacks. What happens next is an ordinary run of v2: trace the settlement, decide whether the right response is to wait for settlement, escalate with the trace to a payments specialist, or explain the pending status to the customer, then draft a reply that states only what the tools returned.

Notice what replanning preserved. The invalidation of t7 removes a possible duplicate refund, which is a safety outcome and not just an optimisation. Notice too what the planner could not do: it could not have discovered the existing refund by reading policy documents; only the payments lookup knew. This is the general reason plans need observations, and why the agent that "plans everything up front and then executes" is brittle.

Uncertainty, Branching, and Why a Checklist Is Not Enough

A static checklist assumes the world matches the author's expectations. Real environments produce evidence gaps and surprises, and the plan needs a way to represent them without becoming a free-for-all. There are four recurring forms.

Conditional steps. The approval task t6 exists only if the decided refund exceeds ₹1,000. A when expression evaluated by the runtime on real task output decides, so the branch is deterministic once its input is known. Prefer conditions the runtime can evaluate over conditions described in English for the model to judge.

Clarification. If the request is ambiguous (the customer has two orders that match "my order"), the correct next task is a question to the customer, not a guess. Model it as a human-owned task whose output is the missing ID; downstream tasks wait on it. The plan then makes explicit that it is blocked on information, which is better than a model fabricating the most likely ID.

Evidence gaps. If the policy search returns passages that conflict, such as POL-REF-004 v4 and a superseded v3, the decision task should not silently pick one. A branch can request a narrower retrieval, or route to a human with both passages attached (the retrieval concerns are covered in the retrieval pipeline lesson). A grounded model output is still only as good as its evidence; a lookup that returns nothing is a gap, not a reason to hallucinate an answer (see hallucinations).

Failure-driven branches. If t9 fails because the settlement service is unavailable, the plan should have a declared fallback, for example "escalate to a human with the facts so far" rather than "retry forever" or "reply as if settlement were fine". A failure branch is a normal edge in the graph with a condition on a task's status, not an exception that kills the run.

A checklist handles none of these. It has no way to say that the item after step 4 depends on what step 4 found, that step 6 may not apply, or that step 7 is now harmful. The graph with conditions, patches and statuses is the minimum structure that does. It is still not a prediction of the future; it is a plan that expects to be revised.

Judging Whether a Plan Is Good

A plan can pass validation and still be poor, so plans are evaluated like any other component, using recorded runs. The useful measures fall into two groups. The first group checks the plan as an artifact: feasibility (every task can be executed with the tools and scopes available), completeness (together the tasks cover every success criterion; the missing refund-status lookup would fail this), minimality (no tasks that contribute to no criterion; fetching the customer's full order history to answer a single-order question would be wasted calls), and dependency correctness (no missing edges that allow a task to run on unavailable data, and no extra edges that needlessly serialize independent work and lengthen the critical path).

The second group checks the outcome: whether the task was actually resolved correctly, how many unnecessary tool calls were made, how many times the plan was revised and why, the cost in model calls and tool calls, and time to completion split into machine latency and human wait. Revision counts are informative in both directions: zero replans on every run suggests the observations are not being checked; many replans on simple requests suggests the initial decomposition is poor. Compare model-generated plans against the template for the same request class on a fixed set of test cases before trusting them. Do not assume a flexible planner beats a template; measure it.

Per-run plan scorecard (illustrative values for run-8472-a)
{
  "run_id": "run-8472-a",
  "plan_versions": 2, "replan_reason": "existing refund observed (tr-8472-03)",
  "tasks_planned_v1": 8, "tasks_invalidated": 2, "tasks_added": 1, "tasks_done": 7,
  "tool_calls": { "read": 4, "write": 0 },
  "llm_calls": { "plan": 1, "replan_patch": 1, "decide": 1, "draft": 1 },
  "duplicate_writes_prevented": 1,
  "outcome": "resolved_with_explanation",
  "human_wait_s": 0
}

Seven done tasks are t1, t2, t3, t4, t9, t5 and t8; t6 and t7 were invalidated; 8 − 2 + 1 = 7. Four read calls are t1, t2, t3 and t9 (t4 is runtime code, t5 and t8 are LLM calls).

When Not to Use Model-Generated Plans

Everything above can be done with a plan that no model ever wrote. If the business process is known and stable, such as "every duplicate-charge claim follows this nine-step procedure", then an explicit workflow, a fixed DAG in code or in a workflow engine, is simpler, cheaper, faster, and easier to test and audit. It has no planning failure modes: no invented tools, no omitted approval, no cycles that appear at runtime. Compliance reviewers can read the diagram and sign it off once.

A model earns its place at the edges: reading free-form text to classify the request, extracting the order ID, deciding between a small number of branches given evidence, and drafting the reply. In many refund systems the right design is a deterministic workflow with model calls as nodes, which is the t5 and t8 tasks in the graph above, instead of a model that writes the workflow. Reach for model-generated plans when requests really are open-ended (diagnosis, research, investigation with unknown tools) and the cost of an occasional bad plan is bounded by validation, scopes, and approvals. The more a step has irreversible side effects, the stronger the case for pinning it in code. The What Is an Agent? lesson draws the same line between autonomy and orchestration.

Key Takeaways

  1. 1

    Pin the problem down before decomposing: a run specification with permissions, limits and constraints comes first. A plan that names a tool or scope outside the grant is rejected before it runs.

  2. 2

    Choose the decomposition source by risk: a template for known request classes, model-generated graphs when requests vary too much, hierarchical expansion for large goals, rolling-horizon planning when observations change what comes next. A refund agent usually combines them.

  3. 3

    Represent the plan as a DAG of data: each node carries its tool, inputs, dependencies and approval and idempotency requirements, so it can be validated, stored, diffed and reviewed.

  4. 4

    Concurrency only removes time that is off the critical path. Find the critical path before adding workers, and cap parallelism with a runtime limit rather than a prompt instruction.

  5. 5

    A model-proposed plan is untrusted input: validate structure, authorisation, resource IDs, data contracts and policy shape before the first task and after every replan. Validity does not prove the plan is good.

  6. 6

    Replan from a mechanical trigger (an observation that invalidates a declared assumption), record the reason with the patch, and keep status and outputs durable so a crash resumes instead of re-running writes.

Where the Plan Lives in a Complete Agent

You now have the pieces of a planning subsystem: a run specification that bounds the work, a decomposition method chosen for the task's risk, a validated DAG with versioned revisions, a scheduler with bounded concurrency and per-task failure policy, a checkpoint store that makes resume safe, and a replanning trigger driven by observations. None of them is useful alone. The task graph has to be fed by retrieval, executed through the tool boundary, gated by approval and policy, and surrounded by the memory, state and verification components that turn a graph into a response.

When one agent has to do all of that, the questions become architectural. Where does the planner sit relative to the policy layer? Which component owns the checkpoint? How does verification of the final reply connect to success criteria? If the work should be split across several specialised agents, the planning problem changes again: a task graph then also assigns tasks to agents and has to coordinate their results (see Multi-Agent Systems). Next, the single-agent architecture lesson assembles these parts into one runtime.

Up Next · AI Agents

Single-Agent Architecture

See how one runtime combines policy, planning, tools, retrieval, memory, verification, and response generation into a complete agent system.