Home / Articles

Build a Planning Agent in LangGraph: Plan, Execute, Replan, Report

A four-node LangGraph planning agent for financial research: structured plans, a namespace-aware tool registry, replanning limits and a traced example run.

Publisher: REACTAPP.TOP16 min read3625 words

This article is published in English.

Sources: Your AI Agent Isn’t Smart. Here’s How to Build One That Actually Thinks

Most agents are prompt chains in disguise

Here's an uncomfortable fact about AI agents: many are glorified prompt chains in a trench coat. The recipe is familiar. A system prompt, a few tools and maybe a ReAct loop, and the result gets called an agent. It works in the demo and on the happy path. Then a tool returns an error, or the data looks odd, or the query needs more than three steps, and the whole thing falls apart.

The alternative is a planning agent. It drafts a full research plan before using any tool, works through that plan a step at a time, and when intermediate results contradict expectations it rewrites what's left. The demo is a financial research system. Give it a plain-English request like "Compare NVDA and AAPL's valuations and tell me which one looks better right now," and it builds a multi-step plan, runs every step against data tools, repairs itself when something breaks and writes an analyst-style report. It sits on LangGraph, works with cloud models via Groq or local ones via Ollama, and fits in roughly four files.

Why plain ReAct falls short

ReAct (Reason-Act-Observe) is the pattern most agents start from. The LLM reasons about its next move, calls a tool, reads what comes back, reasons again and calls the next tool. It's popular, easy and fine for small jobs.

The core weakness is greed, since a ReAct agent only plans one move ahead. Ask for five stocks across three metrics plus recent news and it just begins, say with NVDA's P/E ratio, and the first result pulls its attention sideways. One prototype burned seven tool calls on every Tesla metric before remembering the task compared several stocks, and its final report amounted to a Tesla earnings summary plus a single sentence on the others.

Model capability isn't the culprit here. The architecture is. If one inference pass must choose what to research, how to research it and what to make of the findings, none of those decisions gets proper focus. A planning agent splits thinking from doing: plan, then execute, then check the plan still holds. Every node handles a single job, and they run one after another.

Four nodes and one loop

The planning agent is a directed graph with four nodes, like a flowchart where each box handles one job and passes state along.

START --> Planner --> Executor <--> Replanner --> Reporter --> END
  1. The Planner takes the user's query and produces a structured, step-by-step research plan. Each step names the tool to call, the arguments and the reason.
  2. The Executor picks up the next step, calls the specified tool and records the result in a scratchpad.
  3. The Replanner reviews everything gathered so far after each execution. If a tool errored or the data shows something unexpected, it rewrites the remaining steps. Otherwise it leaves the plan alone.
  4. The Reporter turns all scratchpad entries into the closing analyst report, complete with concrete figures and recommendations.

What drives the design is the Executor-Replanner loop. The two nodes alternate until the plan is done, and that alternation produces the self-correcting behavior, because after each action the agent may reconsider.

State: the shared memory

In LangGraph, state is everything, the shared memory passed between nodes. The full definition:

import operator
from typing import Annotated, TypedDict

from pydantic import BaseModel, Field

class StrategyState(TypedDict, total=False):
    """Global state that flows through the LangGraph nodes."""
    query: str
    plan: list[dict]
    scratchpad: Annotated[list[dict], operator.add]
    current_step: int
    final_report: str
    replan_count: int

In this state, query holds the user's original request unchanged, plan is the ordered list of steps, scratchpad collects every tool result, current_step points into the plan, and replan_count backs the MAX_REPLANS safety cap.

The annotation Annotated[list[dict], operator.add] on the scratchpad matters. Its reducer, operator.add, instructs LangGraph to append new entries and leave existing ones in place. When the Executor returns {"scratchpad": [new_entry]}, LangGraph tacks it onto the current list, so nodes never handle lists by hand and mutation bugs don't arise.

Structured plans and decisions

The Planner never hands back free text. Its output is a Pydantic model, so the LLM has to produce plans in a shape code can parse:

AVAILABLE_TOOLS_TEXT = """
- get_metrics(ticker, metric?): Return stock metrics. 'metric' is optional
  (P/E, EPS, Revenue, Market Cap, Sector).
- search_news(ticker): Return recent news headlines for a ticker.
- compare_metrics(tickers: list, metric): Compare one metric across
  multiple tickers.
"""

class PlanStep(BaseModel):
    """A single executable step inside an analysis plan."""
    step_id: int = Field(description="Sequential step number")
    tool: str = Field(
        description=f"Tool to use. Must be one of:\n{AVAILABLE_TOOLS_TEXT}"
    )
    args: dict = Field(description="Arguments for the tool call")
    purpose: str = Field(description="Why this step is needed")

class Plan(BaseModel):
    """The full plan generated by the planner node."""
    goal: str = Field(description="The overall analysis goal")
    steps: list[PlanStep] = Field(
        description="Ordered list of steps to execute"
    )

The purpose field proves more valuable than expected. It tells the Replanner why a step exists and not just what it does. Reading "Compare P/E ratios to identify relative valuation" in place of a bare "call compare_metrics," the Replanner judges much better whether to keep, change or drop the step.

The Replanner has its own schema:

class ReplanDecision(BaseModel):
    """Output of the replanner node."""

reasoning: str = Field(
        description="Analysis of current progress and findings"
    )
    should_replan: bool = Field(
        description="Whether the plan needs modification"
    )
    updated_steps: list[PlanStep] = Field(
        default_factory=list,
        description="Remaining steps if replan is needed. Empty if no changes.",
    )

Making should_replan a boolean forces the LLM to commit one way or the other: is the plan changing or not? Leave an LLM wiggle room in structured output and it will wiggle every time.

The tool system

Three financial research tools run on realistic mock data. Production would use real API calls, but mock data has a big advantage while building: deterministic debugging. When the agent behaves strangely, it's immediately clear whether the fault lies in its logic or in an API.

A design pattern that deserves more attention is the namespace-aware tool registry, used instead of handing every node every tool:

from langchain_core.tools import BaseTool
from typing import Iterable, Mapping, Any

class ToolRegistry:
    """Namespace-aware container for LangChain tools."""
    def __init__(self) -> None:
        self._tools_by_toolset: dict[str, dict[str, BaseTool]] = {}
    def add_tools(self, toolset: str, tools: Iterable[BaseTool]) -> None:
        bucket = self._tools_by_toolset.setdefault(toolset, {})
        bucket.update({t.name: t for t in tools})
    def get_tools(self, toolset: str) -> tuple[BaseTool, ...]:
        return tuple(self._tools_by_toolset.get(toolset, {}).values())
    def invoke(
        self, toolset: str, tool_name: str, tool_args: Mapping[str, Any]
    ) -> Any:
        t = self._tools_by_toolset.get(toolset, {}).get(tool_name)
        if t is None:
            raise ValueError(
                f"Unknown tool '{tool_name}' in toolset '{toolset}'"
            )
        return t.invoke(dict(tool_args))

Registration happens under the namespaces "planner", "executor" and "replanner". All tools are visible to the Planner so it can plan with them, yet only the Executor can call them. Even if a Replanner prompt bug somehow emitted a tool call, the registry would refuse it because that namespace has no invoke rights. That's role-based access control for agents, and the separation of concerns doubles as a safety mechanism that blocks unwanted state changes while planning.

The Planner node

def planner_node(state: StrategyState) -> dict:
    """Create a step-by-step research plan using structured output."""
    planner = model.with_structured_output(Plan)

prompt = PLAN_PROMPT.format(
        available_tools=AVAILABLE_TOOLS_TEXT,
        ticker_choices=ticker_choices_text(),
        metric_choices=metric_choices_text(),
        query=state["query"],
    )
    plan: Plan = planner.invoke(prompt)
    steps = [s.model_dump() for s in plan.steps]
    return {"plan": steps, "current_step": 0}

model.with_structured_output(Plan) makes the LLM answer with a Pydantic-compatible JSON object, and bad output simply triggers a retry. That beats regex-parsing free text by a wide margin.

Guardrails inside the plan prompt

PLAN_PROMPT = """\
You are a financial research planner. Given a user's analysis request,
create a step-by-step research plan using the available tools.

Available tools:
{available_tools}
Rules:
- Use only the tools listed above.
- Every plan step must be executable with one of those tools.
- When a tool accepts 'ticker' or 'tickers', use only these exact values:
  {ticker_choices}
- When a tool accepts 'metric', use one of these exact values:
  {metric_choices}
- There are no other tools available. Final synthesis is handled separately.
Create an efficient plan. Group related lookups. Aim for 4-8 steps.
User request: {query}"""

Two details stand out. The prompt lists the valid parameter values, so the LLM needn't guess which tickers or metrics exist. This may be the most effective anti-hallucination trick for tool-calling agents. Without it, the model confidently plans steps like get_metrics(ticker="BITCOIN", metric="vibes") and then seems surprised when they fail.

The other detail is the line "There are no other tools available." When it's missing, LLMs have invented steps such as analyze_sentiment(ticker="NVDA") or generate_report(findings=...), tools that don't exist.

The "aim for 4 to 8 steps" instruction is deliberately soft. Hard limits such as "exactly 5 steps" backfired, padding simple queries with redundant steps and cramming complex ones into overloaded steps. A range gives the model room to adapt, and with guidelines in place of mandates it usually makes the right call.

The Executor node

The Executor is intentionally the plainest node: it takes the next step, runs the tool via the registry and stores the output.

MAX_STEPS = 12  # Safety limit on total steps

def executor_node(state: StrategyState) -> dict:
    """Execute the next pending step from the plan."""
    plan = state.get("plan", [])
    current_step = state.get("current_step", 0)
    if current_step >= len(plan):
        return {}
    if current_step >= MAX_STEPS:
        return {"current_step": len(plan)}
    step = plan[current_step]
    tool_name = step["tool"]
    tool_args = step["args"]
    try:
        result = str(
            TOOL_REGISTRY.invoke(
                AgentName.EXECUTOR.value, tool_name, tool_args
            )
        )
        status = "Error" if result.startswith("Error:") else "Success"
    except Exception as exc:
        result = f"Error: {exc}"
        status = "Error"
    entry = {
        "step": current_step + 1,
        "tool": tool_name,
        "args": tool_args,
        "result": result,
        "status": status,
    }
    return {
        "scratchpad": [entry],
        "current_step": current_step + 1,
    }

MAX_STEPS = 12 acts as a hard cap. Should the Replanner keep piling on steps or get stuck, the agent can't spend API credits forever. Once the cap is reached, the Executor sets current_step to len(plan), and the flow jumps to the Reporter with the data gathered so far.

Failures are recognized by convention: status = "Error" if result.startswith("Error:") else "Success". Each of the three tools prefixes its error strings with "Error:", so the Executor can mark a failure without JSON parsing or exception-specific handling. The Replanner reads this status when deciding between a retry and an adjusted plan.

The Replanner node

This node marks the difference between a planning agent and a simple orchestrator. Following each tool run, it weighs all the evidence and asks whether the remaining plan still makes sense:

MAX_REPLANS = 2  # Prevent infinite replanning

def replanner_node(state: StrategyState) -> dict:
    """Review progress and optionally modify the remaining plan."""
    plan = state.get("plan", [])
    current_step = state.get("current_step", 0)
    replan_count = state.get("replan_count", 0)
    scratchpad = state.get("scratchpad", [])
    remaining = plan[current_step:]
    if len(remaining) = MAX_REPLANS:
        return {}
    scratchpad_text = "\n".join(
        f"Step {e['step']}: {format_tool_call(e['tool'], e['args'])} "
        f"-> [{e['status']}] {e['result'][:150]}..."
        for e in scratchpad
    )
    remaining_text = "\n".join(
        f"Step {s['step_id']}: {format_tool_call(s['tool'], s['args'])} "
        f"- {s['purpose']}"
        for s in remaining
    )
    replanner = model.with_structured_output(ReplanDecision)
    prompt = REPLAN_PROMPT.format(
        goal=state["query"],
        scratchpad=scratchpad_text,
        remaining_steps=remaining_text,
    )
    decision: ReplanDecision = replanner.invoke(prompt)
    if decision.should_replan and decision.updated_steps:
        new_steps = plan[:current_step] + [
            s.model_dump() for s in decision.updated_steps
        ]
        return {"plan": new_steps, "replan_count": replan_count + 1}
    return {"replan_count": replan_count + 1}

Look at e['result'][:150]. The Replanner sees each tool result cut to 150 characters, because it needs the gist and not the full JSON payload, while the Reporter later gets the untruncated data. Tokens are saved where detail doesn't matter and spent where it does.

When a replan occurs, plan[:current_step] + [s.model_dump() for s in decision.updated_steps] retains finished steps and substitutes only the unfinished ones, leaving the history untouched.

The replan prompt

REPLAN_PROMPT = """\
You are a financial research planner reviewing progress on a research task.

Original goal: {goal}
Completed steps and findings so far:
{scratchpad}
Remaining steps in the plan:
{remaining_steps}
Based on the findings so far, should the remaining plan change?
If an expected tool failed or revealed something unexpected, add a step
to investigate.
If a step is now redundant, remove it.
Use only the available executable tools already shown in the plan.
Do not add recommendation, summary, or report-writing steps."""

The final line does heavy lifting. Remove "Do not add recommendation, summary, or report-writing steps" and the Replanner tacks a synthesis step onto every plan, even though the Reporter owns that duty. Prompts need to back up the boundaries between nodes.

A cap of MAX_REPLANS = 2 landed in the sweet spot. One early build used six replanning rounds on a three-step task, with the LLM finding "improvements" that improved nothing. Two rounds handle real self-correction, such as dealing with a failed tool or cutting a redundant step, without room for analysis paralysis. At some point an agent has to be told to quit deliberating and finish.

The Reporter receives the whole untruncated scratchpad and writes a structured markdown report. It calls model.invoke(prompt).content, giving plain text and not structured output, which leaves the LLM free to present the analysis as it sees fit. One instruction does the most work: "Be specific, cite numbers from the data." Without it, reports turn vague, saying "NVDA shows strong fundamentals" and skipping actual P/E and revenue figures.

Wiring it together in LangGraph

Here the four nodes meet:

from langgraph.graph import StateGraph, END
from enum import Enum

class AgentName(Enum):
    PLANNER = "planner"
    EXECUTOR = "executor"
    REPLANNER = "replanner"
    REPORT = "report"

def build_graph():
    """Build and compile the LangGraph planning-agent workflow."""
    workflow = StateGraph(StrategyState)
    workflow.add_node(AgentName.PLANNER.value, planner_node)
    workflow.add_node(AgentName.EXECUTOR.value, executor_node)
    workflow.add_node(AgentName.REPLANNER.value, replanner_node)
    workflow.add_node(AgentName.REPORT.value, report_node)
    workflow.set_entry_point(AgentName.PLANNER.value)
    workflow.add_edge(AgentName.PLANNER.value, AgentName.EXECUTOR.value)
    workflow.add_edge(AgentName.EXECUTOR.value, AgentName.REPLANNER.value)
    workflow.add_conditional_edges(
        AgentName.REPLANNER.value,
        should_continue_execution,
        {
            AgentName.EXECUTOR.value: AgentName.EXECUTOR.value,
            AgentName.REPORT.value: AgentName.REPORT.value,
        },
    )
    workflow.add_edge(AgentName.REPORT.value, END)
    return workflow.compile()

def should_continue_execution(state: StrategyState) -> str:
    """Return the next node name after re-planning."""
    if state.get("current_step", 0) >= len(state.get("plan", [])):
        return AgentName.REPORT.value
    return AgentName.EXECUTOR.value

The graph reads nearly like English. Planner goes to Executor, Executor to Replanner, and the Replanner branches to Executor if steps remain or to Reporter when everything's done. Reporter goes to END.

The AgentName enum looks minor but helps. Bare string literals like "planner" strewn through the code invite typos, and "plannr" would silently build a disconnected node. With the enum, the mistake shows up at import time.

Routing checks a single condition: are all steps done? When current_step >= len(plan), control goes to the Reporter, and otherwise back to the Executor. The loop ends because current_step grows with every execution, and MAX_STEPS = 12 caps things even when the Replanner adds steps.

One query, traced

The sample request asks the agent to look at NVDA and AMD, set their P/E ratios and recent news side by side, and then pick one.

Planning. The Planner generates five steps: full metrics for NVDA, full metrics for AMD, a direct P/E comparison, news search for NVDA and news search for AMD. Individual profiles come first for the full picture, then the head-to-head comparison, and news last because it's supplementary context and not core valuation data.

Execution. Steps 1 and 2 return clean metrics JSON, and each time the Replanner confirms that the data looks good and the plan is unchanged. Step 3 returns a comparison of P/E values:

{
  "metric": "P/E",
  "values": {
    "NVDA": 58.3,
    "AMD": 102.5
  }
}

Both values are present and valid, so nothing gets replanned. Steps 4 and 5 each bring back three news articles. Once Step 5 finishes, current_step equals len(plan), and routing hands control to the Reporter.

Report. Handed five complete scratchpad entries, the Reporter writes up concrete numbers, for example that NVDA's P/E of 58.3 against AMD's 102.5 points to NVDA as the better relative value despite a premium market cap of about $2.8T. A complete run takes around 8 to 10 seconds on Groq and 30 to 40 seconds on local Ollama using a 4B model.

Upgrades that matter most

The current version uses mock data, but the architecture is ready for production. Three upgrades count most:

  • Real financial data. Replace the dictionary lookups with calls to Yahoo Finance or Alpha Vantage. Signatures remain as they are and only the function bodies change, thanks to the @tool decorator pattern, so the Planner and Executor never learn whether the numbers originate in a mock JSON file or a REST API.
  • Persistent memory. Today the scratchpad lives for one run only. With a vector store such as ChromaDB or Pinecone, the agent could consult earlier analyses, answering "How does NVDA compare to what we saw last month?" and becoming an evolving analyst and not a stateless tool.
  • Human-in-the-loop. LangGraph offers interrupt points out of the box. Placing one after the Planner lets a person review the plan before anything runs, with a prompt like "The agent wants to run these 6 steps. Approve?" That matters wherever autonomous runs carry real financial risk.

What the structure can't supply

The plan-execute-replan loop does its job. Structured output keeps the agent on track, tool registries head off chaos, and safety caps stop runaway spending. The design holds up and the code stays tidy.

Even so, the intelligence of the system as a whole rests on the LLM beneath the scaffolding. A stronger model builds better plans, notices more errors during replanning and writes sharper reports, while a weaker one follows the structure dutifully and delivers mediocre analysis. The graph can't make the model smarter, and all it does is give the model a better frame for using the intelligence it already has.

The project-management layer is largely solved: state, tool orchestration, execution flow and self-correction all follow clear patterns. Judgment remains open. When should the Replanner really replan, and when should it stay quiet? When is a number anomalous and when is it just surprising? Right now those calls live inside prompts, with the hope that the LLM sorts them out, which works more often than you'd expect and less often than you'd like.