Skip to content
tnsaiDocs
GitHub

Contracts

Check an action's input before it runs and its result after, with JEXL expressions.

@Contract declares preconditions, postconditions and invariants. The framework enforces them around every call to the action. A precondition rejects bad input, including arguments the model made up, before the method runs. A postcondition catches a wrong result or corrupted state after it runs.

A tool input schema, such as MCP's JSON Schema, checks only the shape of the arguments. A contract checks what they mean, and how the result relates to them.

Declare a contract

@ActionSpec(type = ActionType.LOCAL, description = "Withdraw from an account")
@Contract(
    preconditions  = {"amount > 0", "amount <= account.balance"},
    postconditions = {"account.balance == old(account.balance) - amount"},
    invariants     = {"account.balance >= 0"})
public Account withdraw(Account account, double amount) {
    account.balance -= amount;
    return account;
}
  • A failed precondition throws before withdraw runs. The LLM gets a clear "precondition violated: amount > 0" and can retry with corrected arguments.
  • The postcondition binds old(account.balance) to the pre-execution value, so it verifies the balance actually dropped by amount.
  • The invariant is checked both before and after.

Pair it with @ActionSpec

A contract runs only on an action. A method with @Contract but no @ActionSpec fails discovery with IllegalStateException:

@Contract on com.example.Ledger.helper has no @ActionSpec; the clause is never evaluated. Annotate the method with @ActionSpec or remove @Contract.

What each clause does

ClauseWhenOn failure
preconditionsbefore executionalways fails the call
postconditionsafter executionfails the call, or only logs when strict = false
invariantsbefore and afteralways fails the call

A failed clause reaches the caller as ActionExecutionException in category VALIDATION. Its cause is a ContractViolationException, which names the clause kind, the expression and its bindings.

Expressions are JEXL. Inside them you can reference the action's parameters by name, plus two special bindings in postconditions:

  • result: the value the action returned (e.g. result.success()).
  • old(expr): the value expr had before execution, captured automatically (e.g. old(account.balance)). Use it to assert a delta.

Flags

  • validate = false: disables all enforcement for that action, and the contract becomes documentation only.
  • strict = false: downgrades postcondition failures to a logged warning instead of throwing (preconditions and invariants always throw).
  • message = "…": overrides the default violation message.
  • inheritance: declared, with STRENGTHEN, LISKOV, REPLACE and NONE, and a default of STRENGTHEN. It is not enforced. Each action is checked against its own @Contract only.

Add a contract to a programmatic action

Actions added through RoleBuilder.addAction(...) have no annotated method, so they attach a contract with ActionConfig.withContract(...). The ContractSpec builder is the programmatic equivalent of @Contract, enforced through the exact same code path:

package com.example.tnsai.docs;

import com.tnsai.metadata.ActionConfig;
import com.tnsai.metadata.ActionHandler;
import com.tnsai.metadata.ContractSpec;
import com.tnsai.roles.Role;
import com.tnsai.roles.RoleBuilder;

public final class ContractSpecBuilderExample {
    private ContractSpecBuilderExample() {}

    public static Role banker(ActionHandler handler) {
        return RoleBuilder.create()
            .name("banker")
            .goal("Process withdrawals")
            .addAction(ActionConfig.local("withdraw", "Withdraw from an account", handler)
                .withContract(ContractSpec.builder()
                    .precondition("amount > 0")
                    .postcondition("result.success()")
                    .build()))
            .build();
    }
}

ContractSpec.from(@Contract) adapts an annotation into a spec, so the annotation and builder paths are indistinguishable at enforcement time.

Catch typos at build time

AgentBuilder.build() parses every contract clause and reports a malformed expression as warning AGENT-V013. A typo then shows up at build time, not on the first call. If you build expressions at runtime, turn the check off with .relaxValidation("AGENT-V013") on the builder.

Other ways to constrain an action

  • @Contract (this page): JEXL preconditions, postconditions and invariants, with old(...) and result. Prefer it for new code.
  • @State invariants, checked after the action when the method carries @InvariantCheck. See Validation.
  • @ActionSpec.precondition and postcondition are planning metadata. The framework does not check them when the action runs.
  • ActionContract: an interface for contracts written in Java rather than as expression strings.
  • Actions: where contract checks run in the action pipeline.
  • Validation: @State invariants.
  • Exceptions: ContractViolationException and the other action errors.