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