Agent Substrate

Agent Policy: Supervision, Context, Middleware

What this is

An agent that can spawn other agents, run for minutes, and burn real money needs rules wrapped around it: who it reports to, how many helpers the whole job may hire, how much each helper is allowed to spend, when to give up, and what to do around every model call. None of those rules are the agent's reasoning — they are policy. The kernel (layer L0) defines that policy as frozen contracts: a handful of dataclasses, two enums, and three Protocols, with zero I/O.

Think of one agent run as a small company spun up to finish one project:

Real-world thingKernel type
Your spot in the org chart (who's your manager, what project)Supervision
Company-wide hiring cap ("no more than 50 people on this project")SpawnBudget
Each employee's spending allowance (tokens, dollars, hours)ExecutionBudget
How important your branch of work isPriority
Whether your notes are shredded, kept for the project, or filed foreverHistoryRetention
The clerk who trims a fat email thread before you read itCompactionStrategy
Your employee ID badge with a shift deadlineRunMeta
Airport-style security layers wrapping every model callMiddleware (contract lives one layer up, in agents/)
This is the contract-level companion to two higher-level pages

For the story of how these are enforced at runtime — the SpawnTracker, the ExecutionTracker, the MiddlewarePipeline — read Supervision & Budgets and Middleware. This page stays inside kernel/agent/ and documents only the frozen types. Everything that actually tracks a budget or runs a pipeline lives one layer up, in agents/.

The kernel ships four small files, and we cover each:

  1. supervision.py — the org chart (Supervision) and the two budgets.
  2. runtime_context.py — the ID badge with a deadline (RunMeta) and the stop button (CancellationToken, a Protocol only — see below).
  3. context.py — how an agent's prompt window is assembled (AgentContextProtocol, CompactionStrategy).
  4. middleware.py — just the MiddlewareStage enum. The Middleware interceptor Protocol itself lives one layer up, in agents/ — see the note at the end of this page for why.

Supervision — the org chart

A Supervision node is one agent's formal position in an execution hierarchy: who its manager is, which project (run) and conversation (session) it belongs to, what level it sits at, and what resource limits the project operates under. The same policy is passed top-down when spawning children, so every agent in the tree shares the same identifiers and the same hiring cap.

python
@dataclass(frozen=True, slots=True)
class Supervision:
    run_id: str                       # one execution tree (one run() call)
    session_id: str                   # one conversation thread (many runs)
    root_id: AgentId                  # the top of this tree
    parent_id: AgentId | None         # your manager (None at the root)
    depth: int = 0                    # informational only — UI nesting
    spawn_budget: SpawnBudget = ...        # shared across the WHOLE tree
    execution_budget: ExecutionBudget = ...# per-agent resource limits
    retention: HistoryRetention = HistoryRetention.RUN
    priority: Priority = Priority.NORMAL

Two of those fields are IDs that are easy to confuse, so pin them down on first use:

  • session_id — the conversation thread. Long-lived; one session spans many runs. History is always keyed by session_id.
  • run_idone execution tree, the result of a single run() call. Short-lived. It scopes the budget, the supervision tree, resume/replay, and the progress pub/sub topic.
diagram
Rendering diagram…

Building the tree: root() then spawn_child()

You never hand-build a Supervision. You call Supervision.root() once on the top-level agent, then thread the result into every child via spawn_child().

python
# Called ONCE on the user-facing agent — mints a fresh run_id.
sup = Supervision.root(
    agent_id,
    session_id=None,            # None → fresh uuid; pass an id to resume a session
    spawn_budget=None,          # None → SpawnBudget() (50 agents, preempt on)
    execution_budget=None,      # None → ExecutionBudget() (all unlimited)
    retention=HistoryRetention.PERMANENT,   # default for the root
    priority=Priority.NORMAL,
)

# Called by a parent for each helper it hires.
child_sup = sup.spawn_child(
    parent_id=sup.root_id,
    retention=HistoryRetention.RUN,         # default for children
    priority=Priority.HIGH,
    execution_budget=None,      # None → inherit the parent's budget
)

A child created by spawn_child() inherits:

  • the same run_id (one execution tree) and session_id (one conversation),
  • the same SpawnBudget instance — so the headcount cap is global to the tree, not per-branch,
  • the parent's execution_budget by default (pass an override to give a child tighter limits),
  • depth + 1 — purely informational for UI indentation. There is no depth limit; SpawnBudget is the single structural constraint.
Two handy accessors
  • sup.is_rootTrue when parent_id is None.
  • sup.progress_topicTopicId("agent.progress", run_id). Every agent in the run publishes progress to this one topic, so a UI subscribes once and watches the entire tree.
`Supervision` is frozen

Every supervision type on this page is frozen=True. You do not mutate a node to add a child — you derive a new node with spawn_child(). The mutable counters that actually enforce the budgets live one layer up, in the agents/ trackers.


Two orthogonal budgets

Agent Substrate deliberately separates "how many agents may exist" from "how much each agent may spend." They are different questions enforced by different trackers, so they live in two different dataclasses.

diagram
Rendering diagram…
BudgetScopeFieldsDefaultA field of None means
SpawnBudgetRun-wide — one shared instance for the entire treemax_agents, allow_preemptmax_agents=50, allow_preempt=True(no nullable fields)
ExecutionBudgetPer-agent — each agent carries its ownmax_tokens, max_cost_usd, max_turns, deadline_sall None (unlimited)unlimited for that one dimension

SpawnBudget — the hiring cap

python
@dataclass(frozen=True, slots=True)
class SpawnBudget:
    max_agents: int = 50        # total agents allowed in the run (root counts as 1)
    allow_preempt: bool = True  # may HIGH/CRITICAL agents pause lower ones for a slot?

It is a project-wide limit. The same SpawnBudget object is propagated to every node, so a fork bomb is impossible: when the count hits max_agents, no branch can hire. If allow_preempt is on, an important agent that needs a slot can cooperatively pause a lower-priority one to claim it instead of being denied outright (the full preemption flow lives in the Supervision concept page).

ExecutionBudget — the per-employee allowance

python
@dataclass(frozen=True, slots=True)
class ExecutionBudget:
    max_tokens: int | None = None     # total LLM tokens (prompt + completion)
    max_cost_usd: float | None = None # cumulative LLM spend in USD
    max_turns: int | None = None      # LLM round-trips (one tool call = one turn)
    deadline_s: float | None = None   # wall-clock seconds from run start

This is one employee's allowance. Each agent gets its own. The agents/-layer ExecutionTracker counts real consumption against it and raises BudgetExhaustedError the instant any limit is breached. The deadline_s dimension is the only one enforced at the kernel boundary — by the deadline on RunMeta (next section).


Priority and HistoryRetention — two small enums

Priority — how important this branch is

An integer weight used for proportional pool allocation and to decide who gets preempted. A CRITICAL agent gets 8x the default share; BACKGROUND is best-effort.

python
class Priority(int, Enum):
    BACKGROUND = 0
    LOW = 1
    NORMAL = 2     # default
    HIGH = 4
    CRITICAL = 8
PriorityWeightPlain English
BACKGROUND0best-effort, first to be paused
LOW1nice-to-have
NORMAL2the default lane
HIGH4jump the queue, can preempt lower lanes
CRITICAL8top of the food chain

HistoryRetention — how long the notes survive

How long an agent's conversation history is kept after the run ends.

python
class HistoryRetention(str, Enum):
    NONE = "none"          # stateless worker — nothing persisted
    RUN = "run"            # kept for this run (scoped by run_id), then deleted
    PERMANENT = "permanent"# kept forever — for top-level user-facing agents
PolicyLifetimeUse it for
NONEnot persisted at alla throwaway stateless sub-task
RUNscoped to run_id, deleted after the runmost spawned helpers (the spawn_child() default)
PERMANENTkept foreverthe top-level, user-facing agent (the root() default)

The supervision classes at a glance

diagram
Rendering diagram…

RunMeta — the ID badge with a shift deadline

Where Supervision is the org chart, RunMeta is the employee ID badge carried into every kernel call: it says which run you're on, lets anyone hit a stop button, names your shift deadline, and carries the tracing and tenant tags so observability and multi-tenant scoping work without adding a parameter to every function.

python
@dataclass(frozen=True, slots=True)
class RunMeta:
    run_id: str                          # globally unique id for this run
    cancellation: CancellationToken      # the cooperative stop button
    supervision: Supervision | None = None   # org-chart position (None if standalone)
    deadline: datetime | None = None     # wall-clock expiry — agents/tools honour it
    trace_id: str = ...                  # distributed-trace id (auto-generated)
    tenant_id: str | None = None         # tenant namespace (None = single-tenant)

RunMeta is immutable — you thread it down the call stack rather than mutating it. Construct one per run() call with an already-built CancellationToken (run_id is populated from supervision.run_id when supervision is provided).

The stop button: CancellationToken

In the kernel, CancellationToken is a Protocol onlyis_cancelled, cancel(), check(), wait(), add_callback(), child(). The concrete implementation (real asyncio state: an Event, a callback list) lives in agents/runtime/cancellation.py, not in kernel — kernel holds the contract, agents/ holds the working cancellation primitive built against it.

python
token.cancel("user stopped")   # from outside — idempotent
token.check()                  # inside: raises CancellationError if cancelled
await token.wait()             # inside: block until cancelled
child = token.child()          # a child token cancelled when this one is

The one method you call in the loop: meta.check()

This is how the per-agent deadline_s budget and a manual cancel both get enforced. Call it at every cooperative yield point — before an LLM call, before a tool runs, between loop iterations:

python
def check(self) -> None:
    self.cancellation.check()                 # cancelled?  -> CancellationError
    if self.deadline and now() > self.deadline:
        raise CancellationError("deadline exceeded")
diagram
Rendering diagram…
Deadlines compose with the budget

ExecutionBudget.deadline_s is seconds from run start. The runtime turns that into the absolute RunMeta.deadline, and meta.check() is what actually trips it. Same idea, two layers: the kernel states the policy, the runtime enforces it.


CompactionStrategy and AgentContextProtocol — assembling the prompt

A long conversation does not fit in a model's context window, and stuffing the whole transcript in would be slow and expensive. CompactionStrategy is the clerk who trims the fat email thread before you read it: it turns the full raw history into a manageable window. The kernel only fixes the shape — the actual trimming (sliding window, token truncation, LLM summarisation) is an implementation up in agents/.

python
class CompactionStrategy(Protocol):
    async def compact(self, raw_history: list[ChatMessage]) -> list[ChatMessage]:
        """Return the optimised sequence ready for LLM generation."""
        ...

Both input and output are list[ChatMessage] — the same type LLMClient.generate already consumes, so compaction slots in transparently.

AgentContextProtocol is the minimal surface the agent loop sees of its own runtime context. It exposes only two things: the agent's own id, and a way to get the already-compacted prompt window for a session. Storage details (which history provider, which compaction strategy) are deliberately hidden behind it.

python
class AgentContextProtocol(Protocol):
    @property
    def agent_id(self) -> AgentId: ...

    async def get_prompt_window(self, session_id: str) -> list[ChatMessage]: ...
Why so small?

The agent loop should not know how its history is stored or trimmed — only that it can ask for "the messages I should send to the model right now." Keeping the Protocol tiny is what lets the storage and compaction internals change without touching agent code.


Middleware — the airport-security layers

Plenty of things should happen around a model call that are not the agent's reasoning: cache identical requests, retry on a blip, validate the output, redact PII, log for audit, rate-limit a tenant. Middleware pulls each of those into a composable layer — like the layered checkpoints at airport security: each layer can inspect you on the way in, wave you through to the next, then inspect you again on the way out.

Unlike every other type on this page, the Middleware Protocol and its MiddlewareContext do not live in kernel. Every real middleware implementation needs the concrete context's stage-specific fields (messages, arguments, turn_result/chat_result/tool_result, …), which are dataclass fields defined in agents/middleware/_contracts.py — a kernel-minimal duplicate of the Protocol would have zero real consumers (an earlier version of this module had one; nothing outside kernel's own re-export ever imported it). So the interceptor shape lives one layer up:

python
# agents/middleware/_contracts.py
class Middleware(Protocol):
    async def process(
        self,
        context: MiddlewareContext,
        call_next: Callable[[], Awaitable[None]],
    ) -> None: ...

The pattern is always: do work before, await call_next() to go inward, do work after as control unwinds. Skip call_next() to short-circuit (a cache hit). Raise to abort — typically MiddlewareTermination for a guardrail block.

diagram
Rendering diagram…

The one piece of middleware machinery the kernel does define is MiddlewareStage — a plain, dependency-free enum every middleware implementation across the agents layer needs, saying which of the three moments a given context represents:

python
class MiddlewareStage(str, Enum):
    TURN = "turn"   # one inbox message
    CHAT = "chat"   # one model.generate() call
    TOOL = "tool"   # one tool.execute() call

That's it — one enum, no Protocol, no per-level type aliases. The concrete MiddlewareContext at the agents/ layer carries the stage field plus the full set of stage-specific fields (messages, arguments, turn_result/chat_result/tool_result, …); see Middleware for that shape and agents/core/react.py/agents/runtime/context/ for where each stage is actually dispatched.

Why `Middleware` isn't a kernel contract

Every other Protocol on this page is here because kernel needs to typecheck against it without importing a concrete, richer class from agents/. Middleware doesn't have that problem — nothing outside agents/middleware/ and its callers ever needs to reference the interceptor shape independently of the concrete context, so keeping a second, kernel-minimal Protocol around was pure duplication with no real consumers. The MiddlewarePipeline that threads layers together, and the built-ins (Cache, Retry, RateLimiter, guardrails …), live in agents/middleware/ — see Middleware for the full onion and the catalogue.


Summary

TypeKindOne-liner
Supervisionfrozen dataclassan agent's position in the run tree; threads ids + policy down via spawn_child()
SpawnBudgetfrozen dataclassrun-wide headcount cap (max_agents, allow_preempt) — one shared object
ExecutionBudgetfrozen dataclassper-agent spend cap (max_tokens, max_cost_usd, max_turns, deadline_s)
Priorityint enumbranch weight BACKGROUND(0)…CRITICAL(8) for allocation + preemption
HistoryRetentionstr enumNONE / RUN / PERMANENT history lifetime
RunMetafrozen dataclassper-run badge: run_id, cancellation, deadline, trace, tenant; check() enforces stop + deadline
CancellationTokenProtocol (kernel); concrete class in agents/runtime/cancellation.pycooperative stop button: cancel(), check(), wait(), child()
CompactionStrategyProtocoltrims raw history into a prompt window
AgentContextProtocolProtocolminimal context the loop sees: agent_id + get_prompt_window()
MiddlewareProtocol — lives in agents/middleware/_contracts.py, not kernelone process(context, call_next) interceptor shape, used for all three stages
MiddlewareStagestr enum (kernel)TURN / CHAT / TOOL — which moment a MiddlewareContext represents
The one rule to remember

The kernel only states policy — frozen shapes with no behaviour. The agents/ layer holds the mutable trackers and pipelines that enforce it. That split is why you can reason about budgets, retention, and cancellation as plain data, and swap the enforcement machinery without touching the contracts.


Where this lives

PieceLocation
Supervision, SpawnBudget, ExecutionBudget, Priority, HistoryRetentionkernel/agent/supervision.py
RunMeta, CancellationToken (Protocol)kernel/agent/runtime_context.py
CompactionStrategy, AgentContextProtocolkernel/agent/context.py
MiddlewareStagekernel/agent/middleware.py
AgentId, TopicId (ids carried by Supervision)kernel/core/identity.py
ChatMessage (compaction payload)kernel/core/content.py
CancellationError, BudgetExhaustedError, MiddlewareTerminationkernel/core/errors.py
SpawnTracker (headcount + preemption)agents/supervision/budget.py
ExecutionTracker (per-agent spend)agents/resources/budget.py
CancellationToken (concrete implementation)agents/runtime/cancellation.py
Middleware, MiddlewareContextagents/middleware/_contracts.py
MiddlewarePipeline + built-in middlewaresagents/middleware/

Next: The Durable Runtime — the contracts that make a run survivable: the inbox, the scheduler, the event log, and the journal that lets a crashed run resume without re-paying for what it already did.