* 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>
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.
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.
Tool Search
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 thetimemodule 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
- Tool use via code (Anthropic)
- Code mode in production (Cloudflare)
- Pydantic AI capabilities
