docs: ban em-dashes and codify writing style (#270)

Add a Writing style section to AGENTS.md so docs, comments, and PR text
read as human-written: no em-dashes (use `--`), no marketing superlatives
or editorializing adjectives, sparing bold, no decorative Unicode.

Bring the tree into compliance by converting every em-dash to `--` across
16 files. All occurrences are in prose, comments, docstrings, and tool
descriptions. The only runtime effect is that model-facing tool
descriptions now use `--` (punctuation only, no semantic change); no test
snapshots depend on the character. The one literal em-dash left is in
AGENTS.md, where it names the banned character.

https://claude.ai/code/session_01LQ9NTr8q95A99UnVcq6Nzf

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Bill Easton
2026-06-09 11:08:14 -05:00
committed by GitHub
co-authored by Claude
parent 3d3247110c
commit ae371bbdaf
16 changed files with 112 additions and 93 deletions
+19
View File
@@ -70,6 +70,25 @@ When implementing a new capability, reference these docs:
- prefer the most generic input types possible (reduce dependency chains)
- don't add comments that restate what the code does
## Writing style
Applies to docs, READMEs, docstrings, comments, commit messages, and PR text.
- No em-dashes (`—`). Use `--` for an aside or interruption, or split into two
sentences. Em-dash-heavy prose reads as machine-generated.
- State facts, not sales copy. Cut marketing superlatives and hype ("blazingly
fast", "battle-tested", "the single most expensive thing you can do",
"footgun") and editorializing adjectives ("sprawling", "noisy", "silently").
- Avoid absolute claims ("never", "always", "guaranteed") unless they are
literally true and load-bearing. Name the specific mechanism instead of the
slogan.
- Use bold sparingly -- for the lead-in term of a list item, not to emphasize
whole sentences.
- Document the why, the constraints, and the non-obvious. Don't restate what the
code or signature already says.
- Prefer plain ASCII punctuation over decorative Unicode (arrows, fancy quotes)
in prose and comments.
## Package management
- Use `uv` for all dependency operations
+1 -1
View File
@@ -278,7 +278,7 @@ make testcov # pytest with 100% branch coverage
Pydantic AI Harness uses **0.x versioning** to signal that APIs are still stabilizing. During 0.x:
- **Minor releases** (0.1 → 0.2) may include breaking changes renamed parameters, changed defaults, restructured APIs. As the library grows, especially as capabilities gain provider-native support (starting as a local implementation, then auto-switching to the provider's built-in API when available), we may need to reshape APIs we couldn't fully anticipate in the initial design.
- **Minor releases** (0.1 → 0.2) may include breaking changes -- renamed parameters, changed defaults, restructured APIs. As the library grows, especially as capabilities gain provider-native support (starting as a local implementation, then auto-switching to the provider's built-in API when available), we may need to reshape APIs we couldn't fully anticipate in the initial design.
- **Patch releases** (0.1.0 → 0.1.1) will not intentionally break existing behavior.
- **All breaking changes** are documented in release notes with migration guidance.
- Where practical, we'll keep the previous behavior available under a deprecated name or configuration option before removing it.
+9 -9
View File
@@ -8,7 +8,7 @@ Covers `pydantic_ai_harness/filesystem/_toolset.py` and
`pydantic_ai_harness/shell/_toolset.py`.
Run with [mutmut](https://mutmut.readthedocs.io/) v3 via `scripts/run-mutmut.sh`,
which installs mutmut ephemerally with `uv run --with` no dev dependency
which installs mutmut ephemerally with `uv run --with` -- no dev dependency
required.
```bash
@@ -19,21 +19,21 @@ scripts/run-mutmut.sh show <mutant-name>
## Interpreting survivors
A surviving mutant is either a missing test or an equivalent mutant a change
A surviving mutant is either a missing test or an equivalent mutant -- a change
that produces behavior no test could distinguish from the original. Triage each
survivor; the recurring equivalent-mutant categories in this codebase are:
- **Trampoline default params** mutmut v3 wraps functions, and the wrapper
- **Trampoline default params** -- mutmut v3 wraps functions, and the wrapper
keeps the original defaults, so a mutated default is never observed.
- **Omitted `name=` in `add_function()`** pydantic-ai falls back to
- **Omitted `name=` in `add_function()`** -- pydantic-ai falls back to
`method.__name__`, which equals the explicit name being mutated away.
- **`'utf-8'` encoding mutations** Python's codec lookup is case-insensitive
- **`'utf-8'` encoding mutations** -- Python's codec lookup is case-insensitive
and UTF-8 is the default text encoding, so case/omission changes are no-ops.
- **`errors='replace'` mutations** exercised only by invalid bytes; valid
- **`errors='replace'` mutations** -- exercised only by invalid bytes; valid
UTF-8 test data never invokes the error handler.
- **Unreachable `except` blocks** (marked `pragma: no cover`) paths that
- **Unreachable `except` blocks** (marked `pragma: no cover`) -- paths that
can't be triggered in the test environment.
- **`CancelScope(shield=True)` flips** require an outer cancellation during
- **`CancelScope(shield=True)` flips** -- require an outer cancellation during
the near-instant cleanup window.
Anything outside these categories should be treated as a real gap and killed
@@ -43,5 +43,5 @@ with a new test.
Trio-parametrized tests are excluded during mutation testing (`-k 'not trio'`
in `pyproject.toml [tool.mutmut]`) because trio segfaults in mutmut's
subprocess environment on Python 3.14 / macOS. The kill rate is unaffected
subprocess environment on Python 3.14 / macOS. The kill rate is unaffected --
the trio tests exercise the same code paths as the asyncio tests.
@@ -1,6 +1,6 @@
---
name: pydantic-ai-harness
description: Extend Pydantic AI agents with batteries-included capabilities from pydantic-ai-harness currently Code Mode, which collapses many tool calls into one sandboxed Python execution. Use when the user mentions pydantic-ai-harness, CodeMode, Monty, code mode, or tool sandboxing, when they want an agent to run agent-written Python, or when a Pydantic AI agent would benefit from orchestrating multiple tool calls in a single sandboxed script.
description: Extend Pydantic AI agents with batteries-included capabilities from pydantic-ai-harness -- currently Code Mode, which collapses many tool calls into one sandboxed Python execution. Use when the user mentions pydantic-ai-harness, CodeMode, Monty, code mode, or tool sandboxing, when they want an agent to run agent-written Python, or when a Pydantic AI agent would benefit from orchestrating multiple tool calls in a single sandboxed script.
license: MIT
compatibility: Requires Python 3.10+ and pydantic-ai-slim>=1.95.1
metadata:
@@ -11,12 +11,12 @@ metadata:
# Building with Pydantic AI Harness
Pydantic AI Harness is the official capability library for Pydantic AI. Capabilities that need model or
framework support and those fundamental to every agent live in core `pydantic-ai`; optional,
framework support -- and those fundamental to every agent -- live in core `pydantic-ai`; optional,
batteries-included capabilities live here. Both are composed onto an agent through the same
`capabilities=[...]` API.
This skill covers the capabilities shipped by `pydantic-ai-harness`. For the core framework agents,
tools, structured output, hooks, and testing use the `building-pydantic-ai-agents` skill instead.
This skill covers the capabilities shipped by `pydantic-ai-harness`. For the core framework -- agents,
tools, structured output, hooks, and testing -- use the `building-pydantic-ai-agents` skill instead.
## When to Use This Skill
@@ -27,7 +27,7 @@ Invoke this skill when:
- The user asks to sandbox or constrain the code an agent runs
Do **not** use this skill for:
- Core Pydantic AI usage building agents, adding tools, structured output, streaming, or testing (use `building-pydantic-ai-agents`)
- Core Pydantic AI usage -- building agents, adding tools, structured output, streaming, or testing (use `building-pydantic-ai-agents`)
- Capabilities that ship in core `pydantic-ai`, such as web search, tool search, and thinking
- The Pydantic validation library on its own (`pydantic`/`BaseModel` without agents)
@@ -85,17 +85,17 @@ print(result.output)
```
Instead of one model round-trip per tool call, the model writes a single Python script that fetches both
feeds with `asyncio.gather`, dedupes and ranks them in plain Python, and pulls the winning thread
feeds with `asyncio.gather`, dedupes and ranks them in plain Python, and pulls the winning thread --
collapsing many calls into one `run_code`.
## Key Practices
- **Confirm a harness capability is actually needed.** If core Pydantic AI tools and capabilities are enough, use the `building-pydantic-ai-agents` skill instead don't reach for the harness by default.
- **Read the reference before writing code.** Each capability has its own configuration, constraints, and gotchas load the linked reference (e.g. [Code Mode](./references/CODE-MODE.md)) first.
- **Confirm a harness capability is actually needed.** If core Pydantic AI tools and capabilities are enough, use the `building-pydantic-ai-agents` skill instead -- don't reach for the harness by default.
- **Read the reference before writing code.** Each capability has its own configuration, constraints, and gotchas -- load the linked reference (e.g. [Code Mode](./references/CODE-MODE.md)) first.
- **Install the capability's extra.** Importing `CodeMode` without `pydantic-ai-harness[codemode]` raises an `ImportError`; the Monty sandbox is an optional dependency.
## Common Gotchas
- **`native=True` tools bypass `CodeMode`.** Provider-native MCP servers and web search execute server-side, so `run_code` never sees them. Construct them with `native=False` to keep them local and wrappable.
- **The Monty sandbox is a Python subset.** No class definitions, no third-party imports, and only a small stdlib allowlist read [Code Mode](./references/CODE-MODE.md#sandbox-restrictions) before debugging generated code that fails to run.
- **The Monty sandbox is a Python subset.** No class definitions, no third-party imports, and only a small stdlib allowlist -- read [Code Mode](./references/CODE-MODE.md#sandbox-restrictions) before debugging generated code that fails to run.
- **`CodeMode` needs its extra.** Install `pydantic-ai-harness[codemode]`, not the bare package.
+3 -3
View File
@@ -88,15 +88,15 @@ Tools that match the selector are wrapped inside `run_code`. Non-matching tools
### Tool Search
When you mark tools or whole toolsets `defer_loading=True` ([Tool Search](https://ai.pydantic.dev/tools-advanced/#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.
When you mark tools or whole toolsets `defer_loading=True` ([Tool Search](https://ai.pydantic.dev/tools-advanced/#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:
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:
```python
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.
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
+9 -9
View File
@@ -81,7 +81,7 @@ The sandbox uses Monty, a subset of Python. Key restrictions:
State is preserved between calls (REPL-style). Set `restart: true` to reset state.
The last expression's value is automatically captured as the return value you do **not** need to \
The last expression's value is automatically captured as the return value -- you do **not** need to \
`print()` it. Avoid `print()` for return values as it produces Python string representations, not \
structured data. Use `print()` only for supplementary logging or debug output.
@@ -280,7 +280,7 @@ class CodeModeToolset(WrapperToolset[AgentDepsT]):
) -> Any:
"""Execute Python code in the sandbox, or pass through to a native tool."""
if not isinstance(tool, _RunCodeTool):
# Native (non-sandboxed) tool pass through to the wrapped toolset.
# Native (non-sandboxed) tool -- pass through to the wrapped toolset.
return await self.wrapped.call_tool(name, tool_args, ctx, tool)
code = tool_args['code']
@@ -328,7 +328,7 @@ class CodeModeToolset(WrapperToolset[AgentDepsT]):
"""Dispatch a single tool call from inside the sandbox.
Returns the serialized tool result on success. On failure, the
exception propagates the execution loop passes it back into
exception propagates -- the execution loop passes it back into
Monty via `ExternalException` so the sandbox sees it at the
`await` site.
"""
@@ -410,7 +410,7 @@ class CodeModeToolset(WrapperToolset[AgentDepsT]):
)
except MontySyntaxError as e:
raise ModelRetry(f'Syntax error in code:\n{_prepend_prints(e.display(), capture)}') from e
except MontyTypingError as e: # pragma: no cover MontyRepl.feed_start doesn't raise this
except MontyTypingError as e: # pragma: no cover -- MontyRepl.feed_start doesn't raise this
raise ModelRetry(f'Type error in code:\n{_prepend_prints(e.display(), capture)}') from e
except MontyRuntimeError as e:
# Exceptions raised inside dispatch_tool_call (e.g. UserError from
@@ -421,7 +421,7 @@ class CodeModeToolset(WrapperToolset[AgentDepsT]):
# in the display string, so the model sees a useful error. This means
# ModelRetry from a wrapped tool gets double-wrapped
# (ModelRetry → MontyRuntimeError → ModelRetry), but the retry
# semantics are the same the model gets another chance.
# semantics are the same -- the model gets another chance.
raise ModelRetry(f'Runtime error:\n{_prepend_prints(e.display(), capture)}') from e
result = completed.output
@@ -484,7 +484,7 @@ class CodeModeToolset(WrapperToolset[AgentDepsT]):
stacklevel=2,
)
continue
# Warn when a sandboxed tool has no return schema the generated
# Warn when a sandboxed tool has no return schema -- the generated
# signature will show `-> Any`, giving the model no type information
# about the return shape, which limits code mode effectiveness.
if td.return_schema is None and name not in self._warned_deferred:
@@ -659,7 +659,7 @@ async def _handle_function_snapshot(
original_name = sanitized_to_original.get(fn_name, fn_name)
if fn_name in sequential_tools:
# Per-tool sequential: rendered as `def` (sync), so must resolve inline
# Per-tool sequential: rendered as `def` (sync), so must resolve inline --
# the sandbox code doesn't `await` the result. Await pending parallel
# tasks first (barrier) to maintain ordering.
for cid in list(pending):
@@ -669,9 +669,9 @@ async def _handle_function_snapshot(
return snapshot.resume({'return_value': outcome['return_value']})
return snapshot.resume({'exception': outcome['exception']})
# Deferred execution store for later resolution at FutureSnapshot.
# Deferred execution -- store for later resolution at FutureSnapshot.
if global_sequential:
# Bare coroutine don't schedule on the event loop yet.
# Bare coroutine -- don't schedule on the event loop yet.
pending[snapshot.call_id] = dispatch(original_name, snapshot.kwargs)
else:
# Eagerly schedule as a Task for concurrent execution.
+5 -5
View File
@@ -45,7 +45,7 @@ print(result.output)
## Security model
- **Containment.** Paths resolve relative to `root_dir`; anything resolving
outside via `..`, an absolute path, or a symlink is rejected. Symlinks
outside -- via `..`, an absolute path, or a symlink -- is rejected. Symlinks
are resolved with `os.path.realpath` *before* the containment check, closing
the TOCTTOU window.
- **Binary detection.** `read_file` returns a placeholder instead of dumping
@@ -64,7 +64,7 @@ need `**`.
|---|---|
| `allowed_patterns` | If non-empty, only matching paths are accessible (allowlist). |
| `denied_patterns` | Matching paths are always rejected (denylist). |
| `protected_patterns` | Matching paths are read-only reads succeed, writes are rejected. |
| `protected_patterns` | Matching paths are read-only -- reads succeed, writes are rejected. |
`protected_patterns` defaults to `.git/`, `.env`/`.env.*`, `*.pem`, `*.key`,
and `**/secrets*`. Pass an empty list to disable protection.
@@ -77,7 +77,7 @@ The three rules apply at two different granularities:
`create_directory`) gates the operation's target path. You must name a path
that the patterns permit.
- **Walkers** (`list_directory`, `search_files`, `find_files`) gate their root
by deny/protected patterns, but **not** by `allowed_patterns` a directory
by deny/protected patterns, but **not** by `allowed_patterns` -- a directory
root like `.` never matches a file pattern such as `src/*.py`, so requiring
it to would make every listing fail. Instead, the root is always walked and
each **entry** is filtered against all three lists. A directory listing can
@@ -87,14 +87,14 @@ So with `allowed_patterns=['*.py']`, `list_directory('.')` succeeds and shows
only the `.py` entries; `read_file('notes.md')` is rejected.
> Dotfiles and dot-directories (`.git`, `.env`, `.github`, …) are skipped by
> all three walkers `list_directory`, `search_files`, and `find_files`
> all three walkers -- `list_directory`, `search_files`, and `find_files` --
> regardless of patterns.
## Configuration
```python
FileSystem(
root_dir='.', # str | Path sandbox root
root_dir='.', # str | Path -- sandbox root
allowed_patterns=[], # allowlist globs (empty = allow all)
denied_patterns=[], # denylist globs
protected_patterns=[...], # read-only globs (defaults to secrets/.git)
+3 -3
View File
@@ -17,7 +17,7 @@ from pydantic_ai.toolsets import FunctionToolset
_P = ParamSpec('_P')
# Errors that mean "the model asked for something the tool couldn't do" a
# Errors that mean "the model asked for something the tool couldn't do" -- a
# missing file, a denied path, a stale edit. pyai only feeds `ModelRetry` back
# to the model; any other exception aborts the whole run. `_recoverable`
# converts these so the agent can correct itself and continue.
@@ -118,7 +118,7 @@ class FileSystemToolset(FunctionToolset[AgentDepsT]):
"""Glob-match a relative path, treating a leading `**/` as 'any directory, including the root'.
`fnmatch` has no recursive `**`, so a bare `**/secrets*` would miss a
root-level `secrets.yaml` there's no leading directory to match.
root-level `secrets.yaml` -- there's no leading directory to match.
Retrying with the `**/` prefix stripped covers the zero-directory case.
"""
if fnmatch.fnmatch(path, pattern):
@@ -148,7 +148,7 @@ class FileSystemToolset(FunctionToolset[AgentDepsT]):
`check_allowed=False` skips the `allowed_patterns` gate. Walkers
(`list_directory`, `search_files`, `find_files`) pass it so their root
directory isn't required to match `allowed_patterns` itself `.` or
directory isn't required to match `allowed_patterns` itself -- `.` or
`src` would never match a file pattern like `src/*.py`. The walk's
entries are still filtered against `allowed_patterns` per-entry via
`_is_accessible`. Denied and protected patterns continue to gate the
+2 -2
View File
@@ -107,8 +107,8 @@ targeting context and then to the active trace id.
### Templating with deps
By default the resolved prompt is used verbatim. Pass `render_template=True` to render it as a
Handlebars template against the agent's `deps` the same mechanism as
[`TemplateStr`](https://ai.pydantic.dev/api/#pydantic_ai.TemplateStr) so `{{field}}` is filled
Handlebars template against the agent's `deps` -- the same mechanism as
[`TemplateStr`](https://ai.pydantic.dev/api/#pydantic_ai.TemplateStr) -- so `{{field}}` is filled
from `deps`:
```python
+9 -9
View File
@@ -6,8 +6,8 @@ managed background processes.
## The problem
Agents frequently need to run a build, a test suite, a linter, or a quick
`grep`. Wiring up subprocess handling streaming output, timeouts, truncation,
killing runaway processes, and cleaning up background jobs at the end of a run
`grep`. Wiring up subprocess handling -- streaming output, timeouts, truncation,
killing runaway processes, and cleaning up background jobs at the end of a run --
is fiddly boilerplate that every agent reinvents.
## The solution
@@ -40,8 +40,8 @@ print(result.output)
Output is labelled with `[stdout]` / `[stderr]` markers and an `[exit code: N]`
line on non-zero exit. When it exceeds `max_output_chars` the **tail** is kept
(the head is dropped), so errors, stack traces, and the `[stderr]` section
which all land at the end survive truncation.
(the head is dropped), so errors, stack traces, and the `[stderr]` section --
which all land at the end -- survive truncation.
## Command controls
@@ -52,7 +52,7 @@ which all land at the end — survive truncation.
| `denied_operators` | Shell operators (e.g. `>`, `>>`, `|`) that are rejected when present. |
| `allow_interactive` | If `False` (default), commands that expect a TTY (`vi`, `sudo`, `ssh`, …) are blocked. |
`allowed_commands` and `denied_commands` are mutually exclusive set one, not
`allowed_commands` and `denied_commands` are mutually exclusive -- set one, not
both. `denied_commands` defaults to a list of destructive commands (`rm`,
`rmdir`, `mkfs`, `dd`, `shutdown`, `reboot`, …); pass an empty list to disable.
The executable name is extracted with `shlex`, so arguments don't bypass the
@@ -60,7 +60,7 @@ check.
> **These checks are best-effort, not a security boundary.** A sufficiently
> motivated agent can defeat them (e.g. `bash -c '...'`, env-var indirection).
> For hard guarantees, run the agent inside OS-level isolation a container or
> For hard guarantees, run the agent inside OS-level isolation -- a container or
> sandbox.
## Background processes
@@ -68,12 +68,12 @@ check.
`start_command` writes stdout/stderr to temp files and returns a short ID. Use
`check_command(id)` to poll and `stop_command(id)` to terminate and collect
final output. Processes are launched in their own session (`start_new_session`)
so the whole process group can be signalled `SIGTERM`, escalating to
so the whole process group can be signalled -- `SIGTERM`, escalating to
`SIGKILL` after a grace period.
On run end, the toolset's `__aexit__` terminates every still-running background
process and deletes its temp files. The agent runtime enters toolsets via an
`AsyncExitStack`, so this cleanup runs whether the run succeeds or raises an
`AsyncExitStack`, so this cleanup runs whether the run succeeds or raises -- an
agent that forgets to call `stop_command` won't leak processes.
## Working directory
@@ -88,7 +88,7 @@ subsequent calls. Commands containing `;` skip the sentinel injection so the
```python
Shell(
cwd='.', # str | Path working directory
cwd='.', # str | Path -- working directory
allowed_commands=[], # allowlist (mutually exclusive with denied)
denied_commands=[...], # denylist (defaults to destructive commands)
denied_operators=[], # blocked shell operators
+6 -6
View File
@@ -1,4 +1,4 @@
"""Shell toolset gives agents the ability to run commands."""
"""Shell toolset -- gives agents the ability to run commands."""
from __future__ import annotations
@@ -163,7 +163,7 @@ class ShellToolset(FunctionToolset[AgentDepsT]):
def _check_command(self, command: str) -> None:
"""Validate command against allow/deny lists.
These checks are best-effort and are not a security boundary a
These checks are best-effort and are not a security boundary -- a
sufficiently motivated agent can bypass them. Use OS-level isolation
(containers, sandboxes) for hard enforcement.
"""
@@ -190,8 +190,8 @@ class ShellToolset(FunctionToolset[AgentDepsT]):
def _truncate(self, text: str) -> str:
"""Truncate output to the configured cap, keeping the tail.
The most useful output errors, stack traces, exit info, and the
`[stderr]` section (which callers append last) lands at the end, so
The most useful output -- errors, stack traces, exit info, and the
`[stderr]` section (which callers append last) -- lands at the end, so
the head is dropped and the final `max_output_chars` are kept.
"""
if len(text) <= self._max_output_chars:
@@ -204,7 +204,7 @@ class ShellToolset(FunctionToolset[AgentDepsT]):
`pwd` is written to a private temp file whose random path the agent's
command can't address, so command output can never spoof the tracked
cwd unlike parsing a sentinel out of stdout, where any command that
cwd -- unlike parsing a sentinel out of stdout, where any command that
prints the sentinel string (or one using `;` to skip success-gating)
could redirect the cwd. Returns the wrapped command plus the temp-file
path, or the command unchanged and `None` when cwd tracking is off.
@@ -240,7 +240,7 @@ class ShellToolset(FunctionToolset[AgentDepsT]):
await proc.wait()
return
# Still alive after grace period hard kill
# Still alive after grace period -- hard kill
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
+23 -23
View File
@@ -70,7 +70,7 @@ async def build_ctx(
) -> RunContext[T]:
"""Build a `RunContext` with a prepared `ToolManager`.
Use this for tests that call `call_tool` `CodeModeToolset` requires
Use this for tests that call `call_tool` -- `CodeModeToolset` requires
`ctx.tool_manager` to be set.
"""
from pydantic_ai.tool_manager import ToolManager
@@ -259,7 +259,7 @@ class TestCodeMode:
"""End-to-end: a string-returning tool with a default arg is callable from the sandbox.
Exercises (a) string return values flowing back through the await/dispatch loop,
(b) default-argument handling the LLM-side code only passes `name`, not `greeting`.
(b) default-argument handling -- the LLM-side code only passes `name`, not `greeting`.
"""
wrapper = CodeMode[None]().get_wrapper_toolset(_build_function_toolset(greet))
assert isinstance(wrapper, CodeModeToolset)
@@ -332,7 +332,7 @@ class TestCodeMode:
description = tools['run_code'].tool_def.description
assert description is not None
# Note the lack of `(*, ...)` empty params render as `()`.
# Note the lack of `(*, ...)` -- empty params render as `()`.
assert 'async def now_iso() -> str' in description
assert 'async def now_iso(*' not in description
@@ -368,7 +368,7 @@ class TestCodeMode:
run_code = tools['run_code']
await wrapper.call_tool('run_code', {'code': 'x = 99'}, ctx, run_code)
# After restart, `x` should no longer exist on a fresh REPL the static
# After restart, `x` should no longer exist -- on a fresh REPL the static
# type checker catches undefined names before execution.
with pytest.raises(ModelRetry, match=r'x'):
await wrapper.call_tool('run_code', {'code': 'print(x)', 'restart': True}, ctx, run_code)
@@ -427,7 +427,7 @@ class TestCodeMode:
# ---------------------------------------------------------------------------
async def test_for_run_returns_fresh_instance_with_cleared_repl(self) -> None:
"""`for_run` must hand back a new toolset instance concurrent runs cannot share REPL state."""
"""`for_run` must hand back a new toolset instance -- concurrent runs cannot share REPL state."""
wrapper = CodeMode[None]().get_wrapper_toolset(_build_function_toolset(add))
assert isinstance(wrapper, CodeModeToolset)
ctx = await build_ctx(None, wrapper)
@@ -618,9 +618,9 @@ class TestCodeMode:
tools = await wrapper.get_tools(ctx)
run_code = tools['run_code']
# First call sets up variables type-checked but valid.
# First call sets up variables -- type-checked but valid.
await wrapper.call_tool('run_code', {'code': "addr = {'street': '1 Main St', 'city': 'NYC'}"}, ctx, run_code)
# Second call uses them not type-checked (accumulated REPL state).
# Second call uses them -- not type-checked (accumulated REPL state).
code = "p = {'name': 'Alice', 'home': addr}\nprint(await lookup_person(person=p, count=3))"
result = await wrapper.call_tool('run_code', {'code': code}, ctx, run_code)
assert result.return_value == {'output': '3x Alice @ 1 Main St\n'}
@@ -806,7 +806,7 @@ class TestCodeMode:
name='search',
description='Search for things.',
parameters_json_schema={'type': 'object', 'properties': {'q': {'type': 'string'}}, 'required': ['q']},
# No return_schema simulates an MCP tool without outputSchema.
# No return_schema -- simulates an MCP tool without outputSchema.
)
static = _StaticToolset([td], results={'search': 'found it'})
wrapper = CodeMode[None]().get_wrapper_toolset(static)
@@ -870,7 +870,7 @@ class TestCodeMode:
seen_tool_definitions: list[list[str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Snapshot what tool definitions the model is being shown each turn
# Snapshot what tool definitions the model is being shown each turn --
# if `CodeMode` is wired correctly the model only ever sees `run_code`.
seen_tool_definitions.append([td.name for td in info.function_tools])
@@ -900,7 +900,7 @@ class TestCodeMode:
result = await agent.run('please add 4 and 6')
# The model was shown only `run_code` the wrapped `add` tool is hidden behind it.
# The model was shown only `run_code` -- the wrapped `add` tool is hidden behind it.
assert seen_tool_definitions[0] == ['run_code']
assert seen_tool_definitions[1] == ['run_code']
@@ -928,7 +928,7 @@ class TestCodeMode:
@pytest.mark.parametrize(
'original, expected',
[
('get_weather', 'get_weather'), # already valid no change
('get_weather', 'get_weather'), # already valid -- no change
('get-weather', 'get_weather'), # hyphen → underscore
('api.call', 'api_call'), # dot → underscore
('api.call-now', 'api_call_now'), # mixed
@@ -1120,7 +1120,7 @@ class TestCodeMode:
"""
try:
from pydantic_ai.capabilities import HandleDeferredToolCalls
except ImportError: # pragma: no cover only fires on floor-slim CI, which doesn't gate on coverage
except ImportError: # pragma: no cover -- only fires on floor-slim CI, which doesn't gate on coverage
pytest.skip('Requires pydantic-ai-slim with `HandleDeferredToolCalls` (next release after 1.86.1)')
from pydantic_ai.exceptions import ApprovalRequired as _ApprovalRequired
@@ -1148,11 +1148,11 @@ class TestCodeMode:
without re-invoking the handler; the harness then converts it to a `ModelRetry`.
This guards the contract documented on `_resolve_single_deferred.Raises`: a re-raised
deferral after approval is *not* re-resolved it bubbles up to the caller.
deferral after approval is *not* re-resolved -- it bubbles up to the caller.
"""
try:
from pydantic_ai.capabilities import HandleDeferredToolCalls
except ImportError: # pragma: no cover only fires on floor-slim CI, which doesn't gate on coverage
except ImportError: # pragma: no cover -- only fires on floor-slim CI, which doesn't gate on coverage
pytest.skip('Requires pydantic-ai-slim with `HandleDeferredToolCalls` (next release after 1.86.1)')
from pydantic_ai.exceptions import ApprovalRequired as _ApprovalRequired
@@ -1202,7 +1202,7 @@ class TestCodeMode:
ctx = await build_ctx(None, wrapper)
tools = await wrapper.get_tools(ctx)
# Pass a string where int is expected type checker catches this.
# Pass a string where int is expected -- type checker catches this.
with pytest.raises(ModelRetry, match='error in code'):
await wrapper.call_tool(
'run_code',
@@ -1398,7 +1398,7 @@ class TestCodeMode:
with pytest.raises(ModelRetry, match='error in code'):
await wrapper.call_tool('run_code', {'code': 'await nonexistent_tool(x=1)'}, ctx, run_code)
# After a successful call, type checking is skipped falls to runtime NameError.
# After a successful call, type checking is skipped -- falls to runtime NameError.
await wrapper.call_tool('run_code', {'code': '1 + 1', 'restart': True}, ctx, run_code)
with pytest.raises(ModelRetry, match='Runtime error'):
await wrapper.call_tool('run_code', {'code': 'await nonexistent_tool(x=1)'}, ctx, run_code)
@@ -1419,12 +1419,12 @@ class TestCodeMode:
with pytest.raises(ModelRetry, match='error in code'):
await wrapper.call_tool('run_code', {'code': 'await add(1, 2)'}, ctx, run_code)
# After a valid call, type checking is skipped runtime guard catches it.
# After a valid call, type checking is skipped -- runtime guard catches it.
await wrapper.call_tool('run_code', {'code': '1 + 1', 'restart': True}, ctx, run_code)
with pytest.raises(ModelRetry, match='does not accept positional arguments'):
await wrapper.call_tool('run_code', {'code': 'await add(1, 2)'}, ctx, run_code)
# Caught positional args sandbox code handles the error gracefully.
# Caught positional args -- sandbox code handles the error gracefully.
result = await wrapper.call_tool(
'run_code',
{'code': 'try:\n await add(1, 2)\nexcept TypeError:\n pass\n"recovered"'},
@@ -1563,7 +1563,7 @@ class TestCodeMode:
)
assert result.return_value == [3, 'Hello, World!']
# Both calls recorded in metadata greet resolved at barrier, add resolved inline.
# Both calls recorded in metadata -- greet resolved at barrier, add resolved inline.
assert result.metadata['code_mode'] is True
calls = result.metadata['tool_calls']
returns = result.metadata['tool_returns']
@@ -1669,7 +1669,7 @@ class TestCodeMode:
assert 'def add(' in desc
assert 'async def add(' not in desc
# The tool still works global sequential resolves at FutureSnapshot.
# The tool still works -- global sequential resolves at FutureSnapshot.
result = await seq_wrapper.call_tool('run_code', {'code': 'add(a=5, b=7)'}, ctx, run_code)
assert result.return_value == 12
@@ -1682,14 +1682,14 @@ class TestCodeMode:
tools = await wrapper.get_tools(ctx)
run_code = tools['run_code']
# First call succeeds REPL has state.
# First call succeeds -- REPL has state.
await wrapper.call_tool('run_code', {'code': 'x = await add(a=1, b=2)'}, ctx, run_code)
# Restart with bad code type checking catches it.
# Restart with bad code -- type checking catches it.
with pytest.raises(ModelRetry, match='Type error'):
await wrapper.call_tool('run_code', {'code': "await add(a='bad', b=3)", 'restart': True}, ctx, run_code)
# Retry without restart should still be type-checked (REPL was cleared).
# Retry without restart -- should still be type-checked (REPL was cleared).
with pytest.raises(ModelRetry, match='Type error'):
await wrapper.call_tool('run_code', {'code': "await add(a='bad', b=3)"}, ctx, run_code)
+3 -3
View File
@@ -1,7 +1,7 @@
"""DBOS integration tests for CodeMode.
Verifies that the snapshot-based execution loop works inside a DBOS
durable workflow. DBOS uses SQLite locally no external services needed.
durable workflow. DBOS uses SQLite locally -- no external services needed.
DBOS defaults to `parallel_ordered_events` execution mode, which triggers
the sequential FutureSnapshot resolution path in the execution loop.
@@ -115,7 +115,7 @@ def test_code_mode_runs_in_dbos_workflow(dbos_instance: DBOS) -> None:
assert user_part.part_kind == 'user-prompt'
assert user_part.content == 'Calculate 3 + 4' # pyright: ignore[reportUnknownMemberType]
# 2. Model response run_code tool call
# 2. Model response -- run_code tool call
assert isinstance(messages[1], ModelResponse)
tc = messages[1].parts[0]
assert isinstance(tc, ToolCallPart)
@@ -160,7 +160,7 @@ def test_code_mode_runs_in_dbos_workflow(dbos_instance: DBOS) -> None:
assert len(_captured_tool_defs) == 2
for tool_defs in _captured_tool_defs:
tool_names = [td.name for td in tool_defs]
# CodeMode wraps `add` into `run_code` the model should only see `run_code`
# CodeMode wraps `add` into `run_code` -- the model should only see `run_code`
assert 'run_code' in tool_names
assert 'add' not in tool_names
+5 -5
View File
@@ -5,7 +5,7 @@ works inside a Temporal workflow sandbox, which forbids threads and
`call_soon_threadsafe`.
These tests start a local Temporal dev server via
`WorkflowEnvironment.start_local()` the Temporal SDK downloads and
`WorkflowEnvironment.start_local()` -- the Temporal SDK downloads and
runs `temporalite` automatically.
"""
@@ -76,7 +76,7 @@ async def client(temporal_env: WorkflowEnvironment) -> Client:
# ---------------------------------------------------------------------------
# Tools and agents (module-level Temporal requirement)
# Tools and agents (module-level -- Temporal requirement)
# ---------------------------------------------------------------------------
@@ -101,7 +101,7 @@ def _code_mode_model(messages: list[ModelRequest | ModelResponse], info: AgentIn
if isinstance(part, ToolReturnPart) and part.tool_name == 'run_code':
return ModelResponse(parts=[TextPart(content=f'done: {part.content}')])
# First call emit run_code.
# First call -- emit run_code.
return ModelResponse(
parts=[
ToolCallPart(
@@ -174,7 +174,7 @@ async def test_code_mode_runs_in_temporal_workflow(client: Client) -> None:
assert messages[0]['parts'][0]['part_kind'] == 'user-prompt'
assert messages[0]['parts'][0]['content'] == 'Calculate 3 + 4'
# 2. Model response run_code tool call
# 2. Model response -- run_code tool call
assert messages[1]['kind'] == 'response'
tc = messages[1]['parts'][0]
assert tc['part_kind'] == 'tool-call'
@@ -217,7 +217,7 @@ async def test_code_mode_runs_in_temporal_workflow(client: Client) -> None:
assert len(_captured_tool_defs) == 2
for tool_defs in _captured_tool_defs:
tool_names = [td.name for td in tool_defs]
# CodeMode wraps `add` into `run_code` the model should only see `run_code`
# CodeMode wraps `add` into `run_code` -- the model should only see `run_code`
assert 'run_code' in tool_names
assert 'add' not in tool_names
+3 -3
View File
@@ -256,7 +256,7 @@ class TestAccessPatterns:
max_search_results=1000,
max_find_results=1000,
)
# Reads ignore the protected list they only block writes.
# Reads ignore the protected list -- they only block writes.
assert ts._is_accessible('.env')
assert ts._is_accessible('.env', write=True) is False
# A non-protected path passes the protected check even with write=True,
@@ -907,7 +907,7 @@ class TestMutationKillers:
assert f'hash:{expected_hash}' in result
async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset[None], fs_root: Path) -> None:
"""With invalid UTF-8 bytes, the tool should not crash it should use replacement chars."""
"""With invalid UTF-8 bytes, the tool should not crash -- it should use replacement chars."""
# Write raw bytes that are invalid UTF-8
(fs_root / 'broken_utf8.txt').write_bytes(b'hello \xff\xfe world\n')
result = await toolset.read_file('broken_utf8.txt')
@@ -1024,7 +1024,7 @@ class TestMutationKillers:
async def test_find_hidden_skip_does_not_stop_iteration(self, toolset: FileSystemToolset[None]) -> None:
"""Hidden files must be skipped, but subsequent visible files must still appear."""
# .hidden comes before hello.txt alphabetically skipping must not break the loop
# .hidden comes before hello.txt alphabetically -- skipping must not break the loop
result = await toolset.find_files('*')
assert 'hello.txt' in result
assert 'multi.txt' in result
+3 -3
View File
@@ -442,7 +442,7 @@ class TestCwdCapture:
class TestForRunIsolation:
"""B3: `get_toolset` builds one shared instance at agent construction, so
`for_run` must hand each run a fresh copy otherwise concurrent runs share
`for_run` must hand each run a fresh copy -- otherwise concurrent runs share
`_cwd`/`_background` and corrupt each other."""
async def test_for_run_returns_fresh_instance(self, persist_toolset: ShellToolset[None]) -> None:
@@ -463,7 +463,7 @@ class TestForRunIsolation:
class TestPersistCwdHardening:
"""B4: regression tests for the old stdout-sentinel footguns a command's
"""B4: regression tests for the old stdout-sentinel footguns -- a command's
output spoofing the cwd, and `;` silently disabling tracking."""
async def test_cd_persists_even_with_semicolon(self, persist_toolset: ShellToolset[None]) -> None:
@@ -1102,7 +1102,7 @@ class TestEdgeCases:
persist_cwd=True,
allow_interactive=False,
)
# Successful echo sentinel shows same dir, cwd should remain valid
# Successful echo -- sentinel shows same dir, cwd should remain valid
await ts.run_command('echo hi')
assert ts._cwd.is_dir()