Add StepPersistence capability for step-event durability across delegates (#251)

* Add StepPersistence capability for step-event durability across delegates

Supersedes PR #176 (SessionPersistence): orchestrators like pydanty need
visible event trails for delegate runs that may time out before a
"save full session after the run" hook can fire, and need to continue or
fork a delegate's prior investigation without rediscovering context. A
single after-run snapshot is too coarse for that use case.

The capability now records (a) append-only StepEvents at every boundary
(run/model-request/tool-call start, completion, failure), (b) a
ContinuableSnapshot only when message history is provider-valid (every
ToolCallPart has a matching ToolReturnPart / RetryPromptPart) — saved
mid-run after CallToolsNode and at after_run, and (c) a ToolEffectRecord
ledger so a run killed between before_tool_execute and
after_tool_execute leaves an `unknown_after_crash`-style record rather
than a falsely-continuable snapshot. Lineage metadata
(parent_run_id, agent_name) ties delegate runs back to their
orchestrator. `continue_run` / `fork_run` helpers load the latest
continuable snapshot for a run.

Backends: InMemoryStepStore (tests) and FileStepStore (JSONL events +
JSON snapshots, with run_id path-traversal validation and
anyio.to_thread for blocking I/O).

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

* Address pydanty review + ergonomics: tighten correctness and identity model

Correctness fixes from pydanty's PR review:

- FileStepStore: snapshot filenames are now a per-run monotonic counter,
  not `ctx.run_step` — `run_step` resets each Agent.run, so re-using a
  `run_id` across calls would let an earlier run's higher step-index
  snapshot mask a later run's lower-step-index one.
- StepStore.get_tool_effect now takes both `run_id` and `tool_call_id`.
  TestModel and other providers can reuse deterministic tool-call ids
  across runs; the previous unscoped lookup let one run's effect leak
  into another's record (including `started_at`).
- is_provider_valid now rejects orphan, duplicate, and out-of-order
  tool returns — the old `set.discard` pattern silently accepted any
  return regardless of whether a matching call was open.

Identity model:

- `run_id` resolution: explicit > `{agent_name}-{8-char-hex}` > UUID.
  Materialised per Agent.run in `for_run`, so reusing one capability
  instance never silently merges runs.
- `parent_run_id` auto-inferred via a module-level ContextVar set in
  `wrap_run`, so an orchestrator's tool that synchronously calls
  `delegate.run(...)` produces a delegate `RunRecord.parent_run_id`
  pointing at the orchestrator's `run_id` with zero threading. Explicit
  `parent_run_id=` still wins.
- `conversation_id` propagated to `StepEvent` and `RunRecord`;
  `store.list_runs(conversation_id=..., parent_run_id=...)` supports
  filtering by either or both. Mirrors pydantic_ai's three-level
  identity (conversation -> run -> step) so "run 1, run 2, run 3" of
  one dialogue is queryable as a group via `conversation_id`.
- `continue_from=` field dropped from the capability. Continuation is
  now only via `continue_run(store, run_id=...)` -> standard
  `Agent.run(message_history=...)`. One way to pass history into
  pydantic_ai, no parallel capability flag.

README rewritten around the final API. New sections: three-level
identity, run lineage with auto-inferred parent, inspecting a run tree,
failure recovery.

Tests: 168 total (up from 64), 100% branch coverage on the package.
New coverage for the snapshot seq counter, cross-run tool-effect
isolation, orphan/duplicate/out-of-order return rejection, ContextVar
parent inference across nested agent.run, conversation_id propagation,
and the agent_name-derived run_id default.

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

* Address pydanty round-2 review: retry validity, list ordering, effect metadata

Correctness:

- is_provider_valid no longer rejects non-tool RetryPromptParts. Pydantic
  AI emits `RetryPromptPart(tool_name=None)` for output-validation
  failures and providers map those as plain user messages, not tool
  results. The previous check required every RetryPromptPart to resolve
  an open tool call, so a run with one output retry produced no final
  continuable snapshot despite being fully valid.
- StepStore.list_runs now guarantees chronological (started_at ascending)
  ordering across both backends. FileStepStore was previously returning
  directory-name order (lexicographic), so the README's `[-1]` pattern
  for "latest run in conversation" could pick the older run when run ids
  did not sort by recency.
- after_tool_execute and on_tool_execute_error preserve idempotency_key
  and effect_summary from the prior `started` record. Previously the
  terminal record was written without those fields, so any annotation
  the tool body wrote was lost on completion.
- from_spec raises ValueError for unknown backends instead of silently
  falling back to in-memory storage. For a persistence capability,
  turning a typo into accidental non-durability is the wrong failure
  mode.

API:

- New annotate_tool_effect(store, ctx, *, idempotency_key=None,
  effect_summary=None) helper. Tool bodies that write external state
  call it to attach idempotency + effect metadata to the in-flight
  ToolEffectRecord without knowing the (run_id, tool_call_id) plumbing.
  Resolves run_id from a ContextVar set by wrap_run; reads tool_call_id
  / tool_name from RunContext.
- ContextVar moved from `_capability.py` into a new `_context.py` module
  so the helper and the capability can share it without circular imports
  and without crossing the private-name barrier.

Docs: README fixes a non-existent `list_runs(agent_name=None)` call,
documents the chronological-ordering guarantee, and replaces the
hand-wavy "populate fields on the ToolEffectRecord" line with a concrete
`annotate_tool_effect` example.

Tests: 178 total (was 168), 100% branch coverage on the package.
Added coverage for non-tool retry acceptance, chronological list_runs on
both backends, metadata preservation across completed/failed
transitions, annotate_tool_effect under realistic agent.tool, and
from_spec backend validation.

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

* docs(step_persistence): align FileStepStore docstring with seq-counter layout

The class docstring still showed snapshots/<step_index>.json from the
pre-fix layout, but both the README and _next_snapshot_seq document the
monotonic counter. Bring the class docstring in line.

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

* docs(step_persistence): fix broken README examples + clarify run_id sharing

Pydanty round-3 review:

- README continuation and lineage examples queried `list_runs(conversation_id=...)`
  on conversations the earlier `.run(...)` calls never set, so the
  examples crashed with IndexError on `[-1]`. Pass the conversation_id to
  the earlier calls so the lookup actually works.
- The capability docstring claimed reusing a `StepPersistence` instance
  across `Agent.run` calls does NOT share the id. That is true only for
  the auto-derived (`agent_name`-prefixed or `ctx.run_id`) cases — an
  explicit `run_id=` is shared across every `.run()` by design, since
  that is the orchestrator pattern where the caller owns one logical
  identity across turns. Rewrite the resolution-order docs to spell out
  which cases share and which don't, and when to pick each.

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

* docs(step_persistence): align run_id semantics with pydantic_ai (per-call, not shared)

Pydanty round-4 review: the prior round documented explicit `run_id` as
shared across `.run()` calls on one capability instance — that framing
caused real correctness gaps. The `ToolEffectRecord` ledger is keyed by
`(run_id, tool_call_id)` and providers reuse deterministic tool-call ids
(e.g. `TestModel` emits `pyd_ai_tool_call_id__{name}`), so a second
`.run()` overwrites the first's effect record under the same key — the
`unknown_after_crash` signal from turn 1 disappears when turn 2 lands.

Realign:

- `run_id` is per-`Agent.run`, matching `pydantic_ai.RunContext.run_id`.
- For multi-turn logical grouping, use `conversation_id=` on
  `Agent.run(...)` — that is the pyai-native primitive. The orchestrator
  pattern is `conversation_id='orch'` with each turn auto-deriving its
  own `run_id`.
- Explicit `run_id=` remains supported but is documented as single-shot
  (testing, replay, debugging). Reusing it across calls is a caller
  contract violation, not an implementation feature.

Code is unchanged — the implementation was already correct under the
right contract. Only the docs were misleading.

Tests:
- `TestRunIdIsPerCall::test_multi_turn_orchestrator_uses_conversation_id`
  exercises the recommended pattern: three turns sharing a
  `conversation_id`, three distinct auto-derived `run_id`s, all
  queryable as a group.
- `TestRunIdIsPerCall::test_explicit_run_id_reuse_collides_ledger` locks
  down the misuse contract: reusing one explicit `run_id` across two
  `.run()` calls produces colliding effect records under the
  `(run_id, tool_call_id)` key. The behavior is documented; the test
  exists so a future refactor cannot silently change it.

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

* docs(step_persistence): explain why for_run uses a local ContextVar

Pyai-aligned review flagged this as a P3 explainer: pydantic_ai already
has three single-slot cross-run signals (RUN_ID_BAGGAGE_KEY, ctx.run_id,
_CURRENT_RUN_CONTEXT). All three get overwritten by the inner
Instrumentation.wrap_run before any nested capability can see the parent
identity. A separate harness-local ContextVar, snapshotted before our
own wrap_run rebinds it, is the only correct mechanism today. Spell
this out so the next reader doesn't try to 'simplify' it.

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

* feat(step_persistence): enforce explicit-run_id reuse with ValueError

Pydanty round-5 review accepted the docs-only contract but flagged that
"documented but not enforced" is a soft spot. Enforce it: `before_run`
calls `store.get_run(run_id=...)` when the user supplied an explicit
`run_id`, and raises `ValueError` if a record with that id already
exists. The auto-derived cases cannot trigger this check (each call
materialises a fresh id in `for_run`).

The check is one extra store read per Agent.run when an explicit run_id
is set, only. The error message points the caller at `conversation_id`
for multi-turn grouping.

Test renamed from `test_explicit_run_id_reuse_collides_ledger` to
`test_explicit_run_id_reuse_raises` — asserts the second `.run()` raises
and the first run's records survive untouched.

README + capability docstring updated: the misuse path is now "raises"
not "caller's contract."

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

* chore: gitignore branch-context skill artifacts + AGENTS.local.md

Two patterns that match the existing CLAUDE.local.md ignore convention:

- AGENTS.local.md — canonical local-instructions file (CLAUDE.local.md
  is symlinked to it where the worktree follows the same
  AGENTS.md/CLAUDE.md symlink pattern).
- .agents/skills/branch-context/ — per-worktree decisions log
  (`pr-decisions.md`) and the skill's local SKILL.md. Pattern lifted
  from `~/pydantic/ai/base/.claude/skills/branch-context/` where pyai
  uses an identical setup.

Neither is intended to land in PRs — they record cross-iteration design
calls so future AICA sessions in this worktree don't silently undo them.

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

* feat(step_persistence): add SqliteStepStore + MediaStore externalization

Adds a new `pydantic_ai_harness.media` package (MediaStore protocol +
DiskMediaStore / SqliteMediaStore / S3MediaStore) and wires it into the
file/sqlite step-persistence backends so large BinaryContent payloads
get externalized out of snapshot JSON / table rows by default.

Defaults are zero-config: FileStepStore writes blobs under
`<root>/media/<sha256>.bin`; SqliteStepStore writes them to a sibling
`media` table in the same DB. Threshold is 64 KiB and URI scheme is
`media+sha256://<hex>` so blobs are content-addressed across stores.
Pass `media_store=None` to keep bytes inline, or a custom `MediaStore`
to redirect (e.g. `S3MediaStore` for R2 / AWS / MinIO).

S3MediaStore handrolls SigV4 over httpx to avoid a botocore/boto3
dependency. Verified working against Cloudflare R2.

`StepPersistence.from_spec(backend='sqlite', database=...)` now resolves.

180 → 261 tests, 100% branch coverage maintained.

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

* test(media): VCR cassettes for S3MediaStore against R2

Adds replay-driven tests under `tests/media/test_s3_cassettes.py` that
exercise `S3MediaStore.put/get/exists` against pre-recorded Cloudflare
R2 responses. CI runs them without any S3 creds via the committed
cassettes under `tests/media/cassettes/`.

Sanitisation policy:

- `before_record_request`/`before_record_response` swap the real R2
  account-id subdomain and bucket name for fixed placeholders
  (`account.r2.cloudflarestorage.com`, `harness-test-bucket`)
- `Authorization` and `x-amz-date` filtered to `REDACTED`
- CF-RAY, x-amz-version-id, x-amz-checksum-*, x-amz-request-id headers
  dropped (none load-bearing for tests; some carry identifying info)
- Non-2xx response bodies blanked (R2's gzipped XML error envelope
  leaks the bucket name; our code only checks status code)

The `s3_credentials` fixture uses `os.environ.get(NAME, PLACEHOLDER)`
per field, so real R2 creds are used when recording locally with `.env`
loaded, and the placeholder constants match the scrubbed cassettes
during replay. Because the placeholders are fixed, any scrubber miss
during a future re-record shows up as a replay URL mismatch — built-in
canary against credential / private-data leakage in committed cassettes.

Adds `pytest-recording` (pulls `vcrpy`) to the dev deps.

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

* feat(media): public_url resolver — protocol method + per-store callable

Adds `MediaStore.public_url(uri) -> str | None` plus a `public_url=`
constructor parameter on every concrete store. The parameter accepts a
sync or async callable; the store auto-detects and awaits if needed.

This is the bottom-layer primitive for the forthcoming `MediaExternalizer`
capability — that capability will call `store.public_url(...)` per
externalized blob and swap `BinaryContent` for `ImageUrl` / `AudioUrl`
parts before the model sees the message. The callable shape covers both
static URLs (public bucket / CDN — use `make_static_public_url`
helper) and dynamic URLs (presigned, per-request signing — pass any
async callable with TTL captured in its closure).

Why a callable rather than a static config: a public bucket's URL host
is not derivable from the bucket creds (R2 public buckets use
`pub-<hash>.r2.dev`, AWS public buckets use a different scheme than the
path-style endpoint we sign for). The URL is always user-supplied
information, so a callable is the right primitive — same shape for the
static and presigned cases, and `get` stays untouched (it serves the
harness's internal byte fetch, not the model's external HTTP fetch).

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

* refactor(media): introduce MediaContext + key_strategy for extensible operations

Adds `MediaContext` (frozen, kw-only dataclass with `media_type`, `filename`,
`metadata`) and threads it through every `MediaStore` method and both user
callables (`PublicUrlResolver`, `KeyStrategy`). New context fields can be
added non-breakingly; existing call sites and resolvers keep working.

Also adds:
- `KeyStrategy = Callable[[str, MediaContext], str]` for per-store layout
  control. Default `default_key_strategy` produces `<sha256>.bin`. Disk store
  validates the result against `..` traversal.
- `metadata` persistence on `SqliteMediaStore` (new JSON column) and
  `S3MediaStore` (signed `x-amz-meta-*` headers, ASCII key validation).
  Disk store explicitly does NOT persist metadata in v1 — sidecar / xattr
  options each have load-bearing drawbacks; we ship nothing rather than a
  half-true persistence promise.
- `make_static_public_url(...)` updated to the new `(uri, ctx)` signature.

The shift is motivated by the same principle as pydantic_ai's
`RunContext`: extension via fields on a context bag rather than via
breaking signature changes. Every new requirement (TTL hints for
presigned URLs, audit ids, response-header overrides, etc.) becomes a
field addition, not an API revision.

Cassettes from the previous commit replay unchanged — match-on does not
include the signed headers and the request URLs are stable.

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

* feat(media): close metadata round-trip; drop vestigial sqlite key_strategy

Adds `MediaStore.get_metadata(uri) -> Mapping[str, str]` to the protocol
and implements it on all three concrete stores:

- `DiskMediaStore`: writes a sidecar `<resolved>.meta.json` alongside
  the blob on put (atomic tmp + rename), reads it back on
  `get_metadata`. Returns `{}` when no metadata was supplied. v1 had
  documented this as a deliberate gap — sidecar JSON is straightforward
  and the xattr / ADS drawbacks don't apply.
- `SqliteMediaStore`: `SELECT metadata FROM <table> WHERE sha256=?` +
  `json.loads`. Raises `FileNotFoundError` for unknown URIs.
- `S3MediaStore`: HEAD + collects `x-amz-meta-*` response headers,
  strips the prefix. Reuses the existing 404 / non-2xx error shape.

Drops `key_strategy=` from `SqliteMediaStore`. The digest is the primary
key by content-addressing construction — a user-chosen key would either
break dedup or be a no-op. Kept on Disk + S3 where bucket / directory
layout is a real concern.

README + branch-context entries updated to reflect: all three stores
round-trip metadata; key_strategy is Disk + S3 only.

Coverage stays at 100% branch.

* fix(media): reject absolute paths from DiskMediaStore key_strategy

`Path(root) / absolute_path` returns `absolute_path` — the root is
silently discarded — so a custom `key_strategy` returning `/etc/passwd`
(or similar) escapes the store directory even though the previous check
only blocked `..`. Tighten the validator to reject both shapes.

Caught by pydanty during its #251 integration review.

* fix(step_persistence): dedupe terminal snapshot; document sqlite thread-affinity

The terminal CallToolsNode already saves the final provider-valid snapshot
with the correct step_index. after_run was re-saving the same tail stamped
with step_index=0 (ctx.run_step is reset by then), so latest_snapshot
reported a misleading step and every run wrote a duplicate. Track whether a
node snapshot was taken via a task-local ContextVar and make after_run a
fallback that only fires when the run reached no provider-valid boundary.

Also document that a caller-owned sqlite connection= must set
check_same_thread=False (store SQL runs on anyio worker threads), on both
SqliteStepStore and SqliteMediaStore, and correct the WAL-on-every-connection
claim.

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

* fix(media): SigV4 wire path matches signed path; walker preserves unknown fields

S3MediaStore signed `_canonical_uri(path)` (each segment percent-encoded) but
sent the raw path, letting httpx apply looser encoding. A custom key_prefix /
key_strategy emitting reserved chars (`@`, `(`, `=`, ...) diverged from the
signed path -> SignatureDoesNotMatch. Send the canonical bytes via httpx
`raw_path` so signer and sender agree. Default `<hex>.bin` keys are unaffected.

The externalize/restore walker hardcoded the BinaryContent key set, silently
dropping any field pydantic_ai adds upstream. Copy the node and swap only
`data` <-> marker keys so unknown fields round-trip. Adds tests for reserved-char
path agreement, unknown-field preservation, and restore over a pruned blob.

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

* refactor: gate step_persistence + media behind experimental namespace

Move both packages under `pydantic_ai_harness.experimental`, matching the
convention introduced for compaction/planning/subagents (#191): the old
`pydantic_ai_harness.step_persistence` / `.media` paths and the top-level
re-exports are gone, so the only import path is now

    from pydantic_ai_harness.experimental.step_persistence import StepPersistence
    from pydantic_ai_harness.experimental.media import S3MediaStore

Both package __init__s call `warn_experimental(...)`, so importing either
emits a `HarnessExperimentalWarning` (silenced category-wide by one
filter). This keeps us from committing to a public surface before the
capability has real usage.

README gains the standard experimental banner; warning tests cover both
new packages. 100% branch coverage retained.

* docs(media): fix metadata/key_strategy drift, convert em-dashes

Subagent review of the experimental move surfaced doc drift:

- README "Persistence by store" implied `get_metadata` returns
  `media_type`; it returns only the user `metadata` mapping. Reworded.
- `KeyStrategy` docstring still listed `SqliteMediaStore` / "DB primary
  key" as a user; sqlite has no `key_strategy`. Dropped it.
- README understated `DiskMediaStore` traversal protection (it rejects
  absolute paths as well as `..`).
- README had a stale paragraph that read as `key_strategy` but described
  `public_url`, and omitted S3. Rewritten to name `public_url` and all
  three stores.
- README `MediaContext` method list now includes `get_metadata`.
- Converted em-dashes to `--` across the step_persistence + media trees
  per the writing-style rule (#270). Other experimental packages'
  pre-existing em-dashes are left for a separate sweep.

No code behavior change; 100% branch coverage retained.

* docs(experimental): add warning banner to planning/subagents, finish em-dash sweep

Uniformity pass across the experimental packages:

- Add the standard `[!WARNING]` experimental banner to planning/README.md
  and subagents/README.md (compaction and step_persistence already had
  it; these two lacked it).
- Convert remaining em-dashes to `--` in the compaction package and the
  shared `_warn.py`, per the writing-style rule (#270). The whole
  experimental source tree is now em-dash-free.

Docs only; 100% branch coverage retained.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: David SF <david.sanchez@pydantic.dev>
This commit is contained in:
David SF
2026-06-10 23:55:06 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 David SF
parent d347d08e3c
commit 6f2aa11438
39 changed files with 6605 additions and 27 deletions
+2
View File
@@ -2,6 +2,8 @@
.mcp.json
.DS_Store
.agents/settings.local.json
.agents/skills/branch-context/
AGENTS.local.md
CLAUDE.local.md
LOCAL_WORKTREES.md
+7 -2
View File
@@ -1,4 +1,4 @@
"""Pydantic AI capability library."""
"""The batteries for your Pydantic AI agent -- the official capability library."""
from typing import TYPE_CHECKING
@@ -8,7 +8,12 @@ if TYPE_CHECKING:
from .logfire import ManagedPrompt
from .shell import Shell
__all__ = ['CodeMode', 'FileSystem', 'ManagedPrompt', 'Shell']
__all__ = [
'CodeMode',
'FileSystem',
'ManagedPrompt',
'Shell',
]
def __getattr__(name: str) -> object:
+1 -1
View File
@@ -28,7 +28,7 @@ _SILENCE_HINT = (
def warn_experimental(feature: str) -> None:
"""Emit a `HarnessExperimentalWarning` for *feature*, including how to silence all of them.
One filter silences the whole category every experimental capability so users never
One filter silences the whole category (every experimental capability), so users never
need a suppression line per capability.
"""
warnings.warn(
@@ -3,7 +3,7 @@
> [!WARNING]
> **Experimental.** These capabilities live under `pydantic_ai_harness.experimental` and may
> change or be removed in any release, without a deprecation period. Import them from the
> experimental path there is no top-level export:
> experimental path -- there is no top-level export:
>
> ```python
> from pydantic_ai_harness.experimental.compaction import TieredCompaction
@@ -24,7 +24,7 @@ window. Each is a Pydantic AI `Capability` that runs in the `before_model_reques
**persist** into the run's message history, so a trim/clear/summary carries forward to later
steps (it is not recomputed from the full history every turn).
All strategies preserve tool-call / tool-return **pairing** core does not validate this, and a
All strategies preserve tool-call / tool-return **pairing** -- core does not validate this, and a
provider rejects an orphaned pair. The zero-LLM strategies never call a model.
## The menu
@@ -48,9 +48,9 @@ near-lossless). `TieredCompaction` triggers and stops on a single `target_tokens
## Cost: why summarization is the last resort
Summarization turns input tokens into output tokens, which are billed at a premium and generated
serially so it is genuinely expensive. The zero-LLM strategies touch only the cheaper input side.
serially -- so it is genuinely expensive. The zero-LLM strategies touch only the cheaper input side.
The field consensus (Anthropic, OpenCode, Letta) is to clear/dedupe first and summarize only when
that is not enough which is exactly what `TieredCompaction` encodes:
that is not enough -- which is exactly what `TieredCompaction` encodes:
```python
from pydantic_ai import Agent
@@ -77,14 +77,14 @@ agent = Agent(
```
A tier inside `TieredCompaction` is driven directly by the orchestrator, which re-measures after each
and stops once under `target_tokens` so a tier's own `max_*` trigger is irrelevant there (set it to
and stops once under `target_tokens` -- so a tier's own `max_*` trigger is irrelevant there (set it to
anything valid). Any object with `async def compact(messages, ctx) -> list[ModelMessage]`
(`CompactionStrategy`) can be a tier, so you can plug in your own.
## Cache tradeoff (read before using `ClearToolResults`)
Clearing or deduplicating rewrites message content, which invalidates the provider's prompt cache
from the edit point onward the next request pays a cache-write. Use `ClearToolResults`'
from the edit point onward -- the next request pays a cache-write. Use `ClearToolResults`'
`min_clear_tokens` to skip clearing that reclaims too little to be worth busting the cache.
## Model inheritance
@@ -94,8 +94,8 @@ running agent's model. No token caps are imposed on the summary call.
## Usage accounting
The summary call is a real request to the model, so its full usage tokens **and** the request
itself is folded into the run's `ctx.usage`. This is deliberate: it keeps cost honest, keeps the
The summary call is a real request to the model, so its full usage -- tokens **and** the request
itself -- is folded into the run's `ctx.usage`. This is deliberate: it keeps cost honest, keeps the
request count consistent (a model request that didn't count as one would be the surprise), and lets a
`UsageLimits` request limit catch a runaway compaction. A run-request / iteration limiter will
therefore see compaction calls among its requests.
@@ -120,5 +120,5 @@ def my_file_key(call: ToolCallPart) -> str | None:
## Out of scope
These strategies compress or drop context *inside* the window. Moving large tool outputs *out* of the
window overflowing them to a file the agent (or a subagent) can query on demand is a separate
window -- overflowing them to a file the agent (or a subagent) can query on demand -- is a separate
capability, not lossy truncation. Prefer it over capping individual tool outputs.
@@ -1,4 +1,4 @@
"""`ClearToolResults` zero-cost in-place clearing of old tool results."""
"""`ClearToolResults` -- zero-cost in-place clearing of old tool results."""
from __future__ import annotations
@@ -31,7 +31,7 @@ class ClearToolResults(AbstractCapability[AgentDepsT]):
calls remain paired with their (now-blanked) results, so the history stays valid.
No LLM calls are made.
This is the cheap first tier of compaction tool results typically dominate
This is the cheap first tier of compaction -- tool results typically dominate
context, and the agent can re-run a tool if it needs the data again.
Cache tradeoff: clearing rewrites message content, which invalidates the provider's
@@ -1,4 +1,4 @@
"""`DeduplicateFileReads` zero-cost in-place clearing of superseded file reads."""
"""`DeduplicateFileReads` -- zero-cost in-place clearing of superseded file reads."""
from __future__ import annotations
@@ -25,7 +25,7 @@ class DeduplicateFileReads(AbstractCapability[AgentDepsT]):
earlier reads are blanked with a placeholder. Tool-call pairing is preserved. No LLM
calls are made.
File identity is supplied by the ``file_key`` seam given a ``ToolCallPart`` it returns
File identity is supplied by the ``file_key`` seam -- given a ``ToolCallPart`` it returns
a stable key for the file being read, or ``None`` if the call is not a file read. There
is no default: file-read identification is agent-specific, and a wrong guess would drop
live data.
@@ -1,4 +1,4 @@
"""`LimitWarner` injects warnings as the run approaches configured limits."""
"""`LimitWarner` -- injects warnings as the run approaches configured limits."""
from __future__ import annotations
@@ -1,7 +1,7 @@
"""Shared utilities for the compaction capabilities.
Token estimation, the `CompactionStrategy` protocol, tool-pair-safe cutoff logic, first-user
preservation, and in-place tool-result clearing anything used by more than one capability.
preservation, and in-place tool-result clearing -- anything used by more than one capability.
"""
from __future__ import annotations
@@ -121,7 +121,7 @@ class CompactionStrategy(Protocol[AgentDepsT]):
# ---------------------------------------------------------------------------
# Safe cutoff logic preserves tool-call / tool-return pairs
# Safe cutoff logic -- preserves tool-call / tool-return pairs
# ---------------------------------------------------------------------------
_TOOL_PAIR_SEARCH_RANGE = 5
@@ -1,4 +1,4 @@
"""`SlidingWindow` zero-cost trimming of the oldest messages."""
"""`SlidingWindow` -- zero-cost trimming of the oldest messages."""
from __future__ import annotations
@@ -1,4 +1,4 @@
"""`SummarizingCompaction` LLM-powered summarization of older messages."""
"""`SummarizingCompaction` -- LLM-powered summarization of older messages."""
from __future__ import annotations
@@ -44,7 +44,7 @@ The user's overall goal and any standing constraints or preferences.
Choices made and the reasoning, so they are not relitigated.
## Artifacts
Files, paths, identifiers, commands, and APIs touched quote exact names.
Files, paths, identifiers, commands, and APIs touched -- quote exact names.
## Current state
What is done and what is in progress right now.
@@ -55,7 +55,7 @@ The immediate actions still required to finish the task.
## Open questions
Unresolved questions or blockers.
Focus on results, not a replay of completed actions. Respond ONLY with the summary no \
Focus on results, not a replay of completed actions. Respond ONLY with the summary -- no \
preamble, no markdown fences.
<messages>
@@ -140,8 +140,8 @@ class SummarizingCompaction(AbstractCapability[AgentDepsT]):
summarized using a dedicated model call and replaced with a compact, structured
summary message, preserving recent context and tool-call integrity.
This is the expensive tier summarization turns input tokens into (pricier) output
tokens so it is best used behind cheaper passes (see `TieredCompaction`).
This is the expensive tier -- summarization turns input tokens into (pricier) output
tokens -- so it is best used behind cheaper passes (see `TieredCompaction`).
The summary call's usage is folded into the parent run's usage (it counts as a real
request), so cost accounting stays honest; note this also increments the run's request
@@ -1,4 +1,4 @@
"""`TieredCompaction` escalation orchestrator over a sequence of strategies."""
"""`TieredCompaction` -- escalation orchestrator over a sequence of strategies."""
from __future__ import annotations
@@ -26,7 +26,7 @@ class TieredCompaction(AbstractCapability[AgentDepsT]):
tool results, deduplicate reads, then summarize) so the expensive summarization tier is
only reached when the cheap passes cannot reclaim enough.
Each tier's own trigger is bypassed `TieredCompaction` drives the tiers directly via
Each tier's own trigger is bypassed -- `TieredCompaction` drives the tiers directly via
their ``compact`` method and decides when to stop.
Example:
@@ -0,0 +1,41 @@
"""Content-addressed media stores for offloading large binary parts.
Used by `pydantic_ai_harness.experimental.step_persistence` to keep snapshots small when
messages carry `BinaryContent` payloads. A forthcoming `MediaExternalizer`
capability will reuse these stores for in-flight wire-payload reduction
(rewriting `BinaryContent` to URL parts before the model sees them).
"""
from pydantic_ai_harness.experimental._warn import warn_experimental
from pydantic_ai_harness.experimental.media._s3 import S3MediaStore
from pydantic_ai_harness.experimental.media._store import (
DiskMediaStore,
KeyStrategy,
MediaContext,
MediaStore,
PublicUrlResolver,
SqliteMediaStore,
default_key_strategy,
make_static_public_url,
media_uri_for,
parse_media_uri,
)
from pydantic_ai_harness.experimental.media._walker import externalize_media, restore_media
warn_experimental('media')
__all__ = [
'DiskMediaStore',
'KeyStrategy',
'MediaContext',
'MediaStore',
'PublicUrlResolver',
'S3MediaStore',
'SqliteMediaStore',
'default_key_strategy',
'externalize_media',
'make_static_public_url',
'media_uri_for',
'parse_media_uri',
'restore_media',
]
@@ -0,0 +1,309 @@
"""S3-compatible media store with handrolled AWS SigV4 signing.
Targets AWS S3, Cloudflare R2, MinIO, and other S3-compatible endpoints.
Implements path-style URLs (`<endpoint>/<bucket>/<key>`) -- the lowest common
denominator across providers. Only PUT / GET / HEAD are implemented;
multipart, lifecycle, and listing operations are out of scope (any blob we
store is sub-5GB by construction so multipart is unnecessary).
The SigV4 implementation follows the AWS reference algorithm but is local
to this module to keep `pydantic-ai-harness` free of `botocore` / `boto3`.
If a provider needs deeper integration, write a subclass that overrides
`_request`.
"""
from __future__ import annotations
import hashlib
import hmac
import re
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from urllib.parse import quote
from pydantic_ai_harness.experimental.media._store import (
_EMPTY_CONTEXT, # pyright: ignore[reportPrivateUsage]
KeyStrategy,
MediaContext,
PublicUrlResolver,
_resolve_public_url, # pyright: ignore[reportPrivateUsage]
default_key_strategy,
media_uri_for,
parse_media_uri,
)
if TYPE_CHECKING:
import httpx
_ALGORITHM = 'AWS4-HMAC-SHA256'
_SERVICE = 's3'
# Conservative subset for x-amz-meta-* keys: ASCII letters/digits/dash; lowercase.
_META_KEY_RE = re.compile(r'^[a-zA-Z0-9-]+$')
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _hex_sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _hmac_sha256(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
def _canonical_uri(path: str) -> str:
"""Percent-encode each path segment but keep `/` separators.
AWS canonicalization requires double-encoding for non-S3 services but
single-encoding for S3 -- we use single-encoding throughout.
"""
return '/' + '/'.join(quote(segment, safe='') for segment in path.lstrip('/').split('/'))
def _signing_key(secret_access_key: str, date_stamp: str, region: str) -> bytes:
k_date = _hmac_sha256(f'AWS4{secret_access_key}'.encode(), date_stamp)
k_region = _hmac_sha256(k_date, region)
k_service = _hmac_sha256(k_region, _SERVICE)
return _hmac_sha256(k_service, 'aws4_request')
def sign_request(
*,
method: str,
host: str,
path: str,
body: bytes,
region: str,
access_key_id: str,
secret_access_key: str,
content_type: str | None = None,
extra_signed_headers: Mapping[str, str] | None = None,
now: datetime | None = None,
) -> dict[str, str]:
"""Return the headers (including `Authorization`) to send with an S3 request.
`path` should start with `/` and already include any bucket prefix --
e.g. `/my-bucket/object-key`. Query strings are not supported in v1
(we never send them for PUT/GET/HEAD of a single object).
`extra_signed_headers` (lowercase keys) are included in the canonical
request and signature -- e.g. `x-amz-meta-*` user metadata headers.
"""
timestamp = now or _utcnow()
amz_date = timestamp.strftime('%Y%m%dT%H%M%SZ')
date_stamp = timestamp.strftime('%Y%m%d')
payload_hash = _hex_sha256(body)
headers_to_sign: dict[str, str] = {
'host': host,
'x-amz-content-sha256': payload_hash,
'x-amz-date': amz_date,
}
if content_type is not None:
headers_to_sign['content-type'] = content_type
if extra_signed_headers:
for k, v in extra_signed_headers.items():
headers_to_sign[k.lower()] = v
sorted_keys = sorted(headers_to_sign)
canonical_headers = ''.join(f'{k}:{headers_to_sign[k]}\n' for k in sorted_keys)
signed_headers = ';'.join(sorted_keys)
canonical_request = '\n'.join(
[
method.upper(),
_canonical_uri(path),
'',
canonical_headers,
signed_headers,
payload_hash,
]
)
credential_scope = f'{date_stamp}/{region}/{_SERVICE}/aws4_request'
string_to_sign = '\n'.join(
[
_ALGORITHM,
amz_date,
credential_scope,
_hex_sha256(canonical_request.encode('utf-8')),
]
)
signature = hmac.new(
_signing_key(secret_access_key, date_stamp, region),
string_to_sign.encode('utf-8'),
hashlib.sha256,
).hexdigest()
authorization = (
f'{_ALGORITHM} '
f'Credential={access_key_id}/{credential_scope}, '
f'SignedHeaders={signed_headers}, '
f'Signature={signature}'
)
return {**headers_to_sign, 'authorization': authorization}
def _meta_headers_from_context(context: MediaContext) -> dict[str, str]:
"""Map `context.metadata` to `x-amz-meta-*` headers (lowercase, ASCII-safe keys).
Raises `ValueError` for keys containing characters disallowed by HTTP
header naming. Values are passed through verbatim -- callers must keep
them ASCII-clean (S3 returns 400 for non-ASCII without `*=` RFC 5987
encoding, which we don't support in v1).
"""
headers: dict[str, str] = {}
for key, value in context.metadata.items():
if not _META_KEY_RE.fullmatch(key):
raise ValueError(
f'metadata key {key!r} is not a valid HTTP header token; use ASCII letters / digits / dashes'
)
headers[f'x-amz-meta-{key.lower()}'] = value
return headers
class S3MediaStore:
"""S3-compatible content-addressed store using path-style URLs + SigV4.
Works with AWS S3 (`endpoint='https://s3.<region>.amazonaws.com'`),
Cloudflare R2 (`endpoint='https://<account_id>.r2.cloudflarestorage.com'`,
`region='auto'`), MinIO, and other compatible providers.
Customisation:
- `key_strategy=` -- `Callable[[str, MediaContext], str]`. Default
strategy yields `<key_prefix><sha256>.bin`. Override when the bucket
layout demands a specific path / extension scheme. See the
`KeyStrategy` docstring for the read-time caveat.
- `public_url=` -- sync or async callable that takes
`(uri, MediaContext)` and returns a URL (or `None`). Use
`make_static_public_url(...)` for public bucket / CDN setups;
provide your own async callable for presigned URLs (TTL captured in
the closure; `MediaContext` available for content-type-specific
response headers etc.). Without a resolver `public_url(...)` returns
`None`.
- `client=` -- bring your own `httpx.AsyncClient` for connection pooling.
**Metadata persistence**: `context.metadata` is sent as signed
`x-amz-meta-*` headers on PUT (subject to ASCII naming rules) and
read back via `get_metadata(uri)`, which strips the `x-amz-meta-`
prefix from the HEAD response headers.
"""
def __init__(
self,
*,
bucket: str,
endpoint: str,
region: str,
access_key_id: str,
secret_access_key: str,
key_prefix: str = '',
client: httpx.AsyncClient | None = None,
key_strategy: KeyStrategy = default_key_strategy,
public_url: PublicUrlResolver | None = None,
) -> None:
self._bucket = bucket
self._endpoint = endpoint.rstrip('/')
self._region = region
self._access_key_id = access_key_id
self._secret_access_key = secret_access_key
self._key_prefix = key_prefix
self._client = client
self._key_strategy = key_strategy
self._public_url_resolver = public_url
def _object_path(self, uri: str, context: MediaContext) -> str:
key = self._key_strategy(uri, context)
return f'/{self._bucket}/{self._key_prefix}{key}'
def _host(self) -> str:
host = self._endpoint.split('://', 1)[1]
return host.split('/', 1)[0]
async def _request(
self,
method: str,
path: str,
*,
body: bytes = b'',
content_type: str | None = None,
meta_headers: Mapping[str, str] | None = None,
) -> httpx.Response:
import httpx
headers = sign_request(
method=method,
host=self._host(),
path=path,
body=body,
region=self._region,
access_key_id=self._access_key_id,
secret_access_key=self._secret_access_key,
content_type=content_type,
extra_signed_headers=meta_headers,
)
# The signature covers `_canonical_uri(path)` (each segment percent-encoded).
# Send that exact byte sequence as the wire path via `raw_path`, otherwise
# httpx applies its own (looser) path encoding and a custom key_prefix /
# key_strategy emitting reserved chars (`@`, `(`, `=`, ...) would diverge
# from the signed path -> SignatureDoesNotMatch.
url = httpx.URL(self._endpoint).copy_with(raw_path=_canonical_uri(path).encode('ascii'))
if self._client is not None:
return await self._client.request(method, url, content=body, headers=headers)
async with httpx.AsyncClient() as client:
return await client.request(method, url, content=body, headers=headers)
async def put(self, data: bytes, *, context: MediaContext = _EMPTY_CONTEXT) -> str:
uri = media_uri_for(data)
meta = _meta_headers_from_context(context)
response = await self._request(
'PUT',
self._object_path(uri, context),
body=data,
content_type=context.media_type,
meta_headers=meta or None,
)
if response.status_code // 100 != 2:
raise RuntimeError(f'S3 PUT failed for {uri}: {response.status_code} {response.text[:200]}')
return uri
async def get(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bytes:
digest = parse_media_uri(uri)
response = await self._request('GET', self._object_path(uri, context))
if response.status_code == 404:
raise FileNotFoundError(f'media not found: {digest}')
if response.status_code // 100 != 2:
raise RuntimeError(f'S3 GET failed for {digest}: {response.status_code} {response.text[:200]}')
return response.content
async def exists(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bool:
digest = parse_media_uri(uri)
response = await self._request('HEAD', self._object_path(uri, context))
if response.status_code == 404:
return False
if response.status_code // 100 != 2:
raise RuntimeError(f'S3 HEAD failed for {digest}: {response.status_code} {response.text[:200]}')
return True
async def public_url(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> str | None:
return await _resolve_public_url(self._public_url_resolver, uri, context)
async def get_metadata(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> Mapping[str, str]:
digest = parse_media_uri(uri)
response = await self._request('HEAD', self._object_path(uri, context))
if response.status_code == 404:
raise FileNotFoundError(f'media not found: {digest}')
if response.status_code // 100 != 2:
raise RuntimeError(f'S3 HEAD failed for {digest}: {response.status_code} {response.text[:200]}')
out: dict[str, str] = {}
for key, value in response.headers.items():
lower = key.lower()
if lower.startswith('x-amz-meta-'):
out[lower[len('x-amz-meta-') :]] = value
return out
@@ -0,0 +1,445 @@
"""Local `MediaStore` protocol + `DiskMediaStore` / `SqliteMediaStore`.
The shared URI scheme is `media+sha256://<lowercase-hex>` -- content-addressed,
so the same blob written through any store resolves the same way and dedup
is automatic.
"""
from __future__ import annotations
import hashlib
import inspect
import json
import re
import sqlite3
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from types import MappingProxyType
from typing import Protocol, TypeGuard, runtime_checkable
import anyio.to_thread
_URI_SCHEME = 'media+sha256://'
_HEX_RE = re.compile(r'^[0-9a-f]{64}$')
# Sentinel: empty, shareable, immutable. Used as the default `context` on every
# `MediaStore` method. Pulled into a module constant so a default-bound empty
# context is always the same instance (cheap identity checks, no per-call
# allocation).
_EMPTY_METADATA: Mapping[str, str] = MappingProxyType({})
@dataclass(frozen=True, kw_only=True)
class MediaContext:
"""Per-operation context threaded through `MediaStore` methods + callables.
Extensible bag -- new use cases (TTL hints, response-header overrides,
origin run ids for audit, etc.) add a field here without breaking any
method signature or user-supplied callable. Fields default to `None` /
empty so callers and resolvers can ignore what they don't care about.
Conventions:
- `media_type` -- IANA media type (`image/png`, `audio/wav`, ...). When
absent, callbacks may guess from `filename` (stdlib `mimetypes`) or
fall back to a generic default. Never required.
- `filename` -- original filename for the bytes, if known. Useful for
key strategies that want a recognisable extension, and for resolvers
that need to set `Content-Disposition` on presigned URLs.
- `metadata` -- free-form `dict[str, str]` of user-supplied tags.
Persisted by all three concrete stores (`SqliteMediaStore` → JSON
column, `S3MediaStore` → signed `x-amz-meta-*` headers,
`DiskMediaStore` → sidecar JSON file). Read back via
`MediaStore.get_metadata(uri)`.
"""
media_type: str | None = None
filename: str | None = None
metadata: Mapping[str, str] = field(default_factory=lambda: _EMPTY_METADATA)
_EMPTY_CONTEXT = MediaContext()
PublicUrlResolver = Callable[[str, MediaContext], 'str | None | Awaitable[str | None]']
"""User-supplied callable that turns a `media+sha256://<hex>` URI into a fetchable URL.
Sync or async; the store auto-detects via `inspect.isawaitable` on the result.
Return `None` to signal "no URL for this URI" (the resolver may be content-aware
and choose to inline some payloads).
Receives the full `MediaContext` so the resolver can vary its output by media
type, response headers, TTL, etc. without breaking the signature later.
"""
KeyStrategy = Callable[[str, MediaContext], str]
"""User-supplied callable that turns a URI + context into a backend storage key.
Used by `DiskMediaStore` and `S3MediaStore` to derive the on-disk path / S3
object key from the canonical URI. `SqliteMediaStore` does not take one: its
primary key is the content digest, so a user-chosen key would break dedup.
Default strategy is `<sha256>.bin` (see `default_key_strategy`); override when
an existing bucket layout or naming convention dictates otherwise.
**Caveat**: if your strategy depends on `context.media_type` (e.g. to pick an
extension), `get(uri)` and `exists(uri)` won't find the blob unless callers
pass the same context at read time. For pure path-organisation strategies
(e.g. `'media/' + digest + '.bin'`) the context can be ignored entirely.
"""
def default_key_strategy(uri: str, context: MediaContext) -> str:
"""Default storage key: `<sha256>.bin`. Ignores context."""
return f'{parse_media_uri(uri)}.bin'
async def _resolve_public_url(
resolver: PublicUrlResolver | None,
uri: str,
context: MediaContext,
) -> str | None:
if resolver is None:
return None
result = resolver(uri, context)
if inspect.isawaitable(result):
return await result
return result
def make_static_public_url(
base: str,
*,
key_prefix: str = '',
extension: str = '.bin',
) -> PublicUrlResolver:
"""Build a static-URL resolver for the common "public bucket / CDN" case.
The returned callable takes a `media+sha256://<hex>` URI and returns
`{base}/{key_prefix}<hex>{extension}`. Use this when the bytes live at a
known stable URL -- e.g. an R2 public bucket (`https://pub-xxx.r2.dev`)
or a CDN in front of S3. For presigned URLs or any logic that runs per
request, pass your own callable instead.
Ignores `MediaContext` -- pass your own callable if URL needs to vary
with media type, TTL, etc.
"""
base = base.rstrip('/')
def resolver(uri: str, context: MediaContext) -> str:
digest = parse_media_uri(uri)
return f'{base}/{key_prefix}{digest}{extension}'
return resolver
def media_uri_for(data: bytes) -> str:
"""Return the canonical `media+sha256://<hex>` URI for `data`.
The URI is the same regardless of which store the bytes are written to --
content-addressed so two stores holding the same bytes can be queried
interchangeably.
"""
return f'{_URI_SCHEME}{hashlib.sha256(data).hexdigest()}'
def parse_media_uri(uri: str) -> str:
"""Return the lowercase hex digest from a `media+sha256://<hex>` URI.
Raises `ValueError` if `uri` does not match the scheme or the digest is
not 64 lowercase hex characters.
"""
if not uri.startswith(_URI_SCHEME):
raise ValueError(f'not a media URI: {uri!r}')
digest = uri[len(_URI_SCHEME) :]
if not _HEX_RE.fullmatch(digest):
raise ValueError(f'invalid sha256 digest in {uri!r}')
return digest
@runtime_checkable
class MediaStore(Protocol):
"""Async content-addressed bytes store.
`put` returns the canonical URI (`media+sha256://<hex>`) for the bytes --
callers do not pick the key. Implementations may dedup on the hash.
Every method takes an optional `context: MediaContext` for forward
extensibility. New context fields don't break existing call sites or
user-supplied callables.
`public_url(uri)` returns a URL the model can fetch directly, or `None`
if the store can't or hasn't been configured to produce one. The forthcoming
`MediaExternalizer` capability uses this to rewrite `BinaryContent` parts
to `ImageUrl`/`AudioUrl`/etc. before the model sees the message.
"""
async def put(self, data: bytes, *, context: MediaContext = _EMPTY_CONTEXT) -> str: ... # pragma: no cover
async def get(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bytes: ... # pragma: no cover
async def exists(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bool: ... # pragma: no cover
async def public_url(
self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT
) -> str | None: ... # pragma: no cover
async def get_metadata(
self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT
) -> Mapping[str, str]: ... # pragma: no cover
class DiskMediaStore:
"""Filesystem store. One file per blob; default layout is `<directory>/<sha256>.bin`.
Directory is created on first write. Dedup is automatic via the
content-hash filename.
Customisation:
- `key_strategy=` -- `Callable[[str, MediaContext], str]` overriding the
relative path inside `directory` (e.g. `'images/<digest>.png'`).
The returned path must be relative and free of `..` segments
(absolute paths and `..` are both rejected with `ValueError`).
Caveat: if the strategy uses `context.media_type` etc. to pick the
filename, the same context must be supplied on `get`/`exists` to
locate the blob.
- `public_url=` -- resolver exposing a URL the model can fetch.
Without it `public_url(...)` returns `None` (local filesystem paths
are not addressable from a model).
**Metadata persistence**: `context.metadata` is written to a sidecar
JSON file (`<resolved>.meta.json`) alongside the blob and read back
via `get_metadata(uri)`. Sidecars are written atomically (tmp +
rename) and are missing only when no metadata was supplied.
"""
def __init__(
self,
directory: str | Path,
*,
key_strategy: KeyStrategy = default_key_strategy,
public_url: PublicUrlResolver | None = None,
) -> None:
self._root = Path(directory)
self._key_strategy = key_strategy
self._public_url_resolver = public_url
def _path_for(self, uri: str, context: MediaContext) -> Path:
relative = self._key_strategy(uri, context)
candidate = Path(relative)
# `Path('/root') / '/abs'` discards `/root` and returns `/abs` -- an absolute
# key would silently escape the store directory. Reject both shapes.
if candidate.is_absolute() or '..' in candidate.parts:
raise ValueError(f'key_strategy produced traversal-unsafe path: {relative!r}')
return self._root / candidate
async def put(self, data: bytes, *, context: MediaContext = _EMPTY_CONTEXT) -> str:
return await anyio.to_thread.run_sync(self._sync_put, data, context)
def _sync_put(self, data: bytes, context: MediaContext) -> str:
uri = media_uri_for(data)
path = self._path_for(uri, context)
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
tmp = path.with_suffix(path.suffix + '.tmp')
tmp.write_bytes(data)
tmp.replace(path)
if context.metadata:
meta_path = path.with_name(path.name + '.meta.json')
meta_tmp = meta_path.with_suffix('.json.tmp')
meta_tmp.write_text(json.dumps(dict(context.metadata)))
meta_tmp.replace(meta_path)
return uri
async def get(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bytes:
return await anyio.to_thread.run_sync(self._sync_get, uri, context)
def _sync_get(self, uri: str, context: MediaContext) -> bytes:
path = self._path_for(uri, context)
if not path.exists():
raise FileNotFoundError(f'media not found: {uri}')
return path.read_bytes()
async def exists(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bool:
return await anyio.to_thread.run_sync(self._sync_exists, uri, context)
def _sync_exists(self, uri: str, context: MediaContext) -> bool:
return self._path_for(uri, context).exists()
async def public_url(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> str | None:
return await _resolve_public_url(self._public_url_resolver, uri, context)
async def get_metadata(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> Mapping[str, str]:
return await anyio.to_thread.run_sync(self._sync_get_metadata, uri, context)
def _sync_get_metadata(self, uri: str, context: MediaContext) -> Mapping[str, str]:
path = self._path_for(uri, context)
meta_path = path.with_name(path.name + '.meta.json')
if not meta_path.exists():
return _EMPTY_METADATA
return _coerce_metadata_mapping(json.loads(meta_path.read_text()))
def _is_object_dict(raw: object) -> TypeGuard[dict[object, object]]:
return isinstance(raw, dict)
def _coerce_metadata_mapping(raw: object) -> Mapping[str, str]:
if not _is_object_dict(raw):
raise ValueError(f'metadata payload must be a JSON object, got {type(raw).__name__}')
out: dict[str, str] = {}
for key, value in raw.items():
if not isinstance(key, str) or not isinstance(value, str):
raise ValueError(f'metadata entries must be str→str, got {key!r}: {value!r}')
out[key] = value
return out
class SqliteMediaStore:
"""SQLite store. One row per blob in a `media` table keyed by sha256 hex.
Pass either a path to a SQLite file (created on demand) or an existing
`sqlite3.Connection`. When a connection is passed the caller owns its
lifecycle; when a path is passed each call opens a short-lived connection
inside the worker thread (safe across event-loop threads).
A caller-owned connection **must** be created with
`check_same_thread=False`. Store methods dispatch SQL onto worker threads
via `anyio.to_thread`, so the stdlib default (`check_same_thread=True`)
raises `sqlite3.ProgrammingError` on first use. The path form sets this
internally; a passed-in connection cannot, so it is the caller's
responsibility.
The table layout is:
```sql
CREATE TABLE IF NOT EXISTS media (
sha256 TEXT PRIMARY KEY,
media_type TEXT,
bytes BLOB NOT NULL,
size_bytes INTEGER NOT NULL,
metadata TEXT
);
```
`INSERT OR IGNORE` makes writes idempotent -- the second `put` with the
same content is a no-op, not an overwrite. `metadata` is stored as
canonical JSON of `context.metadata` (empty mapping → `'{}'`) and
read back via `get_metadata(uri)`.
There is no `key_strategy=` parameter: SqliteMediaStore is
content-addressed and the digest IS the primary key -- any user-chosen
storage key would either break dedup or be a no-op. Use `table=` if
you need a non-default table name.
"""
def __init__(
self,
*,
database: str | Path | None = None,
connection: sqlite3.Connection | None = None,
table: str = 'media',
public_url: PublicUrlResolver | None = None,
) -> None:
if (database is None) == (connection is None):
raise ValueError('provide exactly one of `database=` or `connection=`')
self._database = Path(database) if database is not None else None
self._connection = connection
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', table):
raise ValueError(f'invalid table name: {table!r}')
self._table = table
self._schema_ready = False
self._public_url_resolver = public_url
def _ensure_schema(self, conn: sqlite3.Connection) -> None:
if self._schema_ready:
return
conn.execute(
f'CREATE TABLE IF NOT EXISTS {self._table} ('
'sha256 TEXT PRIMARY KEY, '
'media_type TEXT, '
'bytes BLOB NOT NULL, '
'size_bytes INTEGER NOT NULL, '
'metadata TEXT)'
)
conn.commit()
self._schema_ready = True
def _open(self) -> sqlite3.Connection:
if self._connection is not None:
return self._connection
assert self._database is not None
self._database.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self._database, isolation_level=None, check_same_thread=False)
conn.execute('PRAGMA journal_mode=WAL').close()
return conn
def _maybe_close(self, conn: sqlite3.Connection) -> None:
if self._connection is None:
conn.close()
async def put(self, data: bytes, *, context: MediaContext = _EMPTY_CONTEXT) -> str:
return await anyio.to_thread.run_sync(self._sync_put, data, context)
def _sync_put(self, data: bytes, context: MediaContext) -> str:
uri = media_uri_for(data)
digest = parse_media_uri(uri)
metadata_json = json.dumps(dict(context.metadata))
conn = self._open()
try:
self._ensure_schema(conn)
conn.execute(
f'INSERT OR IGNORE INTO {self._table} '
'(sha256, media_type, bytes, size_bytes, metadata) VALUES (?, ?, ?, ?, ?)',
(digest, context.media_type, data, len(data), metadata_json),
)
finally:
self._maybe_close(conn)
return uri
async def get(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bytes:
digest = parse_media_uri(uri)
return await anyio.to_thread.run_sync(self._sync_get, digest)
def _sync_get(self, digest: str) -> bytes:
conn = self._open()
try:
self._ensure_schema(conn)
row = conn.execute(f'SELECT bytes FROM {self._table} WHERE sha256 = ?', (digest,)).fetchone()
finally:
self._maybe_close(conn)
if row is None:
raise FileNotFoundError(f'media not found: {digest}')
return bytes(row[0])
async def exists(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> bool:
digest = parse_media_uri(uri)
return await anyio.to_thread.run_sync(self._sync_exists, digest)
def _sync_exists(self, digest: str) -> bool:
conn = self._open()
try:
self._ensure_schema(conn)
row = conn.execute(f'SELECT 1 FROM {self._table} WHERE sha256 = ?', (digest,)).fetchone()
finally:
self._maybe_close(conn)
return row is not None
async def public_url(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> str | None:
return await _resolve_public_url(self._public_url_resolver, uri, context)
async def get_metadata(self, uri: str, *, context: MediaContext = _EMPTY_CONTEXT) -> Mapping[str, str]:
digest = parse_media_uri(uri)
return await anyio.to_thread.run_sync(self._sync_get_metadata, digest)
def _sync_get_metadata(self, digest: str) -> Mapping[str, str]:
conn = self._open()
try:
self._ensure_schema(conn)
row = conn.execute(f'SELECT metadata FROM {self._table} WHERE sha256 = ?', (digest,)).fetchone()
finally:
self._maybe_close(conn)
if row is None:
raise FileNotFoundError(f'media not found: {digest}')
return _coerce_metadata_mapping(json.loads(row[0]))
@@ -0,0 +1,128 @@
"""JSON walkers that swap inline `BinaryContent` for externalized markers.
Used by step-persistence backends to keep large media out of snapshot
payloads. The walkers operate on JSON-shaped trees (`list`/`dict`/scalars)
so they never need to know about `pydantic_ai.messages` types directly --
they recognize the serialized shape (`{"kind": "binary", "data": "<b64>"}`)
emitted by `ModelMessagesTypeAdapter.dump_json`.
Round-trip: `externalize_media` (pre-write) and `restore_media` (post-read)
are inverses for bytes ≥ threshold. Bytes below the threshold pass through
unchanged.
"""
from __future__ import annotations
import base64
from typing import TypeGuard
from pydantic_ai_harness.experimental.media._store import MediaContext, MediaStore
_EXTERNAL_MARKER = '__harness_external_media__'
def _is_json_dict(node: object) -> TypeGuard[dict[str, object]]:
"""Treat any `dict` returned by `json.loads` as `dict[str, object]`.
JSON guarantees string keys, so the runtime `isinstance(..., dict)` check
is sufficient -- pyright just needs the explicit TypeGuard to propagate
the value-type narrowing through the walker.
"""
return isinstance(node, dict)
def _is_json_list(node: object) -> TypeGuard[list[object]]:
"""Treat any `list` returned by `json.loads` as `list[object]`.
Same rationale as `_is_json_dict` -- JSON lists contain JSON-compatible
scalars, and we walk every element regardless.
"""
return isinstance(node, list)
def _is_binary_part(node: dict[str, object]) -> bool:
return node.get('kind') == 'binary' and isinstance(node.get('data'), str)
def _is_external_marker(node: dict[str, object]) -> bool:
return node.get(_EXTERNAL_MARKER) is True
async def externalize_media(node: object, *, media_store: MediaStore, threshold_bytes: int) -> object:
"""Walk `node` and replace inline `BinaryContent` parts ≥ threshold.
Each qualifying part becomes a marker dict carrying the canonical
`media+sha256://` URI; the raw bytes are written to `media_store`.
Returns a new tree -- input is not mutated.
"""
if _is_json_list(node):
out_list: list[object] = []
for item in node:
out_list.append(await externalize_media(item, media_store=media_store, threshold_bytes=threshold_bytes))
return out_list
if _is_json_dict(node):
if _is_binary_part(node):
replaced = await _maybe_externalize_binary(node, media_store, threshold_bytes)
if replaced is not None:
return replaced
out_dict: dict[str, object] = {}
for key, value in node.items():
out_dict[key] = await externalize_media(value, media_store=media_store, threshold_bytes=threshold_bytes)
return out_dict
return node
async def _maybe_externalize_binary(
node: dict[str, object],
media_store: MediaStore,
threshold_bytes: int,
) -> dict[str, object] | None:
# `_is_binary_part` already verified `data` is a string; the cast is safe.
data_value = node['data']
assert isinstance(data_value, str)
raw = base64.b64decode(data_value)
if len(raw) < threshold_bytes:
return None
media_type_value = node.get('media_type')
media_type = media_type_value if isinstance(media_type_value, str) else None
uri = await media_store.put(raw, context=MediaContext(media_type=media_type))
# Preserve every field except the externalized `data`, so the round-trip
# survives new `BinaryContent` fields added by pydantic_ai upstream.
marker = {key: value for key, value in node.items() if key != 'data'}
marker[_EXTERNAL_MARKER] = True
marker['uri'] = uri
return marker
async def restore_media(node: object, *, media_store: MediaStore) -> object:
"""Inverse of `externalize_media`. Walks `node` and re-inlines external refs.
Each marker dict's `uri` is resolved via `media_store.get` and the bytes
are re-encoded as a `kind=binary` part so the result round-trips
through `ModelMessagesTypeAdapter.validate_python`.
"""
if _is_json_list(node):
out_list: list[object] = []
for item in node:
out_list.append(await restore_media(item, media_store=media_store))
return out_list
if _is_json_dict(node):
if _is_external_marker(node):
return await _restore_external(node, media_store)
out_dict: dict[str, object] = {}
for key, value in node.items():
out_dict[key] = await restore_media(value, media_store=media_store)
return out_dict
return node
async def _restore_external(node: dict[str, object], media_store: MediaStore) -> dict[str, object]:
uri_value = node.get('uri')
if not isinstance(uri_value, str):
raise ValueError(f'externalized media marker missing string uri: {node!r}')
raw = await media_store.get(uri_value)
# Inverse of `_maybe_externalize_binary`: drop the marker keys, restore `data`,
# keep every other field the original part carried.
restored = {key: value for key, value in node.items() if key not in (_EXTERNAL_MARKER, 'uri')}
restored['data'] = base64.b64encode(raw).decode('ascii')
return restored
@@ -1,5 +1,24 @@
# Planning
> [!WARNING]
> **Experimental.** This capability lives under `pydantic_ai_harness.experimental` and may
> change or be removed in any release, without a deprecation period. Import it from the
> experimental path -- there is no top-level export:
>
> ```python
> from pydantic_ai_harness.experimental.planning import Planning
> ```
>
> Importing any experimental capability emits a `HarnessExperimentalWarning`. Silence **all**
> harness experimental warnings with a single filter (no per-capability lines needed):
>
> ```python
> import warnings
> from pydantic_ai_harness.experimental import HarnessExperimentalWarning
>
> warnings.filterwarnings('ignore', category=HarnessExperimentalWarning)
> ```
Give an agent a structured, self-updating task plan -- without ever invalidating the prompt cache.
## The problem
@@ -0,0 +1,505 @@
# StepPersistence
> [!WARNING]
> **Experimental.** `StepPersistence` and the `media` stores live under
> `pydantic_ai_harness.experimental` and may change or be removed in any release, without a
> deprecation period. Import them from the experimental path -- there is no top-level export:
>
> ```python
> from pydantic_ai_harness.experimental.step_persistence import StepPersistence
> from pydantic_ai_harness.experimental.media import S3MediaStore
> ```
>
> Importing either package emits a `HarnessExperimentalWarning`. Silence **all** harness
> experimental warnings with a single filter (no per-capability lines needed):
>
> ```python
> import warnings
> from pydantic_ai_harness.experimental import HarnessExperimentalWarning
>
> warnings.filterwarnings('ignore', category=HarnessExperimentalWarning)
> ```
`StepPersistence` records what an agent did at each boundary, separate from
whether the run can be safely resumed. It is the persistence substrate for
orchestrators that delegate to sub-agents (e.g. an AICA orchestrator spawning
a `code_librarian` to investigate one symbol, then continuing that delegate's
investigation with a follow-up question).
It is not a full graph-state checkpoint. Capability-state restore, workspace
snapshots, and graph-node resume are out of scope and tracked separately
(see `pydantic-ai-harness` issues #149 and #196).
## What it gives you
1. **Append-only step events** -- every interesting boundary (run start/end,
model request, tool call, failure) appends a `StepEvent`. A run killed
mid-tool-call still leaves a usable event trail.
2. **Continuable snapshots** -- a `ContinuableSnapshot` is saved only at
boundaries where the message history is **provider-valid**: every
`ToolCallPart` has a matching `ToolReturnPart` or `RetryPromptPart`, with
no orphan, duplicate, or out-of-order returns. Pass the snapshot's
`messages` back to `Agent.run(message_history=...)` to continue or fork.
3. **Tool-effect ledger** -- every tool call's lifecycle (`started`,
`completed`, `failed`) is recorded against `(run_id, tool_call_id)`.
After a crash, a tool with a `started` record and no terminal update
should be treated as `unknown_after_crash`: the side effect may or may
not have happened.
4. **Lineage metadata** -- `conversation_id` (sequence) and `parent_run_id`
(hierarchy) are independent axes. See [Three-level identity](#three-level-identity).
## Quick start
```python
from pydantic_ai import Agent
from pydantic_ai_harness.experimental.step_persistence import StepPersistence, InMemoryStepStore
store = InMemoryStepStore()
librarian = Agent(
'openai:gpt-5',
capabilities=[StepPersistence(store=store, agent_name='code_librarian')],
)
await librarian.run('Find ThinkingPartDelta and confirm the callable allowance')
```
That is the whole setup. **`run_id` is always per-`Agent.run` call**,
matching pydantic_ai's `RunContext.run_id`. For multi-turn logical
grouping use `conversation_id=` -- that is the pydantic_ai-native
primitive for it (see [Three-level identity](#three-level-identity)).
`run_id` resolution per call:
- **Explicit `run_id='libr-1'`** → used as-is for this one call.
Single-shot use cases (deterministic id for testing, replay, debugging,
a one-off scripted run). Reusing one capability instance with the same
explicit `run_id` across multiple `.run()` calls **raises `ValueError`
in `before_run`** -- the tool-effect ledger is keyed by
`(run_id, tool_call_id)` and providers reuse deterministic tool-call
ids, so a silent collision would erase the `unknown_after_crash`
signal. Use `conversation_id=` for multi-turn grouping instead.
- **`agent_name` set, `run_id` unset** → `'{agent_name}-{8-char-hex}'`,
freshly materialised in `for_run` per `.run()` call. Reusing one
capability instance across runs yields distinct ids
(`code_librarian-a3b2`, `code_librarian-c9d1`, …). This is the
recommended default for delegate capabilities.
- **Neither set** → `ctx.run_id` (pydantic_ai's auto-generated id) per
`.run()` call, falling back to a UUID4.
The orchestrator pattern -- one logical agent serving many turns -- uses
`conversation_id`, not a shared `run_id`:
```python
orchestrator = Agent(
'openai:gpt-5',
capabilities=[StepPersistence(store=store, agent_name='orchestrator')],
)
for turn in turns:
await orchestrator.run(turn, conversation_id='orch-conv')
# All turns of this orchestrator, chronological:
records = await store.list_runs(conversation_id='orch-conv')
```
## Three-level identity
The capability mirrors pydantic_ai's identity stack:
| Concept | Definition | Granularity |
| --- | --- | --- |
| `conversation_id` | The dialogue. Resolved by pydantic_ai from the `conversation_id=` argument to `Agent.run`, or the most recent `conversation_id` on `message_history`, or a fresh UUID7. | sequence of runs |
| `run_id` | One `Agent.run` invocation. | one step in the sequence |
| `step_index` | Graph-node count *within* a run (`ctx.run_step`). | one node within one run |
`StepEvent.conversation_id` and `RunRecord.conversation_id` are populated
from `ctx.conversation_id`. So three `.run()` calls sharing one
`conversation_id` produce three distinct `run_id`s, all queryable as a
group:
```python
runs = await store.list_runs(conversation_id='conv-abc') # 3 records, chronological
```
## Continuing a delegate's investigation
pydantic_ai already has `message_history=` for "carry on with this prior
context". `StepPersistence` does not introduce a parallel mechanism -- it
exposes one helper that loads the most recent provider-valid snapshot:
```python
from pydantic_ai_harness.experimental.step_persistence import continue_run
# Earlier: tag the first turn with a conversation id so the follow-up can find it.
await librarian.run(
'Find ThinkingPartDelta and confirm the callable allowance',
conversation_id='libr-conv',
)
# Later (possibly a different process):
prior_run = (await store.list_runs(conversation_id='libr-conv'))[-1].run_id
history = await continue_run(store, run_id=prior_run)
await librarian.run(
'Read _apply_provider_details_delta and check the path',
message_history=history,
conversation_id='libr-conv', # keep the conversation grouping
)
```
`fork_run(store, run_id=...)` returns the same shape but is intended when
the caller wants a branched logical run from that snapshot point (the new
run gets a fresh `run_id` and probably a fresh `conversation_id`).
### What "safe to continue from" means
`continue_run` only returns the messages of the **latest provider-valid
snapshot** for that `run_id`. Snapshots are written at two boundaries:
- after every `CallToolsNode` completes (all tool calls returned), and
- at `after_run`.
A run that crashed mid-tool-call has events (`tool_call_started`) but no
snapshot for that point. `continue_run` returns the snapshot from the
**previous** safe boundary, not the failed step.
## Run lineage -- `parent_run_id`
`parent_run_id` is a lineage label, not a functional dependency. It does
two things:
- Every `StepEvent` and `RunRecord` carries it, so you can filter / group.
- `store.list_runs(parent_run_id='orch-1')` returns every delegate run
pointing at that orchestrator.
It is **auto-inferred for in-process delegation**: when an orchestrator's
tool synchronously calls a delegate's `Agent.run(...)`, the delegate's
`StepPersistence` picks up the orchestrator's `run_id` via a `ContextVar`
that the orchestrator's `wrap_run` set. No threading required:
```python
orchestrator = Agent(
'openai:gpt-5',
capabilities=[StepPersistence(store=store, agent_name='orchestrator')],
)
librarian = Agent(
'openai:gpt-5',
capabilities=[StepPersistence(store=store, agent_name='code_librarian')],
)
@orchestrator.tool_plain
async def ask_librarian(question: str) -> str:
result = await librarian.run(question) # parent_run_id auto-filled
return result.output
# Tag the orchestrator turn so the lookup below can find its run_id.
await orchestrator.run(
'Where is ThinkingPartDelta defined?',
conversation_id='orch-conv',
)
# All librarian runs now point at the orchestrator's run_id:
orch_run_id = (await store.list_runs(conversation_id='orch-conv'))[-1].run_id
delegates = await store.list_runs(parent_run_id=orch_run_id)
```
Set `parent_run_id=` explicitly to override (e.g. cross-process delegation
where `ContextVar`s do not propagate).
`parent_run_id` is **distinct from `conversation_id`**. The orchestrator
and delegate usually live in *different* conversations (the orchestrator
talks to a user; the delegate talks to itself). But they share a
parent-child link.
## Inspecting a run tree
`list_runs` returns matches sorted by `started_at` ascending across both
backends -- pick the most recent with `[-1]`.
```python
# Every delegate of one orchestrator run (chronological)
delegates = await store.list_runs(parent_run_id='orch-3f2a')
# Every run in one dialogue (multi-turn conversation across many .run() calls)
turns = await store.list_runs(conversation_id='conv-abc')
latest_turn = turns[-1]
# Filters combine (AND):
focused = await store.list_runs(
parent_run_id='orch-3f2a',
conversation_id='libr-conv',
)
# Detail per run:
events = await store.list_events(run_id=delegates[0].run_id)
snapshot = await store.latest_snapshot(run_id=delegates[0].run_id)
unresolved = await store.list_unresolved_tool_effects(run_id=delegates[0].run_id)
```
## Failure recovery
```python
# An earlier delegate run died mid-investigation.
events = await store.list_events(run_id='libr-3f2a')
unresolved = await store.list_unresolved_tool_effects(run_id='libr-3f2a')
for record in unresolved:
# status == 'started' with no terminal update -- unknown_after_crash.
print(f'tool {record.tool_name} ({record.tool_call_id}) may or may not have run')
print(f' idempotency_key={record.idempotency_key} '
f'effect_summary={record.effect_summary}')
# Decide whether to resume or branch:
history = await continue_run(store, run_id='libr-3f2a')
# If the unresolved tools were read-only and safe to redo:
await librarian.run('continue investigating', message_history=history,
conversation_id='libr-conv')
# If side effects might have happened and the orchestrator wants a fresh attempt:
history = await fork_run(store, run_id='libr-3f2a')
# ... pass to a new delegate run with a different agent_name / conversation_id.
```
Side-effect deduplication is the orchestrator's responsibility. Tools that
write external state should annotate their in-flight `ToolEffectRecord`
via `annotate_tool_effect`:
```python
from pydantic_ai_harness.experimental.step_persistence import annotate_tool_effect
@orchestrator.tool
async def set_label(ctx: RunContext[Deps], issue: int, label: str) -> str:
await annotate_tool_effect(
store,
ctx,
idempotency_key=f'issue-{issue}::label::{label}',
effect_summary=f'set label {label!r} on issue #{issue}',
)
await github.set_label(issue, label) # the actual side effect
return 'ok'
```
The helper reads the active `run_id` from the `StepPersistence`
`ContextVar` and `tool_call_id` / `tool_name` from `ctx`, then merges the
metadata into the prior record. `after_tool_execute` preserves both
fields when it writes the terminal `completed` / `failed` entry.
## Backends
- `InMemoryStepStore` -- process-local; great for tests.
- `FileStepStore(directory)` -- directory layout under `<directory>/<run_id>/`:
- `run.json` -- `RunRecord` (lineage)
- `events.jsonl` -- append-only `StepEvent`s
- `tool_effects.jsonl` -- append-only `ToolEffectRecord`s, scoped to this run
- `snapshots/{seq}.json` -- `ContinuableSnapshot`s, named by a per-run
monotonic counter (NOT `step_index`, which would collide when the
same `run_id` is reused across `Agent.run` calls -- `ctx.run_step`
resets to 0 each call).
- `SqliteStepStore(database='runs.db')` -- single SQLite file with tables
`runs`, `events`, `snapshots`, `tool_effects`, and a sibling `media`
table for externalized blobs (see [Persisting media](#persisting-media)
below). WAL mode is enabled; `tool_effects` upserts per
`(run_id, tool_call_id)` so the latest state wins; snapshots use
`AUTOINCREMENT seq` to mirror `FileStepStore._next_snapshot_seq`.
Pass `connection=` instead of `database=` to share a `sqlite3.Connection`
with the rest of your application; the connection must be opened with
`check_same_thread=False` because hook calls are dispatched onto a
worker thread.
All three implement the same async `StepStore` protocol, so capability
hooks never block the event loop on the file/sqlite backends (I/O is
dispatched via `anyio.to_thread`).
`FileStepStore` validates `run_id` against `[A-Za-z0-9_.-]{1,200}` (and
rejects `..`) to prevent path traversal -- callers passing user-controlled
IDs should still sanitise first.
## Persisting media
`BinaryContent` payloads (images, audio, documents, video) inline as
base64 inside a snapshot would balloon every file/row containing the
message. Both `FileStepStore` and `SqliteStepStore` externalize any
`BinaryContent.data`**64 KiB** through a configured `MediaStore`,
leaving a URI reference in the snapshot. Round-trip is transparent --
`latest_snapshot(...).messages[*]` returns `BinaryContent` with the
original bytes.
| StepStore | Default `media_store` | Where blobs live |
| ------------------- | --------------------------------------- | ------------------------------------- |
| `InMemoryStepStore` | _(not applicable)_ | bytes stay in the in-memory snapshot |
| `FileStepStore` | `DiskMediaStore(<root>/media/)` | `<root>/media/<sha256>.bin` |
| `SqliteStepStore` | `SqliteMediaStore(database=<same db>)` | sibling `media` table in the same DB |
Override the destination by passing your own `MediaStore`:
```python
from pydantic_ai_harness.experimental.step_persistence import FileStepStore
from pydantic_ai_harness.experimental.media import S3MediaStore
store = FileStepStore(
'runs',
media_store=S3MediaStore(
bucket='my-bucket',
endpoint='https://<account>.r2.cloudflarestorage.com',
region='auto',
access_key_id=...,
secret_access_key=...,
),
media_threshold_bytes=64 * 1024, # raise/lower if you want
)
```
Opt out entirely (keep bytes inline in the snapshot JSON/row):
```python
FileStepStore('runs', media_store=None)
SqliteStepStore(database='runs.db', media_store=None)
```
URIs are `media+sha256://<hex>`, content-addressed. The same blob written
through any `MediaStore` resolves the same way -- dedup is automatic and
moving the underlying storage is a one-line swap. The shipped
implementations are:
- `DiskMediaStore(directory)` -- one file per blob at
`<directory>/<sha256>.bin`.
- `SqliteMediaStore(database=...)` or `SqliteMediaStore(connection=...)` --
one row per blob (`INSERT OR IGNORE` for content-addressed dedup).
- `S3MediaStore(bucket=, endpoint=, region=, access_key_id=, secret_access_key=)`
-- path-style URLs + handrolled SigV4. Compatible with AWS S3, Cloudflare
R2 (`region='auto'`), MinIO, and other S3-compatible providers. PUT/GET/HEAD
only -- no multipart, lifecycle, or listing in v1.
### Exposing externalized bytes as URLs
Each store accepts a `public_url=` callable that turns the canonical
`media+sha256://<hex>` URI into a URL the model can fetch directly. The
forthcoming `MediaExternalizer` capability will use this to swap
`BinaryContent` parts for `ImageUrl` / `AudioUrl` / etc. before the
model sees the message -- letting providers fetch big media over the wire
without re-encoding bytes into the request body.
Static base URL (public R2 bucket, CDN):
```python
from pydantic_ai_harness.experimental.media import S3MediaStore, make_static_public_url
store = S3MediaStore(
bucket='my-bucket',
endpoint='https://<acc>.r2.cloudflarestorage.com',
region='auto',
access_key_id=..., secret_access_key=...,
key_prefix='media/',
public_url=make_static_public_url('https://pub-abc.r2.dev', key_prefix='media/'),
)
```
Presigned / rotating-signature URL -- pass any async callable that takes
`(uri, MediaContext)`:
```python
from pydantic_ai_harness.experimental.media import MediaContext, S3MediaStore
async def presign(uri: str, ctx: MediaContext) -> str:
key = 'media/' + uri.removeprefix('media+sha256://') + '.bin'
return await my_signer.generate(key, ttl=3600, content_type=ctx.media_type)
store = S3MediaStore(..., public_url=presign)
```
### `MediaContext`, extensible per-operation bag
Every `MediaStore` method (`put`, `get`, `exists`, `public_url`,
`get_metadata`) and both user-supplied callables (`PublicUrlResolver`,
`KeyStrategy`) accept a `MediaContext`:
```python
@dataclass(frozen=True, kw_only=True)
class MediaContext:
media_type: str | None = None # e.g. 'image/png'
filename: str | None = None # original filename, when known
metadata: Mapping[str, str] = {} # user-supplied tags
```
All fields default; new fields are added non-breakingly as use cases
emerge. Pass what you have, ignore the rest.
**Persistence by store.** `get_metadata(uri)` round-trips the
user-supplied `metadata` mapping on all three stores. `media_type` is
also persisted but is not part of what `get_metadata` returns (it is
stored for the byte payload itself, e.g. as the `Content-Type`).
- `SqliteMediaStore` writes `metadata` to a JSON column and `media_type`
to a dedicated column
- `S3MediaStore` sends `metadata` as signed `x-amz-meta-*` headers
(ASCII alphanumeric + dash key names) and `media_type` as
`Content-Type`; `get_metadata` reads the `x-amz-meta-*` values back
from the HEAD response
- `DiskMediaStore` writes a sidecar JSON file (`<resolved>.meta.json`)
alongside each blob, atomic via tmp + rename. Sidecars are absent
only when the put carried no metadata
### `key_strategy` -- controlling the backend storage path
Default is `<sha256>.bin`. `DiskMediaStore` and `S3MediaStore` accept
overrides to fit existing layouts; `SqliteMediaStore` does not (its
primary key is the digest, so a user-chosen key would either break
dedup or be a no-op):
```python
from pydantic_ai_harness.experimental.media import DiskMediaStore, MediaContext
def by_media_type(uri: str, ctx: MediaContext) -> str:
digest = uri.removeprefix('media+sha256://')
ext = {'image/png': '.png', 'image/jpeg': '.jpg'}.get(ctx.media_type or '', '.bin')
return f'images/{digest}{ext}'
store = DiskMediaStore('runs', key_strategy=by_media_type)
```
**Caveat**: if your strategy depends on `context.media_type` (e.g. to
pick an extension), `get(uri)` and `exists(uri)` won't find the blob
unless the same context is supplied at read time. For pure
path-organisation strategies (no context dependency) the constraint
doesn't apply.
`DiskMediaStore` rejects strategies that produce absolute paths or paths
containing `..` segments, to prevent escaping the store directory.
Separately, all three stores accept a `public_url=` resolver, useful
when a CDN, local HTTP server, or signed-URL service fronts the bytes.
Without it `public_url(...)` returns `None` (the model never sees a URL
unless a resolver is configured and it returns a string).
pyai providers transparently download bytes from a URL when the target
model doesn't natively accept that URL type, so emitting a URL is
always safe -- you only ever lose wire savings, never correctness.
> **Note on the future `MediaExternalizer` capability.** When it lands,
> the composition will be
> `Agent(capabilities=[MediaExternalizer(store), StepPersistence(...)])`
> and `StepPersistence` will see already-URL-ified messages -- the
> externalize walk becomes a no-op. The existing API does not change.
### Persisting unsupported backends
DynamoDB, Postgres, Redis, GCS, and other backends are out of scope for
this release. Write your own `StepStore` (≈ ten methods on a Protocol) or
your own `MediaStore` (three methods) and pass it via `store=` /
`media_store=`. Please open an issue if you ship one -- we want to feed
the eventual shared adapter layer with N≥3 real implementations before
abstracting.
## What this capability does **not** do
- It does not restore capability per-run state, graph-node state, retry
counters, or in-flight streaming responses.
- It does not deduplicate replayed side effects automatically. Tools that
write artifacts, labels, PRs, or external state should call
`annotate_tool_effect(store, ctx, ...)` (see [Failure recovery](#failure-recovery))
so the orchestrator can decide whether replay is safe.
- It does not clean up old snapshots/events. Retention is the caller's
responsibility.
- It does not emit OpenTelemetry spans. pydantic_ai's `Instrumentation`
capability already spans `agent run` / `chat` / `running tool` and
populates `gen_ai.agent.name`, `gen_ai.agent.call.id`,
`gen_ai.conversation.id` via baggage. A future change may add
step-persistence attributes to the active span; that is tracked as a
follow-up issue.
@@ -0,0 +1,44 @@
"""Step-event persistence: append-only event log, continuable snapshots, tool-effect ledger."""
from pydantic_ai_harness.experimental._warn import warn_experimental
from pydantic_ai_harness.experimental.step_persistence._capability import StepPersistence
from pydantic_ai_harness.experimental.step_persistence._helpers import (
annotate_tool_effect,
continue_run,
fork_run,
is_provider_valid,
)
from pydantic_ai_harness.experimental.step_persistence._store import (
FileStepStore,
InMemoryStepStore,
SqliteStepStore,
StepStore,
)
from pydantic_ai_harness.experimental.step_persistence._types import (
ContinuableSnapshot,
EventKind,
RunRecord,
StepEvent,
ToolEffectRecord,
ToolEffectStatus,
)
warn_experimental('step_persistence')
__all__ = [
'ContinuableSnapshot',
'EventKind',
'FileStepStore',
'InMemoryStepStore',
'RunRecord',
'SqliteStepStore',
'StepEvent',
'StepPersistence',
'StepStore',
'ToolEffectRecord',
'ToolEffectStatus',
'annotate_tool_effect',
'continue_run',
'fork_run',
'is_provider_valid',
]
@@ -0,0 +1,426 @@
"""StepPersistence capability: append-only event log + continuable snapshots."""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from pydantic_ai import CallToolsNode
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.capabilities.abstract import AgentNode, NodeResult, WrapRunHandler
from pydantic_ai.messages import ModelResponse, ToolCallPart
from pydantic_ai.models import ModelRequestContext
from pydantic_ai.run import AgentRunResult
from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition
from pydantic_ai_harness.experimental.step_persistence._context import current_run_id, snapshot_saved
from pydantic_ai_harness.experimental.step_persistence._helpers import is_provider_valid
from pydantic_ai_harness.experimental.step_persistence._store import InMemoryStepStore, StepStore
from pydantic_ai_harness.experimental.step_persistence._types import (
ContinuableSnapshot,
EventKind,
RunRecord,
StepEvent,
ToolEffectRecord,
)
def _empty_metadata() -> dict[str, str]:
return {}
@dataclass
class StepPersistence(AbstractCapability[AgentDepsT]):
"""Append-only step log + continuable snapshots + tool-effect ledger.
The capability emits a `StepEvent` at every interesting boundary
(run/model-request/tool-call start, completion, failure), records a
`ToolEffectRecord` per tool call so the orchestrator can decide whether
replay is safe, and saves a `ContinuableSnapshot` at every
provider-valid boundary -- the end of each `CallToolsNode` -- plus a
fallback save at `after_run` if the run reached no such boundary.
A run that crashes between `before_tool_execute` and `after_tool_execute`
leaves a visible event trail and a `started` tool-effect record, but no
new continuable snapshot -- the latest snapshot reflects the last
provider-valid state.
```python
from pydantic_ai import Agent
from pydantic_ai_harness.experimental.step_persistence import StepPersistence, InMemoryStepStore
store = InMemoryStepStore()
librarian = Agent(
'openai:gpt-5',
capabilities=[StepPersistence(store=store, agent_name='code_librarian')],
)
await librarian.run('Find ThinkingPartDelta and confirm the callable allowance')
```
Use `continue_run(store, run_id=...)` / `fork_run(store, run_id=...)`
to load a prior snapshot, then pass the result to
`Agent.run(..., message_history=...)`.
"""
store: StepStore = field(default_factory=InMemoryStepStore)
"""Backend that records events, snapshots, and tool effects."""
agent_name: str | None = None
"""Logical agent name (e.g. `code_librarian`, `reproducer`).
Used as a stable prefix for the auto-derived `run_id` so store
inspection shows readable IDs like `code_librarian-a3b2`.
"""
run_id: str | None = None
"""Identifier for this one `Agent.run` call.
`run_id` is per-call, matching `pydantic_ai.RunContext.run_id`. For
multi-turn logical grouping use `conversation_id` on `Agent.run(...)` --
that is the pyai-native primitive for it.
Resolution order (materialised in `for_run`):
1. **Explicit value** → used as-is. Single-shot use cases:
deterministic id for testing, replay, debugging. Reusing the
capability across multiple `.run()` calls with the same explicit
`run_id` raises `ValueError` in `before_run` -- the tool-effect
ledger keys on `(run_id, tool_call_id)` and providers reuse
deterministic tool-call ids, so a silent collision would erase
the `unknown_after_crash` signal. Use `conversation_id=` on
`Agent.run` for multi-turn grouping.
2. **`agent_name` set, `run_id` unset** → `{agent_name}-{short-uuid}`,
freshly materialised per `.run()`. Reusing the capability instance
yields distinct ids. Recommended default for delegate capabilities.
3. **Neither set** → `ctx.run_id` per `.run()`, falling back to UUID4.
"""
parent_run_id: str | None = None
"""Run that spawned this one.
Auto-inferred from the enclosing `StepPersistence` `wrap_run` scope --
when an orchestrator's tool synchronously calls a delegate's
`Agent.run(...)`, the delegate picks up the orchestrator's `run_id`
here without manual threading. Set explicitly to override (e.g. for
cross-process delegation where `ContextVar`s do not propagate).
"""
metadata: dict[str, str] = field(default_factory=_empty_metadata)
"""Free-form metadata stored on the `RunRecord` and on each event."""
@classmethod
def from_spec(cls, *args: Any, **kwargs: Any) -> StepPersistence[Any]:
"""Construct from a serialised spec.
Supports `backend='memory'` (default), `backend='file'` (with
`directory`), or `backend='sqlite'` (with `database`). Raises
`ValueError` for any other `backend` value -- silently falling
back to in-memory storage would turn a typo into accidental
non-durability.
"""
backend = kwargs.pop('backend', 'memory')
if backend == 'memory':
return cls(store=InMemoryStepStore(), **kwargs)
if backend == 'file':
from pydantic_ai_harness.experimental.step_persistence._store import FileStepStore
directory = kwargs.pop('directory', '.step-persistence')
return cls(store=FileStepStore(directory), **kwargs)
if backend == 'sqlite':
from pydantic_ai_harness.experimental.step_persistence._store import SqliteStepStore
database = kwargs.pop('database', '.step-persistence.db')
return cls(store=SqliteStepStore(database=database), **kwargs)
raise ValueError(f'unknown backend {backend!r}; expected `memory`, `file`, or `sqlite`')
async def for_run(self, ctx: RunContext[AgentDepsT]) -> AbstractCapability[AgentDepsT]:
"""Materialise `run_id` and `parent_run_id` for this `Agent.run` call.
Reads the contextvar set by any enclosing `StepPersistence.wrap_run`
before the local run overwrites it, so a delegate's `parent_run_id`
ends up pointing at its orchestrator's `run_id`.
A separate `ContextVar` is needed because pydantic_ai's own
cross-run signals (`RUN_ID_BAGGAGE_KEY` via OTel baggage,
`RunContext.run_id`, and `_CURRENT_RUN_CONTEXT`) are single-slot:
the inner `Instrumentation.wrap_run` overwrites them before any
nested capability sees the parent. The harness-local contextvar
lets us snapshot the parent here, *before* the local `wrap_run`
rebinds it.
"""
inferred_parent = self.parent_run_id if self.parent_run_id is not None else current_run_id.get()
resolved_run_id = self.run_id or self._derive_run_id(ctx)
if resolved_run_id == self.run_id and inferred_parent == self.parent_run_id:
return self
return replace(self, run_id=resolved_run_id, parent_run_id=inferred_parent)
def _derive_run_id(self, ctx: RunContext[AgentDepsT]) -> str:
if self.agent_name is not None:
return f'{self.agent_name}-{uuid4().hex[:8]}'
return ctx.run_id or str(uuid4())
def _effective_run_id(self, ctx: RunContext[AgentDepsT]) -> str:
if self.run_id is not None:
return self.run_id
return ctx.run_id or str(uuid4())
def _make_event(
self,
ctx: RunContext[AgentDepsT],
*,
kind: EventKind,
tool_call_id: str | None = None,
tool_name: str | None = None,
error: str | None = None,
) -> StepEvent:
return StepEvent(
run_id=self._effective_run_id(ctx),
kind=kind,
step_index=ctx.run_step,
conversation_id=ctx.conversation_id,
parent_run_id=self.parent_run_id,
agent_name=self.agent_name,
tool_call_id=tool_call_id,
tool_name=tool_name,
error=error,
metadata=dict(self.metadata),
)
async def wrap_run(
self,
ctx: RunContext[AgentDepsT],
*,
handler: WrapRunHandler,
) -> AgentRunResult[Any]:
"""Push this run's id onto the contextvar so nested delegates can read it."""
token = current_run_id.set(self._effective_run_id(ctx))
saved_token = snapshot_saved.set(False)
try:
return await handler()
finally:
snapshot_saved.reset(saved_token)
current_run_id.reset(token)
async def before_run(self, ctx: RunContext[AgentDepsT]) -> None:
"""Register run lineage and emit `run_started`.
When the caller pinned an explicit `run_id`, reject reuse -- the
tool-effect ledger keys on `(run_id, tool_call_id)` and providers
reuse deterministic tool-call ids, so a second `Agent.run` with
the same explicit `run_id` would silently collide. The auto-derived
cases cannot trigger this check because each call materialises a
fresh id in `for_run`.
"""
run_id = self._effective_run_id(ctx)
if self.run_id is not None and await self.store.get_run(run_id=run_id) is not None:
raise ValueError(
f'StepPersistence: run_id {run_id!r} is already in the store. '
'Explicit `run_id` is single-shot; pass `conversation_id=` to '
'`Agent.run` for multi-turn grouping instead.'
)
await self.store.register_run(
RunRecord(
run_id=run_id,
conversation_id=ctx.conversation_id,
parent_run_id=self.parent_run_id,
agent_name=self.agent_name,
metadata=dict(self.metadata),
)
)
await self.store.append_event(self._make_event(ctx, kind='run_started'))
async def after_run(
self,
ctx: RunContext[AgentDepsT],
*,
result: AgentRunResult[Any],
) -> AgentRunResult[Any]:
"""Emit `run_completed`, saving a final snapshot only as a fallback.
The terminal `CallToolsNode` already saved the final provider-valid
snapshot via `after_node_run`, carrying the correct `step_index`. By
`after_run`, `ctx.run_step` is reset to 0, so re-saving here would both
duplicate the tail and stamp a misleading `step_index`. We only save
when the run produced no snapshot at all (no provider-valid node
boundary was reached), as a last-resort capture of the final state.
"""
if not snapshot_saved.get():
messages = result.all_messages()
if is_provider_valid(messages):
await self.store.save_snapshot(
ContinuableSnapshot(
run_id=self._effective_run_id(ctx),
step_index=ctx.run_step,
messages=list(messages),
conversation_id=ctx.conversation_id,
parent_run_id=self.parent_run_id,
agent_name=self.agent_name,
)
)
await self.store.append_event(self._make_event(ctx, kind='run_completed'))
return result
async def on_run_error(
self,
ctx: RunContext[AgentDepsT],
*,
error: BaseException,
) -> AgentRunResult[Any]:
"""Emit `run_failed` so a killed run leaves a visible event trail."""
await self.store.append_event(self._make_event(ctx, kind='run_failed', error=repr(error)))
raise error
async def before_model_request(
self,
ctx: RunContext[AgentDepsT],
request_context: ModelRequestContext,
) -> ModelRequestContext:
await self.store.append_event(self._make_event(ctx, kind='model_request_started'))
return request_context
async def after_model_request(
self,
ctx: RunContext[AgentDepsT],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
await self.store.append_event(self._make_event(ctx, kind='model_request_completed'))
return response
async def on_model_request_error(
self,
ctx: RunContext[AgentDepsT],
*,
request_context: ModelRequestContext,
error: Exception,
) -> ModelResponse:
await self.store.append_event(self._make_event(ctx, kind='model_request_failed', error=repr(error)))
raise error
async def before_tool_execute(
self,
ctx: RunContext[AgentDepsT],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
) -> dict[str, Any]:
run_id = self._effective_run_id(ctx)
await self.store.record_tool_effect(
ToolEffectRecord(
tool_call_id=call.tool_call_id,
tool_name=tool_def.name,
run_id=run_id,
status='started',
)
)
await self.store.append_event(
self._make_event(
ctx,
kind='tool_call_started',
tool_call_id=call.tool_call_id,
tool_name=tool_def.name,
)
)
return args
async def after_tool_execute(
self,
ctx: RunContext[AgentDepsT],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
result: Any,
) -> Any:
run_id = self._effective_run_id(ctx)
prior = await self.store.get_tool_effect(run_id=run_id, tool_call_id=call.tool_call_id)
await self.store.record_tool_effect(
ToolEffectRecord(
tool_call_id=call.tool_call_id,
tool_name=tool_def.name,
run_id=run_id,
status='completed',
started_at=prior.started_at if prior is not None else datetime.now(timezone.utc),
ended_at=datetime.now(timezone.utc),
idempotency_key=prior.idempotency_key if prior is not None else None,
effect_summary=prior.effect_summary if prior is not None else None,
)
)
await self.store.append_event(
self._make_event(
ctx,
kind='tool_call_completed',
tool_call_id=call.tool_call_id,
tool_name=tool_def.name,
)
)
return result
async def on_tool_execute_error(
self,
ctx: RunContext[AgentDepsT],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
run_id = self._effective_run_id(ctx)
prior = await self.store.get_tool_effect(run_id=run_id, tool_call_id=call.tool_call_id)
prior_summary = prior.effect_summary if prior is not None else None
await self.store.record_tool_effect(
ToolEffectRecord(
tool_call_id=call.tool_call_id,
tool_name=tool_def.name,
run_id=run_id,
status='failed',
started_at=prior.started_at if prior is not None else datetime.now(timezone.utc),
ended_at=datetime.now(timezone.utc),
idempotency_key=prior.idempotency_key if prior is not None else None,
effect_summary=prior_summary if prior_summary is not None else repr(error),
)
)
await self.store.append_event(
self._make_event(
ctx,
kind='tool_call_failed',
tool_call_id=call.tool_call_id,
tool_name=tool_def.name,
error=repr(error),
)
)
raise error
async def after_node_run(
self,
ctx: RunContext[AgentDepsT],
*,
node: AgentNode[AgentDepsT],
result: NodeResult[AgentDepsT],
) -> NodeResult[AgentDepsT]:
"""Save a mid-run continuable snapshot after `CallToolsNode` succeeds.
At that boundary every tool call from the preceding `ModelRequestNode`
has a matching tool return, so the history is provider-valid.
Snapshots are filtered through `is_provider_valid` defensively in case
a custom node reshapes history.
"""
if isinstance(node, CallToolsNode):
messages = list(ctx.messages)
if is_provider_valid(messages):
await self.store.save_snapshot(
ContinuableSnapshot(
run_id=self._effective_run_id(ctx),
step_index=ctx.run_step,
messages=messages,
conversation_id=ctx.conversation_id,
parent_run_id=self.parent_run_id,
agent_name=self.agent_name,
)
)
snapshot_saved.set(True)
return result
@@ -0,0 +1,34 @@
"""Shared async-context state for `StepPersistence` cross-capability coordination."""
from __future__ import annotations
from contextvars import ContextVar
current_run_id: ContextVar[str | None] = ContextVar(
'pydantic_ai_harness.experimental.step_persistence.current_run_id',
default=None,
)
"""Async-context-local pointer to the active `StepPersistence` `run_id`.
Set by `StepPersistence.wrap_run` for the duration of a run; read by a
nested capability's `for_run` to auto-fill `parent_run_id`, and by
`annotate_tool_effect` to find the in-flight tool's run scope.
Module-level rather than a class attribute so the helpers in `_helpers.py`
and the capability in `_capability.py` can share it without a circular
import.
"""
snapshot_saved: ContextVar[bool] = ContextVar(
'pydantic_ai_harness.experimental.step_persistence.snapshot_saved',
default=False,
)
"""Async-context-local flag: did `after_node_run` already save a snapshot this run?
Set `False` in `wrap_run`, flipped `True` whenever `after_node_run` saves a
`CallToolsNode` snapshot. `after_run` reads it to skip a redundant terminal
snapshot -- the final `CallToolsNode` already captured the provider-valid tail
with the correct `step_index`, whereas `after_run` runs with `ctx.run_step`
reset to 0. Task-isolated like `current_run_id`, so concurrent runs don't
interfere.
"""
@@ -0,0 +1,119 @@
"""Helpers for continuation, forking, provider-validity checks, and tool-effect annotation."""
from __future__ import annotations
from typing import Any
from pydantic_ai.messages import (
ModelMessage,
ModelResponse,
RetryPromptPart,
ToolCallPart,
ToolReturnPart,
)
from pydantic_ai.tools import RunContext
from pydantic_ai_harness.experimental.step_persistence._context import current_run_id
from pydantic_ai_harness.experimental.step_persistence._store import StepStore
from pydantic_ai_harness.experimental.step_persistence._types import ToolEffectRecord
def is_provider_valid(messages: list[ModelMessage]) -> bool:
"""Return True when `messages` can be safely passed to `Agent.run(message_history=...)`.
A history is provider-valid when:
1. Every `ToolCallPart` has a matching `ToolReturnPart` or
tool-bound `RetryPromptPart` later in the conversation, and
2. Every tool return / tool-bound retry resolves a currently-open
tool call (no orphans, duplicates, or out-of-order returns).
A `RetryPromptPart` with `tool_name is None` is an output-validation
retry -- providers map it as a regular user message, not a tool result,
so it does not need to resolve an open call.
"""
open_calls: set[str] = set()
for msg in messages:
if isinstance(msg, ModelResponse):
for part in msg.parts:
if isinstance(part, ToolCallPart):
open_calls.add(part.tool_call_id)
else:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
if part.tool_call_id not in open_calls:
return False
open_calls.discard(part.tool_call_id)
elif isinstance(part, RetryPromptPart) and part.tool_name is not None:
if part.tool_call_id not in open_calls:
return False
open_calls.discard(part.tool_call_id)
return not open_calls
async def continue_run(store: StepStore, *, run_id: str) -> list[ModelMessage]:
"""Load the latest continuable snapshot for `run_id` as a message history.
Pass the return value to `Agent.run(message_history=...)` to continue
a delegate's prior investigation instead of starting fresh.
Raises `LookupError` if no continuable snapshot exists for `run_id` -- the
run may have crashed mid-tool-call, in which case there is event-log data
but no safe resume point.
"""
snapshot = await store.latest_snapshot(run_id=run_id)
if snapshot is None:
raise LookupError(f'no continuable snapshot for run_id {run_id!r}')
return list(snapshot.messages)
async def fork_run(store: StepStore, *, run_id: str) -> list[ModelMessage]:
"""Return a copy of the latest snapshot's messages, intended for a new logical run.
Semantically identical to `continue_run` at the data layer; the
distinction is in how the caller treats the returned history (new
`run_id`, new lineage entry, branching off prior context).
"""
return await continue_run(store, run_id=run_id)
async def annotate_tool_effect(
store: StepStore,
ctx: RunContext[Any],
*,
idempotency_key: str | None = None,
effect_summary: str | None = None,
) -> None:
"""Attach `idempotency_key` and / or `effect_summary` to the in-flight tool's effect record.
Call from inside a tool body when the tool writes external state
(artifacts, labels, PRs, network mutations) so an orchestrator
inspecting `list_unresolved_tool_effects` after a crash can tell
whether replay is safe.
Resolves the active run from the `StepPersistence` `ContextVar` and
the tool identity from `ctx.tool_call_id` / `ctx.tool_name`. No-op
when called outside a step-persistence-wrapped tool call. The
capability's `after_tool_execute` preserves these fields when it
writes the terminal `completed` / `failed` record.
"""
run_id = current_run_id.get()
tool_call_id = ctx.tool_call_id
tool_name = ctx.tool_name
if run_id is None or tool_call_id is None or tool_name is None:
return
prior = await store.get_tool_effect(run_id=run_id, tool_call_id=tool_call_id)
if prior is None:
return
await store.record_tool_effect(
ToolEffectRecord(
tool_call_id=tool_call_id,
tool_name=tool_name,
run_id=run_id,
status=prior.status,
started_at=prior.started_at,
ended_at=prior.ended_at,
idempotency_key=idempotency_key if idempotency_key is not None else prior.idempotency_key,
effect_summary=effect_summary if effect_summary is not None else prior.effect_summary,
)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
"""Data types for step-event persistence."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Literal
from pydantic_ai.messages import ModelMessage
EventKind = Literal[
'run_started',
'run_completed',
'run_failed',
'model_request_started',
'model_request_completed',
'model_request_failed',
'tool_call_started',
'tool_call_completed',
'tool_call_failed',
]
"""Boundary that produced a `StepEvent`.
Choose `kind` from this set so consumers can route on event type without
string typos. Append-only: never mutate an emitted event; record corrections
as a follow-up event.
"""
ToolEffectStatus = Literal['started', 'completed', 'failed']
"""Lifecycle status of a tool call recorded in the effect ledger.
A `tool_call_id` whose latest record is `started` was in flight when the
process last wrote. Treat it as `unknown_after_crash` when replaying -- the
external side effect may or may not have happened.
"""
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _empty_str_dict() -> dict[str, str]:
return {}
@dataclass(kw_only=True)
class StepEvent:
"""A single append-only event recorded during agent execution.
Events describe boundaries (run start/end, model request, tool call,
failure) but never carry recoverable state on their own. Pair with a
`ContinuableSnapshot` for resume; pair with `ToolEffectRecord` for
side-effect status.
`conversation_id` mirrors pydantic_ai's three-level identity stack --
conversation (the dialogue) → run (one `Agent.run` call) → step
(one graph node). Two `Agent.run` calls that share a
`conversation_id` produce two separate `run_id`s.
"""
run_id: str
kind: EventKind
step_index: int
timestamp: datetime = field(default_factory=_utcnow)
conversation_id: str | None = None
parent_run_id: str | None = None
agent_name: str | None = None
tool_call_id: str | None = None
tool_name: str | None = None
error: str | None = None
metadata: dict[str, str] = field(default_factory=_empty_str_dict)
@dataclass(kw_only=True)
class ContinuableSnapshot:
"""A provider-valid message-history snapshot safe to resume from.
Only emitted at boundaries where every `ToolCallPart` in the history
has a matching `ToolReturnPart` or `RetryPromptPart`. Pass `messages`
to `Agent.run(..., message_history=...)` to continue or fork the run.
"""
run_id: str
step_index: int
messages: list[ModelMessage]
conversation_id: str | None = None
parent_run_id: str | None = None
agent_name: str | None = None
timestamp: datetime = field(default_factory=_utcnow)
@dataclass(kw_only=True)
class ToolEffectRecord:
"""Ledger entry for a tool call's side-effect status.
Read-only tools and side-effectful tools share the same record shape;
the orchestrator decides whether replay is safe based on
`idempotency_key` and `effect_summary`. A record without a matching
`completed` or `failed` update after process restart should be treated
as `unknown_after_crash`.
"""
tool_call_id: str
tool_name: str
run_id: str
status: ToolEffectStatus
started_at: datetime = field(default_factory=_utcnow)
ended_at: datetime | None = None
idempotency_key: str | None = None
effect_summary: str | None = None
@dataclass(kw_only=True)
class RunRecord:
"""Lineage metadata for an agent run.
`conversation_id` groups runs of the same dialogue (the user-visible
sequence). `parent_run_id` is the hierarchical link: which run spawned
this one. The two are independent axes -- a delegate may share a
conversation across attempts (different `run_id`s, same `conversation_id`)
while pointing at a different orchestrator run via `parent_run_id`.
"""
run_id: str
conversation_id: str | None = None
parent_run_id: str | None = None
agent_name: str | None = None
metadata: dict[str, str] = field(default_factory=_empty_str_dict)
started_at: datetime = field(default_factory=_utcnow)
@@ -1,5 +1,24 @@
# SubAgents
> [!WARNING]
> **Experimental.** This capability lives under `pydantic_ai_harness.experimental` and may
> change or be removed in any release, without a deprecation period. Import it from the
> experimental path -- there is no top-level export:
>
> ```python
> from pydantic_ai_harness.experimental.subagents import SubAgents
> ```
>
> Importing any experimental capability emits a `HarnessExperimentalWarning`. Silence **all**
> harness experimental warnings with a single filter (no per-capability lines needed):
>
> ```python
> import warnings
> from pydantic_ai_harness.experimental import HarnessExperimentalWarning
>
> warnings.filterwarnings('ignore', category=HarnessExperimentalWarning)
> ```
Let an agent delegate self-contained tasks to named child agents.
## The problem
+2
View File
@@ -28,6 +28,7 @@ classifiers = [
'Typing :: Typed',
]
dependencies = [
"httpx>=0.28.1",
'pydantic-ai-slim>=1.95.1',
]
@@ -71,6 +72,7 @@ dev = [
'inline-snapshot>=0.32.5',
'pydantic-ai-slim[spec]>=1.95.1',
"pytest-examples>=0.0.18",
"pytest-recording>=0.13.4",
]
lint = [
'ruff>=0.14',
@@ -0,0 +1,90 @@
interactions:
- request:
body: pydantic-ai-harness step-persistence VCR cassette payload v1
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '60'
User-Agent:
- python-httpx/0.28.1
authorization:
- REDACTED
content-type:
- application/octet-stream
host:
- account.r2.cloudflarestorage.com
x-amz-content-sha256:
- 1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1
x-amz-date:
- REDACTED
method: PUT
uri: https://account.r2.cloudflarestorage.com/harness-test-bucket/harness-vcr/1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1.bin
response:
body:
string: ''
headers:
Connection:
- keep-alive
Content-Length:
- '0'
Content-Type:
- text/plain;charset=UTF-8
Date:
- Mon, 25 May 2026 04:00:29 GMT
ETag:
- '"076102a313b5c40b44923280cc2c04c3"'
Server:
- cloudflare
status:
code: 200
message: OK
- request:
body: ''
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-httpx/0.28.1
authorization:
- REDACTED
host:
- account.r2.cloudflarestorage.com
x-amz-content-sha256:
- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-date:
- REDACTED
method: HEAD
uri: https://account.r2.cloudflarestorage.com/harness-test-bucket/harness-vcr/1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1.bin
response:
body:
string: ''
headers:
Accept-Ranges:
- bytes
Connection:
- keep-alive
Content-Length:
- '60'
Content-Type:
- application/octet-stream
Date:
- Mon, 25 May 2026 04:00:29 GMT
ETag:
- '"076102a313b5c40b44923280cc2c04c3"'
Last-Modified:
- Mon, 25 May 2026 04:00:29 GMT
Server:
- cloudflare
status:
code: 200
message: OK
version: 1
@@ -0,0 +1,38 @@
interactions:
- request:
body: ''
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-httpx/0.28.1
authorization:
- REDACTED
host:
- account.r2.cloudflarestorage.com
x-amz-content-sha256:
- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-date:
- REDACTED
method: HEAD
uri: https://account.r2.cloudflarestorage.com/harness-test-bucket/harness-vcr/0000000000000000000000000000000000000000000000000000000000000000.bin
response:
body:
string: ''
headers:
Connection:
- keep-alive
Content-Type:
- text/plain;charset=UTF-8
Date:
- Mon, 25 May 2026 04:00:29 GMT
Server:
- cloudflare
status:
code: 404
message: Not Found
version: 1
@@ -0,0 +1,38 @@
interactions:
- request:
body: ''
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-httpx/0.28.1
authorization:
- REDACTED
host:
- account.r2.cloudflarestorage.com
x-amz-content-sha256:
- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-date:
- REDACTED
method: GET
uri: https://account.r2.cloudflarestorage.com/harness-test-bucket/harness-vcr/0000000000000000000000000000000000000000000000000000000000000000.bin
response:
body:
string: ''
headers:
Connection:
- keep-alive
Content-Type:
- application/xml
Date:
- Mon, 25 May 2026 04:00:31 GMT
Server:
- cloudflare
status:
code: 404
message: Not Found
version: 1
@@ -0,0 +1,90 @@
interactions:
- request:
body: pydantic-ai-harness step-persistence VCR cassette payload v1
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '60'
User-Agent:
- python-httpx/0.28.1
authorization:
- REDACTED
content-type:
- application/octet-stream
host:
- account.r2.cloudflarestorage.com
x-amz-content-sha256:
- 1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1
x-amz-date:
- REDACTED
method: PUT
uri: https://account.r2.cloudflarestorage.com/harness-test-bucket/harness-vcr/1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1.bin
response:
body:
string: ''
headers:
Connection:
- keep-alive
Content-Length:
- '0'
Content-Type:
- text/plain;charset=UTF-8
Date:
- Mon, 25 May 2026 04:00:30 GMT
ETag:
- '"076102a313b5c40b44923280cc2c04c3"'
Server:
- cloudflare
status:
code: 200
message: OK
- request:
body: ''
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-httpx/0.28.1
authorization:
- REDACTED
host:
- account.r2.cloudflarestorage.com
x-amz-content-sha256:
- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-date:
- REDACTED
method: GET
uri: https://account.r2.cloudflarestorage.com/harness-test-bucket/harness-vcr/1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1.bin
response:
body:
string: pydantic-ai-harness step-persistence VCR cassette payload v1
headers:
Accept-Ranges:
- bytes
Connection:
- keep-alive
Content-Length:
- '60'
Content-Type:
- application/octet-stream
Date:
- Mon, 25 May 2026 04:00:30 GMT
ETag:
- '"076102a313b5c40b44923280cc2c04c3"'
Last-Modified:
- Mon, 25 May 2026 04:00:30 GMT
Server:
- cloudflare
status:
code: 200
message: OK
version: 1
@@ -0,0 +1,46 @@
interactions:
- request:
body: pydantic-ai-harness step-persistence VCR cassette payload v1
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '60'
User-Agent:
- python-httpx/0.28.1
authorization:
- REDACTED
content-type:
- application/octet-stream
host:
- account.r2.cloudflarestorage.com
x-amz-content-sha256:
- 1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1
x-amz-date:
- REDACTED
method: PUT
uri: https://account.r2.cloudflarestorage.com/harness-test-bucket/harness-vcr/1743d339a10eaa072801e8bf3eca688d6a339cb26b9354194f15c131727274f1.bin
response:
body:
string: ''
headers:
Connection:
- keep-alive
Content-Length:
- '0'
Content-Type:
- text/plain;charset=UTF-8
Date:
- Mon, 25 May 2026 04:00:28 GMT
ETag:
- '"076102a313b5c40b44923280cc2c04c3"'
Server:
- cloudflare
status:
code: 200
message: OK
version: 1
+206
View File
@@ -0,0 +1,206 @@
"""VCR configuration for `S3MediaStore` cassette tests.
The cassettes are committed alongside the tests so CI can replay them
without R2 / AWS credentials. Recording is opt-in via
`pytest --record-mode=once` (or `=new_episodes`/`=all`) with real
`S3_*` env vars; replay is the default mode and what CI runs.
Sanitisation policy: every recorded cassette is rewritten on disk to
swap the real R2 account-id subdomain and bucket name for fixed
placeholders, and drop the `Authorization` header. The test setup uses
the placeholder endpoint + bucket so request matching still succeeds on
replay. See `_rewrite_request` / `_rewrite_response` below.
"""
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false
from __future__ import annotations
import os
import re
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from vcr.request import Request as VcrRequest # pyright: ignore[reportMissingTypeStubs]
# Public placeholders baked into the committed cassettes. Tests pass
# these *exact* values when constructing `S3MediaStore`, so the replay
# URI matches the recorded URI even though both were sanitised.
SANITIZED_HOST = 'account.r2.cloudflarestorage.com'
SANITIZED_BUCKET = 'harness-test-bucket'
SANITIZED_ENDPOINT = f'https://{SANITIZED_HOST}'
SANITIZED_REGION = 'auto'
def _real_account_host_pattern() -> re.Pattern[str] | None: # pragma: no cover
"""Build a regex that matches the real R2 host so we can scrub it."""
endpoint = os.environ.get('S3_ENDPOINT')
if not endpoint:
return None
match = re.match(r'https?://([^/]+)', endpoint)
if not match:
return None
return re.compile(re.escape(match.group(1)))
def _real_bucket_pattern() -> re.Pattern[str] | None: # pragma: no cover
bucket = os.environ.get('S3_BUCKET_NAME')
if not bucket:
return None
return re.compile(r'/' + re.escape(bucket) + r'/')
def _rewrite_request(request: VcrRequest) -> VcrRequest: # pragma: no cover
"""Strip account-id, bucket name, and credentials from recorded request."""
host_pat = _real_account_host_pattern()
if host_pat is not None:
request.uri = host_pat.sub(SANITIZED_HOST, request.uri)
bucket_pat = _real_bucket_pattern()
if bucket_pat is not None:
request.uri = bucket_pat.sub(f'/{SANITIZED_BUCKET}/', request.uri)
# VCR already drops Authorization via `filter_headers`, but Host is set
# by httpx independently — overwrite it so cassettes never carry the
# real account subdomain.
if 'host' in request.headers:
request.headers['host'] = SANITIZED_HOST
return request
_DROP_RESPONSE_HEADERS = frozenset(
{
'cf-ray',
'cf-cache-status',
'x-amz-version-id',
'x-amz-request-id',
'x-amz-id-2',
'x-amz-checksum-crc64nvme',
'x-amz-checksum-crc32',
'x-amz-checksum-crc32c',
'x-amz-checksum-sha1',
'x-amz-checksum-sha256',
}
)
def _rewrite_response(response: dict[str, Any]) -> dict[str, Any]: # pragma: no cover
"""Sanitise the response: drop noisy / identifying headers and any error body.
For non-2xx responses (typically the gzipped XML R2/AWS error envelope,
which can mention the bucket) we blank the body entirely and strip
`Content-Encoding`. Our `S3MediaStore` only inspects `status_code` for
4xx and `response.text[:200]` for 5xx — no test in this module relies
on the error body shape.
"""
host_pat = _real_account_host_pattern()
bucket_pat = _real_bucket_pattern()
headers = response.get('headers', {})
for header_name in list(headers.keys()):
if header_name.lower() in _DROP_RESPONSE_HEADERS:
del headers[header_name]
continue
values = headers[header_name]
if not isinstance(values, list):
continue
new_values: list[str] = []
for v in values:
if not isinstance(v, str):
new_values.append(v)
continue
if host_pat is not None:
v = host_pat.sub(SANITIZED_HOST, v)
if bucket_pat is not None:
v = bucket_pat.sub(f'/{SANITIZED_BUCKET}/', v)
new_values.append(v)
headers[header_name] = new_values
status = response.get('status', {})
code = status.get('code') if isinstance(status, dict) else None
is_success = isinstance(code, int) and 200 <= code < 300
body = response.get('body', {})
if not is_success and isinstance(body, dict):
# Drop any provider error envelope — it can name the bucket inside
# the gzipped XML. Tests only read the status code on this path.
body['string'] = b''
for header_name in list(headers.keys()):
if header_name.lower() in ('content-encoding', 'content-length', 'transfer-encoding'):
del headers[header_name]
return response
@pytest.fixture(scope='module')
def vcr_config() -> dict[str, Any]:
"""Per-module VCR configuration. Cassettes live next to the tests.
Matching: method + scheme + host + path + body. Headers (including
SigV4 `authorization` and `x-amz-date`) are NOT part of matching —
they regenerate per replay and would otherwise miss every time.
Record mode is whatever `--record-mode` says (default `none`).
"""
return {
'filter_headers': [
('authorization', 'REDACTED'),
('x-amz-date', 'REDACTED'),
],
'before_record_request': _rewrite_request,
'before_record_response': _rewrite_response,
'match_on': ['method', 'scheme', 'host', 'path', 'body'],
}
@pytest.fixture
def anyio_backend() -> str:
"""Restrict the S3 cassette tests to asyncio — we don't need trio cassettes."""
return 'asyncio'
@pytest.fixture
def s3_credentials() -> dict[str, str]:
"""Real R2 creds when env is set; sanitised placeholders otherwise.
The placeholders match the values baked into the scrubbed cassettes
(see `_rewrite_request` / `_rewrite_response` above), so replay works
against `tests/media/cassettes/` with no env vars at all — exactly what
CI runs.
**Why the placeholders double as a leakage canary:** if the scrubber
ever misses a value when re-recording, the cassette will contain the
real bucket / account id while the replay test still constructs URLs
from the placeholder constants — the URL matcher will fail and CI
will surface the leak. Reusing this pattern across the suite (always
pass placeholder values, scrub on write) catches accidental
credential / private-data exposure in committed cassettes.
`region` is hardcoded to `'auto'` because R2 rejects every other name
and the SigV4 region is part of the credential scope (filtered from
the cassette `Authorization`, so it does not affect replay matching).
Override the fixture in another conftest if recording against AWS S3.
"""
return {
'bucket': os.environ.get('S3_BUCKET_NAME', SANITIZED_BUCKET),
'endpoint': os.environ.get('S3_ENDPOINT', SANITIZED_ENDPOINT),
'region': 'auto',
'access_key_id': os.environ.get('S3_ACCESS_KEY_ID', 'AKIAIOSFODNN7EXAMPLE'),
'secret_access_key': os.environ.get('S3_SECRET_ACCESS_KEY', 'REDACTED-FOR-REPLAY'),
}
@pytest.fixture
def s3_store(s3_credentials: dict[str, str]) -> Any:
"""`S3MediaStore` built from the credentials fixture, with a fixed key prefix.
The key prefix is part of the URL path that lands in the cassette, so
keep it stable across re-records.
"""
from pydantic_ai_harness.experimental.media import S3MediaStore
return S3MediaStore(
bucket=s3_credentials['bucket'],
endpoint=s3_credentials['endpoint'],
region=s3_credentials['region'],
access_key_id=s3_credentials['access_key_id'],
secret_access_key=s3_credentials['secret_access_key'],
key_prefix='harness-vcr/',
)
+821
View File
@@ -0,0 +1,821 @@
"""Tests for `pydantic_ai_harness.experimental.media`: stores + walker + SigV4 + S3 store."""
from __future__ import annotations
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
import pytest
from httpx import AsyncClient, MockTransport, Request, Response
from pydantic_ai_harness.experimental.media import (
DiskMediaStore,
MediaContext,
MediaStore,
S3MediaStore,
SqliteMediaStore,
externalize_media,
make_static_public_url,
media_uri_for,
parse_media_uri,
restore_media,
)
from pydantic_ai_harness.experimental.media._s3 import sign_request
pytestmark = pytest.mark.anyio
class TestMediaUriHelpers:
def test_media_uri_for_returns_canonical_scheme(self) -> None:
uri = media_uri_for(b'hello world')
assert uri == 'media+sha256://b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'
def test_parse_media_uri_strips_scheme(self) -> None:
uri = media_uri_for(b'abc')
digest = parse_media_uri(uri)
assert len(digest) == 64
assert all(c in '0123456789abcdef' for c in digest)
def test_parse_media_uri_rejects_other_schemes(self) -> None:
with pytest.raises(ValueError, match='not a media URI'):
parse_media_uri('http://example.com/foo')
def test_parse_media_uri_rejects_short_digest(self) -> None:
with pytest.raises(ValueError, match='invalid sha256 digest'):
parse_media_uri('media+sha256://deadbeef')
def test_parse_media_uri_rejects_uppercase_hex(self) -> None:
with pytest.raises(ValueError, match='invalid sha256 digest'):
parse_media_uri('media+sha256://' + 'A' * 64)
class TestDiskMediaStore:
async def test_put_get_round_trip(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri = await store.put(b'hello bytes', context=MediaContext(media_type='application/octet-stream'))
assert uri.startswith('media+sha256://')
assert await store.get(uri) == b'hello bytes'
async def test_put_with_empty_context_works(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri = await store.put(b'no context')
assert await store.get(uri) == b'no context'
async def test_dedup_on_repeated_put(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri1 = await store.put(b'same content')
uri2 = await store.put(b'same content')
assert uri1 == uri2
files = list(tmp_path.glob('*.bin'))
assert len(files) == 1
async def test_exists(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri = await store.put(b'present')
assert await store.exists(uri) is True
missing_uri = media_uri_for(b'never put')
assert await store.exists(missing_uri) is False
async def test_get_missing_raises(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
with pytest.raises(FileNotFoundError):
await store.get(media_uri_for(b'never put'))
async def test_custom_key_strategy_controls_path(self, tmp_path: Path) -> None:
def strategy(uri: str, ctx: MediaContext) -> str:
return f'images/{parse_media_uri(uri)}.png'
store = DiskMediaStore(tmp_path, key_strategy=strategy)
uri = await store.put(b'pixels')
assert (tmp_path / 'images' / f'{parse_media_uri(uri)}.png').exists()
assert await store.get(uri) == b'pixels'
async def test_key_strategy_blocks_traversal(self, tmp_path: Path) -> None:
def evil(uri: str, ctx: MediaContext) -> str:
return '../../../etc/passwd'
store = DiskMediaStore(tmp_path, key_strategy=evil)
with pytest.raises(ValueError, match='traversal-unsafe'):
await store.put(b'attack')
async def test_key_strategy_blocks_absolute_path(self, tmp_path: Path) -> None:
# `Path('/root') / '/abs'` silently returns `/abs` — without this check
# an absolute key escapes the store directory.
def evil(uri: str, ctx: MediaContext) -> str:
return '/etc/passwd'
store = DiskMediaStore(tmp_path, key_strategy=evil)
with pytest.raises(ValueError, match='traversal-unsafe'):
await store.put(b'attack')
async def test_metadata_round_trips_via_sidecar(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri = await store.put(
b'tagged',
context=MediaContext(metadata={'origin': 'user', 'tenant': 'acme'}),
)
sidecars = list(tmp_path.glob('*.meta.json'))
assert len(sidecars) == 1
assert await store.get_metadata(uri) == {'origin': 'user', 'tenant': 'acme'}
async def test_metadata_absent_when_not_supplied(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri = await store.put(b'no tags')
assert list(tmp_path.glob('*.meta.json')) == []
assert await store.get_metadata(uri) == {}
async def test_get_metadata_rejects_non_object_sidecar(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri = await store.put(b'with meta', context=MediaContext(metadata={'k': 'v'}))
sidecar = next(iter(tmp_path.glob('*.meta.json')))
sidecar.write_text('"not an object"')
with pytest.raises(ValueError, match='must be a JSON object'):
await store.get_metadata(uri)
async def test_get_metadata_rejects_non_string_values(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
uri = await store.put(b'with meta', context=MediaContext(metadata={'k': 'v'}))
sidecar = next(iter(tmp_path.glob('*.meta.json')))
sidecar.write_text('{"k": 1}')
with pytest.raises(ValueError, match='must be str→str'):
await store.get_metadata(uri)
class TestSqliteMediaStore:
async def test_put_get_round_trip(self, tmp_path: Path) -> None:
store = SqliteMediaStore(database=tmp_path / 'media.db')
uri = await store.put(b'hello bytes', context=MediaContext(media_type='image/png'))
assert await store.get(uri) == b'hello bytes'
async def test_put_persists_metadata_column(self, tmp_path: Path) -> None:
store = SqliteMediaStore(database=tmp_path / 'media.db')
uri = await store.put(
b'tagged',
context=MediaContext(media_type='image/png', metadata={'origin': 'user', 'tenant': 'acme'}),
)
conn = sqlite3.connect(tmp_path / 'media.db', check_same_thread=False)
try:
row = conn.execute('SELECT media_type, metadata FROM media').fetchone()
finally:
conn.close()
assert row[0] == 'image/png'
assert await store.get_metadata(uri) == {'origin': 'user', 'tenant': 'acme'}
async def test_get_metadata_missing_raises(self, tmp_path: Path) -> None:
store = SqliteMediaStore(database=tmp_path / 'media.db')
with pytest.raises(FileNotFoundError):
await store.get_metadata(media_uri_for(b'never put'))
async def test_with_shared_connection(self) -> None:
connection = sqlite3.connect(':memory:', check_same_thread=False)
try:
store = SqliteMediaStore(connection=connection)
uri = await store.put(b'shared conn data')
assert await store.get(uri) == b'shared conn data'
assert await store.exists(uri) is True
finally:
connection.close()
async def test_dedup_via_insert_or_ignore(self, tmp_path: Path) -> None:
store = SqliteMediaStore(database=tmp_path / 'm.db')
uri = ''
for _ in range(3):
uri = await store.put(b'duplicated')
assert await store.get(uri) == b'duplicated'
async def test_get_missing_raises(self, tmp_path: Path) -> None:
store = SqliteMediaStore(database=tmp_path / 'm.db')
with pytest.raises(FileNotFoundError):
await store.get(media_uri_for(b'missing'))
def test_requires_exactly_one_of_database_or_connection(self) -> None:
with pytest.raises(ValueError, match='exactly one'):
SqliteMediaStore()
conn = sqlite3.connect(':memory:')
try:
with pytest.raises(ValueError, match='exactly one'):
SqliteMediaStore(database='x', connection=conn)
finally:
conn.close()
def test_rejects_invalid_table_name(self) -> None:
with pytest.raises(ValueError, match='invalid table name'):
SqliteMediaStore(database='x.db', table='bad-name')
class TestExternalizeRestoreWalker:
async def test_round_trip_with_inline_binary(self, tmp_path: Path) -> None:
import base64
import json as _json
store = DiskMediaStore(tmp_path)
big_payload = b'\x00' * 70_000
b64_payload = base64.b64encode(big_payload).decode('ascii')
node: object = {
'parts': [
{
'kind': 'binary',
'data': b64_payload,
'media_type': 'image/png',
'identifier': 'abc',
'vendor_metadata': None,
}
]
}
externalized = await externalize_media(node, media_store=store, threshold_bytes=64 * 1024)
externalized_text = _json.dumps(externalized)
assert '__harness_external_media__' in externalized_text
assert 'media+sha256://' in externalized_text
assert b64_payload not in externalized_text # bytes really went external
restored = await restore_media(externalized, media_store=store)
restored_text = _json.dumps(restored)
assert '"kind": "binary"' in restored_text
assert b64_payload in restored_text # bytes restored exactly
async def test_threshold_boundary_keeps_small_inline(self, tmp_path: Path) -> None:
import base64
store = DiskMediaStore(tmp_path)
small_payload = b'\x42' * 32
node = {
'kind': 'binary',
'data': base64.b64encode(small_payload).decode('ascii'),
'media_type': 'text/plain',
'identifier': 's',
'vendor_metadata': None,
}
externalized = await externalize_media(node, media_store=store, threshold_bytes=64 * 1024)
assert externalized == node
assert list(tmp_path.glob('*.bin')) == []
async def test_restore_raises_when_marker_missing_uri(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
bad_node = {'__harness_external_media__': True, 'media_type': 'image/png'}
with pytest.raises(ValueError, match='missing string uri'):
await restore_media(bad_node, media_store=store)
async def test_round_trip_preserves_unknown_binary_fields(self, tmp_path: Path) -> None:
"""A field the walker doesn't know about survives externalize -> restore."""
import base64
import json as _json
store = DiskMediaStore(tmp_path)
big = base64.b64encode(b'\x01' * 70_000).decode('ascii')
node = {
'kind': 'binary',
'data': big,
'media_type': 'image/png',
'identifier': 'abc',
'vendor_metadata': {'x': 1},
'future_field': 'keep-me',
}
externalized = await externalize_media(node, media_store=store, threshold_bytes=64 * 1024)
assert '"data"' not in _json.dumps(externalized) # bytes field went external
restored = await restore_media(externalized, media_store=store)
assert restored == node
async def test_restore_raises_when_blob_is_missing(self, tmp_path: Path) -> None:
"""A well-formed marker whose blob was pruned surfaces the store's error."""
store = DiskMediaStore(tmp_path)
marker = {
'__harness_external_media__': True,
'uri': 'media+sha256://' + '0' * 64,
'kind': 'binary',
'media_type': 'image/png',
}
with pytest.raises(FileNotFoundError):
await restore_media(marker, media_store=store)
async def test_walker_passes_through_scalars(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
for value in [None, 1, 'foo', True]:
assert await externalize_media(value, media_store=store, threshold_bytes=1) == value
assert await restore_media(value, media_store=store) == value
class TestSigV4Signer:
def test_produces_required_headers(self) -> None:
headers = sign_request(
method='PUT',
host='examplebucket.s3.amazonaws.com',
path='/my-key',
body=b'payload',
region='us-east-1',
access_key_id='AKIAIOSFODNN7EXAMPLE',
secret_access_key='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
content_type='image/png',
now=datetime(2013, 5, 24, tzinfo=timezone.utc),
)
assert headers['host'] == 'examplebucket.s3.amazonaws.com'
assert headers['x-amz-date'] == '20130524T000000Z'
assert headers['x-amz-content-sha256'] == ('239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5')
assert headers['content-type'] == 'image/png'
auth = headers['authorization']
assert auth.startswith('AWS4-HMAC-SHA256 ')
assert 'Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request' in auth
assert 'SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date' in auth
# Signature is deterministic for fixed inputs — locks down the algorithm.
assert 'Signature=' in auth
def test_signature_is_deterministic(self) -> None:
common = dict(
method='GET',
host='bucket.s3.amazonaws.com',
path='/key',
body=b'',
region='us-east-1',
access_key_id='K',
secret_access_key='S',
content_type=None,
now=datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
)
a = sign_request(**common) # type: ignore[arg-type]
b = sign_request(**common) # type: ignore[arg-type]
assert a == b
def test_signature_changes_with_body(self) -> None:
kwargs = dict(
method='PUT',
host='bucket.s3.amazonaws.com',
path='/key',
region='us-east-1',
access_key_id='K',
secret_access_key='S',
content_type=None,
now=datetime(2024, 1, 1, tzinfo=timezone.utc),
)
sig_a = sign_request(body=b'aaa', **kwargs)['authorization'] # type: ignore[arg-type]
sig_b = sign_request(body=b'bbb', **kwargs)['authorization'] # type: ignore[arg-type]
assert sig_a != sig_b
class TestS3MediaStoreWithMockTransport:
async def test_put_signs_request(self) -> None:
captured: list[Request] = []
async def handler(request: Request) -> Response:
captured.append(request)
return Response(200)
transport = MockTransport(handler)
async with AsyncClient(transport=transport) as client:
store = S3MediaStore(
bucket='my-bucket',
endpoint='https://s3.us-east-1.amazonaws.com',
region='us-east-1',
access_key_id='AKIA-FAKE',
secret_access_key='secret-fake',
client=client,
)
uri = await store.put(b'payload', context=MediaContext(media_type='image/png'))
assert uri.startswith('media+sha256://')
assert len(captured) == 1
request = captured[0]
assert request.method == 'PUT'
assert 'authorization' in request.headers
assert request.headers['authorization'].startswith('AWS4-HMAC-SHA256 ')
assert request.headers['x-amz-content-sha256']
assert request.headers['content-type'] == 'image/png'
async def test_wire_path_matches_signed_path_for_reserved_chars(self) -> None:
"""A key_prefix with reserved chars must be percent-encoded identically on the wire.
Otherwise the signed canonical path and the path S3 receives diverge ->
SignatureDoesNotMatch. The wire `raw_path` must equal `_canonical_uri(path)`.
"""
from pydantic_ai_harness.experimental.media._s3 import _canonical_uri # pyright: ignore[reportPrivateUsage]
captured: list[Request] = []
async def handler(request: Request) -> Response:
captured.append(request)
return Response(200)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='my-bucket',
endpoint='https://acct.r2.cloudflarestorage.com',
region='auto',
access_key_id='k',
secret_access_key='s',
key_prefix='runs(2024)/tenant@acme/',
client=client,
)
uri = await store.put(b'payload')
expected_path = _canonical_uri(store._object_path(uri, MediaContext())) # pyright: ignore[reportPrivateUsage]
assert '%28' in expected_path and '%40' in expected_path # ( and @ encoded
assert captured[0].url.raw_path.decode('ascii') == expected_path
async def test_put_propagates_metadata_as_signed_x_amz_meta_headers(self) -> None:
captured: list[Request] = []
async def handler(request: Request) -> Response:
captured.append(request)
return Response(200)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
await store.put(
b'meta-tagged',
context=MediaContext(
media_type='image/png',
metadata={'origin': 'pipeline-a', 'tenant': 'acme'},
),
)
request = captured[0]
assert request.headers.get('x-amz-meta-origin') == 'pipeline-a'
assert request.headers.get('x-amz-meta-tenant') == 'acme'
# Metadata headers MUST be in SignedHeaders to be valid SigV4.
signed = request.headers['authorization'].split('SignedHeaders=')[1].split(',')[0]
assert 'x-amz-meta-origin' in signed
assert 'x-amz-meta-tenant' in signed
async def test_put_rejects_invalid_metadata_key(self) -> None:
async def handler(request: Request) -> Response: # pragma: no cover
return Response(200) # never reached — validation raises pre-request
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
with pytest.raises(ValueError, match='not a valid HTTP header token'):
await store.put(
b'data',
context=MediaContext(metadata={'bad key with space': 'v'}),
)
async def test_get_resolves_uri(self) -> None:
async def handler(request: Request) -> Response:
return Response(200, content=b'fetched bytes')
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
data = await store.get(media_uri_for(b'fetched bytes'))
assert data == b'fetched bytes'
async def test_get_404_raises(self) -> None:
async def handler(request: Request) -> Response:
return Response(404)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
with pytest.raises(FileNotFoundError):
await store.get(media_uri_for(b'missing'))
async def test_exists_uses_head(self) -> None:
captured_methods: list[str] = []
async def handler(request: Request) -> Response:
captured_methods.append(request.method)
return Response(200)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
assert await store.exists(media_uri_for(b'anything')) is True
assert captured_methods == ['HEAD']
async def test_exists_returns_false_on_404(self) -> None:
async def handler(request: Request) -> Response:
return Response(404)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
assert await store.exists(media_uri_for(b'missing')) is False
async def test_put_failure_raises(self) -> None:
async def handler(request: Request) -> Response:
return Response(500, content=b'internal error')
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
with pytest.raises(RuntimeError, match='S3 PUT failed'):
await store.put(b'data')
async def test_get_failure_raises(self) -> None:
async def handler(request: Request) -> Response:
return Response(500, content=b'oops')
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
with pytest.raises(RuntimeError, match='S3 GET failed'):
await store.get(media_uri_for(b'x'))
async def test_get_metadata_reads_x_amz_meta_headers(self) -> None:
async def handler(request: Request) -> Response:
return Response(
200,
headers={
'x-amz-meta-origin': 'pipeline-a',
'x-amz-meta-tenant': 'acme',
'content-type': 'image/png',
'content-length': '0',
},
)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
meta = await store.get_metadata(media_uri_for(b'x'))
assert meta == {'origin': 'pipeline-a', 'tenant': 'acme'}
async def test_get_metadata_404_raises(self) -> None:
async def handler(request: Request) -> Response:
return Response(404)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
with pytest.raises(FileNotFoundError):
await store.get_metadata(media_uri_for(b'missing'))
async def test_get_metadata_500_raises(self) -> None:
async def handler(request: Request) -> Response:
return Response(500, content=b'oops')
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
with pytest.raises(RuntimeError, match='S3 HEAD failed'):
await store.get_metadata(media_uri_for(b'x'))
async def test_head_failure_raises(self) -> None:
async def handler(request: Request) -> Response:
return Response(500)
async with AsyncClient(transport=MockTransport(handler)) as client:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
client=client,
)
with pytest.raises(RuntimeError, match='S3 HEAD failed'):
await store.exists(media_uri_for(b'x'))
async def test_no_client_branch_opens_one_per_call(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without `client=`, the store opens a fresh `httpx.AsyncClient` per call."""
import httpx
captured: list[Request] = []
async def handler(request: Request) -> Response:
captured.append(request)
return Response(200)
original_async_client = httpx.AsyncClient
def patched_client(*args: object, **kwargs: object) -> httpx.AsyncClient:
return original_async_client(transport=MockTransport(handler))
monkeypatch.setattr(httpx, 'AsyncClient', patched_client)
store = S3MediaStore(
bucket='my-bucket',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
key_prefix='runs/',
)
await store.put(b'no-client data')
assert len(captured) == 1
sample_uri = media_uri_for(b'sample')
digest = parse_media_uri(sample_uri)
path = store._object_path(sample_uri, MediaContext()) # type: ignore[reportPrivateUsage]
assert path == f'/my-bucket/runs/{digest}.bin'
@pytest.mark.skipif( # pragma: no cover
not all(
os.environ.get(k)
for k in ('S3_ENDPOINT', 'S3_BUCKET_NAME', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', 'S3_REGION')
),
reason='live S3/R2 env vars not set',
)
class TestS3MediaStoreLive: # pragma: no cover
"""Live integration against the configured S3 endpoint (e.g. R2).
Activated only when all five S3_* env vars are set. Reads creds out of
the environment — the test harness inherits them when invoked with
`~/.claude/scripts/env-run .env -- make test`.
"""
async def test_round_trip_against_live_endpoint(self) -> None:
bucket = os.environ['S3_BUCKET_NAME']
store = S3MediaStore(
bucket=bucket,
endpoint=os.environ['S3_ENDPOINT'],
region=os.environ['S3_REGION'],
access_key_id=os.environ['S3_ACCESS_KEY_ID'],
secret_access_key=os.environ['S3_SECRET_ACCESS_KEY'],
key_prefix='harness-test/',
)
payload = b'pydantic-ai-harness step-persistence live test ' + os.urandom(32)
ctx = MediaContext(media_type='application/octet-stream')
uri = await store.put(payload, context=ctx)
assert await store.exists(uri) is True
fetched = await store.get(uri)
assert fetched == payload
class TestPublicUrl:
"""Verify `public_url` resolves through the user-supplied callable.
Static prefix, async callable, and the absence of a resolver all go
through the same `MediaStore.public_url(uri)` shape — the future
`MediaExternalizer` capability uses this to swap `BinaryContent` for
URL message parts before the model sees them.
"""
async def test_disk_store_without_resolver_returns_none(self, tmp_path: Path) -> None:
store = DiskMediaStore(tmp_path)
assert await store.public_url(media_uri_for(b'x')) is None
async def test_sqlite_store_without_resolver_returns_none(self, tmp_path: Path) -> None:
store = SqliteMediaStore(database=tmp_path / 'm.db')
assert await store.public_url(media_uri_for(b'x')) is None
async def test_s3_store_without_resolver_returns_none(self) -> None:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
)
assert await store.public_url(media_uri_for(b'x')) is None
async def test_sync_callable_resolver(self, tmp_path: Path) -> None:
store = DiskMediaStore(
tmp_path,
public_url=lambda uri, ctx: f'https://cdn.example.com/{parse_media_uri(uri)}.bin',
)
uri = media_uri_for(b'payload')
result = await store.public_url(uri)
assert result == f'https://cdn.example.com/{parse_media_uri(uri)}.bin'
async def test_async_callable_resolver(self, tmp_path: Path) -> None:
async def signer(uri: str, ctx: MediaContext) -> str:
return f'https://signed.example.com/{parse_media_uri(uri)}?sig=abc'
store = DiskMediaStore(tmp_path, public_url=signer)
result = await store.public_url(media_uri_for(b'payload'))
assert result is not None
assert result.startswith('https://signed.example.com/')
assert '?sig=abc' in result
async def test_resolver_receives_media_context(self, tmp_path: Path) -> None:
"""The resolver sees the full `MediaContext` so it can vary by media type, metadata, etc."""
seen: list[MediaContext] = []
def resolver(uri: str, ctx: MediaContext) -> str:
seen.append(ctx)
return 'https://example.com/x'
store = DiskMediaStore(tmp_path, public_url=resolver)
await store.public_url(
media_uri_for(b'p'),
context=MediaContext(media_type='image/png', metadata={'tag': 'v'}),
)
assert seen[0].media_type == 'image/png'
assert dict(seen[0].metadata) == {'tag': 'v'}
async def test_resolver_can_return_none(self, tmp_path: Path) -> None:
"""Resolvers may opt out per-URI (e.g. small payloads keep inline)."""
store = DiskMediaStore(tmp_path, public_url=lambda uri, ctx: None)
assert await store.public_url(media_uri_for(b'x')) is None
async def test_make_static_public_url_helper(self, tmp_path: Path) -> None:
resolver = make_static_public_url('https://pub-abc.r2.dev', key_prefix='media/', extension='.bin')
store = DiskMediaStore(tmp_path, public_url=resolver)
uri = media_uri_for(b'payload')
digest = parse_media_uri(uri)
assert await store.public_url(uri) == f'https://pub-abc.r2.dev/media/{digest}.bin'
async def test_make_static_public_url_strips_trailing_slash(self, tmp_path: Path) -> None:
resolver = make_static_public_url('https://cdn.example.com/')
store = DiskMediaStore(tmp_path, public_url=resolver)
digest = parse_media_uri(media_uri_for(b'p'))
assert await store.public_url(media_uri_for(b'p')) == f'https://cdn.example.com/{digest}.bin'
async def test_s3_with_resolver_uses_it(self) -> None:
store = S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
key_prefix='media/',
public_url=make_static_public_url('https://pub-xyz.r2.dev', key_prefix='media/'),
)
uri = media_uri_for(b'p')
digest = parse_media_uri(uri)
assert await store.public_url(uri) == f'https://pub-xyz.r2.dev/media/{digest}.bin'
async def test_sqlite_with_resolver_uses_it(self, tmp_path: Path) -> None:
store = SqliteMediaStore(
database=tmp_path / 'm.db',
public_url=make_static_public_url('https://cdn.example.com'),
)
uri = media_uri_for(b'p')
digest = parse_media_uri(uri)
assert await store.public_url(uri) == f'https://cdn.example.com/{digest}.bin'
def _assert_media_store_protocol(store: MediaStore) -> None:
"""Mypy/pyright check: every concrete store satisfies the protocol."""
assert isinstance(store, MediaStore)
def test_concrete_stores_satisfy_protocol(tmp_path: Path) -> None:
_assert_media_store_protocol(DiskMediaStore(tmp_path))
_assert_media_store_protocol(SqliteMediaStore(database=tmp_path / 'm.db'))
_assert_media_store_protocol(
S3MediaStore(
bucket='b',
endpoint='https://example.com',
region='auto',
access_key_id='k',
secret_access_key='s',
)
)
@@ -0,0 +1,63 @@
"""VCR cassette tests for `S3MediaStore` against a real R2 endpoint.
The cassettes under `tests/media/cassettes/` were recorded against a
Cloudflare R2 bucket and then sanitised (account id, bucket name,
`Authorization`, `x-amz-date`, identifying response headers, error
bodies) by hooks in `conftest.py::vcr_config`. CI replays them — no
creds needed.
To re-record (or add new cases):
```
~/.claude/scripts/env-run .env -- uv run pytest \\
tests/media/test_s3_cassettes.py \\
--record-mode=once
```
Recording reads real R2 creds from the `s3_credentials` fixture; replay
falls back to the sanitised placeholders that match the committed
cassettes. Drift between real values and placeholders (e.g. scrubber
misses a field) shows up as a replay-time URL mismatch — the canary.
"""
from __future__ import annotations
import pytest
from pydantic_ai_harness.experimental.media import MediaContext, S3MediaStore, media_uri_for
pytestmark = pytest.mark.anyio
# Deterministic payload so the URI / object key are stable across re-records.
_PAYLOAD = b'pydantic-ai-harness step-persistence VCR cassette payload v1'
_MISSING_URI = 'media+sha256://' + ('0' * 64)
_CONTEXT = MediaContext(media_type='application/octet-stream')
class TestS3MediaStoreCassettes:
"""Replay-driven verification of `S3MediaStore` against a real R2 server."""
@pytest.mark.vcr
async def test_put_succeeds(self, s3_store: S3MediaStore) -> None:
uri = await s3_store.put(_PAYLOAD, context=_CONTEXT)
assert uri == media_uri_for(_PAYLOAD)
@pytest.mark.vcr
async def test_exists_present_after_put(self, s3_store: S3MediaStore) -> None:
await s3_store.put(_PAYLOAD, context=_CONTEXT)
assert await s3_store.exists(media_uri_for(_PAYLOAD)) is True
@pytest.mark.vcr
async def test_exists_returns_false_for_missing(self, s3_store: S3MediaStore) -> None:
assert await s3_store.exists(_MISSING_URI) is False
@pytest.mark.vcr
async def test_get_round_trips_bytes(self, s3_store: S3MediaStore) -> None:
await s3_store.put(_PAYLOAD, context=_CONTEXT)
fetched = await s3_store.get(media_uri_for(_PAYLOAD))
assert fetched == _PAYLOAD
@pytest.mark.vcr
async def test_get_missing_raises(self, s3_store: S3MediaStore) -> None:
with pytest.raises(FileNotFoundError):
await s3_store.get(_MISSING_URI)
@@ -0,0 +1,578 @@
"""Tests for `SqliteStepStore` and the media externalization paths.
Mirrors selected coverage from `test_step_persistence.py::TestFileStepStore`
for the SQLite backend, plus end-to-end media-externalization round-trips
through `FileStepStore` and `SqliteStepStore`. The full hook-level coverage
already lives in `test_step_persistence.py`; this module focuses on the
SQLite-specific behavior and the media plumbing.
"""
from __future__ import annotations
import base64
import sqlite3
from pathlib import Path
import pytest
from pydantic_ai import Agent
from pydantic_ai.messages import (
BinaryContent,
ModelMessage,
ModelRequest,
ModelResponse,
TextPart,
UserPromptPart,
)
from pydantic_ai.models.test import TestModel
from pydantic_ai_harness.experimental.media import DiskMediaStore, SqliteMediaStore
from pydantic_ai_harness.experimental.step_persistence import (
ContinuableSnapshot,
FileStepStore,
RunRecord,
SqliteStepStore,
StepEvent,
StepPersistence,
ToolEffectRecord,
)
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return 'asyncio'
def _sample_messages_with_media(payload_size: int) -> list[ModelMessage]:
big = b'\xab' * payload_size
return [
ModelRequest(
parts=[
UserPromptPart(
content=[
'analyze this',
BinaryContent(data=big, media_type='image/png'),
]
)
]
),
ModelResponse(parts=[TextPart(content='done')]),
]
# ---------------------------------------------------------------------------
# SqliteStepStore — direct protocol exercises
# ---------------------------------------------------------------------------
class TestSqliteStepStoreProtocol:
async def test_register_and_get_run(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
record = RunRecord(run_id='r1', conversation_id='c1', agent_name='agent', metadata={'k': 'v'})
await store.register_run(record)
fetched = await store.get_run(run_id='r1')
assert fetched is not None
assert fetched.run_id == 'r1'
assert fetched.conversation_id == 'c1'
assert fetched.metadata == {'k': 'v'}
async def test_register_duplicate_run_raises(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
await store.register_run(RunRecord(run_id='r1'))
with pytest.raises(sqlite3.IntegrityError):
await store.register_run(RunRecord(run_id='r1'))
async def test_list_runs_chronological(self, tmp_path: Path) -> None:
from datetime import datetime, timedelta, timezone
store = SqliteStepStore(database=tmp_path / 'runs.db')
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
await store.register_run(RunRecord(run_id='r3', started_at=base + timedelta(seconds=3)))
await store.register_run(RunRecord(run_id='r1', started_at=base + timedelta(seconds=1)))
await store.register_run(RunRecord(run_id='r2', started_at=base + timedelta(seconds=2)))
records = await store.list_runs()
assert [r.run_id for r in records] == ['r1', 'r2', 'r3']
async def test_list_runs_filters(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
await store.register_run(RunRecord(run_id='r1', conversation_id='a', parent_run_id='p'))
await store.register_run(RunRecord(run_id='r2', conversation_id='a', parent_run_id='q'))
await store.register_run(RunRecord(run_id='r3', conversation_id='b', parent_run_id='p'))
by_conv = await store.list_runs(conversation_id='a')
assert {r.run_id for r in by_conv} == {'r1', 'r2'}
by_parent = await store.list_runs(parent_run_id='p')
assert {r.run_id for r in by_parent} == {'r1', 'r3'}
both = await store.list_runs(parent_run_id='p', conversation_id='a')
assert [r.run_id for r in both] == ['r1']
async def test_append_and_list_events(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
await store.register_run(RunRecord(run_id='r1'))
await store.append_event(StepEvent(run_id='r1', kind='run_started', step_index=0))
await store.append_event(
StepEvent(
run_id='r1',
kind='tool_call_started',
step_index=1,
tool_call_id='t1',
tool_name='add',
metadata={'k': 'v'},
)
)
events = await store.list_events(run_id='r1')
assert [e.kind for e in events] == ['run_started', 'tool_call_started']
assert events[1].tool_call_id == 't1'
assert events[1].metadata == {'k': 'v'}
async def test_save_and_load_snapshot(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db', media_store=None)
await store.register_run(RunRecord(run_id='r1'))
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='hello')]),
ModelResponse(parts=[TextPart(content='hi back')]),
]
await store.save_snapshot(ContinuableSnapshot(run_id='r1', step_index=2, messages=messages))
snap = await store.latest_snapshot(run_id='r1')
assert snap is not None
assert snap.step_index == 2
assert len(snap.messages) == 2
async def test_snapshot_seq_monotonic_across_reset_step(self, tmp_path: Path) -> None:
"""A reused `run_id` with `step_index` reset to 0 must not clobber the prior snapshot.
Mirrors `FileStepStore._next_snapshot_seq` — SQLite uses
`AUTOINCREMENT seq` for the same guarantee.
"""
store = SqliteStepStore(database=tmp_path / 'runs.db', media_store=None)
await store.register_run(RunRecord(run_id='r1'))
msgs: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content='a')])]
await store.save_snapshot(ContinuableSnapshot(run_id='r1', step_index=5, messages=msgs))
await store.save_snapshot(ContinuableSnapshot(run_id='r1', step_index=0, messages=msgs))
snap = await store.latest_snapshot(run_id='r1')
assert snap is not None
assert snap.step_index == 0 # the *last* write wins, not the highest step_index
async def test_tool_effect_upsert_per_call_id(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
await store.record_tool_effect(
ToolEffectRecord(tool_call_id='t1', tool_name='add', run_id='r1', status='started')
)
await store.record_tool_effect(
ToolEffectRecord(
tool_call_id='t1',
tool_name='add',
run_id='r1',
status='completed',
effect_summary='ok',
)
)
effect = await store.get_tool_effect(run_id='r1', tool_call_id='t1')
assert effect is not None
assert effect.status == 'completed'
assert effect.effect_summary == 'ok'
async def test_tool_effect_scoped_by_run(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
await store.record_tool_effect(
ToolEffectRecord(tool_call_id='t1', tool_name='add', run_id='r1', status='started')
)
await store.record_tool_effect(
ToolEffectRecord(tool_call_id='t1', tool_name='add', run_id='r2', status='completed')
)
a = await store.get_tool_effect(run_id='r1', tool_call_id='t1')
b = await store.get_tool_effect(run_id='r2', tool_call_id='t1')
assert a is not None and a.status == 'started'
assert b is not None and b.status == 'completed'
async def test_list_unresolved_tool_effects(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
await store.record_tool_effect(
ToolEffectRecord(tool_call_id='t1', tool_name='add', run_id='r1', status='started')
)
await store.record_tool_effect(
ToolEffectRecord(tool_call_id='t2', tool_name='mul', run_id='r1', status='completed')
)
unresolved = await store.list_unresolved_tool_effects(run_id='r1')
assert [r.tool_call_id for r in unresolved] == ['t1']
async def test_missing_lookups_return_none_or_empty(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
assert await store.get_run(run_id='nope') is None
assert await store.latest_snapshot(run_id='nope') is None
assert await store.get_tool_effect(run_id='nope', tool_call_id='x') is None
assert await store.list_events(run_id='nope') == []
assert await store.list_unresolved_tool_effects(run_id='nope') == []
assert await store.list_runs() == []
async def test_shared_connection_mode(self, tmp_path: Path) -> None:
conn = sqlite3.connect(tmp_path / 'runs.db', check_same_thread=False)
try:
store = SqliteStepStore(connection=conn, media_store=None)
await store.register_run(RunRecord(run_id='r1'))
await store.append_event(StepEvent(run_id='r1', kind='run_started', step_index=0))
assert (await store.get_run(run_id='r1')) is not None
assert len(await store.list_events(run_id='r1')) == 1
finally:
conn.close()
async def test_shared_connection_default_media_store_uses_same_connection(self, tmp_path: Path) -> None:
"""`media_store='auto'` + `connection=` paths a SqliteMediaStore at the same conn."""
conn = sqlite3.connect(tmp_path / 'runs.db', check_same_thread=False)
try:
store = SqliteStepStore(connection=conn)
await store.register_run(RunRecord(run_id='r1'))
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=0, messages=_sample_messages_with_media(70_000))
)
(count,) = conn.execute('SELECT COUNT(*) FROM media').fetchone()
assert count == 1
finally:
conn.close()
def test_rejects_both_database_and_connection(self) -> None:
with pytest.raises(ValueError, match='exactly one'):
SqliteStepStore()
conn = sqlite3.connect(':memory:')
try:
with pytest.raises(ValueError, match='exactly one'):
SqliteStepStore(database='x', connection=conn)
finally:
conn.close()
# ---------------------------------------------------------------------------
# Media integration: FileStepStore + DiskMediaStore (default)
# ---------------------------------------------------------------------------
class TestFileStepStoreMedia:
async def test_large_binary_externalized_to_media_dir(self, tmp_path: Path) -> None:
"""Default FileStepStore wires a `DiskMediaStore(<root>/media/)`.
Large `BinaryContent` payloads end up as `<root>/media/<sha256>.bin`,
not inlined in the snapshot JSON.
"""
store = FileStepStore(tmp_path / 'runs', media_threshold_bytes=64 * 1024)
await store.register_run(RunRecord(run_id='r1'))
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=0, messages=_sample_messages_with_media(70_000))
)
media_dir = tmp_path / 'runs' / 'media'
assert media_dir.is_dir()
blobs = list(media_dir.glob('*.bin'))
assert len(blobs) == 1
assert blobs[0].stat().st_size == 70_000
# Snapshot JSON references the URI, not the inline base64.
snap_files = list((tmp_path / 'runs' / 'r1' / 'snapshots').glob('*.json'))
assert len(snap_files) == 1
snap_text = snap_files[0].read_text(encoding='utf-8')
assert '__harness_external_media__' in snap_text
assert base64.b64encode(b'\xab' * 70_000).decode('ascii') not in snap_text
async def test_round_trip_restores_original_bytes(self, tmp_path: Path) -> None:
store = FileStepStore(tmp_path / 'runs')
await store.register_run(RunRecord(run_id='r1'))
payload = b'\xcd' * 80_000
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content=[BinaryContent(data=payload, media_type='image/jpeg')])]),
ModelResponse(parts=[TextPart(content='ok')]),
]
await store.save_snapshot(ContinuableSnapshot(run_id='r1', step_index=0, messages=messages))
snap = await store.latest_snapshot(run_id='r1')
assert snap is not None
first = snap.messages[0]
assert isinstance(first, ModelRequest)
prompt = first.parts[0]
assert isinstance(prompt, UserPromptPart)
assert isinstance(prompt.content, list)
binary = prompt.content[0]
assert isinstance(binary, BinaryContent)
assert binary.data == payload
assert binary.media_type == 'image/jpeg'
async def test_below_threshold_stays_inline(self, tmp_path: Path) -> None:
store = FileStepStore(tmp_path / 'runs', media_threshold_bytes=64 * 1024)
await store.register_run(RunRecord(run_id='r1'))
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=0, messages=_sample_messages_with_media(1_024))
)
media_dir = tmp_path / 'runs' / 'media'
assert not media_dir.exists() or list(media_dir.glob('*.bin')) == []
async def test_opt_out_keeps_bytes_inline(self, tmp_path: Path) -> None:
store = FileStepStore(tmp_path / 'runs', media_store=None)
await store.register_run(RunRecord(run_id='r1'))
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=0, messages=_sample_messages_with_media(70_000))
)
assert not (tmp_path / 'runs' / 'media').exists()
snap_text = next((tmp_path / 'runs' / 'r1' / 'snapshots').glob('*.json')).read_text(encoding='utf-8')
assert '__harness_external_media__' not in snap_text
# Loading also works with media_store=None — no restore_media walk.
snap = await store.latest_snapshot(run_id='r1')
assert snap is not None
async def test_explicit_media_store_override(self, tmp_path: Path) -> None:
custom_root = tmp_path / 'shared-media'
store = FileStepStore(
tmp_path / 'runs',
media_store=DiskMediaStore(custom_root),
)
await store.register_run(RunRecord(run_id='r1'))
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=0, messages=_sample_messages_with_media(70_000))
)
# External bytes live under the override, not under <root>/media/
assert list(custom_root.glob('*.bin'))
assert not (tmp_path / 'runs' / 'media').exists()
# ---------------------------------------------------------------------------
# Media integration: SqliteStepStore + same-DB SqliteMediaStore (default)
# ---------------------------------------------------------------------------
class TestSqliteStepStoreMedia:
async def test_large_binary_stored_in_same_db(self, tmp_path: Path) -> None:
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db)
await store.register_run(RunRecord(run_id='r1'))
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=0, messages=_sample_messages_with_media(70_000))
)
conn = sqlite3.connect(db, check_same_thread=False)
try:
row = conn.execute('SELECT COUNT(*), SUM(size_bytes) FROM media').fetchone()
assert row[0] == 1
assert row[1] == 70_000
# No sibling DB file or media directory.
assert not (tmp_path / 'media').exists()
finally:
conn.close()
async def test_round_trip_restores_bytes(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'runs.db')
await store.register_run(RunRecord(run_id='r1'))
payload = b'\xef' * 70_000
messages: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content=[BinaryContent(data=payload, media_type='image/png')])]),
ModelResponse(parts=[TextPart(content='done')]),
]
await store.save_snapshot(ContinuableSnapshot(run_id='r1', step_index=0, messages=messages))
snap = await store.latest_snapshot(run_id='r1')
assert snap is not None
first = snap.messages[0]
assert isinstance(first, ModelRequest)
prompt = first.parts[0]
assert isinstance(prompt, UserPromptPart)
assert isinstance(prompt.content, list)
binary = prompt.content[0]
assert isinstance(binary, BinaryContent)
assert binary.data == payload
async def test_dedup_across_snapshots(self, tmp_path: Path) -> None:
"""The same payload appearing in two snapshots ends up as one row."""
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db)
await store.register_run(RunRecord(run_id='r1'))
for step in range(3):
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=step, messages=_sample_messages_with_media(70_000))
)
conn = sqlite3.connect(db, check_same_thread=False)
try:
(count,) = conn.execute('SELECT COUNT(*) FROM media').fetchone()
assert count == 1
finally:
conn.close()
async def test_external_media_store_override(self, tmp_path: Path) -> None:
"""SqliteStepStore can be paired with a non-sqlite media store (e.g. disk)."""
disk = tmp_path / 'media'
store = SqliteStepStore(database=tmp_path / 'runs.db', media_store=DiskMediaStore(disk))
await store.register_run(RunRecord(run_id='r1'))
await store.save_snapshot(
ContinuableSnapshot(run_id='r1', step_index=0, messages=_sample_messages_with_media(70_000))
)
assert len(list(disk.glob('*.bin'))) == 1
# The `media` table is never created in the same DB because we routed
# blobs to a disk store; the StepStore schema doesn't include it.
conn = sqlite3.connect(tmp_path / 'runs.db', check_same_thread=False)
try:
row = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='media'").fetchone()
assert row is None
finally:
conn.close()
# ---------------------------------------------------------------------------
# End-to-end: Agent + StepPersistence + media externalization
# ---------------------------------------------------------------------------
class TestEndToEndMediaWithAgent:
async def test_agent_run_with_large_binary_input(self, tmp_path: Path) -> None:
"""An Agent run carrying BinaryContent in its prompt round-trips through SP."""
store = FileStepStore(tmp_path / 'runs')
agent: Agent[None, str] = Agent(
TestModel(),
capabilities=[StepPersistence(store=store, agent_name='vision')],
)
big = b'\xab' * 100_000
result = await agent.run(
[
'classify this image',
BinaryContent(data=big, media_type='image/png'),
]
)
# Sanity: agent produced something.
assert isinstance(result.output, str)
runs = await store.list_runs()
assert len(runs) == 1
run_id = runs[0].run_id
# Snapshot exists, references external media.
snap = await store.latest_snapshot(run_id=run_id)
assert snap is not None
first = snap.messages[0]
assert isinstance(first, ModelRequest)
prompt = first.parts[0]
assert isinstance(prompt, UserPromptPart)
assert isinstance(prompt.content, list)
binary = next(p for p in prompt.content if isinstance(p, BinaryContent))
assert binary.data == big
# On disk: one media blob, no inlined base64 in any snapshot json.
blobs = list((tmp_path / 'runs' / 'media').glob('*.bin'))
assert len(blobs) == 1
# ---------------------------------------------------------------------------
# Auto / opt-out / explicit media_store semantics on both stores
# ---------------------------------------------------------------------------
class TestSqliteRowDefenses:
"""Exercise the defensive `isinstance`/`None` guards in the row decoders.
These guards never trigger via the public API — they catch poke-by-hand
corruption of the DB. Hitting them explicitly is the cleanest way to
document the contract and the only way to satisfy 100% coverage.
"""
def _open(self, db: Path) -> sqlite3.Connection:
return sqlite3.connect(db, check_same_thread=False, isolation_level=None)
async def test_corrupted_run_row_raises(self, tmp_path: Path) -> None:
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db, media_store=None)
await store.register_run(RunRecord(run_id='r1'))
conn = self._open(db)
try:
# TEXT affinity coerces numbers to strings, but leaves BLOBs alone —
# binding `bytes` is the cleanest way to violate `isinstance(v, str)`.
conn.execute('UPDATE runs SET metadata = ? WHERE run_id = ?', (b'\x00bin', 'r1'))
finally:
conn.close()
with pytest.raises(ValueError, match='run row has wrong types'):
await store.get_run(run_id='r1')
async def test_corrupted_event_row_wrong_type_raises(self, tmp_path: Path) -> None:
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db, media_store=None)
await store.register_run(RunRecord(run_id='r1'))
await store.append_event(StepEvent(run_id='r1', kind='run_started', step_index=0))
conn = self._open(db)
try:
conn.execute('UPDATE events SET metadata = ? WHERE run_id = ?', (b'\x00bin', 'r1'))
finally:
conn.close()
with pytest.raises(ValueError, match='event row has wrong types'):
await store.list_events(run_id='r1')
async def test_corrupted_event_kind_raises(self, tmp_path: Path) -> None:
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db, media_store=None)
await store.register_run(RunRecord(run_id='r1'))
await store.append_event(StepEvent(run_id='r1', kind='run_started', step_index=0))
conn = self._open(db)
try:
conn.execute('UPDATE events SET kind = ? WHERE run_id = ?', ('fictional', 'r1'))
finally:
conn.close()
with pytest.raises(ValueError, match='unknown event kind'):
await store.list_events(run_id='r1')
async def test_corrupted_tool_effect_wrong_type_raises(self, tmp_path: Path) -> None:
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db, media_store=None)
await store.record_tool_effect(
ToolEffectRecord(tool_call_id='t1', tool_name='add', run_id='r1', status='started')
)
conn = self._open(db)
try:
conn.execute('UPDATE tool_effects SET tool_name = ? WHERE tool_call_id = ?', (b'\x00bin', 't1'))
finally:
conn.close()
with pytest.raises(ValueError, match='tool effect row has wrong types'):
await store.get_tool_effect(run_id='r1', tool_call_id='t1')
async def test_corrupted_tool_effect_status_raises(self, tmp_path: Path) -> None:
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db, media_store=None)
await store.record_tool_effect(
ToolEffectRecord(tool_call_id='t1', tool_name='add', run_id='r1', status='started')
)
conn = self._open(db)
try:
conn.execute('UPDATE tool_effects SET status = ? WHERE tool_call_id = ?', ('exploded', 't1'))
finally:
conn.close()
with pytest.raises(ValueError, match='unknown tool effect status'):
await store.get_tool_effect(run_id='r1', tool_call_id='t1')
async def test_corrupted_snapshot_row_raises(self, tmp_path: Path) -> None:
db = tmp_path / 'runs.db'
store = SqliteStepStore(database=db, media_store=None)
await store.register_run(RunRecord(run_id='r1'))
msgs: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content='x')])]
await store.save_snapshot(ContinuableSnapshot(run_id='r1', step_index=0, messages=msgs))
conn = self._open(db)
try:
conn.execute('UPDATE snapshots SET timestamp = ? WHERE run_id = ?', (b'\x00bin', 'r1'))
finally:
conn.close()
with pytest.raises(ValueError, match='snapshot row has wrong types'):
await store.latest_snapshot(run_id='r1')
class TestMediaStoreResolution:
def test_file_store_auto_creates_disk_media_store(self, tmp_path: Path) -> None:
store = FileStepStore(tmp_path / 'runs')
assert isinstance(store._media_store, DiskMediaStore) # type: ignore[reportPrivateUsage]
def test_file_store_none_disables_media(self, tmp_path: Path) -> None:
store = FileStepStore(tmp_path / 'runs', media_store=None)
assert store._media_store is None # type: ignore[reportPrivateUsage]
def test_sqlite_store_auto_uses_same_db(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'r.db')
assert isinstance(store._media_store, SqliteMediaStore) # type: ignore[reportPrivateUsage]
def test_sqlite_store_none_disables_media(self, tmp_path: Path) -> None:
store = SqliteStepStore(database=tmp_path / 'r.db', media_store=None)
assert store._media_store is None # type: ignore[reportPrivateUsage]
File diff suppressed because it is too large Load Diff
+6
View File
@@ -33,3 +33,9 @@ class TestExperimentalWarning:
module = importlib.import_module('pydantic_ai_harness.experimental.compaction')
with pytest.warns(HarnessExperimentalWarning):
importlib.reload(module)
@pytest.mark.parametrize('feature', ['step_persistence', 'media'])
def test_importing_step_persistence_or_media_warns(self, feature: str):
module = importlib.import_module(f'pydantic_ai_harness.experimental.{feature}')
with pytest.warns(HarnessExperimentalWarning, match=feature):
importlib.reload(module)
Generated
+30
View File
@@ -978,6 +978,7 @@ wheels = [
name = "pydantic-ai-harness"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "pydantic-ai-slim" },
]
@@ -1011,6 +1012,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-anyio" },
{ name = "pytest-examples" },
{ name = "pytest-recording" },
]
lint = [
{ name = "pyright" },
@@ -1019,6 +1021,7 @@ lint = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "logfire", marker = "extra == 'logfire'", specifier = ">=4.31.0" },
{ name = "pydantic-ai-slim", specifier = ">=1.95.1" },
{ name = "pydantic-ai-slim", extras = ["dbos"], marker = "extra == 'dbos'" },
@@ -1041,6 +1044,7 @@ dev = [
{ name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-anyio" },
{ name = "pytest-examples", specifier = ">=0.0.18" },
{ name = "pytest-recording", specifier = ">=0.13.4" },
]
lint = [
{ name = "pyright", specifier = ">=1.1.408" },
@@ -1359,6 +1363,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/09/52/7bbfb6e987d9a8a945f22941a8da63e3529465f1b106ef0e26f5df7c780d/pytest_examples-0.0.18-py3-none-any.whl", hash = "sha256:86c195b98c4e55049a0df3a0a990ca89123b7280473ab57608eecc6c47bcfe9c", size = 18169, upload-time = "2025-05-06T07:46:09.349Z" },
]
[[package]]
name = "pytest-recording"
version = "0.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "vcrpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/9c/f4027c5f1693847b06d11caf4b4f6bb09f22c1581ada4663877ec166b8c6/pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c", size = 26576, upload-time = "2025-05-08T10:41:11.231Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/c2/ce34735972cc42d912173e79f200fe66530225190c06655c5632a9d88f1e/pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439", size = 13723, upload-time = "2025-05-08T10:41:09.684Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -1790,6 +1807,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "vcrpy"
version = "8.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/07/bcfd5ebd7cb308026ab78a353e091bd699593358be49197d39d004e5ad83/vcrpy-8.1.1.tar.gz", hash = "sha256:58e3053e33b423f3594031cb758c3f4d1df931307f1e67928e30cf352df7709f", size = 85770, upload-time = "2026-01-04T19:22:03.886Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/d7/f79b05a5d728f8786876a7d75dfb0c5cae27e428081b2d60152fb52f155f/vcrpy-8.1.1-py3-none-any.whl", hash = "sha256:2d16f31ad56493efb6165182dd99767207031b0da3f68b18f975545ede8ac4b9", size = 42445, upload-time = "2026-01-04T19:22:02.532Z" },
]
[[package]]
name = "websockets"
version = "16.0"