Pydantic AI Gepa
GEPA extension for pydantic-ai
Install / Use
npx skills add indexedlabs/pydantic-ai-gepaInstalls into whichever agent you are using.
README
pydantic-ai-gepa
[!NOTE] This library is in an extremely experimental, fast-moving phase and should not be considered stable while we work toward a solid API.
GEPA-driven prompt optimization for pydantic-ai agents. This library provides evolutionary optimization of agent prompts, structured input schemas, and tool descriptions within the pydantic-ai ecosystem.
About
This is a reimplementation of gepa-ai/gepa adapted for pydantic-ai. Huge thanks to the gepa-ai team for the original GEPA algorithm - we rebuilt it here because we needed tight integration with pydantic-ai's async patterns and wanted to use pydantic-graph for workflow management. Check out the original gepa library for the canonical implementation.
Features
Two main things this library adds to pydantic-ai:
1. SignatureAgent - Structured Inputs
Inspired by DSPy's signatures, SignatureAgent adds input_type support to pydantic-ai. Just like pydantic-ai uses output_type for structured outputs, SignatureAgent lets you define structured inputs:
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai_gepa import SignatureAgent
class AnalysisInput(BaseModel):
"""Analyze the provided data and extract insights."""
data: str = Field(description="The raw data to analyze")
focus_area: str = Field(description="Which aspect to focus on")
format: str = Field(description="Output format preference")
# Create base agent
base_agent = Agent(
model="openai:gpt-4o",
output_type=str,
)
# Wrap with SignatureAgent to add input_type support
agent = SignatureAgent(
base_agent,
input_type=AnalysisInput,
)
# Run with structured input
result = await agent.run_signature(
AnalysisInput(
data="...",
focus_area="performance",
format="bullet points"
)
)
The model docstring becomes system instructions, and field descriptions become input specs.
2. Optimizable Components
GEPA can optimize different parts of your agent:
- System prompts
- Signature field descriptions (when using SignatureAgent)
- Tool descriptions and parameter docs (set
optimize_tools=True) - Output model docstrings and field descriptions (set
optimize_output_type=Truewhen using structured outputs) - Agent Skills packs (SKILL.md description/body +
examples/files) when you passskills=...
All these text components evolve together using LLM-guided improvements:
# Optimize agent with SignatureAgent
result = await optimize_agent(
agent=agent, # SignatureAgent instance
trainset=examples,
metric=metric,
optimize_tools=True, # evolve tool descriptions
optimize_output_type=True, # evolve output_type docs/fields
)
# Access all optimized components
print(result.best_candidate.components)
# {
# "instructions": "...", # System prompt
# "signature:AnalysisInput:instructions": "...", # Input schema docstring
# "signature:AnalysisInput:data:desc": "...", # Field description
# "signature:AnalysisInput:focus_area:desc": "...",
# "tool:my_tool:description": "...", # If optimize_tools=True
# "tool:my_tool:param_x:description": "...",
# "output:MyOutput:instructions": "...", # If optimize_output_type=True
# "output:MyOutput:field:desc": "...",
# ...
# }
Quick Start
# Install dependencies
uv sync --all-extras
# Run examples
uv run python examples/classification.py
uv run python examples/math_tools.py
uv run python examples/optimize_skills.py
examples/optimize_skills.py uses the built-in local skills search by default. For a faster in-process index that supports reindex_skills(...), use InMemorySkillsSearchProvider and pass it via skills_search_backend=....
Running the Math Tools Example
The math tools walkthrough is the fastest way to see GEPA optimization in action. It expects API credentials in .env, so load them via --env-file when running.
uv run --env-file .env python examples/math_tools.py --results-dir optimization_results --max-evaluations 25
✅ Optimization result saved to: optimization_results/math_tools_optimization_20251117_181329.json
Original score: 0.5417
Best score: 0.9167
Iterations: 1
Metric calls: 44
Improvement: 69.23%
After an optimization finishes you can re-run the same script in evaluation mode to benchmark a saved candidate:
uv run --env-file .env python examples/math_tools.py --results-dir optimization_results --evaluate-only
Evaluating candidate from optimization_results/math_tools_optimization_20251117_181329.json (best candidate (idx=1))
Evaluation summary
Cases: 29
Average score: 0.8931
Lowest scores:
- empty-range-edge: score=0.0000 | feedback=When the start exceeds the stop in a range, the result is an empty sequence. The sum of an empty sequence is zero. Answer 165.0 deviates from target 0.0 by 165; verify the computation logic and any rounding. A reliable approach uses: `sum(range(20, 10))`.
- degenerate-average: score=0.0000 | feedback=Only one multiple exists in this narrow range. Ensure you handle single-element averages correctly. Answer 0.0 deviates from target 105.0 by 105; verify the computation logic and any rounding. A reliable approach uses: `sum(range(105, 106, 7)) / max(len(range(105, 106, 7)), 1)`.
- between-1-2-empty: score=0.0000 | feedback=The next tool call(s) would exceed the tool_calls_limit of 5 (tool_calls=6).
- between-10-11-empty: score=0.9000 | feedback=Exact match within tolerance. Used `run_python` 2 times; consolidate into a single sandbox execution when possible.
- sign-heavy-expression: score=1.0000 | feedback=Exact match within tolerance.
How It Works
GEPA Graph Architecture
The optimization runs as a pydantic-graph workflow:
┌─────────────────────────────────────────────────────────────┐
│ GEPA Optimization Graph (pydantic-graph) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Start │─────▶│ Evaluate │─────▶│ Continue │ │
│ │ Node │ │ Node │ │ or Stop │ │
│ └──────────┘ └──────────┘ └─────┬────┘ │
│ ▲ │ │
│ │ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Merge │◀─────│ Reflect │ │
│ │ Node │ │ Node │ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Nodes:
- StartNode - Extract seed candidate from agent, initialize state
- EvaluateNode - Run validation set evaluation (parallel), update Pareto fronts
- ContinueNode - Check stopping conditions, decide next action (reflect/merge/stop)
- ReflectNode - Sample minibatch, analyze failures, propose improvements via LLM
- MergeNode - Genetic crossover of successful candidates (when enabled)
To enable merge/crossover (useful when different branches improve different components), set:
result = await optimize_agent(
...,
use_merge=True,
max_merge_invocations=5,
)
For large component sets (e.g. when optimizing a skills pack with many skills/files), prefer module_selector="reflection" so the reflection agent can search/activate only the relevant skill components from traces.
Evaluations run in parallel for speed.
Optimization Process
- Evaluate - Score candidates on validation examples
- Reflect - LLM analyzes failures and proposes improvements
- Merge - Combine successful strategies (optional)
- Repeat - Until convergence or budget exhausted
Results are cached to avoid redundant LLM calls.
Engine Composition (omni)
Inspired by GEPA's omni composition model, one OptimizationTask bundles an agent, dataset, and metric. Select any registered optimizer with engine= and compose engines under one shared metric-call budget.
| Engine | Role |
| --- | --- |
| gepa | Reflective pydantic-graph optimization loop. |
| coding_agent | A caller-supplied proposer owns reflection; the library owns the loop and selection. |
| best_of_n | A small reference engine that evaluates scripted or sampled variants. |
from pydantic_ai_gepa import EngineConfig, OptimizationTask, optimize_best_of
task = OptimizationTask(agent=agent, trainset=trainset, valset=valset, metric=metric)
async def propose(seed):
return improved_candidate(seed)
result = await optimize_best_of(
task,
[
EngineConfig(engine="best_of_n", engine_config={"n": 2, "propose": propose}),
EngineConfig(engine="gepa", max_metric_calls=20),
],
max_metric_calls=40,
)
print(result.best.engine, result.fair_scores)
Use optimize_parallel(...) to keep every result, optimize_sequential(...) to chain engines without accepting a regression, or optimize_vote(...) to select by a fair evaluation vote.
Custom engines are one class implementing the OptimizationEngine protocol plus a registration: register_engine("my_engine", MyEngine). Every engine shares the same budget; optimize_best_of and optimize_vote re-evaluate finalists on the valset without charging that budget, so winner selection is comparable across engines.
Example
Basic Optimization
from pydantic_ai_gepa import optimize_agent
from pydantic_ai import Agent
# Define your agent
agent = Agent(
Related Skills
node-connect
385.5kDiagnose OpenClaw Android, iOS, or macOS node pairing, QR/setup code, route, auth, and connection failures.
blender-python-addon
40.5kBlender Python add-on rules for operators, panels, properties, registration, testing, and API-safe scripting
flutter-development-guidelines-cursorrules-prompt-file
40.5kCursor rules for Flutter development with MVVM architecture, Riverpod state management, Material widgets, and Dart style guidelines.
commit-push-pr
140.7kCommit, push, and open a PR
