---
title: "Agent limits"
description: "Bound execution, loops, delegation and the whole job tree."
canonical_url: "https://docs.oz0.ai/build/parts/contracts-and-limits/limits"
---
# Agent limits

> Bound execution, loops, delegation and the whole job tree.

For: plugin authors.

Bound execution, loops, delegation and the whole job tree.

## Limits

```yaml [agents/acme-reviewer.yaml]
limits:
  max_steps: 20        # model calls per job (default 50)
  max_tokens: 200000   # input plus output tokens per job
  timeout: 10m         # wall-clock time, such as 90s, 10m or 1h30m
  max_cost_usd: 2.0    # the job's default budget, for it and every job it delegates to
  max_output_tokens: 4k  # the most each model request may answer: what its budget reserves per call
```

A job that reaches a limit ends with the reason. `run --timeout` adds a limit on top for one job.

## Loops

The runtime watches every call an agent makes of the tenant's tools, outside the model, from what the job's workflow already holds: the function, a digest of its arguments, and a hash of its result. It finds three kinds of loop:

- repeats: the same function with the same arguments `loops.repeats` times (3);
- cycles: a sequence of two to four calls repeated `loops.cycles` times in a row (2), such as search, read, search, read;
- no progress: `loops.no_progress` results in a row (5) that add nothing: a result the job saw before, an empty one, or the same error again.

A call that changes its arguments, such as a paging agent asking for the next page, is never a repeat. What a repeat or a cycle does is the agent's `on_loop`:

```yaml [agents/acme-reviewer.yaml]
on_loop: wait        # report, wait (the default) or stop
loops:
  repeats: 4         # the same call four times
  no_progress: 6
```

- `wait`: before the call that completes the loop, the job waits as an approval of kind `loop`, with the evidence: which detector, which calls, and the repeated call's arguments. It has the tenant's deadline and [escalation ladder](https://docs.oz0.ai/agents/approvals/configure#the-escalation-ladder). A person who approves continues the job: the counters start again, and the call is made. A denial, or nobody deciding in time, stops the job with `LoopDetected`.
- `stop`: the job ends with `LoopDetected` and the evidence.
- `report`: the job goes on, as before: the trace's `possible_loop` finding says so.

Harness sessions. A harness's own tools (its shell, file reads and edits) are watched only through the calls the session asks about, and a coding session runs the same test command after each edit. So among them only a repeat in a row counts (the same call again with no other call between), no cycle is looked for, and the loop is reported unless the agent or the tenant sets `on_loop`, which then wins. The tenant's tools a session calls are watched as an agent's are.

No progress leaves the agent only its answer: every tool but the answer (and a routine's `oz0.output`) is withdrawn for the rest of the run, and the result that crossed the line tells the model why. A job of an openai-agents agent loses its tools the same way; a harness session's further calls are refused with the same words.

```bash [Terminal]
oz0 approval list                       # a loop waits like any approval, kind loop
oz0 approval approve job-7f3a a-91c2    # continue it
oz0 approval deny job-7f3a a-91c2 --comment "it spins on the same page"
```

Each loop the runtime acts on is counted on the edge (per day, tenant, plugin and tool; never what was said). A tool that loops in three or more tenants in a week shows up in `oz0 doctor` for the cluster's admins, with its risk class and the kill switch.

## Limits of a whole job tree

`limits.tree` bounds the whole tree a job is the root of, its children's children included:

```yaml [agents/acme-reviewer.yaml]
limits:
  tree:
    model_calls: 500   # every model call of the tree
    tokens: 2000000    # their input and output tokens
    children: 40       # child jobs in all
    fan_out: 5         # child jobs running at once
    depth: 3           # how deep the tree may go
    wait: 8h           # the time each of its jobs may wait for people, in all
```

The gateway refuses a call past the tree's model calls or tokens with `402 oz0_tree_limit`.

The platform's own re-warm of a job's prompt cache after an approval (see [The prompt cache](https://docs.oz0.ai/spend/models-and-keys/cache-and-tools#the-prompt-cache)) is not one of the tree's model calls and adds nothing to its tokens, though its cost counts against the budget; the edge refuses a child that would pass `children`, `fan_out` or `depth`, and the agent's model reads which limit and how to raise it; a job whose waits for people (approvals, questions, loop and budget waits) add up past `wait` fails with `TreeLimitExceeded`; each job of the tree counts its own.

The tree's limits are its root agent's, over the tenant's (`oz0 tenant spend --tree-fan-out 5 ...`).

A limit left out, or set to `0` (an empty `wait`), takes the tenant's, then the default in the table below. To remove a limit, set it to `-1` (`wait: none`):

```yaml [agents/acme-batch.yaml]
limits:
  tree:
    fan_out: -1        # no limit on children at once, though the default is 100
    wait: none         # no limit on waiting for people, though the tenant set one
```

Children are admitted where the edge sees them start. The runtime asks the edge before it starts a child; the edge records the admission once, and checks again when the start command reaches it: the tree's `depth`, `children` and `fan_out`, and that the tree's budget is not spent, for every child.

A delegation or a flow tool's flow, which the runtime names when it asks, is also held to the parent agent's `delegation.max_depth` and `max_children` (delegations and flow tools together, per run), counted as the runtime counts them; an outcome's reviewer and a flow's steps are not delegations and meet only the tree's limits. A child the runtime never asked about is checked against the tree's limits and budget there. So a modified runtime cannot start more children than the tree allows: the edge fails such a job with the reason.

The runtime takes each delegation's slot before it waits for anything, so the parallel delegations of one model response cannot all pass `max_children` either.

## Every limit in one table

Each limit of a job, what it counts, its default, and where the platform enforces it. An agent's definition sets its own (`limits`, `limits.tree`, `loops`, `on_loop`, `on_budget`, `spend`), and `oz0 tenant spend` sets the tenant's defaults for the ones under `limits.tree`, `loops` and `spend`; the agent's win.

<table>
<thead>
  <tr>
    <th>
      Limit
    </th>
    
    <th>
      Counts
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Enforced in
    </th>
    
    <th>
      When it is reached
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        limits.max_steps
      </code>
    </td>
    
    <td>
      run
    </td>
    
    <td>
      50
    </td>
    
    <td>
      runtime
    </td>
    
    <td>
      the run fails with UsageLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.max_tokens
      </code>
    </td>
    
    <td>
      run
    </td>
    
    <td>
      none
    </td>
    
    <td>
      runtime
    </td>
    
    <td>
      the run fails with UsageLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.timeout
      </code>
    </td>
    
    <td>
      job
    </td>
    
    <td>
      none (sessions: 2h)
    </td>
    
    <td>
      workflow
    </td>
    
    <td>
      the job fails with AgentTimeout
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.max_cost_usd
      </code>
      
      , <code>
        --budget
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      none
    </td>
    
    <td>
      gateway, before each call (its worst case reserved)
    </td>
    
    <td>
      <code>
        on_budget
      </code>
      
      : wait for a person, or fail with BudgetExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        reserve
      </code>
    </td>
    
    <td>
      every budget of the tree
    </td>
    
    <td>
      last_call
    </td>
    
    <td>
      gateway
    </td>
    
    <td>
      strict: the last call that does not fit is refused too
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        delegation.max_depth
      </code>
    </td>
    
    <td>
      chain
    </td>
    
    <td>
      1
    </td>
    
    <td>
      runtime, and the edge when a child starts
    </td>
    
    <td>
      the model is told it may not delegate deeper
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        delegation.max_children
      </code>
    </td>
    
    <td>
      run
    </td>
    
    <td>
      none
    </td>
    
    <td>
      runtime, and the edge when a child starts
    </td>
    
    <td>
      the model is told to answer with what it has
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.tree.model_calls
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      none
    </td>
    
    <td>
      gateway
    </td>
    
    <td>
      402 <code>
        oz0_tree_limit
      </code>
      
      : the job fails with TreeLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.tree.tokens
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      none
    </td>
    
    <td>
      gateway
    </td>
    
    <td>
      402 <code>
        oz0_tree_limit
      </code>
      
      : the job fails with TreeLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.tree.children
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      500
    </td>
    
    <td>
      edge, when a child starts
    </td>
    
    <td>
      the child is not started: TreeLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.tree.fan_out
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      100
    </td>
    
    <td>
      edge, when a child starts
    </td>
    
    <td>
      the child is not started: TreeLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.tree.depth
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      10
    </td>
    
    <td>
      edge, when a child starts
    </td>
    
    <td>
      the child is not started: TreeLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        limits.tree.wait
      </code>
    </td>
    
    <td>
      each job of the tree
    </td>
    
    <td>
      none
    </td>
    
    <td>
      workflow
    </td>
    
    <td>
      the job fails with TreeLimitExceeded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        loops.repeats
      </code>
    </td>
    
    <td>
      run
    </td>
    
    <td>
      3
    </td>
    
    <td>
      runtime
    </td>
    
    <td>
      <code>
        on_loop
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        loops.cycles
      </code>
    </td>
    
    <td>
      run
    </td>
    
    <td>
      2
    </td>
    
    <td>
      runtime
    </td>
    
    <td>
      <code>
        on_loop
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        loops.no_progress
      </code>
    </td>
    
    <td>
      run
    </td>
    
    <td>
      5
    </td>
    
    <td>
      runtime
    </td>
    
    <td>
      only the answer is left (finish-only)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        on_loop
      </code>
    </td>
    
    <td>
      run
    </td>
    
    <td>
      wait
    </td>
    
    <td>
      runtime
    </td>
    
    <td>
      report, wait for a person, or stop with LoopDetected
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        on_budget
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      wait if a person started the tree, else fail
    </td>
    
    <td>
      gateway and edge
    </td>
    
    <td>
      wait for a person, or fail
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        on_unknown
      </code>
    </td>
    
    <td>
      job
    </td>
    
    <td>
      wait if a person started the tree, else fail
    </td>
    
    <td>
      runtime, asking the edge
    </td>
    
    <td>
      wait for a person (it happened, run it again, stop), or fail with unknown_outcome
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        spend.warn_share
      </code>
    </td>
    
    <td>
      budgets and quotas
    </td>
    
    <td>
      80%
    </td>
    
    <td>
      runtime and edge
    </td>
    
    <td>
      the agent is told to wrap up (people are alerted at the tenant's spend alerts)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        spend.soft_wall
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      85%
    </td>
    
    <td>
      runtime
    </td>
    
    <td>
      only the answer is left, and the agent is asked to report
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        spend.forecast.chance
      </code>
    </td>
    
    <td>
      job tree
    </td>
    
    <td>
      80%
    </td>
    
    <td>
      gateway
    </td>
    
    <td>
      a budget request before the wall (with <code>
        on_budget: wait
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        spend.forecast.min_runs
      </code>
    </td>
    
    <td>
      agent
    </td>
    
    <td>
      20
    </td>
    
    <td>
      edge
    </td>
    
    <td>
      no forecast until the agent has this many finished trees
    </td>
  </tr>
  
  <tr>
    <td>
      monthly quota (<code>
        oz0 quota set
      </code>
      
      )
    </td>
    
    <td>
      person or team
    </td>
    
    <td>
      none
    </td>
    
    <td>
      gateway and edge
    </td>
    
    <td>
      the job waits for a budget request
    </td>
  </tr>
  
  <tr>
    <td>
      tenant monthly budget
    </td>
    
    <td>
      tenant
    </td>
    
    <td>
      none
    </td>
    
    <td>
      gateway
    </td>
    
    <td>
      402: every call of the tenant fails with BudgetExceeded
    </td>
  </tr>
</tbody>
</table>

### Large tool results

One more limit lives apart from the table, since it ends nothing: the threshold above which a tool result reaches the
model as a stub, with `oz.read_result` to read parts of it back ([large results](https://docs.oz0.ai/build/parts/tools/results-and-files#large-results)).

<table>
<thead>
  <tr>
    <th>
      Where
    </th>
    
    <th>
      Sets
    </th>
    
    <th>
      Default
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      The agent's definition, <code>
        offload
      </code>
    </td>
    
    <td>
      its own threshold in tokens, or <code>
        off
      </code>
    </td>
    
    <td>
      unset: the tenant's default
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        oz0 tenant offload &lt;tenant> --default
      </code>
    </td>
    
    <td>
      the threshold of agents that set none: tokens, <code>
        off
      </code>
      
      , or <code>
        platform
      </code>
    </td>
    
    <td>
      10,000 tokens (provisional, until measured)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        oz0 tenant offload &lt;tenant> --ceiling
      </code>
    </td>
    
    <td>
      the most an agent may set; an agent's <code>
        off
      </code>
      
       becomes the ceiling
    </td>
    
    <td>
      none: an agent may set anything, or turn it off
    </td>
  </tr>
</tbody>
</table>

The agent's value wins within the ceiling. The runtime resolves the threshold when a job starts and keeps it for the
whole job, also after a continue-as-new. The tenant's settings are part of its tool policy (`oz0 tenant tool-policy`
keeps them), so a change takes `--reason` as any tool-policy change does; changing them is never counted as a
loosening, since every result stays readable by its own job with its labels. The admin API carries them as
`ToolPolicy.offload` (JSON: `{"default": 6000, "ceiling": 20000}`, with `-1` as the default for off).

```bash
oz0 tenant offload default --default 6000 --ceiling 20000 --reason "long pages fill the context"
```

## Tools and delegation

An agent can call only the tools it lists and delegate only to the agents in `delegation.allow`, within `max_depth` and `max_children`.

<note>

Functions in a tool's `requires_approval` wait for a person before they run; see [Approvals](https://docs.oz0.ai/agents/approvals). Your own [hooks](https://docs.oz0.ai/build/parts/hooks) can allow, deny or change calls, and [evals](https://docs.oz0.ai/improve/evals) stop a worse version at install. During a rollout, a quiet tenant's new version is tried on recent real inputs with write tools stubbed ([shadow runs](https://docs.oz0.ai/build/plugins/install/rollouts#shadow-runs-for-a-quiet-tenant)). An [outcome check](https://docs.oz0.ai/improve/outcome-checks) has a judge, a model call of its own, score each answer against a rubric and send a failing one back.

</note>


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
