Skip to main content
Each tool can carry a constraints: list of string expressions evaluated against the call’s arguments. Every constraint must pass for the call to authorize (implicit AND).
New here? Start with Writing conditions — a guided, example-first walkthrough. This page is the terse reference to look things up once you know the shape.
Types are compared strictly, matching Rego: a boolean is not a number, so true == 1 is false and true in [1] is false (unlike Python’s True == 1). Cross-type ordered comparisons ("x" > 10) never pass — they fail closed. Each side of a comparison is an operand — a literal, a field path, or count(...): A bare unquoted word on the right is read as a field reference, so a forgotten-quotes typo (args.x == USD) compares against the field USD (usually absent → deny) rather than the string "USD". Quote strings. Whole-line string functions take a field and a string literal: Quantifiers constrain the elements of a list-valued argument. . is the current element (.field for a sub-field), usable in any condition: Element sub-fields and nesting work too:
A non-list collection (or a missing one) fails closed. . is only valid inside a quantifier body. Besides args.*, constraints can reference two call-scope facts: role (the caller’s role) and tool (the tool being invoked) — e.g. role == "admin" or tool == "refund_order". These mirror Rego’s input.role / input.tool. Two further namespaces follow below: ctx.* (who is calling) and run.* (what this invocation has done so far).

Caller attributes (ctx.*)

Constraints can also filter on the caller’s attributes — an open bag set on the request scope (HexgateContext(attributes={...})) — through the ctx.<key> namespace, alongside args.* / role / tool:
A missing ctx.<key> fails closed (deny), like any absent reference. ctx.* may gate allow/deny decisions — but it carries the same trust contract as role: populate HexgateContext.attributes from trusted server-side data (your auth/session/IdP), never from raw client input. An attribute is exactly as trustworthy as the code that set it.

Run facts (run.*)

Everything above describes this call. run.* describes the invocation so far — how many tools the agent has already run, how long it has been going, how many tokens it has spent. It is what a runaway agent is stopped with. Facts are accumulated in-process by the SDK, so they are always available and never need a network call. A run starts at the adapter boundary (ainvoke, run, run_sync, astream_events, …) and ends when it returns.

The 13 paths

run.tools_used is list-valued, so read it with count(...), any(...) or every(...). A typo’d path, a deeper path (run.id.value), or a list path used where a scalar belongs are all rejected when the policy loads rather than denying silently at runtime.

What the counters mean

These distinctions change what a cap does, so they are worth reading once:
  • A tool counts when it is dispatched, after the decision has allowed it. run.tool_calls < 20 permits exactly 20 executions, in parallel as well as in sequence.
  • A denied call consumes no tool budget. It counts toward run.denials instead, which you can bound separately — otherwise an agent misbehaving against one tool would exhaust a legitimate caller’s budget.
  • An approval gate counts when it fires, not when it is granted. An approval that is refused adds to run.approvals and nothing else.
  • A guard halt before the tool is a denial; the tool never ran. A guard halt after the tool counts both: the call, because the side effect already happened and only the result is withheld, and a denial, because a post-guard halt is a refusal like any other.
  • A tool that raises counts as both a call and an error.
run.* caps are circuit breakers, not quotas. They stop a run that has gone wrong; they do not meter it precisely. Five places the count can lag reality:
  • Tokens trail by one turn. Tokens are only known once the model has answered, so the turn that blows the budget still gets its tool calls; the cap fires on the next decision.
  • Token facts are not available on the Pydantic AI adapter. It has no per-call usage hook, so it reports usage once, after the whole run finishes. run.input_tokens, run.output_tokens, run.total_tokens and run.llm_calls therefore read zero at every decision in a Pydantic AI run, and a token budget never fires there. Use run.tool_calls or run.elapsed_seconds to bound those runs. The LangChain, OpenAI and Google adapters all report per LLM call and are unaffected.
  • Sub-agents don’t roll up. A child invocation opens its own run, so its counts don’t reach the parent — a per-run cap is bypassable by spawning one.
  • Raw threads. A tool call is counted before it is dispatched, and on an event loop that read-then-increment cannot interleave — so parallel tool calls (asyncio.gather, LangChain’s executor) respect a cap exactly. Guarded calls dispatched to a hand-rolled threading.Thread can still overshoot, since two OS threads can be decided concurrently.
  • Parallel calls waiting on an approval. The guarantee above holds because nothing suspends between the decision and the count. An approval_required call breaks that: an async approval handler that actually waits — on a human, or on a network round-trip — hands the event loop back while the call is still uncounted, so other calls decide against the pre-approval count. A cap of 5 can admit every call in a concurrent fan-out of 50 approval-gated tools. Only the async path and only the policy’s own gate; a bool handler, a sync handler, and a before-guard’s approval all keep the count exact.

Where facts aren’t collected

In a few places a decision happens outside any run scope. There, every counter reads zero and run.id is "" — so a counter cap passes. This is deliberate: an unwired boundary should not brick the agent. The cases:
  • Network egress. The egress proxy is one per process and sits above every run, and a tool’s outbound connection carries no invocation id to correlate back, so egress decisions record nothing.
  • Methods that bypass the wrapper, such as LangChain’s agent.batch().
  • Tools that dispatch guarded work to a raw thread or thread pool. asyncio.gather, asyncio.to_thread and LangChain’s own executor all carry the run correctly; only a hand-rolled threading.Thread loses it.

What a denied model is told

On a run.* denial the model sees the constraint text — run.tool_calls < 20 — so it learns the threshold and that it was crossed. It is never told the current counter value, and the run’s id and counters are not in the payload the model receives. Budget pressure is deliberately not surfaced to the agent. One more, if you consume streams: two astream() generators consumed round-robin in the same task will cross-contaminate, because Python has no per-generator context. Consume one at a time, or put each in its own task. (This already affects caller identity via HexgateContext, so it isn’t new to run.*.)

Where a constraint can live

Three places, widening in reach: default_policy.constraints is the common trap. It is the catch-all policy, not a floor: once a tool is listed under tools:, the default no longer applies to it, so a run cap written there silently misses every tool you named.
Policy-level constraints can only narrow. A tool with mode: deny is denied before constraints are consulted, so nothing here can grant access.

args.* at policy level reaches admission and reach

“Every tool the role can reach” includes the synthetic keys admission: and agents: lower to — agent.run, plus agent.handoff:<target> and agent.tool:<target>. Those gates pass only their own arguments (args.agent for admission; args.agent / args.target / args.via for reach), so an args.* constraint written with tools in mind reads a missing field there, and a missing ref fails closed: the agent is refused before it starts, and every handoff and agent-as-tool call is denied.
This policy denies its own agent at ingress.
Not an admission quirk — the same constraint denies any tool that doesn’t take an amount. Policy level is the place for predicates every reachable key can answer: run.*, ctx.*, role. Keep args.* on the tool that defines the argument.
When you do want one argument fenced role-wide, exempt the whole reserved namespace — tool is a fact like any other, and the grammar has or:
Use the prefix, not tool == "agent.run": naming the admission key alone still leaves agent.handoff:<target> and agent.tool:<target> denied.

Inheritance unions them

Every other field overrides through inherits: — a child’s tools entry replaces its parent’s. Policy-level constraints are the exception: they accumulate, so a child cannot drop a fence a mixin declared.
Policy-level constraints belong in a single-file policy — flat, or role-keyed under roles:, where a top-level block unions into every role. Composed module policies (policies/boundaries/, policies/capabilities/) reject them at load: the fold composes per-tool rules only, and silently dropping a role-wide fence would be fail-open.

Upgrading

Top-level constraints: is new. Single-file policies ignore keys they don’t recognise, so before this release the block parsed and was silently dropped — after it, the same YAML is enforced against every tool the role can reach. If you wrote one speculatively, it starts denying on upgrade, and the bundle’s rego_hash and wasm_hash change with it. Note that source_hash does not move — it is the sha256 of your policy’s raw YAML bytes, which the upgrade leaves untouched, so diffing it will tell you nothing changed. Grep your policies for a top-level constraints: before you roll forward — hexgate policy show-rego prints the compiled rules if you want to diff exactly what moved. (Composed module policies are unaffected: they rejected the key outright, then and now.) Role-keyed documents activate one release later. A document with a top-level roles: key validated only what sat inside each role, so a constraints: sibling was dropped there even after the release above — including a malformed expression, which hexgate policy validate could not see either, since there was nothing left to validate. It now unions into every role, so the same upgrade note applies: grep role-keyed policies too. Any other unrecognised sibling of roles: is now a load error rather than a silent drop, naming the key and telling you to move it inside a role.

Named constants

Define reusable values once in a consts: block and reference them as consts.<name> — a number, string, or list:
Change a value in one place and every tool/role that references it updates. Constants merge through inherits: like tools, so shared constants belong in a mixin. A consts.<name> that isn’t defined is rejected at build (and denies on the pydantic engine); across roles a name must map to a single value.

Boolean composition

Combine conditions with and, or, not, and parentheses:
Precedence is the usual or < and < not; use () to group. Multiple constraint lines are still AND-ed, so and is only needed to combine with an or. (Boolean ops inside a quantifier body — e.g. every(args.x, .a == 1 or .b == 2) — aren’t supported yet.)

End to end

With this billing role policy and async with HexgateContext(user_id="alice", user_roles=["billing"]):
  • refund_order(amount=200, currency="USD") → ✅ allowed
  • refund_order(amount=600, currency="USD") → ❌ denied — constraint args.amount <= 500
  • refund_order(amount=200, currency="EUR") → ❌ denied — constraint args.currency == "USD"
  • wire_transfer(amount=50000) → ✋ requires approval (mode = approval_required)
Switch to user_roles=["default"] and refund_order is missing from that policy — it falls through to default_policy.mode (deny). Constraints are Rego-compatible by design: the WASM engine compiles them to OPA Rego unchanged, and the pydantic engine evaluates the same strings in-process — both produce identical decisions (there’s a parity test suite that proves it).