Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,14 +356,47 @@ JamJet uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
whoever registers workflows. Attribution does not carry into a subgraph's child
workflow. In the Python SDK, `Agent(agent_ref=...)` and YAML `agent_ref:` (workflow)
/ `acts_for:` (node) set it.
- The docs now say what the rest of the card does, because several pages claimed more
than the runtime does: `token_budget` and `cost_budget_usd` are checked only for a
`bounded_autonomous` card on an `agent` node calling it, in the in-process worker;
`max_iterations` and `max_tool_calls` are compared with counters the runtime never
increments, so a limit above 0 never trips (a limit of 0 always does); `time_budget_secs` is never read; the error circuit
breaker never trips; nothing routes an autonomy escalation to a human or a
supervisor; and no tool compiles an `agents.yaml` into a card. See "What each
constraint does" in docs/guides/agent-model-guide.md.
- The card's limits are enforced too: see the next entry.

- **An agent's autonomy limits are enforced.** `max_iterations` and `max_tool_calls`
were compared with counters nothing incremented, `time_budget_secs` was never read,
the circuit breaker never tripped, and `token_budget` and `cost_budget_usd` were
checked only for a `bounded_autonomous` card on an `agent` node in the in-process
worker. Every limit is now checked at the shared guard, at every autonomy level, on
the HTTP claim route and the in-process worker, for every node acting for the agent
and for an `agent` node calling it.
- The counts come from the run's event log, not from in-memory or snapshot counters:
each admitted step is recorded as a new `agent_step_admitted` event (its agents and
tool calls), and tokens, cost and failures are read from the step's `node_completed`
/ `node_failed`. They survive a restart and a continue-as-new rollover, and a node's
`state_patch` cannot reset them. The step is recorded in a compare-and-set that
re-checks the limits, so two claims cannot both take the last step.
- An iteration is a step: a node acting for the agent that was let fire, retries
included. A tool call is one per step whose node uses a tool, plus each call of an
ADK dispatch batch; model steps count none. The time budget runs from the agent's
first step in the run. The circuit breaker counts the agent's failed steps since its
last completed one (new card field `max_consecutive_errors`, default 3) and applies
even to a card with no constraints.
- On a limit the node is refused and fails for good, recording
`autonomy_limit_reached` or `circuit_breaker_tripped`. New card field
`on_limit_exceeded: escalate_to_human` holds the node for an ordinary approval
instead (same digest binding, the policy chain's deadline, the limit shown under
`autonomy_limit`). Each approval of such a request releases one step: a retry of a failed step or a duplicate item asks again, and an approval given for another gate before the limit was reached does not count. `escalate_to_supervisor`
is not implemented and a card that asks for it is refused.
- `POST /work-items/:id/complete` accepts `cost_usd` (a negative or non-finite one is
400), and records a result for a node acting for an agent only for an item the claim
route admitted (409 otherwise). The Python worker forwards `input_tokens`,
`output_tokens` and `cost_usd` from a node's output, and `JamjetClient.complete_work_item`
takes them. No executor in this runtime computes a cost, so a cost budget counts
only what workers report.
- Behaviour changes to plan for: a card that never tripped can now refuse nodes,
including through the default circuit breaker; an item claimed by an earlier build
for a node acting for an agent cannot be completed over HTTP (its lease lapses and
the node is claimed again); the worker no longer runs its own autonomy check, and
`BudgetState`'s iteration, tool call and error counters are documented as unused.
A build that predates this release reads `agent_step_admitted` as an unknown event
and stops advancing the run, so do not roll back past it on a store it has written.
See "Autonomy limits" in docs/guides/agent-model-guide.md.

- **The audit log can be signed with Ed25519, so a third party can verify it.** HMAC
signing uses one shared key, so anyone who can verify the log can also forge it. With
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/agent-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ agent:
| `bounded_autonomous` | Self-directs within iteration, token, cost limits | Configurable |
| `fully_autonomous` | Free operation with guardrails only | Requires explicit policy |

What the runtime enforces of the level and its constraints is listed in the [Agent Model Guide](../guides/agent-model-guide.md#what-each-constraint-does). In short: a card's tool and delegation lists apply to every node that acts for the agent, at every level; for a `bounded_autonomous` card, an `agent` node calling it is refused (with `autonomy_limit_reached` and `escalation_required` events) once the run reaches the card's token or cost budget, in the in-process worker only. Iteration and tool-call limits are not counted, and nothing routes an escalation to a supervisor agent or a human approval node.
What the runtime enforces of the level and its constraints is listed in the [Agent Model Guide](../guides/agent-model-guide.md#what-each-constraint-does). In short: a card's tool and delegation lists and its limits (iterations, tool calls, tokens, cost, time and consecutive errors) apply to every node that acts for the agent, at every level, on both worker transports, counted from the run's event log. On a limit the node fails, or, with `on_limit_exceeded: escalate_to_human`, is held for a human approval. Nothing routes an escalation to a supervisor agent.

---

Expand Down
47 changes: 35 additions & 12 deletions docs/guides/agent-model-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ agents:
max_tool_calls: 50
token_budget: 100000
cost_budget_usd: 5.00
time_budget_secs: 600
max_consecutive_errors: 3
on_limit_exceeded: escalate_to_human
allowed_tools: [search_web, read_document, analyze_data]
blocked_tools: ["delete_*", "write_to_production_*"]
require_approval_for: [publish, external_api_write]
Expand All @@ -33,9 +36,8 @@ The runtime reads these settings from the Agent Card registered with `POST /agen
|------------|----------|-------|
| `allowed_tools`, `blocked_tools`, `require_approval_for` | Yes | Every node that acts for the agent (`agent_ref`), on the HTTP claim route, the in-process worker and the completion gate. See below. |
| `allowed_delegations` | Yes | Same nodes: `agent`, `agent_tool` and `a2a_task` targets. See below. |
| `token_budget`, `cost_budget_usd` | Partly | Only for a `bounded_autonomous` card, only on an `agent` node that calls it (the card named by that node's own `agent_ref` field, which says whom it calls, not the workflow's attribution), and only in the in-process worker, not on the claim route. Compared with the run's token and cost totals as the in-process worker accumulates them (results reported through the HTTP worker protocol add nothing); at or over the limit, or when the registry cannot be read, the node does not run and `autonomy_limit_reached` and `escalation_required` events are recorded (see Budget Enforcement below). |
| `max_iterations`, `max_tool_calls` | No, in effect | Read in the same place as the budgets, but compared with counters the runtime never increments, so a limit above 0 never trips (a limit of 0 always does). |
| `time_budget_secs` | No | Stored, never read. |
| `max_iterations`, `max_tool_calls`, `token_budget`, `cost_budget_usd`, `time_budget_secs`, `max_consecutive_errors` | Yes | Every node that acts for the agent, and an `agent` node calling it, at every autonomy level, on the HTTP claim route and the in-process worker. Counted from the run's event log. See [Autonomy limits](#autonomy-limits). |
| `on_limit_exceeded` | Yes | `fail` (default) or `escalate_to_human`. See [Autonomy limits](#autonomy-limits). |

### Enforcing `allowed_tools`

Expand Down Expand Up @@ -65,12 +67,12 @@ From the Python SDK, `Agent("researcher", ..., agent_ref="jamjet://acme/research

| Level | What it means | What the runtime enforces for it |
|-------|--------------|------|
| `deterministic` | Follows the workflow graph exactly, no self-direction | Nothing beyond the card's tool and delegation lists |
| `guided` | Follows the graph, but chooses how to approach each node (default) | Nothing beyond the card's tool and delegation lists |
| `bounded_autonomous` | Self-directs within iteration, token, and cost budgets | The token and cost budgets, as described in the table above |
| `fully_autonomous` | Operates freely within guardrails | Nothing beyond the card's tool and delegation lists |
| `deterministic` | Follows the workflow graph exactly, no self-direction | The card's lists and limits |
| `guided` | Follows the graph, but chooses how to approach each node (default) | The card's lists and limits |
| `bounded_autonomous` | Self-directs within iteration, token, and cost budgets | The card's lists and limits |
| `fully_autonomous` | Operates freely within guardrails | The card's lists and limits |

The tool and delegation lists apply at every level. A circuit breaker for repeated errors is defined for every level, but the runtime does not count consecutive errors, so it never trips.
The level does not change what is enforced: every level gets the tool and delegation lists and every limit the card declares, and the circuit breaker applies even to a card that declares no constraints. Guided mode does not hold every tool call for approval; name the tools in `require_approval_for` for that.

---

Expand Down Expand Up @@ -129,10 +131,31 @@ When an `agent` node runs, JamJet invokes the referenced agent with the given in

---

## Budget Enforcement
## Autonomy limits

When an `agent` node calls a `bounded_autonomous` agent whose card's `token_budget` or `cost_budget_usd` the run has already reached (in-process worker only):
1. `autonomy_limit_reached` and `escalation_required` (target `human_approval`) events are recorded
2. The node does not run. The worker leaves the item to its lease, so it is retried, and refused again, until its attempts run out. Nothing routes to a human or a supervisor agent automatically.
A node that acts for an agent (the workflow's `agent_ref`, or the node's own) is one step of that agent. So is an `agent` node, for the agent it calls; that agent's tool and delegation lists do not apply to the node, only its limits. When the runtime lets such a node fire, it records an `agent_step_admitted` event naming the agents and the tool calls the step makes. Every limit is checked before the step fires, against what the agent used in the run so far, all of it read from the run's event log:

| Limit | What is counted | Refused when |
|-------|-----------------|--------------|
| `max_iterations` | Steps admitted, retries included | The agent has taken that many steps |
| `max_tool_calls` | One per step whose node uses a tool (`tool`, `mcp_tool`, `finalizer`, an `a2a_task` with a skill, `agent`, `agent_tool`, function nodes, and nodes that pick a target at run time), plus each call of an ADK dispatch batch. Model and condition steps count none | This step's calls would take the total past the limit (a step that makes no call is not limited) |
| `token_budget` | `input_tokens + output_tokens` on the agent's completed steps | The total is at or over the budget |
| `cost_budget_usd` | `cost_usd` on the agent's completed steps | The total is at or over the budget |
| `time_budget_secs` | Wall-clock time since the agent's first step in the run | That long has passed |
| `max_consecutive_errors` (default 3) | The agent's failed steps since its last completed step | That many in a row have failed |

- Counted over the whole run: a continue-as-new segment and an engine restart keep the counts. A node's output or `state_patch` cannot change them.
- A limit of 0 refuses the first step (for `time_budget_secs`, the second).
- Token and cost totals grow only when a step reports. The step that crosses a budget is not refused (its usage is unknown until it finishes), so a total can end above the budget by that step's usage. A time budget is checked when a step starts; a running step is not interrupted.
- Usage is what the step reports. The in-process model executor reports tokens. No executor in this runtime reports a cost, so cost is counted only when a worker reports `cost_usd` on `POST /work-items/:id/complete` (the Python worker forwards `input_tokens`, `output_tokens` and `cost_usd` from a node's output); with nothing reported, the cost total stays 0 and a budget above 0 never trips. A worker that under-reports, or reports a failed call as completed, is believed.
- Only steps admitted while the guard records them count. A step running when the runtime is upgraded to this release has no record and adds nothing.
- Two claims cannot both take the last step: the step is recorded in the same compare-and-set that checks the limits.
- Over HTTP, a result for a node acting for an agent is recorded only for a work item the claim route admitted (409 otherwise), and only once per admission.
- A step is recorded when the guard lets the node fire. In the in-process worker a few things can still stop it after that (a spent workflow-level budget, an idempotency key another worker holds); such a step counts though it did not run. This errs toward refusing.
- Only an `agent` node charges the agent it calls. An `agent_tool`, `a2a_task` or coordinator that reaches a registered agent does not charge that agent's limits; attribute the node with `acts_for` to count it. An in-process worker built without an agent registry does not apply a called agent's limits (nodes acting for an agent are refused there).

On a limit, with `on_limit_exceeded: fail` (the default), the node is refused and fails for good, which fails the run. The runtime records `autonomy_limit_reached` (`limit_type` is `max_iterations`, `max_tool_calls`, `token_budget`, `cost_budget` or `time_budget`) or `circuit_breaker_tripped`, and the node's `node_failed` error names the agent and the limit (it contains `agent autonomy`).

With `on_limit_exceeded: escalate_to_human`, the node is held for an ordinary approval instead: the request is bound to the action like any other (the same digest), carries the policy chain's deadline, and shows the limit under `autonomy_limit` in its context. `autonomy_limit_reached` (or `circuit_breaker_tripped`) and `escalation_required` (target `human_approval`) are recorded. An approval releases that one step, once: a retry of a step that failed, a duplicate item, or the next step past the limit asks again. Only a request raised for the limit (one whose context shows `autonomy_limit`) can release it, so an approval given for a policy or card gate before the limit was reached does not. A failing card's limit reached while the approved step was being decided still refuses it. Without a deadline in the policy chain the request does not expire. A rejection or an expired request fails the node. If a node acts for several agents and one card says `fail`, the node is refused. RFC-005's `escalate_to_supervisor` is not implemented, and a card that sets it is refused at registration.

A workflow-level `token_budget` or `cost_budget_usd` in the IR is a separate check, made by the in-process worker for the nodes it runs; the HTTP claim route does not make it.
2 changes: 1 addition & 1 deletion docs/guides/yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ agents:
token_env: PARTNER_API_KEY
```

`autonomy` and `constraints` mirror the Agent Card's fields. The runtime reads them from the card registered with `POST /agents`, not from this file, and enforces only some of them: see [What each constraint does](agent-model-guide.md#what-each-constraint-does).
`autonomy` and `constraints` mirror the Agent Card's fields. The runtime reads them from the card registered with `POST /agents`, not from this file: see [What each constraint does](agent-model-guide.md#what-each-constraint-does).

---

Expand Down
4 changes: 3 additions & 1 deletion docs/rfcs/RFC-005-agent-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ interface AutonomyConstraints {
}
```

Implementation status (2026-10): the card stores `time_budget` as `time_budget_secs`. Which constraints the runtime enforces, and where, is listed in the [Agent Model Guide](../guides/agent-model-guide.md#what-each-constraint-does).
Implementation status (2026-10): the card stores `time_budget` as `time_budget_secs`, and adds `max_consecutive_errors` (the circuit breaker's threshold, default 3) and `on_limit_exceeded` (`fail` or `escalate_to_human`). Which constraints the runtime enforces, and where, is listed in the [Agent Model Guide](../guides/agent-model-guide.md#what-each-constraint-does).

### 3. Agent Lifecycle

Expand Down Expand Up @@ -157,6 +157,8 @@ When any constraint is exceeded:
- `escalate_to_supervisor` — delegates to a configured supervisor agent
- `fail` — marks the node/workflow as failed

Implementation status (2026-10): the counts are folded from the run's event log per agent, not kept per execution, and the handler is the card's `on_limit_exceeded`. A node over a limit records `autonomy_limit_reached` or `circuit_breaker_tripped`; `fail` fails it, `escalate_to_human` holds it for an approval. `escalate_to_supervisor` is not implemented. See the [Agent Model Guide](../guides/agent-model-guide.md#autonomy-limits).

### 6. Hot Reload

When an agent's definition is updated (new version):
Expand Down
28 changes: 28 additions & 0 deletions runtime/agents/src/card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,34 @@ pub struct AutonomyConstraints {
/// Operations that require explicit approval before execution.
pub require_approval_for: Vec<String>,
pub time_budget_secs: Option<u64>,
/// Consecutive failed steps of this agent before its circuit breaker
/// trips. Absent means [`DEFAULT_MAX_CONSECUTIVE_ERRORS`].
pub max_consecutive_errors: Option<u32>,
/// What happens when a limit is reached or the circuit breaker trips.
/// Absent means [`OnLimitExceeded::Fail`].
pub on_limit_exceeded: Option<OnLimitExceeded>,
}

/// The circuit breaker's threshold when a card sets none.
pub const DEFAULT_MAX_CONSECUTIVE_ERRORS: u32 = 3;

/// What the runtime does with a node that would take an agent past one of
/// its limits (or past a tripped circuit breaker).
///
/// RFC-005 also names `escalate_to_supervisor`. Nothing implements it, so it
/// is not accepted: a card that asks for it fails to register rather than
/// being silently treated as `fail`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnLimitExceeded {
/// Refuse the node and fail it for good.
#[default]
Fail,
/// Hold the node for a human approval of that one step, through the
/// ordinary approval path (bound to the action, with the policy chain's
/// deadline). Each approval releases one step, once: a retry, a duplicate
/// item or the next step past the limit needs its own.
EscalateToHuman,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
Loading
Loading