Policies
Custom session policies — bundle hooks that fire on agent events to govern continuation, autonomy, or session-scoped behavior.
A session policy is a named bundle of hooks that fires on agent events during a session. The two shipped policies are interactive (no hooks) and headless (a step-budget hook that ends the turn at a configurable cap). A capability ships a custom policy when the same agent should behave differently depending on which mode the user picks — tighter budget, stricter observation, an evaluation harness.
import typing as t
from dreadnode.agents.events import AgentStart, AgentStepfrom dreadnode.agents.reactions import Finishfrom dreadnode.core.hook import hookfrom dreadnode.policies import SessionPolicyfrom pydantic import Field, PrivateAttr
class TightBudgetPolicy(SessionPolicy): name: t.ClassVar[str] = "tight-budget" is_autonomous: t.ClassVar[bool] = True display_label: t.ClassVar[str] = "tight"
max_steps: int = Field(default=5, gt=0) _count: int = PrivateAttr(default=0)
@hook(AgentStart) async def reset(self, _event: AgentStart) -> None: self._count = 0
@hook(AgentStep) async def stop_early(self, _event: AgentStep) -> Finish | None: self._count += 1 if self._count >= self.max_steps: return Finish(reason=f"max_steps={self.max_steps} reached") return NoneDrop this file under policies/ in your capability and the runtime registers it on load. Users swap to it with /policy tight-budget or {"policy": {"name": "tight-budget", "max_steps": 3}} over the API.
When to reach for one
Section titled “When to reach for one”Policies bundle session-scoped hooks that the user opts into per session. Use one when you need behavior that’s:
- Per-session, not always-on. Hooks that run for every session belong in the capability’s
hooks/directory; they don’t need a policy. - Named, so a user can swap to it via
/policy <name>without knowing the implementation. - Stateful across the session’s events, where the state is meaningful only to one mode (a step counter, a denial budget).
Don’t reach for a policy to gate individual tool calls. Per-tool permission prompts are a separate runtime concern. Use a policy when the whole session should run differently.
Class metadata
Section titled “Class metadata”Every policy declares three class-level fields. They’re ClassVar so Pydantic treats them as class attributes the runtime can read off the class without instantiating it.
| Field | Required | Purpose |
|---|---|---|
name | yes | Registry key used by /policy <name> and the API. Unique across loaded policies. |
is_autonomous | default False | When True, the runtime resolves any ask_user() call to deny instead of blocking on a human. |
display_label | default "" | Short string the TUI status bar renders when is_autonomous is True (e.g. "auto"). |
Decorate async methods with @hook(EventType) to register them. Each method receives self and the event:
import typing as t
from dreadnode.agents.events import AgentStart, ToolErrorfrom dreadnode.core.hook import hookfrom dreadnode.policies import SessionPolicyfrom loguru import logger
class ObservedPolicy(SessionPolicy): name: t.ClassVar[str] = "observed"
@hook(AgentStart) async def announce(self, event: AgentStart) -> None: logger.info("starting agent {}", event.agent_id)
@hook(ToolError) async def record(self, event: ToolError) -> None: # observe-only — no return value redirects the agent logger.warning("tool {} errored: {}", event.tool_call.name, event.error)A hook returns None to observe only, or a Reaction (Finish, Continue, others) to redirect the agent. The runtime collects every @hook-decorated method on the class via policy.hooks at the start of every turn and threads them into the agent’s hook bundle alongside the capability-shipped hooks.
The protocol — events, return reactions, conditions, scorers — is the same as standalone capability hooks. The full event list, decorator options, and Hook class live in the dreadnode.agents reference.
Pydantic fields for configuration
Section titled “Pydantic fields for configuration”SessionPolicy is a Pydantic model, so configuration goes in normal annotated fields:
from pydantic import Field, PrivateAttr
class CappedPolicy(SessionPolicy): name: t.ClassVar[str] = "capped" is_autonomous: t.ClassVar[bool] = True
# config — settable via /policy capped max_steps=5 max_steps: int = Field(default=30, gt=0) deny_message: str = "out of budget"
# private state — not exposed to API callers _count: int = PrivateAttr(default=0)extra="forbid" is set on the base, so a typo in /policy capped maxStep=5 raises a validation error rather than silently dropping the value. Use Field(...) for validation (gt, ge, regex, …) and PrivateAttr for runtime state — it stays out of the API spec and survives across turns within a single session.
Pydantic config validation is the only validation surface — there is no separate hook for declaring required tools or capability dependencies. If your policy needs a particular tool to be loaded, check for it inside the hook body and return Finish with a clear reason if it is missing.
Reset state per turn
Section titled “Reset state per turn”Policy instances live for the session, so any state stored in self persists across user messages. If a counter or flag should reset between turns, hook AgentStart and clear it:
@hook(AgentStart)async def reset(self, _event: AgentStart) -> None: self._count = 0HeadlessSessionPolicy does this for its step counter so the budget applies per turn, not per session.
Where policies live
Section titled “Where policies live”my-capability/ capability.yaml policies/ tight.py strict.pyAuto-discovery scans policies/*.py for top-level classes with a non-empty name class attribute. Override with explicit listings in capability.yaml:
policies: - policies/tight.py - policies/strict.pySet policies: [] to disable the directory entirely.
How users invoke it
Section titled “How users invoke it”Once your capability is loaded, the policy joins the registry alongside interactive and headless:
/policy # list every registered policy/policy capped # swap to capped with defaults/policy capped max_steps=5 # swap with config argsThe same name resolves through the API:
POST /api/sessions{"policy": {"name": "capped", "max_steps": 5}}POST /api/sessions/{id}/policy accepts the same shape for mid-session swaps. The TUI renders display_label in the status line whenever is_autonomous is true, so users always see what mode they’re in.
Reference
Section titled “Reference”dreadnode.policies—SessionPolicy,register_policy,resolve_policy,registered_policy_names.dreadnode.agents— the@hookdecorator, theHookclass, and every event type a hook can listen for.