Repo Context
RepoContext discovers and loads a repo’s accumulated coding-assistant context engineering (CE): the instruction files (CLAUDE.md/AGENTS.md) scattered across the tree and the assets under .claude/.agents/.codex/.grok (skills, sub-agents, hooks).
While Pydantic AI Harness is on 0.x releases, the API may change between minor releases; when it does, deprecation warnings and release-note migration guidance tell you (or your agent) exactly how to upgrade. See the version policy.
A repo accumulates CE for whatever coding assistant worked in it: instruction files (CLAUDE.md/AGENTS.md) scattered across the tree, and assets under .claude/.agents/.codex/.grok (skills, sub-agents, hooks). An agent that loads only the top-level instruction file misses the ancestor context and has no idea the rest of the setup exists, so it cannot honor all of it.
RepoContext bundles three strategies, each independently toggleable. Construct it with RepoContext(...) in an Agent’s capabilities, with a workspace attached to the run:
from pathlib import Path
from pydantic_ai import Agent
from pydantic_ai.capabilities import LocalWorkspace
from pydantic_ai_harness import RepoContext
agent = Agent(
'anthropic:claude-opus-5-5',
capabilities=[LocalWorkspace('.'), RepoContext(home_dir=Path.home())],
)
result = agent.run_sync('Summarize the coding-assistant setup in this repo.')
print(result.output)
RepoContext reads from the run’s
workspace, starting at its
working directory. To point it at a subdirectory, set it on the workspace:
LocalWorkspace('./repo'). home_dir, asset_roots, and autoload_symlink_roots
are paths in the workspace: relative ones resolve from the working directory,
and ~ is not expanded. A run without a workspace fails at its start.
Loads CLAUDE.md/AGENTS.md from the working directory and every ancestor up to
home_dir (inclusive). Precedence is ancestor-first, workspace-last: broadest
context first, most specific last. Files are deduped by the path a symlink resolves
to and by content hash, so a symlinked CLAUDE.md -> AGENTS.md or two ancestors
sharing identical content load once. Where the workspace can resolve symlinks, a
same-directory symlink is labeled with its target’s name, so CLAUDE.md -> AGENTS.md
reaches the model as AGENTS.md. A file in an ancestor is labeled relative to the
working directory, such as ../AGENTS.md, not by its absolute path.
A same-directory link loads only when it points to another instruction file,
one named in filenames or AGENTS.md/CLAUDE.md: CLAUDE.md -> AGENTS.md
works, while a link to any other file, such as AGENTS.md -> CONTRIBUTING.md or
AGENTS.md -> .env, is skipped, so a repository can’t use one to hand the model
a file the file tools may deny. Make such a file a regular file, or rename its
target, to load it.
When home_dir is None (the default), only the working directory is scanned —
no walk-up. Pass the workspace home path explicitly to walk up to it. For a remote
sandbox, use its home (for example home_dir='/home/user' on E2B), not the agent
host’s Path.home(). If home_dir is not an ancestor of the working directory,
only the working directory is scanned.
If several repositories symlink their instruction files into a shared directory,
authorize that directory with
RepoContext(autoload_symlink_roots=('/opt/agent-context',)). This keyword-only
option defaults to empty, which skips links outside the directory being scanned
when the backend resolves symlinks. Each resolved target must be inside a resolved
authorized root when a nonempty allowlist is used. The option only
affects autoload: it does not scan the shared directory, extend the walk-up, or
change inventory or nested traversal. The workspace backend needs native realpath
support (SupportsRealpath) or command execution (SupportsCommands) to resolve
links; a nonempty autoload_symlink_roots raises UserError without either.
With neither protocol, Workspace.realpath only normalizes path strings, so
scans with the empty default cannot enforce symlink containment. Use symlink-free
storage or a backend that resolves symlinks.
An external link keeps its scanned entry’s label, such as ../AGENTS.md, because
that directory defines where the instructions apply. Same-directory links keep
their target-name labels (CLAUDE.md -> AGENTS.md is labeled AGENTS.md). Files
are still deduplicated by resolved target and content hash, and broken links and
directories are skipped.
Set expose_inventory_tool=True to expose inventory_agent_context(), which reports where the repo’s coding-assistant assets live — the .claude/.agents/.codex/.grok roots and, within each, the skills/ (SKILL.md), agents/ (.md), and settings.json (hooks) it contains. It returns a structured AgentContextInventory; it locates assets and does not parse them.
Rename the tool with inventory_tool_name, or scope which roots it scans with asset_roots.
Skill discovery goes at most eight directories deep.
With FileSystem on the agent, the inventory tool steps aside for FileSystem’s read_file, as long as every asset root is inside the working directory (the default roots are). The tool is marked with unless_tool_kind set to READ_FILE_KIND, so it is left out of every run step that has a tool of that kind. The instructions name the asset roots and say to inspect them with file tools, or with the inventory tool when it’s offered. They name no reader, so they’re the same with or without FileSystem and stay static. The decision is made from asset_roots, not from FileSystem’s access rules: denied_patterns that cover the roots, or allowed_patterns that leave them out, still drop the inventory tool.
When the model lists a directory or reads a file in it, surface the
CLAUDE.md/AGENTS.md of that directory and of every directory between it and
the working directory, shallowest first: reading packages/foo/src/x.py also
surfaces packages/foo/AGENTS.md. This strategy subscribes to FileReadEvent and
DirectoryListedEvent, so it receives normalized, containment-checked paths
instead of inspecting raw tool arguments. It remains opt-in:
from pydantic_ai import Agent
from pydantic_ai.capabilities import LocalWorkspace
from pydantic_ai_harness import FileSystem, RepoContext
agent = Agent(
'anthropic:claude-opus-5-5',
capabilities=[
LocalWorkspace('.'),
FileSystem(),
RepoContext(
nested_traversal=True,
nested_inject='pointer', # or 'contents'
),
],
)
result = agent.run_sync('List the source directory.')
nested_inject='pointer' (default) enqueues a one-line note pointing at the
file; 'contents' enqueues the file body. The note reaches message history
before the next model request. A directory counts as surfaced while its note
is still in the conversation: a later run of the same conversation doesn’t
repeat it, and once compaction summarizes the note away, the next traversal
surfaces it again. The working directory’s files are not surfaced again when
the walk-up already loaded them. Every part of a note carries
{'origin': 'pydantic-ai-harness.repo_context.nested_note.v1', 'directory': ...}
in its metadata, which is how RepoContext and compaction recognize it.
Same-directory instruction symlinks are named after their targets, and load
only when the target is itself an instruction file, as in the walk-up. Where the
backend resolves symlinks, external instruction symlinks are skipped, even when
autoload_symlink_roots authorizes their targets for autoload.
With nested traversal on, the instructions also tell the model how instruction files relate: each applies to its own directory and everything below it, the more deeply nested file wins a conflict, and the user’s direct instructions win over all of them. The statement is static, so it stays in the cached prefix.
FileSystem emits these events directly. Hosts with other file tools can emit
the same types by importing FileReadEvent and DirectoryListedEvent from
pydantic_ai_harness.filesystem; set root_dir to the directory the event’s
path is relative to.
The traversed location is root_dir / path, so a FileSystem rooted at a
subdirectory of the working directory still surfaces the right directory. A
traversal that resolves outside the working directory is ignored: it is not nested in
the workspace, so there is no nested context to surface. Each directory in the
chain must itself resolve inside the working directory, so a symlinked directory
in the path can’t surface an instruction file from outside it.
traversal_tool_names and traversal_path_arg are deprecated. Setting either
to a non-default value emits HarnessDeprecationWarning and keeps the old
tool-name and argument sniffing path active for hosts that do not emit events.
With the defaults, sniffing is disabled, so a FileSystem event cannot deliver
the same note twice.
Walk-up files become part of the agent’s instructions, so the model gives them the same weight as the instructions you write yourself. That is what the strategy is for, and it means the working directory and home_dir should hold only instruction files you would put in your own instructions.
Authorize a directory containing only trusted instruction files, such as
/opt/agent-context, rather than / or your entire home directory. A repository’s
instruction symlinks can select any readable file beneath an authorized root,
not just the file you originally linked.
Nested files are different: the model chooses which directories to read, and those can include vendored code or a checkout someone else wrote. Their contents and paths reach the model as a user message, not as instructions. The 'pointer' note’s own wording is the framework’s, so it is a SystemPromptPart (a system message where the model supports one mid-conversation, and <system>-tagged user text otherwise), followed by the file’s path as a user-message <context-file path="..." /> tag.
Each file is wrapped in <context-file path="..."> tags. The < of every opening or closing <context-file> tag inside a file, and of every framing tag Pydantic AI writes (<system>, <context>, <tool_result> and the rest), is escaped as <, and the path is HTML-escaped, so a file stays inside its own block.
Injecting file contents into the system prompt costs prompt-cache stability: a changed prefix re-bills the whole cached region. RepoContext keeps the two cache-relevant paths separate:
- Strategy 1 reads its files once at run start and injects them as system instructions, so the cached prefix stays byte-identical across the turns of a run. If a file changes between runs of the same conversation, the change is appended, as a diff when that’s shorter, instead of rewriting the prefix.
- Strategy 3 is volatile (it depends on which directory was just touched), so its note is enqueued in the message tail, never in the system prompt, and cannot invalidate the cached prefix.
RepoContext(
home_dir=None, # str | Path | None -- shallowest workspace dir to stop walk-up at, inclusive
filenames=('CLAUDE.md', 'AGENTS.md'),
autoload_instructions=True, # Strategy 1
autoload_symlink_roots=(), # authorized external target roots for Strategy 1 only
expose_inventory_tool=False, # Strategy 2
inventory_tool_name='inventory_agent_context',
nested_traversal=False, # Strategy 3
nested_inject='pointer', # 'pointer' | 'contents'
traversal_tool_names=frozenset({'list_directory', 'read_file'}), # deprecated fallback
traversal_path_arg='path', # deprecated fallback
asset_roots=('.claude', '.agents', '.codex', '.grok'),
)
workspace_dir= is deprecated and ignored: the working directory comes from the workspace.
RepoContext locates and loads CE; it does not parse skill/sub-agent frontmatter or hook bodies, and it does not modify assets. Strategy 1 reads its files once per run, so mid-run edits to those files are not reloaded.
Bases: AbstractCapability[AgentDepsT]
Discover and load a repo’s accumulated coding-assistant context engineering.
Everything is read through the run’s workspace, anchored at its working directory; a run without a workspace fails at its start.
Three strategies, each independently toggleable:
-
Walk-up instruction autoload (
autoload_instructions, on by default): loadCLAUDE.md/AGENTS.mdfrom the working directory and every ancestor up tohome_dir, deduped, ancestor-first. These are read once at run start and injected as system instructions viaget_instructions, so they stay in the cached prefix, stable within a run; a change between runs of a conversation is appended rather than rewritten. -
Asset inventory (
expose_inventory_tool, off by default): a tool that reports where the repo’s coding-assistant assets live (.claude/.agents/.codex/.grokand theirskills/,agents/,settings.json). It locates assets; it does not parse them. It steps aside forFileSystem’sread_filewhen the asset roots are inside the working directory, and the instructions then point at the roots instead. -
Nested-on-traversal (
nested_traversal, off by default): when the model lists a directory or reads a file through a filesystem capability event, surface theCLAUDE.md/AGENTS.mdof that directory and of every directory between it and the working directory, shallowest first. The working directory’s own files are not repeated when Strategy 1 loaded them. The note is enqueued in the message tail, not added to system instructions, so it does not invalidate the cached prefix.nested_inject='pointer'(default) enqueues a one-line pointer;'contents'inlines the file body. With it on, the instructions also gain one static sentence on how instruction files relate: each applies to its subtree, the deeper one wins, and the user’s direct instructions win over all of them.
from pathlib import Path
from pydantic_ai import Agent
from pydantic_ai.capabilities import LocalWorkspace
from pydantic_ai_harness.repo_context import RepoContext
agent = Agent(
'anthropic:claude-sonnet-4-6',
capabilities=[LocalWorkspace('.'), RepoContext(home_dir=Path.home())],
)
Deprecated and ignored: the walk-up and asset scan are anchored at the workspace’s working directory.
Set the working directory on the workspace instead, e.g. LocalWorkspace('./repo').
Type: Path | None Default: None
The shallowest workspace directory to stop the walk-up at, inclusive, as a workspace path.
A relative path resolves against the working directory. None (the default)
scans only the working directory — no walk-up.
Type: str | Path | None Default: None
Instruction filenames to look for, in within-directory precedence order.
Type: Sequence[str] Default: ('CLAUDE.md', 'AGENTS.md')
Strategy 1: load instruction files into the system prompt.
Type: bool Default: True
Authorize external autoload targets beneath these workspace paths, empty by default.
Roots and targets are resolved through the workspace. Requires a backend implementing
SupportsRealpath or command execution for workspace symlink resolution. External files
retain their scanned entry’s label. Nested traversal and inventory are unaffected.
Type: Sequence[str | Path] Default: field(default=(), kw_only=True)
Strategy 2: expose the asset-inventory tool.
Type: bool Default: False
Name of the inventory tool exposed to the model.
Type: str Default: 'inventory_agent_context'
Strategy 3: surface the instruction files between the working directory and a directory the model lists or reads a file in. Off by default — it couples to the list/read tools. With it on, the instructions also state how instruction files relate.
Type: bool Default: False
For Strategy 3: append a one-line pointer, or inline the file contents.
Type: Literal[‘pointer’, ‘contents’] Default: 'pointer'
Deprecated tool names used by the compatibility traversal detector.
Type: frozenset[str] Default: _DEFAULT_TRAVERSAL_TOOL_NAMES
Deprecated path argument used by the compatibility traversal detector.
Type: str Default: _DEFAULT_TRAVERSAL_PATH_ARG
Root directories the inventory tool scans, relative to the working directory.
Type: Sequence[str] Default: ('.claude', '.agents', '.codex', '.grok')
@async
def for_run(ctx: RunContext[AgentDepsT]) -> RepoContext[AgentDepsT]
Return a fresh per-run instance with isolated traversal/cache state.
The copy skips __post_init__, so deprecated arguments warned about at construction are not warned again.
RepoContext[AgentDepsT]
@async
def before_run(ctx: RunContext[AgentDepsT]) -> None
Fail without a workspace, and load walk-up instruction files so get_instructions is sync.
def get_instructions() -> str | Callable[[RunContext[AgentDepsT]], str | None] | None
Cache-stable instructions resolved after before_run loads workspace files.
str | Callable[[RunContext[AgentDepsT]], str | None] | None
def get_toolset() -> AgentToolset[AgentDepsT] | None
The asset-inventory toolset, or None when the tool is disabled.
AgentToolset[AgentDepsT] | None
@async
def after_tool_execute(
ctx: RunContext[AgentDepsT],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
result: Any,
) -> Any
Support customized legacy traversal tool and argument names.
@classmethod
def get_serialization_name(cls) -> str | None
Serialization name for agent-spec support.
Bases: BaseModel
A map of where a repo’s CE assets live, for an orchestrator to inspect.
Bases: BaseModel
Where CE assets live under a single root directory (e.g. .claude).