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