gemini_adk_fluent_rs/
motifs.rs

1//! Conversational motifs — a standard library of high-confidence flow fragments.
2//!
3//! Most developers don't want to invent flow topology; they want vetted
4//! primitives: *collect these slots*, *confirm then commit*, *require a
5//! disclosure*, *answer an FAQ then resume*, *hand off*. A [`Motif`] is a factory
6//! for a pre-configured [`StageSpec`] (or [`OverlaySpec`]) that you append with
7//! [`Conversation::add_stage`](crate::conversation::Conversation::add_stage) /
8//! [`add_overlay`](crate::conversation::Conversation::add_overlay) and wire with
9//! `.next(..)` as usual. Motifs are *just* lowered through the validated IR — same
10//! fail-loud guarantees as hand-written stages (a mis-built commit motif fails
11//! `compile()` exactly like a hand-written one would).
12//!
13//! ```ignore
14//! // `ignore`: `Booking` is an application `#[derive(Frame)]` type; the tests
15//! // in this module build the same conversation against a concrete frame.
16//! let convo = Conversation::new("booking")
17//!     .add_stage(Motif::collect_frame::<Booking>("collect"))
18//!         .next("confirm", Guard::captured(["party_size"]))
19//!     .add_stage(Motif::confirm_then_commit("confirm", "book", "user_confirmed"))
20//!         .next("done", Guard::called_ok("book"))
21//!     .add_stage(Motif::handoff("done"))
22//!     .require(["done"])
23//!     .compile()?;
24//! ```
25
26use gemini_adk_rs::flow::Guard;
27use gemini_adk_rs::frame::Frame;
28
29use crate::conversation::{CommitSpec, OverlaySpec, Resume, StageSpec, TransitionSpec};
30
31/// Namespace of conversational motif factories.
32pub struct Motif;
33
34impl Motif {
35    /// A stage that collects a typed frame's slots (completes on `captured`).
36    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    /// A stage that announces something and advances immediately.
47    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    /// A stage that requires a disclosure acknowledgement before advancing.
57    ///
58    /// The model holds the floor while it reads (see
59    /// [`VoiceTiming::uninterruptible`](gemini_adk_rs::flow::VoiceTiming::uninterruptible)),
60    /// so the user cannot cut a required disclosure short. Override the
61    /// stage's `timing` to allow barge-in.
62    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    /// A confirm-before-act stage: `tool` is allowed here, gated behind
74    /// `confirm_key`, and the stage completes once `tool` succeeds.
75    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    /// An identity-verification stage: completes once `verified_key` is true.
94    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    /// A terminal handoff stage (optionally allowing a transfer tool).
107    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    /// An FAQ digression: triggered by `trigger_key`, it answers a side question
117    /// (gated on `answered_key`) and resumes the main flow where it left off.
118    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        // confirm_then_commit gates `book` behind confirmation.
201        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        // The turn it completes is still its own: the digression's closing
228        // instruction is what the model hears.
229        sim.set("faq_answered", true);
230        sim.set("intent:faq", false);
231        sim.turn();
232        assert_eq!(sim.active_overlay(), Some("faq"));
233
234        // The next boundary resumes the main flow where it was.
235        sim.turn();
236        assert!(sim.active_overlay().is_none());
237        assert!(sim.active().contains(&"triage".to_string())); // resumed where it was
238    }
239
240    #[test]
241    fn unguarded_confirm_motif_would_be_rejected() {
242        // Sanity: motifs lower through the validated IR. A confirm stage whose
243        // commit guard is always-true is rejected just like a hand-written one.
244        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}