Skip to main content

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