edri.integrations.pydantic_ai package¶
Submodules¶
edri.integrations.pydantic_ai.agent module¶
Agent-facing half of the Pydantic AI bridge.
AgentDeps and ToolResponse are the contract between an agent's
tools and the worker hosting them; AgentBase is what an application
subclasses to own domain behaviour without touching runtime concerns. The worker
side lives in edri.integrations.pydantic_ai.worker.
- class edri.integrations.pydantic_ai.agent.AgentBase(*, model_name: str, name: str | None = None)[source]¶
Bases:
ABC,GenericBase class for EDRI-hosted Pydantic AI agents.
An agent owns only its domain behavior: model/provider construction (
build_agent()), prompt, output configuration and domain methods. Runtime concerns (the EDRI pipe, shared resources) arrive asAgentDeps, which the worker bakes in viabind_deps()after construction. Tools reach them throughRunContext[AgentDeps], so domain methods stay clean and never expose deps in their signatures.- bind_deps(deps: AgentDeps[Any]) AgentBase[DepsT, OutputT][source]¶
Bake runtime dependencies into the agent (called once by the worker).
Returns
selfto allow fluent chaining.
- abstractmethod build_agent() Agent[DepsT, OutputT][source]¶
Build the underlying Pydantic AI agent.
Implementations construct the model/provider and return a configured
Agent. Passdeps_type=AgentDeps(and your capabilities) so tools can resolve runtime dependencies.
- capabilities: ClassVar[list[Any]] = []¶
Stateless, shareable capability/tool bundles. Tools read runtime values from
RunContext[AgentDeps]; capabilities themselves hold no per-run state.
- classmethod default_name() str[source]¶
Derive a stable snake_case agent name from the class name.
A trailing
Agentis stripped:ClaimDebateJudgeAgent->claim_debate_judge.
- framework: str = 'pydantic_ai'¶
- model_version: str | None = None¶
- output_mode: str = 'unknown'¶
- prompt_version: str | None = None¶
- provider: str = 'unknown'¶
- version: str | None = None¶
- class edri.integrations.pydantic_ai.agent.AgentDeps(dispatch: DispatchFn, resources: ResourcesT | None = None)[source]¶
Bases:
GenericRuntime dependencies injected into an agent by its worker.
Pydantic AI tools receive this as
RunContext[AgentDeps].deps. Agents never construct or inspect it — the worker builds one and bakes it in viaAgentBase.bind_deps().- Attributes:
dispatch -- Sends a request Event to the EDRI router and returns the tool result extracted from the response. Implemented by the worker (
BaseAgentWorker._dispatch_event()); blocking for the caller, but concurrent dispatches from the same worker don't block each other. This is the only path from a tool back into the rest of the system. Passtimeout=to override the 30s default for a given call, e.g.ctx.deps.dispatch(SlowLookup(...), timeout=5.0).resources -- Optional worker-level shared state (in-memory stores, DB handles, caches). Parametrize
AgentDeps[MyResources]for typing.
- resources: ResourcesT | None = None¶
- class edri.integrations.pydantic_ai.agent.DispatchFn(*args, **kwargs)[source]¶
Bases:
ProtocolCallable shape of
AgentDeps.dispatch.timeoutis keyword-only so each call site (i.e. each tool) can override the default wait for its own dispatched event — a tool known to hit a slow downstream Worker can afford a longer wait; one that should fail fast can ask for less than the 30s default.
- class edri.integrations.pydantic_ai.agent.ToolResponse[source]¶
Bases:
Response,ABCBase for responses that an agent tool consumes via
dispatch().A tool calls
deps.dispatch(SomeRequest())and gets back whatever the reply's response exposes astool_result. Subclass this on the responses your tool-target events use: the response keeps its own typed fields for normal callers and additionally declares which value a tool receives — so the worker needs no extraction hook.tool_resultis a property, not a field, so it never appears in the serialized payload; implement it to return an existing field (or a derived, JSON-friendly view of several).- abstract property tool_result: Any¶
The value handed to the dispatching agent tool.
edri.integrations.pydantic_ai.inspector module¶
The inspector manager: a standard EDRI manager that watches agent runs.
It consumes AgentAction
through ordinary routing and answers queries about what it has seen. An
application includes it like any other component; the environment switch
decides whether workers emit actions, this manager decides whether EDRI also
offers the standard browsing experience over them.
What it deliberately does not do is own anything on an agent's path. It holds no agent workers, no Pydantic AI objects and no queue a run waits on, so an inspector that is slow, wedged or entirely absent cannot slow a run down or fail one. The worst it can do to an agent is forget what the agent did.
- class edri.integrations.pydantic_ai.inspector.AgentInspectorManager(router_queue: Any | None = None, logger: Logger | None = None, from_time: datetime | None = None, max_workers: int = -1, *, limits: StoreLimits | None = None, log_actions: bool = False, publish_updates: bool = True, clock: Callable[[], datetime] | None = None)[source]¶
Bases:
ManagerBaseBounded, in-memory history of observed agent activity.
limitsis the application's retention choice;log_actionsturns on a concise human-readable line per action, off by default because an application that wanted the query API did not necessarily ask for its log to double in size.publish_updatesis on because live inspection is what the manager is for, and the broker sends an update only to clients whose subscription filter matches it -- no browser is fanned the whole stream. What it does cost is one router message per observed action. An application that wants the query API and nothing else turns it off and pays nothing.- resolve(event: Event) None[source]¶
Resolve one event, then publish what the store noticed doing it.
Retention by age has no arrival to hang off: nothing reaches an idle store, so a run that timed out is discovered by whichever read ran next -- a query, or even a refused one. Draining here rather than in each handler means no read can quietly swallow an invalidation a subscriber is waiting for.
- solve_req_get_action(event: AgentInspectorAction) None¶
One action in the detail the store still holds.
- solve_req_get_run(event: AgentInspectorRun) None¶
One run and its action tree.
- solve_req_get_stats(event: AgentInspectorStats) None¶
What the store holds and what retention has already removed.
- solve_req_list_runs(event: AgentInspectorRuns) None¶
A page of run summaries, newest first.
- solve_req_observe(event: AgentAction) None¶
Take one observed action into the store.
The action itself is never sent on -- publishing the observed event back would feed the stream into its own input. What does go back is a subscribed update per state change, which is a different event.
edri.integrations.pydantic_ai.inspector_events module¶
The inspector's query surface, as ordinary EDRI API events.
These are plain @api events: they reach the inspector through the same
broker, the same registration and the same middleware as every other event an
application exposes. There is no inspector-specific web server and no bypass
endpoint -- an application that authenticates its API authenticates these too.
Request fields stay scalar on purpose. The global @api validator admits
scalars, UUIDs, timestamps and lists of them, and that is exactly what a
filterable query needs; the nested trees the inspector returns live in the
response, which the serializer already carries.
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorAction(action_id: UUID | None = None)[source]¶
Bases:
EventOne action, with the attempts and captured content the store kept.
- action_id: UUID | None = None¶
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorActionResponse[source]¶
Bases:
ResponseOne action in full retained detail.
- action: dict[str, Any] | None = None¶
- error: str | None = None¶
- found: bool = None¶
- generation_id: str | None = None¶
- revision: int | None = None¶
- root_action_id: str | None = None¶
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorRun(root_action_id: UUID | None = None, run_id: str | None = None)[source]¶
Bases:
EventOne run, addressed by the root action that identifies it.
- root_action_id: UUID | None = None¶
- run_id: str | None = None¶
An alternative for a caller that only knows the upstream id. Ambiguous by nature, and answered as ambiguous rather than by picking one.
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorRunResponse[source]¶
Bases:
ResponseOne reconstructed run with its action tree.
- error: str | None = None¶
- found: bool = None¶
Falsemeans the store does not have this run -- never seen, or evicted. It is not the same answer as a run that is present and empty.
- generation_id: str | None = None¶
- revision: int | None = None¶
- run: dict[str, Any] | None = None¶
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorRuns(agent: str | None = None, run_id: str | None = None, errors_only: bool | None = None, since: datetime | None = None, until: datetime | None = None, limit: int | None = None, offset: int | None = None)[source]¶
Bases:
EventRecent runs, filtered and paged.
A browser asking for HTML gets the inspector dashboard instead of the listing: one page that opens a WebSocket, subscribes to
AgentInspectorUpdateand then answers itself out of the four query events. It is the same event either way, so an application that authorizes the listing authorizes the page, and one that never routes this event has no page to reach.- agent: str | None = None¶
- errors_only: bool | None = None¶
- limit: int | None = None¶
- offset: int | None = None¶
- run_id: str | None = None¶
The upstream identifier. It may match several runs -- a caller can hand one
run_idto two invocations -- so it filters rather than selects.
- since: datetime | None = None¶
- until: datetime | None = None¶
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorRunsResponse[source]¶
Bases:
ResponseA page of run summaries, newest first.
- error: str | None = None¶
- limit: int = 50¶
- offset: int = None¶
- total: int = None¶
a client paging needs to know what it has not seen.
- Type:
Matching runs, not returned runs
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorStats[source]¶
Bases:
EventRetention and store statistics.
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorStatsResponse[source]¶
Bases:
ResponseWhat the store holds, and what it has already dropped.
- actions: int = None¶
- attempts: int = None¶
- bytes: int = None¶
- dedupe_entries: int = None¶
- evicted_runs: int = None¶
- incomplete_runs: int = None¶
- runs: int = None¶
- trimmed_runs: int = None¶
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorUpdate(root_action_id: UUID | None = None, generation_id: UUID | None = None, revision: int | None = None, kind: ChangeKind | None = None, action_id: UUID | None = None, run_id: str | None = None, agent: str | None = None, namespace: str | None = None, operation: str | None = None, phase: AgentActionPhase | None = None, edri_event_id: UUID | None = None)[source]¶
Bases:
EventOne store change, delivered to whoever registered for it.
Every field here exists to be filtered on. The broker compares a subscription's
param_setagainst top-level event fields, so the keys a client is expected to narrow by -- the run above all -- are carried flat and typed, and the change itself travels in the response.Only
root_action_id,generation_id,revisionandkindare filled on every update. The rest describe the action that caused the change and are absent on an invalidation, which no action caused. A client that filters on one of them therefore receives a view of the run rather than its complete history -- which is a legitimate feed, but not the stream the snapshot handshake detects loss on.- action_id: UUID | None = None¶
The action the change is about, absent on an invalidation.
- agent: str | None = None¶
- edri_event_id: UUID | None = None¶
an application watching what its own request caused filters on this.
- Type:
The business event that started the run, when one did
- generation_id: UUID | None = None¶
A revision is only comparable within its generation. A run evicted and later rebuilt starts counting again under a new
generation_id.
- kind: ChangeKind | None = None¶
- namespace: str | None = None¶
- operation: str | None = None¶
- phase: AgentActionPhase | None = None¶
- revision: int | None = None¶
- root_action_id: UUID | None = None¶
the one filter a browsing client is always expected to set.
- Type:
The run this change belongs to
- run_id: str | None = None¶
- class edri.integrations.pydantic_ai.inspector_events.AgentInspectorUpdateResponse[source]¶
Bases:
ResponseWhat changed, in the detail a live view needs to redraw.
The rich body lives here rather than on the event because
@apirequest fields are restricted to API-safe scalars. That restriction is the reason the filterable keys above are flat: a subscription predicate can only ever compare a top-level field, so anything a client filters on has to be one.- action: dict[str, Any] | None = None¶
The one action this change touched, without captured content. A live view redraws a node from it; the detail query is where content lives.
- kind: str = 'updated'¶
updatedreplaces;invalidatedsays the run is gone from the store and a client holding it should stop trusting what it has.
- run: dict[str, Any] | None = None¶
The run summary as the listing would render it, or
Nonefor an invalidation -- there is no longer a run to summarize.
edri.integrations.pydantic_ai.inspector_store module¶
Bounded reconstruction of agent history from the action stream.
The store turns AgentAction events back into the runs, actions and attempts
they describe. It is development history, not an audit log: it is bounded, it
is in memory, and it is allowed to forget. What it is not allowed to do is
pretend.
Three properties follow from that, and most of this module exists to keep them:
Nothing is invented. An attempt with no terminal event stays unfinished; an action whose parent was never seen keeps
parent_missing; an action that names no run is not filed under one. Absence is reported as absence.Duplicates are free. EDRI delivers an event once, but a store that advanced its revision on a replay would hand a subscriber a change that never happened. Re-applying one
Event._idis a no-op.Forgetting is visible. Evicting attempts marks the surviving action
truncated; evicting a run invalidates it, so a client cannot read the next generation's low revision as an old update.
Run identity is root_action_id, never the upstream run_id: one
run_id may be handed to two concurrent invocations, and an action's parent
may be missing exactly when it is needed.
- class edri.integrations.pydantic_ai.inspector_store.ActionState(action_id: UUID, root_action_id: UUID, parent_action_id: UUID | None = None, namespace: str = '', operation: str = '', name: str | None = None, agent: str | None = None, model: str | None = None, provider: str | None = None, metadata: dict[str, ~typing.Any]=<factory>, attempts: dict[int, ~edri.integrations.pydantic_ai.inspector_store.AttemptState]=<factory>, truncated: bool = False, bytes: int = 0)[source]¶
Bases:
objectOne logical operation, with every attempt of it the store still holds.
- property start_missing: bool¶
- property terminal_missing: bool¶
- class edri.integrations.pydantic_ai.inspector_store.AttemptState(attempt: int, started_at: datetime | None = None, ended_at: datetime | None = None, phase: AgentActionPhase | None = None, outcome: AgentActionOutcome | None = None, duration: float | None = None, usage: dict[str, Any] | None = None, request: Any = None, response_data: Any = None, error: dict[str, Any] | None = None, bytes: int = 0)[source]¶
Bases:
objectOne execution attempt of one logical action.
START and the terminal event are separate routed events and either may be absent, so both halves are optional and their absence is a reported flag rather than a reason to drop the attempt.
- property start_missing: bool¶
- property terminal_missing: bool¶
- class edri.integrations.pydantic_ai.inspector_store.ChangeKind(*values)[source]¶
Bases:
EnumWhat a consumer must do with a change record.
UPDATEDcarries a revision a subscriber can compare against what it holds.INVALIDATEDsays the run's retained state is gone -- evicted, expired or cleared -- so comparing revisions is meaningless and the only correct response is to stop trusting what is held.- INVALIDATED = 'invalidated'¶
- UPDATED = 'updated'¶
- class edri.integrations.pydantic_ai.inspector_store.InspectorStore(*, limits: StoreLimits | None = None, clock: Callable[[], ~datetime.datetime]=<function _utc_now>)[source]¶
Bases:
objectBounded in-memory history of observed agent runs.
Mutation is synchronous on the caller's thread: the manager owns the store and ingests on its own event loop, so there is no background thread and no lock.
clockis injected because retention is a function of time and tests must control it without sleeping.- action(action_id: UUID) tuple[RunState, ActionState] | None[source]¶
One action and the run it belongs to, both detached.
- clear() tuple[StoreChange, ...][source]¶
Forget everything, invalidating every run a client may be holding.
- drain_changes() tuple[StoreChange, ...][source]¶
Take the invalidations the store has accumulated.
A query can expire a run, and a client holding that run has to be told. The manager drains after serving a query for exactly that reason.
- ingest(action: AgentAction) tuple[StoreChange, ...][source]¶
Apply one observed action and report what changed.
Returns one
UPDATEDrecord when the run changed, nothing when the event was already applied, and additionally anINVALIDATEDrecord for anything retention removed while making room.
- run(root_action_id: UUID) RunState | None[source]¶
A detached snapshot of one run, or
Noneif it is not retained.
- runs(*, agent: str | None = None, run_id: str | None = None, errors_only: bool = False, since: datetime | None = None, until: datetime | None = None, offset: int = 0, limit: int | None = None) tuple[tuple[RunState, ...], int][source]¶
Matching runs, newest first, and how many matched in total.
The count is of matches, not of the page: a client paging through needs to know what it has not seen yet. Every returned run is a detached copy.
- stats() StoreStats[source]¶
What is retained now, and what retention has already removed.
- class edri.integrations.pydantic_ai.inspector_store.RunState(root_action_id: UUID, generation_id: UUID, revision: int = 0, run_id: str | None = None, conversation_id: str | None = None, agent: str | None = None, created_at: datetime | None = None, updated_at: datetime | None = None, actions: dict[~uuid.UUID, ~edri.integrations.pydantic_ai.inspector_store.ActionState]=<factory>, truncated: bool = False, event_ids: set[UUID] = <factory>, bytes: int = 0)[source]¶
Bases:
objectOne agent invocation: its identity, its actions and its revision.
- property completed: bool¶
- property failed: bool¶
Something in this run ended badly.
A timed-out or errored action counts even when the run itself finished: a retry that eventually succeeded still describes a run an operator looking for failures wants to find. Cancellation and deferral are not failures -- they are how a run ends when something asked it to.
- property outcome: AgentActionOutcome | None¶
- property partial: bool¶
Something this run should contain is not here.
Either the run itself was never fully observed, or one of its actions was not -- a missing START, a missing terminal, a parent that never arrived, or history retention removed.
- property root: ActionState | None¶
- class edri.integrations.pydantic_ai.inspector_store.StoreChange(kind: ChangeKind, root_action_id: UUID, generation_id: UUID, revision: int, action_id: UUID | None = None, run_id: str | None = None, agent: str | None = None)[source]¶
Bases:
objectOne thing that happened to the store, for the manager to publish.
generation_idtravels with every record becauserevisionmeans nothing without it: a run re-materialized after eviction restarts at 1, and a subscriber holding revision 40 of the previous generation must not read that as stale.
- class edri.integrations.pydantic_ai.inspector_store.StoreLimits(max_runs: int = 256, max_actions: int = 8192, max_attempts_per_action: int = 32, max_bytes: int = 16777216, ttl: timedelta | None = None)[source]¶
Bases:
objectWhat the store is allowed to keep.
Every limit applies to the whole retained state, auxiliary indexes included: a bookkeeping dict that grows while
max_actionsis still satisfied is the same leak, one level down.
- class edri.integrations.pydantic_ai.inspector_store.StoreStats(runs: int, actions: int, attempts: int, bytes: int, incomplete_runs: int, dedupe_entries: int, evicted_runs: int, trimmed_runs: int, limits: StoreLimits)[source]¶
Bases:
objectWhat the store currently holds, and what it has already lost.
evicted_runsis retention's own count. It is not a guess about actions the producer never published: the store cannot see those and does not pretend to.
edri.integrations.pydantic_ai.lifecycle module¶
Observing one run's model and tool seams.
The capability built here is what an observed run installs for itself: it holds the run's correlation state, so nothing is stored on the agent and two runs on one built agent cannot read each other's identity.
Every seam is a wrapper hook. A wrapper owns both ends of the operation it
observes -- START, duration and exactly one terminal -- so pairing is a local
variable rather than a table keyed on an upstream id, and there is no window in
which a crash leaves a half-written pairing entry behind. That choice is forced
for tools: wrap_tool_execute is the only hook that sees every terminal
state, because a ModelRetry surfaces inside it as ToolRetryError and
never reaches on_tool_execute_error.
What the seams deliberately do not do:
They never refuse, delay or alter an operation. An observation failure is contained (see
_emit_safely()); an observed failure is re-raised unchanged, including the original exception instance.They never merge two executions into one logical action. Upstream gives each retry of a tool call a fresh
tool_call_id, so there is no stable identity to merge on, and inferring one from tool name, arguments or timing would silently fuse two concurrent calls of the same tool.They never read an application-defined conversion. Content is projected from an explicit list of attributes and anything else is skipped, not stringified.
Limits of what these seams can see, measured against the supported floor:
A capability that refuses a tool from
before_tool_executeruns outside thewrap_tool_executeframe, so such a tool produces no execution action at all -- not a START left without a terminal. The failure is still visible on the run.A tool that exceeds its own upstream
timeoutarrives as aToolRetryErrorwhose message mentions a timeout. The type says nothing, so the attempt is reported as an ordinary error; TIMEOUT is reserved for an expiry that is visible as a type.Losing the worker outside any seam leaves the attempt with no terminal action. On the supported Pydantic AI floor, a
BaseExceptionescaping a sync tool cancels the enclosing run task: the tool stays partial while the run records that observed cancellation.
- edri.integrations.pydantic_ai.lifecycle.audit_hooks(state: _RunState, emitter: _ActionEmitter, policy: _EffectivePolicy) pydantic_ai.capabilities.Hooks[source]¶
The audit capability for one run.
Registered per run through
run_sync(capabilities=[...]), so it is not installed on the agent and does not persist beyond the invocation it observes.
edri.integrations.pydantic_ai.mcp module¶
Auditing the one MCP operation Pydantic AI exposes a public seam for.
Of the MCP protocol operations an agent performs, exactly one can be observed
without reaching into private internals on the supported dependency floor:
tools/call, through MCPToolset(process_tool_call=...). initialize,
tools/list, resources/* and prompts/* round-trip correctly but offer
no public observation point, so this module makes no claim about them -- and
MCPToolset.direct_call_tool() bypasses the seam entirely, so an application
calling a toolset directly is outside the observed set.
Installation is explicit, in the agent's own build_agent():
from pydantic_ai.mcp import MCPToolset
from edri.integrations.pydantic_ai import audited_tool_calls
class WeatherAgent(AgentBase[AgentDeps, str]):
def build_agent(self):
toolset = MCPToolset(
client, id="weather",
process_tool_call=audited_tool_calls("weather"),
)
return Agent(self.model_name, deps_type=AgentDeps, toolsets=[toolset])
Explicit because the toolset is the application's object: EDRI does not reach
into an agent's toolsets to instrument them, and an integration that silently
replaced a user's own process_tool_call would be worse than one that asks
for a line of wiring.
- edri.integrations.pydantic_ai.mcp.audited_tool_calls(server: str | None = None) ProcessToolCallback[source]¶
A
process_tool_callcallback that publishes one action per call.serverlabels the server in the action stream. The seam is handed only the run context, the transport callable, a tool name and its arguments -- never the connection -- so the label cannot be discovered here; pass the same name the toolset carries as itsid.The returned callback is transparent. It awaits the real call, returns its result unchanged and re-raises its exceptions unchanged, including the
ModelRetrythat a server-side failure arrives as. A call made while no observed run is in progress -- a shared connection's own traffic -- is passed straight through: its attribution is unknown, and EDRI publishes nothing rather than guessing a run for it.The return type is the real
ProcessToolCallback, imported only underTYPE_CHECKING:pydantic_ai.mcpraisesImportErrorunless themcpextra is installed, and this package must import without it.
edri.integrations.pydantic_ai.normalization module¶
Detach, bound and sanitize captured content before it is published.
This is the security boundary of the audit feature. Content is sanitized here, before the action reaches the router queue -- the inspector is not the boundary, and there is no "queue it raw and redact later" path.
The order is snapshot, then redact, then normalize again:
raw value -> bounded detached snapshot -> redactor -> bounded normalization
The application redactor therefore never receives a live business object, so
destructive code such as dict.pop() can only damage the observability copy.
The second pass exists because a redactor can return a new oversized, cyclic or
otherwise unsupported structure even when its input was safe.
What makes the snapshot an isolation boundary rather than a shallow copy is that
it accepts only exact built-in scalars and containers (plus UUID and
datetime, converted by the stdlib). An unknown object is a fixed sentinel: the normalizer never calls repr, str, __iter__,
keys, asdict, model_dump, JSON hooks or any other
application-defined conversion on it, because those run arbitrary code -- on the
agent's hot path, on data the operator may have chosen not to capture at all.
- edri.integrations.pydantic_ai.normalization.BINARY: Final = '<binary>'¶
a size is still information about content.
- Type:
Raw binary. Fixed, with no length
- edri.integrations.pydantic_ai.normalization.CYCLE: Final = '<cycle>'¶
The value is its own ancestor.
- class edri.integrations.pydantic_ai.normalization.NormalizationBudget(limits: NormalizationLimits | None = None)[source]¶
Bases:
objectMutable spend against one action's limits.
One budget is shared by every field of one action -- request, response, metadata, usage and error -- rather than each field getting the full allowance, so an action cannot multiply the limit by its field count.
- fit_text(text: str) str | None[source]¶
Return as much of
textas fits, orNoneif nothing does.Truncation is by characters, never by bytes: slicing an encoded string at a byte offset can split a codepoint, and the result has to stay valid text. The byte budget is then applied by shrinking the character count until the encoded form fits.
- edri.integrations.pydantic_ai.normalization.REDACTION_FAILED: Final = '<redaction-failed>'¶
The application redactor raised. The original is not published instead.
- edri.integrations.pydantic_ai.normalization.REQUEST_USAGE_FIELDS: Final[tuple[str, ...]] = ('input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'input_audio_tokens', 'cache_audio_read_tokens', 'output_audio_tokens')¶
Counters of one model request, which is a different measurement from the run totals above:
requestsandtool_callscount whole-run activity and a single request has no opinion about them. Listed separately rather than filtered at the call site, so a future upstream release that adds a run-scoped counter to the per-request object cannot leak it into a snapshot that a consumer would then be tempted to sum with the run's own.
- edri.integrations.pydantic_ai.normalization.TOO_DEEP: Final = '<max-depth>'¶
Nesting passed
max_depth.
- edri.integrations.pydantic_ai.normalization.TRUNCATED: Final = '<truncated>'¶
A budget ran out -- value count, string length or total bytes.
- edri.integrations.pydantic_ai.normalization.UNSUPPORTED: Final = '<unsupported>'¶
an application object, a container subclass, a set, or a float that JSON cannot represent.
- Type:
A value the normalizer will not expand
- edri.integrations.pydantic_ai.normalization.USAGE_FIELDS: Final[tuple[str, ...]] = ('requests', 'tool_calls', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'input_audio_tokens', 'cache_audio_read_tokens', 'output_audio_tokens')¶
Usage counters EDRI publishes, read explicitly so an upstream release adding a field cannot silently change the audit shape. Measured against
pydantic_ai_slim2.18.0; extending it is an intentional EDRI change.
- edri.integrations.pydantic_ai.normalization.capture_content(value: Any, *, budget: NormalizationBudget, redactor: Callable[[Any], Any] | None = None) Any[source]¶
Snapshot
value, hand the snapshot toredactor, bound the result.The redactor runs on the lifecycle hot path and is application code, so its failure is contained here: the result degrades to
REDACTION_FAILEDand never falls back to the unredacted original, which is the one outcome that would defeat the point of having a redactor.
- edri.integrations.pydantic_ai.normalization.describe_error(exception: BaseException, *, include_message: bool, budget: NormalizationBudget, redactor: Callable[[Any], Any] | None = None) dict[str, Any][source]¶
Describe
exceptionunder the effective content policy.Exception text is not metadata-safe: provider messages carry request bodies, model output, URLs with credentials and application rows, and truncation is not sanitization. So
metadatamode publishes only allowlisted structural fields, and a message appears solely when the application enabledinclude_error_messagesundercontent.The message is built from
args, never fromstr(exception):__str__is an application-defined conversion, and calling it to produce a nicer message is exactly the arbitrary evaluation this module refuses. Neither the live exception nor its traceback is retained -- a traceback holds frames, locals and open resources, and an action outlives all of them.
- edri.integrations.pydantic_ai.normalization.normalize(value: Any, budget: NormalizationBudget) Any[source]¶
Return a detached, bounded, JSON-like clone of
value.Total: no input raises, recurses without limit, or allocates without limit. The emitter drops observations it cannot build, so a normalizer that raised would silently delete audit records rather than produce a degraded one.
Iterative rather than recursive, because
max_depthis caller-configurable and a generous one would otherwise hit the interpreter's stack limit instead of the documented bound.
- edri.integrations.pydantic_ai.normalization.usage_snapshot(usage: Any, budget: NormalizationBudget, counters: tuple[str, ...] = ('requests', 'tool_calls', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'input_audio_tokens', 'cache_audio_read_tokens', 'output_audio_tokens')) dict[str, Any] | None[source]¶
Build EDRI's stable usage shape from an explicit field list.
Counters that are absent are omitted rather than reported as zero: a missing counter is a measurement EDRI did not make, and zero is a claim. Unknown upstream fields are ignored so a dependency upgrade cannot change the published schema.
countersnames the scope being snapshotted:USAGE_FIELDSfor a whole run,REQUEST_USAGE_FIELDSfor one model request. The caller picks it from what it is observing, because the two objects overlap and a per-request object read through the run-scoped list would publish what looks like a run total with counters missing.Returns
Nonewhen nothing supported is available -- "no usage" and "an empty usage mapping" are different claims.
edri.integrations.pydantic_ai.observability module¶
Public observability contract for the Pydantic AI bridge.
This module holds the parts of the audit feature that are not Pydantic AI specific: the action event that enters ordinary EDRI routing, the environment switch that arms the feature, and the application policy that narrows what its payloads may contain.
Nothing here imports pydantic_ai. The action event is what application
managers and the inspector consume, and that side of the system should not need
the optional dependency to understand it.
- class edri.integrations.pydantic_ai.observability.AgentAction(action_id: UUID, namespace: str, operation: str, phase: AgentActionPhase, timestamp: datetime = <factory>, attempt: int = 1, parent_action_id: UUID | None = None, root_action_id: UUID | None = None, duration: float | None = None, outcome: AgentActionOutcome | None = None, run_id: str | None = None, conversation_id: str | None = None, edri_event_id: UUID | None = None, agent: str | None = None, provider: str | None = None, framework: str = 'pydantic_ai', model: str | None = None, model_version: str | None = None, prompt_version: str | None = None, output_mode: str | None = None, name: str | None = None, usage: dict[str, ~typing.Any] | None=None, metadata: dict[str, ~typing.Any]=<factory>, request: Any = None, response_data: Any = None, error: dict[str, ~typing.Any] | None=None)[source]¶
Bases:
EventOne observed lifecycle point of one agent operation.
Identity comes in three layers.
Event._ididentifies this concrete routed event;(action_id, attempt)identifies the logical operation attempt, so a START and its terminal event pair up while retries stay distinguishable;root_action_idnames the run the action belongs to. The run's root action is the key because upstreamrun_idis not unique -- a caller may pass onerun_idto two concurrent invocations -- and because a consumer must be able to bucket an action whose parent it never saw or has already evicted.The event is deliberately not frozen --
Eventcarries mutable framework transport state -- but its payloads are detached and normalized before publication, and the emitter never mutates a published instance. The router queue serializes on a feeder thread, so a payload still aliased to a live object could otherwise change mid-serialization.request/response_dataare typedAnybecause they hold normalized JSON-like values (built-in scalars and containers only).response_datarather thanresponsebecauseEvent.responseis framework state.- agent: str | None = None¶
- attempt: int = 1¶
- conversation_id: str | None = None¶
- duration: float | None = None¶
- edri_event_id: UUID | None = None¶
- error: dict[str, Any] | None = None¶
- framework: str = 'pydantic_ai'¶
- model: str | None = None¶
- model_version: str | None = None¶
- name: str | None = None¶
- outcome: AgentActionOutcome | None = None¶
- output_mode: str | None = None¶
- parent_action_id: UUID | None = None¶
- prompt_version: str | None = None¶
- provider: str | None = None¶
- request: Any = None¶
- response_data: Any = None¶
- root_action_id: UUID | None = None¶
- run_id: str | None = None¶
- usage: dict[str, Any] | None = None¶
- class edri.integrations.pydantic_ai.observability.AgentActionNamespace[source]¶
Bases:
objectBroad subsystems this integration owns.
Plain strings, deliberately:
AgentAction.namespaceis extensible, and adding a future subsystem such asmemorymust not require changing the event model. These constants only spare callers a literal.- AGENT: Final = 'agent'¶
- EDRI: Final = 'edri'¶
- MCP: Final = 'mcp'¶
- MODEL: Final = 'model'¶
- TOOL: Final = 'tool'¶
- class edri.integrations.pydantic_ai.observability.AgentActionOperation[source]¶
Bases:
objectOperations this integration owns, by namespace.
The MCP operations are reserved by the event model from day one; which of them the implementation can actually observe on the supported dependency floor is a separate, measured question (see the spec's MCP coverage matrix).
- DISPATCH: Final = 'dispatch'¶
- EXECUTE: Final = 'execute'¶
- MCP_INITIALIZE: Final = 'initialize'¶
- MCP_PROMPTS_GET: Final = 'prompts.get'¶
- MCP_PROMPTS_LIST: Final = 'prompts.list'¶
- MCP_RESOURCES_LIST: Final = 'resources.list'¶
- MCP_RESOURCES_READ: Final = 'resources.read'¶
- MCP_TOOLS_CALL: Final = 'tools.call'¶
- MCP_TOOLS_LIST: Final = 'tools.list'¶
- REQUEST: Final = 'request'¶
- RUN: Final = 'run'¶
- VALIDATE: Final = 'validate'¶
- class edri.integrations.pydantic_ai.observability.AgentActionOutcome(*values)[source]¶
Bases:
EnumTerminal result of one execution attempt.
Cancellation, timeout and deferral are not failures merely because an upstream library uses an exception to unwind control flow, so they terminate with
AgentActionPhase.ENDand their own outcome.- CANCELLED = 'cancelled'¶
- DEFERRED = 'deferred'¶
- ERROR = 'error'¶
- SUCCESS = 'success'¶
- TIMEOUT = 'timeout'¶
- class edri.integrations.pydantic_ai.observability.AgentActionPhase(*values)[source]¶
Bases:
EnumLifecycle point of one execution attempt.
Closed, unlike
namespace/operation: START/END/ERROR has stable transport semantics that a new subsystem does not extend.- END = 'end'¶
- ERROR = 'error'¶
- START = 'start'¶
- class edri.integrations.pydantic_ai.observability.AgentActionPolicy(include_model_requests: bool = False, include_model_responses: bool = False, include_tool_arguments: bool = False, include_tool_results: bool = False, include_mcp_requests: bool = False, include_mcp_responses: bool = False, include_dispatch_requests: bool = False, include_dispatch_responses: bool = False, include_error_messages: bool = False, redactor: ~typing.Callable[[~typing.Any], ~typing.Any] | None = None, limits: ~edri.integrations.pydantic_ai.observability.NormalizationLimits = <factory>)[source]¶
Bases:
objectWhat an application would like captured, per content category.
This is the application half of the content decision. The deployment half is
AGENT_AUDIT_ENV_VAR, and the effective policy is their intersection: a policy can always remove content, never add content the operator disallowed. Everything therefore defaults to off, error messages included.
- class edri.integrations.pydantic_ai.observability.AgentAuditMode(*values)[source]¶
Bases:
EnumDeployment-level ceiling on what observability may capture.
- CONTENT = 'content'¶
- METADATA = 'metadata'¶
- OFF = 'off'¶
- property enabled: bool¶
- class edri.integrations.pydantic_ai.observability.NormalizationLimits(max_depth: int = 8, max_values: int = 1024, max_string_characters: int = 4096, max_total_bytes: int = 65536)[source]¶
Bases:
objectBounds applied to every captured payload, before and after redaction.
max_total_bytesis one shared budget across an action's request, response, metadata, usage and error fields rather than a per-field allowance, so a single action cannot multiply the limit by its field count.
- edri.integrations.pydantic_ai.observability.resolve_audit_mode(environ: Mapping[str, str] | None = None) AgentAuditMode[source]¶
Read
AGENT_AUDIT_ENV_VAR; missing meansAgentAuditMode.OFF.Read here rather than as an import-time constant in
edri.config.settingbecause the mode is resolved once per worker duringBaseAgentWorker.setup(). An import-time constant would fix the value for the whole process and could not be resolved per worker at all.Unknown values are configuration errors, not silently reinterpreted: an operator who typed
EDRI_PYDANTIC_AI_AUDIT=metdataasked for less exposure than a fallback tocontentwould give them, and more than a fallback tooff.
edri.integrations.pydantic_ai.worker module¶
Worker half of the Pydantic AI bridge.
BaseAgentWorker hosts one or more AgentBase
agents on an EDRI WorkerThread, bakes runtime deps
into each, and exposes dispatch so agent tools can call other services as
plain Events.
- class edri.integrations.pydantic_ai.worker.BaseAgentWorker(pipe: Connection, event: T, name: str, *args: Any, **kwargs: Any)[source]¶
Bases:
WorkerThread[_EventT],ABCA Worker that hosts one or more
AgentBaseagents.The worker is an orchestrator, not an agent. It declares which agents it uses (
agents), constructs them, bakes runtime deps into each, then runs domain logic indo(). Agents are reachable viaself.agent[SomeAgentClass]after setup. The EDRIdispatchand shared resources are injected via deps, so agent methods never expose them.Subclasses must set
agents, supply each agent's constructor arguments — at leastmodel_name— viaagent_configsoragent_kwargs(), and implementdo().The agents are built in
setup(), which the baseWorkerruns beforedo()inside its own error-handling scope — so this class does not overriderun().- agent_action_policy(agent_cls: type[AgentBase[Any, Any]]) AgentActionPolicy[source]¶
Content policy for
agent_cls, resolved once duringsetup().Defaults to capturing no payload bodies at all. Override to describe what would be useful to observe; the deployment environment decides separately what is permitted, and the effective policy is their intersection, so an override here can never widen what
EDRI_PYDANTIC_AI_AUDITallows.
- agent_configs: ClassVar[dict[type[AgentBase[Any, Any]], dict[str, Any]]] = {}¶
Per-agent constructor kwargs for the common case — each agent class in
agentsmaps to the dict passed to its constructor (e.g.{"model_name": "gpt-4o"}), so a worker hosting several agents on different models is a flat declaration instead of a dispatch you write yourself. Seeagent_kwargs()for kwargs that must be computed at runtime instead (env lookups, secrets, ...).
- agent_deps(agent_cls: type[AgentBase[Any, Any]]) AgentDeps[Any][source]¶
Runtime dependencies for
agent_cls.Defaults to sharing the worker's
dispatchand exposing the worker itself asresources. Override to scope resources per agent.
- agent_kwargs(agent_cls: type[AgentBase[Any, Any]]) dict[str, Any][source]¶
Constructor keyword arguments for
agent_cls.Defaults to looking
agent_clsup inagent_configs— set that class-level mapping for the common case (each agent just needs a differentmodel_nameor similar static config). Override this method instead when kwargs must be computed at runtime (env lookups, secrets, ...); an agent needs at leastmodel_name, and only the worker knows the application's model/provider configuration.- Raises:
KeyError -- If
agent_clsisn't inagent_configsand this wasn't overridden.
- agents: ClassVar[list[type[AgentBase[Any, Any]]]] = []¶
Agent classes used by this worker. Identity is the class itself; the logical name comes from
AgentBase.default_name().
- setup() None[source]¶
Construct all declared agents and bake runtime deps into each.
Overrides the
setup()hook, so it runs once beforedo(). Override further for worker-specific setup, but callsuper().setup()so agents get built.The audit mode is resolved once here, not per run: a running worker is not a runtime toggle, and a worker must be restarted for a new mode to take effect.
- Raises:
ValueError -- If two agent classes resolve to the same logical name, or if
EDRI_PYDANTIC_AI_AUDITholds an unsupported value.RuntimeError -- If observability is enabled but no router queue is bound, which would leave the actions with nowhere to go.
Module contents¶
Pydantic AI agent integration for EDRI — optional.
Requires pydantic-ai, which is not a core EDRI dependency. Install it with:
pip install edri[pydantic-ai]
Importing this module without pydantic-ai installed raises a clear ImportError
naming the extra, rather than a bare ModuleNotFoundError.
What this module provides¶
A thin bridge between Pydantic AI agents and EDRI's Worker model:
AgentDeps— runtime dependencies handed to an agent's tools: the only door back into the EDRI router (dispatch) plus arbitrary sharedresources.AgentBase— wraps apydantic_ai.Agentand keeps runtime concerns (the EDRI pipe, shared state) out of an agent's domain methods.BaseAgentWorker— aWorkerThreadthat hosts one or more agents, builds them, bakes their deps in, and exposes the EDRIdispatchso agent tools can call other services as plain Events.AgentInspectorManager— an optional standard manager that consumes theAgentActionstream into bounded in-memory history, answers theagent-inspector-*query events over the application's existing API, and publishesAgentInspectorUpdateto whoever registered for it.
A tool can dispatch any Event — including one handled by a Worker owned by
a different Manager — and get the reply back on this worker's pipe without
any per-type wiring: ManagerBase tracks the reply generically (see
ManagerBase._track_worker_response) keyed on Event._id, which every
event carries unconditionally.
From pydantic-ai this package uses Agent, Agent.run_sync(deps=...) and,
when observability is enabled, the public Hooks capability installed for the
duration of one run. Model/provider construction lives in your concrete agents'
build_agent().