Skip to main content

Phases & Transitions

Phases are a state machine for your agent's session. They control which tools the model can see at each step — the most aggressive intervention point in the enforcement pipeline.


Why phases matter

Without phases, your agent has access to all tools at all times. A model tasked with fixing a bug could call terraform destroy because it has permissions. A support agent could skip identity verification and go straight to issuing refunds.

With phases, tools are invisible outside their valid phase. The model can't call what it can't see.


How it works

1. Define phases in session.yaml

schema_version: "1.0"
agent: refund-support-bot

phases:
- name: triage
initial: true
- name: customer_identified
- name: eligibility_checked
- name: refund_issued
- name: completed
terminal: true

transitions:
triage: [customer_identified]
customer_identified: [eligibility_checked]
eligibility_checked: [refund_issued]
refund_issued: [completed]
phases is an array, transitions is an object

Note the different formats: phases is an array of objects (each with a name), while transitions is an object map (keys are phase names, values are arrays of target phases). Don't mix them up — using an object map for phases will produce a parse error.

# phases: array of { name, initial?, terminal? }
phases:
- name: triage
initial: true
- name: done
terminal: true

# transitions: object map of from → [to, ...]
transitions:
triage: [done]

Rules:

  • Exactly one phase must be initial: true
  • At least one phase must be terminal: true
  • Every non-terminal phase must be reachable from the initial phase
  • The transitions map defines legal phase flows
  • If session.yaml has phases but compilation fails (e.g., missing initial or terminal phase), the session will refuse to start — all create() calls throw ReplayConfigError

2. Mark tools with phase restrictions

# lookup_customer.yaml
transitions:
valid_in_phases: [triage]
advances_to: customer_identified

This tool is only visible during the triage phase. When it executes successfully, the session advances to customer_identified.

3. Narrowing removes invisible tools

Before every LLM call, the enforcement pipeline filters the tool list:

You pass:  [lookup_customer, check_eligibility, issue_refund, send_confirmation]
Phase: triage
LLM sees: [lookup_customer]
Removed: check_eligibility (wrong_phase), issue_refund (wrong_phase), send_confirmation (wrong_phase)

The model literally cannot ask for a tool that isn't in its current phase.


Common patterns

Linear workflow

transitions:
start: [step_a]
step_a: [step_b]
step_b: [step_c]
step_c: [done]
start → step_a → step_b → step_c → done

Use for: Sequential processes like refund flows, onboarding, data pipelines.

Branching with escalation

phases:
- name: triage
initial: true
- name: investigating
- name: resolved
terminal: true
- name: escalated
terminal: true

transitions:
triage: [investigating, escalated]
investigating: [resolved, escalated]
triage → investigating → resolved
↘ ↘
escalated escalated

Use for: Any workflow where any step can bail out to a human.

Hub-and-spoke (research phase)

phases:
- name: start
initial: true
- name: researching
- name: analyzing
- name: done
terminal: true

transitions:
start: [researching]
researching: [researching, analyzing] # Can loop back to itself
analyzing: [done]
start → researching ↻ → analyzing → done

Use for: Agents that need multiple research passes before deciding.

Parallel branches

transitions:
start: [path_a, path_b]
path_a: [merge]
path_b: [merge]
merge: [done]

Both paths converge at merge. The model takes one path based on which tool it calls first.


Multiple tools per phase

Multiple tools can be valid in the same phase:

# search_web.yaml
transitions:
valid_in_phases: [researching]
advances_to: analyzing

# search_database.yaml
transitions:
valid_in_phases: [researching]
advances_to: analyzing

Both tools are visible during researching. The model picks which one to use. Both advance to the same phase.

Different targets from same phase:

# approve.yaml
transitions:
valid_in_phases: [review]
advances_to: approved

# reject.yaml
transitions:
valid_in_phases: [review]
advances_to: rejected

Both tools are visible during review. The model's choice determines which branch the session takes. This is legal branching — the model decides.


Tools without phase restrictions

A tool without transitions is available in all phases:

# logging_tool.yaml
tool: log_event
side_effect: read
# No transitions field — always available

Use for: utility tools like logging, help functions, or status checks that should be available everywhere.


Phase validation rules

The pipeline catches two types of illegal transitions:

Illegal transition

A tool attempts to advance to a phase not in the transitions map:

Current phase: triage
Tool calls: issue_refund (advances_to: refund_issued)
transitions[triage] = [customer_identified]
Result: BLOCKED — "illegal_phase_transition"

This is caught during validation (Stage 3), before the response is released.

Ambiguous transition

Multiple tool calls in one response attempt different phase targets:

Current phase: review
Tool calls: approve (→ approved) + reject (→ rejected)
Result: BLOCKED — "ambiguous_phase_transition"

If both tools advance to the same phase, it's fine. Only different targets in one response are ambiguous.


Phase state in code

const session = replay(client, {
contractsDir: "./contracts",
agent: "refund-support-bot",
mode: "enforce",
});

// Check current phase
const state = session.getState();
console.log(state.currentPhase); // "triage"

// After first tool call...
const state2 = session.getState();
console.log(state2.currentPhase); // "customer_identified"

The onNarrow callback

See exactly what tools were removed and why:

const session = replay(client, {
contractsDir: "./contracts",
agent: "refund-support-bot",
mode: "enforce",
onNarrow: (narrowing) => {
console.log(`Allowed: ${narrowing.allowed.map(t => t.function.name).join(", ")}`);
for (const r of narrowing.removed) {
console.log(` Removed: ${r.tool}${r.reason}`);
}
},
});

Removal reasons:

ReasonMeaning
wrong_phaseTool not valid in current phase
precondition_not_metRequired prior tool not called
forbidden_in_stateTool in forbiddenTools set
no_contractNo contract found (and unmatchedPolicy: "block")
policy_deniedPrincipal not authorized
manual_filterExcluded by session.narrow()

Manual narrowing

Temporarily restrict tools beyond what contracts require:

// Only allow lookup_customer (within compiled legal space)
session.narrow(["lookup_customer"]);

// Remove manual restriction (returns to contract-driven narrowing)
session.widen();

Important: narrow() can only restrict further — it cannot add tools that contracts would remove. widen() returns to contract-driven narrowing, not unbounded access.


Cross-phase behavior

Aggregates and envelopes persist across phases

Session aggregates and value envelopes are not phase-scoped. They accumulate across phase transitions by design:

  • An aggregate tracking sum of shares accumulates whether the session is in pre_trade or post_trade
  • An envelope ceiling set in pre_trade constrains values in post_trade

This prevents salami attacks that span phases — breaking a harmful total across multiple phases doesn't bypass aggregate limits.

Graph analysis detects dead and unreachable phases

The contract graph analyzer (run at compile time) checks for phase-related issues:

  • Dead phases — a phase exists but no tools have valid_in_phases including it
  • Unreachable phases — a non-initial phase has no incoming transitions
  • Circular deadlocks — a set of phases forms a cycle with no exit (error severity)

These diagnostics appear during session creation or when running vesanor validate. They can be suppressed in session.yaml with a required reason:

graph_analysis:
suppress:
- check: dead_phase
phase: pass_through_phase
reason: "Intentional pass-through, no tools needed"

Design tips

  1. Start simple. A linear 3-5 phase workflow covers most use cases. Add branching later if needed.

  2. Name phases by what's been accomplished, not what's happening. customer_identified (past tense, clear) beats identifying_customer (ongoing, ambiguous about completion).

  3. Always have an escalation terminal. Real agents need an escape hatch: escalated, error, or manual_review.

  4. Don't over-phase. If two tools are always valid together and advance to the same place, they don't need separate phases. Phases should represent meaningful state boundaries.

  5. Test with shadow mode first. Run mode: "shadow" to see how your phase machine behaves with real traffic before enabling enforcement.


Next steps