Edictum
Contracts Reference

Session Contracts

Stateful contracts that cap tool calls, attempts, and per-tool executions across an agent session -- catching runaway loops and resource exhaustion before they escalate.

AI Assistance

Right page if: you need to cap cumulative agent behavior -- total tool calls, per-tool limits, or retry loop detection across a session. Wrong page if: you want to deny a single tool call based on its arguments -- see https://docs.edictum.ai/docs/contracts/preconditions. Gotcha: max_attempts counts ALL pipeline evaluations including denied calls, while max_tool_calls only counts successful executions. A high attempt-to-call ratio signals a stuck agent retrying denied operations.

An agent stuck in a retry loop can burn thousands of API calls in minutes. An agent that deploys 47 times because the LLM misinterprets a rollback is worse. Session contracts cap cumulative behavior across an entire agent session -- total tool calls, total attempts (including denials), and per-tool execution limits.

- id: session-limits
  type: session
  limits:
    max_tool_calls: 50
    max_attempts: 120
    max_calls_per_tool:
      deploy_service: 3
      send_notification: 10
  then:
    effect: deny
    message: "Session limit reached. Summarize progress and stop."

This contract stops the agent after 50 successful tool executions, 120 total pipeline evaluations (including denied calls), or 3 deploys -- whichever comes first.

Why Session Contracts Exist

Preconditions check individual tool calls. Sandbox contracts check boundaries. Neither tracks cumulative behavior. Without session contracts, three failure modes go undetected:

Runaway execution. The agent enters a loop -- read file, parse, read again, parse again -- burning through hundreds of tool calls without making progress. Each individual call is perfectly valid. The problem is volume.

Denial retry loops. A precondition denies deploy_service because the principal lacks the required role. The agent retries. Denied again. Retries. The execs counter never increments because the tool never executes, but the agent is stuck. max_attempts catches this because it counts every pre_execute call, including denials.

Resource exhaustion on specific tools. Deploying once is fine. Deploying 47 times because the agent misinterprets error messages is not. max_calls_per_tool puts a hard cap on individual tools without limiting everything else.

The Three Limit Types

Session contracts support three limit fields. At least one must be present. All three can be combined in a single contract.

max_tool_calls

Counts successful tool executions only. A tool call that is denied by a precondition or sandbox contract does not increment this counter.

- id: total-execution-cap
  type: session
  limits:
    max_tool_calls: 100
  then:
    effect: deny
    message: "Reached 100 tool executions. Summarize progress and stop."

Use this as a general safety net. Most agent tasks complete well under 100 tool calls. If the agent exceeds the cap, something unexpected is happening.

max_attempts

Counts every pre_execute call -- including calls that are denied by preconditions, sandbox contracts, or previous session limits. This is the counter that catches denial retry loops.

- id: attempt-cap
  type: session
  limits:
    max_attempts: 200
  then:
    effect: deny
    message: "200 attempts reached (including denied calls). Stop retrying."

Consider the relationship between max_attempts and max_tool_calls. If your agent has 50 successful calls and 150 denied calls, max_tool_calls: 100 would not fire (only 50 executions), but max_attempts: 200 would (200 total evaluations). Set max_attempts higher than max_tool_calls to account for legitimate denials, but low enough to catch infinite retry loops.

max_calls_per_tool

Caps execution counts for specific tools by name. Keys are tool names, values are integer limits.

- id: per-tool-caps
  type: session
  limits:
    max_calls_per_tool:
      deploy_service: 3
      send_email: 5
      delete_record: 10
  then:
    effect: deny
    message: "Per-tool limit reached for {tool.name}. This tool cannot be called again."

Tools not listed in max_calls_per_tool are uncapped by this contract (they are still subject to max_tool_calls if set). Only successful executions count -- denied calls do not increment per-tool counters.

Combining All Three Limits

A single session contract can set all three limits. The first limit reached triggers the denial.

- id: comprehensive-session-limits
  type: session
  limits:
    max_tool_calls: 50
    max_attempts: 120
    max_calls_per_tool:
      deploy_service: 3
      send_notification: 10
      bash: 30
  then:
    effect: deny
    message: "Session limit reached. Summarize progress and stop."
    tags: [rate-limit, session]

This fires when any of the following happens first:

  • 50 total successful tool executions
  • 120 total pipeline evaluations (including denials)
  • 3 executions of deploy_service, 10 of send_notification, or 30 of bash

How Session State Is Tracked

Edictum maintains four counters per session. These counters are the foundation of session contract evaluation.

CounterIncremented whenSemantics
attemptsEvery pre_execute callIncludes denied calls. Catches retry loops.
execsTool actually executesSuccessful executions only.
tool:{name}Tool actually executesPer-tool execution count. Key is tool: + the tool name.
consec_failConsecutive failuresResets to 0 on any success.

The attempts counter increments before any contract evaluation. Even if the very first precondition denies the call, the attempt is counted. The execs and tool:{name} counters increment only after the tool runs successfully.

StorageBackend

All counter operations go through the StorageBackend protocol:

class StorageBackend(Protocol):
    async def get(self, key: str) -> str | None: ...
    async def set(self, key: str, value: str) -> None: ...
    async def delete(self, key: str) -> None: ...
    async def increment(self, key: str, amount: float = 1) -> float: ...

The critical method is increment() -- it must be atomic. This is the fundamental requirement for correctness under concurrent access (e.g., asyncio.gather running multiple tool calls).

MemoryBackend (Default)

MemoryBackend stores counters in a Python dict. It enforces atomicity on increment() and delete() via asyncio.Lock. This is the default backend and covers the common case: a single agent process enforcing session limits on its own tool calls.

from edictum import Edictum

# MemoryBackend is used by default -- no configuration needed
guard = Edictum.from_yaml("contracts.yaml")

ServerBackend (Multi-Process)

For agents running across multiple processes -- multiple replicas, or separate agent processes that share a session -- MemoryBackend cannot coordinate. Each process would have its own counters.

ServerBackend (from edictum[server]) implements StorageBackend over HTTP, with session state managed centrally by edictum-console. When one process increments a counter, all processes see the updated value.

from edictum import Edictum

# ServerBackend is wired automatically by from_server()
guard = Edictum.from_server(
    url="https://console.example.com",
    api_key="ek_...",
)
BackendScopeUse when...
MemoryBackendSingle processOne agent process with its own session limits. Most use cases.
ServerBackendMulti-processMultiple agent replicas or processes sharing session state via edictum-console.

Session Contract Constraints

Session contracts have specific constraints that distinguish them from other contract types:

  • No tool or when fields. Session contracts apply globally to all tools. They do not match specific tools or evaluate conditions against arguments.
  • effect: deny is the only valid effect. Session contracts are gates -- when a limit is reached, further execution is denied.
  • At least one limit field is required. A session contract must have max_tool_calls, max_attempts, max_calls_per_tool, or any combination.

Enforce vs. Observe

Session contracts support the same mode field as other contract types.

In enforce mode (the default), reaching a limit denies all subsequent tool calls for the session.

In observe mode, the session contract evaluates and emits CALL_WOULD_DENY audit events when a limit would have been reached, but the tool call proceeds. This is useful for calibrating limits on a live agent before enforcing them.

- id: calibration-limits
  type: session
  mode: observe
  limits:
    max_tool_calls: 30
    max_attempts: 80
  then:
    effect: deny
    message: "Would have hit session limit (observe mode)."
    tags: [calibration]

Deploy this in observe mode, watch the audit trail to see how many tool calls your agent typically makes, then set your enforced limits above the observed baseline.

Pipeline Position

Session contracts evaluate after preconditions and sandbox contracts, but before the tool executes. The full pipeline order is:

  1. Preconditions (deny-list) -- catch known-bad patterns
  2. Sandbox (allowlist) -- catch calls outside boundaries
  3. Session contracts -- check cumulative limits
  4. Tool executes
  5. Postconditions -- scan output

This ordering means that a call denied by a precondition still increments the attempts counter (relevant for max_attempts), but does not increment execs or tool:{name} counters (relevant for max_tool_calls and max_calls_per_tool).

Denial Message Design

The denial message is sent to the agent. A well-designed message steers the agent toward a productive response instead of continued retrying.

# Bad: vague message, agent may keep retrying
- id: bad-message-example
  type: session
  limits:
    max_tool_calls: 50
  then:
    effect: deny
    message: "Limit reached."

# Good: tells the agent what to do next
- id: good-message-example
  type: session
  limits:
    max_tool_calls: 50
  then:
    effect: deny
    message: "50 tool calls reached. Summarize what you accomplished, list remaining tasks, and stop."

For per-tool limits, include the tool name so the agent knows which tool is capped:

- id: deploy-cap
  type: session
  limits:
    max_calls_per_tool:
      deploy_service: 3
  then:
    effect: deny
    message: "deploy_service has been called 3 times this session. No more deploys allowed. If the deployment failed, report the error instead of retrying."

What Needs the Server

Most session contract features work with just pip install edictum. The server is only needed for multi-process session coordination.

Session CapabilityCore (pip install edictum)Server (edictum-console + edictum[server])
All three limit typesYes--
Counter tracking (single process)Yes (MemoryBackend)--
Counter tracking (multi-process)--Yes (ServerBackend)
Session denial in audit (stdout/file/OTel)Yes--
Session denial dashboards--Yes (ServerAuditSink)
Observe mode for session contractsYes--
Hot-reload session limits across fleet--Yes (ServerContractSource)

Next Steps

Last updated on

On this page