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    pub fn disclosure(id: impl Into<String>, ack_key: impl Into<String>) -> StageSpec {
58        let ack = ack_key.into();
59        StageSpec {
60            id: id.into(),
61            say: Some("Read the required disclosure, then continue.".into()),
62            done: Some(Guard::is_true(ack)),
63            ..Default::default()
64        }
65    }
66
67    /// A confirm-before-act stage: `tool` is allowed here, gated behind
68    /// `confirm_key`, and the stage completes once `tool` succeeds.
69    pub fn confirm_then_commit(
70        id: impl Into<String>,
71        tool: impl Into<String>,
72        confirm_key: impl Into<String>,
73    ) -> StageSpec {
74        let tool = tool.into();
75        StageSpec {
76            id: id.into(),
77            allow: vec![tool.clone()],
78            commit: Some(CommitSpec {
79                tool: tool.clone(),
80                when: Guard::is_true(confirm_key),
81            }),
82            done: Some(Guard::called_ok(tool)),
83            ..Default::default()
84        }
85    }
86
87    /// An identity-verification stage: completes once `verified_key` is true.
88    pub fn identity_verification(
89        id: impl Into<String>,
90        verified_key: impl Into<String>,
91    ) -> StageSpec {
92        StageSpec {
93            id: id.into(),
94            say: Some("Verify the caller's identity before proceeding.".into()),
95            done: Some(Guard::is_true(verified_key)),
96            ..Default::default()
97        }
98    }
99
100    /// A terminal handoff stage (optionally allowing a transfer tool).
101    pub fn handoff(id: impl Into<String>) -> StageSpec {
102        StageSpec {
103            id: id.into(),
104            say: Some("Hand off to a human agent with a summary.".into()),
105            terminal: true,
106            ..Default::default()
107        }
108    }
109
110    /// An FAQ digression: triggered by `trigger_key`, it answers a side question
111    /// (gated on `answered_key`) and resumes the main flow where it left off.
112    pub fn faq_digression(
113        name: impl Into<String>,
114        trigger_key: impl Into<String>,
115        answered_key: impl Into<String>,
116    ) -> OverlaySpec {
117        let answered = answered_key.into();
118        OverlaySpec {
119            name: name.into(),
120            trigger: Guard::is_true(trigger_key),
121            stages: vec![
122                StageSpec {
123                    id: "answer".into(),
124                    say: Some("Answer the user's question.".into()),
125                    done: Some(Guard::is_true(answered.clone())),
126                    next: vec![TransitionSpec {
127                        to: "faq_end".into(),
128                        when: Guard::is_true(answered),
129                    }],
130                    ..Default::default()
131                },
132                StageSpec {
133                    id: "faq_end".into(),
134                    terminal: true,
135                    ..Default::default()
136                },
137            ],
138            require: Vec::new(),
139            resume: Resume::Previous,
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::conversation::Conversation;
148    use crate::simulation::Sim;
149    use gemini_adk_rs::flow::Enforcement;
150    use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
151
152    struct Booking;
153    impl Frame for Booking {
154        fn frame() -> FrameSpec {
155            FrameSpec {
156                name: "booking".into(),
157                slots: vec![SlotSpec {
158                    recognizer: Some(SlotRecognizer::IntegerNear(vec!["people".into()])),
159                    ..SlotSpec::new("party_size")
160                }],
161            }
162        }
163    }
164
165    #[tokio::test]
166    async fn motifs_compose_into_a_runnable_conversation() {
167        let convo = Conversation::new("booking")
168            .add_stage(Motif::identity_verification("verify", "verified"))
169            .next("collect", Guard::is_true("verified"))
170            .add_stage(Motif::collect_frame::<Booking>("collect"))
171            .next("confirm", Guard::captured(["party_size"]))
172            .add_stage(Motif::confirm_then_commit(
173                "confirm",
174                "book",
175                "user_confirmed",
176            ))
177            .next("done", Guard::called_ok("book"))
178            .add_stage(Motif::handoff("done"))
179            .require(["done"])
180            .compile()
181            .expect("motif conversation compiles");
182
183        let mut sim = Sim::new(&convo, Enforcement::Enforce);
184        assert!(sim.active().contains(&"verify".to_string()));
185
186        sim.set("verified", true);
187        sim.turn();
188        assert!(sim.active().contains(&"collect".to_string()));
189
190        sim.user("a table for 4 people").await;
191        assert_eq!(sim.slot::<u32>("party_size"), Some(4));
192        assert!(sim.active().contains(&"confirm".to_string()));
193
194        // confirm_then_commit gates `book` behind confirmation.
195        assert!(!sim.allowed("book"));
196        sim.set("user_confirmed", true);
197        sim.turn();
198        assert!(sim.allowed("book"));
199        sim.tool_ok("book");
200        assert!(sim.is_complete());
201    }
202
203    #[tokio::test]
204    async fn faq_digression_motif_suspends_and_resumes() {
205        let convo = Conversation::new("support")
206            .stage("triage")
207            .next("resolve", Guard::is_true("triaged"))
208            .stage("resolve")
209            .terminal()
210            .add_overlay(Motif::faq_digression("faq", "intent:faq", "faq_answered"))
211            .compile()
212            .expect("compiles");
213
214        let mut sim = Sim::new(&convo, Enforcement::Enforce);
215        assert!(sim.active().contains(&"triage".to_string()));
216
217        sim.set("intent:faq", true);
218        sim.turn();
219        assert_eq!(sim.active_overlay(), Some("faq"));
220
221        sim.set("faq_answered", true);
222        sim.set("intent:faq", false);
223        sim.turn();
224        assert!(sim.active_overlay().is_none());
225        assert!(sim.active().contains(&"triage".to_string())); // resumed where it was
226    }
227
228    #[test]
229    fn unguarded_confirm_motif_would_be_rejected() {
230        // Sanity: motifs lower through the validated IR. A confirm stage whose
231        // commit guard is always-true is rejected just like a hand-written one.
232        let mut stage = Motif::confirm_then_commit("confirm", "book", "user_confirmed");
233        stage.commit = Some(CommitSpec {
234            tool: "book".into(),
235            when: Guard::always(),
236        });
237        let err = Conversation::new("x")
238            .add_stage(stage)
239            .next("done", Guard::called_ok("book"))
240            .stage("done")
241            .terminal()
242            .compile()
243            .expect_err("unguarded commit must fail");
244        assert!(matches!(
245            err,
246            crate::conversation::ConversationError::Compile(_)
247        ));
248    }
249}