Files

230 lines
9.5 KiB
Python

"""Code mode capability that routes selected tools through a Monty sandbox."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import KW_ONLY, dataclass, field, replace
from typing import TYPE_CHECKING, Any
from pydantic import TypeAdapter, ValidationError
from pydantic_ai import AbstractToolset
from pydantic_ai.capabilities import AbstractCapability, CapabilityOrdering
from pydantic_ai.capabilities._tool_search import ToolSearch as _ToolSearch
from pydantic_ai.messages import ModelResponse, NativeToolSearchReturnPart, SystemPromptPart
from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition, ToolSelector
from typing_extensions import TypedDict
from pydantic_ai_harness.code_mode._toolset import CodeModeMount, CodeModeOS, CodeModeToolset
if TYPE_CHECKING:
from pydantic_ai.capabilities.abstract import ValidatedToolArgs
from pydantic_ai.messages import ToolCallPart
from pydantic_ai.models import ModelRequestContext
_DISCOVERY_ANNOUNCEMENT_PREFIX = (
'New functions are now available inside `run_code`. Their signatures have been '
'added to the available-functions catalog in the system prompt'
)
@dataclass
class CodeMode(AbstractCapability[AgentDepsT]):
"""Capability that exposes selected tools as callables inside a `run_code` sandbox.
By default (`tools='all'`) every tool the agent has is wrapped behind a single
`run_code` tool -- the model writes Python that calls them as functions instead
of issuing tool calls directly.
Pass a list of tool names or a callable predicate to `tools` to split the
toolset: matching tools become callables inside the sandbox, and the rest
stay visible to the model as normal tool calls.
```python
from pydantic_ai import Agent
from pydantic_ai_harness import CodeMode
# Sandbox all tools
agent = Agent('openai:gpt-5', capabilities=[CodeMode()])
# Sandbox only specific tools
agent = Agent('openai:gpt-5', capabilities=[CodeMode(tools=['search', 'fetch'])])
```
By default, sandboxed code cannot touch the host -- no filesystem, environment
variables, or clock. Two parameters open it up:
- `mount` shares specific host directories: reach for it when the agent reads or
writes real files.
- `os_access` routes the sandbox's OS calls to a handler you provide: reach for it
when the agent needs environment variables, the clock, or filesystem behavior you
control.
Both expose the real host to model-written code, so grant only what the task needs.
```python
from pydantic_monty import MountDir
agent = Agent('openai:gpt-5', capabilities=[CodeMode(mount=MountDir('/work', '/tmp/agent-work'))])
```
"""
tools: ToolSelector[AgentDepsT] = field(default='all')
"""Which wrapped tools should be sandboxed inside `run_code`.
- `'all'` (default): every tool the agent has is sandboxed.
- `Sequence[str]`: only tools whose names are listed are sandboxed.
- Callable `(ctx, tool_def) -> bool | Awaitable[bool]`: tools where the
callable returns `True` are sandboxed; the rest stay as native tool calls.
"""
max_retries: int = 3
"""Maximum number of retries for the `run_code` tool (syntax errors count as retries)."""
_: KW_ONLY
os_access: CodeModeOS | None = None
"""Give sandboxed code environment variables, the clock, and file I/O through a handler you provide; unset, they are unavailable."""
mount: CodeModeMount | None = None
"""Host directories to expose to sandboxed `pathlib` code; each mount's `mode` controls whether writes reach the host."""
dynamic_catalog: bool = False
"""Keep the `run_code` tool definition cache-stable as the sandboxed toolset grows.
By default the signatures of all sandboxed tools are rendered into `run_code`'s
description, which lives in the prompt-cache-keyed tool-definitions block. When the
toolset changes mid-run -- e.g. [`ToolSearch`][pydantic_ai.capabilities.ToolSearch]
reveals a new tool that then gets folded into `run_code` -- the description changes and
busts the prefix cache from that point on.
Set `dynamic_catalog=True` to instead:
- keep only the static base prose (sandbox restrictions, return-value contract) in
`run_code.description`, so the tool-definitions block stays byte-stable across
discoveries;
- move the "available functions" catalog (TypedDict definitions + signatures) into
agent instructions as a dynamic
[`InstructionPart`][pydantic_ai.messages.InstructionPart], which providers with
static/dynamic instruction splitting (Anthropic, Bedrock) place after the cache
breakpoint;
- announce newly-discovered tools via a short
[`SystemPromptPart`][pydantic_ai.messages.SystemPromptPart] enqueued through
[`RunContext.enqueue`][pydantic_ai.tools.RunContext.enqueue], so the model knows the
new functions are callable without rewriting the cached description.
This pays off when paired with [`ToolSearch`][pydantic_ai.capabilities.ToolSearch]: the
tool-definitions cache survives discoveries at the cost of a larger (but
cache-friendly) system prompt. With a fixed toolset and no `ToolSearch`, the default
keeps the system prompt shorter and is the better choice.
"""
_announced_tools: set[str] = field(default_factory=set[str], init=False, repr=False)
def get_ordering(self) -> CapabilityOrdering:
"""CodeMode wraps around ToolSearch so that search_tools stays native."""
return CapabilityOrdering(position='outermost', wraps=[_ToolSearch])
async def for_run(self, ctx: RunContext[AgentDepsT]) -> CodeMode[AgentDepsT]:
"""Return a fresh instance so concurrent runs don't share `_announced_tools`."""
if not self.dynamic_catalog:
return self
return replace(self)
def get_wrapper_toolset(self, toolset: AbstractToolset[AgentDepsT]) -> AbstractToolset[AgentDepsT] | None:
"""Wrap the agent's assembled toolset, splitting it into native + sandboxed subsets if needed."""
return CodeModeToolset(
wrapped=toolset,
tool_selector=self.tools,
max_retries=self.max_retries,
dynamic_catalog=self.dynamic_catalog,
os_access=self.os_access,
mount=self.mount,
)
async def after_tool_execute(
self,
ctx: RunContext[AgentDepsT],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: ValidatedToolArgs,
result: Any,
) -> Any:
"""Announce newly-discovered tools from a local `search_tools` return.
Only active with `dynamic_catalog=True`. The native-search path is handled by
[`after_model_request`][pydantic_ai_harness.CodeMode.after_model_request] instead
(server-side search emits a `NativeToolSearchReturnPart` rather than a regular tool
execute result).
"""
if self.dynamic_catalog and tool_def.tool_kind == 'tool-search':
self._announce_newly_discovered(ctx, _extract_discovered_names(result))
return result
async def after_model_request(
self,
ctx: RunContext[AgentDepsT],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
"""Announce newly-discovered tools from a native (server-side) tool-search return.
Only active with `dynamic_catalog=True`.
"""
if self.dynamic_catalog:
for part in response.parts:
if isinstance(part, NativeToolSearchReturnPart):
self._announce_newly_discovered(ctx, _extract_discovered_names(part.content))
return response
def _announce_newly_discovered(self, ctx: RunContext[AgentDepsT], names: Sequence[str]) -> None:
"""Enqueue a system-prompt announcement for any names we haven't already announced."""
fresh = [n for n in names if n not in self._announced_tools]
if not fresh:
return
self._announced_tools.update(fresh)
listing = ', '.join(f'`{name}`' for name in fresh)
# Enqueue a `SystemPromptPart` so the announcement is framed as system-level context.
# Mid-conversation `SystemPromptPart`s are rendered inline (not hoisted to the top-level
# system prompt) on all providers since pydantic/pydantic-ai#5509, so this is cache-safe.
ctx.enqueue(SystemPromptPart(content=f'{_DISCOVERY_ANNOUNCEMENT_PREFIX}: {listing}.'))
class _DiscoveredCatalog(TypedDict):
"""Lenient view of a tool-search return: just the entry list, items left unvalidated."""
discovered_tools: list[object]
class _DiscoveredEntry(TypedDict):
"""Lenient view of one discovered-tool entry: only the name we announce."""
name: str
_CATALOG_ADAPTER = TypeAdapter(_DiscoveredCatalog)
_ENTRY_ADAPTER = TypeAdapter(_DiscoveredEntry)
def _extract_discovered_names(content: object) -> list[str]:
"""Read newly-discovered tool names from a tool-search return content.
Carried on both the local `ToolSearchReturnPart` and the native
`NativeToolSearchReturnPart`. Validated leniently: a malformed catalog yields `[]` and a
malformed entry is skipped, since the announcement is a courtesy nudge, not load-bearing
logic.
"""
try:
catalog = _CATALOG_ADAPTER.validate_python(content)
except ValidationError:
return []
names: list[str] = []
for entry in catalog['discovered_tools']:
try:
names.append(_ENTRY_ADAPTER.validate_python(entry)['name'])
except ValidationError:
continue
return names