Phase System
The phase system models conversations as a state machine. Each phase carries its own instruction, tool filter, and transition rules. The SDK evaluates transition guards after every state mutation and automatically switches phases when conditions are met -- updating the model's instruction, running lifecycle callbacks, and filtering tools, all without manual wiring.
What Are Phases?
A phase is a named conversation stage. A debt collection call might have:
disclosure -> verify_identity -> inform_debt -> negotiate -> close.
A support call might have: greet -> identify -> investigate -> resolve -> close.
Each phase defines:
- Instruction -- what the model should do in this stage
- Transitions -- guard conditions that trigger moves to other phases
- Tools -- which tools the model can call (optional filter)
- Lifecycle callbacks --
on_enter/on_exithooks
Defining Phases
Use the fluent Live::builder() API. Each .phase() call starts a
PhaseBuilder that returns to the main builder via .done():
use gemini_adk_fluent_rs::prelude::*;
let handle = Live::builder()
.model(GeminiModel::Gemini2_0FlashLive)
.phase("greeting")
.instruction("Welcome the user warmly and ask how you can help.")
.transition("main", |s| s.get::<bool>("greeted").unwrap_or(false))
.done()
.phase("main")
.instruction("Handle the user's request.")
.terminal()
.done()
.initial_phase("greeting")
.connect_vertex(project, location, token)
.await?;
Key points:
.initial_phase("greeting")declares which phase the machine starts in..terminal()marks a phase with no outbound transitions.- The machine is validated at connect time -- missing initial phase or dangling transition targets produce clear errors.
Phase Transitions
Transitions are guard-based: a closure that receives &State and returns bool.
When the guard returns true, the machine transitions to the target phase.
.phase("disclosure")
.instruction(DISCLOSURE_INSTRUCTION)
// When disclosure_given becomes true, move to verify_identity
.transition("verify_identity", |s| {
s.get::<bool>("disclosure_given").unwrap_or(false)
})
// Emergency exit: cease-and-desist goes straight to close
.transition("close", |s| {
s.get::<bool>("cease_desist_requested").unwrap_or(false)
})
.done()
Transitions are evaluated in order -- the first guard that returns true wins.
This means you should order transitions from most specific to most general.
Transition Descriptions
Use transition_with() to add a human-readable description to each transition.
These descriptions are used by the phase navigation context (see below) to give
the model awareness of where it can go and why:
.phase("identify_caller")
.instruction("Get the caller's full name and organization.")
.transition_with("determine_purpose", |s| {
s.get::<String>("caller_name").is_some()
}, "when caller provides their name")
.transition_with("take_message", |s| {
let tc: u32 = s.session().get("turn_count").unwrap_or(0);
tc >= 12
}, "after 12 turns if caller refuses to identify")
.done()
The plain .transition() method still works and sets description: None.
S:: Predicates
The S module provides ergonomic predicate factories that eliminate boilerplate:
use gemini_adk_fluent_rs::prelude::S;
.transition("verify_identity", S::is_true("disclosure_given"))
.transition("negotiate", S::is_true("debt_acknowledged"))
.transition("arrange_payment", S::one_of("negotiation_intent", &["full_pay", "partial_pay"]))
.transition("tech_support", S::eq("issue_type", "technical"))
Available predicates:
S::is_true(key)-- key holdstrueS::eq(key, value)-- key equals the given stringS::one_of(key, &[values])-- key matches any of the given strings
Guards
A phase-level guard prevents the phase from being entered unless a condition is met. This is different from transition guards -- a transition guard decides when to leave a phase, while a phase guard decides whether the phase can be entered.
.phase("verify_identity")
.instruction(VERIFY_IDENTITY_INSTRUCTION)
// This phase can only be entered after disclosure is acknowledged
.guard(S::is_true("disclosure_given"))
.transition("inform_debt", S::is_true("identity_verified"))
.done()
If a transition guard fires but the target phase's guard returns false, the
machine skips that transition and evaluates the next one in order.
Dynamic Instructions
For instructions that depend on runtime state, use dynamic_instruction:
.phase("discuss")
.dynamic_instruction(|s| {
let topic: String = s.get("topic").unwrap_or_default();
let mood: String = s.get("derived:sentiment").unwrap_or_default();
format!("Discuss {topic}. The user's mood is {mood}. Adjust tone accordingly.")
})
.done()
The closure is evaluated at transition time, so the instruction always reflects current state.
Instruction Modifiers
Modifiers append context to a phase's instruction without replacing it. Three types are available:
StateAppend -- Inject Key/Value Context
.phase("negotiate")
.instruction("Help the customer resolve their debt.")
// Appends: [Context: emotional_state=frustrated, willingness_to_pay=0.3]
.with_state(&["emotional_state", "willingness_to_pay", "derived:call_risk_level"])
.done()
Conditional -- Append Text When True
fn risk_is_elevated(s: &State) -> bool {
let risk: String = s.get("derived:call_risk_level").unwrap_or_default();
risk == "high" || risk == "critical"
}
.phase("negotiate")
.instruction("Help the customer resolve their debt.")
.when(risk_is_elevated, "IMPORTANT: Use extra empathy. Never threaten.")
.done()
CustomAppend -- Arbitrary Formatting
.phase("investigate")
.instruction("Investigate the issue.")
.with_context(|state| {
let items: Vec<String> = state.get("order_items").unwrap_or_default();
if items.is_empty() {
String::new()
} else {
format!("Current order: {}", items.join(", "))
}
})
.done()
Tool Filtering
Each phase can restrict which tools the model is allowed to call. Tool calls for tools not in the filter are rejected by the processor:
.phase("verify_identity")
.instruction("Verify the caller's identity.")
.tools(vec!["verify_identity".into(), "log_compliance_event".into()])
.done()
.phase("negotiate")
.instruction("Negotiate a payment plan.")
.tools(vec!["calculate_payment_plan".into(), "log_compliance_event".into()])
.done()
Omitting .tools() means all registered tools are available in that phase.
Phase Needs
Declare what state keys a phase requires. The SDK uses these to generate the navigation context (see below), showing the model what information is still missing. This helps guide the conversation without over-constraining the LLM:
.phase("identify_caller")
.instruction("Get the caller's full name and organization.")
.needs(&["caller_name", "caller_org"])
.transition_with("determine_purpose", |s| {
s.get::<String>("caller_name").is_some()
}, "when caller provides their name")
.done()
At runtime, needs are filtered against the current state -- only keys not
yet present are shown as "still needed" in the navigation context.
Phase Navigation Context
The .navigation() modifier (available on both PhaseBuilder and
phase_defaults) injects a structured description of the phase graph into the
model's instruction. This gives the model geolocation awareness:
[Navigation]
Current phase: identify_caller -- Get the caller's full name and organization.
Previous: greeting (turn 2)
Still needed: caller_org
Possible next:
-> determine_purpose: when caller provides their name
-> take_message: after 12 turns if caller refuses to identify
This is auto-generated from .needs() keys filtered by state, .transition_with()
descriptions, and phase history. Apply it via phase_defaults so all phases
benefit:
Live::builder()
.phase_defaults(|d| d
.with_state(&["caller_name", "caller_org"])
.navigation() // inject navigation context into every phase
)
The navigation context is stored in session:navigation_context and regenerated
on every turn and phase transition.
Phase Lifecycle Callbacks
on_enter and on_exit are async callbacks that run during transitions:
.phase("verify_identity")
.instruction(VERIFY_IDENTITY_INSTRUCTION)
.on_enter(|state, writer| async move {
// Log the transition, initialize phase-specific state
state.set("verification_attempts", 0u32);
tracing::info!("Entered verify_identity phase");
})
.on_exit(|state, writer| async move {
// Clean up, log compliance event
tracing::info!("Exiting verify_identity phase");
})
.done()
The callbacks receive State and Arc<dyn SessionWriter>, so you can both
mutate state and send messages to the model.
enter_prompt -- Model Speaks on Entry
Use enter_prompt to inject a model-role bridge message and prompt the model to
respond immediately when entering a phase. This prevents the "cold start" problem
where the model says "how can I help?" after a phase transition:
.phase("verify_identity")
.instruction(VERIFY_IDENTITY_INSTRUCTION)
// Model will say this, then continue with the phase instruction
.enter_prompt("The caller confirmed the disclosure. I'll now verify their identity.")
.done()
For state-dependent prompts, use enter_prompt_fn:
.phase("close")
.instruction(CLOSE_INSTRUCTION)
.enter_prompt_fn(|state, _tw| {
if state.get::<bool>("cease_desist_requested").unwrap_or(false) {
"Cease-and-desist requested. Closing call respectfully.".into()
} else {
"Wrapping up the call.".into()
}
})
.done()
Phase Defaults
Settings shared across all phases are declared with phase_defaults. These are
merged into each phase -- phase-specific modifiers extend (not replace) the
defaults:
const DEBT_STATE_KEYS: &[&str] = &[
"emotional_state",
"willingness_to_pay",
"derived:call_risk_level",
"identity_verified",
"disclosure_given",
];
Live::builder()
.phase_defaults(|d| d
.with_state(DEBT_STATE_KEYS)
.when(risk_is_elevated, "IMPORTANT: Use extra empathy.")
.prompt_on_enter(true)
)
.phase("disclosure")
.instruction(DISCLOSURE_INSTRUCTION)
// Inherits with_state, when(), and prompt_on_enter from defaults
.done()
.phase("negotiate")
.instruction(NEGOTIATE_INSTRUCTION)
// Phase-specific modifier is appended after defaults
.with_state(&["negotiation_intent"])
.done()
Multi-Phase Example
A 3-phase conversation (greeting -> service -> close) combining the features covered above:
Live::builder()
.model(GeminiModel::Gemini2_0FlashLive)
.greeting("Greet the user warmly.")
.phase_defaults(|d| d
.with_state(&["customer_name", "derived:sentiment"])
.prompt_on_enter(true)
)
.phase("greeting")
.instruction("Welcome the customer. Ask for their name.")
.transition("service", |s| s.contains("customer_name"))
.done()
.phase("service")
.instruction("Help the customer with their request.")
.guard(|s| s.contains("customer_name"))
.tools(vec!["lookup_account".into(), "process_refund".into()])
.transition("close", S::is_true("resolved"))
.when(|s| s.get::<String>("derived:sentiment").unwrap_or_default() == "negative",
"The customer seems upset. Use extra empathy.")
.enter_prompt("I have the customer's name. I'll help them now.")
.done()
.phase("close")
.instruction("Thank the customer and wrap up.")
.terminal()
.done()
.initial_phase("greeting")
.connect_vertex(project, location, token)
.await?;
For a full 7-phase example with compliance gates, computed state, watchers, and
temporal patterns, see apps/gemini-adk-web-rs/src/apps/debt_collection.rs.
How Transitions Are Evaluated
The processor evaluates transitions after every state mutation cycle (extractors run, computed variables update, watchers fire). The evaluation is pure -- it checks guards without side effects:
- Get the current phase's transition list.
- For each transition (in order), check the guard.
- If the guard returns
true, check the target phase's guard (if any). - If both pass, execute the transition:
on_exit-> update current ->on_enter. - The new phase's instruction (with modifiers applied) is sent to the model.
Terminal phases skip transition evaluation entirely.
For the full turn-complete pipeline, timing diagrams, background agent dispatch, and common pitfalls, see Phase Transitions Deep Dive.