Files
b4365440b5 feat: serve a Pydantic AI agent to editors over ACP (experimental) (#274)
* feat: add ACP capability to serve agents to editors

Editors and TUIs that speak the Agent Client Protocol (Zed and others)
can drive an external coding agent, but plugging a Pydantic AI agent
into one previously meant implementing the ACP server side by hand.
run_acp_stdio serves any Agent over stdio: streamed text and thinking,
file edits rendered as diffs, human-in-the-loop tool approval mapped to
deferred-approval tools, per-workspace sessions via a session_config
hook, model switching, cancellation, and optional session persistence.

FileSystem.get_toolset/Shell.get_toolset now return their concrete
toolset types so the ACP presenter can recognize their tool calls by
name and annotate them with kinds, locations, and diffs.

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

* fix: skip ACP tests at collection when the acp extra is not installed

The slim CI jobs sync without extras, so `agent-client-protocol` is absent and
the ACP test modules failed at import during collection. Ignore them via
conftest when `acp` can't be found; `test_packaging.py` stays collected since
package metadata holds on base installs.

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

* fix(acp): enable unstable routing and bound streamed updates by byte size

session/set_model and session/close are advertised at initialize, but the ACP
SDK router rejects them as unstable unless run_agent is given
use_unstable_protocol=True -- so the model picker and session-close affordance
returned method_not_found over a real connection. Enable the flag.

Streamed text was chunked by character count, but the SDK serializes with
ensure_ascii=True, so a non-ASCII code point expands up to 12 bytes (a surrogate
pair) inside the JSON string. A single agent_message_chunk of emoji/CJK could
exceed the client's 64 KiB read buffer and drop the connection. Chunk by escaped
byte length instead.

Both gaps were invisible to the suite: the close/set_model tests called the
adapter directly (bypassing the router) and the large-output test used only
ASCII. Add a through-the-router stdio test and a non-ASCII chunking test as guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(acp): per-turn usage, read-resilient terminal cleanup, persistence + native-toolset tests

Address review follow-ups on the ACP capability:

- Report per-turn token usage on `PromptResponse.usage`, summed across approval
  passes (UNSTABLE ACP field; clients that don't support it ignore it).
- Suppress client errors during the shielded terminal kill/release so a failing
  cleanup call can't mask the in-flight CancelledError (the spec requires the turn
  to end with a `cancelled` stop reason).
- Cover the persistence guarantees that previously had no store-active test: a
  cancelled turn commits nothing, an approval-resume turn persists each update
  once (no duplicate tool-call start), and `StoredSession` round-trips through
  Pydantic across the full `SessionUpdate` union.
- Add a through-the-wire stdio test that the editor-native fs/terminal toolsets
  route to the client when mounted per session.
- Document the dynamic-`ApprovalRequired` partial-side-effect nuance, per-turn
  usage, and that session persistence composes with per-run step durability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(acp): editor-native reads for read-only fs clients, writes delegated locally

A client that advertises filesystem reads but not writes previously got no
editor-native filesystem at all (acp_filesystem returned None, so callers fell
back to a fully-local toolset and lost the editor's live view, e.g. unsaved
buffers). It now returns a combined toolset: reads route through the editor, and
writes are delegated to the local FileSystem capability rooted at the session
cwd (reusing its path sandboxing rather than reimplementing writes).

This is coherent only when the agent shares the workspace disk with the editor
(same machine, or an agent inside the editor's container); for a remote editor
the writes land on the agent's disk. Documented as such at the helper and README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(acp): point _all_known_model_names at the now-merged known_model_names()

pydantic-ai#5803 added `pydantic_ai.models.known_model_names()`, the public
replacement for the `KnownModelName.__value__` introspection. It isn't in a
released `pydantic-ai-slim` yet, so the swap (and the floor bump it requires)
waits for that release; update the breadcrumb so it's actioned then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(acp): replay user turns on session/load, advertise MCP capabilities

Two spec-conformance gaps from PR review:

- The transcript only ever recorded agent-direction updates, so a reopened
  session replayed a one-sided conversation. ACP requires session/load to
  replay the entire conversation, user turns included. The prompt's content
  blocks are now seeded into the turn's update list -- recorded for replay
  but never sent live (the client renders its own prompt; the prompt-turn
  spec sends no user echo) -- riding the existing commit path so a cancelled
  turn still rolls its user message back.

- initialize left mcp_capabilities at the default (http/sse false), and the
  spec forbids clients from sending HTTP/SSE MCP servers that are not
  advertised -- making the documented session_config -> mcp_servers path
  unreachable for conforming clients over those transports (stdio is not
  gated). Advertising is now an explicit opt-in mirroring prompt_capabilities,
  since only the embedder knows which transports their session_config
  connects; setting it without a session_config fails at construction rather
  than inviting servers that session/new would reject.

Also adds the guard test from review follow-up: _all_known_model_names
fails loudly if pyai ever recomposes the KnownModelName alias.

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

* fix(acp): make the collection guard coverable in a single environment

The acp-extra collection guard was an `if` statement whose true arm only
runs on slim installs, so local `make testcov` (always all-extras) could
never reach 100% -- only CI's combined slim+all-extras matrix could. A
conditional expression has no statement arc for branch coverage to miss,
so the documented local gate works again without excluding a line CI
genuinely covers.

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

* docs(acp): record why _all_known_model_names avoids the unreleased public API

The breadcrumb read as if known_model_names() were already usable; make the
deliberate avoidance explicit. The public enumerator is merged upstream but
not in any released pydantic-ai-slim, and the test-floor CI job runs against
the floor release, so calling it would break there. Move the rationale from
the docstring to a code comment (docstring stays contract-only) and spell out
the swap-and-delete trigger: a release that ships it plus a floor bump.

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

* test(acp): add spec-conformance suite driven over a real in-memory wire

The existing tests invoke adapter methods directly, below the SDK's JSON-RPC
router and serialization -- a boundary that cannot see two bug classes that
already bit this adapter: a method the router gates before the adapter runs
(the use_unstable_protocol reachability bug) and a frame whose serialized
bytes overrun the client buffer (the ensure_ascii chunking bug).

`tests/acp/_wire.py` closes that gap without a subprocess: a real
ClientSideConnection talks to acp.run_agent across a socket.socketpair in one
event loop, so every request crosses the router and codec and every update
arrives as bytes the client re-parses, with asyncio's default 64 KiB reader
limit standing in for the stdio buffer.

`tests/acp/test_conformance.py` is organized by spec clause, not by adapter
method, each with an oracle built from the spec/input rather than the
adapter's own output:
- version negotiation echoes the requested version (not a literal)
- capabilities are advertised iff supported (load_session, mcp, auth_methods,
  session list/fork/resume, modes, config options) -- read back off the wire
- unstable methods route through with the flag and are method_not_found
  without it (the load-bearing reason run_acp_stdio enables it)
- error codes distinguish method_not_found from invalid_params
- session/load replays the entire conversation including user turns
- large non-ASCII output reassembles intact within the client's read buffer

CONFORMANCE.md records the clause-to-test matrix, the open gaps (none a known
bug), the two verified deviations, and the N/A ledger, derived from a
spec-page sweep with adversarial verification of every candidate finding.

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

* refactor(acp): stop surfacing unconsumed additionalDirectories

The adapter accepted the client's additionalDirectories and forwarded them to
the session_config AcpSession, but never advertised
sessionCapabilities.additionalDirectories -- so a conformant client never sent
them, and nothing downstream consumed them (the FileSystem capability is
single-root). The ACP capability is also still UNSTABLE.

Drop the dead surface: remove the field from AcpSession and stop forwarding it.
The acp.Agent base signature still carries the parameter, so the session
methods accept and ignore it. Supporting it properly (multi-root filesystem +
advertisement) is recorded as a deferred feature.

Also trims CONFORMANCE.md to a public supported-capability summary.

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

* fix(acp): keep inline image bytes and let run_acp_stdio advertise MCP

An ACP image block always carries inline `data`; `uri` is only an optional
source reference, so preferring the URL dropped the bytes the client actually
sent in favour of a link the model may be unable to fetch. Prefer the inline
data, matching the reference ACP agents.

`run_acp_stdio`/`run_acp_stdio_sync` forwarded every adapter option except
`mcp_capabilities`, so advertising MCP transports was only reachable by
constructing `PydanticAIACPAgent` by hand. Forward it for parity.

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

* fix(acp): reliable approval scope, mapped stop reasons, safe session reload

"Always allow"/"always reject" keyed the scope on the raw tool arguments,
which a streaming model (and OpenAI by default) delivers as a JSON string;
re-ordered keys then produced a different key and silently re-prompted for a
call already decided. Canonicalize via `args_as_dict()` so the same logical
call shares one remembered decision.

Completed turns always reported `end_turn`, hiding `max_tokens`/`refusal`
from the client. Map the model's finish reason to the ACP stop reason.

`session/load` overwrote a still-open session without cancelling its in-flight
turn, leaking a task that could later persist stale state over the restored
transcript. Tear it down first, sharing the close_session teardown path.

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

* docs(acp): correct the API reference and capability notes

The README API block omitted `session_store`/`models` (and now
`mcp_capabilities`), the limitations list contradicted the documented
MCP-via-`session_config` support, and the overwrite-diff note misdescribed
what the code emits. Also drop a dangling internal-doc reference from a
source comment.

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

* docs(acp): record the unhandled SessionStore failure contract

A durable SessionStore can fail on save or return a corrupt payload on load,
but the adapter currently propagates those exceptions rather than handling
them. Document the gap on the Protocol where store implementers will see it,
so the behaviour is a known boundary rather than a surprise -- without adding
handling we have no consumer for yet.

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

* docs(acp): correct the SessionStore failure note to match observed behaviour

The previous note assumed store exceptions surface "raw". Verified against the
SDK (connection.py `_run_request`) and over a real in-memory wire with a raising
stub store: they are converted to JSON-RPC errors before reaching the client -- a
`pydantic.ValidationError` to `invalid_params` (-32602), anything else to a
generic internal error (-32603). Record what the client actually receives rather
than what was guessed.

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

* fix(acp): handle SessionStore failures instead of leaking them

`session_store` is a public, documented extension point, so a durable store
that fails on save or returns a corrupt payload on load is reachable today --
not hypothetical. A failed save previously errored the very turn the user had
already watched stream to completion; a corrupt load leaked raw pydantic/IO
detail to the client.

Make save failures non-fatal: log and swallow them so the turn (or session)
that already committed in memory still succeeds, and the next save catches the
store up. Make load failures fail `session/load` with a clear internal error
rather than a leaked exception, since a session that cannot be read cannot be
reopened. Verified over the in-memory wire with a failing stub store.

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

* fix(acp): close out in-flight tool calls when a turn is cancelled

A tool call announced as pending/in_progress was never driven to a terminal
status when its turn was cancelled (via session/cancel or a dismissed permission
dialog), so a client kept rendering it as running after the turn ended cancelled.

On the cancel unwind, fail any tool call still lacking a terminal result -- sent
live (a cancelled turn never commits its transcript) and shielded so the asyncio
cancellation cannot abort the send. Verified over the in-memory wire.

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

* fix(acp): reject prompts queued behind a session that closed or reloaded

A prompt waiting on the session's turn lock held a reference to the old
SessionState; close_session/load_session only cancel the *active* turn, so
the queued prompt would run against the discarded state - invisible to
session/cancel - and persist its orphaned history over the closed (or just
restored) session. Re-checking liveness after acquiring the lock turns that
zombie turn into the invalid_params the client already gets for a prompt
sent after close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(acp): never swallow a handler's own teardown cancellation

prompt() and _cancel_active_turn awaited the turn task bare, so the
CancelledError from connection teardown was indistinguishable from the
turn's own cancellation and got converted into a response (or, via a
dismissed permission dialog, replaced by the internal _TurnCancelled).
The SDK cancels each handler task exactly once on shutdown and its sender
is already closed by then, so a handler that survives that one cancel and
tries to answer hangs Connection.close() forever. Awaiting through
asyncio.shield keeps the two cancellation sources apart: the turn's end
is read off the (done) task, while the handler's own cancellation
propagates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(acp): don't report rollback when a cancel lands in the post-commit save

The store save after a turn commits was the one suspension point left
between commit and return: a session/cancel delivered there escaped the
turn task and the prompt answered 'cancelled' (the rollback signal, no
user_message_id) while the session's in-memory history and transcript
already contained the whole turn - the next prompt would build on history
the client believed was discarded. The InMemorySessionStore never
suspends, which is why the existing cancel-persistence test could not see
this; any real (file/db) store can. A cancel that arrives after commit
has simply lost the race, so the interrupted save is treated like the
write failures _persist already swallows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(acp): don't leak a terminal when run_command is cancelled mid-create

The terminal/create call sat before the cleanup try-block, so a
cancellation landing there unwound without a kill or release - but the
request may already be on the wire (the SDK sender flushes queued
payloads even when the awaiting future is cancelled), leaving the
command running client-side with nobody holding its id. Running the
create as a shielded task lets the cleanup await the response late,
learn the id, and kill/release as usual. Folding the late-id case into
one kill path and one release path also keeps the existing guarantee
that a failing kill never skips the release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(acp): end limit-hit turns with max_turn_requests, close out tool calls on errors

pydantic-ai's default UsageLimits(request_limit=50) means a long tool
loop routinely raises UsageLimitExceeded - which escaped the turn as a
JSON-RPC internal_error, the exact condition ACP defines the
max_turn_requests stop reason for (token limits map to max_tokens). The
raising run's partial messages are not retrievable, so such a turn rolls
back like a cancellation and says so (no user_message_id, no usage).
Turns failing with any other exception now also close out their
announced tool calls before the error reaches the client, matching the
cancellation path, so the client does not render them as running next to
the turn's failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(acp): resolve workspace-relative paths before they reach fs/read|write

ACP requires absolute paths on fs/read_text_file and fs/write_text_file,
but models routinely emit workspace-relative ones - the local FileSystem
tools document relative paths and share these tools' names, and the
presenter layer already absolutizes for exactly that reason. The
client-backed toolset forwarded them raw, producing non-conformant
requests the client may reject. acp_filesystem now hands the toolset the
session cwd so relative paths resolve before the wire; absolute paths
and directly-constructed cwd-less toolsets are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(acp): true up the conformance claims, comments, and doc links

The stop-reason note predated the finish-reason mapping and said the
opposite of what the code (and its tests) do; the prompt-capabilities
bullet read as inbound enforcement when the spec puts that restriction
on the client; the stdio MCP gap (the spec's unconditional MUST) was
undisclosed; and two code comments claimed validation/rejection the SDK
router does not actually perform - the adapter's own raises are the
load-bearing behavior. Doc links now use the canonical pydantic.dev
paths both old URLs redirect to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(acp): shield the terminal create in place instead of via a side task

The ensure_future + asyncio.shield shape from the previous commit hit
Python 3.12+'s shield behavior: once the outer await is cancelled, a
late failure of the inner create is reported to the loop exception
handler even when the cleanup retrieves it, which spams production logs
and fails under pytest-anyio. An anyio shield around the create await
itself is simpler and leans on the same anyio-mediated cancellation the
kill/release cleanup already depends on: the cancellation defers to the
next await, by which point the terminal id is known and the existing
cleanup kills and releases as usual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(acp): small type and surface cleanups from review

- ToolCallPermission.args was typed object although the only
  construction site passes args_as_dict(); Mapping[str, object] lets a
  custom policy scope by an argument without isinstance gymnastics.
- default_permission_scope is referenced by the permission_policy docs
  as the fallback to compose with, so it must be importable: exported.
- McpServer names the per-server union once instead of spelling it in
  two places; McpServers gains the TypeAlias marker its sibling had.
- _model_state folds the None case so both call sites drop a repeated
  conditional; the load_session store guard's no-cover pragma was wrong
  (the SDK router routes session/load regardless of the advertisement,
  and test_persistence already executes the branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(acp): pin claimed behavior the suite asserted too weakly or not at all

- The unsupported-method test asserted only RequestError despite name and
  CONFORMANCE.md promising method_not_found; codes now pinned (-32601,
  and -32602 for the unknown-session prompt).
- The model-override test was tautological: both models answered
  successfully either way. It now asserts the override's distinct canned
  output (mutation-checked: dropping the per-run override fails it).
- Usage summation across approval passes had no test; an approval turn's
  output tokens must exceed the resume pass alone (mutation-checked
  against 'usage = result.usage()').
- New protocol-contract coverage: cancel racing an unanswered permission
  dialog (turn ends cancelled, pending call driven to failed), double
  cancel idempotency, image block arriving in model history as decoded
  BinaryContent, the denial message reaching the failed update's
  raw_output, chunk_text's exact-budget boundary, and a set_model save
  failure being swallowed like every other persist failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(acp): survive raw cancellation during terminal create; answer cancelled after a post-commit cancel

An adversarial re-review of this branch's own fixes caught a regression:
the anyio shield around terminal/create only blocks anyio-mediated
cancellation, but the adapter and pydantic-ai deliver raw task.cancel()
(turn.cancel, cancel_and_drain), which pierces it - reintroducing the
leaked-terminal window end to end. The create now runs as its own task
awaited via asyncio.wait: a raw cancel hits the waiter, not the create,
and unlike asyncio.shield (3.12+) a late create failure is not reported
to the loop exception handler when the cleanup retrieves it. The leak
scenario is pinned at both the toolset level and through the real
adapter/agent path.

Also from the re-review: a cancel that lands after the turn committed
(inside the store save) now answers 'cancelled' as the spec requires -
while keeping the committed signals (user_message_id, usage, history) -
and the one-tick teardown-vs-turn ambiguity that 3.10 cannot resolve is
documented at both await sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(acp): record the post-commit-cancel semantics in CONFORMANCE

Also drops an unbounded poll loop in the permission-dialog race test for
a single deterministic tick (prompt sets active_turn before its first
suspension point), which was the one partial branch left repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(acp): let the turn build its own response and drop the commit-flag tuple

_run_turn returned (usage, stop_reason, committed) so prompt() could
reassemble a PromptResponse it had all the pieces for; building the
response in the turn removes that protocol and the committed flag.
Recording turn.updates is now unconditional (only the commit gate
matters), and the presenter-fields/acp_filesystem branches collapse to
single construction paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(acp): appease codespell (unparseable -> unparsable)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Support Pydantic AI 2.x in ACP

* feat(acp): forward usage limits into agent runs

Let hosts bound each ACP run segment with Pydantic AI usage ceilings while preserving the default run behavior when no explicit limits are configured.

* feat(acp): resolve selected session models

Let embedders map advertised ACP model ids to concrete Pydantic AI models and re-surface a resident session's current model state.

* feat(acp): expose read-only session history

Hosts can gauge context with the same estimate_token_count path the compaction tiers use by reading committed resident-session history.

The returned list is a shallow snapshot, and callers must treat the shared ModelMessage objects as read-only.

* Track ACP 0.11 session config models

* chore(acp): fix coverage, trim redundancy, clarify module names

Get the ACP PR to green CI and simpler to review, without changing behavior.

- Coverage: the only uncovered line was a dead `return None` branch in the
  `_model_option` test helper (source was already 100%). Assert the option is
  present instead, dropping the untested branch and its now-redundant guards.
- Remove CONFORMANCE.md: its supported/not-supported matrix duplicated the
  package README's feature sections and "Cancellation and limitations". Point
  the two references at the README.
- Dedup the test ACP clients: RecordingClient, FakeClient, and WireClient each
  re-spelled the same ~13-method "unused capability" stub block to satisfy the
  SDK Client interface. Extract it once into RecordingClientBase; each client now
  subclasses it and overrides only what it exercises. Interface drift is one edit.
- Rename two opaque modules for clarity (files only; public symbols unchanged):
  _present.py -> _presentation.py (matches its ToolCallPresentation exports) and
  _native.py -> _client_toolsets.py (client-routed filesystem/shell toolsets),
  with tests/acp/test_native.py -> test_client_toolsets.py to mirror the source.

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

* refactor(acp): move under experimental and shrink the public surface

New capabilities start under pydantic_ai_harness.experimental, so ACP
follows: importing warns HarnessExperimentalWarning and the API may
change without deprecation. __all__ drops the typing-only aliases
(25 to 18 names); default_permission_scope and McpServer stay because
policies compose with the default scope and AcpSession.mcp_servers is
typed by McpServer. Tests move to tests/experimental/acp to mirror the
source tree; the experimental warning test lives in the SDK-gated
test_acp.py so slim (no-extras) installs stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(acp): expose model_resolver and usage_limits on the stdio entry points

PydanticAIACPAgent already accepted both, so wanting a per-run token
ceiling or host-defined model ids forced users off run_acp_stdio onto
the class and a hand-rolled acp.run_agent call, where forgetting
use_unstable_protocol=True silently loses session/close. Forward the
two params so the one-liner covers every adapter option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: David SF <david.sanchez@pydantic.dev>
2026-07-07 23:36:09 +05:30

81 lines
2.9 KiB
Python

"""Filesystem capability that provides sandboxed file system access."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.tools import AgentDepsT
from pydantic_ai_harness.filesystem._toolset import FileSystemToolset
_DEFAULT_PROTECTED: list[str] = [
'.git/*',
'.env',
'.env.*',
'*.pem',
'*.key',
'**/secrets*',
]
@dataclass
class FileSystem(AbstractCapability[AgentDepsT]):
"""File system access scoped to a root directory.
All paths are resolved relative to `root_dir`. Traversal above the root
is rejected. Symlinks are resolved before authorization.
"""
root_dir: str | Path = '.'
"""Root directory for all file operations. Defaults to the current directory."""
allowed_patterns: Sequence[str] = field(default_factory=list[str])
"""If non-empty, only paths matching at least one glob pattern are accessible."""
denied_patterns: Sequence[str] = field(default_factory=list[str])
"""Paths matching any of these glob patterns are rejected."""
protected_patterns: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_PROTECTED))
"""Paths matching these patterns are read-only (writes are rejected).
Defaults to protecting `.git/`, `.env`, key files, and secrets.
Set to an empty list to disable protection.
"""
max_read_lines: int = 2000
"""Maximum number of lines returned by a single `read_file` call."""
max_search_results: int = 1000
"""Maximum number of matches returned by `search_files`."""
max_find_results: int = 1000
"""Maximum number of matches returned by `find_files`."""
def __post_init__(self) -> None:
# Runtime validation: dataclass field annotations are advisory, not enforced.
# A config-driven caller could pass a string that would otherwise propagate.
values: dict[str, Any] = {
'max_read_lines': self.max_read_lines,
'max_search_results': self.max_search_results,
'max_find_results': self.max_find_results,
}
for name, value in values.items():
if not isinstance(value, int) or value <= 0:
raise ValueError(f'{name} must be a positive integer, got {value!r}')
def get_toolset(self) -> FileSystemToolset[AgentDepsT]:
"""Build and return the filesystem toolset."""
return FileSystemToolset[AgentDepsT](
root_dir=Path(self.root_dir),
allowed_patterns=self.allowed_patterns,
denied_patterns=self.denied_patterns,
protected_patterns=self.protected_patterns,
max_read_lines=self.max_read_lines,
max_search_results=self.max_search_results,
max_find_results=self.max_find_results,
)