Planning

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page’s examples target Python 3.12 with LangChain 1.4.2 / langchain-core 1.6.5 / LangGraph 1.2.12 and langchain-ollama 1.1.0, Java 21 with LangChain4j 1.20.2 (langchain4j-agentic 1.20.2-beta30), and Spring Boot 4.1 with Spring AI 2.0.1, using DocsAssistant's Ollama llama3.1:8b as the executor model and Ollama qwen3:8b (thinking-capable) as the planner, as checked on 2026-09-28 (see the AI Agents baseline).

A plan is the agent’s answer to "what will I do, in what order, to reach the goal?". Every architecture on Agent Loop & Architectures plans somehow — ReAct one step at a time, plan-and-execute all at once. This page is about the planning itself: how much to plan up front, how to recover when a plan meets reality, a classic non-LLM planning structure (behaviour trees) that pairs well with LLMs, and how planning fails.

Sequential and stepwise planning

Sequential (plan up front) Stepwise (plan as you go)

How

One call writes the full plan; an executor runs it

Each step decides only the next action, from the latest observation

Architecture

Plan-and-execute, orchestrator-workers

ReAct

Strengths

Focus on long tasks; the plan is inspectable and approvable before anything runs; cheap executors

Adapts immediately to surprises; no wasted planning for short tasks

Weaknesses

A wrong early assumption propagates; brittle when the environment changes

Myopic: can wander, repeat itself or lose the goal on long tasks

Choose when

The task’s shape is knowable in advance, or a human must approve the plan

The next step genuinely depends on the last result

Most production agents sit in between: plan coarsely up front, execute each step with a small ReAct loop, and re-plan at checkpoints. Where planning, reasoning and feedback live depends on the application: a chat assistant plans implicitly, turn by turn; a research agent plans explicitly and revisits the plan each round; a workflow fixes the plan in code and lets the model fill in only the steps (see Workflow Patterns).

Re-planning on failure

A plan is a hypothesis. Re-planning makes the agent check it after each step: continue with the remaining steps, replan (replace the remaining steps in light of what happened), or respond because the goal is already met. Two rules keep this from becoming an infinite loop: a re-plan budget, and a record of every step’s outcome that the reviewer reads, so a failed approach is not tried again.

flowchart LR Q[Question] --> P[Plan] P --> X[Execute next step] X --> R{Review} R -->|continue| X R -->|replan: new steps| X R -->|respond or budget spent| A([Answer])
Plan, execute, review: re-planning with a budget
import operator
from typing import Annotated, Literal, TypedDict

from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel

from docsassistant.search import search_docs

planner_llm = ChatOllama(model="qwen3:8b", reasoning=True, temperature=0)   # a thinking model plans
worker_llm = ChatOllama(model="llama3.1:8b", temperature=0)                 # a small model executes
MAX_REPLANS = 2


class Plan(BaseModel):
    steps: list[str]


class Replan(BaseModel):
    action: Literal["continue", "replan", "respond"]
    new_steps: list[str] = []          # only for "replan"
    reason: str


class ReplanState(TypedDict):
    question: str
    plan: list[str]
    done: Annotated[list[str], operator.add]    # "step -> outcome" records
    replans: int
    answer: str


def plan(state: ReplanState) -> dict:
    p = planner_llm.with_structured_output(Plan).invoke(
        f"Plan documentation lookups (max 4) to answer: {state['question']}")
    return {"plan": p.steps or [state["question"]], "replans": 0}   # never an empty plan


def execute(state: ReplanState) -> dict:
    step, *rest = state["plan"]
    chunks = search_docs(step, k=3)
    outcome = "NO RESULTS" if not chunks else worker_llm.invoke(f"{step}\n{chunks}").content
    return {"plan": rest, "done": [f"{step} -> {outcome[:300]}"]}


def review(state: ReplanState) -> dict:
    decision = planner_llm.with_structured_output(Replan).invoke(
        f"Question: {state['question']}\nDone so far:\n" + "\n".join(state["done"]) +
        f"\nRemaining plan: {state['plan']}\n"
        "Continue, replace the remaining plan (replan), or respond now?")
    if decision.action == "replan" and decision.new_steps and state["replans"] < MAX_REPLANS:
        return {"plan": decision.new_steps, "replans": state["replans"] + 1}
    if decision.action == "continue" and state["plan"]:
        return {}
    answer = worker_llm.invoke("Answer from these findings:\n" + "\n".join(state["done"]))
    return {"plan": [], "answer": answer.content}


def next_node(state: ReplanState) -> str:
    return END if state.get("answer") else "execute"


builder = StateGraph(ReplanState)
builder.add_node("plan", plan)
builder.add_node("execute", execute)
builder.add_node("review", review)
builder.add_edge(START, "plan")
builder.add_edge("plan", "execute")
builder.add_edge("execute", "review")
builder.add_conditional_edges("review", next_node, ["execute", END])
replanner = builder.compile()

The planner and reviewer use a thinking model; the executor uses the small model. done accumulates step → outcome records through its operator.add reducer, so the reviewer always sees what was tried, and an empty search is recorded explicitly as NO RESULTS rather than silently skipped. execute is only ever reached with at least one step: an empty initial plan falls back to searching the question itself, and a replan with no new_steps is treated as respond. See LangGraph v1 release notes and LangChain — Structured output.

In LangChain4j’s agentic module, a re-planning loop is what the supervisorBuilder() agent does internally: after every sub-agent invocation, its LLM planner looks at the results in the AgenticScope and chooses the next agent or declares the task done, within maxAgentsInvocations(…​). For an algorithmic planner, implement the Planner interface (firstAction / nextAction, each returning agents to call(…​) or done()) and register it with AgenticServices.plannerBuilder():

LangChain4j: a custom Planner that reviews after every step, with a budget
import java.util.List;

import dev.langchain4j.agentic.AgenticServices;
import dev.langchain4j.agentic.UntypedAgent;
import dev.langchain4j.agentic.planner.Action;
import dev.langchain4j.agentic.planner.AgentInstance;
import dev.langchain4j.agentic.planner.InitPlanningContext;
import dev.langchain4j.agentic.planner.Planner;
import dev.langchain4j.agentic.planner.PlanningContext;

public class ReviewingPlanner implements Planner {

    private static final int MAX_ROUNDS = 3;                  // the re-plan budget
    private AgentInstance searcher, reviewer, answerer;
    private int rounds = 0;

    @Override
    public void init(InitPlanningContext context) {
        List<AgentInstance> agents = context.subagents();     // in subAgents(...) order
        searcher = agents.get(0);
        reviewer = agents.get(1);
        answerer = agents.get(2);
    }

    @Override
    public Action firstAction(PlanningContext context) {
        return call(searcher);
    }

    @Override
    public Action nextAction(PlanningContext context) {
        String last = context.previousAgentInvocation().agentName();
        if (last.equals(searcher.name())) {
            return call(reviewer);                            // review after every step
        }
        if (last.equals(reviewer.name())) {
            String decision = context.agenticScope().readState("decision", "respond");
            boolean more = decision.equals("continue") && ++rounds < MAX_ROUNDS;
            return more ? call(searcher) : call(answerer);
        }
        return done();                                        // the answerer has written "answer"
    }
}

UntypedAgent replanner = AgenticServices.plannerBuilder()
        .subAgents(docsSearcher, stepReviewer, docsAnswerer)  // @Agent interfaces from agentBuilder(...)
        .outputKey("answer")
        .planner(ReviewingPlanner::new)                       // a fresh planner (and budget) per run
        .build();

The reviewer agent writes continue or respond to the decision key of the AgenticScope, and the searcher reads the reviewer’s notes from the same scope, so every round sees what was already tried. See LangChain4j — Agents and Agentic AI (custom agentic patterns).

Spring AI has no planner abstraction; the Python loop above becomes a few ChatClient calls with .entity(…​) for the plan and review records:

Spring AI: plan, execute, review with ChatClient
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;

public class Replanner {

    record Plan(List<String> steps) {}
    record Review(String action, List<String> newSteps, String reason) {}   // continue | replan | respond

    private static final int MAX_REPLANS = 2;
    private final ChatClient planner;                         // qwen3:8b with thinking
    private final ChatClient worker;                          // llama3.1:8b
    private final VectorStore docs;

    public Replanner(ChatClient planner, ChatClient worker, VectorStore docs) {
        this.planner = planner;
        this.worker = worker;
        this.docs = docs;
    }

    public String answer(String question) {
        Deque<String> plan = new ArrayDeque<>(planner.prompt()
                .user("Plan documentation lookups (max 4) to answer: " + question)
                .call().entity(Plan.class).steps());
        List<String> done = new ArrayList<>();
        int replans = 0;
        while (!plan.isEmpty()) {                             // an empty plan goes straight to the answer
            String step = plan.removeFirst();
            List<Document> chunks = docs.similaritySearch(SearchRequest.builder().query(step).topK(3).build());
            String outcome = chunks.isEmpty() ? "NO RESULTS"
                    : worker.prompt().user(step + "\n" + chunks).call().content();
            done.add(step + " -> " + outcome);
            Review review = planner.prompt()
                    .user("Question: " + question + "\nDone so far:\n" + String.join("\n", done)
                            + "\nRemaining plan: " + plan
                            + "\nContinue, replace the remaining plan (replan), or respond now?")
                    .call().entity(Review.class);
            if (review.action().equals("replan") && review.newSteps() != null && replans < MAX_REPLANS) {
                plan = new ArrayDeque<>(review.newSteps());
                replans++;
            } else if (!review.action().equals("continue")) {
                break;                                        // respond, or the re-plan budget is spent
            }
        }
        return worker.prompt().user("Answer from these findings:\n" + String.join("\n", done)).call().content();
    }
}

As in the Python version, an empty plan or an empty replan never reaches a step: the while condition sends it straight to the answer. See Spring AI — ChatClient for .entity(…​).

Behaviour trees

A behaviour tree (BT) is a planning structure from game AI and robotics: a tree whose leaves do work and whose inner nodes decide which leaves run. Every node, when ticked, returns one of three statuses — SUCCESS, FAILURE or RUNNING — and four node types cover most trees:

  • Sequence (→) — ticks its children left to right and stops at the first one that does not succeed: a logical AND ("check the evidence, then get approval, then file the issue").

  • Selector or fallback (?) — ticks its children left to right and stops at the first one that does not fail: a logical OR, ordered by preference ("if it is already reported, say so; otherwise report it").

  • Condition — checks the world or the blackboard and returns SUCCESS or FAILURE; never changes anything.

  • Action — does work and returns SUCCESS, FAILURE, or RUNNING if it has not finished yet (waiting for a human, a long tool call).

Tick semantics. The runtime ticks the root repeatedly — on a timer, or whenever something changes. Each tick walks the tree from the root, so a tree re-evaluates its conditions every time and reacts when the world changes: if someone else files the issue while DocsAssistant waits for approval, the next tick’s left branch succeeds and the pending action is never reached. RUNNING propagates upward, so the whole tree reports "in progress" while one action waits. Shared data lives on a blackboard (a dictionary every node can read and write).

A behaviour tree whose root selector has two sequences: the left one checks whether an open issue already exists and, if so, fetches the issue link and replies with it; the right one checks whether evidence was found, gets approval (which can return RUNNING), and opens a GitHub issue. A legend explains selector, sequence, condition and action nodes and the SUCCESS, FAILURE and RUNNING statuses.
Figure 1. A behaviour tree for "report a wrong page"

The whole runtime fits in a few classes:

A minimal behaviour-tree runtime
from enum import Enum
from typing import Callable


class Status(Enum):
    SUCCESS = "success"
    FAILURE = "failure"
    RUNNING = "running"


class Node:
    def tick(self, bb: dict) -> Status: ...


class Condition(Node):
    def __init__(self, check: Callable[[dict], bool]):
        self.check = check

    def tick(self, bb):
        return Status.SUCCESS if self.check(bb) else Status.FAILURE


class Action(Node):
    def __init__(self, run: Callable[[dict], Status]):
        self.run = run

    def tick(self, bb):
        return self.run(bb)


class Sequence(Node):                       # AND: stop at the first child that does not succeed
    def __init__(self, *children: Node):
        self.children = children

    def tick(self, bb):
        for child in self.children:
            status = child.tick(bb)
            if status is not Status.SUCCESS:
                return status
        return Status.SUCCESS


class Selector(Node):                       # OR: stop at the first child that does not fail
    def __init__(self, *children: Node):
        self.children = children

    def tick(self, bb):
        for child in self.children:
            status = child.tick(bb)
            if status is not Status.FAILURE:
                return status
        return Status.FAILURE

This is a teaching subset of what a behaviour-tree library provides; for a maintained Python implementation with the same statuses, Sequence and Selector composites, and a blackboard, see py_trees documentation.

Agentic behaviour trees

An agentic behaviour tree keeps the tree’s structure fixed and deterministic but lets LLM calls sit at the leaves: a condition can be a model’s judgement, and an action can be a tool call or a whole sub-agent. The tree provides what LLM agents lack — a guaranteed, inspectable control flow, explicit fallbacks, and reactivity — while the model provides judgement where rules cannot.

DocsAssistant’s "report a wrong page" tree
from langchain_ollama import ChatOllama
from pydantic import BaseModel

from docsassistant.issues import find_open_issue, open_issue   # wraps github_open_issue
from docsassistant.search import search_docs

llm = ChatOllama(model="llama3.1:8b", temperature=0)


class Evidence(BaseModel):
    contradicts: bool
    source_url: str


def issue_exists(bb) -> bool:
    bb["existing"] = find_open_issue(bb["page"])
    return bb["existing"] is not None


def reply_with_link(bb) -> Status:
    bb["reply"] = f"Already reported: {bb['existing']}"
    return Status.SUCCESS


def evidence_found(bb) -> bool:                  # an agentic condition: an LLM judges the evidence
    if "contradicts" not in bb:                  # cached on the blackboard, so re-ticks are free
        chunks = search_docs(bb["claim"], k=5)
        verdict = llm.with_structured_output(Evidence).invoke(
            f"Does official documentation contradict this page claim? {bb['claim']}\n{chunks}")
        bb["evidence"], bb["contradicts"] = verdict.source_url, verdict.contradicts
    return bb["contradicts"]


def get_approval(bb) -> Status:                  # RUNNING until a human decides
    decision = bb.get("approval")                # written by the UI when the reviewer clicks
    if decision is None:
        return Status.RUNNING
    return Status.SUCCESS if decision == "approve" else Status.FAILURE


def file_issue(bb) -> Status:
    bb["reply"] = open_issue(bb["page"], bb["claim"], bb["evidence"])
    return Status.SUCCESS


report_wrong_page = Selector(
    Sequence(Condition(issue_exists), Action(reply_with_link)),
    Sequence(Condition(evidence_found), Action(get_approval), Action(file_issue)),
)

blackboard = {"page": "modules/ROOT/pages/ai/agents/memory.adoc",
              "claim": "MessageWindowChatMemory keeps 10 messages by default"}
status = report_wrong_page.tick(blackboard)      # RUNNING: waiting for approval
blackboard["approval"] = "approve"
status = report_wrong_page.tick(blackboard)      # re-tick from the root: SUCCESS

See LangChain — Structured output for the Evidence verdict, and py_trees documentation for ticking a tree repeatedly and sharing state through a blackboard.

The approval action returns RUNNING until the reviewer decides, so the tree simply reports "in progress" and is ticked again later — no special interrupt mechanism is needed. The LLM condition caches its verdict on the blackboard so later ticks do not repeat the call. docsassistant.issues wraps the least-privilege github_open_issue tool from Tool Design.

Back-chaining

Back-chaining is how behaviour trees are designed: start from the goal and work backwards. Write the goal as a condition ("an issue exists for this page"). If it can be false, replace it with a selector: the condition itself first, then a sequence of an action that achieves it, preceded by that action’s own preconditions ("evidence found", "approved"). Repeat for each precondition that can fail. The result is a tree that tries the cheapest satisfied route first and only does work when the goal is not already met — which is also a good planning prompt for an LLM: "what must be true before this action can succeed?". LangChain4j’s goal-oriented agentic pattern applies the same backward search to agents: it treats each agent’s inputs as preconditions and its output as a postcondition, and computes the shortest chain of agents that produces the goal output (see LangChain4j — Agents and Agentic AI, goal-oriented agentic pattern).

Reasoning models as planners

Planning is where a reasoning model’s extra thinking pays for itself: a plan is short to write but expensive to get wrong, and every later step inherits its mistakes. A common split is a reasoning model for the planner and the reviewer and a small, fast model for the executor — qwen3:8b with thinking enabled and llama3.1:8b in the example above, or a hosted reasoning model such as claude-opus-5-5 or gpt-6-sol at higher effort when the plan matters more. Reasoning models also make the explicit "think step by step" planning prompt unnecessary; give them the goal, the tools and the constraints, and let them plan. The parameters (thinking budgets, effort levels) and when reasoning is worth its latency are covered in Reasoning Models.

If a plan of steps succeeds only when every step does, and each step independently succeeds with probability , the plan succeeds with probability : at , a 10-step plan succeeds only about 60% of the time ( ). That arithmetic is why shorter plans, re-planning checkpoints and better planners all matter more than they first appear.

Failure modes

Agent failures fall into three families, each with its own measurement (a framing paraphrased from one of the books in the bibliography):

  • Planning failures — the plan itself is wrong. The agent calls a tool that does not exist, calls a real tool with invalid parameters or wrong values, produces a plan that does not achieve the goal (or violates a constraint the user gave), or wrongly concludes that it is done. Measure: the share of plans that are valid (only real tools, valid arguments) and the share that achieve the goal.

  • Tool failures — the plan is right but a tool fails or returns the wrong thing: an API error, a retriever that misses the relevant chunk, or a translation step that turns the plan into the wrong tool call. Measure each tool in isolation, with its own test set.

  • Efficiency failures — the agent reaches the goal, but with too many steps, too much cost or too much latency. Measure: average steps, tokens and wall-clock time per task, against a baseline.

flowchart TD F[Agent failed] --> P{Was the plan valid and sufficient?} P -->|no| PF[Planning failure: fix prompts, tools list, planner model] P -->|yes| T{Did every tool return the right thing?} T -->|no| TF[Tool failure: fix or test the tool] T -->|yes| E[Efficiency failure: too many steps, cost or latency]

These families are what the trajectory and tool-call metrics on Agent Evaluation measure.

In other frameworks

Framework Planning support

OpenAI Agents SDK

No explicit planner; plan-and-execute is built by chaining agents (a planner agent whose output feeds executor agents), and reasoning models plan implicitly inside Runner.run.

Claude Agent SDK

A permission_mode of "plan" makes the agent plan without executing tools until the plan is approved.

Microsoft Agent Framework

The Magentic orchestration maintains an explicit task ledger (a plan) and re-plans when progress stalls.

CrewAI

planning=True on a Crew adds a planning step that writes a step-by-step plan for every task before the crew runs; Process.hierarchical adds a manager that re-delegates.