Skip to main content
Writing a metric for an agent is hard, because a reply has no single expected output to compare against. Your scenario suite is already that metric: it runs the whole conversation with a simulated user and judges it on the criteria you wrote, so the optimizer runs the improvement loop one candidate after another, against the same suite you use as the quality gate. To run a DSPy optimizer you give it a program and a metric. The program is a dspy.ReAct module, whose instructions contain every tool description, so rewriting the instructions rewrites the tool descriptions too. The metric runs one scenario against the candidate and scores the verdict, and GEPA also reads the judge’s reasoning as feedback for the next candidate. The runnable example is in the SDK repository: sdks/python/examples/agent-optimization.

What you need

  • Python 3.10 or later. In the example folder, uv sync installs everything. In your own project, pip install "langwatch[dspy]" "dspy[optuna]" langwatch-scenario (MIPROv2 needs the optuna extra), and import scenario before dspy in every module, because in the other order dspy 3.3 fails while loading numpy.
  • OPENAI_API_KEY for the agent, the simulated user and the judge, and LANGWATCH_API_KEY so the optimization run and the simulations appear in LangWatch.
  • Five to ten scenarios. One optimization run executes each of them several times: the MIPROv2 run of the example below took 64 scenario runs, 42 minutes and $0.93, with gpt-5-mini as the agent, the simulated user and the judge, and gpt-5 writing the instruction candidates.

Step 1: Wrap the agent as a DSPy module

Build the agent as a dspy.ReAct over the tools, and write a scenario adapter that passes the conversation to it as dspy.History plus the last user message and returns the answer:
trajectory_to_messages turns the ReAct trajectory into tool_calls and tool messages followed by the answer, so the judge sees the tool calls and the rejected arguments as well as the reply; both helpers are in the example’s agent.py. The tool descriptions come from the docstrings: dspy.ReAct writes them into the instructions of its react predictor, and that predictor is what the optimizer rewrites.

Step 2: One example per scenario

Write one dspy.Example per scenario, with the situation, the criteria and a step budget, where a rejected tool call that the agent retries counts as an extra step.

Step 3: Run the scenario inside the program

The optimizer calls the program on every example and then the metric on what the program returned. So wrap the whole scenario in the program: it runs the conversation with the candidate agent inside it and returns the verdict, the steps and the judge’s reasoning, and the metric turns those into a score:
The metric scores a failed scenario zero and a passed scenario one minus half a point per budget of extra steps, so the pass gate comes first and the step budget is the objective under it. When a tool rejects its arguments during the conversation, the example adds one line to the feedback that says so, which is what GEPA needs to rewrite that tool’s description. scenario.run executes the conversation on its own thread, and GEPA reads the trace of the thread that called the program, so the example’s forward copies the DSPy trace of the scenario thread back into the calling thread.

Step 4: Run the optimizer

max_metric_calls=48 gave four iterations on the example: the first full pass costs six calls, and each accepted candidate costs a minibatch of three for the parent, three for the candidate and a full pass of six. GEPA’s auto="light" budget plans hundreds of metric calls, which is too many when every call is a full simulation.langwatch.dspy.init records the seed program and every candidate that reaches a full validation pass as one step each, with the candidate’s instructions per predictor and the score of every scenario in that pass. GEPA drops a candidate that loses on its minibatch before the validation set, so that candidate has no step.
Run the suite once before and once after with dspy.Evaluate(devset=trainset, metric=scenario_metric, num_threads=3), so the before and after pass rates and steps come from the same scenarios; the example’s evaluate_suite prints that table. To run the example:

Step 5: Read the result in LangWatch

  • Experiments shows the optimization run as a score chart, one point per trial, with the instructions of each predictor at that step. Both scripts register the run through langwatch.dspy.init: MIPROv2 logs one step per trial, and GEPA logs one step for the seed program and one for every candidate it evaluates on the validation set. See DSPy visualization.
  • Agent Testing shows every simulation the optimizer ran, as runs of the dspy-optimization set. The example puts the baseline pass and the final pass in their own batches, named <run id>-baseline and <run id>-best, so they appear as two runs of six next to the larger runs from inside the optimizer, and the scripts print both names.
The Experiments page for returns-agent-scenarios: the MIPROv2 score chart with seven points and the Best marker on step 5, and the predictors table of the selected step below it

The MIPROv2 run in Experiments: one point per trial, the best trial marked, and the instructions of the selected step below the chart.

The Results tab of the dspy-optimization set, with eight runs in the sidebar and the six scenarios of the MIPROv2 best pass with their verdicts, time and cost

The dspy-optimization set in Agent Testing: the baseline and best passes of both runs as runs of six, next to the runs from inside the optimizers.

Compare the instructions before and after: both scripts print every instruction the optimizer changed, and the tool descriptions are part of them.

What the example runs showed

One run of each script on the six scenarios, with the agent, the simulated user and the judge on gpt-5-mini and gpt-5 as the proposer or the reflection model: Neither optimizer saw the tool code, but both saw the rejected calls in the traces, and both wrote the accepted values into the react instructions. MIPROv2, from the proposer’s candidates:
GEPA, from the judge’s reasoning and the tool errors in the feedback, on its first iteration:
That is the same tool description fix as in Fix tool calls, found by search. On both optimizer runs the damaged blender return went from failed to passed and from 5 steps to 4; the hand-written fix on that page, which also validates the arguments and rewrites the error message, got the same return down to 3. The scenario where the customer asks for a recipe failed on every pass, and the cause was in the example rather than in the agent: its criterion said the agent calls no tool, and the judge counts ReAct’s internal finish step as a tool call. The criterion now lists the three tools it means instead. On the MIPROv2 run the angry customer passed before and failed after, and the 83% of the best trial did not repeat on the final pass, which is the non-determinism described in the Limits below.

Limits

  • The optimizer changes text, meaning the instructions and the tool descriptions. Changing a tool’s schema, adding a missing tool or capping the harness is still a code change: see Fix tool calls.
  • Scenarios are not deterministic: the simulated user words the situation differently on every run, so a candidate can pass once and fail once. Evaluate the baseline and the final program twice before you compare them, and use --repeat on the platform for the same reason.
  • The score counts the agent’s steps, so the cost of the simulated user and the judge is not in it.
  • DSPy is Python. For a TypeScript agent, use the guided loop instead: see Reduce turns.
Also check: Optimization algorithms (which optimizer to pick), Scenarios in code, DSPy visualization (how a run appears in Experiments).
Last modified on September 5, 2026