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:
check_eligibilitywas called- Its output had
eligible: trueand areasonfield 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 valuesIf 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:
- When
issue_refundis proposed with{ order_id: "ORD-123" }, the pipeline extractsORD-123from the current call's arguments - It looks for a prior
check_eligibilitycall that also hadorder_id: "ORD-123" - It checks that call's output had
eligible: true - If no matching call exists,
issue_refundis 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:
issue_refundexecutes successfullyissue_refund,void_order, andreverse_chargeare added toforbiddenTools- Those tools are removed from the tool list on all future calls
- The model can never see or call them again
Key properties:
forbiddenToolsis 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
- Session Limits — cap total steps, cost, and tool calls
- Contract Cookbook — every contract field
- Phases & Transitions — combine with phase-based narrowing