Skip to main content

Preconditions & Ordering

Preconditions enforce tool ordering: "you must call A before you can call B." Combined with forbids_after, they create powerful cross-step rules that prevent multi-step attack chains.


The problem

Your refund agent has check_eligibility and issue_refund tools. Without preconditions, the model might skip eligibility and go straight to refunding. Each individual call is valid — the bypass is only visible across steps.

Worse: the model might check eligibility for order A, then refund order B. Per-call validation can't catch entity mismatches across calls.


Basic ordering: requires_prior_tool

Require that a specific tool was called earlier in the session.

# issue_refund.yaml
tool: issue_refund
preconditions:
- requires_prior_tool: check_eligibility

issue_refund is blocked unless check_eligibility was called in a previous step. The enforcement pipeline checks this at both narrowing time (Stage 1 — tool removed from visibility) and validation time (Stage 3 — blocked if somehow proposed).


Require specific output: with_output

Not just "was it called" but "did it return the right thing."

preconditions:
- requires_prior_tool: check_eligibility
with_output:
- path: "$.eligible"
equals: true
- path: "$.reason"
exists: true

Both conditions must be met:

  1. check_eligibility was called
  2. Its output had eligible: true and a reason field present

Numeric comparisons: gte and lte

In addition to equals, with_output supports numeric threshold checks:

preconditions:
- requires_prior_tool: calculate_var
with_output:
- path: "$.var_value"
lte: 0.05

This blocks the downstream tool unless calculate_var returned a var_value of 0.05 or less. You can combine operators:

with_output:
- path: "$.status"
equals: "OK"
- path: "$.var_value"
gte: 0.01
lte: 0.05

Non-numeric output values fail gte/lte checks (fail-closed).

How output is captured: When a tool executes, the enforcement pipeline extracts fields referenced by downstream preconditions from the tool result message in the LLM conversation history (the tool role message). These extracts are stored in satisfiedPreconditions and survive step eviction for long sessions.

with_output paths resolve against the tool result message, not session.tools return values

If you use session.tools (wrapped tool executors), the return value is { result, constraint_verdict }. Do not pass this wrapper object as the tool result content. The with_output path $.eligible resolves against the tool result message content — not the session.tools return value.

// WRONG — with_output path $.eligible won't find anything
const output = await session.tools.check_eligibility(args);
messages.push({ role: "tool", content: JSON.stringify(output) });
// Tool result content: {"result":{"eligible":true},"constraint_verdict":{...}}
// $.eligible → undefined ✗

// RIGHT — unwrap .result before constructing the tool result message
const output = await session.tools.check_eligibility(args);
messages.push({ role: "tool", content: JSON.stringify(output.result) });
// Tool result content: {"eligible":true}
// $.eligible → true ✓

If you don't use session.tools, this doesn't apply — your executor's return value goes directly into the message.

End-to-end example: session.tools + with_output

A complete multi-step flow showing tool execution, .result unwrapping, and with_output precondition resolution:

const session = replay(client, {
contractsDir: "./contracts",
tools: {
check_eligibility: async (args) => {
const result = await db.checkEligibility(args.order_id);
return { eligible: result.eligible, reason: result.reason };
},
issue_refund: async (args) => {
return await db.issueRefund(args.order_id, args.amount);
},
},
});

// Step 1: LLM proposes check_eligibility
const r1 = await session.client.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "Refund order ORD-123" }],
tools: allToolDefs,
});

// Execute via session.tools — returns { result, constraint_verdict }
const tc1 = r1.choices[0].message.tool_calls[0];
const wrapped1 = await session.tools.check_eligibility(
JSON.parse(tc1.function.arguments),
);
// wrapped1.result = { eligible: true, reason: "within_policy" }

// Step 2: Push the unwrapped .result as tool message content
// The pipeline extracts with_output paths from this message
const r2 = await session.client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "user", content: "Refund order ORD-123" },
{ role: "assistant", tool_calls: r1.choices[0].message.tool_calls },
{ role: "tool", tool_call_id: tc1.id, content: JSON.stringify(wrapped1.result) },
{ role: "user", content: "Proceed with the refund" },
],
tools: allToolDefs,
});
// issue_refund is now allowed — check_eligibility was called for the
// same order_id and its output had eligible: true

Same-entity enforcement: resource binding

The most important precondition feature. Prevents "check order A, refund order B."

# issue_refund.yaml
preconditions:
- requires_prior_tool: check_eligibility
resource:
bind_from: arguments
path: "$.order_id"
with_output:
- path: "$.eligible"
equals: true
description: "Eligibility must be checked for the same order"

What this does:

  1. When issue_refund is proposed with { order_id: "ORD-123" }, the pipeline extracts ORD-123 from the current call's arguments
  2. It looks for a prior check_eligibility call that also had order_id: "ORD-123"
  3. It checks that call's output had eligible: true
  4. If no matching call exists, issue_refund is blocked

Without resource binding: Any prior check_eligibility call satisfies the precondition, regardless of which order it checked.

With resource binding: Only a check_eligibility call for the same order satisfies it.


Minimum step count

Require that the agent has taken at least N steps before this tool is available.

preconditions:
- requires_step_count:
gte: 3
description: "Agent must have gathered enough context (3+ steps)"

Use for: Tools that should only be available after the agent has done some initial investigation.


Combining preconditions

A tool can have multiple preconditions — all must be satisfied.

# transfer_funds.yaml
preconditions:
- requires_prior_tool: verify_identity
description: "Identity must be verified"

- requires_prior_tool: check_balance
resource:
bind_from: arguments
path: "$.account_id"
with_output:
- path: "$.sufficient"
equals: true
description: "Balance must be sufficient for the same account"

- requires_prior_tool: approve_transfer
with_output:
- path: "$.approved"
equals: true
description: "Transfer must be approved"

- requires_step_count:
gte: 3

All four conditions must be met before transfer_funds is available.


Forbidden tools: forbids_after

After this tool executes, permanently block specific tools for the rest of the session.

# issue_refund.yaml
forbids_after:
- issue_refund # Can't refund twice (idempotency)
- void_order # Can't void after refund
- reverse_charge # Can't reverse after refund

How it works:

  1. issue_refund executes successfully
  2. issue_refund, void_order, and reverse_charge are added to forbiddenTools
  3. Those tools are removed from the tool list on all future calls
  4. The model can never see or call them again

Key properties:

  • forbiddenTools is append-only — once forbidden, stays forbidden forever
  • Takes effect immediately — even within the same response (if the LLM proposed multiple tool calls)
  • Enforced at both narrowing (Stage 1) and validation (Stage 3)

Common forbids_after patterns

Idempotency — prevent double execution:

forbids_after: [issue_refund]

State lock — after a write, prevent conflicting writes:

# deploy.yaml
forbids_after:
- rollback
- deploy # Can't deploy twice without review

One-way gate — after confirmation, prevent going back:

# confirm_order.yaml
forbids_after:
- edit_order
- cancel_order
- modify_shipping

Preconditions at narrowing vs validation

Preconditions are checked at two points in the pipeline:

Stage 1 (Narrowing) — before the LLM call

Tools with unmet preconditions are removed from the tool list. The model doesn't see them.

Limitation: At narrowing time, there are no tool call arguments yet (the model hasn't been called). So resource-bound preconditions can't be fully evaluated. The pipeline checks if the required prior tool was called for any resource, not the specific one.

Stage 3 (Validation) — after the LLM responds

The full precondition is evaluated with actual tool call arguments. Resource binding is fully resolved. This is where "check order A, refund order B" is caught.

This two-stage design is intentional:

  • Stage 1 is a coarse pre-filter — removes obviously unavailable tools
  • Stage 3 is the precise check — catches resource mismatches with actual arguments

Preconditions in practice

Refund agent

lookup_customer → check_eligibility → issue_refund → send_confirmation
# check_eligibility.yaml
preconditions:
- requires_prior_tool: lookup_customer

# issue_refund.yaml
preconditions:
- requires_prior_tool: check_eligibility
resource:
bind_from: arguments
path: "$.order_id"
with_output:
- path: "$.eligible"
equals: true
forbids_after: [issue_refund]

# send_confirmation.yaml
preconditions:
- requires_prior_tool: issue_refund

Deployment pipeline

fetch_config → run_tests → approve_deploy → deploy → verify
# deploy.yaml
preconditions:
- requires_prior_tool: run_tests
with_output:
- path: "$.all_passed"
equals: true
- requires_prior_tool: approve_deploy
with_output:
- path: "$.approved"
equals: true
forbids_after: [deploy, rollback]

Data pipeline

validate_schema → transform_data → write_output
# write_output.yaml
preconditions:
- requires_prior_tool: validate_schema
resource:
bind_from: arguments
path: "$.dataset_id"
with_output:
- path: "$.valid"
equals: true
- requires_prior_tool: transform_data
resource:
bind_from: arguments
path: "$.dataset_id"
- requires_step_count:
gte: 2

Next steps