OpenProgram Docs

How Rules Are Written and How They Run#

The overview explained what a rule (Policy) is and that there are two kinds. This doc covers the details: what a complete rule looks like, what's distinctive about each of the two kinds (the blocking ones / the watching ones), and how the framework invokes them. Read overview.md and events-and-state.md before this one.

1. What a Complete Rule Looks Like#

A rule is just a Python class, and the framework recognizes these few things:

class Policy:
    on: set[str]          # which kinds of events I watch
    lane: str             # am I a "blocker" (gate) or a "watcher" (observer)
    cooldown_s: float     # after I act, how long before I'm allowed to act again (anti-spam)

    def evaluate(self, event, state) -> Action | None:
        # an event arrives; take a look at the current event + current state, decide whether to act or stay out of it
        # to act, return an Action; to stay out, return None
        ...

That's the four things. on and lane are declarations (what I care about, which kind I am), evaluate is the logic (what to do when something arrives), and cooldown_s is a simple anti-spam knob.

At startup the framework registers all rules, building an index keyed by on: "when a tool.before event arrives, which rules should be woken up." When an event arrives, the framework looks it up in the index, wakes only the rules that care about it, and calls evaluate on each one.

2. The Blocking Ones (gate)#

Stop something before it happens. They watch only one kind of event: tool.before (a tool is about to execute). Because "blocking" only makes sense before the thing has happened — blocking after the tool has already run is pointless.

class DangerousCommandGuard:
    on = {"tool.before"}
    lane = "gate"
    cooldown_s = 0

    def evaluate(self, event, state):
        if event.payload["tool"] != "bash":
            return None
        command = event.payload["command"]
        if "rm -rf" in command or "git push --force" in command:
            return Gate.ask(f"This command is risky: {command}, confirm execution?")
        return None

A blocking rule can only return three actions: Gate.allow() (let it through), Gate.deny(reason) (block it outright), and Gate.ask(question) (ask the user, only let it through if the user says OK).

Two Things to Keep in Mind for Blocking Rules#

One, it must be fast. It sits in the middle of the path of tool execution, and the agent is waiting on its verdict before it can continue. If it's slow, the agent stalls. So a blocking rule's evaluate must not do slow work: no calling the LLM, no network reads, no reading the kind of complex state that requires on-the-spot inference. Just look at the event in front of you + simple ready-at-hand state, and give an answer immediately. DangerousCommandGuard only glances at the command string — very fast.

Two, it applies to subtasks as well. OpenProgram currently sets "no approval" for subagents (permission_mode=bypass), meaning tools inside subtasks don't go through the approval flow. But blocking rules must bypass this setting and block anyway — otherwise a dangerous command could slip through simply by being stuffed into a subtask. This is why the interception point sits outside the approval wrapper rather than inside it.

Mechanically, tool_gate is one gate slot on the event bus: register_tool_gate is subscribe_gate("tool.before", ...) and the consultation runs through EventBus.emit_gate (docs/reference/design/proactive/event-layer.md §3). The same gate dispatch also carries turn.stop.

3. The Watching Ones (observer)#

Watch after something happens, think it over slowly, don't hold up the agent. They watch all kinds of events — a tool finished, the model finished replying, a file changed.

class StuckToolWatcher:
    on = {"tool.after"}
    lane = "observer"
    cooldown_s = 300                 # already reminded once, don't bring up the same thing again within 5 minutes

    def evaluate(self, event, state):
        tool = event.payload["tool"]
        if state.tool_failure_count[tool] >= 3:
            return Notify(f"{tool} has failed repeatedly, might be stuck", severity="info")
        return None

A watching rule can return these actions:

Action What it does
Notify(message) Give the user a non-intrusive reminder (doesn't interrupt the agent's work)
Inject(text) Quietly slip a hint to the model before its next round of thinking (doesn't disturb the user)
Prepare(task) Spin up a read-only background task to do some homework first, and once it has a conclusion, decide whether to Notify

What to Keep in Mind for Watching Rules: It Can Be Slow, but It Mustn't Slow the Agent Down#

A watching rule can do slow work (even call the LLM to judge), because it isn't blocking the path. But there's a reverse requirement: no matter how slow it is, it must not slow the agent down. The way this works is — the agent does its thing and doesn't wait on it; the watching rule processes events on a separate, independent lane alongside. The event stream is recorded first, the watching lane consumes it slowly, and the agent side is entirely unaware.

Prepare is the most interesting move among the watching ones: "do your homework before you speak up." Take "tests weren't added," for example: rather than reminding the moment you see it (easy to get false positives), it's better to spin up a read-only background task, let it actually go look at whether this change really lacks tests, and whether the gap is worth raising, and only Notify once it has a reliable conclusion. This background task is read-only (not allowed to modify files) and runs on the existing background-task mechanism.

4. How the Framework Strings This All Together#

Event dispatch: blocking synchronous / watching asynchronous

5. A Few Simple Fallbacks#

  • Anti-spam: each rule has a cooldown_s; after acting, it won't act on the same situation again within that window. The simplest dedup is enough.
  • Multiple rules hitting the same tool: when blocking, if multiple rules act on the same tool.before, take the strictest — deny > ask > allow.
  • A rule erroring out: if a rule's evaluate throws an exception, the framework catches it, logs it, and treats it as returning None (don't let one rule's bug crash the whole agent).

More complex interruption budgets, automatic circuit-breaking, and fine-grained fallback strategies on error are out of scope; they're archived in _research_archive/. cooldown_s is the single knob, which is enough to get the framework running and to add rules.

6. What It Takes to Add a New Rule#

This is what you'll be doing most often going forward, so just remember it:

  1. Write a new class, defining on (which kind of event to watch), lane (blocking or watching), and cooldown_s.
  2. Write evaluate: look at the event + state, return an action or None.
  3. Register it with the framework.

No need to touch the framework core, no need to touch the event stream, fold, or persistence logic. The framework core stays stable, and capabilities grow by adding rules — this is exactly the point of "building a framework" rather than "hanging a few hooks."

Last updated · 2026-08-13