Extraction Pipeline
What Is Extraction?
Section titled “What Is Extraction?”Extraction turns unstructured conversation into structured data. As the user
and model talk, extractors analyze the transcript and produce typed JSON:
customer name, order items, emotional state, account numbers. These values
flow into session State where they drive phase transitions, trigger
watchers, and inform instruction composition.
Why Out-of-Band?
Section titled “Why Out-of-Band?”Extraction runs on the control lane, not on the conversation path. An LLM extraction call takes 1-5 seconds. Voice conversations cannot pause for that. Extractors run concurrently after each turn completes while the conversation continues uninterrupted.
Turn completes -> Transcript buffer finalizes the turn -> Extractors run concurrently (control lane) -> Results written to State under derived: prefix -> Watchers evaluate -> Phase transitions fire -> Conversation continues (no blocking)Deterministic extraction (no model)
Section titled “Deterministic extraction (no model)”Not every field needs an LLM. Slots like quantities, money, yes/no, a name
matched against a roster, or a date/time are recognizable on the CPU — no model,
no network, no accelerator. The Extract kit declares a record of typed fields,
each filled by a Recognizer, and compiles to a TurnExtractor that promotes
recognized fields straight into State.
use gemini_adk_rs::Extract; // the #[derive(Extract)] macro
#[derive(Extract)]#[extract(name = "order", window = 3)]struct Order { #[recognize(integer_near = ["want", "get"])] quantity: Option<i64>, #[recognize(one_of = ["pizza", "salad", "soda"])] item: Option<String>, #[recognize(fuzzy = ["Johnson", "Jackson"])] // ASR-robust name match name: Option<String>, #[recognize(datetime)] // → { "time": "18:00", "day": "tomorrow" } #[extract(state = "when")] pickup: Option<serde_json::Value>, #[recognize(yes_no)] confirmed: Option<bool>,}
Live::builder() .extract_record(Order::extract()) // deterministic, runs on the control lane .govern(order_flow) // Flow reads done(captured(["quantity", "item"])) .connect_from_env().await?;Recognizer forms: integer/integer_near, money, regex, one_of,
fuzzy (Jaro-Winkler, ASR-robust), yes_no, and datetime (a small on-device
clock/calendar normalizer: 12h/24h time → HH:MM, relative days, weekdays,
parts of day, ISO dates). You can also build a record fluently with
Extract::record(name).field(..) instead of the derive.
Async field sources
Section titled “Async field sources”A field can be filled by an async resolver instead of a recognizer —
arguments bound from State, with an optional TTL cache:
let booking = Extract::record("booking") .field("slot", Recognizer::one_of(["morning", "afternoon"])) // args bound from State → fetcher → field; cached 30s by (field, args). .field_resolve("availability", ["slot"], Some(Duration::from_secs(30)), |args| async move { let slot = args.get("slot").and_then(|v| v.as_str()).unwrap_or(""); Ok(serde_json::json!({ "open": slot == "afternoon" })) }) // run a downstream agent when the record lands fields (result → booking:result): .on_complete(router_agent, AgentMode::Dispatch) .build();(Closures can’t live in attributes, so resolver fields use the builder;
#[derive(Extract)] covers the recognizer fields.)
Standalone Resolver
Section titled “Standalone Resolver”The async sibling of Recognizer is Resolver: a named value source whose
inputs come from State and whose result lands under {name}:result (or
{name}:error). It generalizes a sub-agent call to any async source — a
tool call, an HTTP fetch, or an MCP request (see Orchestration):
use gemini_adk_rs::Resolver;
// From a sub-agent (its String output becomes the result):Resolver::agent("availability", availability_agent).resolve(&state).await?;
// From any async system, with inputs bound from State:Resolver::fetch("availability", |s: State| async move { let slot = s.get::<String>("slot").unwrap_or_default(); Ok(serde_json::json!({ "open": slot == "afternoon" }))}).resolve(&state).await?; // or .dispatch(state) to run detachedBoth deterministic Recognizer fields and async Resolver results live in
State under the same conventions, so a Flow step completes on
either — done(captured(["quantity"])) or done(resolved("availability")) —
and a flow step’s on_enter can launch a resolver automatically.
TurnExtractor
Section titled “TurnExtractor”The base trait for all extractors. Implement it for synchronous extraction (regex, keyword matching, heuristics):
use async_trait::async_trait;use gemini_adk_rs::live::extractor::TurnExtractor;use gemini_adk_rs::live::transcript::TranscriptTurn;use gemini_adk_rs::llm::LlmError;
struct OrderNumberExtractor;
#[async_trait]impl TurnExtractor for OrderNumberExtractor { fn name(&self) -> &str { "order_info" } // State key for results fn window_size(&self) -> usize { 5 } // Look at last 5 turns
fn should_extract(&self, window: &[TranscriptTurn]) -> bool { // Skip trivial turns -- checked before async extraction window.last() .map(|t| t.user.split_whitespace().count() >= 3) .unwrap_or(false) }
async fn extract(&self, window: &[TranscriptTurn]) -> Result<serde_json::Value, LlmError> { let text: String = window.iter() .map(|t| format!("{} {}", t.user, t.model)) .collect::<Vec<_>>().join(" ");
let re = regex::Regex::new(r"order\s+#?(\d+)").unwrap(); let mut result = serde_json::Map::new(); if let Some(caps) = re.captures(&text) { result.insert("order_number".into(), serde_json::json!(caps[1].to_string())); } Ok(serde_json::Value::Object(result)) }}should_extract is checked before launching async work. Return false to
skip the LLM round-trip entirely on trivial turns.
LlmExtractor
Section titled “LlmExtractor”For extraction requiring understanding (sentiment, intent, entity
recognition), LlmExtractor sends the transcript to an OOB LLM:
use gemini_adk_rs::live::extractor::LlmExtractor;
let extractor = LlmExtractor::new( "SentimentAnalysis", llm, // Arc<dyn BaseLlm> "Analyze conversation sentiment and extract the customer's emotional state.", 3, // window size).with_schema(serde_json::json!({ "type": "object", "properties": { "sentiment": { "type": "string", "enum": ["positive", "neutral", "negative"] }, "score": { "type": "number" } }})).with_min_words(5); // Skip "uh huh", "ok", "yes" turnsSchema Definition
Section titled “Schema Definition”The fluent API’s extract_turns auto-generates the schema from a Rust struct:
use schemars::JsonSchema;use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, JsonSchema)]struct DebtorState { /// "calm", "cooperative", "frustrated", "angry" emotional_state: Option<String>, /// 0.0 (refusing) to 1.0 (eager) willingness_to_pay: Option<f32>, /// "full_pay", "partial_pay", "dispute", "refuse", "delay" negotiation_intent: Option<String>, /// Whether debtor explicitly requested cease-and-desist cease_desist_requested: Option<bool>,}
Live::builder() .extract_turns::<DebtorState>( llm, "Extract: emotional state, willingness to pay, negotiation intent, cease-and-desist.", ) .connect(config).await?;The type name (DebtorState) becomes the extractor name and State key.
extract_turns auto-enables transcription, generates the JSON schema, and
defaults to a 3-turn window. Use extract_turns_windowed for custom sizes.
Extraction Triggers
Section titled “Extraction Triggers”By default, extractors run on every TurnComplete event. For many use cases
this is wasteful — trivial utterances (“yeah”, “ok”) rarely contain extractable
data, and each extraction is an OOB LLM call. Extraction triggers control
when extractors fire:
use gemini_adk_rs::live::extractor::ExtractionTrigger;
Live::builder() // Extract every 2 turns instead of every turn (reduces LLM costs by ~50%) .extract_turns_triggered::<DebtorState>( llm, "Extract debtor emotional state and negotiation intent", 5, // transcript window size ExtractionTrigger::Interval(2), ) .connect(config).await?;| Trigger | When it fires | Use case |
|---|---|---|
EveryTurn | After every TurnComplete | Default — high-frequency extraction |
Interval(n) | Every N turns | Reduce LLM costs for slow-changing data |
AfterToolCall | After tool dispatch completes | Extract from tool results |
OnPhaseChange | When phase transitions fire | Re-extract on context shift |
The TurnExtractor trait also has a trigger() method with a default
implementation returning EveryTurn, so custom extractors get the old
behavior for free:
impl TurnExtractor for MyExtractor { fn trigger(&self) -> ExtractionTrigger { ExtractionTrigger::AfterToolCall } // ...}Transcript Window
Section titled “Transcript Window”Extractors receive a slice of TranscriptTurn values:
pub struct TranscriptTurn { pub turn_number: u32, pub user: String, // Accumulated user speech pub model: String, // Accumulated model speech pub tool_calls: Vec<ToolCallSummary>, pub timestamp: Instant,}The TranscriptBuffer is a ring buffer (default 50 turns) that evicts the
oldest turns to prevent unbounded memory growth:
let mut buf = TranscriptBuffer::new();let recent = buf.window(3); // last 3 completed turnslet formatted = buf.format_window(3); // human-readable textlet snapshot = buf.snapshot_window(5); // cheap read-only clone for callbacksAuto-Flatten
Section titled “Auto-Flatten”When an extractor returns a JSON object, the framework automatically flattens
it to individual state keys under the derived: prefix. Given this result:
{ "emotional_state": "frustrated", "willingness_to_pay": 0.3 }The framework writes:
derived:emotional_state="frustrated"derived:willingness_to_pay=0.3
The prefix is transparent — state.get("emotional_state") auto-checks
derived:emotional_state if the unprefixed key is not found:
.transition("close", S::is_true("cease_desist_requested"))// Internally checks derived:cease_desist_requestedConcurrent Extraction
Section titled “Concurrent Extraction”Multiple extractors run in parallel via futures::future::join_all:
Live::builder() .extractor(Arc::new(regex_extractor)) // instant .extract_turns::<DebtorState>(llm, "...") // 1-3 seconds .on_extracted(|name, value| async move { println!("Extractor '{name}' produced: {value}"); }) .on_extraction_error(|name, error| async move { eprintln!("Extractor '{name}' failed: {error}"); }) .connect(config).await?;Extraction to State to Watchers
Section titled “Extraction to State to Watchers”The full data flow after each turn:
- Extractors run concurrently on the control lane.
- Results auto-flatten — each JSON field becomes a
derived:key. - Computed state evaluates — derived variables that depend on extracted keys re-compute.
- Watchers fire — any watcher observing a changed key triggers.
- Phase transitions evaluate — guards check, machine transitions.
Live::builder() .extract_turns::<DebtorState>(llm, "Extract emotional state") .computed("call_risk_level", &["derived:sentiment_score"], |state| { let score: f64 = state.get("derived:sentiment_score").unwrap_or(0.5); if score < 0.3 { Some(json!("high")) } else { Some(json!("low")) } }) .watch("derived:call_risk_level") .changed_to(json!("high")) .then(|_old, _new, state| async move { state.set("alert:risk_escalation", true); }) .phase("negotiate") .instruction("Negotiate payment") .transition("close", S::is_true("cease_desist_requested")) .done() .connect(config).await?;Real Example
Section titled “Real Example”The debt collection demo combines regex and LLM extractors:
// Regex: captures dollar amounts, phone numbers, disclosure acknowledgmentlet regex_extractor = Arc::new(RegexExtractor::new("debt_fields", 10, |text, existing| { let mut extracted = HashMap::new(); if !existing.contains_key("dollar_amount") { if let Some(m) = DOLLAR_RE.find(text) { extracted.insert("dollar_amount".into(), json!(m.as_str())); } } if !existing.contains_key("disclosure_given") { if DISCLOSURE_ACK_RE.is_match(text) { extracted.insert("disclosure_given".into(), json!(true)); } } extracted}));
let handle = Live::builder() .extractor(regex_extractor) .extract_turns::<DebtorState>(llm, "Extract debtor emotional state and intent") .computed("sentiment_score", &["emotional_state"], |state| { let emotion: String = state.get("emotional_state")?; Some(json!(match emotion.as_str() { "cooperative" => 0.9, "calm" => 0.7, "frustrated" => 0.4, "angry" => 0.2, _ => 0.5, })) }) .computed("call_risk_level", &["derived:sentiment_score", "cease_desist_requested"], |state| { let sentiment: f64 = state.get("derived:sentiment_score").unwrap_or(0.5); let cease: bool = state.get("cease_desist_requested").unwrap_or(false); Some(json!(if cease { "critical" } else if sentiment < 0.3 { "high" } else { "low" })) }) .phase("disclosure") .instruction("Deliver the Mini-Miranda disclosure") .transition("verify_identity", S::is_true("disclosure_given")) .transition("close", S::is_true("cease_desist_requested")) .done() .initial_phase("disclosure") .connect(config).await?;Extracted fields flow through the full pipeline: extraction produces raw values, computed state derives higher-level signals, guards evaluate on every turn, and the phase machine transitions when conditions are met.
See also
Section titled “See also”- Live Callbacks —
on_extractedandon_extraction_errorcallbacks - State Management — how extracted values land in
derived:prefix and are accessed - cookbook 29 — live voice