Skip to main content

gemini_adk_rs/flow/
decided.rs

1//! The `decided` guard atom: a guard on what a decision model concluded about
2//! the conversation, rather than on a state key something else wrote.
3//!
4//! A question is declared once (see [`crate::decision::Decisions`]); a guard
5//! names it and what it expects:
6//!
7//! | JSON | Holds when |
8//! |---|---|
9//! | `{"decided": "confirmed"}` | a boolean was answered yes, a choice picked an option, a score was given |
10//! | `{"decided": {"confirmed": false}}` | a boolean was answered no, or a choice picked "none of these" |
11//! | `{"decided": {"next_step": "book"}}` | a choice picked `book` |
12//! | `{"decided": {"frustration": {"at_least": 2}}}` | a score of 2 or more |
13//! | `{"decided": {"frustration": {"at_most": 1}}}` | a score of 1 or less |
14//!
15//! The runtime asks the questions the flow can act on at each decision point
16//! (the caller's turn ends, or the model calls a tool) and records each answer
17//! under `decision:{id}` with the caller turn it was about. An answer counts
18//! only for that turn: a yes given three turns ago does not satisfy a guard
19//! now, so nothing has to be latched or cleared. An unsure answer satisfies
20//! no expectation.
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use serde_json::{Value, json};
24
25use crate::state::State;
26
27/// Prefix of the state keys answers are recorded under.
28pub const DECISION_PREFIX: &str = "decision:";
29/// The state key holding the caller turn answers are currently about.
30pub const DECISION_TURN_KEY: &str = "decision:turn";
31
32/// The state key an answer to `question` is recorded under.
33pub fn decision_key(question: &str) -> String {
34    format!("{DECISION_PREFIX}{question}")
35}
36
37/// What a `decided` guard expects of an answer.
38#[derive(Clone, Debug, PartialEq)]
39pub enum Expect {
40    /// A boolean answered yes, a choice that picked an option, or any score.
41    Yes,
42    /// A boolean answered no, or a choice that picked "none of these".
43    No,
44    /// A choice that picked this option.
45    Is(String),
46    /// A score of at least this.
47    AtLeast(f64),
48    /// A score of at most this.
49    AtMost(f64),
50}
51
52/// A guard on a decision: the question's id and what is expected.
53#[derive(Clone, Debug, PartialEq)]
54pub struct Decided {
55    /// The question's id in the decision bank.
56    pub question: String,
57    /// What the answer must be.
58    pub expect: Expect,
59}
60
61/// How an answer came out.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Outcome {
65    /// A boolean answered yes.
66    Yes,
67    /// A boolean answered no.
68    No,
69    /// A choice picked an option.
70    Chosen,
71    /// A choice picked "none of these".
72    None,
73    /// A score was given.
74    Scored,
75    /// Not sure enough to decide, refused, or the model could not be asked.
76    Unsure,
77}
78
79/// An answer as recorded in state under [`decision_key`].
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81pub struct DecisionRecord {
82    /// How it came out.
83    pub outcome: Outcome,
84    /// The chosen option's key (choice), or the score.
85    #[serde(default, skip_serializing_if = "Value::is_null")]
86    pub value: Value,
87    /// `P(true)` for a boolean, the certainty for a choice or score.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub confidence: Option<f64>,
90    /// The caller turn the answer is about (see [`DECISION_TURN_KEY`]).
91    pub turn: String,
92}
93
94impl DecisionRecord {
95    /// The record for `question` in `state`, if it is about the current
96    /// caller turn.
97    pub fn current(state: &State, question: &str) -> Option<Self> {
98        let record: Self = state.get(&decision_key(question))?;
99        let turn: String = state.get(DECISION_TURN_KEY)?;
100        (record.turn == turn).then_some(record)
101    }
102}
103
104impl Decided {
105    /// Whether the current answer meets the expectation.
106    pub fn holds(&self, state: &State) -> bool {
107        let Some(record) = DecisionRecord::current(state, &self.question) else {
108            return false;
109        };
110        match (&self.expect, record.outcome) {
111            (Expect::Yes, Outcome::Yes | Outcome::Chosen | Outcome::Scored) => true,
112            (Expect::No, Outcome::No | Outcome::None) => true,
113            (Expect::Is(option), Outcome::Chosen) => record.value.as_str() == Some(option),
114            (Expect::AtLeast(x), Outcome::Scored) => record.value.as_f64().is_some_and(|s| s >= *x),
115            (Expect::AtMost(x), Outcome::Scored) => record.value.as_f64().is_some_and(|s| s <= *x),
116            _ => false,
117        }
118    }
119
120    /// The guard as a prose clause, for a refusal the model reads.
121    pub fn describe(&self) -> String {
122        let q = &self.question;
123        match &self.expect {
124            Expect::Yes => format!("the caller's latest words must settle '{q}'"),
125            Expect::No => format!("the caller's latest words must rule out '{q}'"),
126            Expect::Is(o) => format!("the caller's latest words must settle '{q}' as '{o}'"),
127            Expect::AtLeast(x) => format!("'{q}' must be at least {x}"),
128            Expect::AtMost(x) => format!("'{q}' must be at most {x}"),
129        }
130    }
131
132    fn to_value(&self) -> Value {
133        let expect = match &self.expect {
134            Expect::Yes => return Value::String(self.question.clone()),
135            Expect::No => json!(false),
136            Expect::Is(o) => json!(o),
137            Expect::AtLeast(x) => json!({ "at_least": x }),
138            Expect::AtMost(x) => json!({ "at_most": x }),
139        };
140        json!({ self.question.clone(): expect })
141    }
142
143    fn from_value(value: Value) -> Result<Self, String> {
144        match value {
145            Value::String(question) => Ok(Self {
146                question,
147                expect: Expect::Yes,
148            }),
149            Value::Object(map) if map.len() == 1 => {
150                let (question, expect) = map.into_iter().next().expect("one entry");
151                let expect = match expect {
152                    Value::Bool(true) => Expect::Yes,
153                    Value::Bool(false) => Expect::No,
154                    Value::String(o) => Expect::Is(o),
155                    Value::Object(bound) => match (
156                        bound.get("at_least").and_then(Value::as_f64),
157                        bound.get("at_most").and_then(Value::as_f64),
158                    ) {
159                        (Some(x), None) if bound.len() == 1 => Expect::AtLeast(x),
160                        (None, Some(x)) if bound.len() == 1 => Expect::AtMost(x),
161                        _ => {
162                            return Err(format!(
163                                "decided '{question}': a score bound is {{\"at_least\": n}} or {{\"at_most\": n}}"
164                            ));
165                        }
166                    },
167                    other => {
168                        return Err(format!(
169                            "decided '{question}': expected true, false, an option or a score bound, not {other}"
170                        ));
171                    }
172                };
173                Ok(Self { question, expect })
174            }
175            other => Err(format!(
176                "decided takes a question id, or one {{question: expectation}} entry, not {other}"
177            )),
178        }
179    }
180}
181
182impl Serialize for Decided {
183    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
184        self.to_value().serialize(s)
185    }
186}
187
188impl<'de> Deserialize<'de> for Decided {
189    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
190        Self::from_value(Value::deserialize(d)?).map_err(serde::de::Error::custom)
191    }
192}
193
194impl schemars::JsonSchema for Decided {
195    fn schema_name() -> String {
196        "Decided".to_string()
197    }
198    fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
199        #[derive(schemars::JsonSchema)]
200        #[serde(untagged)]
201        #[allow(dead_code)]
202        enum Bound {
203            AtLeast { at_least: f64 },
204            AtMost { at_most: f64 },
205        }
206        #[derive(schemars::JsonSchema)]
207        #[serde(untagged)]
208        #[allow(dead_code)]
209        enum Expectation {
210            /// `true` (yes) or `false` (no).
211            Boolean(bool),
212            /// A choice's option.
213            Option(String),
214            /// A score bound.
215            Score(Bound),
216        }
217        /// A question id (yes, picked, or scored), or one
218        /// `{question: expectation}` entry.
219        #[derive(schemars::JsonSchema)]
220        #[serde(untagged)]
221        #[allow(dead_code)]
222        enum DecidedSchema {
223            Question(String),
224            Expecting(std::collections::BTreeMap<String, Expectation>),
225        }
226        DecidedSchema::json_schema(generator)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn record(state: &State, question: &str, outcome: Outcome, value: Value, turn: &str) {
235        let _ = state.set(
236            decision_key(question),
237            serde_json::to_value(DecisionRecord {
238                outcome,
239                value,
240                confidence: None,
241                turn: turn.into(),
242            })
243            .unwrap(),
244        );
245    }
246
247    #[test]
248    fn json_forms_round_trip() {
249        for (json, expect) in [
250            (json!("confirmed"), Expect::Yes),
251            (json!({ "confirmed": true }), Expect::Yes),
252            (json!({ "confirmed": false }), Expect::No),
253            (json!({ "next": "book" }), Expect::Is("book".into())),
254            (json!({ "anger": { "at_least": 2 } }), Expect::AtLeast(2.0)),
255            (json!({ "anger": { "at_most": 1 } }), Expect::AtMost(1.0)),
256        ] {
257            let d: Decided = serde_json::from_value(json.clone()).unwrap();
258            assert_eq!(d.expect, expect, "{json}");
259            let back: Decided = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap();
260            assert_eq!(back, d);
261        }
262        assert!(serde_json::from_value::<Decided>(json!({ "a": 1, "b": 2 })).is_err());
263        assert!(serde_json::from_value::<Decided>(json!({ "a": { "over": 2 } })).is_err());
264        assert!(serde_json::from_value::<Decided>(json!(3)).is_err());
265    }
266
267    #[test]
268    fn an_answer_counts_only_for_its_turn() {
269        let state = State::new();
270        let yes = Decided {
271            question: "confirmed".into(),
272            expect: Expect::Yes,
273        };
274        assert!(!yes.holds(&state), "never asked");
275        let _ = state.set(DECISION_TURN_KEY, "t1");
276        record(&state, "confirmed", Outcome::Yes, Value::Null, "t1");
277        assert!(yes.holds(&state));
278        let _ = state.set(DECISION_TURN_KEY, "t2");
279        assert!(
280            !yes.holds(&state),
281            "a yes from an earlier turn is not a yes now"
282        );
283    }
284
285    #[test]
286    fn expectations_match_outcomes() {
287        let state = State::new();
288        let _ = state.set(DECISION_TURN_KEY, "t");
289        let d = |q: &str, expect| Decided {
290            question: q.into(),
291            expect,
292        };
293        record(&state, "ok", Outcome::No, Value::Null, "t");
294        assert!(d("ok", Expect::No).holds(&state));
295        assert!(!d("ok", Expect::Yes).holds(&state));
296
297        record(&state, "pick", Outcome::Chosen, json!("book"), "t");
298        assert!(d("pick", Expect::Yes).holds(&state));
299        assert!(d("pick", Expect::Is("book".into())).holds(&state));
300        assert!(!d("pick", Expect::Is("stay".into())).holds(&state));
301
302        record(&state, "none", Outcome::None, Value::Null, "t");
303        assert!(d("none", Expect::No).holds(&state));
304        assert!(!d("none", Expect::Yes).holds(&state));
305
306        record(&state, "anger", Outcome::Scored, json!(2.4), "t");
307        assert!(d("anger", Expect::AtLeast(2.0)).holds(&state));
308        assert!(!d("anger", Expect::AtMost(2.0)).holds(&state));
309
310        record(&state, "unsure", Outcome::Unsure, Value::Null, "t");
311        for e in [Expect::Yes, Expect::No] {
312            assert!(!d("unsure", e).holds(&state), "unsure satisfies nothing");
313        }
314    }
315}