Migration Guide: L0 -> L1 -> L2
This guide shows the same voice agent implemented at all three layers, so you can see what each layer adds and decide where to build.
Why Migrate?
Each layer removes a category of boilerplate:
| What you write | L0 (gemini-genai-rs) | L1 (gemini-adk-rs) | L2 (gemini-adk-fluent-rs) |
|---|---|---|---|
| WebSocket connection | Manual | Manual | One line |
Event loop (select!) | Manual | Automatic | Automatic |
| Tool dispatch + response | Manual | Automatic | Automatic |
| State management | None | Built-in | Built-in |
| Phase transitions | Manual | PhaseMachine | .phase() builder |
| Turn extraction | None | TurnExtractor | .extract_turns::<T>() |
| Telemetry | None | SessionTelemetry | Auto-collected |
| Instruction updates | Manual | instruction_template | .instruction_template() |
The tradeoff is control. L0 gives you total control over every message. L2 handles the common patterns automatically but gives you less room to customize the event processing loop itself.
The L2 prelude: kernel + submodule map
gemini_adk_fluent_rs::prelude is a kernel, not an everything-glob. It
re-exports the ~40 types a typical application touches; everything else lives in
a focused, discoverable submodule. Start with the prelude and reach for a
submodule when the compiler says a name isn't found.
In the kernel prelude:
- Builders & composition:
AgentBuilder/Agent, theS·C·T·P·M·A·E·G·Ctxalgebra, operators (>> | * /) and patterns (until,review_loop,fan_out_merge,supervised),Live. - State:
State,StateKey. - Flow (core):
Flow,Guard,FlowMonitor,FlowMode,Verdict,ToolPolicy. - Tools (core):
SimpleTool,TypedTool,ToolFunction,ToolDispatcher,#[tool],Extract,Frame. - LLM (core):
BaseLlm,GeminiLlm. - Errors:
AgentError,AgentResult,ToolError. - Callback contexts:
CallbackContext,ToolContext. - Common Live types:
LiveHandle,EventCallbacks,SteeringMode,ContextDelivery,RepairConfig,SessionPersistence,FsPersistence,MemoryPersistence,TurnExtractor,ExtractionTrigger,LlmExtractor,SoftTurnDetector,TranscriptBuffer,TranscriptTurn. - Text-agent combinators (
LlmTextAgent,SequentialTextAgent, …). - Build-time validation:
check_contracts,ContractViolation,diagnose,infer_data_flow,AgentHarness,DataFlowEdge. - The L0 wire prelude (
GeminiModel,Voice,Content,Part,Role, …).
Moved to submodules (import the named module):
| Symbol(s) | Home |
|---|---|
Full Live control plane: LiveEvent, RuntimeContract, FieldPromotion, DeferredWriter, PendingContext, NeedsFulfillment, RepairAction, SessionSnapshot, LiveSessionBuilder, CallbackMode, ToolExecutionMode, the *Contract types, … | gemini_adk_fluent_rs::live |
| Text-agent runtime internals | gemini_adk_fluent_rs::text |
Toolset, StaticToolset, ConfirmationProvider, Recognizer, RecordExtractor, FrameSpec, SlotSpec, … | gemini_adk_fluent_rs::tools |
SlotEvidence, prefix-scope helpers | gemini_adk_fluent_rs::state |
CompiledFlow, StepAction, Violation, FlowExplanation, run (on-enter), … | gemini_adk_fluent_rs::flow |
AgentTrait (L1 Agent trait), call_agent, AgentMode, provenance, Resolver, agent_session::* | gemini_adk_fluent_rs::agents |
LlmRequest, LlmResponse, GeminiLlmParams, LlmRegistry | gemini_adk_fluent_rs::llm |
Conversation, ConversationSpec, CompiledConversation, FlowStack, … | gemini_adk_fluent_rs::conversation |
A2AServer, RemoteAgent, SkillDeclaration | gemini_adk_fluent_rs::a2a |
Scenario, Sim, SimStep | gemini_adk_fluent_rs::simulation |
Motif, CommitPolicy, Policy | gemini_adk_fluent_rs::{motifs, policy} |
| Raw L0 wire types | gemini_adk_fluent_rs::wire |
The L1
Agenttrait is exposed asAgentTrait(in bothpreludeandagents) to avoid colliding with the L2Agentbuilder alias.
0.8 feature changes (slim defaults)
gemini-genai-rs default features contracted to ["live", "tls-native"]:
- ML VAD is opt-in. The
wavekatVAD model is no longer compiled by default. Enablevad-wavekat(available as a passthrough feature ongemini-adk-rsandgemini-adk-fluent-rstoo). The lightweight energy VAD (vad) is still enabled bygemini-adk-rs. - TLS backend is selectable.
tls-native(default) ortls-rustls; both the WebSocket transport and the optional REST client follow the choice. To go rustls:default-features = false, features = ["live", "tls-rustls"]. - Tracing facade vs subscriber. The
tracingfacade is always compiled (spans/events are no-ops without a subscriber).TelemetryConfig::init's console-logging machinery now sits behind thetracing-subscriberfeature. The oldtracing-supportfeature is a deprecated no-op for one release. - No more
tokio/full. The published crates declare only the tokio features they use; applications control their own tokio feature set.
L0: Wire Protocol
At L0, you work directly with SessionHandle, SessionEvent, and
SessionCommand. You write your own event loop, dispatch tools manually,
and manage all state yourself.
Here is a weather assistant with one tool:
use gemini_genai_rs::prelude::*;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Build session config with tool declaration
let config = SessionConfig::from_endpoint(
ApiEndpoint::google_ai(std::env::var("GEMINI_API_KEY")?)
)
.model(GeminiModel::Gemini2_0FlashLive)
.system_instruction("You are a weather assistant. Use get_weather for queries.")
.add_tool(Tool {
function_declarations: Some(vec![FunctionDeclaration {
name: "get_weather".into(),
description: "Get current weather for a city".into(),
parameters: Some(json!({
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" }
},
"required": ["city"]
})),
}]),
..Default::default()
});
// 2. Connect
let handle = ConnectBuilder::new(config).build().await?;
handle.wait_for_phase(SessionPhase::Active).await;
// 3. Subscribe to events
let mut events = handle.subscribe();
// 4. Send a question
handle.send_text("What's the weather in Tokyo?").await?;
// 5. Manual event loop
while let Some(event) = recv_event(&mut events).await {
match event {
SessionEvent::TextDelta(text) => {
print!("{text}");
}
SessionEvent::TurnComplete => {
println!();
}
SessionEvent::ToolCall(calls) => {
// Manual tool dispatch
let mut responses = Vec::new();
for call in calls {
let result = match call.name.as_str() {
"get_weather" => {
let city = call.args.get("city")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
json!({ "city": city, "temp_c": 22, "condition": "sunny" })
}
_ => json!({ "error": "unknown tool" }),
};
responses.push(FunctionResponse {
name: call.name.clone(),
id: call.id.clone(),
response: result,
});
}
// Manual response send
handle.send_tool_response(responses).await?;
}
SessionEvent::Disconnected(_) => break,
_ => {}
}
}
Ok(())
}
Lines of code: ~70 What you manage: Event loop, tool dispatch, tool response serialization, phase waiting, all state.
L1: Agent Runtime
At L1, LiveSessionBuilder handles the event loop, tool dispatch, and
state. You register callbacks and a ToolDispatcher instead of writing
a match over every event variant.
Same weather assistant:
use gemini_adk_rs::{SimpleTool, ToolDispatcher, LiveSessionBuilder};
use gemini_genai_rs::prelude::*;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Create tool dispatcher
let mut dispatcher = ToolDispatcher::new();
dispatcher.register(SimpleTool::new(
"get_weather",
"Get current weather for a city",
None, // JSON Schema for parameters (None = no declared schema)
|args| async move {
let city = args["city"].as_str().unwrap_or("unknown");
Ok(json!({ "city": city, "temp_c": 22, "condition": "sunny" }))
},
));
// 2. Build session config
let config = SessionConfig::from_endpoint(
ApiEndpoint::google_ai(std::env::var("GEMINI_API_KEY")?)
)
.model(GeminiModel::Gemini2_0FlashLive)
.system_instruction("You are a weather assistant. Use get_weather for queries.");
// 3. Build callbacks
let mut callbacks = gemini_adk_rs::EventCallbacks::default();
callbacks.on_text = Some(Box::new(|t| print!("{t}")));
callbacks.on_turn_complete = Some(std::sync::Arc::new(|| {
Box::pin(async { println!() })
}));
// 4. Build and connect
let handle = LiveSessionBuilder::new(config)
.dispatcher(dispatcher)
.callbacks(callbacks)
.connect()
.await?;
// 5. Send a question (tools are auto-dispatched)
handle.send_text("What's the weather in Tokyo?").await?;
handle.done().await?;
Ok(())
}
Lines of code: ~40
What changed: No event loop. No manual tool dispatch. No manual
send_tool_response. The ToolDispatcher handles tool calls automatically:
it matches the function name, deserializes args, calls your function, and
sends the response back to the model.
You also get State (via handle.state()), SessionTelemetry
(via handle.telemetry()), and the full three-lane processor for free.
L2: Fluent DX
At L2, Live::builder() wraps everything in a chainable API. The same
weather assistant:
use gemini_adk_fluent_rs::prelude::*;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = Live::builder()
.model(GeminiModel::Gemini2_0FlashLive)
.instruction("You are a weather assistant. Use get_weather for queries.")
.with_tools(
T::simple("get_weather", "Get current weather for a city", |args| async move {
let city = args["city"].as_str().unwrap_or("unknown");
Ok(json!({ "city": city, "temp_c": 22, "condition": "sunny" }))
})
)
.on_text(|t| print!("{t}"))
.on_turn_complete(|| async { println!() })
.connect_google_ai(std::env::var("GEMINI_API_KEY")?)
.await?;
handle.send_text("What's the weather in Tokyo?").await?;
handle.done().await?;
Ok(())
}
Lines of code: ~20
What changed: No SessionConfig construction. No ToolDispatcher
setup. No EventCallbacks struct. The builder infers everything:
.with_tools()creates and configures theToolDispatcher.instruction()sets the system instruction on the underlyingSessionConfig.connect_google_ai()builds the endpoint and connects in one call
L2 with Multiple Tools
Tools compose with the | operator:
let handle = Live::builder()
.model(GeminiModel::Gemini2_0FlashLive)
.instruction("You are a helpful assistant with access to tools.")
.with_tools(
T::simple("get_weather", "Get weather", |args| async move {
Ok(json!({ "temp_c": 22 }))
})
| T::simple("get_time", "Get current time", |_| async move {
Ok(json!({ "time": "14:30" }))
})
| T::google_search()
)
.on_text(|t| print!("{t}"))
.connect_google_ai(api_key)
.await?;
Feature Comparison Table
| Feature | L0 | L1 | L2 |
|---|---|---|---|
| WebSocket connection | ConnectBuilder::new(config).build() | LiveSessionBuilder::new(config).connect() | Live::builder().connect_*() |
| Event loop | Manual while let + match | Automatic (three-lane processor) | Automatic |
| Audio callback | Manual match SessionEvent::AudioData | callbacks.on_audio = Some(...) | .on_audio(|data| ...) |
| Tool dispatch | Manual match + response send | ToolDispatcher auto-dispatch | .tools() or .with_tools() |
| Tool declaration | Manual Tool + FunctionDeclaration | Auto from ToolFunction::parameters() | Auto from T::simple() |
| State management | None (DIY) | State with prefixes | State with prefixes |
| Phase machine | None (DIY) | PhaseMachine::new() | .phase("name").instruction().done() |
| Watchers | None (DIY) | WatcherRegistry | .watch("key").became_true().then() |
| Turn extraction | None (DIY) | TurnExtractor trait | .extract_turns::<T>(llm, prompt) |
| Instruction template | handle.update_instruction() | callbacks.instruction_template | .instruction_template(|state| ...) |
| Greeting | handle.send_text() after connect | builder.greeting("...") | .greeting("...") |
| Telemetry | None | SessionTelemetry auto-collected | Auto-collected |
| Session signals | None | SessionSignals auto-collected | Auto-collected |
| Transcription toggle | config.enable_input_transcription() | Same | .transcription(true, true) |
| Computed state | None | ComputedRegistry | .computed("key", &["deps"], |s| ...) |
| Temporal patterns | None | TemporalRegistry | .when_sustained() / .when_rate() |
| Text agent tools | None | TextAgentTool | .agent_tool("name", "desc", agent) |
When to Stay at L0
L0 is the right choice when you need:
Custom transport: You want to route WebSocket frames through a proxy, use a Unix socket, or implement a custom reconnection strategy.
let handle = ConnectBuilder::new(config)
.transport(MyCustomTransport::new())
.codec(MyCustomCodec::new())
.build()
.await?;
Non-standard event processing: Your application needs to process events in an order or pattern that does not fit the callback model (e.g., batching audio chunks before processing, custom priority queuing).
Embedding in a larger runtime: You are building your own agent framework and want wire-level access without the L1 runtime's task spawning.
Minimal binary size: L0 has fewer dependencies than L1/L2.
When to Stay at L1
L1 is the right choice when you need:
Programmatic callback registration: You build callbacks dynamically based on configuration or plugin systems, and the fluent builder syntax gets in the way.
let mut callbacks = EventCallbacks::default();
if config.enable_logging {
callbacks.on_text = Some(Box::new(|t| println!("{t}")));
}
if config.enable_audio {
callbacks.on_audio = Some(Box::new(move |data| {
audio_tx.send(data.clone()).ok();
}));
}
Custom PhaseMachine setup: You need to build the phase machine programmatically (e.g., phases loaded from a database at runtime).
Direct registry access: You want to add/configure ComputedRegistry,
WatcherRegistry, or TemporalRegistry objects directly rather than
through sub-builders.
Mixing Layers
The layers are designed to compose. Common patterns:
L0 config + L2 builder: Build a SessionConfig at L0 and pass it to
the L2 builder. Useful when build_session_config() handles credential
detection for you:
let config = build_session_config(Some("gemini-2.0-flash-live"))?
.voice(Voice::Kore)
.response_modalities(vec![Modality::Audio])
.system_instruction("You are a helpful assistant.");
let handle = Live::builder()
.on_audio(|data| { /* play */ })
.on_text(|t| print!("{t}"))
.connect(config)
.await?;
L1 types in L2 callbacks: The on_tool_call callback receives State
(an L1 type) that you can query and mutate:
let handle = Live::builder()
.on_tool_call(|calls, state| async move {
// Promote tool context to state
state.set("last_tool", calls[0].name.clone());
None // auto-dispatch
})
.connect_google_ai(api_key)
.await?;
L0 handle from L2: Access the underlying SessionHandle for operations
not exposed on LiveHandle:
let live_handle = Live::builder()
.connect_google_ai(api_key)
.await?;
// Access raw L0 handle
let session = live_handle.session();
let events = session.subscribe();
let phase = session.phase();
Migration Checklist
When migrating from L0 to L2:
- Replace
SessionConfig::from_endpoint(...)withLive::builder().model().instruction() - Replace manual
Tooldeclarations with.tools(dispatcher)or.with_tools(T::simple(...)) - Replace the
while let Some(event) = recv_event(...)loop with callbacks - Replace
match SessionEvent::AudioDatawith.on_audio() - Replace
match SessionEvent::TextDeltawith.on_text() - Replace manual
send_tool_response()withToolDispatcherauto-dispatch - Replace
ConnectBuilder::new(config).build()with.connect_google_ai()or.connect_vertex() - Replace manual phase tracking with
.phase("name").instruction().transition().done() - Replace manual state HashMaps with
.extract_turns::<T>()andhandle.state() - Remove the
tokio::select!loop -- the three-lane processor handles it
See also
- Architecture Overview — the three-crate stack explained, with a guide on choosing your layer
- S.C.T.P.M.A Operator Algebra — fluent composition operators available at L2