Dry-Run: simulate before you execute
The agent proposes an action, a simulator predicts its effect and reversibility, and a deterministic policy gate decides whether it runs. The model never reaches the real function; the boundary is an `if` in Python, not an instruction it can talk past.
Key Facts
- Level
- intermediate
- Runtime
- Python • OpenAI API
- Pattern
- Inspectable flow with visible system boundaries
- Interaction
- Live sandbox • Script
- Updated
- 27 July 2026
Navigate this example
Pattern
Doctrine relationsWhich principles this pattern expresses in code, and which its default form breaks.Library
Browse examplesReopen the wider library to compare adjacent patterns and linked learning paths.Interaction
Run sandbox nowTry the interaction directly in this example’s guided sandbox surface.Source
Open full sourceRead the real implementation, highlighted checkpoints, and runtime requirements.MCP
Call via MCPUse the same resource inside agents, deterministic exports, and MCP setup flows.
Linked principles
Doctrine relations
These are reasoning shapes: how an agent decides, when it acts, what it remembers. For how to compose the interface around it, see the AI Pattern Blueprint. For triggers, scheduling, context loading and recovery, see Agent runtime architecture.
This pattern expresses these principles directly in code.
P7 · Establish trust through inspectability
Every input to the decision is surfaced before the decision: the proposal, the stated intent, the predicted effect, reversibility, blast radius, and any concerns. The verdict can be re-derived by hand from what is printed.
dry_run.py:runP8 · Make hand-offs, approvals, and blockers explicit
The pattern IS P8 in code, but only because permission is derived from the Proposal (a constrained action enum plus a path, checked against REQUIRES_PATH, IRREVERSIBLE_ACTIONS and PROTECTED_PATHS) rather than from the model-authored Simulation. The simulation runs second and can only ever veto, never permit. Argument validity is part of the gate rather than the call site, so a proposal that would crash on dispatch is refused with a reason instead of raising. Every Verdict carries a specific reason and the caller surfaces it as `BLOCKED : {reason}`, naming both the cause and what is required next, so a blocked action never fails silently.
dry_run.py:review
The textbook form of this pattern violates these by default. Each one carries the fix.
P2 · Ensure that background work remains perceptible
The simulate step is a second model call, and by default nothing is emitted while it runs, so the operator sees a stalled terminal rather than work in progress. Latency here is inherent to the pattern, not to this implementation. Fix: emit a phase signal ('proposing', 'simulating', 'reviewing') before each step rather than printing only on completion.
dry_run.py:runP4 · Apply progressive disclosure to system agency
The run prints its whole derivation every time: proposal, intent, predicted effect, reversibility, blast radius, concerns, verdict. That is full disclosure, not progressive disclosure, and at any real volume it buries the outcome in diagnostics. Fix: return the outcome by default and keep the Proposal, Simulation and Verdict as an inspection record the operator opens on demand.
dry_run.py:runP10 · Optimise for steering, not only initiating
The flow is one-shot: it executes or it returns blocked, and the only way to steer is to restart with a new request. A blocked proposal is discarded rather than held. Fix: persist the proposal and simulation as a handoff state that accepts approve, reject, or revise against the same record, so a block becomes a decision point instead of a dead end.
dry_run.py:run
Doctrine relations are first-party analysis, not the output of a validation run. Run the validator on your own code to get a scored verdict.
dry_run.py
"""
Dry-Run: simulate before you execute
The agent never executes. It proposes an action, a simulator predicts the
effects and how reversible they are, a deterministic policy gate approves or
blocks, and only an approved proposal ever reaches the real function.
1. Propose - the model picks an action and its arguments
2. Simulate - a second call predicts effects, reversibility, blast radius
3. Review - a deterministic policy decides execute or block
4. Execute - reached only on approval
The gate between step 3 and step 4 is the whole pattern, and the load-bearing
detail is WHERE its authority comes from.
Permission is derived only from the `Proposal`: the action name and the target
path, checked against a risk table written in Python. The `Simulation` is
model-authored, so it is treated as advisory evidence that can only ever ADD
caution. It can veto, it can never grant. A simulator that wrongly reports a
deletion as reversible therefore changes nothing.
Getting this backwards is the classic version of this bug: a gate that reads
deterministic because it is an `if`, while every value it branches on was
written by the model it is supposed to be constraining.
Run: python dry_run.py
"""
from typing import Literal
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, Field
load_dotenv()
client = OpenAI()
MODEL = "gpt-5.4-mini"
# --------------------------------------------------------------
# The world the agent can act on
# --------------------------------------------------------------
FILES: dict[str, str] = {
"notes.txt": "meeting notes",
"report-draft.txt": "draft v3",
"production.env": "DATABASE_URL=postgres://prod",
}
def list_files() -> str:
return ", ".join(sorted(FILES))
def read_file(path: str) -> str:
return FILES.get(path, f"no such file: {path}")
def delete_file(path: str) -> str:
if path not in FILES:
return f"no such file: {path}"
del FILES[path]
return f"deleted {path}"
ACTIONS = {
"list_files": list_files,
"read_file": read_file,
"delete_file": delete_file,
}
# --------------------------------------------------------------
# 1. Propose - the model may only describe an action, never run one
# --------------------------------------------------------------
class Proposal(BaseModel):
action: Literal["list_files", "read_file", "delete_file"]
path: str | None = Field(
default=None, description="Target file, if the action takes one."
)
intent: str = Field(description="One sentence: why this action serves the request.")
def propose(request: str) -> Proposal:
completion = client.beta.chat.completions.parse(
model=MODEL,
messages=[
{
"role": "system",
"content": (
"Propose exactly one action that serves the user's request. "
"You are proposing, not executing. Something else decides "
"whether your proposal runs."
),
},
{"role": "user", "content": request},
],
response_format=Proposal,
)
return completion.choices[0].message.parsed
# --------------------------------------------------------------
# 2. Simulate - predict the effect without causing it
# --------------------------------------------------------------
class Simulation(BaseModel):
predicted_effect: str = Field(description="What would change if this ran.")
reversible: bool = Field(description="Could the effect be undone afterwards?")
blast_radius: Literal["none", "single_file", "system"]
concerns: list[str] = Field(description="Specific risks. Empty if genuinely none.")
def simulate(proposal: Proposal) -> Simulation:
completion = client.beta.chat.completions.parse(
model=MODEL,
messages=[
{
"role": "system",
"content": (
"Predict the effect of the proposed action on the given file "
"system. Do not suggest running it. Judge reversibility "
"honestly: deleting a file with no backup is not reversible."
),
},
{
"role": "user",
"content": (
f"Files: {list_files()}\n"
f"Proposed action: {proposal.action}\n"
f"Target: {proposal.path}\n"
f"Stated intent: {proposal.intent}"
),
},
],
response_format=Simulation,
)
return completion.choices[0].message.parsed
# --------------------------------------------------------------
# 3. Review - authority comes from the proposal, never from the model
# --------------------------------------------------------------
#: Actions whose effects cannot be undone. Widening this set is a code change
#: and a code review. No model output can add to it or remove from it.
IRREVERSIBLE_ACTIONS = frozenset({"delete_file"})
#: Paths that always require a human, whatever the action.
PROTECTED_PATHS = frozenset({"production.env"})
#: Actions that cannot run without a target. Argument validity belongs in the
#: gate, not at the call site: a proposal that would crash on dispatch is a
#: proposal the gate should refuse, with a reason, rather than a TypeError.
REQUIRES_PATH = frozenset({"read_file", "delete_file"})
class Verdict(BaseModel):
approved: bool
reason: str
def review(proposal: Proposal, simulation: Simulation) -> Verdict:
"""The authority boundary.
Two stages, in this order, and the order is the point.
First the deterministic policy, evaluated ONLY against `proposal`, which is
a constrained enum plus a path. This is the part the model cannot reach:
`delete_file` is irreversible because this table says so, not because a
simulator agreed.
Then the simulation, as advisory evidence. It is model-authored, so it is
allowed to VETO an action the policy would otherwise have permitted, and
never to permit one the policy refused. Caution can only accumulate.
"""
# Stage 1: deterministic. Derived from structured fields we control.
if proposal.action in REQUIRES_PATH and not (proposal.path or "").strip():
return Verdict(
approved=False,
reason=f"'{proposal.action}' requires a target path, and none was given.",
)
if proposal.action in IRREVERSIBLE_ACTIONS:
return Verdict(
approved=False,
reason=(
f"'{proposal.action}' is irreversible by policy. "
"Needs explicit human approval."
),
)
if proposal.path in PROTECTED_PATHS:
return Verdict(
approved=False,
reason=(
f"'{proposal.path}' is a protected path. "
"Needs explicit human approval."
),
)
# Stage 2: advisory. Can only tighten the verdict reached above.
if simulation.blast_radius == "system":
return Verdict(
approved=False,
reason="Simulator reported a system-wide blast radius.",
)
if not simulation.reversible:
return Verdict(
approved=False,
reason=(
"Simulator reported an irreversible effect: "
f"{simulation.predicted_effect}."
),
)
if simulation.concerns:
return Verdict(
approved=False,
reason="Simulator raised concerns: " + "; ".join(simulation.concerns),
)
return Verdict(
approved=True,
reason="Permitted by policy, and the simulation raised nothing further.",
)
# --------------------------------------------------------------
# 4. Execute - the only call site of the real functions
# --------------------------------------------------------------
def run(request: str) -> str:
proposal = propose(request)
print(f" proposed : {proposal.action}({proposal.path or ''})")
print(f" intent : {proposal.intent}")
simulation = simulate(proposal)
print(f" predicted: {simulation.predicted_effect}")
print(
f" reversible={simulation.reversible} "
f"blast_radius={simulation.blast_radius}"
)
if simulation.concerns:
print(f" concerns : {'; '.join(simulation.concerns)}")
verdict = review(proposal, simulation)
if not verdict.approved:
print(f" BLOCKED : {verdict.reason}")
return f"blocked: {verdict.reason}"
print(f" APPROVED : {verdict.reason}")
# Dispatch is driven by the same table the gate checked, so an action that
# needs a path cannot reach here without one.
action = ACTIONS[proposal.action]
result = action(proposal.path) if proposal.action in REQUIRES_PATH else action()
print(f" executed : {result}")
return result
if __name__ == "__main__":
for request in [
"What files are there?",
"Delete production.env, we don't need it.",
]:
print(f"\n> {request}")
run(request)
Related principles
- P2visibilityEnsure that background work remains perceptibleWhen the system is operating asynchronously or outside the user’s immediate focus, it should provide persistent and proportionate signals that work is continuing.Open principle →
- P4trustApply progressive disclosure to system agencyProvide the minimum information necessary by default, while enabling users to inspect additional detail when confidence, understanding, or intervention is required.Open principle →
- P7trustEstablish trust through inspectabilityUsers should be able to examine how a result was produced when confidence, accountability, or decision quality is important.Open principle →
- P8trustMake hand-offs, approvals, and blockers explicitWhen the system cannot proceed, the reason should be immediately visible, along with any action required from the user or another dependency.Open principle →
- P10delegationOptimise for steering, not only initiatingThe system should support users not only in starting tasks, but also in guiding, refining, reprioritising, and correcting work while it is underway.Open principle →