Home / Articles / Blog

Python AI SDKs in 2026: OpenAI Agents SDK vs Pydantic-AI vs smolagents vs Strands
PythonAI AgentsOpenAIPydanticAWS

Python AI SDKs in 2026: OpenAI Agents SDK vs Pydantic-AI vs smolagents vs Strands

A code-first python ai agent sdk comparison: build the same three-tool agent in OpenAI Agents SDK, Pydantic-AI, smolagents, and Strands — execution models, testing, and which framework fits.

Shrey Vijayvargiya

If you search "best Python AI agent framework" right now, you'll get a dozen posts that read almost identically: a bullet list pulled from each project's own README, a paragraph of adjectives ("lightweight," "type-safe," "production-ready"), and a recommendation that somehow always lines up with whichever framework the post's author works for. None of them show you the actual code long enough to tell you anything real.

Worth being honest about scale first: the broader "AI agent" GitHub landscape is enormous and mostly irrelevant to this specific comparison. Visual workflow builders like Dify (130,000+ stars) and Langflow, and general-purpose autonomous-agent projects like AutoGPT (180,000+ stars), solve a genuinely different problem — no-code orchestration and open-ended autonomy — than what we're comparing here. The four frameworks in this piece are specifically Python-native, code-first, developer-controlled agent SDKs — you write the tools, you write the logic, the framework handles the model loop. That's a narrower, more useful comparison for anyone actually shipping a product, and it's why LangChain, CrewAI, and the visual builders aren't on this list even though they're bigger by star count.

So this one does something different. We're building the exact same agent — three tools, one task — in four of the most-used Python agent SDKs right now: OpenAI Agents SDK, Pydantic-AI, smolagents, and Strands Agents SDK. Then we're comparing what actually came out, not what the docs promised. For reference points on package maturity, martimfasantos' ai-agents-frameworks repo — a genuinely useful playground with working examples for 14 different frameworks, sitting at 616 stars and 64 forks — pins the versions we're working against: OpenAI Agents SDK 0.17.6, Pydantic-AI 1.107.0, smolagents 1.26.0, and Strands Agents SDK 1.41.0. Community size varies a lot across the four — Pydantic-AI sits around 17,000 GitHub stars and smolagents has passed 24,000, while Strands, younger and AWS-backed, has a smaller but fast-growing community concentrated around Bedrock users.

The task: one agent, three tools, every framework

To keep the comparison honest, every version below builds the identical thing: an assistant with three simple, self-contained tools —

  1. get_weather(city) — returns a mock weather string for a city
  2. get_stock_price(ticker) — returns a mock price for a stock ticker
  3. calculate(expression) — evaluates a basic arithmetic expression

No external API keys required to follow along, no framework-specific magic hidden in a tutorial's setup script. Just the part that actually matters: how much code it takes, what the ergonomics feel like, and what each framework is quietly optimizing for.

OpenAI Agents SDK

OpenAI's own production framework — the successor to the Swarm prototype, and the same primitives that power AgentKit's visual builder underneath. The whole package imports as agents, and the shape is built around three concepts: Agent, Runner, and function_tool. Installing it is a single pip install openai-agents, with your OPENAI_API_KEY picked up from the environment automatically.

python
from agents import Agent, Runner, function_tool

@function_tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"The weather in {city} is 72°F and sunny."

@function_tool
def get_stock_price(ticker: str) -> str:
    """Get the current stock price for a ticker symbol."""
    prices = {"AAPL": 178.32, "GOOGL": 141.80, "MSFT": 402.15}
    return f"{ticker} is trading at ${prices.get(ticker.upper(), 'unknown')}"

@function_tool
def calculate(expression: str) -> str:
    """Evaluate a basic arithmetic expression, e.g. '12 * 4 + 7'."""
    try:
        return str(eval(expression, {"__builtins__": {}}))
    except Exception as e:
        return f"Could not calculate: {e}"

agent = Agent(
    name="Assistant",
    instructions="You are a helpful assistant with access to weather, stock, and calculator tools.",
    tools=[get_weather, get_stock_price, calculate],
)

result = Runner.run_sync(agent, "What's the weather in Austin, and what's 340 * 12?")
print(result.final_output)

Twenty-some lines, and every tool is just a plain function with a @function_tool decorator on top — the decorator reads the type hints and docstring to build the schema the model sees, so there's no separate schema definition to keep in sync. Runner.run_sync() (or await Runner.run() for the async version) handles the whole tool-calling loop internally: call the model, execute any tool calls it requests, feed results back, repeat until there's a final answer. You never write that loop yourself.

What the code doesn't show, but matters: this is the same Agent primitive that supports handoffs to other agents (agent.as_tool() or a handoffs=[] list) and built-in guardrails on input/output — so a 3-tool single agent and a multi-agent triage system with specialists are the same mental model, just more agents wired together. That's the framework's actual bet: production-grade single-agent loops that scale cleanly into delegation, without switching abstractions.

Pydantic-AI

Built by the team behind Pydantic itself — the validation library already running inside FastAPI, LangChain, and roughly every other Python AI tool you've used. The pitch is specific: treat an LLM call like a typed function call, not a string-in-string-out black box. pip install pydantic-ai gets you the whole framework, including support for OpenAI, Anthropic, Google, Groq, Mistral, Cohere, Bedrock, and any OpenAI-compatible endpoint out of the box — the "openai:gpt-4o" string in the example below is a provider-prefixed model identifier, and swapping providers is usually just swapping that one string.

python
from pydantic_ai import Agent

agent = Agent("openai:gpt-4o")

@agent.tool_plain
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"The weather in {city} is 72°F and sunny."

@agent.tool_plain
def get_stock_price(ticker: str) -> str:
    """Get the current stock price for a ticker symbol."""
    prices = {"AAPL": 178.32, "GOOGL": 141.80, "MSFT": 402.15}
    return f"{ticker} is trading at ${prices.get(ticker.upper(), 'unknown')}"

@agent.tool_plain
def calculate(expression: str) -> str:
    """Evaluate a basic arithmetic expression, e.g. '12 * 4 + 7'."""
    try:
        return str(eval(expression, {"__builtins__": {}}))
    except Exception as e:
        return f"Could not calculate: {e}"

result = agent.run_sync("What's the weather in Austin, and what's 340 * 12?")
print(result.output)

Structurally near-identical line count to the OpenAI SDK version, but two details separate it immediately. First, tools register two ways: @agent.tool_plain for stateless functions like these, or @agent.tool when a tool needs RunContext — a typed dependency-injection object that can carry a database connection, an authenticated user, an HTTP client, whatever your tool actually needs, without reaching for global state. None of our three tools need that here, but the moment one does (say, a tool that queries a real database scoped to the current user), Pydantic-AI's answer is built in rather than bolted on.

Second — and this is the framework's actual differentiator — you can hand Agent() an output_type and get back a validated Pydantic model instead of a string, with automatic retries if the model returns something that doesn't fit the schema. We didn't use that here because the task is a plain text answer, but it's the reason teams pick Pydantic-AI specifically for structured-extraction work: invoice parsing, form-filling, anything where "the LLM said something JSON-shaped" isn't good enough and you need "the LLM produced a Invoice object with a total: float field, guaranteed."

smolagents

Hugging Face's entry, and the one genuinely different paradigm in this comparison. Where the other three frameworks have the model emit a tool-call request in JSON that the framework then executes, smolagents' default CodeAgent has the model write actual Python code at every step, which then runs in a constrained interpreter. pip install smolagents for the core; model-provider extras (smolagents[openai], smolagents[litellm], and so on) are installed separately depending on which model you're pointing at.

python
from smolagents import CodeAgent, InferenceClientModel, tool

@tool
def get_weather(city: str) -> str:
    """
    Get the current weather for a city.

    Args:
        city: The name of the city.
    """
    return f"The weather in {city} is 72°F and sunny."

@tool
def get_stock_price(ticker: str) -> str:
    """
    Get the current stock price for a ticker symbol.

    Args:
        ticker: The stock ticker symbol, e.g. 'AAPL'.
    """
    prices = {"AAPL": 178.32, "GOOGL": 141.80, "MSFT": 402.15}
    return f"{ticker} is trading at ${prices.get(ticker.upper(), 'unknown')}"

@tool
def calculate(expression: str) -> str:
    """
    Evaluate a basic arithmetic expression.

    Args:
        expression: A math expression as a string, e.g. '12 * 4 + 7'.
    """
    try:
        return str(eval(expression, {"__builtins__": {}}))
    except Exception as e:
        return f"Could not calculate: {e}"

model = InferenceClientModel()
agent = CodeAgent(tools=[get_weather, get_stock_price, calculate], model=model)
agent.run("What's the weather in Austin, and what's 340 * 12?")

Same three tools, same decorator-based registration pattern on the surface — but the Args: section in each docstring isn't optional flavor text here the way it is elsewhere; smolagents parses it directly to build the tool schema, so a missing or malformed Args: block will actually break tool discovery, not just make the docs slightly worse.

The real difference is invisible in this snippet and shows up at runtime: instead of "call get_weather, get a result, call calculate, get a result," the model writes something closer to weather = get_weather("Austin"); result = calculate("340 * 12"); final_answer(f"...") as one Python snippet, and the interpreter executes it directly — meaning multi-step logic, loops, and combining tool outputs can all happen in a single model turn instead of round-tripping through the LLM once per tool call. That's faster and can be meaningfully cheaper on token usage for multi-tool tasks. It's also why smolagents ships with hard limits by default: the interpreter blocks all imports outside a small safe allowlist, and anything a tool needs (say, requests or re) has to be explicitly declared via additional_authorized_imports when you construct the agent. That restriction is the actual safety mechanism, not an afterthought — letting a model write and immediately execute arbitrary Python is the kind of thing you want guardrails on by default, not by discipline.

If you want the JSON-tool-calling behavior instead — say your model is weaker at generating correct Python, or you want closer parity with how the other three frameworks behave — smolagents ships ToolCallingAgent as a drop-in alternative to CodeAgent, same tool registration, different execution strategy underneath.

One honest caveat worth flagging before you run this against anything real: smolagents' own SECURITY.md is explicit that the default local Python executor is not a sandbox. It states plainly that escaping the local executor or reading the host filesystem is expected behavior under that execution mode, not a bug — the safe-import allowlist limits accidental damage, not a malicious or heavily hallucinating model. For anything beyond a local experiment, the docs point you toward the E2B or Docker executors (executor_type="e2b" or executor_type="docker" when constructing the agent) instead of trusting the default. It's the kind of gotcha that's easy to miss reading the quickstart and expensive to discover in production — the same "narrow the blast radius before you trust it" lesson that keeps coming up across every agent-sandboxing piece we've written.

Strands Agents SDK

AWS's open-source entry, released May 2025 and already running inside production systems at Amazon Q Developer and AWS Glue internally. Of the four, it's the leanest by a real margin. pip install strands-agents strands-agents-tools gets you the core SDK plus a library of 20+ pre-built tools (file manipulation, API requests, AWS service calls) so you're rarely starting from a truly empty toolset.

python
from strands import Agent, tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"The weather in {city} is 72°F and sunny."

@tool
def get_stock_price(ticker: str) -> str:
    """Get the current stock price for a ticker symbol."""
    prices = {"AAPL": 178.32, "GOOGL": 141.80, "MSFT": 402.15}
    return f"{ticker} is trading at ${prices.get(ticker.upper(), 'unknown')}"

@tool
def calculate(expression: str) -> str:
    """Evaluate a basic arithmetic expression, e.g. '12 * 4 + 7'."""
    try:
        return str(eval(expression, {"__builtins__": {}}))
    except Exception as e:
        return f"Could not calculate: {e}"

agent = Agent(tools=[get_weather, get_stock_price, calculate])
result = agent("What's the weather in Austin, and what's 340 * 12?")
print(result)

Notice what's missing compared to every other version: no Runner, no explicit .run() or .run_sync() call. The Agent object is itself callable — agent("...") both sends the message and runs the tool-calling loop, returning an AgentResult object whose string representation is the final text answer (result.message gives you the structured dict version if you need it). It's a genuinely smaller surface area to learn, consistent with the framework's stated philosophy: give the model a prompt, a set of tools, and get out of the way, rather than defining explicit workflows or state machines.

The defaults reveal where it's coming from, too: Agent() with no arguments defaults to Amazon Bedrock and Claude 4 Sonnet, requiring AWS credentials configured out of the box. Every other provider — OpenAI, Anthropic direct, Ollama, LiteLLM, Google Gemini — is supported, but Bedrock is clearly the path of least resistance, which makes sense given who built it. Combined with native Model Context Protocol support (thousands of pre-built tools available through MCP servers with no custom integration code) and a companion "AgentCore" runtime for deployment, Strands reads less like a standalone library and more like the agent-building half of a two-piece AWS-native stack — genuinely usable with zero AWS involvement, but clearly optimized for teams who are already there or heading there.

Testing and observability: the part nobody puts in the quickstart

Every one of these frameworks looks equally simple in a 20-line example. They diverge hard the moment you ask "how do I test this without hitting a real model, and how do I see what it actually did in production" — and that's arguably a more important axis than tool-registration syntax for anything you're actually going to ship.

OpenAI Agents SDK has built-in tracing baked into the Runner — every model call, tool call, and handoff gets recorded automatically and can be viewed in a tracing dashboard with no manual @trace decorators required, per the SDK's own documentation. That's genuinely convenient default behavior, though it's most naturally wired for OpenAI's own tooling.

Pydantic-AI takes testing furthest of the four: it ships a TestModel you can substitute for a real LLM in unit tests, giving deterministic, fast, offline test runs without burning API credits on every CI run, plus a dedicated Pydantic Evals package for scoring agent output quality over time, and native Logfire integration (OpenTelemetry-based) for production observability. If "how do I actually test an agent" is a question your team cares about early, this is the most opinionated, most complete answer of the four.

smolagents doesn't ship dedicated testing or tracing tooling of its own — you're expected to bring your own OpenTelemetry setup or observability platform if you need production visibility, which is consistent with the library's minimal-core philosophy (its entire agent-loop implementation is famously small, often cited around 1,000 lines) but is a real gap if your team was hoping for something built in.

Strands builds on OpenTelemetry from the ground up for tracing, and being backed by AWS, integrates cleanly with CloudWatch and the broader AWS observability stack if that's already where your infrastructure lives — another data point for the "this is the AWS-native choice" read of the framework.

Why the execution model actually matters: a worked example

The comparison table below is useful, but one row deserves more than a table cell: the difference between "the model writes code" (smolagents) and "the model requests a JSON tool call" (the other three) sounds academic until you trace through what happens on a slightly harder version of our task.

Suppose the request is: "Get the weather for Austin, Seattle, and Miami, and tell me which city is warmest."

In a JSON-tool-calling framework (OpenAI Agents SDK, Pydantic-AI, Strands), the loop runs roughly like this: the model requests get_weather("Austin"), the framework executes it and returns the result, the model reads that result and requests get_weather("Seattle"), gets that result, requests get_weather("Miami"), gets that result, and only then — now holding all three results in its context — compares them and writes the final answer. That's four full round trips to the model: three tool-call turns plus one final-answer turn, each one a separate inference call.

In smolagents' CodeAgent, the model can write this in one step:

python
cities = ["Austin", "Seattle", "Miami"]
results = {c: get_weather(c) for c in cities}
final_answer(f"Comparing: {results}")

One inference call generates that whole snippet, the interpreter executes all three tool calls locally without going back to the model between them, and only the comparison logic and final answer need a model turn at all. For a three-city comparison the difference is modest. For a task that loops over 50 items, or conditionally branches based on an intermediate result, the gap between "one code block" and "fifty separate round trips to the model" becomes the actual cost and latency story — not a footnote.

The trade-off, worth repeating because it's the whole reason the other three frameworks don't work this way: letting a model write and immediately execute arbitrary code is a materially bigger trust surface than letting it request one predefined function call at a time. JSON tool calling can only ever do exactly what your registered tools allow, nothing more, which is a real safety property. Code execution can do far more per turn — and exactly as much more of a problem if something goes wrong, which is precisely why the sandboxing caveat above isn't optional reading.

Side by side

OpenAI Agents SDKPydantic-AIsmolagentsStrands Agents SDK
Tool registration@function_tool@agent.tool / @agent.tool_plain@tool (code-based)@tool
Execution modelJSON tool calls, managed loopJSON tool calls, managed loopPython code execution per stepJSON tool calls, managed loop
Run callRunner.run_sync(agent, input)agent.run_sync(input)agent.run(input)agent(input) — agent itself is callable
Typed structured outputVia output schemasCore strength — Pydantic models, auto-retryNot the focusBasic
Dependency injectionContext objectRunContext, typed deps_typeNot built inNot built in
Default model homeOpenAIProvider-neutral (9+ providers)Hugging Face Inference / localAmazon Bedrock (provider-neutral otherwise)
Multi-agent patternHandoffs, agents-as-toolspydantic-graph for state machinesManage manuallyMulti-agent systems built in
Lines for this example~24~22~30 (docstring-heavy)~18
Testing/observabilityBuilt-in tracing dashboardTestModel, Pydantic Evals, LogfireBring your own (no built-in)OpenTelemetry, AWS-native
GitHub stars (approx.)Growing fast, OpenAI-backed~17,00024,000+Smaller, AWS-backed, fast-growing

What the code actually reveals

Reading docs, every one of these frameworks claims to be "simple," "production-ready," and "flexible." Reading the code, the actual differences are sharper than the marketing:

OpenAI Agents SDK is the safest default if you're building on OpenAI models and want a framework that scales from "single agent with tools" to "multi-agent system with handoffs and guardrails" without switching abstractions halfway through. The tradeoff is that it's most naturally OpenAI-centric, even though other providers are technically supported.

Pydantic-AI is the clear pick the moment structured, validated output is the actual point of the agent — not a chat response, but a typed object your downstream code can trust without a manual parsing step. The RunContext dependency-injection pattern is also the most FastAPI-like of the four, which matters if your team already lives in that ecosystem.

smolagents is the one to reach for specifically because it's not JSON-tool-calling underneath — if your task involves genuinely multi-step logic (loop over a list, combine three tool outputs, branch on a condition) rather than one tool call at a time, letting the model write real Python for that instead of round-tripping through the LLM per step is a real efficiency win, not just a novelty. The cost is a startup mindset: any import your tools need has to be explicitly authorized, so it's a framework you configure deliberately, not one you can grab in five minutes without reading past the first example.

Strands is the leanest of the four by every measure — fewest concepts, fewest lines, agent-as-callable instead of agent-plus-runner. That's a genuine strength for straightforward tool-calling agents, and a real signal if you're already on AWS infrastructure and want the framework that pairs natively with Bedrock and an AWS-native deployment runtime. It's the least opinionated about output structure of the four, which is fine until structured output becomes the actual requirement — at that point Pydantic-AI is doing more of that work for you by default.

None of these are "the best Python agent framework." They're optimized for different shapes of the same underlying problem, and the only way to actually see that is to build the same three-tool agent in each one and look at what came out — which is the whole reason this comparison exists instead of another bullet-point roundup.

Picking one

If you're just getting started and want the smallest possible first agent before committing to any of these, our walkthrough of the smallest AI agent you can actually build is a good next stop — same underlying idea, stripped down further. Otherwise, the honest, fast version of the decision:

  • Building on OpenAI, want room to grow into multi-agent handoffs → OpenAI Agents SDK
  • The output needs to be a validated, typed object, not just a nice-sounding reply → Pydantic-AI
  • The task genuinely needs multi-step reasoning per turn, not one tool call at a time → smolagents
  • Already on AWS, or want the leanest possible surface area → Strands Agents SDK

A couple of second-order factors worth weighing alongside the primary one above, since they change the answer for specific teams: if your org already has a mandatory testing and CI discipline, Pydantic-AI's TestModel closes that gap faster than any of the others will out of the box. If regulatory or compliance sign-off is part of your deployment process, Strands' native OpenTelemetry and AWS integration is the path of least resistance if your infra is already there — fighting that current with any other framework just means building the same observability layer yourself. And if your team is genuinely small and doesn't want to own an opinion about testing, tracing, or dependency injection yet, Strands' minimal API surface is the fastest to a working first agent, full stop — you can always outgrow it later.

What this piece didn't cover, on purpose, because it would have doubled the length without changing the core comparison: streaming responses (all four support it, with different ergonomics), MCP server integration (Strands and Pydantic-AI both have particularly strong native support here), and multi-agent orchestration patterns beyond a single agent with tools — each of those is genuinely worth its own deep-dive rather than a rushed paragraph here. And none of these frameworks cost anything themselves; all four are open source and free. The cost that actually shows up on your bill is the underlying model provider's token pricing, which is identical regardless of which of these four wraps around it.

All four are open source, all four are actively maintained, and all four will build the exact three-tool agent above with no real functional difference to the end user. The difference is entirely in what happens when the agent you're building outgrows this example — which, for a real project, it always eventually does.

External Links (1)

Explore topics

More in

Weekly letter

Subscribe to iHateReading

Our once-a-week newsletter on programming, jobs, AI, and building products online.

Our once a week newsletter on Programming, Jobs, AI, and Business