Files
pydantic-ai-harness/pydantic_ai_harness/code_mode
59e65b7a12 fix(code_mode): honor Tool Search's deferred-loading contract (#240)
* fix(code_mode): honor Tool Search's deferred-loading contract

CodeMode flattened `defer_loading=True` tools into `run_code`'s description
regardless of discovery state — defeating progressive disclosure, and on
providers with native tool search also double-listing the same tool on the
wire (top-level `tools[]` with `defer_loading: true` *and* its signature in
`run_code.description`), plus busting the prompt cache on every discovery.

Keep `defer_loading=True` tools as native pass-through so ToolSearchToolset's
`defer_loading` / `with_native` flags reach `Model.prepare_request` unaltered;
they fold into `run_code` only once discovered (`defer_loading=False`). Migrate
the sibling `prefer_builtin` filter to its renamed `unless_native` form.

Sources pydantic-ai from `main` for pydantic/pydantic-ai#5143 (native Tool
Search); drop `[tool.uv.sources]` and bump the `pydantic-ai-slim` floor once
that ships in a release. The same pydantic-ai changes rename the `builtin=`
capability kwarg to `native=`, so the README and quick-start test are updated
to match.

Closes #232

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* deps: bump pydantic-ai-slim floor to >=1.95.0, drop temporary git pin

pydantic-ai 1.95.0 ships pydantic/pydantic-ai#5143 (native Tool Search), so
remove the `[tool.uv.sources]` git override and bump the floor to it. 1.95.0
also deprecates `Agent(instrument=...)` in favor of an `Instrumentation`
capability, so the OTel-spans test switches to `capabilities=[..., Instrumentation(...)]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: say "local MCP toolset" not "FastMCP" in the CodeMode + MCP example

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: add ToolSearch to the ecosystem-agent README example

Show progressive tool discovery alongside CodeMode in the full ecosystem
showcase — deferred tools fold into `run_code` once discovered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: group ToolSearch with CodeMode as a meta-capability in the README example

Both transform the toolset before it reaches the model, so they sit together
under "Tool execution & discovery" rather than alongside the tool providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: trim the ToolSearch comment to one line, matching the others

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: clarify CodeMode + Tool Search behavior and the cache escape hatch

Rewrite the code-mode README's Tool Search section to cover both the native
and local-fallback paths accurately, spell out that a discovered tool folds
into `run_code` (busting the prompt cache once at discovery), and document the
`tools=` selector workaround (`td.with_native is None`) for keeping a Tool
Search corpus fully native. Mirror the escape-hatch note in the toolset docstring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(compat): set UV_FROZEN=1 so the editable overlay isn't clobbered by uv run

`compat-test.yml` overlays `pydantic-ai-slim` / `pydantic-graph` from the local
checkout via `uv pip install --no-deps -e ...`, but doesn't set `UV_FROZEN`.
The next `uv run ruff/pyright/pytest` calls then auto-re-resolve against PyPI
("Ignoring existing lockfile due to change in resolution mode: `lowest-direct`
vs. `highest`"), silently uninstall the overlay, and install whatever
`pydantic-ai-slim` is on PyPI — so the compat check has been a no-op against
the actual pydantic-ai checkout we wanted to validate.

Caught when pydantic-ai 1.95.0 landed `current_otel_traceparent` with lazy
imports that trip the Temporal sandbox; harness-compat against the merge
commit went green (because it was testing 1.94.0 from PyPI, not the merge
commit), and only failed once 1.95.0 was published to PyPI.

`main.yml` already pins `UV_FROZEN: '1'` at workflow scope; do the same here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* deps: re-pin pydantic-ai-slim to git main for the Temporal sandbox fix

Released `1.95.0` (the lock-resolved version under `pydantic-ai-slim>=1.95.0`)
still has the `current_otel_traceparent` lazy-import bug that hangs Temporal
workflows on the `test_code_mode_runs_in_temporal_workflow` path — the
`all-extras` jobs sat at 1h+ before this. Repin to pai `main` (now at
`cf0b9077e`, which has pydantic/pydantic-ai#5422 merged) until `1.95.1` is on
PyPI. Drop this section + bump the floor in a follow-up once the release lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:00:15 -06:00
..

Code Mode

Replace individual tool calls with a single sandboxed Python execution environment.

The problem

Standard tool calling requires one model round-trip per tool call. An agent that needs to fetch 10 items and process each one makes 11+ model calls -- slow, expensive, and context-heavy.

The solution

CodeMode wraps your tools into a single run_code tool. The model writes Python code that calls multiple tools with loops, conditionals, variables, and asyncio.gather -- all inside a sandboxed Monty runtime.

Standard tool calling Code mode
1 model call per tool 1 model call for N tools
Sequential by default Parallel via asyncio.gather
No local computation Filter, transform, aggregate in code
Large conversation history Compact -- fewer messages

Usage

from pydantic_ai import Agent
from pydantic_ai_harness import CodeMode

agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[CodeMode()])

@agent.tool_plain
def get_weather(city: str) -> dict:
    """Get current weather for a city."""
    return {'city': city, 'temp_f': 72, 'condition': 'sunny'}

@agent.tool_plain
def convert_temp(fahrenheit: float) -> float:
    """Convert Fahrenheit to Celsius."""
    return round((fahrenheit - 32) * 5 / 9, 1)

result = agent.run_sync("What's the weather in Paris and Tokyo, in Celsius?")
print(result.output)

The model writes code like:

paris, tokyo = await asyncio.gather(
    get_weather(city='Paris'),
    get_weather(city='Tokyo'),
)
paris_c = await convert_temp(fahrenheit=paris['temp_f'])
tokyo_c = await convert_temp(fahrenheit=tokyo['temp_f'])
{'paris': paris_c, 'tokyo': tokyo_c}

In practice

The harness Quick start wires CodeMode up against an MCP server and a web search and asks it to find the most-discussed Hacker News story across three feeds, pull the comment thread and the submitter's profile, and search the web for follow-up coverage. CodeMode collapses that into two run_code calls: the first fetches all three feeds in parallel via asyncio.gather, dedupes by id, filters by score, and ranks by comment count -- in plain Python; the second batches the three follow-up calls (hn_get_thread, hn_get_user, duckduckgo_search) together.

CodeMode's first run_code: parallel asyncio.gather over three HN feeds, then a dedupe and a score filter

See the full Logfire trace → Each run_code span fans out into the tool calls the model issued from inside the sandbox -- the easiest way to understand what code mode actually did. See the Pydantic AI Logfire docs for setup details.

Installation

Code mode requires the Monty sandbox:

uv add "pydantic-ai-harness[codemode]"

The code-mode extra is also supported as an alias.

Selective tool sandboxing

By default, CodeMode(tools='all') sandboxes every tool. You can control which tools go through the sandbox:

# By name -- only these tools are available inside run_code
CodeMode(tools=['search', 'fetch'])

# By predicate
CodeMode(tools=lambda ctx, td: td.name != 'dangerous_tool')

# By metadata -- combine with SetToolMetadata or .with_metadata()
CodeMode(tools={'code_mode': True})

Tools that match the selector are wrapped inside run_code. Non-matching tools remain available as regular tool calls.

When you mark tools or whole toolsets defer_loading=True (Tool Search), CodeMode keeps them out of run_code while they're undiscovered — they pass straight through, so Tool Search drives them as usual (sent on the wire with defer_loading on providers with native tool search; otherwise dropped until discovered, with a search_tools tool alongside run_code). Once the model discovers a tool it comes back with defer_loading=False, and from then on CodeMode folds it into run_code like any other tool, so it's callable from generated code.

That fold-in grows run_code's description, which invalidates the prompt-cache prefix once at the moment of discovery (turns with no discovery stay cache-warm). To instead keep a Tool Search corpus fully native — never folded into run_code, fully cache-stable, but not callable from inside it — exclude it with a tools selector; corpus members carry with_native set to the managing native tool:

CodeMode(tools=lambda ctx, td: td.with_native is None)

A future Pydantic AI change will let run_code's description stay static — newly discovered tools announced separately — so the fold-in costs nothing; until then, the selector above is the escape hatch.

Metadata-based selection

from pydantic_ai import Agent
from pydantic_ai.toolsets import FunctionToolset
from pydantic_ai_harness import CodeMode

search_tools = FunctionToolset(tools=[search, fetch]).with_metadata(code_mode=True)

agent = Agent(
    'anthropic:claude-sonnet-4-6',
    toolsets=[search_tools],
    capabilities=[CodeMode(tools={'code_mode': True})],
)

Return values

The last expression in the code snippet is automatically captured as the return value -- the model does not need to print().

Scenario Return
No print output Last expression value
With print output {"output": "<printed text>", "result": <last expression>}
Multimodal content (e.g. images) Returned natively for model processing

REPL state

State persists between run_code calls within the same agent run -- variables, imports, and function definitions carry over. Pass restart: true in the tool call to reset state.

Observability

Nested tool calls inside run_code produce their own spans when instrumented with Logfire or any OpenTelemetry backend. The run_code tool return includes metadata with all nested calls:

for msg in result.all_messages():
    for part in msg.parts:
        if isinstance(part, ToolReturnPart) and part.tool_name == 'run_code':
            tool_calls = part.metadata['tool_calls']    # dict[str, ToolCallPart]
            tool_returns = part.metadata['tool_returns'] # dict[str, ToolReturnPart]

Sandbox restrictions

Code runs inside Monty, a sandboxed Python subset. Key restrictions:

  • No class definitions
  • No third-party imports (allowed stdlib: sys, typing, asyncio, math, json, re, datetime, os, pathlib)
  • No wall-clock or timing primitives: asyncio.sleep, datetime.datetime.now()/datetime.date.today(), and the time module are unavailable
  • No import *
  • Tools requiring approval or with deferred execution are excluded from the sandbox

API

CodeMode(
    tools: ToolSelector = 'all',   # 'all', list[str], callable, or dict
    max_retries: int = 3,          # retries on sandbox execution errors
)

Agent spec (YAML/JSON)

CodeMode works with Pydantic AI's agent spec feature for defining agents in YAML:

# agent.yaml
model: anthropic:claude-sonnet-4-6
capabilities:
  - CodeMode: {}
from pydantic_ai import Agent
from pydantic_ai_harness import CodeMode

agent = Agent.from_file('agent.yaml', custom_capability_types=[CodeMode])
result = agent.run_sync('...')
print(result.output)

Pass custom_capability_types so the spec loader knows how to instantiate CodeMode. You can also pass arguments in the YAML:

capabilities:
  - CodeMode:
      tools: ['search', 'fetch']
      max_retries: 5

Further reading