DEV Community

Programming Central
Programming Central

Posted on • Originally published at programmingcentral.hashnode.dev

Stop LLMs From Hallucinating: The Plan-and-Execute Pattern in LangGraph.js

Have you ever watched an AI agent spin its wheels, re-evaluating the same step over and over in an infinite reasoning loop? It’s a common frustration with autonomous systems. The agent has the tools, it has the context, but it gets stuck in a cycle of "Reason -> Act -> Observe -> Reason," often hallucinating new paths or forgetting its original objective.

What if you could separate the thinking from the doing?

Enter the Plan-and-Execute pattern. Unlike the dynamic but unpredictable ReAct (Reasoning and Acting) approach, Plan-and-Execute introduces a rigid separation of concerns. It decouples the strategy (the plan) from the tactics (the execution), creating deterministic workflows that are reliable, auditable, and efficient.

In this guide, we’ll break down the architecture, compare it with ReAct, and provide a complete TypeScript implementation using LangGraph.js.

The Core Concept: The Blueprint and the Builder

To understand Plan-and-Execute, imagine constructing a complex modular bookshelf.

  • The ReAct Approach (The "As-You-Go" Builder): You pick up a piece of wood, glance at the diagram, nail it in, and repeat. You might realize halfway through that you used the wrong screw or missed a step. It’s flexible but prone to getting stuck.
  • The Plan-and-Execute Approach (The Architect & The Contractor): First, you (the Planner) study the diagram and create a numbered list: 1. Assemble base frame. 2. Attach vertical supports. 3. Install shelves. You hand this list to a Contractor (the Executor). The Contractor follows the list blindly. If a screw is missing, they report the error but don’t rewrite the blueprint.

In LangGraph.js, this pattern ensures deterministic workflows. By forcing the agent to commit to a plan before acting, we prevent infinite reasoning loops—a critical requirement for production systems where predictability is paramount.

Why Choose Plan-and-Execute Over ReAct?

The primary motivation for this architectural shift is control.

1. Mitigating Reasoning Hallucinations

In a ReAct agent, the LLM decides the next step based on immediate context. If the context is noisy, the model might hallucinate a tool that doesn't exist or perform redundant actions. By generating a plan upfront, we constrain the LLM's future choices. The execution phase is restricted to the steps defined in the plan, significantly reducing the surface area for error.

2. Optimization via Parallelization

While a basic Plan-and-Execute implementation executes steps sequentially, the planning phase allows us to identify independent steps. In a ReAct loop, the agent often cannot "see" the future to realize that Step 3 and Step 4 could happen simultaneously. With a plan in hand, a sophisticated orchestrator can analyze the dependency graph and dispatch independent tasks to a Worker Agent Pool concurrently.

3. State Management and Resilience

In a long-running ReAct loop, if the agent encounters an error, it often has to restart the reasoning process from scratch. In Plan-and-Execute, the state is explicitly tracked against a static plan. If Step 3 fails, the system knows exactly where it left off. The state isn't just a conversation history; it's a structured object tracking plan_id, current_step_index, and completed_steps.

The Architecture: A Web Development Analogy

To visualize the mechanics, let’s map the Plan-and-Execute architecture to a modern Microservices Architecture using an API Gateway.

  • The Planner Node = The API Gateway / API Design Phase Before writing code, an architect defines the API endpoints (e.g., POST /users, GET /orders). This is the "Plan." It defines what needs to happen without defining how the internal services implement it.
  • The Executor Node = The Microservice The microservice is the "Worker." It receives a specific request (a step) and executes it. It is stateless regarding the overall workflow; it only cares about its specific input and output.
  • The Shared State = The Database / Redux Store The state tracks the current status of the workflow. Just as a Redux store tracks the state of a frontend application, the LangGraph state tracks which steps are pending, in_progress, or completed.

Under the Hood: State Management

In LangGraph.js, the state is typically defined using Annotation. For Plan-and-Execute, the state object is more complex than a simple chat history.

import { Annotation, StateGraph, START, END } from "@langchain/langgraph";

// Defining the state for a Plan-and-Execute workflow
const PlanExecuteState = Annotation.Root({
  // The high-level objective provided by the user
  input: Annotation<string>(),

  // The generated plan: An array of step objects
  // Example: [{ step: 1, description: "Search for X", status: "pending" }]
  plan: Annotation<Array<{ step: number; description: string; status: string }>>({
    reducer: (state, update) => {
      // We replace the entire plan or update specific steps
      return update; 
    },
    default: () => [],
  }),

  // The current step index being executed
  currentStep: Annotation<number>({
    reducer: (state, update) => update,
    default: () => 0,
  }),

  // The final output after all steps are complete
  finalOutput: Annotation<string>({
    reducer: (state, update) => update,
    default: () => "",
  }),
});
Enter fullscreen mode Exit fullscreen mode

The Workflow Breakdown

The execution flow can be visualized as a directed acyclic graph (DAG). Unlike ReAct, which is a cycle (Reason -> Act -> Observe -> Reason), Plan-and-Execute is a linear progression through the plan.

1. The Planner Node

This is the entry point. It takes the user's input (e.g., "Research the latest trends in WebGPU and write a summary report") and prompts an LLM to generate a sequence of discrete steps.

  • Input: The user query.
  • Process: The LLM is instructed to output a structured list (JSON) of steps. It does not execute any tools yet.
  • Output: A populated plan array in the state.

2. The Execution Loop

Once the plan is set, the graph enters the execution phase. This is handled by the Executor Node.

  • Step Selection: The node reads state.currentStep and retrieves the corresponding step description from state.plan.
  • Action: The node passes the step description to an LLM (often a more powerful model or a specialized model) which decides which tool to use (e.g., a Search Agent or a Code Generator).
  • Update: Upon completion, the node updates the status of that step in state.plan (e.g., changing status: "pending" to status: "completed") and increments state.currentStep.

3. The Conditional Edge (The Loop)

The magic of LangGraph lies in the conditional edge. After the Executor node runs, we define a function that checks if state.currentStep < state.plan.length.

  • If True: The graph routes back to the Executor node.
  • If False: The graph routes to the End node.

Comparison with ReAct Agents

It is vital to distinguish this from the ReAct pattern.

Feature ReAct Agent Plan-and-Execute
Decision Making Dynamic, step-by-step. Decisions are made during execution. Static, upfront. Decisions are made before execution begins.
Flexibility High. Can adapt to unexpected results immediately. Low. Sticks to the plan unless explicitly programmed to replan on failure.
Predictability Low. The path to the answer can vary. High. The path is defined in the plan.
Best For Open-ended exploration, creative tasks. Structured workflows, data extraction, reliable automation.

The Role of Specialized Agents (Worker Agent Pool)

In a sophisticated implementation, the Executor Node does not perform the work itself. Instead, it acts as a Supervisor that delegates to a Worker Agent Pool.

Returning to our microservice analogy: The Executor is the API Gateway. When it receives a request for "Search for WebGPU trends," it routes this request to the Search Agent microservice. The Search Agent is a specialized agent equipped with specific tools (like a browser tool or a search API tool). It returns the result to the Executor, which then updates the state.

This architecture allows for:

  1. Specialization: A Search Agent can be optimized for retrieval, while a Code Agent is optimized for syntax and logic.
  2. Separation of Context: The Search Agent doesn't need to know about the final report; it only needs to know how to search.
  3. Scalability: You can swap out the underlying models or tools for specific agents without breaking the overall workflow.

Basic Code Example: A Simple Plan-and-Execute Workflow

Let’s build a minimal Plan-and-Execute agent in TypeScript. We will simulate a SaaS application where a user requests a weekend trip plan to Paris.

The LangGraph Architecture

The graph defines a linear flow: Planner -> Executor -> Reflector (Router) -> (Loop back or End).

TypeScript Implementation

/**
 * @fileoverview A minimal Plan-and-Execute agent implementation using LangGraph.js.
 * Dependencies: @langchain/core, langgraph
 */

// ============================================================================
// 1. IMPORTS & SETUP
// ============================================================================

import { StateGraph, Annotation, END, START } from "@langchain/core/graphs";
import { BaseMessage, HumanMessage, AIMessage } from "@langchain/core/messages";

// ============================================================================
// 2. STATE DEFINITION
// ============================================================================

const GraphState = Annotation.Root({
  plan: Annotation<string[]>({
    reducer: (state, update) => update, // Overwrite the plan
    default: () => [],
  }),
  currentStepIndex: Annotation<number>({
    reducer: (state, update) => update, // Overwrite index
    default: () => 0,
  }),
  pastSteps: Annotation<Array<[string, string]>>({
    reducer: (state, update) => [...state, update], // Append history
    default: () => [],
  }),
  response: Annotation<string>({
    reducer: (state, update) => update, // Overwrite response
    default: () => "",
  }),
});

// ============================================================================
// 3. TOOL SIMULATION (EXECUTION LOGIC)
// ============================================================================

async function executeTool(step: string): Promise<string> {
  // Simulate network latency
  await new Promise((resolve) => setTimeout(resolve, 100));

  // Simple keyword matching to simulate different outcomes
  if (step.toLowerCase().includes("flight")) {
    return "Flight to Paris booked successfully (Ref: AF1234).";
  }
  if (step.toLowerCase().includes("hotel")) {
    return "Hotel reservation confirmed at 'Le Grand Hotel' (Ref: H-5678).";
  }
  if (step.toLowerCase().includes("museum")) {
    return "Louvre Museum tickets purchased online.";
  }
  return `Executed step: ${step}`;
}

// ============================================================================
// 4. NODES (LOGICAL BLOCKS)
// ============================================================================

/**
 * Node 1: Planner
 * Generates the high-level plan based on the user's initial request.
 */
const plannerNode = async (state: typeof GraphState.State) => {
  console.log("🤖 [Planner] Generating plan...");

  // Simulated LLM output
  const steps = [
    "Book flight to Paris",
    "Reserve hotel accommodation",
    "Purchase Louvre Museum tickets"
  ];

  return {
    plan: steps,
  };
};

/**
 * Node 2: Executor
 * Executes the specific step at `currentStepIndex`.
 */
const executorNode = async (state: typeof GraphState.State) => {
  const { plan, currentStepIndex } = state;

  if (currentStepIndex >= plan.length) {
    return { response: "No more steps to execute." };
  }

  const stepToExecute = plan[currentStepIndex];
  console.log(`⚡ [Executor] Executing step ${currentStepIndex + 1}: "${stepToExecute}"`);

  const result = await executeTool(stepToExecute);

  return {
    pastSteps: [stepToExecute, result] as [string, string],
  };
};

/**
 * Node 3: Reflector (Router)
 * Determines if the execution is complete or if we need to loop back.
 */
const reflectNode = async (state: typeof GraphState.State) => {
  const { plan, currentStepIndex, pastSteps } = state;

  console.log("🔍 [Reflector] Checking progress...");

  // If we have completed all steps, format the final response
  if (currentStepIndex >= plan.length - 1) {
    const summary = pastSteps
      .map(([step, result]) => `- ${step}: ${result}`)
      .join("\n");

    return {
      response: `Trip Planning Complete!\n\nSummary:\n${summary}`,
    };
  }

  // Otherwise, increment the step index for the next loop
  return {
    currentStepIndex: currentStepIndex + 1,
  };
};

// ============================================================================
// 5. GRAPH CONSTRUCTION
// ============================================================================

function createWorkflow() {
  const workflow = new StateGraph(GraphState)
    .addNode("planner", plannerNode)
    .addNode("executor", executorNode)
    .addNode("reflector", reflectNode)
    .addEdge(START, "planner")
    .addEdge("planner", "executor")
    .addConditionalEdges(
      "reflector",
      (state: typeof GraphState.State) => {
        if (state.response && state.response.length > 0) {
          return END;
        }
        return "executor";
      }
    );

  return workflow.compile();
}

// ============================================================================
// 6. EXECUTION (SIMULATED SERVER ACTION)
// ============================================================================

async function runTripPlanner() {
  const app = createWorkflow();
  const initialInput = {};

  console.log("🚀 Starting Plan-and-Execute Workflow...\n");

  const stream = await app.stream(initialInput);

  for await (const step of stream) {
    const nodeName = Object.keys(step)[0];
    const state = step[nodeName];

    if (state.plan?.length > 0) {
      console.log(`   -> Plan updated: [${state.plan.join(", ")}]`);
    }
    if (state.pastSteps?.length > 0) {
      const lastStep = state.pastSteps[state.pastSteps.length - 1];
      console.log(`   -> Result: ${lastStep[1]}`);
    }
    if (state.response) {
      console.log(`   -> Final Response: ${state.response}`);
    }
    console.log("");
  }
}

// Execute the function
runTripPlanner().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Detailed Line-by-Line Explanation

  • State Definition: We use Annotation.Root to define the graph's memory. The pastSteps array uses a reducer that appends updates ([...state, update]), creating an immutable history crucial for generating the final summary.
  • The Nodes:
    • Planner: Returns a hardcoded plan (in a real app, this would be an LLM call).
    • Executor: Reads the current step, calls the simulated tool, and appends the result to pastSteps.
    • Reflector: Checks if we've reached the end of the plan. If yes, it generates the final response string; if no, it increments currentStepIndex.
  • Graph Construction: We define a conditional edge on the reflector node. If state.response exists, we route to END. Otherwise, we loop back to the executor node.
  • Streaming: We use app.stream() instead of invoke() to observe state changes at every node transition, which is vital for debugging and real-time UI updates.

Common Pitfalls and Solutions

1. State Mutation (The "Reference" Trap)

Issue: In JavaScript, objects are passed by reference. Modifying state directly (e.g., state.plan.push(...)) corrupts the graph's history.
Solution: Always return a new object or rely on LangGraph's reducers. Use spread operators (...state) or return new objects entirely to ensure immutability.

2. Async/Await Loops in Conditional Edges

Issue: LangGraph's addConditionalEdges predicate function must be synchronous. You cannot make the predicate function async.
Solution: Perform all asynchronous logic inside the Node itself. The node should update the state with the result of the async operation, and the conditional edge should simply read that synchronous state property.

3. Vercel/AWS Lambda Timeouts

Issue: In serverless environments, execution has strict timeouts (e.g., 10 seconds on Vercel Hobby). If your executeTool function involves heavy computation, the workflow will fail.
Solution: Offload heavy work. For long-running tasks, trigger a background job in the executor node and return immediately with a "Job Started" status, rather than waiting for completion.

4. Hallucinated JSON in Tool Calls

Issue: If using an LLM inside the plannerNode or executorNode to generate structured data, models can return malformed JSON.
Solution: Always use Structured Output (JSON mode) or tool calling schemas provided by the model provider. Do not rely on parsing raw text strings with regex.

Conclusion

The Plan-and-Execute pattern is the architectural bridge between simple LLM chains and complex autonomous systems. By enforcing a strict separation between planning and execution, we gain reliability, observability, and state integrity.

Whether you are building a trip planner, a research assistant, or a data extraction pipeline, this deterministic approach ensures your agents follow a predictable path. As you scale, this pattern serves as the foundation for orchestrating multiple specialized agents under a unified strategy, turning chaotic reasoning loops into streamlined, production-ready workflows.

The concepts and code demonstrated here are drawn directly from the comprehensive roadmap laid out in the book Autonomous Agents. Building Multi-Agent Systems and Workflows with LangGraph.js Amazon Link of the AI with JavaScript & TypeScript Series.
The ebook is also on Leanpub.com: https://leanpub.com/JSTypescriptAutonomousAgents.

Top comments (0)