gemini_adk_fluent_rs/
motifs.rs1use gemini_adk_rs::flow::Guard;
27use gemini_adk_rs::frame::Frame;
28
29use crate::conversation::{CommitSpec, OverlaySpec, Resume, StageSpec, TransitionSpec};
30
31pub struct Motif;
33
34impl Motif {
35 pub fn collect_frame<F: Frame>(id: impl Into<String>) -> StageSpec {
37 let frame = F::frame();
38 StageSpec {
39 id: id.into(),
40 collect: frame.slot_keys(),
41 frame: Some(frame),
42 ..Default::default()
43 }
44 }
45
46 pub fn say(id: impl Into<String>, text: impl Into<String>) -> StageSpec {
48 StageSpec {
49 id: id.into(),
50 say: Some(text.into()),
51 done: Some(Guard::always()),
52 ..Default::default()
53 }
54 }
55
56 pub fn disclosure(id: impl Into<String>, ack_key: impl Into<String>) -> StageSpec {
63 let ack = ack_key.into();
64 StageSpec {
65 id: id.into(),
66 say: Some("Read the required disclosure, then continue.".into()),
67 done: Some(Guard::is_true(ack)),
68 timing: Some(gemini_adk_rs::flow::VoiceTiming::new().uninterruptible()),
69 ..Default::default()
70 }
71 }
72
73 pub fn confirm_then_commit(
76 id: impl Into<String>,
77 tool: impl Into<String>,
78 confirm_key: impl Into<String>,
79 ) -> StageSpec {
80 let tool = tool.into();
81 StageSpec {
82 id: id.into(),
83 allow: vec![tool.clone()],
84 commit: Some(CommitSpec {
85 tool: tool.clone(),
86 when: Guard::is_true(confirm_key),
87 }),
88 done: Some(Guard::called_ok(tool)),
89 ..Default::default()
90 }
91 }
92
93 pub fn identity_verification(
95 id: impl Into<String>,
96 verified_key: impl Into<String>,
97 ) -> StageSpec {
98 StageSpec {
99 id: id.into(),
100 say: Some("Verify the caller's identity before proceeding.".into()),
101 done: Some(Guard::is_true(verified_key)),
102 ..Default::default()
103 }
104 }
105
106 pub fn handoff(id: impl Into<String>) -> StageSpec {
108 StageSpec {
109 id: id.into(),
110 say: Some("Hand off to a human agent with a summary.".into()),
111 terminal: true,
112 ..Default::default()
113 }
114 }
115
116 pub fn faq_digression(
119 name: impl Into<String>,
120 trigger_key: impl Into<String>,
121 answered_key: impl Into<String>,
122 ) -> OverlaySpec {
123 let answered = answered_key.into();
124 OverlaySpec {
125 name: name.into(),
126 trigger: Guard::is_true(trigger_key),
127 stages: vec![
128 StageSpec {
129 id: "answer".into(),
130 say: Some("Answer the user's question.".into()),
131 done: Some(Guard::is_true(answered.clone())),
132 next: vec![TransitionSpec {
133 to: "faq_end".into(),
134 when: Guard::is_true(answered),
135 }],
136 ..Default::default()
137 },
138 StageSpec {
139 id: "faq_end".into(),
140 terminal: true,
141 ..Default::default()
142 },
143 ],
144 require: Vec::new(),
145 resume: Resume::Previous,
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::conversation::Conversation;
154 use crate::simulation::Sim;
155 use gemini_adk_rs::flow::Enforcement;
156 use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
157
158 struct Booking;
159 impl Frame for Booking {
160 fn frame() -> FrameSpec {
161 FrameSpec {
162 name: "booking".into(),
163 slots: vec![SlotSpec {
164 recognizer: Some(SlotRecognizer::IntegerNear(vec!["people".into()])),
165 ..SlotSpec::new("party_size")
166 }],
167 }
168 }
169 }
170
171 #[tokio::test]
172 async fn motifs_compose_into_a_runnable_conversation() {
173 let convo = Conversation::new("booking")
174 .add_stage(Motif::identity_verification("verify", "verified"))
175 .next("collect", Guard::is_true("verified"))
176 .add_stage(Motif::collect_frame::<Booking>("collect"))
177 .next("confirm", Guard::captured(["party_size"]))
178 .add_stage(Motif::confirm_then_commit(
179 "confirm",
180 "book",
181 "user_confirmed",
182 ))
183 .next("done", Guard::called_ok("book"))
184 .add_stage(Motif::handoff("done"))
185 .require(["done"])
186 .compile()
187 .expect("motif conversation compiles");
188
189 let mut sim = Sim::new(&convo, Enforcement::Enforce);
190 assert!(sim.active().contains(&"verify".to_string()));
191
192 sim.set("verified", true);
193 sim.turn();
194 assert!(sim.active().contains(&"collect".to_string()));
195
196 sim.user("a table for 4 people").await;
197 assert_eq!(sim.slot::<u32>("party_size"), Some(4));
198 assert!(sim.active().contains(&"confirm".to_string()));
199
200 assert!(!sim.allowed("book"));
202 sim.set("user_confirmed", true);
203 sim.turn();
204 assert!(sim.allowed("book"));
205 sim.tool_ok("book");
206 assert!(sim.is_complete());
207 }
208
209 #[tokio::test]
210 async fn faq_digression_motif_suspends_and_resumes() {
211 let convo = Conversation::new("support")
212 .stage("triage")
213 .next("resolve", Guard::is_true("triaged"))
214 .stage("resolve")
215 .terminal()
216 .add_overlay(Motif::faq_digression("faq", "intent:faq", "faq_answered"))
217 .compile()
218 .expect("compiles");
219
220 let mut sim = Sim::new(&convo, Enforcement::Enforce);
221 assert!(sim.active().contains(&"triage".to_string()));
222
223 sim.set("intent:faq", true);
224 sim.turn();
225 assert_eq!(sim.active_overlay(), Some("faq"));
226
227 sim.set("faq_answered", true);
230 sim.set("intent:faq", false);
231 sim.turn();
232 assert_eq!(sim.active_overlay(), Some("faq"));
233
234 sim.turn();
236 assert!(sim.active_overlay().is_none());
237 assert!(sim.active().contains(&"triage".to_string())); }
239
240 #[test]
241 fn unguarded_confirm_motif_would_be_rejected() {
242 let mut stage = Motif::confirm_then_commit("confirm", "book", "user_confirmed");
245 stage.commit = Some(CommitSpec {
246 tool: "book".into(),
247 when: Guard::always(),
248 });
249 let err = Conversation::new("x")
250 .add_stage(stage)
251 .next("done", Guard::called_ok("book"))
252 .stage("done")
253 .terminal()
254 .compile()
255 .expect_err("unguarded commit must fail");
256 assert!(matches!(
257 err,
258 crate::conversation::ConversationError::Compile(_)
259 ));
260 }
261}