Skip to main content

adk_core/
instruction_template.rs

1use crate::{AdkError, InvocationContext, Result};
2
3/// Checks if a character is valid as the first character of a placeholder identifier.
4fn is_ident_start(c: char) -> bool {
5    c.is_ascii_alphabetic() || c == '_'
6}
7
8/// Checks if a character is valid inside a placeholder identifier body.
9fn is_ident_body(c: char) -> bool {
10    c.is_ascii_alphanumeric() || c == '_' || c == ':' || c == '.'
11}
12
13/// Finds the next placeholder `{...}` in `template` starting from byte offset `from`.
14/// Returns `Some((start, end, content))` where start/end are byte offsets of the
15/// outer braces and content is the inner string (without braces).
16/// Returns `None` when no more placeholders exist.
17fn find_next_placeholder(template: &str, from: usize) -> Option<(usize, usize, &str)> {
18    let bytes = template.as_bytes();
19    let len = bytes.len();
20    let mut i = from;
21
22    while i < len {
23        if bytes[i] == b'{' {
24            let content_start = i + 1;
25            if content_start >= len {
26                break;
27            }
28            // First char must be a valid identifier start
29            if !is_ident_start(bytes[content_start] as char) {
30                i += 1;
31                continue;
32            }
33            // Scan the body
34            let mut j = content_start + 1;
35            while j < len && is_ident_body(bytes[j] as char) {
36                j += 1;
37            }
38            // Optional trailing '?'
39            if j < len && bytes[j] == b'?' {
40                j += 1;
41            }
42            // Must close with '}'
43            if j < len && bytes[j] == b'}' {
44                let content = &template[content_start..j];
45                return Some((i, j + 1, content));
46            }
47        }
48        i += 1;
49    }
50    None
51}
52
53/// Checks if a string is a valid identifier (like Python's str.isidentifier())
54/// Must start with letter or underscore, followed by letters, digits, or underscores
55fn is_identifier(s: &str) -> bool {
56    if s.is_empty() {
57        return false;
58    }
59
60    let mut chars = s.chars();
61    let first = chars.next().unwrap();
62
63    if !first.is_alphabetic() && first != '_' {
64        return false;
65    }
66
67    chars.all(|c| c.is_alphanumeric() || c == '_')
68}
69
70/// Checks if a variable name is a valid state name
71/// Supports prefixes: app:, user:, temp:
72fn is_valid_state_name(var_name: &str) -> bool {
73    let parts: Vec<&str> = var_name.split(':').collect();
74
75    match parts.len() {
76        1 => is_identifier(var_name),
77        2 => {
78            let prefix = format!("{}:", parts[0]);
79            let valid_prefixes = ["app:", "user:", "temp:"];
80            valid_prefixes.contains(&prefix.as_str()) && is_identifier(parts[1])
81        }
82        _ => false,
83    }
84}
85
86/// Replaces a single placeholder match with its resolved value
87/// Handles {var}, {var?}, and {artifact.name} syntax
88async fn replace_match(ctx: &dyn InvocationContext, content: &str) -> Result<String> {
89    let var_name = content.trim();
90
91    // Check if optional (ends with ?)
92    let (var_name, optional) =
93        if let Some(name) = var_name.strip_suffix('?') { (name, true) } else { (var_name, false) };
94
95    // Handle artifact.{name} pattern
96    if let Some(file_name) = var_name.strip_prefix("artifact.") {
97        if file_name.is_empty() {
98            return Err(AdkError::agent(
99                "Invalid artifact name '': must include a file name after 'artifact.'",
100            ));
101        }
102
103        // Reject path traversal attempts in artifact names
104        if file_name.contains("..") || file_name.contains('/') || file_name.contains('\\') {
105            return Err(AdkError::agent(format!(
106                "Invalid artifact name '{file_name}': must not contain path separators or '..'"
107            )));
108        }
109
110        let artifacts = ctx
111            .artifacts()
112            .ok_or_else(|| AdkError::agent("Artifact service is not initialized"))?;
113
114        match artifacts.load(file_name).await {
115            Ok(part) => {
116                if let Some(text) = part.text() {
117                    return Ok(text.to_string());
118                }
119                Ok(String::new())
120            }
121            Err(e) => {
122                if optional {
123                    Ok(String::new())
124                } else {
125                    Err(AdkError::agent(format!("Failed to load artifact {file_name}: {e}")))
126                }
127            }
128        }
129    } else if is_valid_state_name(var_name) {
130        let state_value = ctx.session().state().get(var_name);
131
132        match state_value {
133            Some(value) => {
134                if let Some(s) = value.as_str() {
135                    Ok(s.to_string())
136                } else {
137                    Ok(format!("{}", value))
138                }
139            }
140            None => {
141                if optional {
142                    Ok(String::new())
143                } else {
144                    Err(AdkError::agent(format!("State variable '{var_name}' not found")))
145                }
146            }
147        }
148    } else {
149        // Not a valid variable name - return original match as literal
150        Ok(format!("{{{}}}", content))
151    }
152}
153
154/// Injects session state and artifact values into an instruction template
155///
156/// Supports the following placeholder syntax:
157/// - `{var_name}` - Required session state variable (errors if missing)
158/// - `{var_name?}` - Optional variable (empty string if missing)
159/// - `{artifact.file_name}` - Artifact content insertion
160/// - `{app:var}`, `{user:var}`, `{temp:var}` - Prefixed state variables
161///
162/// # Examples
163///
164/// ```ignore
165/// let template = "Hello {user_name}, your score is {score}";
166/// let result = inject_session_state(ctx, template).await?;
167/// // Result: "Hello Alice, your score is 100"
168/// ```
169///
170/// # Errors
171///
172/// Returns an error if:
173/// - A required variable is not found in session state
174/// - A required artifact cannot be loaded
175/// - The artifact service is not initialized
176pub async fn inject_session_state(ctx: &dyn InvocationContext, template: &str) -> Result<String> {
177    // Pre-allocate 20% extra capacity to reduce reallocations when placeholders expand
178    let mut result = String::with_capacity((template.len() as f32 * 1.2) as usize);
179    let mut last_end = 0;
180
181    while let Some((start, end, content)) = find_next_placeholder(template, last_end) {
182        // Append text between last match and this one
183        result.push_str(&template[last_end..start]);
184
185        // Get the replacement for the current match
186        let replacement = replace_match(ctx, content).await?;
187        result.push_str(&replacement);
188
189        last_end = end;
190    }
191
192    // Append any remaining text
193    result.push_str(&template[last_end..]);
194
195    Ok(result)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_is_identifier() {
204        assert!(is_identifier("valid_name"));
205        assert!(is_identifier("_private"));
206        assert!(is_identifier("name123"));
207        assert!(!is_identifier("123invalid"));
208        assert!(!is_identifier(""));
209        assert!(!is_identifier("with-dash"));
210    }
211
212    #[test]
213    fn test_is_valid_state_name() {
214        assert!(is_valid_state_name("valid_var"));
215        assert!(is_valid_state_name("app:config"));
216        assert!(is_valid_state_name("user:preference"));
217        assert!(is_valid_state_name("temp:data"));
218        assert!(!is_valid_state_name("invalid:prefix"));
219        assert!(!is_valid_state_name("app:invalid-name"));
220        assert!(!is_valid_state_name("too:many:parts"));
221    }
222
223    #[test]
224    fn test_find_placeholder_basic() {
225        let t = "Hello {name}, welcome!";
226        let (s, e, c) = find_next_placeholder(t, 0).unwrap();
227        assert_eq!(c, "name");
228        assert_eq!(&t[s..e], "{name}");
229    }
230
231    #[test]
232    fn test_find_placeholder_optional() {
233        let t = "Hello {name?}!";
234        let (_, _, c) = find_next_placeholder(t, 0).unwrap();
235        assert_eq!(c, "name?");
236    }
237
238    #[test]
239    fn test_find_placeholder_prefixed() {
240        let t = "Value: {app:config}";
241        let (_, _, c) = find_next_placeholder(t, 0).unwrap();
242        assert_eq!(c, "app:config");
243    }
244
245    #[test]
246    fn test_find_placeholder_artifact() {
247        let t = "Content: {artifact.readme}";
248        let (_, _, c) = find_next_placeholder(t, 0).unwrap();
249        assert_eq!(c, "artifact.readme");
250    }
251
252    #[test]
253    fn test_find_placeholder_skips_invalid() {
254        // {123} should not match (starts with digit)
255        assert!(find_next_placeholder("{123}", 0).is_none());
256        // Empty braces
257        assert!(find_next_placeholder("{}", 0).is_none());
258        // JSON-like content
259        assert!(find_next_placeholder("{\"key\": \"value\"}", 0).is_none());
260    }
261
262    #[test]
263    fn test_find_placeholder_multiple() {
264        let t = "{a} and {b}";
265        let (_, e1, c1) = find_next_placeholder(t, 0).unwrap();
266        assert_eq!(c1, "a");
267        let (_, _, c2) = find_next_placeholder(t, e1).unwrap();
268        assert_eq!(c2, "b");
269    }
270}