gemini_adk_rs/
instruction.rs

1//! Instruction templating — inject state values into instruction strings.
2//!
3//! Replaces `{key}` placeholders with values from the state container.
4//! Supports optional `{key?}` syntax that resolves to empty string if missing.
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9use crate::state::State;
10
11static PLACEHOLDER_RE: LazyLock<Regex> =
12    LazyLock::new(|| Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_:]*)\??\}").unwrap());
13
14/// Replace `{key}` placeholders in `template` with values from `state`.
15///
16/// - `{key}` — required: if present in state, replaced with the string representation;
17///   if missing, left as-is (e.g., `{unknown}` stays `{unknown}`)
18/// - `{key?}` — optional: if present in state, replaced; if missing, replaced with `""`
19/// - Prefix keys are supported: `{app:flag}`, `{user:name}`, etc.
20pub fn inject_session_state(template: &str, state: &State) -> String {
21    PLACEHOLDER_RE
22        .replace_all(template, |caps: &regex::Captures| {
23            let full_match = &caps[0];
24            let key = &caps[1];
25            let optional = full_match.ends_with("?}");
26
27            match state.get_raw(key) {
28                Some(value) => value_to_string(&value),
29                None => {
30                    if optional {
31                        String::new()
32                    } else {
33                        full_match.to_string()
34                    }
35                }
36            }
37        })
38        .into_owned()
39}
40
41/// A dynamic instruction source — the ADK "instruction provider" pattern:
42/// instead of a string fixed at build time, the instruction is produced
43/// from live session state on every model request (persona switching,
44/// risk-driven guardrails, multi-tenant instructions without rebuilding
45/// the agent). Any `Fn(&State) -> String` closure is a provider.
46pub trait InstructionProvider: Send + Sync {
47    /// Produce the system instruction for the current request.
48    fn provide(&self, state: &State) -> String;
49}
50
51impl<F> InstructionProvider for F
52where
53    F: Fn(&State) -> String + Send + Sync,
54{
55    fn provide(&self, state: &State) -> String {
56        self(state)
57    }
58}
59
60impl<T: InstructionProvider + ?Sized> InstructionProvider for std::sync::Arc<T> {
61    fn provide(&self, state: &State) -> String {
62        (**self).provide(state)
63    }
64}
65
66/// A full template-engine instruction *(feature `templates`)* — minijinja
67/// (Jinja2 syntax: conditionals, loops, filters) over the session state,
68/// mirroring ADK's `use_jinja2` instructions. The whole state is exposed
69/// as `state` (subscript prefixed keys: `{{ state["session:turn_count"] }}`),
70/// and every key that is a bare identifier is also available at top level
71/// (`{{ name }}`). Falls back to the empty string for missing values under
72/// Jinja's default undefined semantics.
73///
74/// ```ignore
75/// let inst = TemplateInstruction::new(
76///     "You are a support agent.\n\
77///      {% if state[\"derived:risk\"] and state[\"derived:risk\"] > 0.8 %}\
78///      Escalate carefully and show extra empathy.{% endif %}",
79/// )?;
80/// LlmTextAgent::new("support", llm).instruction_provider(inst);
81/// ```
82#[cfg(feature = "templates")]
83pub struct TemplateInstruction {
84    source: String,
85}
86
87#[cfg(feature = "templates")]
88impl TemplateInstruction {
89    /// Compile-check the template now so errors surface at build time, not
90    /// on the first model request.
91    pub fn new(source: impl Into<String>) -> Result<Self, String> {
92        let source = source.into();
93        let env = minijinja::Environment::new();
94        env.template_from_str(&source)
95            .map_err(|e| format!("template error: {e}"))?;
96        Ok(Self { source })
97    }
98
99    fn context(state: &State) -> minijinja::Value {
100        let mut all = serde_json::Map::new();
101        let mut top = serde_json::Map::new();
102        for key in state.keys() {
103            if let Some(value) = state.get_raw(&key) {
104                let bare_identifier = !key.is_empty()
105                    && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
106                    && !key.starts_with(|c: char| c.is_ascii_digit());
107                if bare_identifier {
108                    top.insert(key.clone(), value.clone());
109                }
110                all.insert(key, value);
111            }
112        }
113        top.insert("state".into(), serde_json::Value::Object(all));
114        minijinja::Value::from_serialize(serde_json::Value::Object(top))
115    }
116}
117
118#[cfg(feature = "templates")]
119impl InstructionProvider for TemplateInstruction {
120    fn provide(&self, state: &State) -> String {
121        let env = minijinja::Environment::new();
122        env.render_str(&self.source, Self::context(state))
123            .unwrap_or_else(|e| format!("[template render error: {e}]"))
124    }
125}
126
127fn value_to_string(value: &serde_json::Value) -> String {
128    match value {
129        serde_json::Value::String(s) => s.clone(),
130        serde_json::Value::Null => String::new(),
131        other => other.to_string(),
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn closure_is_an_instruction_provider() {
141        let state = State::new();
142        let _ = state.set("persona", "pirate");
143        let provider = |s: &State| {
144            format!(
145                "Speak like a {}.",
146                s.get::<String>("persona").unwrap_or_default()
147            )
148        };
149        assert_eq!(
150            InstructionProvider::provide(&provider, &state),
151            "Speak like a pirate."
152        );
153    }
154
155    #[cfg(feature = "templates")]
156    #[test]
157    fn template_instruction_renders_conditionals_and_prefixed_keys() {
158        let state = State::new();
159        let _ = state.set("name", "Alice");
160        let _ = state.set("derived:risk", 0.9);
161        let inst = TemplateInstruction::new(
162            "Hello {{ name }}.{% if state[\"derived:risk\"] > 0.8 %} Escalate.{% endif %}",
163        )
164        .unwrap();
165        assert_eq!(inst.provide(&state), "Hello Alice. Escalate.");
166    }
167
168    #[cfg(feature = "templates")]
169    #[test]
170    fn template_instruction_rejects_bad_syntax_at_build() {
171        assert!(TemplateInstruction::new("{% if x %}unclosed").is_err());
172    }
173
174    #[test]
175    fn simple_substitution() {
176        let state = State::new();
177        let _ = state.set("name", "Alice");
178        let result = inject_session_state("Hello, {name}!", &state);
179        assert_eq!(result, "Hello, Alice!");
180    }
181
182    #[test]
183    fn optional_key_present() {
184        let state = State::new();
185        let _ = state.set("title", "Dr.");
186        let result = inject_session_state("Hello, {title?} Smith!", &state);
187        assert_eq!(result, "Hello, Dr. Smith!");
188    }
189
190    #[test]
191    fn optional_key_missing() {
192        let state = State::new();
193        let result = inject_session_state("Hello, {title?}Smith!", &state);
194        assert_eq!(result, "Hello, Smith!");
195    }
196
197    #[test]
198    fn missing_required_key_left_as_is() {
199        let state = State::new();
200        let result = inject_session_state("Hello, {unknown}!", &state);
201        assert_eq!(result, "Hello, {unknown}!");
202    }
203
204    #[test]
205    fn multiple_keys() {
206        let state = State::new();
207        let _ = state.set("first", "Alice");
208        let _ = state.set("last", "Smith");
209        let result = inject_session_state("{first} {last}", &state);
210        assert_eq!(result, "Alice Smith");
211    }
212
213    #[test]
214    fn prefix_key() {
215        let state = State::new();
216        let _ = state.app().set("flag", true);
217        let result = inject_session_state("Flag is {app:flag}", &state);
218        assert_eq!(result, "Flag is true");
219    }
220
221    #[test]
222    fn no_placeholders_passthrough() {
223        let state = State::new();
224        let template = "No placeholders here.";
225        assert_eq!(inject_session_state(template, &state), template);
226    }
227
228    #[test]
229    fn numeric_value() {
230        let state = State::new();
231        let _ = state.set("count", 42);
232        let result = inject_session_state("Count: {count}", &state);
233        assert_eq!(result, "Count: 42");
234    }
235
236    #[test]
237    fn empty_template() {
238        let state = State::new();
239        assert_eq!(inject_session_state("", &state), "");
240    }
241}