Agent contracts

Agent limits

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

For: plugin authors.

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

Limits

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:

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. 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.

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:

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

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.

LimitCountsDefaultEnforced inWhen it is reached
limits.max_stepsrun50runtimethe run fails with UsageLimitExceeded
limits.max_tokensrunnoneruntimethe run fails with UsageLimitExceeded
limits.timeoutjobnone (sessions: 2h)workflowthe job fails with AgentTimeout
limits.max_cost_usd, --budgetjob treenonegateway, before each call (its worst case reserved)on_budget: wait for a person, or fail with BudgetExceeded
reserveevery budget of the treelast_callgatewaystrict: the last call that does not fit is refused too
delegation.max_depthchain1runtime, and the edge when a child startsthe model is told it may not delegate deeper
delegation.max_childrenrunnoneruntime, and the edge when a child startsthe model is told to answer with what it has
limits.tree.model_callsjob treenonegateway402 oz0_tree_limit: the job fails with TreeLimitExceeded
limits.tree.tokensjob treenonegateway402 oz0_tree_limit: the job fails with TreeLimitExceeded
limits.tree.childrenjob tree500edge, when a child startsthe child is not started: TreeLimitExceeded
limits.tree.fan_outjob tree100edge, when a child startsthe child is not started: TreeLimitExceeded
limits.tree.depthjob tree10edge, when a child startsthe child is not started: TreeLimitExceeded
limits.tree.waiteach job of the treenoneworkflowthe job fails with TreeLimitExceeded
loops.repeatsrun3runtimeon_loop
loops.cyclesrun2runtimeon_loop
loops.no_progressrun5runtimeonly the answer is left (finish-only)
on_looprunwaitruntimereport, wait for a person, or stop with LoopDetected
on_budgetjob treewait if a person started the tree, else failgateway and edgewait for a person, or fail
on_unknownjobwait if a person started the tree, else failruntime, asking the edgewait for a person (it happened, run it again, stop), or fail with unknown_outcome
spend.warn_sharebudgets and quotas80%runtime and edgethe agent is told to wrap up (people are alerted at the tenant's spend alerts)
spend.soft_walljob tree85%runtimeonly the answer is left, and the agent is asked to report
spend.forecast.chancejob tree80%gatewaya budget request before the wall (with on_budget: wait)
spend.forecast.min_runsagent20edgeno forecast until the agent has this many finished trees
monthly quota (oz0 quota set)person or teamnonegateway and edgethe job waits for a budget request
tenant monthly budgettenantnonegateway402: every call of the tenant fails with BudgetExceeded

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).

WhereSetsDefault
The agent's definition, offloadits own threshold in tokens, or offunset: the tenant's default
oz0 tenant offload <tenant> --defaultthe threshold of agents that set none: tokens, off, or platform10,000 tokens (provisional, until measured)
oz0 tenant offload <tenant> --ceilingthe most an agent may set; an agent's off becomes the ceilingnone: an agent may set anything, or turn it off

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).

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.

Functions in a tool's requires_approval wait for a person before they run; see Approvals. Your own hooks can allow, deny or change calls, and 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). An outcome check has a judge, a model call of its own, score each answer against a rubric and send a failing one back.
Copyright © 2026