1use std::fmt::Write as _;
12
13use gemini_adk_rs::flow::{Constraint, Flow, Guard, Pred};
14use serde_json::Value;
15
16use super::{
17 ContextDeliverySpec, EffectSpec, PersistenceSpec, PromotePolicy, RuntimeSpec, SessionSpec,
18 SpecModality, StateType, SteeringSpec, ToolSpec, TriggerSpec, WatchCondition,
19};
20
21impl SessionSpec {
22 pub fn to_rust(&self) -> String {
24 let mut out = String::new();
25 let name = if self.name.is_empty() {
26 "app"
27 } else {
28 &self.name
29 };
30 let _ = writeln!(
31 out,
32 "//! {name} — generated by Flow Studio from a SessionSpec."
33 );
34 if !self.description.is_empty() {
35 let _ = writeln!(out, "//! {}", self.description);
36 }
37 out.push_str("//!\n//! The chain below is exactly what the JSON document lowers to;\n");
38 out.push_str("//! edit either — they stay equivalent.\n\n");
39 out.push_str("use serde_json::json;\n");
40 out.push_str("use gemini_adk_fluent_rs::prelude::*;\n");
41 if !self.extract.is_empty() {
42 out.push_str(
43 "use gemini_adk_fluent_rs::gemini_adk_rs::live::extractor::{\n \
44 ExtractionTrigger, FieldPromotion, LlmExtractor,\n};\n",
45 );
46 }
47 if !self.computed.is_empty() {
48 out.push_str("use gemini_adk_fluent_rs::gemini_adk_rs::expr::Expr;\n");
49 }
50 if let Some(runtime) = &self.runtime {
51 let mut live_imports = Vec::new();
52 if runtime.steering.is_some() {
53 live_imports.push("SteeringMode");
54 }
55 if runtime.context_delivery.is_some() {
56 live_imports.push("ContextDelivery");
57 }
58 if runtime.repair.is_some() {
59 live_imports.push("RepairConfig");
60 }
61 match runtime.persistence {
62 Some(PersistenceSpec::Fs { .. }) => live_imports.push("FsPersistence"),
63 Some(PersistenceSpec::Memory) => live_imports.push("MemoryPersistence"),
64 None => {}
65 }
66 if !live_imports.is_empty() {
67 let _ = writeln!(
68 out,
69 "use gemini_adk_fluent_rs::live::{{{}}};",
70 live_imports.join(", ")
71 );
72 }
73 }
74 if self.memory.is_some() {
75 out.push_str("use gemini_memory_rs::prelude::*;\n");
76 out.push_str("use gemini_memory_rs::runtime::LiveMemoryExt;\n");
77 }
78 let needs_arc = !self.extract.is_empty()
79 || self.memory.is_some()
80 || self
81 .runtime
82 .as_ref()
83 .is_some_and(|r| r.persistence.is_some());
84 if needs_arc {
85 out.push_str("use std::sync::Arc;\n");
86 }
87 out.push('\n');
88 if !self.state.is_empty() {
89 out.push_str(&gen_state_keys(&self.state));
90 out.push('\n');
91 }
92 out.push_str(
93 "#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n",
94 );
95 out.push_str(" let state = State::new();\n");
96 for (key, field) in &self.state {
97 if let Some(default) = &field.default {
98 let _ = writeln!(
99 out,
100 " let _ = state.set({}, json!({}));",
101 rust_str(key),
102 compact(default)
103 );
104 }
105 }
106 out.push('\n');
107 if self.memory.is_some() {
108 out.push_str(" // ── Memory ─────────────────────────────────────────────\n");
109 out.push_str(
110 " // In-process engine for local runs; swap the repository and event\n \
111 // log for durable backends in production.\n",
112 );
113 out.push_str(
114 " let memory_engine = MemoryEngine::in_memory(UserId::new(\"local-user\"));\n",
115 );
116 out.push_str(
117 " let memory = Arc::new(memory_engine.begin_session(SessionId::new(\
118 \"session-1\")));\n\n",
119 );
120 }
121
122 if !self.tools.is_empty() {
123 out.push_str(" // ── Tools ──────────────────────────────────────────────\n");
124 out.push_str(" let mut tools = ToolDispatcher::new();\n");
125 for tool in &self.tools {
126 out.push_str(&gen_tool(tool));
127 }
128 out.push('\n');
129 }
130
131 let flow = self.effective_flow().unwrap_or_default();
132 if !flow.steps.is_empty() {
133 out.push_str(" // ── Governed flow ──────────────────────────────────────\n");
134 out.push_str(&gen_flow(&flow));
135 out.push('\n');
136 }
137
138 out.push_str(" // ── Session ────────────────────────────────────────────\n");
139 out.push_str(" let session = Live::builder()\n");
140 let instruction = if self.instruction.is_empty() {
141 "Follow the conversation flow you are given."
142 } else {
143 &self.instruction
144 };
145 let _ = writeln!(out, " .instruction({})", rust_str(instruction));
146 if let Some(greeting) = &self.greeting {
147 let _ = writeln!(out, " .greeting({})", rust_str(greeting));
148 }
149 match self.modality {
150 SpecModality::Text => out.push_str(" .text_only()\n"),
151 SpecModality::Audio => {
152 let voice = self.voice.as_deref().unwrap_or("Puck");
153 let known = ["Puck", "Aoede", "Charon", "Fenrir", "Kore"];
154 if known.contains(&voice) {
155 let _ = writeln!(out, " .voice(Voice::{voice})");
156 } else {
157 let _ = writeln!(
158 out,
159 " .voice(Voice::Custom({}.into()))",
160 rust_str(voice)
161 );
162 }
163 }
164 }
165 out.push_str(" .state(state.clone())\n");
166 if !self.tools.is_empty() {
167 out.push_str(" .dispatcher(tools)\n");
168 }
169 for params in &self.mcp {
170 let _ = writeln!(out, " .tools(T::mcp({}))", rust_str(params));
171 }
172 if !flow.steps.is_empty() {
173 out.push_str(" .govern(flow)\n");
174 }
175 for computed in &self.computed {
176 out.push_str(&gen_computed(computed));
177 }
178 if let Some(memory) = &self.memory {
179 if memory.slots.is_empty() {
180 out.push_str(" .with_memory(memory.clone())\n");
181 } else {
182 let slots: Vec<String> = memory
183 .slots
184 .iter()
185 .map(|s| {
186 format!(
187 "MemorySlot::new({}, {})",
188 rust_str(&s.predicate),
189 rust_str(&s.to)
190 )
191 })
192 .collect();
193 let _ = writeln!(
194 out,
195 " .with_memory_slots(memory.clone(), [{}])",
196 slots.join(", ")
197 );
198 }
199 }
200 for tool in &self.tools {
201 match (tool.background, tool.scheduling) {
202 (_, Some(scheduling)) => {
203 let variant = match scheduling {
204 super::SchedulingSpec::Interrupt => "Interrupt",
205 super::SchedulingSpec::WhenIdle => "WhenIdle",
206 super::SchedulingSpec::Silent => "Silent",
207 };
208 let _ = writeln!(
209 out,
210 " .tool_background_with_scheduling({}, \
211 FunctionResponseScheduling::{variant})",
212 rust_str(&tool.name)
213 );
214 }
215 (true, None) => {
216 let _ = writeln!(out, " .tool_background({})", rust_str(&tool.name));
217 }
218 (false, None) => {}
219 }
220 }
221 if let Some(runtime) = &self.runtime {
222 out.push_str(&gen_runtime(runtime));
223 }
224 for extract in &self.extract {
225 out.push_str(&gen_extract(extract));
226 }
227 for phase in &self.phases {
228 out.push_str(&gen_phase(phase));
229 }
230 if let Some(initial) = &self.initial_phase {
231 let _ = writeln!(out, " .initial_phase({})", rust_str(initial));
232 }
233 for watch in &self.watch {
234 out.push_str(&gen_watch(watch));
235 }
236 for pattern in &self.patterns {
237 out.push_str(&gen_pattern(pattern));
238 }
239 if self.modality == SpecModality::Text {
240 out.push_str(" .on_text(|t| print!(\"{t}\"))\n");
241 out.push_str(" .on_turn_complete(|| async { println!(); })\n");
242 }
243 out.push_str(" .connect_from_env()\n .await?;\n\n");
244
245 match self.modality {
246 SpecModality::Audio => {
247 out.push_str(
248 " // Microphone in, speakers out, barge-in handled.\n \
249 // Requires the `voice-io` feature (see Cargo.toml).\n \
250 session.talk().await?;\n",
251 );
252 }
253 SpecModality::Text => {
254 out.push_str(
255 " // Minimal text REPL — type a line, read the reply.\n \
256 let stdin = std::io::stdin();\n \
257 let mut line = String::new();\n \
258 while stdin.read_line(&mut line)? > 0 {\n \
259 session.send_text(line.trim()).await?;\n \
260 line.clear();\n }\n \
261 session.disconnect().await?;\n",
262 );
263 }
264 }
265 out.push_str(" Ok(())\n}\n");
266 out
267 }
268
269 pub fn to_cargo_toml(&self) -> String {
271 let name = if self.name.is_empty() {
272 "flow-app".to_string()
273 } else {
274 self.name.replace([' ', '_'], "-").to_lowercase()
275 };
276 let features = if self.modality == SpecModality::Audio {
277 r#", features = ["gemini-llm", "voice-io"]"#
278 } else {
279 r#", features = ["gemini-llm"]"#
280 };
281 let memory_dep = if self.memory.is_some() {
282 "gemini-memory-rs = \"0.8\"\n"
283 } else {
284 ""
285 };
286 format!(
287 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
288 [dependencies]\ngemini-adk-fluent-rs = {{ version = \"0.8\"{features} }}\n\
289 {memory_dep}tokio = {{ version = \"1\", features = [\"full\"] }}\n\
290 serde_json = \"1\"\n"
291 )
292 }
293}
294
295fn gen_tool(tool: &ToolSpec) -> String {
297 let mut out = String::new();
298 let description = if tool.description.is_empty() {
299 format!("Tool '{}'", tool.name)
300 } else {
301 tool.description.clone()
302 };
303 let params = match &tool.parameters {
304 Some(schema) => format!("Some(json!({}))", compact(schema)),
305 None => "None".to_string(),
306 };
307 let writes_state = !tool.set_state.is_empty();
308 if writes_state {
309 out.push_str(" {\n let state = state.clone();\n");
310 } else {
311 out.push_str(" {\n");
312 }
313 let _ = writeln!(
314 out,
315 " tools.register(SimpleTool::new(\n {},\n {},\n {params},",
316 rust_str(&tool.name),
317 rust_str(&description),
318 );
319 if writes_state {
320 out.push_str(
321 " move |_args| {\n let state = state.clone();\n async move {\n",
322 );
323 } else {
324 out.push_str(" move |_args| async move {\n");
325 }
326 if let Some(http) = &tool.http {
327 let _ = writeln!(
328 out,
329 " // HTTP binding: {} {} — replace with your client of choice.",
330 http.method, http.url
331 );
332 }
333 for (key, value) in &tool.set_state {
334 let _ = writeln!(
335 out,
336 " let _ = state.set({}, json!({}));",
337 rust_str(key),
338 compact(value)
339 );
340 }
341 let response = tool
342 .response
343 .clone()
344 .unwrap_or_else(|| serde_json::json!({"ok": true}));
345 if writes_state {
346 let _ = writeln!(out, " Ok(json!({}))", compact(&response));
347 out.push_str(" }\n },\n ));\n }\n");
348 } else {
349 let _ = writeln!(out, " Ok(json!({}))", compact(&response));
350 out.push_str(" },\n ));\n }\n");
351 }
352 out
353}
354
355fn gen_flow(flow: &Flow) -> String {
357 let mut out = String::from(" let flow = Flow::new()\n");
358 for step in &flow.steps {
359 let _ = writeln!(out, " .step({})", rust_str(&step.id));
360 for dep in &step.after {
361 match &dep.when {
362 Some(when) => {
363 let _ = writeln!(
364 out,
365 " .after_when({}, {})",
366 rust_str(&dep.step),
367 gen_guard(when)
368 );
369 }
370 None => {
371 let _ = writeln!(out, " .after({})", rust_str(&dep.step));
372 }
373 }
374 }
375 if step.join == gemini_adk_rs::flow::Join::Any {
376 out.push_str(" .join_any()\n");
377 }
378 if let Some(posture) = &step.posture {
379 let _ = writeln!(out, " .posture({})", rust_str(posture));
380 }
381 if let Some(ground) = &step.ground {
382 let _ = writeln!(out, " .ground({})", rust_str(ground));
383 }
384 if !step.allow.is_empty() {
385 let _ = writeln!(out, " .allow({})", str_array(&step.allow));
386 }
387 if !step.deny.is_empty() {
388 let _ = writeln!(out, " .deny({})", str_array(&step.deny));
389 }
390 if let Some(gate) = &step.gate {
391 let _ = writeln!(out, " .gate({})", gen_guard(gate));
392 }
393 if step.terminal {
394 out.push_str(" .terminal()\n");
395 }
396 if let Some(done) = &step.done {
397 let _ = writeln!(out, " .done({})", gen_guard(done));
398 }
399 }
400 for constraint in &flow.constraints {
401 match constraint {
402 Constraint::Once(tool) => {
403 let _ = writeln!(out, " .once({})", rust_str(tool));
404 }
405 Constraint::Before(a, b) => {
406 let _ = writeln!(out, " .before({}, {})", rust_str(a), rust_str(b));
407 }
408 Constraint::NeverUntil { tool, until } => {
409 let _ = writeln!(
410 out,
411 " .never({}).until({})",
412 rust_str(tool),
413 gen_guard(until)
414 );
415 }
416 Constraint::Require(steps) => {
417 let _ = writeln!(out, " .require({})", str_array(steps));
418 }
419 Constraint::Reset { steps, when } => {
420 let _ = writeln!(
421 out,
422 " .reset({}).when({})",
423 str_array(steps),
424 gen_guard(when)
425 );
426 }
427 }
428 }
429 if !flow.ambient.is_empty() {
430 let _ = writeln!(out, " .ambient({})", str_array(&flow.ambient));
431 }
432 for tool in &flow.confirm_tools {
433 let _ = writeln!(
434 out,
435 " // confirm-gated: {tool} (see Per-Tool Policies)"
436 );
437 }
438 out.push_str(" .build()\n .expect(\"validated in the Flow Studio\");\n");
439 out
440}
441
442fn gen_guard(guard: &Guard) -> String {
444 match guard {
445 Guard::Spec(pred) => gen_pred(pred),
446 Guard::Custom(_) => "Guard::custom(|_| todo!(\"custom guard\"))".to_string(),
447 }
448}
449
450fn gen_pred(pred: &Pred) -> String {
451 match pred {
452 Pred::Always => "Guard::always()".to_string(),
453 Pred::IsTrue(key) => format!("Guard::is_true({})", rust_str(key)),
454 Pred::IsSet(key) => format!("Guard::is_set({})", rust_str(key)),
455 Pred::Eq(key, value) => format!("Guard::eq({}, json!({}))", rust_str(key), compact(value)),
456 Pred::Captured(fields) => format!("Guard::captured({})", str_array(fields)),
457 Pred::CalledOk(tool) => format!("Guard::called_ok({})", rust_str(tool)),
458 Pred::Done(step) => format!("Guard::done({})", rust_str(step)),
459 Pred::All(preds) => format!(
460 "Guard::all([{}])",
461 preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
462 ),
463 Pred::Any(preds) => format!(
464 "Guard::any([{}])",
465 preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
466 ),
467 Pred::Not(pred) => format!("Guard::not({})", gen_pred(pred)),
468 }
469}
470
471fn gen_extract(extract: &super::ExtractSpec) -> String {
472 let mut out = String::new();
473 out.push_str(" .extractor(Arc::new(\n");
474 let _ = writeln!(
475 out,
476 " LlmExtractor::new(\n {}.to_string(),\n \
477 Arc::new(GeminiLlm::new(Default::default())),\n {}.to_string(),\n {},\n )",
478 rust_str(&extract.name),
479 rust_str(&extract.instruction),
480 extract.window
481 );
482 let _ = writeln!(
483 out,
484 " .with_schema(json!({}))",
485 compact(&extract.schema)
486 );
487 let trigger = match extract.trigger {
488 TriggerSpec::EveryTurn => "EveryTurn",
489 TriggerSpec::AfterToolCall => "AfterToolCall",
490 TriggerSpec::OnGenerationComplete => "OnGenerationComplete",
491 TriggerSpec::OnPhaseChange => "OnPhaseChange",
492 };
493 let _ = writeln!(
494 out,
495 " .with_trigger(ExtractionTrigger::{trigger})"
496 );
497 if !extract.promote.is_empty() {
498 out.push_str(" .with_promotions(vec![\n");
499 for promote in &extract.promote {
500 let ctor = match promote.policy {
501 PromotePolicy::KeepKnown => "keep_known",
502 PromotePolicy::Overwrite => "overwrite",
503 PromotePolicy::TrueOnly => "true_only",
504 PromotePolicy::NonEmpty => "non_empty",
505 };
506 let mut rule = format!("FieldPromotion::{ctor}({})", rust_str(&promote.field));
507 if let Some(to) = &promote.to {
508 let _ = write!(rule, ".to({})", rust_str(to));
509 }
510 let _ = writeln!(out, " {rule},");
511 }
512 out.push_str(" ])\n");
513 }
514 out.push_str(" ))\n");
515 out
516}
517
518fn gen_phase(phase: &super::PhaseSpec) -> String {
519 let mut out = String::new();
520 let _ = writeln!(out, " .phase({})", rust_str(&phase.name));
521 if let Some(instruction) = &phase.instruction {
522 let _ = writeln!(out, " .instruction({})", rust_str(instruction));
523 }
524 if !phase.tools.is_empty() {
525 let owned: Vec<String> = phase
526 .tools
527 .iter()
528 .map(|t| format!("{}.into()", rust_str(t)))
529 .collect();
530 let _ = writeln!(out, " .tools(vec![{}])", owned.join(", "));
531 }
532 if !phase.needs.is_empty() {
533 let _ = writeln!(out, " .needs(&{})", str_slice(&phase.needs));
534 }
535 if phase.prompt_on_enter == Some(true) {
536 out.push_str(" .prompt_on_enter()\n");
537 }
538 for transition in &phase.transitions {
539 let guard = gen_guard(&transition.when);
540 let _ = writeln!(
541 out,
542 " .transition({}, move |s: &State| {guard}.eval_state(s))",
543 rust_str(&transition.to)
544 );
545 }
546 if !phase.on_enter.is_empty() {
547 out.push_str(" .on_enter(move |_state, _writer| async move {\n");
548 out.push_str(&gen_effects(&phase.on_enter, " "));
549 out.push_str(" })\n");
550 }
551 if phase.terminal {
552 out.push_str(" .terminal()\n");
553 }
554 out.push_str(" .done()\n");
555 out
556}
557
558fn gen_pattern(pattern: &super::PatternSpec) -> String {
559 let mut out = String::new();
560 let guard = gen_guard(&pattern.when);
561 let (method, arg) = match (pattern.sustained_secs, pattern.turns) {
562 (Some(secs), _) => (
563 "when_sustained",
564 format!("std::time::Duration::from_secs({secs})"),
565 ),
566 (_, Some(turns)) => ("when_turns", turns.to_string()),
567 _ => ("when_turns", "1".to_string()),
568 };
569 let _ = writeln!(
570 out,
571 " .{method}({}, move |s: &State| {guard}.eval_state(s), {arg},",
572 rust_str(&pattern.name)
573 );
574 out.push_str(" move |_state, _writer| async move {\n");
575 out.push_str(&gen_effects(&pattern.effects, " "));
576 out.push_str(" })\n");
577 out
578}
579
580fn gen_effects(effects: &[EffectSpec], indent: &str) -> String {
585 let mut out = String::new();
586 for effect in effects {
587 match effect {
588 EffectSpec::Set(map) => {
589 for (key, value) in map {
590 let _ = writeln!(
591 out,
592 "{indent}let _ = _state.set({}, json!({}));",
593 rust_str(key),
594 compact(value)
595 );
596 }
597 }
598 EffectSpec::Context(text) => {
599 let _ = writeln!(
600 out,
601 "{indent}let _ = _writer.send_client_content(vec![Content::model({})], false).await;",
602 rust_str(text)
603 );
604 }
605 EffectSpec::Prompt(text) => {
606 let _ = writeln!(
607 out,
608 "{indent}let _ = _writer.send_client_content(vec![Content::model({})], true).await;",
609 rust_str(text)
610 );
611 }
612 EffectSpec::Remember(note) => {
613 let _ = writeln!(
614 out,
615 "{indent}// remember (durable): {} — route through your memory engine\n\
616 {indent}// (`MemorySession::apply_explicit_command`, see gemini-memory-rs).",
617 rust_str(note)
618 );
619 }
620 }
621 }
622 out
623}
624
625fn gen_computed(computed: &super::ComputedSpec) -> String {
628 let mut out = String::new();
629 let expr_json = serde_json::to_value(&computed.from).unwrap_or(Value::Null);
630 let deps: Vec<String> = computed.from.keys_read().into_iter().collect();
631 let _ = writeln!(
632 out,
633 " .computed({}, &{}, {{",
634 rust_str(&computed.key),
635 str_array(&deps)
636 );
637 let _ = writeln!(
638 out,
639 " let expr: Expr = serde_json::from_value(json!({}))",
640 compact(&expr_json)
641 );
642 out.push_str(" .expect(\"expression validated in the Flow Studio\");\n");
643 out.push_str(" move |s: &State| expr.eval(s)\n })\n");
644 out
645}
646
647fn gen_state_keys(state: &std::collections::BTreeMap<String, super::StateFieldSpec>) -> String {
649 let mut out = String::new();
650 out.push_str("/// Typed keys for the declared state dictionary.\n");
651 out.push_str("#[allow(dead_code)]\nmod keys {\n");
652 out.push_str(" use gemini_adk_fluent_rs::prelude::StateKey;\n\n");
653 for (key, field) in state {
654 let rust_type = match field.kind {
655 Some(StateType::Boolean) => "bool",
656 Some(StateType::Number) => "f64",
657 Some(StateType::String) => "String",
658 _ => "serde_json::Value",
659 };
660 if !field.description.is_empty() {
661 let _ = writeln!(out, " /// {}", field.description);
662 }
663 let _ = writeln!(
664 out,
665 " pub const {}: StateKey<{rust_type}> = StateKey::new({});",
666 const_name(key),
667 rust_str(key)
668 );
669 }
670 out.push_str("}\n");
671 out
672}
673
674fn const_name(key: &str) -> String {
676 let mut out = String::with_capacity(key.len());
677 for c in key.chars() {
678 if c.is_ascii_alphanumeric() {
679 out.push(c.to_ascii_uppercase());
680 } else {
681 out.push('_');
682 }
683 }
684 if out.is_empty() {
685 return "_".to_string();
687 }
688 if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
689 out.insert(0, '_');
690 }
691 out
692}
693
694fn gen_runtime(runtime: &RuntimeSpec) -> String {
696 let mut out = String::new();
697 if let Some(t) = runtime.temperature {
698 let _ = writeln!(out, " .temperature({t:?})");
699 }
700 if let Some(budget) = runtime.thinking_budget {
701 let _ = writeln!(out, " .thinking({budget})");
702 }
703 if runtime.include_thoughts == Some(true) {
704 out.push_str(" .include_thoughts()\n");
705 }
706 if let Some(t) = runtime.transcription {
707 match (t.input, t.output) {
708 (true, true) => out.push_str(" .transcription()\n"),
709 (true, false) => out.push_str(" .input_transcription()\n"),
710 (false, true) => out.push_str(" .output_transcription()\n"),
711 (false, false) => {}
712 }
713 }
714 if runtime.proactive_audio == Some(true) {
715 out.push_str(" .proactive_audio()\n");
716 }
717 if let Some(vad) = &runtime.vad {
718 out.push_str(" .vad(AutomaticActivityDetection {\n");
719 let sens = |s: super::SensitivitySpec| match s {
720 super::SensitivitySpec::Low => "Sensitivity::SensitivityLow",
721 super::SensitivitySpec::Medium => "Sensitivity::SensitivityMedium",
722 super::SensitivitySpec::High => "Sensitivity::SensitivityHigh",
723 };
724 let _ = writeln!(
725 out,
726 " start_of_speech_sensitivity: {},",
727 vad.start_sensitivity
728 .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
729 );
730 let _ = writeln!(
731 out,
732 " end_of_speech_sensitivity: {},",
733 vad.end_sensitivity
734 .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
735 );
736 let _ = writeln!(
737 out,
738 " prefix_padding_ms: {:?},",
739 vad.prefix_padding_ms
740 );
741 let _ = writeln!(
742 out,
743 " silence_duration_ms: {:?},",
744 vad.silence_duration_ms
745 );
746 out.push_str(" disabled: None,\n })\n");
747 }
748 if let Some(audio) = &runtime.audio {
749 if audio.denoise == Some(true) {
750 out.push_str(" .mic_denoise() // feature `denoise`\n");
751 }
752 if let Some(gate) = &audio.noise_gate {
753 let _ = writeln!(
754 out,
755 " .mic_noise_gate({:?}, {})",
756 gate.threshold_rms, gate.hold_frames
757 );
758 }
759 if let Some(vad) = &audio.client_vad {
760 let base = match vad.preset {
761 Some(super::ClientVadPreset::NoisyStreet) => "VadConfig::noisy_street()",
762 _ => "VadConfig::default()",
763 };
764 let mut overrides = String::new();
765 if let Some(v) = vad.start_threshold_db {
766 let _ = write!(overrides, " start_threshold_db: {v:?},");
767 }
768 if let Some(v) = vad.stop_threshold_db {
769 let _ = write!(overrides, " stop_threshold_db: {v:?},");
770 }
771 if let Some(v) = vad.min_speech_frames {
772 let _ = write!(overrides, " min_speech_frames: {v},");
773 }
774 if let Some(v) = vad.hangover_frames {
775 let _ = write!(overrides, " hangover_frames: {v},");
776 }
777 if overrides.is_empty() {
778 let _ = writeln!(out, " .input_vad({base})");
779 } else {
780 let _ = writeln!(
781 out,
782 " .input_vad(VadConfig {{{overrides} ..{base} }})"
783 );
784 }
785 }
786 if audio.authority == Some(super::AuthoritySpec::Client) {
787 out.push_str(" .client_interruption_authority()\n");
788 }
789 if let Some(eot_ms) = audio.eot_hold_ms {
790 let _ = writeln!(out, " .turn_commit_eot_hold_ms({eot_ms})");
791 }
792 if let Some(min_int_ms) = audio.min_interruption_ms {
793 let _ = writeln!(
794 out,
795 " .turn_commit_min_interruption_ms({min_int_ms})"
796 );
797 }
798 }
799 if let Some(ms) = runtime.soft_turn_timeout_ms {
800 let _ = writeln!(
801 out,
802 " .soft_turn_timeout(std::time::Duration::from_millis({ms}))"
803 );
804 }
805 if let Some(steering) = runtime.steering {
806 let variant = match steering {
807 SteeringSpec::InstructionUpdate => "InstructionUpdate",
808 SteeringSpec::ContextInjection => "ContextInjection",
809 SteeringSpec::Hybrid => "Hybrid",
810 };
811 let _ = writeln!(out, " .steering_mode(SteeringMode::{variant})");
812 }
813 if let Some(delivery) = runtime.context_delivery {
814 let variant = match delivery {
815 ContextDeliverySpec::Immediate => "Immediate",
816 ContextDeliverySpec::Deferred => "Deferred",
817 };
818 let _ = writeln!(out, " .context_delivery(ContextDelivery::{variant})");
819 }
820 if let Some(repair) = runtime.repair {
821 let _ = writeln!(
822 out,
823 " .repair(RepairConfig {{ nudge_after: {}, escalate_after: {} }})",
824 repair.nudge_after, repair.escalate_after
825 );
826 }
827 match &runtime.persistence {
828 Some(PersistenceSpec::Fs { dir }) => {
829 let _ = writeln!(
830 out,
831 " .persistence(Arc::new(FsPersistence::new({})))",
832 rust_str(dir)
833 );
834 }
835 Some(PersistenceSpec::Memory) => {
836 out.push_str(" .persistence(Arc::new(MemoryPersistence::new()))\n");
837 }
838 None => {}
839 }
840 if let Some(id) = &runtime.session_id {
841 let _ = writeln!(out, " .session_id({})", rust_str(id));
842 }
843 if runtime.lossy_audio {
844 out.push_str(" .lossy_audio()\n");
845 }
846 if runtime.lossy_transcript {
847 out.push_str(" .lossy_transcript()\n");
848 }
849 out
850}
851
852fn gen_watch(watch: &super::WatchSpec) -> String {
853 let mut out = String::new();
854 let _ = writeln!(out, " .watch({})", rust_str(&watch.key));
855 let condition = match &watch.condition {
856 WatchCondition::Changed => " .changed()".to_string(),
857 WatchCondition::ChangedTo(value) => {
858 format!(" .changed_to(json!({}))", compact(value))
859 }
860 WatchCondition::CrossedAbove(threshold) => {
861 format!(" .crossed_above({threshold:?})")
862 }
863 WatchCondition::CrossedBelow(threshold) => {
864 format!(" .crossed_below({threshold:?})")
865 }
866 WatchCondition::BecameTrue => " .became_true()".to_string(),
867 WatchCondition::BecameFalse => " .became_false()".to_string(),
868 };
869 let _ = writeln!(out, "{condition}");
870 if watch.effects.is_empty() {
871 out.push_str(" .then(move |_old, _new, _state| async move {\n");
872 } else {
873 out.push_str(
874 " .then_with_writer(move |_old, _new, _state, _writer| async move {\n",
875 );
876 }
877 for (key, value) in &watch.set {
878 let _ = writeln!(
879 out,
880 " let _ = _state.set({}, json!({}));",
881 rust_str(key),
882 compact(value)
883 );
884 }
885 out.push_str(&gen_effects(&watch.effects, " "));
886 out.push_str(" })\n");
887 out
888}
889
890fn rust_str(s: &str) -> String {
892 let mut out = String::with_capacity(s.len() + 2);
893 out.push('"');
894 for c in s.chars() {
895 match c {
896 '"' => out.push_str("\\\""),
897 '\\' => out.push_str("\\\\"),
898 '\n' => out.push_str("\\n"),
899 '\t' => out.push_str("\\t"),
900 '\r' => out.push_str("\\r"),
901 c if c.is_control() => {
902 let _ = write!(out, "\\u{{{:x}}}", c as u32);
903 }
904 c => out.push(c),
905 }
906 }
907 out.push('"');
908 out
909}
910
911fn str_array(items: &[String]) -> String {
913 format!(
914 "[{}]",
915 items
916 .iter()
917 .map(|s| rust_str(s))
918 .collect::<Vec<_>>()
919 .join(", ")
920 )
921}
922
923fn str_slice(items: &[String]) -> String {
925 str_array(items)
926}
927
928fn compact(value: &Value) -> String {
930 serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
931}
932
933#[cfg(test)]
934mod tests {
935 use super::super::SessionSpec;
936 use serde_json::json;
937
938 fn spec() -> SessionSpec {
939 SessionSpec::from_value(json!({
940 "name": "collections",
941 "instruction": "Collect payments.",
942 "greeting": "Say hello.",
943 "tools": [
944 {"name": "verify_identity", "description": "Verify the caller.",
945 "set_state": {"identity_verified": true}},
946 {"name": "charge_card", "response": {"charged": true}}
947 ],
948 "extract": [{
949 "name": "ptp", "instruction": "Extract the promise to pay.",
950 "schema": {"type": "object"},
951 "promote": [{"field": "ptp_amount"}]
952 }],
953 "phases": [{"name": "greet", "instruction": "Welcome.",
954 "transitions": [{"to": "main", "when": {"is_true": "greeted"}}]},
955 {"name": "main"}],
956 "initial_phase": "greet",
957 "watch": [{"key": "risk", "condition": {"crossed_above": 0.9},
958 "set": {"alert": true}}],
959 "flow": {
960 "steps": [
961 {"id": "verify", "posture": "Verify.", "allow": ["verify_identity"],
962 "done": {"is_true": "identity_verified"}},
963 {"id": "pay", "after": ["verify"],
964 "gate": {"all": [{"captured": ["ptp_amount"]},
965 {"not": {"is_true": "disputed"}}]},
966 "allow": ["charge_card"], "done": {"called_ok": "charge_card"}}
967 ],
968 "constraints": [
969 {"once": "charge_card"},
970 {"never_until": {"tool": "charge_card",
971 "until": {"is_true": "identity_verified"}}},
972 {"require": ["pay"]}
973 ]
974 }
975 }))
976 .expect("parses")
977 }
978
979 #[test]
980 fn generated_main_contains_the_full_chain() {
981 let code = spec().to_rust();
982 for fragment in [
983 "let flow = Flow::new()",
984 ".step(\"verify\")",
985 ".done(Guard::is_true(\"identity_verified\"))",
986 ".gate(Guard::all([Guard::captured([\"ptp_amount\"]), Guard::not(Guard::is_true(\"disputed\"))]))",
987 ".never(\"charge_card\").until(Guard::is_true(\"identity_verified\"))",
988 ".once(\"charge_card\")",
989 ".require([\"pay\"])",
990 "tools.register(SimpleTool::new(",
991 "state.set(\"identity_verified\", json!(true))",
992 "LlmExtractor::new(",
993 "FieldPromotion::keep_known(\"ptp_amount\")",
994 ".phase(\"greet\")",
995 ".transition(\"main\", move |s: &State| Guard::is_true(\"greeted\").eval_state(s))",
996 ".watch(\"risk\")",
997 ".crossed_above(0.9)",
998 ".connect_from_env()",
999 "session.send_text(line.trim()).await?",
1000 ] {
1001 assert!(
1002 code.contains(fragment),
1003 "missing fragment: {fragment}\n---\n{code}"
1004 );
1005 }
1006 }
1007
1008 #[test]
1009 fn audio_specs_generate_talk_and_voice_feature() {
1010 let mut audio = spec();
1011 audio.modality = super::super::SpecModality::Audio;
1012 audio.voice = Some("Kore".into());
1013 let code = audio.to_rust();
1014 assert!(code.contains(".voice(Voice::Kore)"));
1015 assert!(code.contains("session.talk().await?"));
1016 assert!(audio.to_cargo_toml().contains("voice-io"));
1017 }
1018
1019 #[test]
1020 fn turn_commit_tuning_knobs_generate_builder_calls() {
1021 let spec = SessionSpec::from_value(json!({
1022 "name": "tuned",
1023 "instruction": "Talk to me.",
1024 "runtime": {
1025 "audio": {
1026 "eot_hold_ms": 800,
1027 "min_interruption_ms": 1400
1028 }
1029 }
1030 }))
1031 .expect("parses");
1032 let code = spec.to_rust();
1033 assert!(
1034 code.contains(".turn_commit_eot_hold_ms(800)"),
1035 "missing eot_hold_ms in codegen:\n{code}"
1036 );
1037 assert!(
1038 code.contains(".turn_commit_min_interruption_ms(1400)"),
1039 "missing min_interruption_ms in codegen:\n{code}"
1040 );
1041 }
1042
1043 #[test]
1044 fn string_escaping_is_sound() {
1045 assert_eq!(super::rust_str("say \"hi\"\n"), "\"say \\\"hi\\\"\\n\"");
1046 }
1047
1048 #[test]
1049 fn new_sections_generate_their_wiring() {
1050 let spec = SessionSpec::from_value(json!({
1051 "name": "concierge",
1052 "instruction": "Help the guest.",
1053 "state": {
1054 "verified": {"type": "boolean", "default": false,
1055 "description": "Identity has been checked."},
1056 "session:turn_count": {"type": "number"}
1057 },
1058 "computed": [{"key": "high_risk",
1059 "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
1060 "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
1061 "tools": [
1062 {"name": "record_score", "set_state": {"score": 0.9}},
1063 {"name": "log_event", "scheduling": "silent"}
1064 ],
1065 "runtime": {
1066 "temperature": 0.4,
1067 "soft_turn_timeout_ms": 1500,
1068 "steering": "context_injection",
1069 "repair": {"nudge_after": 2, "escalate_after": 5},
1070 "persistence": {"fs": {"dir": "/tmp/sessions"}},
1071 "session_id": "guest-1"
1072 },
1073 "patterns": [{"name": "stuck", "when": {"is_true": "confused"}, "turns": 3,
1074 "effects": [{"prompt": "Offer to help."},
1075 {"remember": "guest got stuck"}]}],
1076 "flow": {"steps": [
1077 {"id": "assess", "posture": "Assess.",
1078 "allow": ["record_score", "log_event"],
1079 "done": {"is_true": "high_risk"}}
1080 ]}
1081 }))
1082 .expect("parses");
1083 let code = spec.to_rust();
1084 for fragment in [
1085 "mod keys {",
1086 "pub const VERIFIED: StateKey<bool> = StateKey::new(\"verified\");",
1087 "pub const SESSION_TURN_COUNT: StateKey<f64> = StateKey::new(\"session:turn_count\");",
1088 "let _ = state.set(\"verified\", json!(false));",
1089 ".computed(\"high_risk\", &[\"score\"], {",
1090 "serde_json::from_value(json!({\"gt\":[{\"key\":\"score\"},{\"const\":0.5}]}))",
1091 "MemoryEngine::in_memory(UserId::new(\"local-user\"))",
1092 ".with_memory_slots(memory.clone(), [MemorySlot::new(\"dietary_identity\", \"user:diet\")])",
1093 ".tool_background_with_scheduling(\"log_event\", FunctionResponseScheduling::Silent)",
1094 ".temperature(0.4)",
1095 ".soft_turn_timeout(std::time::Duration::from_millis(1500))",
1096 ".steering_mode(SteeringMode::ContextInjection)",
1097 ".repair(RepairConfig { nudge_after: 2, escalate_after: 5 })",
1098 ".persistence(Arc::new(FsPersistence::new(\"/tmp/sessions\")))",
1099 ".session_id(\"guest-1\")",
1100 "send_client_content(vec![Content::model(\"Offer to help.\")], true)",
1101 "// remember (durable): \"guest got stuck\"",
1102 ] {
1103 assert!(
1104 code.contains(fragment),
1105 "missing fragment: {fragment}\n---\n{code}"
1106 );
1107 }
1108 assert!(spec.to_cargo_toml().contains("gemini-memory-rs"));
1109 }
1110
1111 #[test]
1112 fn empty_state_key_name_generates_valid_const_name() {
1113 let key = "";
1115 let name = super::const_name(key);
1116 assert!(
1119 !name.is_empty(),
1120 "const_name produced empty string for empty key"
1121 );
1122 assert!(
1123 name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
1124 "const_name produced invalid Rust identifier: {name}"
1125 );
1126 }
1127
1128 #[test]
1129 fn state_key_name_starting_with_digit_is_escaped() {
1130 let name = super::const_name("123abc");
1131 assert!(
1132 name.starts_with('_'),
1133 "const_name should prepend _ to digit-starting keys, got {name}"
1134 );
1135 assert_eq!(name, "_123ABC");
1136 }
1137}