mirror of
https://github.com/pydantic/pydantic-ai-harness.git
synced 2026-07-21 02:45:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user