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.
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:
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:
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
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.
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:
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%)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:
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"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())→ 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 tokensTwo 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.
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:
agent_pattern="verify",>>> 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 tokensSame 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.
Build a plan, act, verify, reflect graph
Now the real pattern. Four nodes, each with one job:
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 graphWhat 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.
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:
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())[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 tokensRead 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.
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:
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.9Every 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:
[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 tokensThe 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:
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,
)
)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 tokensThe 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:
@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:
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}")[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 -> actThe 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:
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 graphRun it like the graph in Step 5, and the path is the ReAct path plus one step that costs nothing:
path: act -> act -> check
usage: 2 model calls, 1,063 input + 1,232 output tokensThe 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:
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.