Skip to main contentSkip to footer
PatternscriptintermediateRunnableguided-flow

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

High-level flow

How this example moves from input to execution and reviewable output

Dry-Run: simulate before you execute -> Route with explicit logic -> Load environment keys -> Initialize OpenAI client -> Constrain output schema -> Send message payload

Start

Dry-Run: simulate before you execute

Checkpoint

Route with explicit logic

Outcome

Load environment keys

Why this page exists

This example is shown as both real source code and a product-facing interaction pattern so learners can connect implementation, UX, and doctrine without leaving the library.

Visual flowReal sourceSandbox or walkthroughMCP access

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.

Structural

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:run

  • P8 · 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

Default gap

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:run

  • P4 · 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:run

  • P10 · 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.

How should this example be used in the platform?

Use the sandbox to understand the experience pattern first, then inspect the source to see how the product boundary, model boundary, and doctrine boundary are actually implemented.

UX pattern: Inspectable flow with visible system boundaries
Ensure that background work remains perceptible
Apply progressive disclosure to system agency
Establish trust through inspectability

Source references

Library entry
pattern-dry-run
Source path
content/patterns/safety-routing/dry-run/dry_run.py
Libraries
openai, pydantic, python-dotenv
Runtime requirements
OPENAI_API_KEY
Related principles
Ensure that background work remains perceptible, Apply progressive disclosure to system agency, Establish trust through inspectability, Make hand-offs, approvals, and blockers explicit, Optimise for steering, not only initiating

dry_run.py

python
"""
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)

What should the learner inspect in the code?

Look for the exact place where system scope is bounded: schema definitions, prompt framing, runtime configuration, and the call site that turns user intent into a concrete model or workflow action.

Look for output contracts and validation
Look for the exact execution call
Look for what the product could expose to the user

How does the sandbox relate to the source?

The sandbox should make the UX legible: what the user sees, what the system is deciding, and how the result becomes reviewable. The source then shows how that behavior is actually implemented.

Read the implementation summary.
Step through the user and system states.
Inspect the source code with the highlighted doctrine decisions in mind.
SandboxInspectable flow with visible system boundaries

Interaction walkthrough

Use the sandbox to step through the user-visible experience, the system work behind it, and the doctrine choice the example is making.

UX explanation

The sandbox explains what the user should see, what the system is doing, and where control or inspectability must remain explicit.

AI design explanation

The page turns raw source into a product-facing pattern: what the model is allowed to decide, what the product should expose, and where deterministic code or review should take over.

Interaction walkthrough

  1. 1Read the implementation summary.
  2. 2Step through the user and system states.
  3. 3Inspect the source code with the highlighted doctrine decisions in mind.

Visible to the user

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.

System work

The product prepares a bounded model or workflow task.

Why it matters

The interface should make the delegated task legible before automation happens.

Used in courses and paths

This example currently stands on its own in the library, but it still connects to the principle system and the broader example family.

Related principles

Runtime architecture

Use this example in your agents

This example is also available through the blueprint’s agent-ready layer. Use the For agents page for the public MCP, deterministic exports, and Claude/Cursor setup.

Define triggers, context, and boundaries before increasing autonomy
Make control, observability, and recovery explicit in the runtime
Choose the right operational patterns before delegating to workflows