PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Learning paths> Agentic Engineering with Promptise Foundry
Lesson 47 / 71
66%

Plan-and-Execute Agents in Python: Beyond the ReAct Loop

Build a plan, act, verify, reflect agent in Python with the Promptise Reasoning Graph, then compare it with ReAct on one task: accuracy, calls, tokens.

Advanced15 minAgent Reasoning PatternsReAct
All 71 lessons↓
  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. ✓FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. ✓OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. ✓MCP Authentication in Python: API Keys, JWT and OAuth
  23. ✓Build an Authenticated MCP Server for Your AI Agent (Python)
  24. ✓MCP Security Best Practices: A Checklist for MCP Servers
  25. ✓Testing MCP Servers in Python: pytest Without a Live Server
  26. ✓Too Many MCP Tools? LLM Tool Selection, Measured
  27. ✓MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. ✓MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. ✓AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. ✓AI Agent Human in the Loop: Approve Tool Calls in Python
  31. ✓AI Agent Prompt Injection: How to Defend Against It
  32. ✓Build a Multi-Agent System in Python (and When Not To)
  33. ✓Context Engineering for AI Agents: Context Window Management
  34. ✓How to Deploy an AI Agent with Docker (2026 Guide)
  35. ✓LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. ✓MCP Long-Running Tasks: Background Jobs in Python
  37. ✓MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. ✓Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. ✓Self-Improving AI Agent: Learn From Tool Failures in Python
  40. ✓AI Agent Identity: Authenticate Agents to MCP Servers
  41. ✓Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. ✓Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. ✓What Is Agentic RAG? How It Differs from Classic RAG
  44. ✓AI Agent Memory in Python: Short-Term and Long-Term
  45. ✓Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. ✓AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. 47Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. 48Self-Modifying AI Agent in Python: Open Mode & Limits
  49. 49CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. 50Migrate from AutoGen: Agent Framework, Promptise and More
  51. 51What Is an LLM Guardrail? Types, Examples and Limits
  52. 52AI Agent Guardrails in Python: Block Injection, Redact PII
  53. 53AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. 54AI Agent Production Checklist: What to Check Before Launch
  55. 55Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. 56What Is Human in the Loop AI? Approvals for AI Agents
  57. 57AI Agent Notifications: Webhook and Slack Alerts in Python
  58. 58AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. 59AI Agent Stuck in a Loop? How to Detect and Stop It
  60. 60How to Run, Debug and Deploy an AI Agent in Python
  61. 61AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. 62How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. 63AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. 64How to Test LLM Prompts and AI Agents in Python
  65. 65AI Agent Evals in CI: Run Them on Every Pull Request
  66. 66Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. 67Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)

Lessons in this path

  1. ✓What Is an AI Agent? Definition, How It Works, Examples
  2. ✓How to Build an AI Agent in Python (2026 Guide)
  3. ✓What Is an Agent Harness? Parts and How to Build One
  4. ✓System Prompt Best Practices for AI Agents in Python
  5. ✓Best LLM for AI Agents: Test and Switch Models in Python
  6. ✓AI Agent YAML: Define and Run an Agent From a Config File
  7. ✓Streaming LLM Responses with FastAPI: Tokens and Tool Calls
  8. ✓Claude AI Agent with MCP: Use Anthropic Models in Promptise
  9. ✓Gemini AI Agent with MCP: Use Google Models in Promptise
  10. ✓Azure OpenAI Agent with MCP: Use Deployments in Promptise
  11. ✓OpenRouter AI Agent with MCP: Many Models in Promptise
  12. ✓Ollama AI Agent with MCP: Run Local Models in Promptise
  13. ✓LLM Semantic Cache and Model Fallback in Python
  14. ✓What Is MCP (Model Context Protocol)? A Developer's Guide
  15. ✓What Is Tool Calling? LLM Function Calling Explained
  16. ✓MCP Tools vs Resources vs Prompts (With Python Examples)
  17. ✓How to Connect MCP Servers to Your AI Agent in Python
  18. ✓Build a Production MCP Server in Python (Step by Step)
  19. ✓MCP Transports: stdio vs Streamable HTTP vs SSE (Python)
  20. ✓FastAPI MCP Server: Turn Your FastAPI App into MCP Tools
  21. ✓OpenAPI to MCP: Turn Any REST API into an MCP Server
  22. ✓MCP Authentication in Python: API Keys, JWT and OAuth
  23. ✓Build an Authenticated MCP Server for Your AI Agent (Python)
  24. ✓MCP Security Best Practices: A Checklist for MCP Servers
  25. ✓Testing MCP Servers in Python: pytest Without a Live Server
  26. ✓Too Many MCP Tools? LLM Tool Selection, Measured
  27. ✓MCP Server Caching, Timeouts and Circuit Breakers in Python
  28. ✓MCP vs CLI for AI Agents: Tokens, Security, When to Use Each
  29. ✓AI Agent Audit Logging: Tamper-Evident Logs in Python
  30. ✓AI Agent Human in the Loop: Approve Tool Calls in Python
  31. ✓AI Agent Prompt Injection: How to Defend Against It
  32. ✓Build a Multi-Agent System in Python (and When Not To)
  33. ✓Context Engineering for AI Agents: Context Window Management
  34. ✓How to Deploy an AI Agent with Docker (2026 Guide)
  35. ✓LLM Tool Rate Limiting: Per-Client Limits for MCP Servers
  36. ✓MCP Long-Running Tasks: Background Jobs in Python
  37. ✓MCP Server Monitoring: Prometheus, OpenTelemetry and Logs
  38. ✓Python AI Agent Frameworks Compared: Which to Choose (2026)
  39. ✓Self-Improving AI Agent: Learn From Tool Failures in Python
  40. ✓AI Agent Identity: Authenticate Agents to MCP Servers
  41. ✓Code-Action Agents in Python: One Program vs 37 Tool Calls
  42. ✓Multi-Tenant AI Agents: Keep Each Customer's Data Apart
  43. ✓What Is Agentic RAG? How It Differs from Classic RAG
  44. ✓AI Agent Memory in Python: Short-Term and Long-Term
  45. ✓Agentic RAG in Python: Give Your AI Agent a Search Tool
  46. ✓AI Agent Reasoning Patterns in Python: 9 Patterns Tested
  47. 47Plan-and-Execute Agents in Python: Beyond the ReAct Loop
  48. 48Self-Modifying AI Agent in Python: Open Mode & Limits
  49. 49CrewAI Alternatives: 7 Options and How to Migrate (2026)
  50. 50Migrate from AutoGen: Agent Framework, Promptise and More
  51. 51What Is an LLM Guardrail? Types, Examples and Limits
  52. 52AI Agent Guardrails in Python: Block Injection, Redact PII
  53. 53AI Agent Code Execution Sandbox: Run LLM Code Safely
  54. 54AI Agent Production Checklist: What to Check Before Launch
  55. 55Approval Fatigue: Auto-Approve Safe AI Agent Actions
  56. 56What Is Human in the Loop AI? Approvals for AI Agents
  57. 57AI Agent Notifications: Webhook and Slack Alerts in Python
  58. 58AI Agent Observability: Trace Tool Calls, Tokens and Cost
  59. 59AI Agent Stuck in a Loop? How to Detect and Stop It
  60. 60How to Run, Debug and Deploy an AI Agent in Python
  61. 61AI Agent Cost Tracking: Tokens and Cost per Run and User
  62. 62How to Evaluate AI Agents: Evals, LLM Judges and pass^k
  63. 63AI Agent Cost Control: Budgets That Stop Runaway Spend
  64. 64How to Test LLM Prompts and AI Agents in Python
  65. 65AI Agent Evals in CI: Run Them on Every Pull Request
  66. 66Autonomous AI Agent Runtime: Budgets, Crash Recovery, API
  67. 67Event-Driven AI Agents: Webhook, Cron and File Triggers
  68. 68Durable AI Agents: Crash Recovery and Safe Resume
  69. 69Deploy AI Agents in Production: Long-Running Python Agents
  70. 70What Is an AI Agent Framework? When You Need One
  71. 71LangGraph Alternatives: 7 Options and How to Migrate (2026)
← Path overview

Most AI agents run the ReAct loop: the model thinks, calls a tool, reads the result, and repeats until it can answer. It's simple and it works, but sometimes you want more shape: a plan before the first tool call, a check before the answer leaves, a moment of reflection when the check fails. This guide builds that plan-and-execute agent in Python with the Reasoning Graph in Promptise Foundry, runs it against the default ReAct agent on the same analysis task, and reports what each one cost. The short version is honest: on this task the extra model calls bought nothing, and the cheapest check, a few lines of code, cost nothing. Every snippet ran against Promptise Foundry 1.3.2 with gpt-5-mini, and every output is what it printed.

[01]

How do you build a plan-and-execute agent in Python?

You describe each step as a node in a graph, wire the nodes together, and let an engine walk the graph. In Promptise Foundry the graph is a PromptGraph, the steps are nodes such as PlanNode, PromptNode and ValidateNode, and PromptGraphEngine runs it:

Pythonquick_answer.py
1
2
3
4
5
6
7
8
9
10
        tools = agent.tools
        graph = PromptGraph("plan-and-execute", mode="static")
        graph.add_node(PlanNode("plan", output_schema=Plan, instructions=f"Plan the steps. Tools: {[t.name for t in tools]}"))
        graph.add_node(PromptNode("act", tools=tools))
        graph.add_node(ValidateNode("verify", output_schema=Verdict, on_pass="__end__", on_fail="act"))
        graph.sequential("plan", "act", "verify")
        graph.set_entry("plan")

        engine = PromptGraphEngine(graph=graph, model=resolve_model("openai:gpt-5-mini"), max_iterations=12)
        result = await engine.ainvoke({"messages": [{"role": "user", "content": "Which regions missed their Q3 target?"}]})

The planner writes subgoals, the act node works through them with your MCP tools, and the verifier either ends the run or sends the work back. If you only want a self-check and no new steps, there's a one-word version: build_agent(..., agent_pattern="verify"). The rest of this guide shows both running, adds a reflect step, and measures whether any of it was worth it.


[02]

How the Reasoning Graph runs your agent

Every Promptise agent is a graph, even the default one. build_agent gives you a ReAct graph: one PromptNode named reason that holds the tools and loops until the model stops calling them. A custom pattern is just a bigger graph. Here's the one you'll build:

Rendering diagram…

After each node, the engine picks the next one in a fixed order. If the node called tools, it runs the same node again so the model sees the results. Otherwise it takes, in this order: a next node the node chose itself (this is how ValidateNode routes on pass or fail), a route field in the node's structured output, your edges (always, sequential, when, on_output and friends), the node's transitions, and finally its default_next. If none applies, the run ends.

Promptise ships ten reasoning nodes that come with their own instructions: PlanNode, ThinkNode, ReflectNode, ObserveNode, JustifyNode, CritiqueNode, SynthesizeNode, ValidateNode, RetryNode and FanOutNode. Next to them sit the general ones, such as PromptNode, ToolNode, RouterNode, GuardNode, ParallelNode, HumanNode and the @node decorator, which turns any async function into a step. Nodes also take typed flags from NodeFlag, for example CRITICAL to abort the run if the node fails, RETRYABLE to retry it with backoff, or SKIP_ON_ERROR to skip it after a failed step.

You don't have to build a graph to change the pattern. build_agent takes a name:

agent_pattern

What it builds

"react" (default)

One tool-using node that loops until the model answers

"verify"

The same single node, told to plan, solve and check its answer in one response

"managed"

One node that sees a deduplicated ledger of facts instead of the full tool transcript

"code-action"

The model writes one Python program that calls your tools in a Docker sandbox

"peoatr"

Plan, act, think and reflect nodes, then an answer node that writes the reply

"research"

Search, a verify step, then synthesize

"deliberate"

Think, plan, act, observe and reflect in sequence

"debate"

Proposer, critic and judge, without tools

"autonomous"

One node that lets the model pick the next step from a pool

a PromptGraph

Your own graph


[03]

What you need

  • Python 3.10 or newer.

  • Promptise Foundry 1.3.2 or newer from PyPI. Everything here uses the base install:

>_Terminal
pip install "promptise>=1.3.2"
export OPENAI_API_KEY="sk-..."
  • An API key for a model provider. The examples use openai:gpt-5-mini; any model with tool calling and structured output works.


[04]

Build a plan, act, verify, reflect agent, step by step

Step 01

Give the agent a small analysis job

You need a task where a shortcut gives a wrong answer, or a check has nothing to catch. This MCP server has two tools: one returns each region's quarterly target, the other returns monthly gross revenue and refunds. The targets are for net revenue, so an agent that forgets the refunds will think two regions hit their target when they didn't.

Pythonsales_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from promptise.mcp.server import MCPServer

server = MCPServer("sales")

# A stand-in for your warehouse. Amounts are in USD.
TARGETS = {"Q3": {"North": 300_000, "South": 250_000, "East": 180_000, "West": 220_000}}

REVENUE = {
    ("North", "Q3"): [("Jul", 104_200, 3_100), ("Aug", 98_750, 2_400), ("Sep", 101_300, 4_050)],
    ("South", "Q3"): [("Jul", 86_400, 1_200), ("Aug", 82_100, 950), ("Sep", 88_900, 1_650)],
    ("East", "Q3"): [("Jul", 55_300, 2_900), ("Aug", 61_800, 3_300), ("Sep", 58_050, 2_150)],
    ("West", "Q3"): [("Jul", 74_900, 1_100), ("Aug", 71_250, 2_600), ("Sep", 76_400, 1_900)],
}


@server.tool()
async def get_targets(quarter: str) -> dict:
    """Get each region's net revenue target for a quarter.

    Args:
        quarter: The quarter, for example "Q3".
    """
    return {"quarter": quarter, "net_revenue_targets": TARGETS.get(quarter.upper(), {})}


@server.tool()
async def get_monthly_revenue(region: str, quarter: str) -> dict:
    """Get a region's gross revenue and refunds for each month of a quarter.

    Args:
        region: North, South, East or West.
        quarter: The quarter, for example "Q3".
    """
    rows = REVENUE.get((region.strip().title(), quarter.upper()))
    if rows is None:
        return {"error": f"No data for {region} in {quarter}."}
    return {
        "region": region.strip().title(),
        "quarter": quarter.upper(),
        "months": [{"month": m, "gross": g, "refunds": r} for m, g, r in rows],
    }


if __name__ == "__main__":
    server.run()

Computed straight from the data, this is the answer every agent has to reach. North and West beat their target on gross revenue and miss it on net:

Output
North gross=304,250 refunds=9,550 net=294,700 target=300,000 gap=-5,300 (-1.77%)
South gross=257,400 refunds=3,800 net=253,600 target=250,000 gap=+3,600 (+1.44%)
East  gross=175,150 refunds=8,350 net=166,800 target=180,000 gap=-13,200 (-7.33%)
West  gross=222,550 refunds=5,600 net=216,950 target=220,000 gap=-3,050 (-1.39%)
Step 02

Run it with the default ReAct loop

Start with the baseline, so you know what any extra structure has to beat. To count model calls and tokens, pass a small LangChain callback in the run's config. It sees every model call, whichever pattern runs:

Pythonusage.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from langchain_core.callbacks import BaseCallbackHandler


class Usage(BaseCallbackHandler):
    """Count model calls and tokens for one run."""

    def __init__(self):
        self.calls = self.input_tokens = self.output_tokens = 0

    def on_llm_end(self, response, **kwargs):
        self.calls += 1
        usage = response.generations[0][0].message.usage_metadata or {}
        self.input_tokens += usage.get("input_tokens", 0)
        self.output_tokens += usage.get("output_tokens", 0)

    def __str__(self):
        return f"{self.calls} model calls, {self.input_tokens:,} input + {self.output_tokens:,} output tokens"
Pythonreact_agent.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import asyncio
import sys

from promptise import build_agent
from promptise.config import StdioServerSpec

from usage import Usage

QUESTION = "Which regions missed their Q3 revenue target, and by how much in dollars and percent?"


async def main():
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={"sales": StdioServerSpec(command=sys.executable, args=["sales_server.py"])},
        instructions="You are a sales analyst. Use the sales tools for every number.",
        agent_pattern="react",  # the default
        trace_tools=True,
    )
    try:
        usage = Usage()
        result = await agent.ainvoke(
            {"messages": [{"role": "user", "content": QUESTION}]},
            config={"callbacks": [usage]},
        )
        print(">>>", result["messages"][-1].content)
        print("path:", " -> ".join(agent.last_report.nodes_visited))
        print("usage:", usage)
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
→ Invoking tool: get_targets with {'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'North', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'South', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'East', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'West', 'quarter': 'Q3'}
…
>>> Summary — regions that missed the Q3 net revenue target:

- North: Net = $294,700 vs Target = $300,000 → Missed by $5,300 (−1.77%)
- East: Net = $166,800 vs Target = $180,000 → Missed by $13,200 (−7.33%)
- West: Net = $216,950 vs Target = $220,000 → Missed by $3,050 (−1.39%)

For reference, South met/exceeded its target: Net = $253,600 vs Target = $250,000 → +$3,600 (+1.44%).
path: reason -> reason
usage: 2 model calls, 1,063 input + 1,425 output tokens

Two model calls. In the first, the model asked for all five tool calls at once, and Promptise ran them in parallel. In the second, it wrote a correct answer, refunds and all. agent.last_report is the engine's execution report; nodes_visited is the path through the graph; for this graph, each entry is one model call. Since 1.3.0 the report's total_tokens is filled in too; the callback also splits input from output tokens.

Step 03

Try the built-in verify pattern

Before you build anything, try the one-line change. With agent_pattern="verify", the same single node is told to plan, solve, and check its own work in one response:

Pythonverify_agent.py
        agent_pattern="verify",
Output
>>> PLAN
- Restate the task: identify which regions missed their Q3 net revenue targets and compute the shortfall in dollars and percent.
…
VERIFY
- Rechecked by summing month-by-month net (gross − refunds) for each region:
…
- Recomputed percent shortfalls as (1 − net/target) × 100; they match the values above.

FINAL ANSWER
Regions that missed their Q3 targets:
- North: missed by $5,300 (1.77%)
- East: missed by $13,200 (7.33%)
- West: missed by $3,050 (1.39%)
…
path: reason -> reason
usage: 2 model calls, 1,201 input + 2,474 output tokens

Same two calls, correct again, and about 1.7 times the output tokens. Notice where the self-check lives: in the answer. Your users see the plan and the arithmetic, so this pattern suits an analyst's report better than a chat reply.

Step 04

Build a plan, act, verify, reflect graph

Now the real pattern. Four nodes, each with one job:

Pythonplan_act_verify.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from typing import TypedDict

from promptise.engine import PlanNode, PromptGraph, PromptNode, ReflectNode, ValidateNode


class Plan(TypedDict):
    subgoals: list[str]
    quality_score: int


class Verdict(TypedDict):
    passes: bool
    issues: list[str]


def plan_act_verify(tools, system_prompt: str = "") -> PromptGraph:
    """Plan, act with tools, verify the draft, and reflect before retrying."""
    tool_list = "\n".join(f"- {t.name}: {t.description.splitlines()[0]}" for t in tools)
    graph = PromptGraph("plan-act-verify", mode="static")

    graph.add_node(
        PlanNode(
            "plan",
            output_schema=Plan,
            instructions=(
                f"{system_prompt}\n\nPlan how to answer the question with these tools:\n{tool_list}\n\n"
                "Write 4 or fewer subgoals, each one step. Never plan to ask the user for data "
                "a tool can fetch. Rate the plan from 1 to 5 as quality_score."
            ),
        )
    )
    graph.add_node(
        PromptNode(
            "act",
            instructions=f"{system_prompt}\n\nWork through the plan with your tools, then write the answer.",
            tools=tools,
        )
    )
    graph.add_node(
        ValidateNode(
            "verify",
            criteria=[
                "The answer names every region that missed, with the shortfall in dollars and percent",
                "Every number in the answer matches the tool results",
                "Every sum, difference and percentage is recomputed and correct",
                "The answer uses the same definition as the targets (net, not gross)",
            ],
            output_schema=Verdict,
            on_pass="__end__",
            on_fail="reflect",
        )
    )
    graph.add_node(ReflectNode("reflect", input_keys=["validation"]))

    graph.sequential("plan", "act", "verify")
    graph.always("reflect", "act")
    graph.set_entry("plan")
    return graph

What each node does:

  • `plan` is a PlanNode. It asks the model for subgoals and a quality score. When the output has subgoals, the node stores them as the run's plan, and a score below its quality_threshold (3 by default) sends it round to plan again. Later nodes see the plan in their prompt.

  • `act` is an ordinary PromptNode with the MCP tools. It loops through tool calls exactly like the ReAct node, then writes a draft.

  • `verify` is a ValidateNode. It checks the draft against your criteria and returns passes and issues. Its on_pass and on_fail decide where the run goes.

  • `reflect` is a ReflectNode. It reads the verdict through input_keys=["validation"], validation being where ValidateNode stores its output, and writes down what went wrong before act tries again.

The edges are two lines: sequential chains plan, act and verify, and always sends reflect back to act.

Note

Routing on a model's answer needs structured output, from a TypedDict or a pydantic model. Since 1.3.0 a pydantic result is stored as a plain dict (model_dump()), so it routes exactly like a TypedDict; in a test, a pydantic Verdict ended the run on pass. Either way, read the output as a dict: result.output["passes"], not .passes.

Step 05

Run the graph with your agent's tools

build_agent connects to your MCP servers and discovers their tools. Hand those tools to the graph, and run it with PromptGraphEngine. A small hook prints each node as it finishes, so you can watch the path:

Pythonrun_custom.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import asyncio
import sys

from promptise import build_agent, resolve_model
from promptise.config import StdioServerSpec
from promptise.engine import PromptGraphEngine

from plan_act_verify import plan_act_verify
from usage import Usage

QUESTION = "Which regions missed their Q3 revenue target, and by how much in dollars and percent?"
INSTRUCTIONS = "You are a sales analyst. Use the sales tools for every number."


class ShowSteps:
    """Engine hook: print each node as it finishes, with its structured output."""

    async def post_node(self, node, result, state):
        detail = result.output if isinstance(result.output, dict) else ""
        print(f"[{node.name}] {detail}")
        return result


async def main():
    # build_agent connects to the MCP servers and discovers their tools.
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={"sales": StdioServerSpec(command=sys.executable, args=["sales_server.py"])},
        trace_tools=True,
    )
    try:
        engine = PromptGraphEngine(
            graph=plan_act_verify(agent.tools, INSTRUCTIONS),
            model=resolve_model("openai:gpt-5-mini"),
            hooks=[ShowSteps()],
            max_iterations=12,  # a hard cap on node runs, retries included
        )
        usage = Usage()
        result = await engine.ainvoke(
            {"messages": [{"role": "user", "content": QUESTION}]},
            config={"callbacks": [usage]},
        )
        print(">>>", result["messages"][-1].content)
        print("path:", " -> ".join(engine.last_report.nodes_visited))
        print("usage:", usage)
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
[plan] {'subgoals': ["Call get_targets to retrieve each region's Q3 net revenue targets.", 'For each region, call get_monthly_revenue for the three Q3 months and compute Q3 net revenue = sum(gross - refunds).', "Compare each region's Q3 net revenue to its target and calculate dollar shortfall (target - net) and percent shortfall (shortfall / target * 100); mark regions that missed their target.", 'Present a concise list of regions that missed their Q3 target with the dollar and percent shortfall (rounded appropriately).'], 'quality_score': 5}
→ Invoking tool: get_targets with {'quarter': 'Q3'}
✔ Tool result from get_targets: {"quarter": "Q3", "net_revenue_targets": {"North": 300000, "South": 250000, "East": 180000, "West": 220000}}
[act] 
→ Invoking tool: get_monthly_revenue with {'region': 'North', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'South', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'East', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'West', 'quarter': 'Q3'}
…
[act] 
[act] 
[verify] {'passes': True, 'issues': []}
>>> Regions that missed their Q3 target:

- North — Shortfall $5,300 (1.77% of target)  
- East — Shortfall $13,200 (7.33% of target)  
- West — Shortfall $3,050 (1.39% of target)
path: plan -> act -> act -> act -> verify
usage: 5 model calls, 3,335 input + 3,306 output tokens

Read the path: one call to plan, three rounds of act (targets first, then the four regions, then the answer), one call to verify. The plan made the agent more sequential: it fetched the targets in one round and the revenue in the next, where the ReAct agent did both at once. The answer is right, and the verifier passed it on the first try, so reflect never ran.

Note

This guide passes the tools to the node itself and runs the graph with PromptGraphEngine. You can also hand the graph to build_agent: since 1.3.0, build_agent(..., agent_pattern=graph) gives a node with inject_tools=True the agent's MCP tools, and in a test it made all five tool calls.

Step 06

Compare the patterns on the same question

One run proves little, because models vary. So the same question went to each pattern ten times, with the answer checked automatically for all three dollar shortfalls. act-check is a fourth pattern you'll meet later in this guide. The model call and token counts come from the Usage callback above:

Output
pattern           correct  model calls   tokens  median s
react               10/10          2.0    2,348       9.3
verify              10/10          2.0    3,691      19.2
plan-act-verify     10/10          5.2    7,571      23.7
act-check           10/10          2.0    2,320       8.9

Every pattern got every run right. That's the honest headline: with a capable model and a task this size, the ReAct loop doesn't make the mistake the extra steps are there to catch. The plan, act, verify graph paid for its structure anyway, with about 2.6 times the model calls, 3.2 times the tokens and two and a half times the wait. Its verifier passed nine of the ten first drafts. In the other run it sent the draft back once, reflect ran, and the second draft passed; that one run took 16,401 tokens on its own. verify cost about half as much again as ReAct, most of it in a longer answer.

That doesn't make custom patterns useless. It means the value of a step is the mistakes it catches, and you only learn that by measuring on your own task.


[05]

When the planner can't see the tools

The first version of this graph used PlanNode with its built-in instructions, which don't mention your tools, three criteria without the one that demands every missed region, and max_iterations=3 on verify. In ten runs of that version, every plan started by getting hold of "the Q3 revenue dataset" from a finance system or a spreadsheet, as if the agent had none. One planner wrote the data down as a job for the user:

Output
[plan] {'subgoals': ['Obtain the Q3 actual and Q3 target revenue for each region — ask the user to paste or upload a table with columns: Region, Q3_Actual (USD), Q3_Target (USD).', 'Calculate for each region: Var …
→ Invoking tool: get_targets with {'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'North', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'South', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'East', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'West', 'quarter': 'Q3'}
…
[act] 
[act] 
[verify] {'passes': True, 'issues': []}
>>> I used the Q3 net revenue targets and the monthly gross/refund data from the sales system and computed net revenue, variances, and percent variances.
…
Regions that missed their Q3 target (shortfalls shown as absolute values):
- North — Shortfall: $5,300.00 (1.77%)
- East  — Shortfall: $13,200.00 (7.33%)
- West  — Shortfall: $3,050.00 (1.39%)
…
path: plan -> act -> act -> verify
usage: 4 model calls, 3,410 input + 5,836 output tokens

The act node skipped that step and fetched the data with its tools, as it did in all ten runs, and the verifier passed every answer on the first try, so every run was right. That's the model being sensible, not the graph: the plan told the agent to ask the user for data it already had, and nothing in this version stops an act step from doing what the plan says.

Three lessons from that version are in the final graph:

  • Tell the planner what the tools are. A plan written blind is a guess. The fixed PlanNode gets the tool list and one rule: never plan to ask for data a tool can fetch.

  • Make "there is an answer" a criterion. An LLM verifier judges what's in front of it, and a draft with no numbers gives the number checks nothing to check. The first criterion now demands every missed region with both numbers.

  • Cap the loop. The first version passed max_iterations=3 to the verify node, so a verifier that never passes can send the draft back only three times. Without it, a node's default of 10 applies, and behind that the engine's own caps: max_iterations (all node runs, 50 by default) and max_node_iterations (runs of any one node, 25 by default). Keep them as a backstop, and decide what reaching one should do, as the next section shows.

What happens when a node reaches its limit

To see a cap at work, give the verifier a criterion no answer can meet. cap_demo.py builds the Step 4 graph, swaps its verify node for one that also wants the whole answer in under five words, and caps it at three runs:

Pythoncap_demo.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        graph = plan_act_verify(agent.tools, INSTRUCTIONS)
        # Swap in a verifier that no answer can satisfy, capped at three runs.
        graph.add_node(
            ValidateNode(
                "verify",
                criteria=[
                    "The answer names every region that missed, with the shortfall in dollars and percent",
                    "The whole answer is shorter than five words",
                ],
                output_schema=Verdict,
                on_pass="__end__",
                on_fail="reflect",
                max_iterations=3,
            )
        )
Output
Replacing existing node 'verify' in graph 'plan-act-verify'
[plan] {'subgoals': ["Call get_targets to retrieve each region's Q3 net revenue target.", 'For each region, ca …
[act] 
[act] 
[act] 
[verify] {'passes': False, 'issues': ['Length requirement failed: the answer is much longer than five words. Suggestion: provide a single-line concise …
[reflect] 
[act] 
[verify] {'passes': False, 'issues': ['Violates length constraint: response is far longer than five words. Improvement: provide a single-line answer u …
[reflect] 
[act] 
[verify] {'passes': False, 'issues': ['Issue: Violated brevity constraint — answer is much longer than five words. Suggestion: request clarification o …
[reflect] 
[act] 
Node 'verify' reached its iteration limit (3) in graph 'plan-act-verify' — not running it again
Stuck node 'verify' — forcing graph end
>>> North: shortfall $5,300 (1.77%); East: shortfall $13,200 (7.33%); West: shortfall $3,050 (1.39%).
path: plan -> act -> act -> act -> verify -> reflect -> act -> verify -> reflect -> act -> verify -> reflect -> act
usage: 13 model calls, 17,982 input + 11,479 output tokens

The verifier rejected three drafts. When the graph came back to it a fourth time, the engine logged Node 'verify' reached its iteration limit (3) and Stuck node 'verify' — forcing graph end, and ainvoke returned normally: the answer is the fourth draft, which no verifier saw. It happens to be right here, but nothing checked it. That's the rule in 1.3.2: a limit reached after the model has answered ends the run normally, and only a limit that stops a tool loop before the model saw the results raises an error, as the next example shows.

If an unchecked draft shouldn't go out, route the node's "error" transition. The engine follows it once the node has used its runs, so you decide what happens instead of the run simply ending. In cap_demo_route.py, a give_up node writes the reply:

Pythoncap_demo_route.py
1
2
3
4
5
6
7
8
9
10
11
12
13
@node("give_up")
async def give_up(state):
    """Where the run goes when verify has used its runs: say so instead of sending the last draft."""
    state.messages.append(AIMessage("I couldn't verify this answer, so I'm not sending it. A person should check the numbers."))
    return NodeResult(node_name="give_up", next_node="__end__")


                on_fail="reflect",
                max_iterations=3,
                transitions={"error": "give_up"},  # where to go once verify has used its 3 runs
            )
        )
        graph.add_node(give_up)

In a run with it, verify again rejected three drafts, the path ended … -> act -> give_up, and the reply was the give_up message, not an unchecked draft.

A cap that cuts into a tool loop has no draft to fall back on. limit_demo.py is run_custom.py with the engine's max_iterations set far too low and a try around ainvoke:

Pythonlimit_demo.py
1
2
3
4
5
6
7
8
9
10
11
12
        engine = PromptGraphEngine(
            graph=plan_act_verify(agent.tools, INSTRUCTIONS),
            model=resolve_model("openai:gpt-5-mini"),
            hooks=[ShowSteps()],
            max_iterations=2,  # far too low, to hit the cap on purpose
        )
        try:
            result = await engine.ainvoke({"messages": [{"role": "user", "content": QUESTION}]})
            print(">>>", result["messages"][-1].content)
        except GraphIterationLimitError as exc:
            # The cap stopped a tool loop before the model saw the results: there is no answer.
            print(f"no answer: {exc.node_name!r} stopped at limit {exc.limit}, waiting on {exc.pending_tools}")
Output
[plan] {'subgoals': ["Fetch each region's net revenue target for Q3 using get_targets.", 'For each region, fet …
→ Invoking tool: get_targets with {'quarter': 'Q3'}
✔ Tool result from get_targets: {"quarter": "Q3", "net_revenue_targets": {"North": 300000, "South": 250000, "East": 180000, "West": 220000}}
[act] 
→ Invoking tool: get_monthly_revenue with {'region': 'West'}
✖ get_monthly_revenue error: Invalid arguments for tool 'get_monthly_revenue':
- quarter: Field required
Correct the arguments and call the tool again.
→ Invoking tool: get_monthly_revenue with {'region': 'North', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'South', 'quarter': 'Q3'}
→ Invoking tool: get_monthly_revenue with {'region': 'East', 'quarter': 'Q3'}
…
[act] 
Max iterations (2) reached in graph 'plan-act-verify'
Graph 'plan-act-verify' at node 'act': the run reached the engine's max_iterations (2) before the model gave an answer; the run stopped after a call to get_monthly_revenue whose result the model never saw
no answer: 'act' stopped at limit 2, waiting on ['get_monthly_revenue']
path: plan -> act -> act

The cap stopped the run after act's second round had asked for the revenue tools, so there was no answer to return. One of those calls left out quarter, and Promptise sent the argument error back instead of calling the tool; the model never got to fix it. ainvoke raised GraphIterationLimitError from promptise.engine, a GraphExecutionError that carries the node, the limit and the pending_tools whose results the model never saw. Before 1.3.2 such a run returned the last tool result as if it were the answer.


[06]

Check with code instead of a model

A model checking a model is the expensive way to verify. When the answer can be computed, check it with code: it's free, it's fast, and it can't be talked into passing. The @node decorator turns an async function into a graph step. This one recomputes every shortfall from the raw tool results the engine keeps in state.observations, and sends the agent back with the right numbers if the draft is missing any:

Pythoncode_check.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import json

from langchain_core.messages import HumanMessage

from promptise.engine import NodeResult, PromptGraph, PromptNode, node


@node("check")
async def check(state):
    """Recompute every shortfall from the raw tool results and compare it with the draft."""
    targets, net = {}, {}
    for obs in state.observations:
        data = json.loads(obs["result"])
        if obs["tool"] == "get_targets":
            targets = data["net_revenue_targets"]
        elif obs["tool"] == "get_monthly_revenue":
            net[data["region"]] = sum(m["gross"] - m["refunds"] for m in data["months"])

    draft = state.messages[-1].content.replace(",", "")
    wrong = [
        f"{region} missed by ${targets[region] - value:,}"
        for region, value in net.items()
        if value < targets[region] and str(targets[region] - value) not in draft
    ]
    if not wrong:
        return NodeResult(node_name="check", next_node="__end__")

    state.messages.append(HumanMessage(f"Check failed. The answer must state: {'; '.join(wrong)}. Fix it."))
    return NodeResult(node_name="check", next_node="act", output={"wrong": wrong})


def act_then_check(tools, system_prompt: str = "") -> PromptGraph:
    """A ReAct node whose answer is checked by code before it leaves the graph."""
    graph = PromptGraph("act-check", mode="static")
    graph.add_node(PromptNode("act", instructions=system_prompt, tools=tools, default_next="check"))
    graph.add_node(check)
    graph.set_entry("act")
    return graph

Run it like the graph in Step 5, and the path is the ReAct path plus one step that costs nothing:

Output
path: act -> act -> check
usage: 2 model calls, 1,063 input + 1,232 output tokens

The model got it right in every run, so to see the failure branch, feed the check a draft that made the classic mistake, using gross revenue instead of net:

Output
next node: act
message added: Check failed. The answer must state: North missed by $5,300; East missed by $13,200; West missed by $3,050. Fix it.

The agent goes back to act with the exact numbers it has to explain. A check like this is specific to your task, and that's the point: you know what a correct answer looks like better than a second model does.


[07]

Honest limits

The Reasoning Graph is flexible. Plan around these:

  • A `PromptNode` stops after 10 model calls by default. Since 1.3.0 a node's own max_iterations (10 unless you set it, tool-loop rounds included) is enforced. Raise it per node, for example PromptNode("act", ..., max_iterations=25), where a deep tool loop is expected.

  • A cap ends the run, and only sometimes with an error. When a node uses its runs after the model has answered, the run ends normally with the last draft, checked or not. When a cap stops a tool loop before the model saw the results, ainvoke raises GraphIterationLimitError (since 1.3.2). Route the node's "error" transition, or catch the error, wherever a cap can be reached.

  • Structured outputs are dicts. A pydantic output_schema result arrives as model_dump() in result.output and state.context. Read keys, or re-validate with Verdict.model_validate(...).

  • Nodes that answer in plain text give nothing to route on. Give a node an output_schema when its answer should decide where the run goes.

  • Unknown pattern names raise. agent_pattern="plan-act-verify" raises ValueError: Unknown agent_pattern 'plan-act-verify'. Use one of 'react', 'managed', 'code-action', 'verify', 'peoatr', 'research', 'autonomous', 'deliberate', 'debate', or pass a PromptGraph. For a pipeline, pass PromptGraph.pipeline(node_a, node_b); the string "pipeline" raises a ValueError that says so.

  • A saved graph needs its references to load. Since 1.3.0, save_graph keeps every node setting, but schemas are saved as "module:QualName" references and tools as names. load_graph(path, tools=agent.tools, refs=[Plan, Verdict]) loaded this guide's graph with its routing intact; without refs it raised GraphSerializationError. Lambdas and other objects that can't be written down make save_graph raise.


[08]

Frequently asked questions

Is plan and execute better than ReAct?

Not by default. On this task, ten runs each, ReAct was right every time with the fewest calls, and the plan, act, verify graph was right every time for about 3.2 times the tokens. Planning helps when the order of steps matters and the model gets it wrong without guidance, and a planner that can't see what the agent can do writes plans the agent shouldn't follow. Measure on your own task before you switch.

What is a ReAct agent?

An agent that alternates reasoning and acting: the model decides on a tool call, sees the result, and repeats until it can answer. The pattern comes from the 2022 paper ReAct: Synergizing Reasoning and Acting in Language Models. It's what build_agent gives you unless you choose otherwise.

What is a reflection agent?

An agent that reviews its own work and tries again with what it learned. Here, ReflectNode reads the verifier's issues and writes down the mistake and a correction before the act step runs again. Reflection only helps if something real triggers it, so pair it with a verifier that can actually fail, ideally one written in code.

Is the Promptise Reasoning Graph a LangGraph alternative?

It covers the same ground: an agent as a graph of steps with conditional edges and loops. The difference is what a node is. A Promptise node is a whole reasoning step with its own instructions, tools, output schema and routing, and ten of them come ready-made. Promptise builds on LangChain's model and tool interfaces, so LangChain tools and callbacks, like the Usage counter here, work unchanged.

Can I use a different model for some nodes?

Yes. Every PromptNode accepts model_override, and so do the reasoning nodes built on it, such as PlanNode and ValidateNode. It takes either a model object or a string such as "openai:gpt-5-mini". A common split is a small, fast model for planning and checking and a stronger one for the act step.


[09]

Where to go next

  • Reasoning Graph: the engine, its two modes, and every building block.

  • Nodes and Node Flags: parameters for every node type and what each flag does.

  • Prebuilt Patterns and Reasoning Patterns: the built-in graphs and when to pick one.

  • Building Custom Reasoning: more graph shapes, from pipelines to parallel research.

  • Edges & Transitions and Hooks: conditional routing, budgets and per-node metrics.

  • How to Connect MCP Servers to Your AI Agent in Python: where the tools in this guide come from.

← PreviousAI Agent Reasoning Patterns in Python: 9 Patterns TestedNext lesson →Self-Modifying AI Agent in Python: Open Mode & Limits