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 version = env!("CARGO_PKG_VERSION");
282 let memory_dep = if self.memory.is_some() {
283 format!("gemini-memory-rs = \"{version}\"\n")
284 } else {
285 String::new()
286 };
287 format!(
288 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
289 [dependencies]\ngemini-adk-fluent-rs = {{ version = \"{version}\"{features} }}\n\
290 {memory_dep}tokio = {{ version = \"1\", features = [\"full\"] }}\n\
291 serde_json = \"1\"\n"
292 )
293 }
294}
295
296fn gen_tool(tool: &ToolSpec) -> String {
298 let mut out = String::new();
299 let description = if tool.description.is_empty() {
300 format!("Tool '{}'", tool.name)
301 } else {
302 tool.description.clone()
303 };
304 let params = match &tool.parameters {
305 Some(schema) => format!("Some(json!({}))", compact(schema)),
306 None => "None".to_string(),
307 };
308 let writes_state = !tool.set_state.is_empty();
309 if writes_state {
310 out.push_str(" {\n let state = state.clone();\n");
311 } else {
312 out.push_str(" {\n");
313 }
314 let _ = writeln!(
315 out,
316 " tools.register(SimpleTool::new(\n {},\n {},\n {params},",
317 rust_str(&tool.name),
318 rust_str(&description),
319 );
320 if writes_state {
321 out.push_str(
322 " move |_args| {\n let state = state.clone();\n async move {\n",
323 );
324 } else {
325 out.push_str(" move |_args| async move {\n");
326 }
327 if let Some(http) = &tool.http {
328 let _ = writeln!(
329 out,
330 " // HTTP binding: {} {} — replace with your client of choice.",
331 http.method, http.url
332 );
333 }
334 for (key, value) in &tool.set_state {
335 let _ = writeln!(
336 out,
337 " let _ = state.set({}, json!({}));",
338 rust_str(key),
339 compact(value)
340 );
341 }
342 let response = tool
343 .response
344 .clone()
345 .unwrap_or_else(|| serde_json::json!({"ok": true}));
346 if writes_state {
347 let _ = writeln!(out, " Ok(json!({}))", compact(&response));
348 out.push_str(" }\n },\n ));\n }\n");
349 } else {
350 let _ = writeln!(out, " Ok(json!({}))", compact(&response));
351 out.push_str(" },\n ));\n }\n");
352 }
353 out
354}
355
356fn gen_flow(flow: &Flow) -> String {
358 let mut out = String::from(" let flow = Flow::new()\n");
359 for step in &flow.steps {
360 let _ = writeln!(out, " .step({})", rust_str(&step.id));
361 for dep in &step.after {
362 match &dep.when {
363 Some(when) => {
364 let _ = writeln!(
365 out,
366 " .after_when({}, {})",
367 rust_str(&dep.step),
368 gen_guard(when)
369 );
370 }
371 None => {
372 let _ = writeln!(out, " .after({})", rust_str(&dep.step));
373 }
374 }
375 }
376 if step.join == gemini_adk_rs::flow::Join::Any {
377 out.push_str(" .join_any()\n");
378 }
379 if let Some(posture) = &step.posture {
380 let _ = writeln!(out, " .posture({})", rust_str(posture));
381 }
382 if let Some(ground) = &step.ground {
383 let _ = writeln!(out, " .ground({})", rust_str(ground));
384 }
385 if !step.allow.is_empty() {
386 let _ = writeln!(out, " .allow({})", str_array(&step.allow));
387 }
388 if !step.deny.is_empty() {
389 let _ = writeln!(out, " .deny({})", str_array(&step.deny));
390 }
391 if let Some(gate) = &step.gate {
392 let _ = writeln!(out, " .gate({})", gen_guard(gate));
393 }
394 if step.terminal {
395 out.push_str(" .terminal()\n");
396 }
397 if let Some(done) = &step.done {
398 let _ = writeln!(out, " .done({})", gen_guard(done));
399 }
400 }
401 for constraint in &flow.constraints {
402 match constraint {
403 Constraint::Once(tool) => {
404 let _ = writeln!(out, " .once({})", rust_str(tool));
405 }
406 Constraint::Before(a, b) => {
407 let _ = writeln!(out, " .before({}, {})", rust_str(a), rust_str(b));
408 }
409 Constraint::NeverUntil { tool, until } => {
410 let _ = writeln!(
411 out,
412 " .never({}).until({})",
413 rust_str(tool),
414 gen_guard(until)
415 );
416 }
417 Constraint::Require(steps) => {
418 let _ = writeln!(out, " .require({})", str_array(steps));
419 }
420 Constraint::Reset { steps, when } => {
421 let _ = writeln!(
422 out,
423 " .reset({}).when({})",
424 str_array(steps),
425 gen_guard(when)
426 );
427 }
428 }
429 }
430 if !flow.ambient.is_empty() {
431 let _ = writeln!(out, " .ambient({})", str_array(&flow.ambient));
432 }
433 for tool in &flow.confirm_tools {
434 let _ = writeln!(
435 out,
436 " // confirm-gated: {tool} (see Per-Tool Policies)"
437 );
438 }
439 out.push_str(" .build()\n .expect(\"validated in the Flow Studio\");\n");
440 out
441}
442
443fn gen_guard(guard: &Guard) -> String {
445 match guard {
446 Guard::Spec(pred) => gen_pred(pred),
447 Guard::Custom(_) => "Guard::custom(|_| todo!(\"custom guard\"))".to_string(),
448 }
449}
450
451fn gen_pred(pred: &Pred) -> String {
452 match pred {
453 Pred::Always => "Guard::always()".to_string(),
454 Pred::IsTrue(key) => format!("Guard::is_true({})", rust_str(key)),
455 Pred::IsSet(key) => format!("Guard::is_set({})", rust_str(key)),
456 Pred::Eq(key, value) => format!("Guard::eq({}, json!({}))", rust_str(key), compact(value)),
457 Pred::Captured(fields) => format!("Guard::captured({})", str_array(fields)),
458 Pred::CalledOk(tool) => format!("Guard::called_ok({})", rust_str(tool)),
459 Pred::Done(step) => format!("Guard::done({})", rust_str(step)),
460 Pred::All(preds) => format!(
461 "Guard::all([{}])",
462 preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
463 ),
464 Pred::Any(preds) => format!(
465 "Guard::any([{}])",
466 preds.iter().map(gen_pred).collect::<Vec<_>>().join(", ")
467 ),
468 Pred::Not(pred) => format!("Guard::not({})", gen_pred(pred)),
469 }
470}
471
472fn gen_extract(extract: &super::ExtractSpec) -> String {
473 let mut out = String::new();
474 out.push_str(" .extractor(Arc::new(\n");
475 let _ = writeln!(
476 out,
477 " LlmExtractor::new(\n {}.to_string(),\n \
478 Arc::new(GeminiLlm::new(Default::default())),\n {}.to_string(),\n {},\n )",
479 rust_str(&extract.name),
480 rust_str(&extract.instruction),
481 extract.window
482 );
483 let _ = writeln!(
484 out,
485 " .with_schema(json!({}))",
486 compact(&extract.schema)
487 );
488 let trigger = match extract.trigger {
489 TriggerSpec::EveryTurn => "EveryTurn",
490 TriggerSpec::AfterToolCall => "AfterToolCall",
491 TriggerSpec::OnGenerationComplete => "OnGenerationComplete",
492 TriggerSpec::OnPhaseChange => "OnPhaseChange",
493 };
494 let _ = writeln!(
495 out,
496 " .with_trigger(ExtractionTrigger::{trigger})"
497 );
498 if !extract.promote.is_empty() {
499 out.push_str(" .with_promotions(vec![\n");
500 for promote in &extract.promote {
501 let ctor = match promote.policy {
502 PromotePolicy::KeepKnown => "keep_known",
503 PromotePolicy::Overwrite => "overwrite",
504 PromotePolicy::TrueOnly => "true_only",
505 PromotePolicy::NonEmpty => "non_empty",
506 };
507 let mut rule = format!("FieldPromotion::{ctor}({})", rust_str(&promote.field));
508 if let Some(to) = &promote.to {
509 let _ = write!(rule, ".to({})", rust_str(to));
510 }
511 let _ = writeln!(out, " {rule},");
512 }
513 out.push_str(" ])\n");
514 }
515 out.push_str(" ))\n");
516 out
517}
518
519fn gen_phase(phase: &super::PhaseSpec) -> String {
520 let mut out = String::new();
521 let _ = writeln!(out, " .phase({})", rust_str(&phase.name));
522 if let Some(instruction) = &phase.instruction {
523 let _ = writeln!(out, " .instruction({})", rust_str(instruction));
524 }
525 if !phase.tools.is_empty() {
526 let owned: Vec<String> = phase
527 .tools
528 .iter()
529 .map(|t| format!("{}.into()", rust_str(t)))
530 .collect();
531 let _ = writeln!(out, " .tools(vec![{}])", owned.join(", "));
532 }
533 if !phase.needs.is_empty() {
534 let _ = writeln!(out, " .needs(&{})", str_slice(&phase.needs));
535 }
536 if phase.prompt_on_enter == Some(true) {
537 out.push_str(" .prompt_on_enter()\n");
538 }
539 for transition in &phase.transitions {
540 let guard = gen_guard(&transition.when);
541 let _ = writeln!(
542 out,
543 " .transition({}, move |s: &State| {guard}.eval_state(s))",
544 rust_str(&transition.to)
545 );
546 }
547 if !phase.on_enter.is_empty() {
548 out.push_str(" .on_enter(move |_state, _writer| async move {\n");
549 out.push_str(&gen_effects(&phase.on_enter, " "));
550 out.push_str(" })\n");
551 }
552 if phase.terminal {
553 out.push_str(" .terminal()\n");
554 }
555 out.push_str(" .done()\n");
556 out
557}
558
559fn gen_pattern(pattern: &super::PatternSpec) -> String {
560 let mut out = String::new();
561 let guard = gen_guard(&pattern.when);
562 let (method, arg) = match (pattern.sustained_secs, pattern.turns) {
563 (Some(secs), _) => (
564 "when_sustained",
565 format!("std::time::Duration::from_secs({secs})"),
566 ),
567 (_, Some(turns)) => ("when_turns", turns.to_string()),
568 _ => ("when_turns", "1".to_string()),
569 };
570 let _ = writeln!(
571 out,
572 " .{method}({}, move |s: &State| {guard}.eval_state(s), {arg},",
573 rust_str(&pattern.name)
574 );
575 out.push_str(" move |_state, _writer| async move {\n");
576 out.push_str(&gen_effects(&pattern.effects, " "));
577 out.push_str(" })\n");
578 out
579}
580
581fn gen_effects(effects: &[EffectSpec], indent: &str) -> String {
586 let mut out = String::new();
587 for effect in effects {
588 match effect {
589 EffectSpec::Set(map) => {
590 for (key, value) in map {
591 let _ = writeln!(
592 out,
593 "{indent}let _ = _state.set({}, json!({}));",
594 rust_str(key),
595 compact(value)
596 );
597 }
598 }
599 EffectSpec::Context(text) => {
600 let _ = writeln!(
601 out,
602 "{indent}let _ = _writer.send_client_content(vec![Content::model({})], false).await;",
603 rust_str(text)
604 );
605 }
606 EffectSpec::Prompt(text) => {
607 let _ = writeln!(
608 out,
609 "{indent}let _ = _writer.send_client_content(vec![Content::model({})], true).await;",
610 rust_str(text)
611 );
612 }
613 EffectSpec::Remember(note) => {
614 let _ = writeln!(
615 out,
616 "{indent}// remember (durable): {} — route through your memory engine\n\
617 {indent}// (`MemorySession::apply_explicit_command`, see gemini-memory-rs).",
618 rust_str(note)
619 );
620 }
621 }
622 }
623 out
624}
625
626fn gen_computed(computed: &super::ComputedSpec) -> String {
629 let mut out = String::new();
630 let expr_json = serde_json::to_value(&computed.from).unwrap_or(Value::Null);
631 let deps: Vec<String> = computed.from.keys_read().into_iter().collect();
632 let _ = writeln!(
633 out,
634 " .computed({}, &{}, {{",
635 rust_str(&computed.key),
636 str_array(&deps)
637 );
638 let _ = writeln!(
639 out,
640 " let expr: Expr = serde_json::from_value(json!({}))",
641 compact(&expr_json)
642 );
643 out.push_str(" .expect(\"expression validated in the Flow Studio\");\n");
644 out.push_str(" move |s: &State| expr.eval(s)\n })\n");
645 out
646}
647
648fn gen_state_keys(state: &std::collections::BTreeMap<String, super::StateFieldSpec>) -> String {
650 let mut out = String::new();
651 out.push_str("/// Typed keys for the declared state dictionary.\n");
652 out.push_str("#[allow(dead_code)]\nmod keys {\n");
653 out.push_str(" use gemini_adk_fluent_rs::prelude::StateKey;\n\n");
654 for (key, field) in state {
655 let rust_type = match field.kind {
656 Some(StateType::Boolean) => "bool",
657 Some(StateType::Number) => "f64",
658 Some(StateType::String) => "String",
659 _ => "serde_json::Value",
660 };
661 if !field.description.is_empty() {
662 let _ = writeln!(out, " /// {}", field.description);
663 }
664 let _ = writeln!(
665 out,
666 " pub const {}: StateKey<{rust_type}> = StateKey::new({});",
667 const_name(key),
668 rust_str(key)
669 );
670 }
671 out.push_str("}\n");
672 out
673}
674
675fn const_name(key: &str) -> String {
677 let mut out = String::with_capacity(key.len());
678 for c in key.chars() {
679 if c.is_ascii_alphanumeric() {
680 out.push(c.to_ascii_uppercase());
681 } else {
682 out.push('_');
683 }
684 }
685 if out.is_empty() {
686 return "_".to_string();
688 }
689 if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
690 out.insert(0, '_');
691 }
692 out
693}
694
695fn gen_runtime(runtime: &RuntimeSpec) -> String {
697 let mut out = String::new();
698 if let Some(t) = runtime.temperature {
699 let _ = writeln!(out, " .temperature({t:?})");
700 }
701 if let Some(budget) = runtime.thinking_budget {
702 let _ = writeln!(out, " .thinking({budget})");
703 }
704 if runtime.include_thoughts == Some(true) {
705 out.push_str(" .include_thoughts()\n");
706 }
707 if let Some(t) = runtime.transcription {
708 match (t.input, t.output) {
709 (true, true) => out.push_str(" .transcription()\n"),
710 (true, false) => out.push_str(" .input_transcription()\n"),
711 (false, true) => out.push_str(" .output_transcription()\n"),
712 (false, false) => {}
713 }
714 }
715 if runtime.proactive_audio == Some(true) {
716 out.push_str(" .proactive_audio()\n");
717 }
718 if let Some(vad) = &runtime.vad {
719 out.push_str(" .vad(AutomaticActivityDetection {\n");
720 let sens = |s: super::SensitivitySpec| match s {
721 super::SensitivitySpec::Low => "Sensitivity::SensitivityLow",
722 super::SensitivitySpec::Medium => "Sensitivity::SensitivityMedium",
723 super::SensitivitySpec::High => "Sensitivity::SensitivityHigh",
724 };
725 let _ = writeln!(
726 out,
727 " start_of_speech_sensitivity: {},",
728 vad.start_sensitivity
729 .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
730 );
731 let _ = writeln!(
732 out,
733 " end_of_speech_sensitivity: {},",
734 vad.end_sensitivity
735 .map_or("None".to_string(), |s| format!("Some({})", sens(s)))
736 );
737 let _ = writeln!(
738 out,
739 " prefix_padding_ms: {:?},",
740 vad.prefix_padding_ms
741 );
742 let _ = writeln!(
743 out,
744 " silence_duration_ms: {:?},",
745 vad.silence_duration_ms
746 );
747 out.push_str(" disabled: None,\n })\n");
748 }
749 if let Some(audio) = &runtime.audio {
750 if audio.denoise == Some(true) {
751 out.push_str(" .mic_denoise() // feature `denoise`\n");
752 }
753 if let Some(gate) = &audio.noise_gate {
754 let _ = writeln!(
755 out,
756 " .mic_noise_gate({:?}, {})",
757 gate.threshold_rms, gate.hold_frames
758 );
759 }
760 if let Some(vad) = &audio.client_vad {
761 let base = match vad.preset {
762 Some(super::ClientVadPreset::NoisyStreet) => "VadConfig::noisy_street()",
763 _ => "VadConfig::default()",
764 };
765 let mut overrides = String::new();
766 if let Some(v) = vad.start_threshold_db {
767 let _ = write!(overrides, " start_threshold_db: {v:?},");
768 }
769 if let Some(v) = vad.stop_threshold_db {
770 let _ = write!(overrides, " stop_threshold_db: {v:?},");
771 }
772 if let Some(v) = vad.min_speech_frames {
773 let _ = write!(overrides, " min_speech_frames: {v},");
774 }
775 if let Some(v) = vad.hangover_frames {
776 let _ = write!(overrides, " hangover_frames: {v},");
777 }
778 if overrides.is_empty() {
779 let _ = writeln!(out, " .input_vad({base})");
780 } else {
781 let _ = writeln!(
782 out,
783 " .input_vad(VadConfig {{{overrides} ..{base} }})"
784 );
785 }
786 }
787 if audio.authority == Some(super::AuthoritySpec::Client) {
788 out.push_str(" .client_interruption_authority()\n");
789 }
790 if let Some(eot_ms) = audio.eot_hold_ms {
791 let _ = writeln!(out, " .turn_commit_eot_hold_ms({eot_ms})");
792 }
793 if let Some(min_int_ms) = audio.min_interruption_ms {
794 let _ = writeln!(
795 out,
796 " .turn_commit_min_interruption_ms({min_int_ms})"
797 );
798 }
799 }
800 if let Some(ms) = runtime.soft_turn_timeout_ms {
801 let _ = writeln!(
802 out,
803 " .soft_turn_timeout(std::time::Duration::from_millis({ms}))"
804 );
805 }
806 if let Some(steering) = runtime.steering {
807 let variant = match steering {
808 SteeringSpec::InstructionUpdate => "InstructionUpdate",
809 SteeringSpec::ContextInjection => "ContextInjection",
810 SteeringSpec::Hybrid => "Hybrid",
811 };
812 let _ = writeln!(out, " .steering_mode(SteeringMode::{variant})");
813 }
814 if let Some(delivery) = runtime.context_delivery {
815 let variant = match delivery {
816 ContextDeliverySpec::Immediate => "Immediate",
817 ContextDeliverySpec::Deferred => "Deferred",
818 };
819 let _ = writeln!(out, " .context_delivery(ContextDelivery::{variant})");
820 }
821 if let Some(repair) = runtime.repair {
822 let _ = writeln!(
823 out,
824 " .repair(RepairConfig {{ nudge_after: {}, escalate_after: {} }})",
825 repair.nudge_after, repair.escalate_after
826 );
827 }
828 match &runtime.persistence {
829 Some(PersistenceSpec::Fs { dir }) => {
830 let _ = writeln!(
831 out,
832 " .persistence(Arc::new(FsPersistence::new({})))",
833 rust_str(dir)
834 );
835 }
836 Some(PersistenceSpec::Memory) => {
837 out.push_str(" .persistence(Arc::new(MemoryPersistence::new()))\n");
838 }
839 None => {}
840 }
841 if let Some(id) = &runtime.session_id {
842 let _ = writeln!(out, " .session_id({})", rust_str(id));
843 }
844 if runtime.lossy_audio {
845 out.push_str(" .lossy_audio()\n");
846 }
847 if runtime.lossy_transcript {
848 out.push_str(" .lossy_transcript()\n");
849 }
850 out
851}
852
853fn gen_watch(watch: &super::WatchSpec) -> String {
854 let mut out = String::new();
855 let _ = writeln!(out, " .watch({})", rust_str(&watch.key));
856 let condition = match &watch.condition {
857 WatchCondition::Changed => " .changed()".to_string(),
858 WatchCondition::ChangedTo(value) => {
859 format!(" .changed_to(json!({}))", compact(value))
860 }
861 WatchCondition::CrossedAbove(threshold) => {
862 format!(" .crossed_above({threshold:?})")
863 }
864 WatchCondition::CrossedBelow(threshold) => {
865 format!(" .crossed_below({threshold:?})")
866 }
867 WatchCondition::BecameTrue => " .became_true()".to_string(),
868 WatchCondition::BecameFalse => " .became_false()".to_string(),
869 };
870 let _ = writeln!(out, "{condition}");
871 if watch.effects.is_empty() {
872 out.push_str(" .then(move |_old, _new, _state| async move {\n");
873 } else {
874 out.push_str(
875 " .then_with_writer(move |_old, _new, _state, _writer| async move {\n",
876 );
877 }
878 for (key, value) in &watch.set {
879 let _ = writeln!(
880 out,
881 " let _ = _state.set({}, json!({}));",
882 rust_str(key),
883 compact(value)
884 );
885 }
886 out.push_str(&gen_effects(&watch.effects, " "));
887 out.push_str(" })\n");
888 out
889}
890
891fn rust_str(s: &str) -> String {
893 let mut out = String::with_capacity(s.len() + 2);
894 out.push('"');
895 for c in s.chars() {
896 match c {
897 '"' => out.push_str("\\\""),
898 '\\' => out.push_str("\\\\"),
899 '\n' => out.push_str("\\n"),
900 '\t' => out.push_str("\\t"),
901 '\r' => out.push_str("\\r"),
902 c if c.is_control() => {
903 let _ = write!(out, "\\u{{{:x}}}", c as u32);
904 }
905 c => out.push(c),
906 }
907 }
908 out.push('"');
909 out
910}
911
912fn str_array(items: &[String]) -> String {
914 format!(
915 "[{}]",
916 items
917 .iter()
918 .map(|s| rust_str(s))
919 .collect::<Vec<_>>()
920 .join(", ")
921 )
922}
923
924fn str_slice(items: &[String]) -> String {
926 str_array(items)
927}
928
929fn compact(value: &Value) -> String {
931 serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
932}
933
934#[cfg(test)]
935mod tests {
936 use super::super::SessionSpec;
937 use serde_json::json;
938
939 fn spec() -> SessionSpec {
940 SessionSpec::from_value(json!({
941 "name": "collections",
942 "instruction": "Collect payments.",
943 "greeting": "Say hello.",
944 "tools": [
945 {"name": "verify_identity", "description": "Verify the caller.",
946 "set_state": {"identity_verified": true}},
947 {"name": "charge_card", "response": {"charged": true}}
948 ],
949 "extract": [{
950 "name": "ptp", "instruction": "Extract the promise to pay.",
951 "schema": {"type": "object"},
952 "promote": [{"field": "ptp_amount"}]
953 }],
954 "phases": [{"name": "greet", "instruction": "Welcome.",
955 "transitions": [{"to": "main", "when": {"is_true": "greeted"}}]},
956 {"name": "main"}],
957 "initial_phase": "greet",
958 "watch": [{"key": "risk", "condition": {"crossed_above": 0.9},
959 "set": {"alert": true}}],
960 "flow": {
961 "steps": [
962 {"id": "verify", "posture": "Verify.", "allow": ["verify_identity"],
963 "done": {"is_true": "identity_verified"}},
964 {"id": "pay", "after": ["verify"],
965 "gate": {"all": [{"captured": ["ptp_amount"]},
966 {"not": {"is_true": "disputed"}}]},
967 "allow": ["charge_card"], "done": {"called_ok": "charge_card"}}
968 ],
969 "constraints": [
970 {"once": "charge_card"},
971 {"never_until": {"tool": "charge_card",
972 "until": {"is_true": "identity_verified"}}},
973 {"require": ["pay"]}
974 ]
975 }
976 }))
977 .expect("parses")
978 }
979
980 #[test]
981 fn generated_main_contains_the_full_chain() {
982 let code = spec().to_rust();
983 for fragment in [
984 "let flow = Flow::new()",
985 ".step(\"verify\")",
986 ".done(Guard::is_true(\"identity_verified\"))",
987 ".gate(Guard::all([Guard::captured([\"ptp_amount\"]), Guard::not(Guard::is_true(\"disputed\"))]))",
988 ".never(\"charge_card\").until(Guard::is_true(\"identity_verified\"))",
989 ".once(\"charge_card\")",
990 ".require([\"pay\"])",
991 "tools.register(SimpleTool::new(",
992 "state.set(\"identity_verified\", json!(true))",
993 "LlmExtractor::new(",
994 "FieldPromotion::keep_known(\"ptp_amount\")",
995 ".phase(\"greet\")",
996 ".transition(\"main\", move |s: &State| Guard::is_true(\"greeted\").eval_state(s))",
997 ".watch(\"risk\")",
998 ".crossed_above(0.9)",
999 ".connect_from_env()",
1000 "session.send_text(line.trim()).await?",
1001 ] {
1002 assert!(
1003 code.contains(fragment),
1004 "missing fragment: {fragment}\n---\n{code}"
1005 );
1006 }
1007 }
1008
1009 #[test]
1010 fn audio_specs_generate_talk_and_voice_feature() {
1011 let mut audio = spec();
1012 audio.modality = super::super::SpecModality::Audio;
1013 audio.voice = Some("Kore".into());
1014 let code = audio.to_rust();
1015 assert!(code.contains(".voice(Voice::Kore)"));
1016 assert!(code.contains("session.talk().await?"));
1017 assert!(audio.to_cargo_toml().contains("voice-io"));
1018 }
1019
1020 #[test]
1021 fn turn_commit_tuning_knobs_generate_builder_calls() {
1022 let spec = SessionSpec::from_value(json!({
1023 "name": "tuned",
1024 "instruction": "Talk to me.",
1025 "runtime": {
1026 "audio": {
1027 "eot_hold_ms": 800,
1028 "min_interruption_ms": 1400
1029 }
1030 }
1031 }))
1032 .expect("parses");
1033 let code = spec.to_rust();
1034 assert!(
1035 code.contains(".turn_commit_eot_hold_ms(800)"),
1036 "missing eot_hold_ms in codegen:\n{code}"
1037 );
1038 assert!(
1039 code.contains(".turn_commit_min_interruption_ms(1400)"),
1040 "missing min_interruption_ms in codegen:\n{code}"
1041 );
1042 }
1043
1044 #[test]
1045 fn string_escaping_is_sound() {
1046 assert_eq!(super::rust_str("say \"hi\"\n"), "\"say \\\"hi\\\"\\n\"");
1047 }
1048
1049 #[test]
1050 fn new_sections_generate_their_wiring() {
1051 let spec = SessionSpec::from_value(json!({
1052 "name": "concierge",
1053 "instruction": "Help the guest.",
1054 "state": {
1055 "verified": {"type": "boolean", "default": false,
1056 "description": "Identity has been checked."},
1057 "session:turn_count": {"type": "number"}
1058 },
1059 "computed": [{"key": "high_risk",
1060 "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
1061 "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
1062 "tools": [
1063 {"name": "record_score", "set_state": {"score": 0.9}},
1064 {"name": "log_event", "scheduling": "silent"}
1065 ],
1066 "runtime": {
1067 "temperature": 0.4,
1068 "soft_turn_timeout_ms": 1500,
1069 "steering": "context_injection",
1070 "repair": {"nudge_after": 2, "escalate_after": 5},
1071 "persistence": {"fs": {"dir": "/tmp/sessions"}},
1072 "session_id": "guest-1"
1073 },
1074 "patterns": [{"name": "stuck", "when": {"is_true": "confused"}, "turns": 3,
1075 "effects": [{"prompt": "Offer to help."},
1076 {"remember": "guest got stuck"}]}],
1077 "flow": {"steps": [
1078 {"id": "assess", "posture": "Assess.",
1079 "allow": ["record_score", "log_event"],
1080 "done": {"is_true": "high_risk"}}
1081 ]}
1082 }))
1083 .expect("parses");
1084 let code = spec.to_rust();
1085 for fragment in [
1086 "mod keys {",
1087 "pub const VERIFIED: StateKey<bool> = StateKey::new(\"verified\");",
1088 "pub const SESSION_TURN_COUNT: StateKey<f64> = StateKey::new(\"session:turn_count\");",
1089 "let _ = state.set(\"verified\", json!(false));",
1090 ".computed(\"high_risk\", &[\"score\"], {",
1091 "serde_json::from_value(json!({\"gt\":[{\"key\":\"score\"},{\"const\":0.5}]}))",
1092 "MemoryEngine::in_memory(UserId::new(\"local-user\"))",
1093 ".with_memory_slots(memory.clone(), [MemorySlot::new(\"dietary_identity\", \"user:diet\")])",
1094 ".tool_background_with_scheduling(\"log_event\", FunctionResponseScheduling::Silent)",
1095 ".temperature(0.4)",
1096 ".soft_turn_timeout(std::time::Duration::from_millis(1500))",
1097 ".steering_mode(SteeringMode::ContextInjection)",
1098 ".repair(RepairConfig { nudge_after: 2, escalate_after: 5 })",
1099 ".persistence(Arc::new(FsPersistence::new(\"/tmp/sessions\")))",
1100 ".session_id(\"guest-1\")",
1101 "send_client_content(vec![Content::model(\"Offer to help.\")], true)",
1102 "// remember (durable): \"guest got stuck\"",
1103 ] {
1104 assert!(
1105 code.contains(fragment),
1106 "missing fragment: {fragment}\n---\n{code}"
1107 );
1108 }
1109 assert!(spec.to_cargo_toml().contains("gemini-memory-rs"));
1110 }
1111
1112 #[test]
1113 fn empty_state_key_name_generates_valid_const_name() {
1114 let key = "";
1116 let name = super::const_name(key);
1117 assert!(
1120 !name.is_empty(),
1121 "const_name produced empty string for empty key"
1122 );
1123 assert!(
1124 name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
1125 "const_name produced invalid Rust identifier: {name}"
1126 );
1127 }
1128
1129 #[test]
1130 fn state_key_name_starting_with_digit_is_escaped() {
1131 let name = super::const_name("123abc");
1132 assert!(
1133 name.starts_with('_'),
1134 "const_name should prepend _ to digit-starting keys, got {name}"
1135 );
1136 assert_eq!(name, "_123ABC");
1137 }
1138}