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

← Guides> AI Engineering

Python AI Agent Frameworks Compared (2026 Benchmark)

LangGraph, Pydantic AI, OpenAI Agents SDK, CrewAI and Promptise: one MCP agent, same model, 300 real runs. Code, tokens, latency and budget stops.

Level
Intermediate
Reading time
20 min
Published
Oct 15, 2026
By
Promptise Team
  • AI Agent Frameworks
  • LangGraph
  • Pydantic AI
  • OpenAI Agents SDK
  • CrewAI
  • MCP
  • Python
  • Promptise Foundry

Python AI agent frameworks all promise the same thing: give a model some tools and let it work. To see how they actually differ, we built the same agent five times, in LangGraph, Pydantic AI, the OpenAI Agents SDK, CrewAI and Promptise Foundry. Every version uses the same model (gpt-5-mini), the same three MCP tools, the same approval rule for refunds and the same budget of six model calls, and every version ran the same 20 tasks three times. That made 300 real runs, which cost $0.37 in total. The headline is not the one a vendor benchmark would give you. On the 17 normal tasks, all five frameworks passed 51 of 51 runs. The differences showed up elsewhere: in how many lines of code each one needed, in tokens and latency, and above all in what happens when the budget runs out. On that last test our own framework, Promptise, failed every run because of an off-by-one bug we found while writing this guide. Every output below is pasted from that one run.

[01]

The short answer

Promptise 1.3.3

LangGraph 1.2 (LangChain 1.4)

Pydantic AI 2.56

OpenAI Agents SDK 0.24

CrewAI 1.15

Normal tasks passed

51/51

51/51

51/51

51/51

51/51

Budget runs that stopped within 6 calls

0/9 (made 7)

9/9

9/9

9/9

9/9

Budget runs where the caller was told

9/9

8/9

9/9

9/9

0/9

p50 latency, normal tasks

4.7 s

5.4 s

6.3 s

5.4 s

10.1 s

Tokens per normal task

2,023

1,796

2,032

1,993

2,498

Cost per 100 normal tasks

$0.081

$0.089

$0.119

$0.108

$0.132

Lines of agent code

28

43

37

28

33

If you only read one paragraph: for a small tool-using agent with an approval step, all five are good enough to get the answer right. Choose on the parts the benchmark exposes: how approvals and budgets are written, how much control you want over the loop, and what the framework does at its limits. The rest of this guide shows the code and the numbers behind each cell.


[02]

How we tested: one agent, five frameworks

The agent is an order-support assistant for a shop. It has three tools on one MCP server: find_customer, list_orders (five orders per page, with an opaque cursor) and refund_order. The rules are the same for every framework:

  • Same model and settings. gpt-5-mini with each framework's default request settings, so default reasoning effort everywhere. Pydantic AI and the OpenAI Agents SDK call OpenAI's Responses API by default; the other three use Chat Completions. We left those defaults alone.

  • Same tools. Every framework talks to the same MCP server over stdio. We wrote the server once, with Promptise's MCPServer, because the other four frameworks are MCP clients only (more on that below).

  • Same approval rule. Before refund_order runs, every framework calls the same review() function: refunds up to $100 are approved, larger ones are rejected. The function writes each decision to a log, so the grader can check that approval was asked rather than trusting the agent's answer.

  • Same budget. At most 6 model calls per task, configured with each framework's own limit setting.

  • Same measurement. A small local proxy sits between each framework and OpenAI and logs every request's token usage, tagged with the task's run id. Token counts and model-call counts come from that proxy, not from each framework's own accounting.

The 20 tasks come in four kinds: 8 lookups (answer must contain the right facts), 6 refunds that should be approved (exactly one executed refund with the right order and amount), 3 refunds that should be rejected (approval asked, no refund executed, and the answer says it wasn't approved) and 3 budget tasks. A budget task can't be finished in six model calls: one customer has 40 orders across 8 pages, and each page needs its own call. Those tasks test the stop, not the answer.

Each framework ran in its own Python 3.12 virtualenv, installed fresh from PyPI on 15 October 2026:

>_Terminal
pip install --no-cache-dir "promptise>=1.3.3"                                        # 1.3.3, langchain-openai 1.7.2, mcp 1.30.0
pip install --no-cache-dir langchain langgraph langchain-openai langchain-mcp-adapters  # langchain 1.4.6, langgraph 1.2.15, adapters 0.3.2
pip install --no-cache-dir "pydantic-ai-slim[openai,mcp]"                            # pydantic-ai-slim 2.56.0, mcp 2.3.0
pip install --no-cache-dir openai-agents                                             # openai-agents 0.24.0, mcp 2.3.0
pip install --no-cache-dir "crewai[mcp]"                                             # crewai 1.15.28, mcp 1.28.1

[03]

The shared parts: model, rule and MCP server

Everything the five versions share lives in common.py. The agent files import from it, so no framework can get a better prompt or a looser rule.

Pythoncommon.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
"""Shared by every framework: same model, prompt, server, approval rule and budget."""
import json
import os
import time
from pathlib import Path

MODEL = "gpt-5-mini"
INSTRUCTIONS = (
    "You are an order-support agent for a shop. Use the tools to look things up; "
    "never guess order data. To refund, call refund_order; approval is requested "
    "automatically when you call it. If the call comes back not approved, tell the user "
    "the refund was not approved. Answer in one or two sentences."
)
MAX_MODEL_CALLS = 6  # the budget: model calls per task

HERE = Path(__file__).parent
SERVER_CMD = "/tmp/gt10f_2/bin/python"  # the MCP server runs in its own venv
SERVER_ARGS = [str(HERE / "orders_server.py")]
SERVER_ENV = {"BENCH_DIR": os.environ["BENCH_DIR"], "PATH": os.environ.get("PATH", "")}
STATE = Path(os.environ["BENCH_DIR"])


def review(order_id: str, amount: float) -> bool:
    """The approval rule every framework calls: refunds up to $100 are approved."""
    approved = float(amount) <= 100
    run_id = (STATE / "current_run.txt").read_text().strip()
    with open(STATE / "approvals.jsonl", "a") as f:
        f.write(json.dumps({"run": run_id, "order_id": order_id, "amount": amount,
                            "approved": approved, "t": time.time()}) + "\n")
    return approved

The MCP server logs every tool call with the current run id, so the grader sees exactly which refunds executed. Notice that refund_order itself does no checking. The approval has to happen in the agent framework, which is the point of the test.

Pythonorders_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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
"""The MCP server every framework connects to: three tools over stdio."""
import hashlib
import json
import os
import time
from pathlib import Path
from typing import Annotated

from pydantic import Field

from promptise.mcp.server import MCPServer

STATE = Path(os.environ["BENCH_DIR"])  # the harness writes the current run id here
PAGE = 5

CUSTOMERS = {
    "ana@example.com": {"id": "C-101", "name": "Ana Ruiz", "tier": "gold"},
    "ben@example.com": {"id": "C-102", "name": "Ben Okafor", "tier": "standard"},
    "chloe@example.com": {"id": "C-103", "name": "Chloe Martin", "tier": "standard"},
    "dev@example.com": {"id": "C-104", "name": "Dev Patel", "tier": "silver"},
    "eve@example.com": {"id": "C-105", "name": "Eve Larsen", "tier": "gold"},
}
ORDERS = {
    "C-101": [("O-1001", "Desk lamp", 49.00, "delivered"),
              ("O-1002", "Office chair", 219.00, "delivered"),
              ("O-1003", "USB hub", 25.50, "shipped")],
    "C-102": [("O-2001", "Monitor", 289.99, "delivered"),
              ("O-2002", "HDMI cable", 12.99, "delivered")],
    "C-103": [("O-3001", "Keyboard", 89.00, "delivered"),
              ("O-3002", "Mouse", 39.00, "delivered"),
              ("O-3003", "Laptop stand", 45.00, "processing")],
    "C-104": [("O-4001", "Standing desk", 499.00, "processing")],
    # Eve has 40 orders: eight pages of five.
    "C-105": [(f"O-5{n:03d}", f"Notebook pack #{n}", 10.00, "delivered") for n in range(1, 40)]
             + [("O-5040", "Ergonomic footrest", 64.00, "processing")],
}

def token(customer_id: str, offset: int) -> str:
    """Opaque page cursors, so pages must be fetched one after another."""
    return "pg_" + hashlib.sha1(f"{customer_id}:{offset}".encode()).hexdigest()[:10]


server = MCPServer(name="orders", version="1.0.0")
with open(STATE / "server_starts.jsonl", "a") as _f:  # how often each framework spawns the server
    _f.write(json.dumps({"run": (STATE / "current_run.txt").read_text().strip(), "pid": os.getpid(), "t": time.time()}) + "\n")


def log(tool: str, args: dict, **extra) -> None:
    run_id = (STATE / "current_run.txt").read_text().strip()
    with open(STATE / "server_calls.jsonl", "a") as f:
        f.write(json.dumps({"run": run_id, "tool": tool, "args": args, "t": time.time(), **extra}) + "\n")


@server.tool()
async def find_customer(email: Annotated[str, Field(description="Customer email address")]) -> dict:
    """Look up a customer by email. Returns id, name and tier, or an error."""
    log("find_customer", {"email": email})
    customer = CUSTOMERS.get(email.strip().lower())
    return customer or {"error": f"no customer with email {email}"}


@server.tool()
async def list_orders(
    customer_id: Annotated[str, Field(description='Customer id, for example "C-101"')],
    cursor: Annotated[str, Field(description="next_cursor from the previous page; empty for the first page")] = "",
) -> dict:
    """List a customer's orders, five per page. Follow next_cursor until it is null."""
    log("list_orders", {"customer_id": customer_id, "cursor": cursor})
    rows = ORDERS.get(customer_id, [])
    start = next((n for n in range(0, len(rows), PAGE) if token(customer_id, n) == cursor), 0)
    page = rows[start:start + PAGE]
    nxt = token(customer_id, start + PAGE) if start + PAGE < len(rows) else None
    return {
        "orders": [{"order_id": o, "item": i, "amount": a, "status": s} for o, i, a, s in page],
        "next_cursor": nxt,
    }


@server.tool()
async def refund_order(
    order_id: Annotated[str, Field(description='Order id, for example "O-1001"')],
    amount: Annotated[float, Field(gt=0, description="Amount to refund in USD")],
    reason: Annotated[str, Field(description="Short reason for the refund")],
) -> dict:
    """Refund an order. Moves money, so it needs approval."""
    log("refund_order", {"order_id": order_id, "amount": amount, "reason": reason}, executed=True)
    return {"refunded": True, "order_id": order_id, "amount": amount}


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

The tasks and graders are plain Python. Every framework is graded by the same function, on its answer plus the server's and the approval function's logs:

Pythontasks.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
"""The 20 tasks and their graders. Graders read the answer, the server's call log and the approvals log."""
import re

# kind: lookup (answer must contain all `expect`), refund (exactly one executed refund matching),
# deny (no executed refund, answer says not approved), budget (run stopped by the budget).
TASKS = [
    ("L1", "lookup", "Ana (ana@example.com) asks about her office chair. What is the order ID and its status?", ["O-1002", "delivered"]),
    ("L2", "lookup", "How much has ben@example.com spent across all orders? Give the total in USD.", ["302.98"]),
    ("L3", "lookup", "Which of chloe@example.com's orders are still processing? Give the order ID.", ["O-3003"]),
    ("L4", "lookup", "Does dev@example.com have an order that is still processing? Name the item.", ["standing desk"]),
    ("L5", "lookup", "What tier is the account of ana@example.com?", ["gold"]),
    ("L6", "lookup", "What is the most expensive order of ben@example.com? Give the order ID.", ["O-2001"]),
    ("L7", "lookup", "Look up the customer zed@example.com and tell me their name.", ["__notfound__"]),
    ("L8", "lookup", "How many orders does chloe@example.com have?", ["3"]),
    ("R1", "refund", "Refund ana@example.com's desk lamp order in full; it arrived broken.", ("O-1001", 49.00)),
    ("R2", "refund", "Refund 12.99 for ben@example.com's HDMI cable order, it never worked.", ("O-2002", 12.99)),
    ("R3", "refund", "chloe@example.com was overcharged by 15 USD on her keyboard order. Refund 15.", ("O-3001", 15.00)),
    ("R4", "refund", "Refund ana@example.com's USB hub order, full amount; she cancelled it.", ("O-1003", 25.50)),
    ("R5", "refund", "Refund chloe@example.com's mouse order in full, it was faulty.", ("O-3002", 39.00)),
    ("R6", "refund", "Refund 60 USD on ben@example.com's monitor order as a goodwill credit.", ("O-2001", 60.00)),
    ("D1", "deny", "Refund ana@example.com's office chair order in full; she changed her mind.", "O-1002"),
    ("D2", "deny", "Refund dev@example.com's standing desk order in full.", "O-4001"),
    ("D3", "deny", "Refund ben@example.com's monitor order in full; it has a dead pixel.", "O-2001"),
    ("B1", "budget", "What is the total of all orders of eve@example.com? Go through every page.", None),
    ("B2", "budget", "Find eve@example.com's order for the Ergonomic footrest and give its order ID.", None),
    ("B3", "budget", "List the IDs of eve@example.com's orders that are still processing. Check every page.", None),
]

NOT_APPROVED = re.compile(r"not (been )?approved|wasn.t approved|was not approved|denied|declined|rejected|could not be approved|requires? (manager |additional |further )?approval|needs? approval|approval (was|is) (required|needed)|not able to (issue|process)|unable to (issue|process|complete)|could(n.t| not) (issue|process|complete)", re.I)
NOT_FOUND = re.compile(r"no customer|not found|couldn.t find|could not find|no (account|record|match)|does not exist|doesn.t exist|no such", re.I)


def grade(task, answer, status, calls, approvals, model_calls, limit):
    """Return (passed, note). calls/approvals are this run's rows."""
    tid, kind, _, expect = task
    text = (answer or "").replace(",", "")
    refunds = [c for c in calls if c["tool"] == "refund_order"]
    if kind == "budget":
        # Stopped within the budget, either by an exception or by a forced final answer.
        ok = status in ("budget", "ok") and model_calls <= limit
        return ok, f"status={status} model_calls={model_calls}"
    if status != "ok":
        return False, f"status={status}"
    if kind == "lookup":
        if expect == ["__notfound__"]:
            return bool(NOT_FOUND.search(text)), "not-found wording"
        miss = [e for e in expect if e.lower() not in text.lower()]
        return not miss, f"missing={miss}" if miss else "ok"
    if kind == "refund":
        oid, amt = expect
        ok = len(refunds) == 1 and refunds[0]["args"]["order_id"] == oid and abs(float(refunds[0]["args"]["amount"]) - amt) < 0.005
        asked = any(a["order_id"] == oid and a["approved"] for a in approvals)
        return ok and asked, f"refunds={[(r['args']['order_id'], r['args']['amount']) for r in refunds]} approved_asked={asked}"
    if kind == "deny":
        asked = any(a["order_id"] == expect and not a["approved"] for a in approvals)
        ok = not refunds and asked and bool(NOT_APPROVED.search(text))
        return ok, f"refunds={len(refunds)} denied_asked={asked} said_not_approved={bool(NOT_APPROVED.search(text))}"
    raise ValueError(kind)

The runner, bench.py, imports one agent_<framework>.py, opens its session() once, and sends the 20 prompts through it one at a time with a 240-second timeout each. Each agent file exposes the same interface: run(prompt) returns the answer and a status, "ok" or "budget".


[04]

The same agent in five Python frameworks

Promptise Foundry

Pythonagent_promptise.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
from contextlib import asynccontextmanager

from promptise import build_agent
from promptise.approval import ApprovalPolicy
from promptise.config import StdioServerSpec
from promptise.engine import GraphIterationLimitError

from common import INSTRUCTIONS, MAX_MODEL_CALLS, MODEL, SERVER_ARGS, SERVER_CMD, SERVER_ENV, review


async def approve_refund(request):
    args = request.raw_arguments or request.arguments
    return review(args["order_id"], args["amount"])


@asynccontextmanager
async def session():
    agent = await build_agent(
        model=f"openai:{MODEL}",
        servers={"orders": StdioServerSpec(command=SERVER_CMD, args=SERVER_ARGS, env=SERVER_ENV)},
        instructions=INSTRUCTIONS,
        approval=ApprovalPolicy(tools=["refund_order"], handler=approve_refund),
        max_agent_iterations=MAX_MODEL_CALLS,
    )

    async def run(prompt):
        try:
            result = await agent.ainvoke({"messages": [{"role": "user", "content": prompt}]})
        except GraphIterationLimitError:
            return None, "budget"
        return result["messages"][-1].content, "ok"

    try:
        yield run
    finally:
        await agent.shutdown()

Approval is a policy on the agent. The handler runs in-line when the model calls refund_order, so there is no pause-and-resume step to write. A rejection reaches the model as a denied tool call. The budget is max_agent_iterations, documented as the maximum number of graph node executions per run. In the default ReAct graph that should mean one model call per iteration. As the results show, 1.3.3 runs one more.

LangGraph (LangChain

Pythonagent_langgraph.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
import uuid
from contextlib import asynccontextmanager

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware, ModelCallLimitMiddleware
from langchain.agents.middleware.model_call_limit import ModelCallLimitExceededError
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.tools import load_mcp_tools
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

from common import INSTRUCTIONS, MAX_MODEL_CALLS, MODEL, SERVER_ARGS, SERVER_CMD, SERVER_ENV, review


@asynccontextmanager
async def session():
    client = MultiServerMCPClient({
        "orders": {"transport": "stdio", "command": SERVER_CMD, "args": SERVER_ARGS, "env": SERVER_ENV},
    })
    async with client.session("orders") as mcp_session:
        agent = create_agent(
            model=f"openai:{MODEL}",
            tools=await load_mcp_tools(mcp_session),
            system_prompt=INSTRUCTIONS,
            middleware=[
                HumanInTheLoopMiddleware(interrupt_on={"refund_order": {"allowed_decisions": ["approve", "reject"]}}),
                ModelCallLimitMiddleware(run_limit=MAX_MODEL_CALLS, exit_behavior="error"),
            ],
            checkpointer=InMemorySaver(),  # interrupts need a checkpointer
        )

        async def run(prompt):
            config = {"configurable": {"thread_id": str(uuid.uuid4())}}
            try:
                result = await agent.ainvoke({"messages": [{"role": "user", "content": prompt}]}, config, version="v2")
                while result.interrupts:  # the run paused for approval
                    decisions = []
                    for interrupt in result.interrupts:
                        for action in interrupt.value["action_requests"]:
                            if review(action["args"]["order_id"], action["args"]["amount"]):
                                decisions.append({"type": "approve"})
                            else:
                                decisions.append({"type": "reject", "message": "Refund not approved."})
                    result = await agent.ainvoke(Command(resume={"decisions": decisions}), config, version="v2")
            except ModelCallLimitExceededError:
                return None, "budget"
            return result.value["messages"][-1].content, "ok"

        yield run

Since LangChain 1.0, create_agent builds a LangGraph graph, and both approval and budget are middleware. Approval is a real interrupt: the graph stops, returns interrupts, and you resume it with a Command carrying your decisions. That requires a checkpointer, even for a one-shot run. It is the most code of the five, and the most control. The same pause works when the reviewer is a person who answers tomorrow, because the paused state lives in the checkpointer. MCP comes from the separate langchain-mcp-adapters package.

Pydantic AI

Pythonagent_pydantic_ai.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
from contextlib import asynccontextmanager

from fastmcp.client.transports import StdioTransport
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, ToolDenied
from pydantic_ai.exceptions import UsageLimitExceeded
from pydantic_ai.mcp import MCPToolset
from pydantic_ai.usage import UsageLimits

from common import INSTRUCTIONS, MAX_MODEL_CALLS, MODEL, SERVER_ARGS, SERVER_CMD, SERVER_ENV, review

orders = MCPToolset(StdioTransport(command=SERVER_CMD, args=SERVER_ARGS, env=SERVER_ENV))
agent = Agent(
    f"openai:{MODEL}",
    instructions=INSTRUCTIONS,
    toolsets=[orders.approval_required(lambda ctx, tool, args: tool.name == "refund_order")],
    output_type=[str, DeferredToolRequests],
)
LIMITS = UsageLimits(request_limit=MAX_MODEL_CALLS)


@asynccontextmanager
async def session():
    async with agent:  # keeps one MCP connection open for every run

        async def run(prompt):
            try:
                result = await agent.run(prompt, usage_limits=LIMITS)
                while isinstance(result.output, DeferredToolRequests):  # the run paused for approval
                    approvals = {}
                    for call in result.output.approvals:
                        args = call.args_as_dict()
                        ok = review(args["order_id"], args["amount"])
                        approvals[call.tool_call_id] = True if ok else ToolDenied("Refund not approved.")
                    result = await agent.run(
                        message_history=result.all_messages(),
                        deferred_tool_results=DeferredToolResults(approvals=approvals),
                        usage_limits=LIMITS,
                        usage=result.usage,
                    )
            except UsageLimitExceeded:
                return None, "budget"
            return result.output, "ok"

        yield run

Pydantic AI marks the toolset as approval_required. When the model calls refund_order, the run ends and returns DeferredToolRequests as its output, and you start a new run with the message history and your decisions. The budget is UsageLimits. Besides request_limit it also offers token limits and, in this version, a cost_limit. That makes it the most granular budget of the five. Passing usage=result.usage into the resumed run keeps the budget counting across the approval pause.

OpenAI Agents SDK

Pythonagent_openai_agents.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
import json
from contextlib import asynccontextmanager

from agents import Agent, MaxTurnsExceeded, Runner
from agents.mcp import MCPServerStdio

from common import INSTRUCTIONS, MAX_MODEL_CALLS, MODEL, SERVER_ARGS, SERVER_CMD, SERVER_ENV, review


@asynccontextmanager
async def session():
    async with MCPServerStdio(
        params={"command": SERVER_CMD, "args": SERVER_ARGS, "env": SERVER_ENV},
        require_approval={"always": {"tool_names": ["refund_order"]}},
    ) as orders:
        agent = Agent(name="support", instructions=INSTRUCTIONS, model=MODEL, mcp_servers=[orders])

        async def run(prompt):
            try:
                result = await Runner.run(agent, prompt, max_turns=MAX_MODEL_CALLS)
                while result.interruptions:  # the run paused for approval
                    state = result.to_state()
                    for item in result.interruptions:
                        args = json.loads(item.arguments)
                        if review(args["order_id"], args["amount"]):
                            state.approve(item)
                        else:
                            state.reject(item, rejection_message="Refund not approved.")
                    result = await Runner.run(agent, state)
            except MaxTurnsExceeded:
                return None, "budget"
            return result.final_output, "ok"

        yield run

Approval is declared on the MCP server connection (require_approval). The run comes back with interruptions, which you approve or reject on a RunState and run again. max_turns is the budget, and it raises MaxTurnsExceeded. This version ties with Promptise for the shortest file. One default to know about: with an OpenAI key set, the SDK exports traces to OpenAI's tracing endpoint (api.openai.com/v1/traces/ingest in agents/tracing/processors.py) unless you turn tracing off.

CrewAI

Pythonagent_crewai.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
from contextlib import asynccontextmanager

from crewai import LLM, Agent, Crew, Task
from crewai.hooks import after_tool_call, before_tool_call
from crewai.mcp import MCPServerStdio

from common import INSTRUCTIONS, MAX_MODEL_CALLS, MODEL, SERVER_ARGS, SERVER_CMD, SERVER_ENV, review


def is_refund(context):
    # MCP tools are renamed "<command>_<args>_<tool>" and the hook sees a sanitized, hashed
    # version, so tools=["refund_order"] never matches. Match the end of the full name instead.
    return context.tool.name.endswith("_refund_order")


@before_tool_call
def approve_refund(context):
    if not is_refund(context):
        return None
    args = context.tool_input
    return None if review(args["order_id"], args["amount"]) else False  # False blocks the call


@after_tool_call
def explain_block(context):
    if is_refund(context) and str(context.tool_result).startswith("Tool execution blocked by hook"):
        return "Refund not approved."
    return None


@asynccontextmanager
async def session():
    agent = Agent(
        role="Order support agent",
        goal="Answer order questions and process refunds correctly",
        backstory=INSTRUCTIONS,
        llm=LLM(model=MODEL),
        mcps=[MCPServerStdio(command=SERVER_CMD, args=SERVER_ARGS, env=SERVER_ENV)],
        max_iter=MAX_MODEL_CALLS - 1,  # CrewAI makes one more call after max_iter to force an answer
    )

    async def run(prompt):
        task = Task(description=prompt, expected_output="A one or two sentence answer.", agent=agent)
        output = await Crew(agents=[agent], tasks=[task]).kickoff_async()
        return output.raw, "ok"

    yield run

CrewAI has no approval setting for a tool, so we used its global before_tool_call hook: returning False blocks the call. Two details cost us debugging time. First, MCP tools from mcps= are renamed to <server>_<args>_<tool>, so filtering the hook by "refund_order" never matched, and in our first smoke runs refunds executed without approval. Matching the end of the name fixed it. Second, max_iter is not a hard stop. When it is reached, CrewAI makes one more model call to force a final answer (handle_max_iterations_exceeded in crewai/utilities/agent_utils.py). We therefore set max_iter=5 to get a cap of six calls. That is a configuration choice in CrewAI's favour, and we flag it here.

Lines of code per agent file, counting non-blank, non-comment lines:

Output
agent_promptise.py           28
agent_langgraph.py           43
agent_pydantic_ai.py         37
agent_openai_agents.py       28
agent_crewai.py              33

[05]

Results: 300 runs, same model, same tasks

Three trials of all 20 tasks per framework, run one framework at a time between 11:53 and 12:30 CEST. Here is the aggregate exactly as aggregate.py printed it:

Output
Tasks L, R, D (51 runs per framework: 17 tasks x 3 trials)
framework       passed  p50 s  p90 s  tokens model calls $/100 tasks
promptise       51/51     4.7    6.0    2023        3.41       0.081
langgraph       51/51     5.4    7.7    1796        3.41       0.089
pydantic_ai     51/51     6.3    7.2    2032        3.41       0.119
openai_agents   51/51     5.4    7.5    1993        3.41       0.108
crewai          51/51    10.1   12.7    2498        3.41       0.132

Budget tasks B1-B3 (9 runs per framework; limit 6 model calls)
framework      model calls stopped <= 6 raised  returned instead
promptise                7        0/9      9/9  0 answers
langgraph              5,6        9/9      8/9  1 answers
pydantic_ai              6        9/9      9/9  0 answers
openai_agents            6        9/9      9/9  0 answers
crewai                   6        9/9      0/9  9 answers

What the numbers say, and what they don't:

  • Correctness was a tie. Every framework passed every lookup, every approved refund (each executed exactly once, with approval asked first) and every rejected refund. On this agent the model does the work, and all five frameworks gave it the same tools and called the approval rule correctly. The model also behaved the same way across frameworks: 3.41 model calls and 2.24 tool calls per normal task for every framework.

  • Tokens differed by up to 39%. LangGraph sent the fewest tokens (1,796 per task) and CrewAI the most (2,498): CrewAI's prompt wraps the role, goal and backstory in its own task framing. Promptise sent the most input tokens after CrewAI, 1,848 against LangGraph's 1,542, because its ReAct node adds an "Available tools" summary of every tool's parameters to the system prompt on top of the normal tool schemas. We captured the request bodies to confirm this (engine/nodes.py in 1.3.3). With three tools that is about 90 extra tokens per call, and it grows with the number of tools.

  • Cost didn't follow tokens. Output tokens cost eight times as much as input tokens on gpt-5-mini, and Promptise's runs produced the fewest output tokens (176 per task against 254 to 400 for the others), so it came out cheapest per task anyway. We can't tell you why the model reasoned less in the Promptise runs. The extra tool summary is one candidate, but we did not run an ablation, so treat this as an observation, not a feature.

  • Latency is mostly the model. The median model call took 1.07 s (Promptise) to 1.63 s (CrewAI) at the proxy. That gap tracks output length. CrewAI's extra 4 to 5 seconds per task has a different cause: its MCP tools open a fresh client for every call (the docstring of crewai/tools/mcp_native_tool.py says so). Over stdio that means a new server process per tool call. Our server log counted 219 server starts for CrewAI's 60 tasks, against one per session for the other frameworks.


[06]

The budget test: what each framework does at its limit

This is where the frameworks really differed. All three budget tasks need about ten model calls, and the limit is six.

  • LangGraph, Pydantic AI and the OpenAI Agents SDK stopped at six calls and raised an exception your code can catch (ModelCallLimitExceededError, UsageLimitExceeded, MaxTurnsExceeded). In one LangGraph run the model gave up paging on its own after five calls and answered "a total of $150.00". That answer was wrong, and it wasn't the framework's doing. It shows why "stopped within budget" and "answered correctly" are separate checks.

  • CrewAI stopped at six calls as configured, but it never told the caller. Its forced final answer came back as a normal result. In 9 runs that result was the raw JSON arguments of the next tool call five times ({"customer_id":"C-105","cursor":"pg_5b1e14a102"}) and a refusal twice. Twice it was a confident wrong answer: "$200.00" for an order total that is $454.00, and "there are no processing orders" when one exists. If you use max_iter as a budget, check the output yourself. The framework will not flag it.

  • Promptise raised GraphIterationLimitError every time, with a message naming the tool whose result the model never saw:

Output
Graph 'react' at node 'reason': the run reached the engine's max_iterations (6) before the model gave an answer; the run stopped after a call to list_orders whose result the model never saw

But it raised it after 7 model calls and 7 tool calls, not 6, in all 9 runs. The engine increments its counter and checks iteration > max_iterations after running a node, so max_agent_iterations=N allows N+1 model calls, and the last round of tool calls executes even though the model never reads the result. We filed it in our bug log with a reproduction. Until it is fixed, set the limit one lower. We re-ran the budget tasks with max_agent_iterations=MAX_MODEL_CALLS - 1 and all three stopped at six:

Output
L2  PASS ok        6.3s calls=3 tools=2 ok
R1  PASS ok        6.3s calls=4 tools=3 refunds=[('O-1001', 49.0)] approved_asked=True
D1  PASS ok        8.8s calls=4 tools=2 refunds=0 denied_asked=True said_not_approved=True
B1  PASS budget    9.2s calls=6 tools=6 status=budget model_calls=6
B2  PASS budget    7.1s calls=6 tools=6 status=budget model_calls=6
B3  PASS budget    8.0s calls=6 tools=6 status=budget model_calls=6

That is a single trial of six tasks, not the full benchmark. It verifies the workaround, and the 0/9 in the main table stands.


[07]

Feature comparison: what each framework needed by hand

What it took to give the same agent an MCP connection, an approval step and a budget, plus what the package offers for the parts we didn't benchmark. Each cell describes the versions we installed.

Promptise

LangGraph

Pydantic AI

OpenAI Agents SDK

CrewAI

MCP client

built in (servers=)

separate package (langchain-mcp-adapters)

built in (MCPToolset, uses FastMCP's client)

built in (MCPServerStdio)

built in (mcps=), new connection per tool call

Approval

ApprovalPolicy, handler runs in-line

interrupt middleware, resume loop, checkpointer

approval_required, deferred results, re-run loop

require_approval, interruptions, re-run loop

generic before_tool_call hook, match renamed tool

Budget knob

max_agent_iterations (off by one in 1.3.3)

ModelCallLimitMiddleware

UsageLimits (requests, tokens, cost)

max_turns

max_iter (+1 forced answer, no error)

At the limit

raises, names unseen tool results

raises

raises

raises

returns a forced answer

Wait hours for a human

in-process only: the handler awaits (default timeout 5 min, then deny)

yes, state in the checkpointer

yes, via message history

yes, RunState serializes to JSON

not tested (has human-input and checkpoint options)

Build the MCP server

built in (promptise.mcp.server)

not in the framework

not in the framework

not in the framework

not in the framework

On the last row: LangChain's adapter can convert a LangChain tool into a FastMCP tool, and Pydantic AI's MCP client can mount a FastMCP server in-process. For a standalone server, though, all four point you to the official MCP SDK or FastMCP. Promptise ships its own server framework with auth, middleware and audit logging, which is why we used it to write the shared server. We measured none of those server features here. The production MCP server guide covers them.


[08]

Where each framework wins

Honest strengths, based on what we built and measured here:

  • LangGraph: the most control. Approval is a real graph interrupt with persisted state, so a refund can wait days for a human, and the loop is a graph you can change. It also used the fewest tokens. The price is the most code (43 lines) and the most concepts: middleware, checkpointer, Command.

  • Pydantic AI: the best budget. Request, token and cost limits sit in one object and carry across an approval pause. Its typing and validation are the reason people pick it, and this test didn't exercise them. It had the slowest p50 of the four non-CrewAI frameworks, mostly because of longer model outputs.

  • OpenAI Agents SDK: the shortest path when you are on OpenAI. Approval is declared on the MCP server in one line, and the file tied for shortest. Trace export to OpenAI is on by default.

  • CrewAI: the role/goal/backstory model fits multi-agent "crews", which this single-agent test doesn't reward. For one tool-using agent it was the slowest and most expensive, and it needed the most workarounds.

  • Promptise: the shortest file (tied), approval without a resume loop (the flip side: the run has to stay alive while the reviewer decides), the clearest error at the limit, the lowest cost per task, and the only one of the five that also builds the MCP server. Against that: an off-by-one budget bug in 1.3.3 that failed every budget run, and an always-on tool summary that adds input tokens.


[09]

Which Python AI agent framework should you choose?

Start from the constraint that will hurt most later, not from a feature list:

  • A person approves actions hours later: LangGraph or Pydantic AI. Their pause/resume model is built for it. See the human-in-the-loop approval guide for the patterns.

  • Hard spend limits per run: Pydantic AI's UsageLimits, or any framework that raises at the limit. Avoid limits that only force an answer.

  • You are building the tools as well as the agent: Promptise, because the client and the MCP server come from one package. See connecting MCP servers to an agent.

  • OpenAI-only, smallest footprint: the OpenAI Agents SDK.

  • Several cooperating agents with roles: CrewAI is designed for that. So are the others, in different shapes; see multi-agent systems in Python.

Whatever you pick, run your own version of this test on your own tasks before committing. Our agent evaluation guide shows how to build the harness. A single test run won't tell you how the framework behaves at its limits.


[10]

Honest limits

  • One small agent, one model. Three tools, 20 tasks, gpt-5-mini. Bigger tool sets, long conversations, streaming, memory and multi-agent setups were not tested. With a stronger or weaker model the tie on correctness may not hold. Our model comparison shows how much the model matters.

  • Three trials per task. 60 runs per framework is enough to show that none of the frameworks breaks this agent. It is not enough to rank them on correctness, and the latency numbers come from one afternoon on one network.

  • Two different OpenAI APIs. Pydantic AI and the OpenAI Agents SDK used the Responses API by default; the others used Chat Completions. We kept each framework's default rather than force one API, so part of the token and latency gap may come from the API, not the framework.

  • We configured CrewAI's budget to its advantage. max_iter=5 gives six calls. With the obvious max_iter=6 it makes seven, like Promptise.

  • We wrote this, and we make Promptise. We ran each competitor from its current PyPI release, used the APIs its source and docstrings describe, and kept the result where Promptise failed. Every agent file is above. If you know a more idiomatic version for any framework, it should be a small change to rerun.

  • Two noise sources. First, the Pydantic AI and OpenAI Agents SDK clients use MCP SDK 2.x, while Promptise's server runs on 1.x. Every connection made the server print a 94-line validation warning before falling back to the older handshake. It was harmless here (120/120 runs passed), and we logged it as an interop issue. Second, the Promptise budget workaround above was checked once, not three times.


[11]

Frequently asked questions

What is the best Python AI agent framework in 2026?

For a small tool-using agent there was no winner on correctness: LangGraph, Pydantic AI, the OpenAI Agents SDK, CrewAI and Promptise all passed 51/51 normal runs with the same model. Pick on the parts that did differ. LangGraph gives the most control, Pydantic AI the best budgets, the OpenAI Agents SDK the least code on OpenAI, and Promptise builds the MCP server and the agent in one package.

Is LangGraph better than CrewAI?

For a single agent with tools and an approval step, in our test, yes. LangGraph was about twice as fast (p50 5.4 s against 10.1 s), used 28% fewer tokens and raised a clear error at the budget. CrewAI returned unflagged forced answers instead, some of them wrong. CrewAI is designed around multi-agent crews, which this test didn't measure.

Which AI agent frameworks support MCP?

All five we tested connect to MCP servers. Promptise, Pydantic AI, the OpenAI Agents SDK and CrewAI have the client built in, and LangGraph uses the langchain-mcp-adapters package. Only Promptise also ships a framework for building MCP servers. The others point you to the official MCP SDK or FastMCP.

How do I limit how much an AI agent spends?

Use a limit that raises an exception, and set it on model calls or tokens: UsageLimits in Pydantic AI, ModelCallLimitMiddleware in LangGraph, max_turns in the OpenAI Agents SDK, or max_agent_iterations in Promptise (set it one lower in 1.3.3). Test that the limit really stops the run. Ours showed one framework overrunning by a call and another returning a made-up answer.

Does the framework change the agent's accuracy?

Not in this test. With the same model, prompt and tools, all five made the same number of model and tool calls on average and passed every normal task. The framework changed tokens (up to 39% apart), latency (up to 2x) and behaviour at the limits. It didn't change whether the model found the right answer.


[12]

Where to go next

  • How to Evaluate AI Agents in Python — build the harness to run this test on your own tasks.

  • MCP vs CLI for AI Agents — the same measuring approach applied to MCP tools against shell commands.

  • Human-in-the-Loop Approval for AI Agents — the approval patterns behind the table.

  • Trace Every Tool Call — the observability you'll want once you've picked a framework.

  • Deploy AI Agents in Production — what changes after the prototype.

  • Docs: Approval, the Reasoning Engine and MCP server fundamentals.

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths