Skip to main content

Add Phase Control in 10 Minutes

Build on the Quickstart by adding a session.yaml with phases. Your agent will only see tools valid in its current phase — the LLM can't even ask for tools it shouldn't use.


The scenario

You're building a customer support agent that handles refunds. The correct flow is:

triage → look up customer → check eligibility → issue refund → send confirmation

Without phases, the model might skip straight to issue_refund without checking eligibility first. With phases, it can't — issue_refund isn't even visible until the right phase.


1. Create your contracts directory

contracts/
session.yaml
lookup_customer.yaml
check_eligibility.yaml
issue_refund.yaml
send_confirmation.yaml

2. Define the session contract

contracts/session.yaml defines the phase machine — the legal sequence of states your agent can move through.

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]

session_limits:
max_steps: 10
max_tool_calls: 8

risk_defaults:
write: block
destructive: block

What this says:

  • The session starts in triage and must end in completed
  • Each phase can only transition to specific next phases
  • Maximum 10 LLM calls, 8 tool calls total
  • Write and destructive tools are blocked by default (unless their contract says otherwise)

3. Define per-tool contracts

Each tool declares which phase it's valid in and which phase it advances to.

contracts/lookup_customer.yaml

tool: lookup_customer
side_effect: read
evidence_class: local_transaction
commit_requirement: acknowledged
timeouts: { total_ms: 30000 }
retries: { max_attempts: 1, retry_on: [] }
rate_limits: { on_429: { respect_retry_after: true, max_sleep_seconds: 60 } }
assertions: { input_invariants: [], output_invariants: [] }
golden_cases: []
allowed_errors: []

transitions:
valid_in_phases: [triage]
advances_to: customer_identified

argument_value_invariants:
- path: "$.customer_email"
regex: "^.+@.+"

contracts/check_eligibility.yaml

tool: check_eligibility
side_effect: read
evidence_class: local_transaction
commit_requirement: acknowledged
timeouts: { total_ms: 30000 }
retries: { max_attempts: 1, retry_on: [] }
rate_limits: { on_429: { respect_retry_after: true, max_sleep_seconds: 60 } }
assertions: { input_invariants: [], output_invariants: [] }
golden_cases: []
allowed_errors: []

transitions:
valid_in_phases: [customer_identified]
advances_to: eligibility_checked

preconditions:
- requires_prior_tool: lookup_customer

contracts/issue_refund.yaml

tool: issue_refund
side_effect: write
evidence_class: ack_only
commit_requirement: acknowledged
timeouts: { total_ms: 30000 }
retries: { max_attempts: 1, retry_on: [] }
rate_limits: { on_429: { respect_retry_after: true, max_sleep_seconds: 60 } }
assertions: { input_invariants: [], output_invariants: [] }
golden_cases: []
allowed_errors: []
gate: block

transitions:
valid_in_phases: [eligibility_checked]
advances_to: refund_issued

preconditions:
- requires_prior_tool: check_eligibility

forbids_after: [issue_refund]

argument_value_invariants:
- path: "$.amount"
gte: 0
lte: 10000

contracts/send_confirmation.yaml

tool: send_confirmation
side_effect: write
evidence_class: ack_only
commit_requirement: acknowledged
timeouts: { total_ms: 30000 }
retries: { max_attempts: 1, retry_on: [] }
rate_limits: { on_429: { respect_retry_after: true, max_sleep_seconds: 60 } }
assertions: { input_invariants: [], output_invariants: [] }
golden_cases: []
allowed_errors: []
gate: block

transitions:
valid_in_phases: [refund_issued]
advances_to: completed

preconditions:
- requires_prior_tool: issue_refund

4. Run it

import OpenAI from "openai";
import { replay } from "@vesanor/replay";

const client = new OpenAI();

// Define all tools (you'll pass the full list — narrowing handles the rest)
const ALL_TOOLS: OpenAI.ChatCompletionTool[] = [
{ type: "function", function: { name: "lookup_customer", description: "Look up a customer by email", parameters: { type: "object", properties: { customer_email: { type: "string" } }, required: ["customer_email"] } } },
{ type: "function", function: { name: "check_eligibility", description: "Check refund eligibility", parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] } } },
{ type: "function", function: { name: "issue_refund", description: "Issue a refund", parameters: { type: "object", properties: { order_id: { type: "string" }, amount: { type: "number" } }, required: ["order_id", "amount"] } } },
{ type: "function", function: { name: "send_confirmation", description: "Send confirmation email", parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] } } },
];

const session = replay(client, {
contractsDir: "./contracts",
agent: "refund-support-bot",
mode: "enforce",
gate: "reject_all",
onNarrow: (narrowing) => {
console.log(`Phase: tools available = ${narrowing.allowed.length}, removed = ${narrowing.removed.length}`);
for (const r of narrowing.removed) {
console.log(` removed: ${r.tool} (${r.reason})`);
}
},
});

// Step 1: In triage phase — only lookup_customer is visible
const state0 = session.getState();
console.log("Phase:", state0.currentPhase); // "triage"

const response1 = await session.client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are a customer support agent." },
{ role: "user", content: "Look up customer [email protected]" },
],
tools: ALL_TOOLS, // You pass all 4 tools...
tool_choice: "required",
});

// But the LLM only saw lookup_customer!
// The onNarrow callback printed:
// Phase: tools available = 1, removed = 3
// removed: check_eligibility (wrong_phase)
// removed: issue_refund (wrong_phase)
// removed: send_confirmation (wrong_phase)

const state1 = session.getState();
console.log("Phase:", state1.currentPhase); // "customer_identified"

The key insight: You pass all 4 tools to create(), but the enforcement pipeline removes 3 of them before the LLM sees the request. The model literally cannot ask for issue_refund during the triage phase — it's not in the tool list.


5. Watch the phases advance

As the agent calls each tool, the phase advances automatically:

Step 1: lookup_customer      → phase: triage → customer_identified
Step 2: check_eligibility → phase: customer_identified → eligibility_checked
Step 3: issue_refund → phase: eligibility_checked → refund_issued
Step 4: send_confirmation → phase: refund_issued → completed

After step 3, issue_refund is also added to forbiddenTools (because of forbids_after: [issue_refund]). Even if the model somehow tried to call it again, it would be blocked.

After step 4, the session is in the completed terminal phase. No more tool calls are valid.


What you just built

With 5 YAML files and zero changes to your agent code, you now have:

  • Phase-based narrowing — the model only sees tools valid in the current phase
  • Preconditionscheck_eligibility requires lookup_customer first
  • Forbidden toolsissue_refund can't be called twice
  • Argument validation — refund amount must be between $0 and $10,000
  • Session limits — max 10 steps, 8 tool calls
  • Risk defaults — write/destructive tools blocked unless explicitly allowed

Common patterns

Linear workflow (like the refund example)

A → B → C → D

Each phase has exactly one next phase. Simple and clear.

Branching workflow

transitions:
triage: [customer_identified, escalated]
customer_identified: [eligibility_checked, escalated]
eligibility_checked: [refund_issued, escalated]

Any phase can branch to escalated (a terminal phase). Useful for error handling.

Multiple tools per phase

# Tool A: valid in "research" phase
transitions:
valid_in_phases: [research]
advances_to: analysis

# Tool B: also valid in "research" phase
transitions:
valid_in_phases: [research]
advances_to: analysis

Both tools are available during the research phase. The model picks which one to use.


Next steps