gemini_adk_fluent_rs/spec/
mod.rs

1//! `SessionSpec` — a whole Live session as one serializable JSON document.
2//!
3//! Where [`Flow`] made the *governance DAG* data, `SessionSpec` makes the
4//! *application* data: session framing (instruction, greeting, modality),
5//! declarative tool bindings (mock, HTTP, MCP), schema-as-JSON extraction that
6//! fills the state guards read, data-driven phases and watchers over the same
7//! closed [`Guard`] vocabulary, reusable flow fragments, and an embedded test
8//! suite that replays scripted conversations through the real
9//! [`FlowMonitor`](gemini_adk_rs::flow::FlowMonitor) offline.
10//!
11//! The invariants:
12//! - **What serializes, runs.** [`SessionSpec::apply`] configures a
13//!   [`Live`] builder from the document; nothing in the document needs Rust.
14//! - **What can fail, fails at load time.** [`SessionSpec::validate`] runs the
15//!   flow compiler, cross-checks tool names, and diffs the state keys guards
16//!   *read* against the keys the session *writes* — the flow-level analogue of
17//!   `compile_with_tools` for the dominant silent failure in data-authored
18//!   flows (a guard waiting on a key nothing sets).
19//! - **The escape hatches stay in code.** Custom closures (guards, tools,
20//!   callbacks) are added on the returned builder after `apply`, exactly as
21//!   before; the spec never pretends to serialize them.
22
23mod codegen;
24mod simulate;
25
26pub use simulate::{
27    SimEvent, SimSnapshot, SpecTest, TestExpectation, TestReport, TestStepResult, trace_test,
28};
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35
36use gemini_adk_rs::expr::Expr;
37use gemini_adk_rs::flow::{Constraint, Flow, Guard, Pred, Step};
38use gemini_adk_rs::live::extractor::{ExtractionTrigger, FieldPromotion, LlmExtractor};
39use gemini_adk_rs::live::{ContextDelivery, RepairConfig, SteeringMode};
40use gemini_adk_rs::llm::BaseLlm;
41use gemini_adk_rs::state::State;
42use gemini_adk_rs::tool::{SimpleTool, ToolDispatcher};
43use gemini_genai_rs::prelude::{
44    AutomaticActivityDetection, Content, FunctionResponseScheduling, Sensitivity, SessionWriter,
45    Voice,
46};
47
48use crate::compose::tools::T;
49use crate::live::Live;
50
51/// Output modality for a spec-driven session.
52#[derive(
53    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
54)]
55#[serde(rename_all = "lowercase")]
56pub enum SpecModality {
57    /// Text-only session (the default — no microphone needed).
58    #[default]
59    Text,
60    /// Audio (voice) session.
61    Audio,
62}
63
64/// An HTTP binding for a declared tool: the call is executed as an HTTP
65/// request with `{args.field}` / `{state.key}` interpolation in the URL,
66/// headers, and body strings, and the JSON response is returned to the model.
67///
68/// Requires the `http-tools` feature; without it, validation reports the
69/// binding as unsupported instead of failing silently at call time.
70#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
71pub struct HttpBinding {
72    /// HTTP method (GET, POST, PUT, PATCH, DELETE). Default GET.
73    #[serde(default = "default_method")]
74    pub method: String,
75    /// Request URL, with `{args.*}`/`{state.*}` interpolation.
76    pub url: String,
77    /// Request headers, values interpolated.
78    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79    pub headers: BTreeMap<String, String>,
80    /// JSON body; every string value is interpolated. Omit for no body.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub body: Option<Value>,
83}
84
85fn default_method() -> String {
86    "GET".to_string()
87}
88
89/// A declared tool. Without an `http` binding it is a **mock**: it returns
90/// `response` (default `{"ok": true}`) and writes `set_state` — enough to
91/// model, validate, and demo a governed conversation before any real tool
92/// exists. With `http` it performs the request instead (and still applies
93/// `set_state` afterwards, so guards latch identically) — swap a mock for a
94/// binding without touching the flow.
95#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
96pub struct ToolSpec {
97    /// Tool (function) name the model calls.
98    pub name: String,
99    /// Description shown to the model.
100    #[serde(default)]
101    pub description: String,
102    /// JSON Schema for the arguments (Gemini subset). `None` = no parameters.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub parameters: Option<Value>,
105    /// Canned JSON response (mock tools). Default `{"ok": true}`.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub response: Option<Value>,
108    /// State keys written when the tool runs — how a tool latches guards.
109    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110    pub set_state: BTreeMap<String, Value>,
111    /// Store the tool's full response under this state key.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub save_response_as: Option<String>,
114    /// Execute as an HTTP request instead of returning the canned response.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub http: Option<HttpBinding>,
117    /// Run non-blocking: the model keeps speaking while the tool executes
118    /// (`behavior: NonBlocking` on the wire; Google AI only, stripped on
119    /// Vertex). Implied by `scheduling`.
120    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
121    pub background: bool,
122    /// How the async response is delivered (implies `background`).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub scheduling: Option<SchedulingSpec>,
125}
126
127/// Delivery mode for a background tool's response.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
129#[serde(rename_all = "snake_case")]
130pub enum SchedulingSpec {
131    /// Halt current output and report immediately.
132    Interrupt,
133    /// Wait until the model finishes its current output.
134    WhenIdle,
135    /// Integrate silently without notifying the user.
136    Silent,
137}
138
139impl SchedulingSpec {
140    fn to_wire(self) -> FunctionResponseScheduling {
141        match self {
142            SchedulingSpec::Interrupt => FunctionResponseScheduling::Interrupt,
143            SchedulingSpec::WhenIdle => FunctionResponseScheduling::WhenIdle,
144            SchedulingSpec::Silent => FunctionResponseScheduling::Silent,
145        }
146    }
147}
148
149/// How extracted fields are promoted into bare state keys.
150#[derive(
151    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
152)]
153#[serde(rename_all = "snake_case")]
154pub enum PromotePolicy {
155    /// Write once, never overwrite a known value (default).
156    #[default]
157    KeepKnown,
158    /// Always overwrite.
159    Overwrite,
160    /// Promote only when the extracted value is `true`.
161    TrueOnly,
162    /// Promote only non-empty values.
163    NonEmpty,
164}
165
166/// Promote one extracted field into a session state key, where flow guards
167/// (`captured`, `is_true`, …) read it.
168#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
169pub struct PromoteSpec {
170    /// Field name in the extraction result.
171    pub field: String,
172    /// Target state key. Defaults to the field name.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub to: Option<String>,
175    /// Merge policy.
176    #[serde(default)]
177    pub policy: PromotePolicy,
178}
179
180impl PromoteSpec {
181    fn target(&self) -> &str {
182        self.to.as_deref().unwrap_or(&self.field)
183    }
184
185    fn to_rule(&self) -> FieldPromotion {
186        let rule = match self.policy {
187            PromotePolicy::KeepKnown => FieldPromotion::keep_known(&self.field),
188            PromotePolicy::Overwrite => FieldPromotion::overwrite(&self.field),
189            PromotePolicy::TrueOnly => FieldPromotion::true_only(&self.field),
190            PromotePolicy::NonEmpty => FieldPromotion::non_empty(&self.field),
191        };
192        match &self.to {
193            Some(key) => rule.to(key),
194            None => rule,
195        }
196    }
197}
198
199/// When an extractor runs.
200#[derive(
201    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
202)]
203#[serde(rename_all = "snake_case")]
204pub enum TriggerSpec {
205    /// After every turn (default).
206    #[default]
207    EveryTurn,
208    /// After tool calls complete.
209    AfterToolCall,
210    /// On generation complete — before interruption truncation.
211    OnGenerationComplete,
212    /// When a phase transition occurs.
213    OnPhaseChange,
214}
215
216impl TriggerSpec {
217    fn to_trigger(self) -> ExtractionTrigger {
218        match self {
219            TriggerSpec::EveryTurn => ExtractionTrigger::EveryTurn,
220            TriggerSpec::AfterToolCall => ExtractionTrigger::AfterToolCall,
221            TriggerSpec::OnGenerationComplete => ExtractionTrigger::OnGenerationComplete,
222            TriggerSpec::OnPhaseChange => ExtractionTrigger::OnPhaseChange,
223        }
224    }
225}
226
227/// Schema-as-JSON out-of-band extraction: an OOB model fills `schema` from the
228/// transcript, the result lands in state under `name`, and `promote` rules
229/// write individual fields to bare keys — closing the loop that lets a flow
230/// advance from *speech alone* (`captured` guards latch with no tool call).
231#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
232pub struct ExtractSpec {
233    /// Extraction name — the state key the full result is stored under.
234    pub name: String,
235    /// Instruction for the extraction model.
236    pub instruction: String,
237    /// JSON Schema the extraction must satisfy.
238    pub schema: Value,
239    /// Transcript window in turns. Default 3.
240    #[serde(default = "default_window")]
241    pub window: usize,
242    /// When to run.
243    #[serde(default)]
244    pub trigger: TriggerSpec,
245    /// Field-promotion rules into bare state keys.
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub promote: Vec<PromoteSpec>,
248}
249
250fn default_window() -> usize {
251    3
252}
253
254/// A serializable side effect for phase entry, watcher, and pattern actions —
255/// the closed-effect counterpart to [`Guard`]'s closed predicates. One
256/// vocabulary, honored identically wherever effects fire.
257#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
258#[serde(rename_all = "snake_case")]
259pub enum EffectSpec {
260    /// Write these state keys.
261    Set(BTreeMap<String, Value>),
262    /// Inject a model-role context turn (steering text the model reads before
263    /// its next response — it does not answer it directly).
264    Context(String),
265    /// Inject the text as a model-role turn **and** ask the model to respond
266    /// now — the "make the model speak" effect (proactive check-in, nudge).
267    Prompt(String),
268    /// Durably remember a note (`{state.key}` templates interpolated) through
269    /// the session's memory binding. Requires the spec's `memory` section.
270    Remember(String),
271}
272
273/// A data-driven phase transition: fire `when` the guard holds over state.
274///
275/// Guards here evaluate against state alone (no flow marking), so
276/// `called_ok`/`done` atoms are rejected by validation — use a state key a
277/// tool or extractor writes instead.
278#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
279pub struct TransitionSpec {
280    /// Target phase.
281    pub to: String,
282    /// Condition over session state.
283    pub when: Guard,
284    /// Optional description for navigation steering.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub description: Option<String>,
287}
288
289/// A data-driven conversation phase.
290#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
291pub struct PhaseSpec {
292    /// Phase name.
293    pub name: String,
294    /// Instruction while this phase is active.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub instruction: Option<String>,
297    /// Tool names available in this phase.
298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
299    pub tools: Vec<String>,
300    /// Required state keys (drives conversation repair).
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub needs: Vec<String>,
303    /// Effects fired on phase entry.
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub on_enter: Vec<EffectSpec>,
306    /// Guarded transitions out of this phase.
307    #[serde(default, skip_serializing_if = "Vec::is_empty")]
308    pub transitions: Vec<TransitionSpec>,
309    /// Prompt the model on entry.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub prompt_on_enter: Option<bool>,
312    /// Terminal phase.
313    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
314    pub terminal: bool,
315}
316
317/// A data-driven state watcher condition.
318#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
319#[serde(rename_all = "snake_case")]
320pub enum WatchCondition {
321    /// Any change.
322    Changed,
323    /// Changed to exactly this value.
324    ChangedTo(Value),
325    /// Numeric value crossed above the threshold.
326    CrossedAbove(f64),
327    /// Numeric value crossed below the threshold.
328    CrossedBelow(f64),
329    /// Became `true`.
330    BecameTrue,
331    /// Became `false`.
332    BecameFalse,
333}
334
335/// A data-driven state watcher: when `key` satisfies the condition, run the
336/// effects. Watchers receive the live session writer, so the full
337/// [`EffectSpec`] vocabulary applies — a watcher can set state, inject
338/// context, prompt the model, or remember durably.
339#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
340pub struct WatchSpec {
341    /// Watched state key.
342    pub key: String,
343    /// Trigger condition.
344    pub condition: WatchCondition,
345    /// State keys written when it fires (sugar for an [`EffectSpec::Set`]).
346    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
347    pub set: BTreeMap<String, Value>,
348    /// Effects fired in order after `set`.
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub effects: Vec<EffectSpec>,
351}
352
353/// A data-driven temporal pattern: fire effects when a state condition holds
354/// continuously — for a duration (`sustained_secs`) or a number of
355/// consecutive turns (`turns`). Exactly one of the two must be set.
356///
357/// This is the "the caller has sounded confused for 30 seconds" /
358/// "we've been stuck on this for 3 turns" reactor, as data.
359#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
360pub struct PatternSpec {
361    /// Pattern name (diagnostic identity).
362    pub name: String,
363    /// Condition over session state (no marking atoms).
364    pub when: Guard,
365    /// Fire after the condition holds this many seconds.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub sustained_secs: Option<u64>,
368    /// Fire after the condition holds for this many consecutive turns.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub turns: Option<u32>,
371    /// Effects when the pattern fires (`set` state and/or inject `context`).
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub effects: Vec<EffectSpec>,
374}
375
376/// A computed (derived) state variable authored as data: `key` is written to
377/// `derived:{key}` whenever the [`Expr`] evaluates to a value. Dependencies
378/// are inferred from the expression's [`Expr::keys_read`], so the runtime's
379/// dependency-ordered [`ComputedRegistry`](gemini_adk_rs::live::ComputedRegistry)
380/// invariants hold with nothing extra to declare. Guards read the result by
381/// its bare key (the `derived:` fallback).
382#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
383pub struct ComputedSpec {
384    /// Result key (written as `derived:{key}`).
385    pub key: String,
386    /// The expression computing the value.
387    pub from: Expr,
388    /// Human-readable note.
389    #[serde(default, skip_serializing_if = "String::is_empty")]
390    pub description: String,
391}
392
393/// Declared JSON type of a state key.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
395#[serde(rename_all = "snake_case")]
396pub enum StateType {
397    /// JSON boolean.
398    Boolean,
399    /// JSON number.
400    Number,
401    /// JSON string.
402    String,
403    /// JSON object.
404    Object,
405    /// JSON array.
406    Array,
407}
408
409impl StateType {
410    fn matches(self, value: &Value) -> bool {
411        matches!(
412            (self, value),
413            (StateType::Boolean, Value::Bool(_))
414                | (StateType::Number, Value::Number(_))
415                | (StateType::String, Value::String(_))
416                | (StateType::Object, Value::Object(_))
417                | (StateType::Array, Value::Array(_))
418        )
419    }
420}
421
422/// One declared state key: its type, meaning, and optional starting value.
423///
424/// The `state` section is the session's data dictionary. Declaring it is
425/// optional, but once present it powers editor autocomplete, key-existence
426/// warnings for every guard/effect/tool reference, typed key constants in
427/// generated code, and `default` seeding at connect.
428#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
429pub struct StateFieldSpec {
430    /// Declared JSON type.
431    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
432    pub kind: Option<StateType>,
433    /// What this key means.
434    #[serde(default, skip_serializing_if = "String::is_empty")]
435    pub description: String,
436    /// Initial value seeded at connect when the key is unset.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub default: Option<Value>,
439}
440
441/// Project one remembered fact into a governed state slot: when memory holds
442/// a value for `predicate`, it is written to the `to` state key — where
443/// `needs`, `captured`, and every other guard reads it exactly as if the
444/// caller had just said it.
445#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
446pub struct MemorySlotSpec {
447    /// Memory predicate (e.g. `dietary_identity`).
448    pub predicate: String,
449    /// Target state key (must not be `derived:` — that prefix is read-only).
450    pub to: String,
451}
452
453/// The session's durable-memory declaration. Installing it wires the memory
454/// subsystem in through a [`MemoryBinding`] supplied in [`SpecResources`]:
455/// the `recall_context` / `manage_memory` tools (ambient, so step `allow`
456/// lists don't switch recall off), turn ingestion, end-of-session
457/// reconciliation, and the slot projections below. [`EffectSpec::Remember`]
458/// writes through the same binding.
459#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
460pub struct MemorySpec {
461    /// Remembered facts projected into state slots.
462    #[serde(default, skip_serializing_if = "Vec::is_empty")]
463    pub slots: Vec<MemorySlotSpec>,
464}
465
466/// Speech-detection sensitivity for [`VadSpec`].
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
468#[serde(rename_all = "snake_case")]
469pub enum SensitivitySpec {
470    /// Fewer false positives; may miss soft speech.
471    Low,
472    /// Balanced.
473    Medium,
474    /// Catches everything; more false positives.
475    High,
476}
477
478impl SensitivitySpec {
479    fn to_wire(self) -> Sensitivity {
480        match self {
481            SensitivitySpec::Low => Sensitivity::SensitivityLow,
482            SensitivitySpec::Medium => Sensitivity::SensitivityMedium,
483            SensitivitySpec::High => Sensitivity::SensitivityHigh,
484        }
485    }
486}
487
488/// Voice-activity-detection tuning — the knobs that decide how eagerly the
489/// session hears speech start and stop.
490#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
491pub struct VadSpec {
492    /// Sensitivity for detecting speech onset.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub start_sensitivity: Option<SensitivitySpec>,
495    /// Sensitivity for detecting end of speech.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub end_sensitivity: Option<SensitivitySpec>,
498    /// Milliseconds of audio kept before speech onset.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub prefix_padding_ms: Option<u32>,
501    /// Milliseconds of silence before end-of-speech triggers.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub silence_duration_ms: Option<u32>,
504}
505
506/// Input-audio hardening: the measured mic chain (denoiser, noise gate),
507/// client input-VAD tuning, and interruption authority. Lowers to
508/// `Live::mic_denoise` / `mic_noise_gate` / `input_vad` /
509/// `client_interruption_authority`; see the hardening chapter for the
510/// benchmark behind each default.
511#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
512pub struct AudioSpec {
513    /// Run the RNNoise speech enhancer over incoming user audio (requires
514    /// the `denoise` feature; skipped with a validation warning otherwise).
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub denoise: Option<bool>,
517    /// Noise gate after the denoiser — silences frames below a level
518    /// threshold (near-talker preference; rejects denoiser residue).
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub noise_gate: Option<NoiseGateSpec>,
521    /// Client input-VAD tuning (the detector driving speech edges in
522    /// `send_audio`).
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub client_vad: Option<ClientVadSpec>,
525    /// Who decides when user speech interrupts the model.
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub authority: Option<AuthoritySpec>,
528    /// Milliseconds to hold the turn-end marker during mid-turn pauses, suppressing
529    /// false end-of-turn commits. Measured on TurnBench dev: 800 ms = 0.1-fp
530    /// qualifying point (recall 0.798), 1600 ms = recall 0.508 (frontier).
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub eot_hold_ms: Option<u32>,
533    /// Milliseconds to suppress barge-in detection on backchannels and false
534    /// interruptions. Measured on TurnBench dev: 1400 ms suppresses backchannel
535    /// false positives (0.702 → 0.062), 2000 ms = maximum interruption match window.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub min_interruption_ms: Option<u32>,
538}
539
540/// Noise-gate stage parameters.
541#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
542pub struct NoiseGateSpec {
543    /// RMS threshold in sample units (i16 full scale 32767; measured sweet
544    /// spot 400–700 behind the denoiser).
545    #[serde(default = "default_gate_threshold")]
546    pub threshold_rms: f64,
547    /// Quiet frames the gate stays open after the last loud one.
548    #[serde(default = "default_gate_hold")]
549    pub hold_frames: u32,
550}
551
552fn default_gate_threshold() -> f64 {
553    700.0
554}
555fn default_gate_hold() -> u32 {
556    3
557}
558
559/// Client input-VAD tuning: start from a preset, override individual knobs.
560#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
561pub struct ClientVadSpec {
562    /// Base preset the overrides apply to.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub preset: Option<ClientVadPreset>,
565    /// Energy above the noise floor (dB) to open.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub start_threshold_db: Option<f64>,
568    /// Energy above the noise floor (dB) to close.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub stop_threshold_db: Option<f64>,
571    /// Consecutive frames (30 ms each) to confirm onset.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub min_speech_frames: Option<u32>,
574    /// Frames of hangover before speech-end.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub hangover_frames: Option<u32>,
577}
578
579/// Named client-VAD starting points.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
581#[serde(rename_all = "snake_case")]
582pub enum ClientVadPreset {
583    /// The library defaults (quiet environments).
584    Default,
585    /// The closed-loop-tuned noisy-environment profile — use behind
586    /// `denoise: true` (see `VadConfig::noisy_street`).
587    NoisyStreet,
588}
589
590impl ClientVadSpec {
591    /// Lower to a wire `VadConfig`: preset base plus overrides.
592    pub fn to_config(&self) -> gemini_genai_rs::vad::VadConfig {
593        let mut config = match self.preset {
594            Some(ClientVadPreset::NoisyStreet) => gemini_genai_rs::vad::VadConfig::noisy_street(),
595            _ => gemini_genai_rs::vad::VadConfig::default(),
596        };
597        if let Some(v) = self.start_threshold_db {
598            config.start_threshold_db = v;
599        }
600        if let Some(v) = self.stop_threshold_db {
601            config.stop_threshold_db = v;
602        }
603        if let Some(v) = self.min_speech_frames {
604            config.min_speech_frames = v;
605        }
606        if let Some(v) = self.hangover_frames {
607            config.hangover_frames = v;
608        }
609        config
610    }
611}
612
613/// Interruption authority (measured trade: client is ~2× faster to barge
614/// in; server posted zero false interruptions in every benchmark run).
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
616#[serde(rename_all = "snake_case")]
617pub enum AuthoritySpec {
618    /// The Live API's automatic activity detection decides (default).
619    Server,
620    /// This client's input VAD decides: automatic detection is disabled and
621    /// speech edges send activityStart/activityEnd.
622    Client,
623}
624
625/// Input/output transcription toggles.
626#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
627pub struct TranscriptionSpec {
628    /// Transcribe the user's speech.
629    #[serde(default = "default_true")]
630    pub input: bool,
631    /// Transcribe the model's audio output.
632    #[serde(default = "default_true")]
633    pub output: bool,
634}
635
636fn default_true() -> bool {
637    true
638}
639
640/// How phase instructions are steered to the model.
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
642#[serde(rename_all = "snake_case")]
643pub enum SteeringSpec {
644    /// Replace the system instruction on phase transitions (default).
645    InstructionUpdate,
646    /// Set the instruction once; deliver phase steering as context turns.
647    ContextInjection,
648    /// Both.
649    Hybrid,
650}
651
652/// When batched context turns hit the wire.
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
654#[serde(rename_all = "snake_case")]
655pub enum ContextDeliverySpec {
656    /// Send immediately during TurnComplete (default).
657    Immediate,
658    /// Queue and flush with the next user send (voice-glitch avoidance).
659    Deferred,
660}
661
662/// Conversation-repair thresholds (unmet phase `needs`).
663#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
664pub struct RepairSpec {
665    /// Turns without progress before the first nudge.
666    pub nudge_after: u32,
667    /// Turns without progress before escalation.
668    pub escalate_after: u32,
669}
670
671/// Session persistence backend.
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
673#[serde(rename_all = "snake_case")]
674pub enum PersistenceSpec {
675    /// Filesystem snapshots under the given directory.
676    Fs {
677        /// Snapshot directory (created if missing).
678        dir: String,
679    },
680    /// In-memory (tests, ephemeral sessions).
681    Memory,
682}
683
684/// Control-plane and voice tuning — every session capability that is
685/// configuration rather than conversation, in one section. Everything here
686/// lowers to a `Live` builder setter; omitted fields keep the builder's
687/// defaults.
688#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
689pub struct RuntimeSpec {
690    /// Sampling temperature.
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub temperature: Option<f32>,
693    /// Thinking budget in tokens (Google AI only; stripped on Vertex).
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub thinking_budget: Option<u32>,
696    /// Receive thought summaries (requires `thinking_budget`).
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub include_thoughts: Option<bool>,
699    /// Input/output transcription.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub transcription: Option<TranscriptionSpec>,
702    /// Let the model choose to stay silent (proactive audio).
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub proactive_audio: Option<bool>,
705    /// Voice-activity-detection tuning.
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub vad: Option<VadSpec>,
708    /// Input-audio hardening: mic chain, client VAD, interruption authority.
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    pub audio: Option<AudioSpec>,
711    /// Fire a soft turn when the model stays silent this long after VAD end.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub soft_turn_timeout_ms: Option<u64>,
714    /// How phase instructions reach the model.
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub steering: Option<SteeringSpec>,
717    /// When context turns hit the wire.
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub context_delivery: Option<ContextDeliverySpec>,
720    /// Conversation-repair thresholds.
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub repair: Option<RepairSpec>,
723    /// Session persistence backend.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub persistence: Option<PersistenceSpec>,
726    /// Stable session id (resume across restarts; requires `persistence`).
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub session_id: Option<String>,
729    /// Drop audio chunks instead of applying backpressure when consumers lag.
730    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
731    pub lossy_audio: bool,
732    /// Drop transcript deltas instead of applying backpressure.
733    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
734    pub lossy_transcript: bool,
735}
736
737/// Splice a named flow fragment into the session's flow under a namespace.
738///
739/// Every step id inside the fragment becomes `{namespace}/{id}`; internal
740/// `after` edges, `done(step)` guard atoms, and `before`/`require` constraints
741/// are rewritten to match. Fragment root steps (no `after`) gain this entry's
742/// `after` dependencies, attaching the fragment into the outer DAG. Steps
743/// outside the fragment reference its steps as `{namespace}/{id}`.
744#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
745pub struct UseFragment {
746    /// Fragment name (a key in [`SessionSpec::fragments`]).
747    pub fragment: String,
748    /// Namespace prefix for the spliced step ids.
749    pub namespace: String,
750    /// Outer steps the fragment's roots depend on.
751    #[serde(default, skip_serializing_if = "Vec::is_empty")]
752    pub after: Vec<String>,
753}
754
755/// A complete Live session as one JSON document. See the module docs.
756#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
757pub struct SessionSpec {
758    /// App name (display and registry identity).
759    #[serde(default)]
760    pub name: String,
761    /// Spec version tag (freeform, e.g. "1" or "2025-08-24").
762    #[serde(default, skip_serializing_if = "String::is_empty")]
763    pub version: String,
764    /// Human-readable description.
765    #[serde(default)]
766    pub description: String,
767    /// Base system instruction.
768    #[serde(default)]
769    pub instruction: String,
770    /// Optional greeting prompt — the model speaks first on connect.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub greeting: Option<String>,
773    /// Output modality. Defaults to text.
774    #[serde(default)]
775    pub modality: SpecModality,
776    /// Voice name for audio sessions (e.g. "Puck").
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub voice: Option<String>,
779    /// Declared tools (mock or HTTP-bound).
780    #[serde(default, skip_serializing_if = "Vec::is_empty")]
781    pub tools: Vec<ToolSpec>,
782    /// MCP toolset connection strings — the whole MCP ecosystem as this app's
783    /// tool library. Resolved at connect time.
784    #[serde(default, skip_serializing_if = "Vec::is_empty")]
785    pub mcp: Vec<String>,
786    /// Out-of-band extraction pipelines.
787    #[serde(default, skip_serializing_if = "Vec::is_empty")]
788    pub extract: Vec<ExtractSpec>,
789    /// Declared state keys — the session's data dictionary.
790    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
791    pub state: BTreeMap<String, StateFieldSpec>,
792    /// Computed (derived) state variables.
793    #[serde(default, skip_serializing_if = "Vec::is_empty")]
794    pub computed: Vec<ComputedSpec>,
795    /// Durable memory: slots projected into state, `remember` effects, and
796    /// the ambient recall/manage tools. Requires a [`MemoryBinding`] in
797    /// [`SpecResources`] at apply time.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub memory: Option<MemorySpec>,
800    /// Control-plane and voice tuning.
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub runtime: Option<RuntimeSpec>,
803    /// Conversation phases.
804    #[serde(default, skip_serializing_if = "Vec::is_empty")]
805    pub phases: Vec<PhaseSpec>,
806    /// Initial phase name (required when `phases` is non-empty).
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub initial_phase: Option<String>,
809    /// State watchers.
810    #[serde(default, skip_serializing_if = "Vec::is_empty")]
811    pub watch: Vec<WatchSpec>,
812    /// Temporal patterns (sustained / consecutive-turn conditions).
813    #[serde(default, skip_serializing_if = "Vec::is_empty")]
814    pub patterns: Vec<PatternSpec>,
815    /// Reusable flow fragments, spliced via `use_fragments`.
816    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
817    pub fragments: BTreeMap<String, Flow>,
818    /// Fragment splice directives.
819    #[serde(default, skip_serializing_if = "Vec::is_empty")]
820    pub use_fragments: Vec<UseFragment>,
821    /// The governed flow DAG (optional — a spec may be phases-only).
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub flow: Option<Flow>,
824    /// Embedded conformance tests, replayed offline by
825    /// [`SessionSpec::run_tests`].
826    #[serde(default, skip_serializing_if = "Vec::is_empty")]
827    pub tests: Vec<SpecTest>,
828}
829
830/// Structured result of validating a [`SessionSpec`].
831#[derive(Debug, Clone, Serialize)]
832pub struct SpecValidation {
833    /// Whether the spec compiled cleanly.
834    pub valid: bool,
835    /// Errors (empty when valid).
836    pub errors: Vec<String>,
837    /// Non-fatal advisories.
838    pub warnings: Vec<String>,
839    /// Mermaid rendering of the effective (fragment-spliced) flow.
840    pub mermaid: String,
841    /// Every tool name the flow references.
842    pub tools: Vec<String>,
843    /// Steps in the effective flow.
844    pub steps: usize,
845}
846
847/// The seam through which a memory engine plugs into a spec-driven session.
848///
849/// The spec's `memory` section is pure data; the engine that honors it lives
850/// above this crate (`gemini-memory-rs` implements this trait over its
851/// `MemorySession`). `apply` calls [`install`](Self::install) once to wire
852/// tools/ingestion/slots, and routes every [`EffectSpec::Remember`] through
853/// [`remember`](Self::remember).
854pub trait MemoryBinding: Send + Sync {
855    /// Wire the memory subsystem onto the builder per the spec's declaration.
856    fn install(&self, live: Live, memory: &MemorySpec) -> Live;
857    /// Durably remember a note (fire-and-forget; implementations may commit
858    /// asynchronously).
859    fn remember(&self, note: String);
860}
861
862/// Tool names a [`MemoryBinding`] installs (ambient on the flow).
863pub const MEMORY_TOOL_NAMES: [&str; 2] = ["recall_context", "manage_memory"];
864
865/// External resources a spec cannot carry: model handles and capability
866/// bindings.
867#[derive(Default)]
868pub struct SpecResources {
869    /// The OOB model backing `extract` entries. Required when any are present.
870    pub extraction_llm: Option<Arc<dyn BaseLlm>>,
871    /// The memory engine honoring the spec's `memory` section. Required when
872    /// that section is present.
873    pub memory: Option<Arc<dyn MemoryBinding>>,
874}
875
876impl SessionSpec {
877    /// Parse a spec from a JSON value. Accepts a full document or a *bare
878    /// flow* (`{"steps": [...]}`), which is wrapped in a default spec.
879    pub fn from_value(value: Value) -> Result<Self, String> {
880        let is_bare_flow = value.get("flow").is_none() && value.get("steps").is_some();
881        if is_bare_flow {
882            let flow: Flow =
883                serde_json::from_value(value).map_err(|e| format!("invalid flow JSON: {e}"))?;
884            return Ok(Self {
885                flow: Some(flow),
886                ..Self::default()
887            });
888        }
889        serde_json::from_value(value).map_err(|e| format!("invalid session spec JSON: {e}"))
890    }
891
892    /// The JSON Schema of the spec document itself — for editor autocomplete
893    /// and for validating machine-authored specs at generation time.
894    pub fn json_schema() -> Value {
895        serde_json::to_value(schemars::schema_for!(SessionSpec)).unwrap_or_else(|_| json!({}))
896    }
897
898    /// Declared tool names (mock/HTTP; MCP names resolve at connect).
899    pub fn tool_names(&self) -> Vec<String> {
900        self.tools.iter().map(|t| t.name.clone()).collect()
901    }
902
903    /// The flow with every `use_fragments` directive spliced in.
904    pub fn effective_flow(&self) -> Result<Flow, Vec<String>> {
905        let mut flow = self.flow.clone().unwrap_or_default();
906        let mut errors = Vec::new();
907        for use_frag in &self.use_fragments {
908            match self.fragments.get(&use_frag.fragment) {
909                Some(fragment) => {
910                    splice_fragment(&mut flow, fragment, use_frag, &mut errors);
911                }
912                None => errors.push(format!(
913                    "use_fragments references unknown fragment '{}'",
914                    use_frag.fragment
915                )),
916            }
917        }
918        if errors.is_empty() {
919            Ok(flow)
920        } else {
921            Err(errors)
922        }
923    }
924
925    /// Every state key the session declares a *writer* for: tool `set_state`
926    /// and `save_response_as`, extraction names and promotion targets, phase
927    /// and watcher `set` effects.
928    pub fn state_keys_written(&self) -> std::collections::BTreeSet<String> {
929        let mut keys = std::collections::BTreeSet::new();
930        for t in &self.tools {
931            keys.extend(t.set_state.keys().cloned());
932            keys.extend(t.save_response_as.iter().cloned());
933        }
934        for e in &self.extract {
935            keys.insert(e.name.clone());
936            for p in &e.promote {
937                keys.insert(p.target().to_string());
938            }
939        }
940        for p in &self.phases {
941            for eff in &p.on_enter {
942                if let EffectSpec::Set(map) = eff {
943                    keys.extend(map.keys().cloned());
944                }
945            }
946        }
947        for w in &self.watch {
948            keys.extend(w.set.keys().cloned());
949            for eff in &w.effects {
950                if let EffectSpec::Set(map) = eff {
951                    keys.extend(map.keys().cloned());
952                }
953            }
954        }
955        for p in &self.patterns {
956            for eff in &p.effects {
957                if let EffectSpec::Set(map) = eff {
958                    keys.extend(map.keys().cloned());
959                }
960            }
961        }
962        for c in &self.computed {
963            // A computed var writes `derived:{key}`, and guards read it by
964            // either name thanks to the `derived:` fallback.
965            keys.insert(c.key.clone());
966            keys.insert(format!("derived:{}", c.key));
967        }
968        if let Some(memory) = &self.memory {
969            for slot in &memory.slots {
970                keys.extend([slot.to.clone()]);
971            }
972        }
973        for (key, field) in &self.state {
974            if field.default.is_some() {
975                keys.insert(key.clone());
976            }
977        }
978        keys
979    }
980
981    /// Every [`EffectSpec`] anywhere in the document, with a location label.
982    fn all_effects(&self) -> Vec<(String, &EffectSpec)> {
983        let mut out = Vec::new();
984        for p in &self.phases {
985            for eff in &p.on_enter {
986                out.push((format!("phase '{}'", p.name), eff));
987            }
988        }
989        for w in &self.watch {
990            for eff in &w.effects {
991                out.push((format!("watch '{}'", w.key), eff));
992            }
993        }
994        for p in &self.patterns {
995            for eff in &p.effects {
996                out.push((format!("pattern '{}'", p.name), eff));
997            }
998        }
999        out
1000    }
1001
1002    /// Re-evaluate every computed variable in dependency order, writing
1003    /// results to `derived:{key}`. Used by the offline simulator so guards
1004    /// over computed keys latch exactly as they do live. Validation forbids
1005    /// cycles, so iterating to a fixed point terminates.
1006    pub(crate) fn recompute_computed(&self, state: &State) {
1007        for _ in 0..self.computed.len() {
1008            let mut changed = false;
1009            for c in &self.computed {
1010                if let Some(value) = c.from.eval(state) {
1011                    let derived = format!("derived:{}", c.key);
1012                    if state.get_raw(&derived).as_ref() != Some(&value) {
1013                        let _ = state.set(&derived, value);
1014                        changed = true;
1015                    }
1016                }
1017            }
1018            if !changed {
1019                break;
1020            }
1021        }
1022    }
1023
1024    /// Seed declared `state` defaults for keys not yet set.
1025    pub(crate) fn seed_state_defaults(&self, state: &State) {
1026        for (key, field) in &self.state {
1027            if let Some(default) = &field.default
1028                && state.get_raw(key).is_none()
1029            {
1030                let _ = state.set(key, default.clone());
1031            }
1032        }
1033    }
1034
1035    /// Validate the whole document: flow compilation (with the declared tool
1036    /// registry), fragment splicing, phase-guard restrictions, HTTP-binding
1037    /// support, and the read/write state-key diff.
1038    pub fn validate(&self) -> SpecValidation {
1039        let mut errors = Vec::new();
1040        let mut warnings = Vec::new();
1041
1042        // Fragment namespace validation: must be non-empty to create valid step ids.
1043        for use_frag in &self.use_fragments {
1044            if use_frag.namespace.is_empty() {
1045                errors.push(
1046                    "use_fragments directive has empty namespace — step ids would be malformed"
1047                        .into(),
1048                );
1049            }
1050        }
1051
1052        let flow = match self.effective_flow() {
1053            Ok(flow) => flow,
1054            Err(errs) => {
1055                errors.extend(errs);
1056                self.flow.clone().unwrap_or_default()
1057            }
1058        };
1059        let mermaid = flow.to_mermaid();
1060        let steps = flow.steps.len();
1061        let has_flow = !flow.steps.is_empty();
1062
1063        if !has_flow && self.phases.is_empty() {
1064            errors.push("spec has neither a flow nor phases — nothing to run".into());
1065        }
1066        if !self.phases.is_empty() && self.initial_phase.is_none() {
1067            errors.push("phases are declared but initial_phase is not set".into());
1068        }
1069        if let Some(initial) = &self.initial_phase
1070            && !self.phases.iter().any(|p| &p.name == initial)
1071        {
1072            errors.push(format!("initial_phase '{initial}' is not a declared phase"));
1073        }
1074        // Check for duplicate phase names
1075        {
1076            let phase_names: std::collections::BTreeSet<&str> =
1077                self.phases.iter().map(|p| p.name.as_str()).collect();
1078            if phase_names.len() != self.phases.len() {
1079                let mut seen = std::collections::BTreeSet::new();
1080                for p in &self.phases {
1081                    if !seen.insert(p.name.as_str()) {
1082                        errors.push(format!("phase '{}' is declared more than once", p.name));
1083                    }
1084                }
1085            }
1086        }
1087        for pattern in &self.patterns {
1088            match (pattern.sustained_secs, pattern.turns) {
1089                (Some(_), Some(_)) | (None, None) => errors.push(format!(
1090                    "pattern '{}' must set exactly one of sustained_secs or turns",
1091                    pattern.name
1092                )),
1093                _ => {}
1094            }
1095            if guard_uses_marking(&pattern.when) {
1096                errors.push(format!(
1097                    "pattern '{}' uses a called_ok/done atom — pattern guards see state only",
1098                    pattern.name
1099                ));
1100            }
1101        }
1102        for p in &self.phases {
1103            for t in &p.transitions {
1104                if guard_uses_marking(&t.when) {
1105                    errors.push(format!(
1106                        "phase '{}' transition to '{}' uses a called_ok/done atom — phase guards \
1107                         see state only (no flow marking); latch a state key instead",
1108                        p.name, t.to
1109                    ));
1110                }
1111            }
1112        }
1113        if cfg!(not(feature = "http-tools")) {
1114            for t in &self.tools {
1115                if t.http.is_some() {
1116                    errors.push(format!(
1117                        "tool '{}' has an http binding but the `http-tools` feature is not \
1118                         enabled",
1119                        t.name
1120                    ));
1121                }
1122            }
1123        }
1124
1125        // Computed variables: unique keys, no dependency cycles (Kahn over the
1126        // computed subset; a bare read and a `derived:` read are the same
1127        // dependency).
1128        {
1129            let computed_keys: std::collections::BTreeSet<&str> =
1130                self.computed.iter().map(|c| c.key.as_str()).collect();
1131            if computed_keys.len() != self.computed.len() {
1132                errors.push("computed variables declare a duplicate key".into());
1133            }
1134            let normalize = |k: &str| k.strip_prefix("derived:").unwrap_or(k).to_string();
1135            let mut in_degree: BTreeMap<&str, usize> =
1136                computed_keys.iter().map(|k| (*k, 0)).collect();
1137            let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1138            for c in &self.computed {
1139                for dep in c.from.keys_read() {
1140                    let dep = normalize(&dep);
1141                    if dep != c.key && computed_keys.contains(dep.as_str()) {
1142                        let dep_key = *computed_keys.get(dep.as_str()).unwrap();
1143                        dependents.entry(dep_key).or_default().push(c.key.as_str());
1144                        *in_degree.entry(c.key.as_str()).or_default() += 1;
1145                    }
1146                    if dep == c.key {
1147                        errors.push(format!("computed '{}' reads its own key", c.key));
1148                    }
1149                }
1150            }
1151            let mut queue: Vec<&str> = in_degree
1152                .iter()
1153                .filter(|(_, d)| **d == 0)
1154                .map(|(k, _)| *k)
1155                .collect();
1156            let mut visited = 0usize;
1157            while let Some(key) = queue.pop() {
1158                visited += 1;
1159                for dependent in dependents.get(key).cloned().unwrap_or_default() {
1160                    let d = in_degree.get_mut(dependent).unwrap();
1161                    *d -= 1;
1162                    if *d == 0 {
1163                        queue.push(dependent);
1164                    }
1165                }
1166            }
1167            if visited != computed_keys.len() {
1168                let cycle: Vec<&str> = in_degree
1169                    .iter()
1170                    .filter(|(_, d)| **d > 0)
1171                    .map(|(k, _)| *k)
1172                    .collect();
1173                errors.push(format!(
1174                    "computed variables form a dependency cycle: {}",
1175                    cycle.join(", ")
1176                ));
1177            }
1178        }
1179
1180        // Effects: `remember` needs the memory section; memory slots must not
1181        // target the read-only `derived:` scope.
1182        for (location, effect) in self.all_effects() {
1183            if matches!(effect, EffectSpec::Remember(_)) && self.memory.is_none() {
1184                errors.push(format!(
1185                    "{location} uses a `remember` effect but the spec has no `memory` section"
1186                ));
1187            }
1188        }
1189        if let Some(memory) = &self.memory {
1190            for slot in &memory.slots {
1191                if slot.to.starts_with("derived:") {
1192                    errors.push(format!(
1193                        "memory slot '{}' targets read-only key '{}' — the `derived:` scope \
1194                         belongs to computed variables",
1195                        slot.predicate, slot.to
1196                    ));
1197                }
1198            }
1199        }
1200
1201        // Declared state dictionary: defaults must match their declared type;
1202        // once a dictionary exists, undeclared keys are worth flagging.
1203        for (key, field) in &self.state {
1204            if let (Some(kind), Some(default)) = (field.kind, &field.default)
1205                && !kind.matches(default)
1206            {
1207                warnings.push(format!(
1208                    "state key '{key}' declares type {kind:?} but its default is {default}"
1209                ));
1210            }
1211        }
1212        if !self.state.is_empty() {
1213            let declared: std::collections::BTreeSet<&str> =
1214                self.state.keys().map(String::as_str).collect();
1215            let mut undeclared = std::collections::BTreeSet::new();
1216            for key in self.state_keys_written() {
1217                let bare = key.strip_prefix("derived:").unwrap_or(&key);
1218                if !declared.contains(bare) && !self.computed.iter().any(|c| c.key == bare) {
1219                    undeclared.insert(key.clone());
1220                }
1221            }
1222            for key in &undeclared {
1223                warnings.push(format!(
1224                    "state key '{key}' is written but not declared in the `state` section"
1225                ));
1226            }
1227        }
1228
1229        // Computed inputs: like guard reads, a dependency nothing writes can
1230        // never produce a value.
1231        {
1232            let written = self.state_keys_written();
1233            for c in &self.computed {
1234                for dep in c.from.keys_read() {
1235                    let bare = dep.strip_prefix("derived:").unwrap_or(&dep);
1236                    if !written.contains(&dep)
1237                        && !written.contains(bare)
1238                        && !dep.ends_with(":result")
1239                    {
1240                        warnings.push(format!(
1241                            "computed '{}' reads state key '{dep}' but nothing writes it",
1242                            c.key
1243                        ));
1244                    }
1245                }
1246            }
1247        }
1248
1249        // Runtime coherence.
1250        if let Some(runtime) = &self.runtime {
1251            if runtime.include_thoughts == Some(true) && runtime.thinking_budget.is_none() {
1252                warnings.push(
1253                    "runtime.include_thoughts is set without runtime.thinking_budget — no \
1254                     thoughts will arrive"
1255                        .into(),
1256                );
1257            }
1258            if let Some(audio) = &runtime.audio {
1259                if audio.denoise == Some(true) && !cfg!(feature = "denoise") {
1260                    warnings.push(
1261                        "runtime.audio.denoise requires building with the `denoise` feature — \
1262                         the stage will be skipped"
1263                            .into(),
1264                    );
1265                }
1266                if audio.authority == Some(AuthoritySpec::Client) && audio.denoise != Some(true) {
1267                    warnings.push(
1268                        "runtime.audio.authority=client without denoise — in noise the raw \
1269                         energy VAD latches open and will drive interruptions falsely \
1270                         (measured); enable denoise or expect spurious barge-ins"
1271                            .into(),
1272                    );
1273                }
1274                if audio.noise_gate.is_some() && audio.denoise != Some(true) {
1275                    warnings.push(
1276                        "runtime.audio.noise_gate without denoise — the gate calibrates on \
1277                         noisy levels; chain it behind denoise so it gates clean audio"
1278                            .into(),
1279                    );
1280                }
1281                if audio.authority == Some(AuthoritySpec::Client) && runtime.vad.is_some() {
1282                    warnings.push(
1283                        "runtime.audio.authority=client disables the server's automatic \
1284                         activity detection — runtime.vad sensitivities will have no effect"
1285                            .into(),
1286                    );
1287                }
1288                if let Some(eot_ms) = audio.eot_hold_ms
1289                    && eot_ms > 1600
1290                {
1291                    warnings.push(
1292                        "runtime.audio.eot_hold_ms exceeds the measured frontier (1600 ms): \
1293                             recall fell to 0.508 at 1600ms on TurnBench dev — values beyond this \
1294                             may cause missed turn-end detection"
1295                            .into(),
1296                    );
1297                }
1298                if let Some(min_int_ms) = audio.min_interruption_ms
1299                    && min_int_ms > 2000
1300                {
1301                    warnings.push(
1302                        "runtime.audio.min_interruption_ms exceeds the interruption match \
1303                             window (2000 ms) — commits may land too late to count"
1304                            .into(),
1305                    );
1306                }
1307            }
1308            if runtime.session_id.is_some() && runtime.persistence.is_none() {
1309                warnings.push(
1310                    "runtime.session_id is set without runtime.persistence — nothing will be \
1311                     snapshotted"
1312                        .into(),
1313                );
1314            }
1315        }
1316
1317        // Flow compilation, with declared tools as the registry. MCP tool
1318        // names are unknown until connect, so their presence downgrades the
1319        // unknown-tool check to a warning-free plain compile.
1320        let (valid_flow, referenced) = if has_flow {
1321            let compile_result = if self.tools.is_empty() || !self.mcp.is_empty() {
1322                if !self.mcp.is_empty() && !self.tools.is_empty() {
1323                    warnings.push(
1324                        "MCP toolsets resolve at connect time, so tool-name checking against \
1325                         the flow is skipped"
1326                            .into(),
1327                    );
1328                }
1329                flow.clone().compile()
1330            } else {
1331                let mut names = self.tool_names();
1332                if self.memory.is_some() {
1333                    // The memory binding installs its tools at connect; the
1334                    // flow may reference or gate them.
1335                    names.extend(
1336                        MEMORY_TOOL_NAMES
1337                            .iter()
1338                            .map(std::string::ToString::to_string),
1339                    );
1340                }
1341                let refs: Vec<&str> = names.iter().map(String::as_str).collect();
1342                flow.clone().compile_with_tools(&refs)
1343            };
1344            match compile_result {
1345                Ok(compiled) => (
1346                    true,
1347                    compiled
1348                        .tool_surface()
1349                        .tools
1350                        .iter()
1351                        .cloned()
1352                        .collect::<Vec<_>>(),
1353                ),
1354                Err(errs) => {
1355                    errors.extend(errs.0.iter().map(std::string::ToString::to_string));
1356                    (false, Vec::new())
1357                }
1358            }
1359        } else {
1360            (true, Vec::new())
1361        };
1362
1363        if valid_flow && has_flow {
1364            // Read/write state-key diff — the guard-key analogue of the
1365            // unknown-tool check.
1366            let written = self.state_keys_written();
1367            for key in flow.state_keys_read() {
1368                if written.contains(&key) {
1369                    continue;
1370                }
1371                // `{name}:result` keys are written by on_enter orchestration.
1372                if key.ends_with(":result") {
1373                    continue;
1374                }
1375                let hint = written
1376                    .iter()
1377                    .filter(|w| levenshtein(&key, w) <= 2)
1378                    .cloned()
1379                    .collect::<Vec<_>>();
1380                let suffix = if hint.is_empty() {
1381                    String::new()
1382                } else {
1383                    format!(" — did you mean {}?", hint.join(" / "))
1384                };
1385                warnings.push(format!(
1386                    "a guard reads state key '{key}' but no tool, extractor, phase, or watcher \
1387                     writes it (it can never latch){suffix}"
1388                ));
1389            }
1390            for t in &self.tools {
1391                if !referenced.contains(&t.name) {
1392                    warnings.push(format!(
1393                        "tool '{}' is declared but no step or constraint references it \
1394                         (it will be denied whenever a step with an `allow` list is active \
1395                         unless you add it to `ambient`)",
1396                        t.name
1397                    ));
1398                }
1399            }
1400            for s in &flow.steps {
1401                if !s.terminal && s.posture.is_none() {
1402                    warnings.push(format!(
1403                        "step '{}' has no posture — the model gets no steering while it is active",
1404                        s.id
1405                    ));
1406                }
1407            }
1408        }
1409
1410        SpecValidation {
1411            valid: errors.is_empty(),
1412            errors,
1413            warnings,
1414            mermaid,
1415            tools: referenced,
1416            steps,
1417        }
1418    }
1419
1420    /// Build the dispatcher of declared tools bound to `state`.
1421    pub fn build_dispatcher(&self, state: &State) -> ToolDispatcher {
1422        let mut dispatcher = ToolDispatcher::new();
1423        for tool in &self.tools {
1424            dispatcher.register(build_tool(tool, state));
1425        }
1426        dispatcher
1427    }
1428
1429    /// Apply the mock semantics of a declared tool to `state` (its
1430    /// `set_state` writes). Used by the offline simulator.
1431    pub(crate) fn apply_tool_state(&self, name: &str, state: &State) {
1432        if let Some(tool) = self.tools.iter().find(|t| t.name == name) {
1433            for (key, value) in &tool.set_state {
1434                let _ = state.set(key, value.clone());
1435            }
1436        }
1437    }
1438
1439    /// Run the embedded test suite offline (no model, no network) — scripted
1440    /// events replayed through the real [`FlowMonitor`](gemini_adk_rs::flow::FlowMonitor).
1441    pub fn run_tests(&self) -> Vec<TestReport> {
1442        simulate::run_tests(self)
1443    }
1444
1445    /// Configure a [`Live`] builder from this spec.
1446    ///
1447    /// `state` is the session state the declared tools bind to — pass the
1448    /// same one via `.state` (this method does). Returns an error when
1449    /// the spec fails validation or requires a resource `resources` lacks.
1450    /// Everything code-only (callbacks, custom guards, middleware) is added on
1451    /// the returned builder afterwards.
1452    pub fn apply(
1453        &self,
1454        live: Live,
1455        state: &State,
1456        resources: &SpecResources,
1457    ) -> Result<Live, String> {
1458        let validation = self.validate();
1459        if !validation.valid {
1460            return Err(format!(
1461                "spec failed validation: {}",
1462                validation.errors.join("; ")
1463            ));
1464        }
1465        if !self.extract.is_empty() && resources.extraction_llm.is_none() {
1466            return Err(
1467                "spec declares extraction but SpecResources.extraction_llm is not set".into(),
1468            );
1469        }
1470        if self.memory.is_some() && resources.memory.is_none() {
1471            return Err("spec declares memory but SpecResources.memory is not set".into());
1472        }
1473
1474        // Seed declared defaults before anything reads state.
1475        self.seed_state_defaults(state);
1476
1477        let mut live = live
1478            .state(state.clone())
1479            .instruction(if self.instruction.is_empty() {
1480                "Follow the conversation flow you are given.".to_string()
1481            } else {
1482                self.instruction.clone()
1483            });
1484
1485        if let Some(greeting) = &self.greeting {
1486            live = live.greeting(greeting.clone());
1487        }
1488        live = match self.modality {
1489            SpecModality::Text => live.text_only(),
1490            SpecModality::Audio => live.voice(resolve_voice(self.voice.as_deref())),
1491        };
1492
1493        // Tools: declared (mock/HTTP) via the dispatcher, MCP merged on top.
1494        if !self.tools.is_empty() {
1495            live = live.dispatcher(self.build_dispatcher(state));
1496        }
1497        for params in &self.mcp {
1498            live = live.tools(T::mcp(params.clone()));
1499        }
1500
1501        // Governance.
1502        let flow = self.effective_flow().map_err(|e| e.join("; "))?;
1503        if !flow.steps.is_empty() {
1504            live = live.govern(flow);
1505        }
1506
1507        // Computed (derived) state variables — dependencies inferred from the
1508        // expression, so the registry's topological ordering holds.
1509        for c in &self.computed {
1510            let expr = c.from.clone();
1511            let deps: Vec<String> = expr.keys_read().into_iter().collect();
1512            let dep_refs: Vec<&str> = deps.iter().map(String::as_str).collect();
1513            live = live.computed(c.key.clone(), &dep_refs, move |s| expr.eval(s));
1514        }
1515
1516        // Memory: install through the binding (tools, ingestion, slots).
1517        let memory_binding = resources.memory.clone();
1518        if let (Some(memory), Some(binding)) = (&self.memory, &memory_binding) {
1519            live = binding.install(live, memory);
1520        }
1521
1522        // Background tools (async function calling).
1523        for tool in &self.tools {
1524            match (tool.background, tool.scheduling) {
1525                (_, Some(scheduling)) => {
1526                    live = live
1527                        .tool_background_with_scheduling(tool.name.clone(), scheduling.to_wire());
1528                }
1529                (true, None) => {
1530                    live = live.tool_background(tool.name.clone());
1531                }
1532                (false, None) => {}
1533            }
1534        }
1535
1536        // Control-plane and voice tuning.
1537        if let Some(runtime) = &self.runtime {
1538            live = apply_runtime(live, runtime);
1539        }
1540
1541        // Extraction.
1542        if let Some(llm) = &resources.extraction_llm {
1543            for e in &self.extract {
1544                let mut extractor =
1545                    LlmExtractor::new(e.name.clone(), llm.clone(), e.instruction.clone(), e.window)
1546                        .with_schema(e.schema.clone())
1547                        .with_min_words(3)
1548                        .with_trigger(e.trigger.to_trigger());
1549                if !e.promote.is_empty() {
1550                    extractor = extractor
1551                        .with_promotions(e.promote.iter().map(PromoteSpec::to_rule).collect());
1552                }
1553                live = live.extractor(Arc::new(extractor));
1554            }
1555        }
1556
1557        // Phases.
1558        for p in &self.phases {
1559            let mut builder = live.phase(p.name.clone());
1560            if let Some(instruction) = &p.instruction {
1561                builder = builder.instruction(instruction.clone());
1562            }
1563            if !p.tools.is_empty() {
1564                builder = builder.tools(p.tools.clone());
1565            }
1566            if !p.needs.is_empty() {
1567                let refs: Vec<&str> = p.needs.iter().map(String::as_str).collect();
1568                builder = builder.needs(&refs);
1569            }
1570            if p.prompt_on_enter == Some(true) {
1571                builder = builder.prompt_on_enter();
1572            }
1573            if p.terminal {
1574                builder = builder.terminal();
1575            }
1576            for t in &p.transitions {
1577                let guard = t.when.clone();
1578                let predicate = move |s: &State| guard.eval_state(s);
1579                builder = match &t.description {
1580                    Some(desc) => builder.transition_with(&t.to, predicate, desc.clone()),
1581                    None => builder.transition(&t.to, predicate),
1582                };
1583            }
1584            if !p.on_enter.is_empty() {
1585                let effects = p.on_enter.clone();
1586                let memory = memory_binding.clone();
1587                builder = builder.on_enter(move |state, writer| {
1588                    let effects = effects.clone();
1589                    let memory = memory.clone();
1590                    async move {
1591                        run_effects(&effects, &state, &writer, memory.as_ref()).await;
1592                    }
1593                });
1594            }
1595            live = builder.done();
1596        }
1597        if let Some(initial) = &self.initial_phase {
1598            live = live.initial_phase(initial.clone());
1599        }
1600
1601        // Temporal patterns.
1602        for pattern in &self.patterns {
1603            let guard = pattern.when.clone();
1604            let condition = move |s: &State| guard.eval_state(s);
1605            let effects = pattern.effects.clone();
1606            let memory = memory_binding.clone();
1607            let action = move |state: State, writer: Arc<dyn SessionWriter>| {
1608                let effects = effects.clone();
1609                let memory = memory.clone();
1610                async move {
1611                    run_effects(&effects, &state, &writer, memory.as_ref()).await;
1612                }
1613            };
1614            if let Some(secs) = pattern.sustained_secs {
1615                live = live.when_sustained(
1616                    pattern.name.clone(),
1617                    condition,
1618                    std::time::Duration::from_secs(secs),
1619                    action,
1620                );
1621            } else if let Some(turns) = pattern.turns {
1622                live = live.when_turns(pattern.name.clone(), condition, turns, action);
1623            }
1624        }
1625
1626        // Watchers.
1627        for w in &self.watch {
1628            let builder = live.watch(w.key.clone());
1629            let builder = match &w.condition {
1630                WatchCondition::Changed => builder.changed(),
1631                WatchCondition::ChangedTo(v) => builder.changed_to(v.clone()),
1632                WatchCondition::CrossedAbove(t) => builder.crossed_above(*t),
1633                WatchCondition::CrossedBelow(t) => builder.crossed_below(*t),
1634                WatchCondition::BecameTrue => builder.became_true(),
1635                WatchCondition::BecameFalse => builder.became_false(),
1636            };
1637            let sets = w.set.clone();
1638            let effects = w.effects.clone();
1639            let memory = memory_binding.clone();
1640            live = builder.then_with_writer(move |_old, _new, state, writer| {
1641                let sets = sets.clone();
1642                let effects = effects.clone();
1643                let memory = memory.clone();
1644                async move {
1645                    for (key, value) in &sets {
1646                        let _ = state.set(key, value.clone());
1647                    }
1648                    run_effects(&effects, &state, &writer, memory.as_ref()).await;
1649                }
1650            });
1651        }
1652
1653        Ok(live)
1654    }
1655}
1656
1657/// Execute a list of closed effects — the one executor behind phase
1658/// `on_enter`, watcher, and pattern actions, so every surface honors the
1659/// vocabulary identically.
1660async fn run_effects(
1661    effects: &[EffectSpec],
1662    state: &State,
1663    writer: &Arc<dyn SessionWriter>,
1664    memory: Option<&Arc<dyn MemoryBinding>>,
1665) {
1666    for effect in effects {
1667        match effect {
1668            EffectSpec::Set(map) => {
1669                for (key, value) in map {
1670                    let _ = state.set(key, value.clone());
1671                }
1672            }
1673            EffectSpec::Context(text) => {
1674                let _ = writer
1675                    .send_client_content(vec![Content::model(text.clone())], false)
1676                    .await;
1677            }
1678            EffectSpec::Prompt(text) => {
1679                let _ = writer
1680                    .send_client_content(vec![Content::model(text.clone())], true)
1681                    .await;
1682            }
1683            EffectSpec::Remember(template) => {
1684                if let Some(binding) = memory {
1685                    binding.remember(interpolate(template, &Value::Null, state));
1686                }
1687            }
1688        }
1689    }
1690}
1691
1692/// Lower the turn-commit tuning knobs onto the Live builder. A knob left
1693/// unset keeps the default from
1694/// [`TurnCommitConfig::responsive()`](gemini_adk_rs::live::TurnCommitConfig::responsive).
1695fn apply_turn_commit(
1696    mut live: Live,
1697    eot_hold_ms: Option<u32>,
1698    min_interruption_ms: Option<u32>,
1699) -> Live {
1700    if let Some(ms) = eot_hold_ms {
1701        live = live.turn_commit_eot_hold_ms(u64::from(ms));
1702    }
1703    if let Some(ms) = min_interruption_ms {
1704        live = live.turn_commit_min_interruption_ms(u64::from(ms));
1705    }
1706    live
1707}
1708
1709/// Lower the `runtime` section onto the builder.
1710fn apply_runtime(mut live: Live, runtime: &RuntimeSpec) -> Live {
1711    if let Some(t) = runtime.temperature {
1712        live = live.temperature(t);
1713    }
1714    if let Some(budget) = runtime.thinking_budget {
1715        live = live.thinking(budget);
1716    }
1717    if runtime.include_thoughts == Some(true) {
1718        live = live.include_thoughts();
1719    }
1720    if let Some(t) = runtime.transcription {
1721        if t.input {
1722            live = live.input_transcription();
1723        }
1724        if t.output {
1725            live = live.output_transcription();
1726        }
1727    }
1728    if runtime.proactive_audio == Some(true) {
1729        live = live.proactive_audio();
1730    }
1731    if let Some(vad) = &runtime.vad {
1732        live = live.vad(AutomaticActivityDetection {
1733            disabled: None,
1734            start_of_speech_sensitivity: vad.start_sensitivity.map(SensitivitySpec::to_wire),
1735            end_of_speech_sensitivity: vad.end_sensitivity.map(SensitivitySpec::to_wire),
1736            prefix_padding_ms: vad.prefix_padding_ms,
1737            silence_duration_ms: vad.silence_duration_ms,
1738        });
1739    }
1740    if let Some(audio) = &runtime.audio {
1741        #[cfg(feature = "denoise")]
1742        if audio.denoise == Some(true) {
1743            live = live.mic_denoise();
1744        }
1745        if let Some(gate) = &audio.noise_gate {
1746            live = live.mic_noise_gate(gate.threshold_rms, gate.hold_frames);
1747        }
1748        if let Some(vad) = &audio.client_vad {
1749            live = live.input_vad(vad.to_config());
1750        }
1751        if audio.authority == Some(AuthoritySpec::Client) {
1752            live = live.client_interruption_authority();
1753        }
1754        // Turn-commit tuning knobs integration seam: if either field is set,
1755        // the Live builder's turn_commit(...) method will wire them through.
1756        live = apply_turn_commit(live, audio.eot_hold_ms, audio.min_interruption_ms);
1757    }
1758    if let Some(ms) = runtime.soft_turn_timeout_ms {
1759        live = live.soft_turn_timeout(std::time::Duration::from_millis(ms));
1760    }
1761    if let Some(steering) = runtime.steering {
1762        live = live.steering_mode(match steering {
1763            SteeringSpec::InstructionUpdate => SteeringMode::InstructionUpdate,
1764            SteeringSpec::ContextInjection => SteeringMode::ContextInjection,
1765            SteeringSpec::Hybrid => SteeringMode::Hybrid,
1766        });
1767    }
1768    if let Some(delivery) = runtime.context_delivery {
1769        live = live.context_delivery(match delivery {
1770            ContextDeliverySpec::Immediate => ContextDelivery::Immediate,
1771            ContextDeliverySpec::Deferred => ContextDelivery::Deferred,
1772        });
1773    }
1774    if let Some(repair) = runtime.repair {
1775        live = live.repair(RepairConfig {
1776            nudge_after: repair.nudge_after,
1777            escalate_after: repair.escalate_after,
1778        });
1779    }
1780    if let Some(persistence) = &runtime.persistence {
1781        live = match persistence {
1782            PersistenceSpec::Fs { dir } => {
1783                live.persistence(Arc::new(gemini_adk_rs::live::FsPersistence::new(dir)))
1784            }
1785            PersistenceSpec::Memory => {
1786                live.persistence(Arc::new(gemini_adk_rs::live::MemoryPersistence::new()))
1787            }
1788        };
1789    }
1790    if let Some(id) = &runtime.session_id {
1791        live = live.session_id(id.clone());
1792    }
1793    if runtime.lossy_audio {
1794        live = live.lossy_audio();
1795    }
1796    if runtime.lossy_transcript {
1797        live = live.lossy_transcript();
1798    }
1799    live
1800}
1801
1802/// Build one declared tool as a [`SimpleTool`] bound to `state`.
1803fn build_tool(tool: &ToolSpec, state: &State) -> SimpleTool {
1804    let description = if tool.description.is_empty() {
1805        format!("Tool '{}'", tool.name)
1806    } else {
1807        tool.description.clone()
1808    };
1809    let response = tool.response.clone().unwrap_or_else(|| json!({"ok": true}));
1810    let sets = tool.set_state.clone();
1811    let save_as = tool.save_response_as.clone();
1812    let http = tool.http.clone();
1813    let st = state.clone();
1814    let name = tool.name.clone();
1815    SimpleTool::new(
1816        &tool.name,
1817        description,
1818        tool.parameters.clone(),
1819        move |args| {
1820            let response = response.clone();
1821            let sets = sets.clone();
1822            let save_as = save_as.clone();
1823            let http = http.clone();
1824            let st = st.clone();
1825            let name = name.clone();
1826            async move {
1827                let result = match &http {
1828                    Some(binding) => execute_http(binding, &args, &st).await.map_err(|e| {
1829                        gemini_adk_rs::error::ToolError::Other(format!("{name}: {e}"))
1830                    })?,
1831                    None => response,
1832                };
1833                for (key, value) in &sets {
1834                    let _ = st.set(key, value.clone());
1835                }
1836                if let Some(key) = &save_as {
1837                    let _ = st.set(key, result.clone());
1838                }
1839                Ok(result)
1840            }
1841        },
1842    )
1843}
1844
1845/// Interpolate `{args.field}` and `{state.key}` templates in a string.
1846fn interpolate(template: &str, args: &Value, state: &State) -> String {
1847    let mut out = String::with_capacity(template.len());
1848    let mut rest = template;
1849    while let Some(open) = rest.find('{') {
1850        out.push_str(&rest[..open]);
1851        let after = &rest[open + 1..];
1852        let Some(close) = after.find('}') else {
1853            out.push_str(&rest[open..]);
1854            return out;
1855        };
1856        let expr = after[..close].trim();
1857        let value = if let Some(field) = expr.strip_prefix("args.") {
1858            args.get(field).cloned()
1859        } else if let Some(key) = expr.strip_prefix("state.") {
1860            state.get::<Value>(key)
1861        } else {
1862            None
1863        };
1864        match value {
1865            Some(Value::String(s)) => out.push_str(&s),
1866            Some(v) => out.push_str(&v.to_string()),
1867            None => {}
1868        }
1869        rest = &after[close + 1..];
1870    }
1871    out.push_str(rest);
1872    out
1873}
1874
1875/// Walk a JSON value, interpolating every string.
1876#[cfg_attr(not(feature = "http-tools"), allow(dead_code))]
1877fn interpolate_value(value: &Value, args: &Value, state: &State) -> Value {
1878    match value {
1879        Value::String(s) => Value::String(interpolate(s, args, state)),
1880        Value::Array(items) => Value::Array(
1881            items
1882                .iter()
1883                .map(|v| interpolate_value(v, args, state))
1884                .collect(),
1885        ),
1886        Value::Object(map) => Value::Object(
1887            map.iter()
1888                .map(|(k, v)| (k.clone(), interpolate_value(v, args, state)))
1889                .collect(),
1890        ),
1891        other => other.clone(),
1892    }
1893}
1894
1895#[cfg(feature = "http-tools")]
1896async fn execute_http(binding: &HttpBinding, args: &Value, state: &State) -> Result<Value, String> {
1897    let url = interpolate(&binding.url, args, state);
1898    let client = reqwest::Client::new();
1899    let method = reqwest::Method::from_bytes(binding.method.to_uppercase().as_bytes())
1900        .map_err(|_| format!("invalid HTTP method '{}'", binding.method))?;
1901    let mut request = client.request(method, &url);
1902    for (name, value) in &binding.headers {
1903        request = request.header(name, interpolate(value, args, state));
1904    }
1905    if let Some(body) = &binding.body {
1906        request = request.json(&interpolate_value(body, args, state));
1907    }
1908    let response = request.send().await.map_err(|e| e.to_string())?;
1909    let status = response.status().as_u16();
1910    let text = response.text().await.map_err(|e| e.to_string())?;
1911    Ok(serde_json::from_str(&text).unwrap_or_else(|_| json!({ "status": status, "body": text })))
1912}
1913
1914#[cfg(not(feature = "http-tools"))]
1915#[allow(
1916    clippy::unused_async,
1917    reason = "same signature as the http-tools implementation so the call site is feature-agnostic"
1918)]
1919async fn execute_http(
1920    _binding: &HttpBinding,
1921    _args: &Value,
1922    _state: &State,
1923) -> Result<Value, String> {
1924    Err("http tool bindings require the `http-tools` feature".to_string())
1925}
1926
1927/// Splice `fragment` into `flow` under the directive's namespace.
1928fn splice_fragment(
1929    flow: &mut Flow,
1930    fragment: &Flow,
1931    directive: &UseFragment,
1932    errors: &mut Vec<String>,
1933) {
1934    let ns = &directive.namespace;
1935    let prefix = |id: &str| format!("{ns}/{id}");
1936    let internal: std::collections::BTreeSet<&str> =
1937        fragment.steps.iter().map(|s| s.id.as_str()).collect();
1938
1939    for step in &fragment.steps {
1940        let new_id = prefix(&step.id);
1941        if flow.steps.iter().any(|s| s.id == new_id) {
1942            errors.push(format!(
1943                "fragment splice '{ns}' collides with existing step '{new_id}'"
1944            ));
1945            continue;
1946        }
1947        let mut after: Vec<gemini_adk_rs::flow::Edge> = step
1948            .after
1949            .iter()
1950            .map(|d| gemini_adk_rs::flow::Edge {
1951                step: if internal.contains(d.step.as_str()) {
1952                    prefix(&d.step)
1953                } else {
1954                    d.step.clone()
1955                },
1956                when: d
1957                    .when
1958                    .clone()
1959                    .map(|g| rewrite_guard_steps(g, &internal, ns)),
1960            })
1961            .collect();
1962        if step.after.is_empty() {
1963            after.extend(
1964                directive
1965                    .after
1966                    .iter()
1967                    .cloned()
1968                    .map(gemini_adk_rs::flow::Edge::to),
1969            );
1970        }
1971        flow.steps.push(Step {
1972            id: new_id,
1973            after,
1974            join: step.join,
1975            gate: step
1976                .gate
1977                .clone()
1978                .map(|g| rewrite_guard_steps(g, &internal, ns)),
1979            done: step
1980                .done
1981                .clone()
1982                .map(|g| rewrite_guard_steps(g, &internal, ns)),
1983            posture: step.posture.clone(),
1984            ground: step.ground.clone(),
1985            allow: step.allow.clone(),
1986            deny: step.deny.clone(),
1987            terminal: step.terminal,
1988        });
1989    }
1990    for constraint in &fragment.constraints {
1991        flow.constraints.push(match constraint {
1992            Constraint::Once(t) => Constraint::Once(t.clone()),
1993            Constraint::Before(a, b) => Constraint::Before(
1994                if internal.contains(a.as_str()) {
1995                    prefix(a)
1996                } else {
1997                    a.clone()
1998                },
1999                if internal.contains(b.as_str()) {
2000                    prefix(b)
2001                } else {
2002                    b.clone()
2003                },
2004            ),
2005            Constraint::NeverUntil { tool, until } => Constraint::NeverUntil {
2006                tool: tool.clone(),
2007                until: rewrite_guard_steps(until.clone(), &internal, ns),
2008            },
2009            Constraint::Require(rs) => Constraint::Require(
2010                rs.iter()
2011                    .map(|r| {
2012                        if internal.contains(r.as_str()) {
2013                            prefix(r)
2014                        } else {
2015                            r.clone()
2016                        }
2017                    })
2018                    .collect(),
2019            ),
2020            Constraint::Reset { steps, when } => Constraint::Reset {
2021                steps: steps
2022                    .iter()
2023                    .map(|r| {
2024                        if internal.contains(r.as_str()) {
2025                            prefix(r)
2026                        } else {
2027                            r.clone()
2028                        }
2029                    })
2030                    .collect(),
2031                when: rewrite_guard_steps(when.clone(), &internal, ns),
2032            },
2033        });
2034    }
2035    for tool in &fragment.ambient {
2036        if !flow.ambient.contains(tool) {
2037            flow.ambient.push(tool.clone());
2038        }
2039    }
2040    for tool in &fragment.confirm_tools {
2041        if !flow.confirm_tools.contains(tool) {
2042            flow.confirm_tools.push(tool.clone());
2043        }
2044    }
2045}
2046
2047/// Rewrite `done(step)` atoms that reference fragment-internal steps.
2048fn rewrite_guard_steps(
2049    guard: Guard,
2050    internal: &std::collections::BTreeSet<&str>,
2051    ns: &str,
2052) -> Guard {
2053    fn rewrite(pred: Pred, internal: &std::collections::BTreeSet<&str>, ns: &str) -> Pred {
2054        match pred {
2055            Pred::Done(s) if internal.contains(s.as_str()) => Pred::Done(format!("{ns}/{s}")),
2056            Pred::All(ps) => Pred::All(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2057            Pred::Any(ps) => Pred::Any(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2058            Pred::Not(p) => Pred::Not(Box::new(rewrite(*p, internal, ns))),
2059            other => other,
2060        }
2061    }
2062    match guard {
2063        Guard::Spec(p) => Guard::Spec(rewrite(p, internal, ns)),
2064        custom => custom,
2065    }
2066}
2067
2068/// Whether a guard contains `called_ok`/`done` atoms, which need a flow
2069/// marking and therefore cannot back phase transitions.
2070fn guard_uses_marking(guard: &Guard) -> bool {
2071    fn walk(pred: &Pred) -> bool {
2072        match pred {
2073            Pred::CalledOk(_) | Pred::Done(_) => true,
2074            Pred::All(ps) | Pred::Any(ps) => ps.iter().any(walk),
2075            Pred::Not(p) => walk(p),
2076            _ => false,
2077        }
2078    }
2079    match guard {
2080        Guard::Spec(p) => walk(p),
2081        Guard::Custom(_) => false,
2082    }
2083}
2084
2085/// Resolve a voice name to the `Voice` enum.
2086fn resolve_voice(name: Option<&str>) -> Voice {
2087    match name {
2088        Some("Aoede") => Voice::Aoede,
2089        Some("Charon") => Voice::Charon,
2090        Some("Fenrir") => Voice::Fenrir,
2091        Some("Kore") => Voice::Kore,
2092        Some("Puck") | None => Voice::Puck,
2093        Some(other) => Voice::Custom(other.to_string()),
2094    }
2095}
2096
2097/// Edit distance for the did-you-mean hint on unmatched state keys.
2098fn levenshtein(a: &str, b: &str) -> usize {
2099    let a: Vec<char> = a.chars().collect();
2100    let b: Vec<char> = b.chars().collect();
2101    let mut prev: Vec<usize> = (0..=b.len()).collect();
2102    let mut current = vec![0; b.len() + 1];
2103    for (i, ca) in a.iter().enumerate() {
2104        current[0] = i + 1;
2105        for (j, cb) in b.iter().enumerate() {
2106            let cost = usize::from(ca != cb);
2107            current[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(current[j] + 1);
2108        }
2109        std::mem::swap(&mut prev, &mut current);
2110    }
2111    prev[b.len()]
2112}
2113
2114#[cfg(test)]
2115mod tests {
2116
2117    #[test]
2118    fn audio_spec_lowers_and_validates() {
2119        let spec: SessionSpec = serde_json::from_str(
2120            r#"{
2121                "name": "noisy",
2122                "instruction": "hi",
2123                "runtime": {
2124                    "audio": {
2125                        "denoise": true,
2126                        "noise_gate": { "threshold_rms": 700.0 },
2127                        "client_vad": { "preset": "noisy_street", "hangover_frames": 12 },
2128                        "authority": "client"
2129                    }
2130                }
2131            }"#,
2132        )
2133        .unwrap();
2134        let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
2135        assert_eq!(audio.noise_gate.as_ref().unwrap().hold_frames, 3); // serde default
2136        let config = audio.client_vad.as_ref().unwrap().to_config();
2137        assert_eq!(config.start_threshold_db, 21.0); // preset
2138        assert_eq!(config.hangover_frames, 12); // override wins
2139        assert_eq!(audio.authority, Some(AuthoritySpec::Client));
2140        // Round-trips through JSON unchanged.
2141        let json = serde_json::to_value(&spec).unwrap();
2142        let back: SessionSpec = serde_json::from_value(json).unwrap();
2143        assert_eq!(
2144            back.runtime
2145                .unwrap()
2146                .audio
2147                .unwrap()
2148                .client_vad
2149                .unwrap()
2150                .hangover_frames,
2151            Some(12)
2152        );
2153    }
2154
2155    #[test]
2156    fn audio_spec_warns_on_risky_combinations() {
2157        let spec: SessionSpec = serde_json::from_str(
2158            r#"{
2159                "name": "risky",
2160                "instruction": "hi",
2161                "runtime": { "audio": { "authority": "client", "noise_gate": {} } }
2162            }"#,
2163        )
2164        .unwrap();
2165        let validation = spec.validate();
2166        assert!(
2167            validation
2168                .warnings
2169                .iter()
2170                .any(|w| w.contains("authority=client without denoise")),
2171            "expected client-authority warning, got {:?}",
2172            validation.warnings
2173        );
2174        assert!(
2175            validation
2176                .warnings
2177                .iter()
2178                .any(|w| w.contains("noise_gate without denoise")),
2179            "expected gate warning, got {:?}",
2180            validation.warnings
2181        );
2182    }
2183
2184    #[test]
2185    fn turn_commit_tuning_knobs_serialize_and_round_trip() {
2186        let spec: SessionSpec = serde_json::from_str(
2187            r#"{
2188                "name": "tune",
2189                "instruction": "hi",
2190                "runtime": {
2191                    "audio": {
2192                        "eot_hold_ms": 800,
2193                        "min_interruption_ms": 1400
2194                    }
2195                }
2196            }"#,
2197        )
2198        .unwrap();
2199        let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
2200        assert_eq!(audio.eot_hold_ms, Some(800));
2201        assert_eq!(audio.min_interruption_ms, Some(1400));
2202        // Round-trips through JSON unchanged.
2203        let json = serde_json::to_value(&spec).unwrap();
2204        let back: SessionSpec = serde_json::from_value(json).unwrap();
2205        let audio_back = back.runtime.unwrap().audio.unwrap();
2206        assert_eq!(audio_back.eot_hold_ms, Some(800));
2207        assert_eq!(audio_back.min_interruption_ms, Some(1400));
2208    }
2209
2210    #[test]
2211    fn turn_commit_tuning_knobs_validate_thresholds() {
2212        // Warn on eot_hold_ms > 1600
2213        let spec: SessionSpec = serde_json::from_str(
2214            r#"{
2215                "name": "frontier",
2216                "instruction": "hi",
2217                "runtime": { "audio": { "eot_hold_ms": 1700 } }
2218            }"#,
2219        )
2220        .unwrap();
2221        let validation = spec.validate();
2222        assert!(
2223            validation
2224                .warnings
2225                .iter()
2226                .any(|w| w.contains("eot_hold_ms") && w.contains("1600") && w.contains("frontier")),
2227            "expected eot_hold_ms frontier warning, got {:?}",
2228            validation.warnings
2229        );
2230
2231        // Warn on min_interruption_ms > 2000
2232        let spec2: SessionSpec = serde_json::from_str(
2233            r#"{
2234                "name": "window",
2235                "instruction": "hi",
2236                "runtime": { "audio": { "min_interruption_ms": 2100 } }
2237            }"#,
2238        )
2239        .unwrap();
2240        let validation2 = spec2.validate();
2241        assert!(
2242            validation2
2243                .warnings
2244                .iter()
2245                .any(|w| w.contains("min_interruption_ms")
2246                    && w.contains("2000")
2247                    && w.contains("window")),
2248            "expected min_interruption_ms window warning, got {:?}",
2249            validation2.warnings
2250        );
2251    }
2252    use super::*;
2253
2254    fn collections_spec() -> SessionSpec {
2255        SessionSpec::from_value(json!({
2256            "name": "collections",
2257            "instruction": "Collect payments.",
2258            "tools": [
2259                {"name": "verify_identity", "set_state": {"identity_verified": true}},
2260                {"name": "charge_card", "response": {"charged": true}}
2261            ],
2262            "extract": [{
2263                "name": "ptp",
2264                "instruction": "Extract the promise to pay.",
2265                "schema": {"type": "object", "properties": {
2266                    "ptp_amount": {"type": "number"}, "ptp_date": {"type": "string"}}},
2267                "promote": [
2268                    {"field": "ptp_amount"},
2269                    {"field": "ptp_date", "policy": "overwrite"}
2270                ]
2271            }],
2272            "flow": {
2273                "steps": [
2274                    {"id": "verify", "posture": "Verify the caller.",
2275                     "allow": ["verify_identity"],
2276                     "done": {"is_true": "identity_verified"}},
2277                    {"id": "pay", "after": ["verify"], "posture": "Take payment.",
2278                     "allow": ["charge_card"],
2279                     "gate": {"captured": ["ptp_amount", "ptp_date"]},
2280                     "done": {"called_ok": "charge_card"}}
2281                ]
2282            }
2283        }))
2284        .expect("spec parses")
2285    }
2286
2287    #[test]
2288    fn extraction_promotions_satisfy_guard_reads() {
2289        let v = collections_spec().validate();
2290        assert!(v.valid, "errors: {:?}", v.errors);
2291        assert!(
2292            v.warnings.iter().all(|w| !w.contains("can never latch")),
2293            "promoted keys cover the guard reads: {:?}",
2294            v.warnings
2295        );
2296    }
2297
2298    #[test]
2299    fn unwritten_guard_key_warns_with_suggestion() {
2300        let mut spec = collections_spec();
2301        // Typo in the guard key relative to the tool's write.
2302        spec.flow.as_mut().unwrap().steps[0].done = Some(Guard::is_true("identity_verifed"));
2303        let v = spec.validate();
2304        assert!(v.valid);
2305        let warning = v
2306            .warnings
2307            .iter()
2308            .find(|w| w.contains("identity_verifed"))
2309            .expect("warns about the unwritten key");
2310        assert!(
2311            warning.contains("identity_verified"),
2312            "suggests the fix: {warning}"
2313        );
2314    }
2315
2316    #[test]
2317    fn phase_guard_rejects_marking_atoms() {
2318        let spec = SessionSpec::from_value(json!({
2319            "instruction": "x",
2320            "phases": [{"name": "a", "transitions": [
2321                {"to": "b", "when": {"called_ok": "some_tool"}}]},
2322                {"name": "b"}],
2323            "initial_phase": "a"
2324        }))
2325        .expect("parses");
2326        let v = spec.validate();
2327        assert!(!v.valid);
2328        assert!(v.errors.iter().any(|e| e.contains("called_ok")));
2329    }
2330
2331    #[test]
2332    fn fragments_splice_with_namespacing() {
2333        let spec = SessionSpec::from_value(json!({
2334            "instruction": "x",
2335            "tools": [
2336                {"name": "check_id", "set_state": {"id_ok": true}},
2337                {"name": "book", "response": {}}
2338            ],
2339            "fragments": {
2340                "verify": {"steps": [
2341                    {"id": "ask", "posture": "Ask for ID.", "allow": ["check_id"],
2342                     "done": {"is_true": "id_ok"}},
2343                    {"id": "confirm", "after": ["ask"], "terminal": true,
2344                     "gate": {"done": "ask"}}
2345                ]}
2346            },
2347            "use_fragments": [{"fragment": "verify", "namespace": "v"}],
2348            "flow": {"steps": [
2349                {"id": "book_step", "after": ["v/confirm"], "allow": ["book"],
2350                 "done": {"called_ok": "book"}}
2351            ]}
2352        }))
2353        .expect("parses");
2354        let flow = spec.effective_flow().expect("splices");
2355        let ids: Vec<&str> = flow.steps.iter().map(|s| s.id.as_str()).collect();
2356        assert!(ids.contains(&"v/ask") && ids.contains(&"v/confirm"));
2357        // Internal done() atom rewritten to the namespaced id.
2358        let confirm = flow.steps.iter().find(|s| s.id == "v/confirm").unwrap();
2359        assert_eq!(
2360            serde_json::to_value(confirm.gate.as_ref().unwrap()).unwrap(),
2361            json!({"done": "v/ask"})
2362        );
2363        let v = spec.validate();
2364        assert!(v.valid, "errors: {:?}", v.errors);
2365    }
2366
2367    #[test]
2368    fn bare_flow_round_trips() {
2369        let spec = SessionSpec::from_value(json!({
2370            "steps": [{"id": "only", "terminal": true}]
2371        }))
2372        .expect("parses");
2373        assert!(spec.validate().valid);
2374    }
2375
2376    #[test]
2377    fn empty_fragment_namespace_is_rejected() {
2378        let spec = SessionSpec::from_value(json!({
2379            "instruction": "x",
2380            "fragments": {
2381                "verify": {"steps": [
2382                    {"id": "ask", "terminal": true}
2383                ]}
2384            },
2385            "use_fragments": [{"fragment": "verify", "namespace": ""}],
2386            "flow": {"steps": [
2387                {"id": "start", "terminal": true}
2388            ]}
2389        }))
2390        .expect("parses");
2391        let v = spec.validate();
2392        // Empty namespace should be a validation error
2393        assert!(
2394            !v.valid,
2395            "empty namespace should fail validation, got errors: {:?}",
2396            v.errors
2397        );
2398        assert!(
2399            v.errors.iter().any(|e| e.contains("namespace")),
2400            "should mention namespace issue: {:?}",
2401            v.errors
2402        );
2403    }
2404
2405    #[test]
2406    fn duplicate_computed_keys_are_rejected() {
2407        let spec = SessionSpec::from_value(json!({
2408            "instruction": "x",
2409            "flow": {"steps": [{"id": "only", "terminal": true}]},
2410            "computed": [
2411                {"key": "risk", "from": {"key": "score"}},
2412                {"key": "risk", "from": {"key": "other_score"}}
2413            ]
2414        }))
2415        .expect("parses");
2416        let v = spec.validate();
2417        assert!(!v.valid, "duplicate computed keys should fail validation");
2418        assert!(
2419            v.errors.iter().any(|e| e.contains("duplicate")),
2420            "should mention duplicate computed key: {:?}",
2421            v.errors
2422        );
2423    }
2424
2425    #[test]
2426    fn duplicate_phase_names_are_rejected() {
2427        let spec = SessionSpec::from_value(json!({
2428            "instruction": "x",
2429            "phases": [
2430                {"name": "greet", "instruction": "Welcome."},
2431                {"name": "greet", "instruction": "Hi again."}
2432            ],
2433            "initial_phase": "greet"
2434        }))
2435        .expect("parses");
2436        let v = spec.validate();
2437        assert!(!v.valid, "duplicate phase names should fail validation");
2438        assert!(
2439            v.errors.iter().any(|e| e.contains("phase")),
2440            "should mention duplicate phase: {:?}",
2441            v.errors
2442        );
2443    }
2444
2445    #[test]
2446    fn tool_background_false_round_trips() {
2447        // Test that explicitly setting background: false round-trips correctly
2448        let spec = SessionSpec::from_value(json!({
2449            "instruction": "x",
2450            "tools": [
2451                {"name": "search", "background": false},
2452                {"name": "log", "background": true}
2453            ],
2454            "flow": {"steps": [{"id": "only", "terminal": true}]}
2455        }))
2456        .expect("parses");
2457
2458        // After serialization
2459        let serialized = serde_json::to_value(&spec).unwrap();
2460        let tools = serialized["tools"].as_array().unwrap();
2461
2462        // background: false is skipped in serialization (optimization), but
2463        // when deserialized, missing field defaults to false ✓
2464        assert!(
2465            tools[0].get("background").is_none(),
2466            "background: false is optimized away"
2467        );
2468
2469        // background: true is serialized
2470        assert_eq!(tools[1].get("background"), Some(&json!(true)));
2471
2472        // Round-trip test
2473        let back: SessionSpec = serde_json::from_value(serialized).unwrap();
2474        assert!(!back.tools[0].background);
2475        assert!(back.tools[1].background);
2476    }
2477
2478    #[test]
2479    fn promotion_spec_with_no_to_field_uses_field_name() {
2480        // Test that promotion without explicit "to" uses the field name as target
2481        let spec = SessionSpec::from_value(json!({
2482            "instruction": "x",
2483            "tools": [{"name": "extract", "set_state": {"test": true}}],
2484            "extract": [{
2485                "name": "data",
2486                "instruction": "Extract.",
2487                "schema": {"type": "object"},
2488                "promote": [
2489                    {"field": "amount"},
2490                    {"field": "date", "to": "extracted_date"}
2491                ]
2492            }],
2493            "flow": {"steps": [{"id": "only", "terminal": true}]}
2494        }))
2495        .expect("parses");
2496
2497        let promote = &spec.extract[0].promote;
2498        assert_eq!(promote[0].target(), "amount");
2499        assert_eq!(promote[1].target(), "extracted_date");
2500    }
2501
2502    #[test]
2503    fn spec_schema_publishes() {
2504        let schema = SessionSpec::json_schema().to_string();
2505        for token in [
2506            "is_true",
2507            "never_until",
2508            "set_state",
2509            "use_fragments",
2510            "promote",
2511        ] {
2512            assert!(schema.contains(token), "schema missing {token}");
2513        }
2514    }
2515
2516    #[test]
2517    fn interpolation_reads_args_and_state() {
2518        let state = State::new();
2519        let _ = state.set("city", "Paris");
2520        let args = json!({"guests": 4});
2521        assert_eq!(
2522            interpolate("book/{state.city}/{args.guests}/{missing}", &args, &state),
2523            "book/Paris/4/"
2524        );
2525    }
2526
2527    #[tokio::test]
2528    async fn declared_tools_write_state() {
2529        let spec = collections_spec();
2530        let state = State::new();
2531        let dispatcher = spec.build_dispatcher(&state);
2532        let out = dispatcher
2533            .call_function("verify_identity", json!({}))
2534            .await
2535            .expect("tool runs");
2536        assert_eq!(out, json!({"ok": true}));
2537        assert_eq!(state.get::<bool>("identity_verified"), Some(true));
2538    }
2539
2540    #[test]
2541    fn computed_cycles_are_load_time_errors() {
2542        let spec = SessionSpec::from_value(json!({
2543            "instruction": "x",
2544            "flow": {"steps": [{"id": "only", "terminal": true}]},
2545            "computed": [
2546                {"key": "a", "from": {"add": [{"key": "b"}, {"const": 1}]}},
2547                {"key": "b", "from": {"add": [{"key": "derived:a"}, {"const": 1}]}}
2548            ]
2549        }))
2550        .expect("parses");
2551        let v = spec.validate();
2552        assert!(!v.valid);
2553        assert!(v.errors.iter().any(|e| e.contains("dependency cycle")));
2554
2555        let self_read = SessionSpec::from_value(json!({
2556            "instruction": "x",
2557            "flow": {"steps": [{"id": "only", "terminal": true}]},
2558            "computed": [{"key": "a", "from": {"key": "a"}}]
2559        }))
2560        .expect("parses");
2561        assert!(
2562            self_read
2563                .validate()
2564                .errors
2565                .iter()
2566                .any(|e| e.contains("reads its own key"))
2567        );
2568    }
2569
2570    #[test]
2571    fn computed_keys_satisfy_guard_reads_and_deps_are_checked() {
2572        let spec = SessionSpec::from_value(json!({
2573            "instruction": "x",
2574            "tools": [{"name": "record", "set_state": {"score": 0.8}}],
2575            "computed": [{"key": "high_risk",
2576                          "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
2577            "flow": {"steps": [
2578                {"id": "assess", "posture": "Assess.", "allow": ["record"],
2579                 "done": {"is_true": "high_risk"}}
2580            ]}
2581        }))
2582        .expect("parses");
2583        let v = spec.validate();
2584        assert!(v.valid, "errors: {:?}", v.errors);
2585        assert!(
2586            v.warnings.iter().all(|w| !w.contains("can never latch")),
2587            "computed key covers the guard read: {:?}",
2588            v.warnings
2589        );
2590
2591        let mut dangling = spec.clone();
2592        dangling.computed[0].from =
2593            serde_json::from_value(json!({"gt": [{"key": "scoer"}, {"const": 0.5}]})).unwrap();
2594        let v = dangling.validate();
2595        assert!(
2596            v.warnings
2597                .iter()
2598                .any(|w| w.contains("computed 'high_risk' reads state key 'scoer'"))
2599        );
2600    }
2601
2602    #[test]
2603    fn remember_requires_the_memory_section() {
2604        let spec = SessionSpec::from_value(json!({
2605            "instruction": "x",
2606            "flow": {"steps": [{"id": "only", "terminal": true}]},
2607            "patterns": [{"name": "note", "when": {"is_true": "flag"}, "turns": 2,
2608                          "effects": [{"remember": "caller likes {state.thing}"}]}]
2609        }))
2610        .expect("parses");
2611        let v = spec.validate();
2612        assert!(!v.valid);
2613        assert!(v.errors.iter().any(|e| e.contains("no `memory` section")));
2614    }
2615
2616    #[test]
2617    fn memory_slots_join_written_keys_and_reject_derived_targets() {
2618        let spec = SessionSpec::from_value(json!({
2619            "instruction": "x",
2620            "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
2621            "flow": {"steps": [
2622                {"id": "plan", "posture": "Plan dinner.",
2623                 "done": {"is_set": "user:diet"}}
2624            ]}
2625        }))
2626        .expect("parses");
2627        let v = spec.validate();
2628        assert!(v.valid, "errors: {:?}", v.errors);
2629        assert!(v.warnings.iter().all(|w| !w.contains("can never latch")));
2630
2631        let mut bad = spec.clone();
2632        bad.memory.as_mut().unwrap().slots[0].to = "derived:diet".into();
2633        assert!(
2634            bad.validate()
2635                .errors
2636                .iter()
2637                .any(|e| e.contains("read-only key"))
2638        );
2639    }
2640
2641    #[test]
2642    fn memory_section_requires_a_binding_at_apply() {
2643        let spec = SessionSpec::from_value(json!({
2644            "instruction": "x",
2645            "memory": {},
2646            "flow": {"steps": [{"id": "only", "terminal": true}]}
2647        }))
2648        .expect("parses");
2649        let err = spec
2650            .apply(Live::builder(), &State::new(), &SpecResources::default())
2651            .err()
2652            .expect("memory binding required");
2653        assert!(err.contains("SpecResources.memory"));
2654
2655        struct NullBinding;
2656        impl MemoryBinding for NullBinding {
2657            fn install(&self, live: Live, _memory: &MemorySpec) -> Live {
2658                live
2659            }
2660            fn remember(&self, _note: String) {}
2661        }
2662        let resources = SpecResources {
2663            memory: Some(Arc::new(NullBinding)),
2664            ..Default::default()
2665        };
2666        assert!(
2667            spec.apply(Live::builder(), &State::new(), &resources)
2668                .is_ok()
2669        );
2670    }
2671
2672    #[test]
2673    fn state_dictionary_seeds_defaults_and_flags_undeclared_writes() {
2674        let spec = SessionSpec::from_value(json!({
2675            "instruction": "x",
2676            "state": {
2677                "attempts": {"type": "number", "default": 0,
2678                             "description": "Verification attempts so far."},
2679                "verified": {"type": "boolean", "default": "yes"}
2680            },
2681            "tools": [{"name": "verify", "set_state": {"verified": true, "vip": true}}],
2682            "flow": {"steps": [
2683                {"id": "v", "posture": "Verify.", "allow": ["verify"],
2684                 "done": {"is_true": "verified"}}
2685            ]}
2686        }))
2687        .expect("parses");
2688        let v = spec.validate();
2689        assert!(v.valid, "errors: {:?}", v.errors);
2690        assert!(
2691            v.warnings
2692                .iter()
2693                .any(|w| w.contains("'verified' declares type Boolean")),
2694            "type-mismatched default warns: {:?}",
2695            v.warnings
2696        );
2697        assert!(
2698            v.warnings
2699                .iter()
2700                .any(|w| w.contains("'vip' is written but not declared")),
2701            "undeclared write warns: {:?}",
2702            v.warnings
2703        );
2704
2705        let state = State::new();
2706        spec.seed_state_defaults(&state);
2707        assert_eq!(state.get::<i64>("attempts"), Some(0));
2708    }
2709
2710    #[test]
2711    fn runtime_section_lowers_onto_the_builder() {
2712        let spec = SessionSpec::from_value(json!({
2713            "instruction": "x",
2714            "flow": {"steps": [{"id": "only", "terminal": true}]},
2715            "runtime": {
2716                "temperature": 0.4,
2717                "thinking_budget": 1024,
2718                "include_thoughts": true,
2719                "transcription": {"input": true, "output": false},
2720                "proactive_audio": true,
2721                "vad": {"start_sensitivity": "high", "silence_duration_ms": 400},
2722                "soft_turn_timeout_ms": 1500,
2723                "steering": "context_injection",
2724                "context_delivery": "deferred",
2725                "repair": {"nudge_after": 2, "escalate_after": 5},
2726                "persistence": "memory",
2727                "session_id": "user-1",
2728                "lossy_audio": true
2729            }
2730        }))
2731        .expect("parses");
2732        let v = spec.validate();
2733        assert!(v.valid, "errors: {:?}", v.errors);
2734        assert!(
2735            spec.apply(Live::builder(), &State::new(), &SpecResources::default())
2736                .is_ok()
2737        );
2738
2739        let mut incoherent = spec.clone();
2740        incoherent.runtime.as_mut().unwrap().thinking_budget = None;
2741        assert!(
2742            incoherent
2743                .validate()
2744                .warnings
2745                .iter()
2746                .any(|w| w.contains("include_thoughts"))
2747        );
2748    }
2749
2750    #[test]
2751    fn background_tools_and_scheduling_parse_and_apply() {
2752        let spec = SessionSpec::from_value(json!({
2753            "instruction": "x",
2754            "tools": [
2755                {"name": "search_kb", "background": true},
2756                {"name": "log_event", "scheduling": "silent"}
2757            ],
2758            "flow": {"steps": [
2759                {"id": "s", "posture": "Serve.", "allow": ["search_kb", "log_event"],
2760                 "done": {"called_ok": "search_kb"}}
2761            ]}
2762        }))
2763        .expect("parses");
2764        assert!(spec.validate().valid);
2765        assert!(
2766            spec.apply(Live::builder(), &State::new(), &SpecResources::default())
2767                .is_ok()
2768        );
2769    }
2770
2771    #[test]
2772    fn apply_configures_a_builder() {
2773        let spec = collections_spec();
2774        let state = State::new();
2775        // No extraction LLM provided → apply must refuse (extraction declared).
2776        let err = spec
2777            .apply(Live::builder(), &state, &SpecResources::default())
2778            .err()
2779            .expect("requires extraction llm");
2780        assert!(err.contains("extraction_llm"));
2781
2782        // Without the extraction entries it applies clean.
2783        let mut no_extract = spec.clone();
2784        no_extract.extract.clear();
2785        assert!(
2786            no_extract
2787                .apply(Live::builder(), &state, &SpecResources::default())
2788                .is_ok()
2789        );
2790    }
2791}