Plan for provrun, the runtime beside provcat
provrun is a Python library that runs agents and model calls on data that provcat resolves. It is the caller of record for every run. It makes the run ID, asks the access broker for a grant and asks provcat for the files. Then it stages the files, runs the agent and records the evidence. The first version runs agents in the same process as the caller. Docker and Azure Foundry come later, so the classes must already have room for them.
Status: phases 0 to 4 are built. Commit 5d8246b made the workspace, and commit 2524d03 added snapshots and provrun. provrun.md is the guide for users. This plan records the design, so some steps below describe work that is now finished.
The design comes from three sources. The first source is the private planning documents of the larger system that provrun serves. Of those documents, the sections of the implementation spec on access, evidence and execution are the most important to the design. The second source is the agent execution spec of an earlier, private runtime. The third source is the current Microsoft documentation of the Foundry Agent Service.
Terms
| Term | Meaning |
|---|---|
| run | One execution of one agent or one model call, with one run ID. |
| caller of record | The process that owns a run. It writes the facts about the run, and the agent does not. |
| grant | The answer of the access broker to one request for one URI. It names the mode, the pinned URI and the expiry. |
| broker | The access broker. It decides each grant. In this plan it is a stub. |
| snapshot | A read-only copy of one pinned version of a file or a folder, which no later fetch changes. |
| stager | The part that makes the granted files visible to the agent, for example as a mount in a sandbox. |
| agent spec | The portable description of an agent: instructions, model route, tool names, inputs and outputs. |
| backend | The part that runs an agent spec in one place, for example in this process or in Foundry. |
| evidence event | One typed, append-only record of one thing that happened in a run. |
| evidence bundle | All the evidence events and payloads of one run, committed as one catalog object. |
| sink | A place that the evidence recorder writes events to, for example a local JSONL file. |
| importer | Code that turns a trace from another system into evidence events. |
| JSONL | A text file with one JSON object on each line. |
Decisions already made
- The earlier runtime does not change. It will be retired. provrun copies its generic parts and changes them. Nothing in provrun imports it.
- provrun depends on provcat, and provcat never depends on provrun.
- The broker is a stub. Profiles come later.
- The first backends run in this process. They are a single LiteLLM call and a PydanticAI agent with a filesystem sandbox.
- Evidence comes first. Every other part writes evidence from its first version.
- One call writes two kinds of record. Each step writes a canonical evidence event and an OpenTelemetry span.
- The backend interface is asynchronous. Foundry runs are remote and need start, poll and cancel. An in-process backend completes at once.
- Evidence failure has two modes. In the strict mode, a run fails when the recorder cannot write an event. In the lenient mode, the recorder logs a warning and the run goes on. Strict is the default outside development.
Layout of the repository
MahaCata becomes a uv workspace. A uv workspace is one repository with several Python packages, one lock file and editable installs between the packages.
MahaCata/
pyproject.toml # workspace root, no package of its own
uv.lock
docs/ # shared docs, including this plan
packages/
provcat/
pyproject.toml # today's pyproject.toml, with the provc command
src/provcat/
tests/
provrun/
pyproject.toml # the provr command
src/provrun/
tests/The move of provcat changes paths only. The provcat package, its provc command and its tests keep their names and their behavior.
Classes
The classes below are the whole design. The phases build some of them now and leave the others as named interfaces.
| Class | Role | Built now |
|---|---|---|
RunContext |
Holds the run ID, the environment, the recorder, the resolver and the broker. Every other part takes it. | Yes |
EvidenceRecorder |
Appends evidence events, stores large payloads by hash and opens OpenTelemetry spans. | Yes |
Sink |
Writes events. JsonlSink writes a local file. EvidenceServerSink comes later. |
JsonlSink |
Importer |
Turns another system’s trace into evidence events, with a confidence level. | PydanticAI messages only |
Resolver |
Pins a URI and makes a snapshot. ProvcatResolver calls provcat. |
Yes |
Broker and Grant |
Decide access for one URI and one mode. StubBroker checks an allow list. |
Stub |
Stager |
Makes granted files visible to the agent. | InProcessStager |
RunSpec |
Lists the staged inputs, the input, output and scratch folders, and the environment of one run. The limits are in AgentSpec. |
Yes |
AgentSpec |
The portable agent description. | Yes |
Backend |
Runs an agent spec. | LiteLLM and PydanticAI |
Tool and ToolRegistry |
Hold tools by name, each with a function and an execution mode. PydanticAI makes the JSON schema from the function. | In-process mode |
Outputter |
Collects the outputs and hashes them. RunContext commits them with the evidence. |
Yes |
Each step of a run writes an evidence event. The steps of one run follow this order:
RunContextwritesrun_created.Brokerwritesaccess_requestedand thenaccess_grantedoraccess_deniedfor each input URI.Resolverwritescontext_resolvedandsnapshot_materialized. Steps 2 and 3 repeat for each input.Stagerwritesinput_stagedfor each input, with its hash.Backendwritessandbox_started,prompt_rendered, eachmodel_calledand eachtool_called, and thensandbox_completed.Stagerhashes each input again and writesinput_verified.Outputterwritesoutput_artifact_createdfor each output.RunContextwritesrun_completedorrun_failed, and then commits the evidence bundle.
A step that raises an error writes step_failed. The PydanticAI backend also writes trace_imported, immediately before sandbox_completed. The agent can write note_added.
Room for later backends
The later backends need no change to the classes above. They need new subclasses only:
- Docker: a
BindMountStagerand aDockerBackend. The container serves the Foundry hosted-agent protocol,POST /responseson port 8088, so the same image runs in Foundry. - Foundry prompt agents: a
SessionUploadStageror aBlobRefStager, and aFoundryPromptBackend. Function tools run in provrun through thefunction_callandfunction_call_outputround trip. - Foundry hosted agents: a
FoundryHostedBackendand an importer for Application Insights spans. - Remote tools: a remote MCP mode on
Tool, with an importer for the calls that provrun cannot see.
Foundry has no read-only mounts, and its session files are writable. So the stager records the hash of each staged input, and integrity checks use those hashes.
Evidence events
An evidence event has these fields:
| Field | Meaning |
|---|---|
run_id |
The run that the event belongs to. |
seq |
The position of the event in the run, from 0. |
time |
The UTC time at which the recorder wrote the event. |
type |
One of the event types in the section “Classes” above. |
component |
The class that wrote the event, for example StubBroker. |
actor |
The user or the service identity of the run. |
refs |
The pinned logical URIs that the event is about. |
data |
Small facts, for example a model name, a token count or a decision. |
payload |
The SHA-256 of a large payload, for example a prompt or a response, stored apart from the event. |
confidence |
observed, parsed, inferred or verified. Events that provrun sees itself are observed. A provider’s account of a tool that it ran, and an imported trace, are parsed. |
prev |
The SHA-256 of the previous event of the run. The chain shows when someone changes or removes an event. |
Events refer to logical URIs and not to paths on a disk, so that a later job can ask for the same snapshot.
At the end of a run, the recorder writes a manifest with the hash of the last event and the list of payloads. Then RunContext commits the evidence and output folders of the run through provcat to run://<org>/<domain>/<env>/<run_id>. The commit uses the provcat write path that exists, which is put and then commit. The bundle then has a pinned version like any other catalog object.
The recorder also opens an OpenTelemetry span for each step. The spans use the GenAI semantic conventions, for example invoke_agent and execute_tool. Those conventions are still at Development status, so provrun pins one version. When no exporter is set, the spans go nowhere and cost little.
Model calls and tool calls inside PydanticAI must reach the recorder without help from the agent. provrun gives the agent one PydanticAI capability, which writes model_called for each model request and tool_called for each tool call. A capability is PydanticAI’s hook on each step of an agent run. The agent can add notes and citations through a tool, and those events have the component agent.
Phases
Phase 0: make the workspace
- Wait until no other work on provcat is uncommitted, so that the move does not collide with it.
- Move
src/provcat,testsandpyproject.tomlintopackages/provcatwithgit mv, so that the history follows the files. - Write the workspace root
pyproject.toml, and runuv lock. - Run the provcat unit and integration tests. They must pass with no change to their code.
- Fix the paths in
README.mdand indocs/.
Done when: uv run provc --help works from the root, and the provcat tests pass.
Phase 1: run context and evidence
- Write
RunContext, with the run ID, the environment, the actor and the evidence mode. A context variable holds the current run, so that code inside a tool can find it. - Write
EvidenceEvent,EvidenceRecorder,JsonlSinkand the payload store. The payload store writes each payload under its SHA-256. - Write the hash chain and the manifest.
- Write
recorder.step(type, ...), which opens a span and writes the event. When the step fails, it writesstep_failedwith the name of the step and the error. - Write the commit of the bundle through provcat.
- Write the strict and lenient modes.
Tests:
- A run with three steps writes a chain that verifies.
- A changed or removed event fails the verification.
- A sink that fails stops the run in strict mode and does not stop it in lenient mode.
- The bundle commits to a versioned bucket and reads back at the same version. The tests use a moto S3 bucket. A
filestore has no commits, so there the bundle stays unpinned.
Phase 2: resolver, broker and snapshots
- Write
GrantandStubBroker. The stub grants a URI only if the URI is in the allow list of the run. The environment segment of the URI must also match the environment of the run. The stub grants only the modemounted_snapshot. (Later, the stub also grantspinned_read, for the run’s experiment, which provrun reads itself. provrun’s page has the rules now.) A physical URI, such asfile://, has no environment segment, so the stub grants one only indev. - Write
ProvcatResolver. It callspinand then makes a snapshot of the pinned version. - Add snapshots to provcat as
Catalog.snapshot(uri), which returns aSnapshotwith the pinned URI, the path and the SHA-256. A snapshot lives at.provcat/snapshots/<kind>/<sha256>/<name>in the cache, where the kind is file or folder. The SHA-256 is of the content. For a folder, it is the SHA-256 of a list of its files and their hashes. So two versions with the same kind, the same name and the same content share one snapshot. A snapshot is read-only. provcat reuses it only if its hash still matches, and replaces it otherwise. It is made with a copy-on-write clone where the disk has one, and with a copy elsewhere.
When this plan was written, the provcat cache kept one path for each store and key, whatever the version. It now keeps fixed versions in their own folders, but the path without a version still moves. A later fetch of a moved branch rewrites that path in place. A mount of that path is not safe, so mounts use snapshots only.
Tests:
- The stub denies a URI that is not in the allow list.
- The stub denies a URI from another environment.
- A snapshot does not change after the branch moves and the cache fetches the new version.
- Every grant and every snapshot writes its evidence event.
Phase 3: specs, stager and backends
- Write
AgentSpecandRunSpecas dataclasses that convert to JSON and back. - Write
InProcessStager. It copies each snapshot into the input folder of the run, with a copy-on-write clone where the disk has one. The PydanticAI backend mounts that folder read-only at/input, and the output folder writable at/output. A clone shares no inode with the snapshot, so a change in one run cannot reach the snapshot that other runs use. - Write the
Backendinterface withstart,poll,stream,cancelandresult, all asynchronous. - Write
LiteLLMBackendfor one text call or one multimodal call. - Write
PydanticAIBackend, with the filesystem toolset andgrep_file. - Write
ToolRegistry, which loads tools from Python entry points and from a tools folder. Each module in a tools folder names its tools in a list calledTOOLS. - Write
Outputter. It hashes each file in/output.RunContextcommits the folder through provcat torun://.../<run_id>/output. - Write the PydanticAI importer. It puts the whole message history into one
trace_importedevent with confidenceparsed. The wrappers have already recorded each model call and tool call.
Tests:
- Use the PydanticAI
TestModelandFunctionModel, and a LiteLLM mock, so that the tests use no network and no keys. - An agent that reads
/input/<name>and writes/output/answer.txtgives a bundle with every event from the list in section “Classes”. - An agent cannot write to
/input.
Phase 4: the provr command
- Write
provr run <spec.yaml>, which runs one agent spec and prints the run ID. - Write
provr show <run_id>, which prints the events of a run. - Write
provr verify <run_id>, which checks the hash chain and the hashes of the payloads and the outputs. - Write
docs/provrun.md, with one example that reads afileURI and one that reads a lakeFS URI.
Done when: the two examples run from a clean checkout, and provr verify passes on both bundles.
What is copied from the earlier runtime
| Earlier runtime source | provrun destination | Change |
|---|---|---|
interfaces.py: ContentRetriever, Executor, Outputter, OutputResult |
Stager, Backend, Outputter, RunResult |
Remove experiment_path, file_key and question_key. Add RunContext. Make the methods asynchronous. |
ContainerSpec in the agent execution spec |
RunSpec |
Inputs are grants and snapshots, not host paths. |
executors.py: _LiteLLMBase, StandardExecutor, MultimodalExecutor, build_content_blocks |
LiteLLMBackend |
Use acompletion. Write evidence for each call. Pin litellm==1.83.0 exactly. Releases 1.82.7, 1.82.8 and 1.107.0 were compromised. Releases after 1.83.0 need an older openai than PydanticAI 2.32 and later, or an older huggingface-hub than the hf driver. |
executors.py: PydanticAIExecutor |
PydanticAIBackend |
Mounts come from RunSpec, not from incoming/. Remove the answers folder and the {file_key} substitution. |
agent_tools.py: grep_file and its deps class |
provrun.tools.grep_file and RunDeps |
RunDeps holds the sandbox and the RunContext. |
registry.py |
load_backend in provrun.backends, and ToolRegistry in provrun.tools |
Keep the entry points. Rename the groups to provrun.backends, provrun.stagers and provrun.tools. |
| Workspace protocol in the agent execution spec, section 2.8 | RunSpec folder layout |
Keep input/, output/ and scratch/. |
These parts of the earlier runtime are not copied:
- the judge executors
- the code that finds a file in
incoming/from a file key - the question dependencies
- MLflow, Elo and the scores
Checks before each commit
- Run the unit tests of both packages.
- Ask an adversarial reviewer to break the change. The reviewer is a subagent on the strongest model.
- Fix each finding, and add a test for each finding.
Out of scope for this plan
- Docker, Foundry and Azure Container Apps backends.
- Importers for Foundry, Application Insights, Claude Code and Codex traces.
- Profiles, a real broker and identity.
- An evidence server, Blob storage with immutability policies and redaction policies. The recorder has a redaction hook that does nothing yet.
- Durable runs that live through a restart of the process.
Open questions
- The org, domain and environment of
run://URIs. Today therunsection of the run file gives them toRunContext. A profile will give them later.
Questions that the build settled
- The place of snapshots. Snapshots live in provcat, because they are a cache feature.
- The format of a tool folder. Each module lists its tools in
TOOLS. provrun does not load every public function, as the earlier runtime does.