Skip to main content

axon/
exec_context.rs

1//! Execution context — runtime variables accessible between steps.
2//!
3//! Provides `$variable` interpolation in user prompts and system prompts.
4//! Variables are populated automatically by the runner as steps execute.
5//!
6//! Built-in variables:
7//!   $result       — output of the most recent step
8//!   $step_name    — name of the current step
9//!   $step_type    — type of the current step
10//!   $flow_name    — name of the current flow
11//!   $persona_name — name of the current persona
12//!   $unit_index   — 1-based index of the current execution unit
13//!   $step_index   — 1-based index of the current step within the unit
14//!   ${StepName}   — result of a specific named step (e.g., ${Analyze})
15//!
16//! Variable syntax: `$name` or `${name}` (braces for disambiguation).
17
18use std::collections::HashMap;
19
20/// Variable names the runner manages internally. They are excluded from
21/// the "user binding" view (see [`ExecContext::user_bindings`]) so that
22/// a `persist`/`mutate` into a SQL-backed `axonstore` writes only the
23/// flow's own data as a row — never runner bookkeeping.
24const BUILTIN_VARS: &[&str] = &[
25    "flow_name",
26    "persona_name",
27    "unit_index",
28    "result",
29    "step_name",
30    "step_type",
31    "step_index",
32];
33
34/// Execution context — holds runtime variables for a single execution unit.
35#[derive(Debug, Clone)]
36pub struct ExecContext {
37    vars: HashMap<String, String>,
38}
39
40impl ExecContext {
41    /// Create a new context with unit-level variables pre-set.
42    pub fn new(flow_name: &str, persona_name: &str, unit_index: usize) -> Self {
43        let mut vars = HashMap::new();
44        vars.insert("flow_name".to_string(), flow_name.to_string());
45        vars.insert("persona_name".to_string(), persona_name.to_string());
46        vars.insert("unit_index".to_string(), format!("{}", unit_index + 1));
47        vars.insert("result".to_string(), String::new());
48        ExecContext { vars }
49    }
50
51    /// Set a variable.
52    pub fn set(&mut self, key: &str, value: &str) {
53        self.vars.insert(key.to_string(), value.to_string());
54    }
55
56    /// Get a variable value.
57    pub fn get(&self, key: &str) -> Option<&str> {
58        self.vars.get(key).map(|s| s.as_str())
59    }
60
61    /// §Fase 37.d (D3) — the full variable map, for resolving `${name}`
62    /// placeholders in a store `where:` clause against the flow context
63    /// (the Request Binding Contract on the synchronous filter path).
64    pub fn vars(&self) -> &HashMap<String, String> {
65        &self.vars
66    }
67
68    /// Set the current step context variables.
69    pub fn set_step(&mut self, step_name: &str, step_type: &str, step_index: usize) {
70        self.vars.insert("step_name".to_string(), step_name.to_string());
71        self.vars.insert("step_type".to_string(), step_type.to_string());
72        self.vars.insert("step_index".to_string(), format!("{}", step_index + 1));
73    }
74
75    /// Record the result of a step (updates $result and ${StepName}).
76    pub fn set_result(&mut self, step_name: &str, result: &str) {
77        self.vars.insert("result".to_string(), result.to_string());
78        self.vars.insert(step_name.to_string(), result.to_string());
79    }
80
81    /// Interpolate variables in a string.
82    ///
83    /// Replaces `${name}` and `$name` with their values from the context.
84    /// Unknown variables are left as-is. Delegates to the free
85    /// [`interpolate_vars`] so the streaming dispatcher interpolates
86    /// `persist` field values with byte-identical semantics (D5).
87    pub fn interpolate(&self, text: &str) -> String {
88        interpolate_vars(text, &self.vars)
89    }
90
91    /// §Fase 60 — resolve a `use Tool(k = v)` keyword-arg value by its
92    /// `value_kind` (reference → binding lookup; literal → interpolation).
93    /// Delegates to the free [`resolve_named_arg_value`] so the sync runner and
94    /// the streaming dispatcher resolve kwargs byte-identically (D5).
95    pub fn resolve_named_arg(&self, value: &str, value_kind: &str) -> String {
96        resolve_named_arg_value(value, value_kind, &self.vars)
97    }
98
99    /// Number of variables currently set.
100    pub fn var_count(&self) -> usize {
101        self.vars.len()
102    }
103
104    /// The user-meaningful bindings — every variable that is not a
105    /// runner built-in ([`BUILTIN_VARS`]): `let` bindings and step
106    /// results keyed by step name. These are the columns a `persist` /
107    /// `mutate` into a postgresql-backed `axonstore` writes as a row
108    /// (Fase 35.e). Sorted by name for deterministic SQL.
109    pub fn user_bindings(&self) -> Vec<(String, String)> {
110        let mut out: Vec<(String, String)> = self
111            .vars
112            .iter()
113            .filter(|(k, _)| !BUILTIN_VARS.contains(&k.as_str()))
114            .map(|(k, v)| (k.clone(), v.clone()))
115            .collect();
116        out.sort_by(|a, b| a.0.cmp(&b.0));
117        out
118    }
119}
120
121/// §Fase 35.o — Interpolate `${name}` / `$name` references in `text`
122/// against an arbitrary variable map. Extracted from
123/// [`ExecContext::interpolate`] so both execution paths — the sync
124/// runner (`ExecContext.vars`) and the streaming dispatcher
125/// (`DispatchCtx.let_bindings`) — interpolate `persist` field values
126/// with byte-identical semantics (D5: the two paths never diverge).
127/// Unknown variables are left literal.
128pub fn interpolate_vars(text: &str, vars: &HashMap<String, String>) -> String {
129    let bytes = text.as_bytes();
130    let mut out = String::with_capacity(text.len());
131    let mut i = 0;
132
133    while i < bytes.len() {
134        if bytes[i] == b'$' && i + 1 < bytes.len() {
135            if bytes[i + 1] == b'{' {
136                // ${name} form
137                if let Some(close) = text[i + 2..].find('}') {
138                    let var_name = &text[i + 2..i + 2 + close];
139                    if let Some(val) = vars.get(var_name) {
140                        out.push_str(val);
141                    } else {
142                        // Unknown variable — keep literal
143                        out.push_str(&text[i..i + 3 + close]);
144                    }
145                    i += 3 + close;
146                    continue;
147                }
148            } else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
149                // $name form — consume alphanumeric + underscore
150                let start = i + 1;
151                let mut end = start;
152                while end < bytes.len()
153                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
154                {
155                    end += 1;
156                }
157                let var_name = &text[start..end];
158                if let Some(val) = vars.get(var_name) {
159                    out.push_str(val);
160                } else {
161                    out.push_str(&text[i..end]);
162                }
163                i = end;
164                continue;
165            }
166        }
167        out.push(bytes[i] as char);
168        i += 1;
169    }
170
171    out
172}
173
174/// §Fase 60 — resolve a `use Tool(k = v)` keyword-argument VALUE against the
175/// runtime bindings, by its frontend-classified `value_kind`:
176///
177/// - `"reference"` — a bare identifier (`company`), a `let` name, or a
178///   `Step.output` — resolved by binding lookup, mirroring the `let` reference
179///   handler ([`crate::flow_dispatcher::orchestration`]). Steps bind their output
180///   under their bare name, so a trailing `.output` maps to the step-name key.
181///   An unbound reference yields the empty string (the type-checker §60.c rejects
182///   unknown references at compile time, so a type-checked program never hits
183///   this) — never a silent passthrough of the literal name (the pre-60 bug).
184/// - anything else (`"literal"`) — `${…}` / `$name` interpolation, as before.
185///
186/// Shared by both dispatch paths (sync runner + streaming dispatcher) so kwarg
187/// value resolution is byte-identical (D5).
188pub fn resolve_named_arg_value(
189    value: &str,
190    value_kind: &str,
191    vars: &HashMap<String, String>,
192) -> String {
193    if value_kind == "reference" {
194        vars.get(value)
195            .or_else(|| value.strip_suffix(".output").and_then(|step| vars.get(step)))
196            .cloned()
197            .unwrap_or_default()
198    } else {
199        interpolate_vars(value, vars)
200    }
201}
202
203// ── Tests ──────────────────────────────────────────────────────────────────
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    // ── §Fase 60 — resolve_named_arg_value ──────────────────────────────────
210
211    fn bindings() -> HashMap<String, String> {
212        let mut m = HashMap::new();
213        m.insert("user_input".to_string(), "analiza https://acme.com".to_string());
214        m.insert("company".to_string(), "Acme".to_string());
215        // A step's output is bound under its (bare) step name in both paths.
216        m.insert("ExtractUrl".to_string(), "https://acme.com".to_string());
217        m
218    }
219
220    #[test]
221    fn reference_resolves_bare_flow_param() {
222        // The pre-60 bug: a bare identifier was passed literally. Now it resolves.
223        assert_eq!(
224            resolve_named_arg_value("company", "reference", &bindings()),
225            "Acme"
226        );
227    }
228
229    #[test]
230    fn reference_resolves_step_output_dotted_to_step_name_key() {
231        // `ExtractUrl.output` → strip `.output` → the step-name binding.
232        assert_eq!(
233            resolve_named_arg_value("ExtractUrl.output", "reference", &bindings()),
234            "https://acme.com"
235        );
236    }
237
238    #[test]
239    fn reference_resolves_bare_step_name() {
240        assert_eq!(
241            resolve_named_arg_value("ExtractUrl", "reference", &bindings()),
242            "https://acme.com"
243        );
244    }
245
246    #[test]
247    fn reference_unbound_is_empty_not_literal_name() {
248        // D6 — honest empty, never the literal name passthrough (the old bug).
249        assert_eq!(resolve_named_arg_value("nope", "reference", &bindings()), "");
250    }
251
252    #[test]
253    fn literal_keeps_interpolation_and_verbatim() {
254        // A `"literal"` value keeps `${…}` interpolation (back-compat, D5).
255        assert_eq!(
256            resolve_named_arg_value("${company}", "literal", &bindings()),
257            "Acme"
258        );
259        // A bare literal string is verbatim (NOT a binding lookup).
260        assert_eq!(
261            resolve_named_arg_value("Acme", "literal", &bindings()),
262            "Acme"
263        );
264    }
265
266    #[test]
267    fn new_context_has_unit_vars() {
268        let ctx = ExecContext::new("Analyze", "Expert", 0);
269        assert_eq!(ctx.get("flow_name"), Some("Analyze"));
270        assert_eq!(ctx.get("persona_name"), Some("Expert"));
271        assert_eq!(ctx.get("unit_index"), Some("1"));
272        assert_eq!(ctx.get("result"), Some(""));
273    }
274
275    #[test]
276    fn set_step_updates_vars() {
277        let mut ctx = ExecContext::new("F", "P", 0);
278        ctx.set_step("Gather", "step", 0);
279        assert_eq!(ctx.get("step_name"), Some("Gather"));
280        assert_eq!(ctx.get("step_type"), Some("step"));
281        assert_eq!(ctx.get("step_index"), Some("1"));
282    }
283
284    #[test]
285    fn set_result_updates_both() {
286        let mut ctx = ExecContext::new("F", "P", 0);
287        ctx.set_result("Analyze", "The answer is 42");
288        assert_eq!(ctx.get("result"), Some("The answer is 42"));
289        assert_eq!(ctx.get("Analyze"), Some("The answer is 42"));
290    }
291
292    #[test]
293    fn interpolate_dollar_name() {
294        let mut ctx = ExecContext::new("F", "P", 0);
295        ctx.set_result("Analyze", "42");
296        let out = ctx.interpolate("The result is $result from step $step_name");
297        // $step_name not set yet — left as-is
298        assert!(out.contains("The result is 42"));
299    }
300
301    #[test]
302    fn interpolate_braced() {
303        let mut ctx = ExecContext::new("F", "P", 0);
304        ctx.set_result("Analyze", "42");
305        let out = ctx.interpolate("Previous: ${Analyze}, flow: ${flow_name}");
306        assert_eq!(out, "Previous: 42, flow: F");
307    }
308
309    #[test]
310    fn interpolate_unknown_kept_literal() {
311        let ctx = ExecContext::new("F", "P", 0);
312        let out = ctx.interpolate("Value: $unknown and ${also_unknown}");
313        assert_eq!(out, "Value: $unknown and ${also_unknown}");
314    }
315
316    #[test]
317    fn interpolate_no_vars() {
318        let ctx = ExecContext::new("F", "P", 0);
319        let out = ctx.interpolate("No variables here.");
320        assert_eq!(out, "No variables here.");
321    }
322
323    #[test]
324    fn interpolate_adjacent_vars() {
325        let mut ctx = ExecContext::new("F", "P", 0);
326        ctx.set("a", "hello");
327        ctx.set("b", "world");
328        let out = ctx.interpolate("$a$b");
329        assert_eq!(out, "helloworld");
330    }
331
332    #[test]
333    fn interpolate_dollar_at_end() {
334        let ctx = ExecContext::new("F", "P", 0);
335        let out = ctx.interpolate("price is $");
336        assert_eq!(out, "price is $");
337    }
338
339    #[test]
340    fn interpolate_dollar_number() {
341        let ctx = ExecContext::new("F", "P", 0);
342        let out = ctx.interpolate("cost: $100");
343        assert_eq!(out, "cost: $100");
344    }
345
346    #[test]
347    fn set_and_get_custom() {
348        let mut ctx = ExecContext::new("F", "P", 0);
349        ctx.set("custom_key", "custom_value");
350        assert_eq!(ctx.get("custom_key"), Some("custom_value"));
351    }
352
353    #[test]
354    fn var_count() {
355        let ctx = ExecContext::new("F", "P", 0);
356        // flow_name, persona_name, unit_index, result = 4
357        assert_eq!(ctx.var_count(), 4);
358    }
359
360    #[test]
361    fn user_bindings_excludes_builtins() {
362        let mut ctx = ExecContext::new("F", "P", 0);
363        ctx.set_step("Gather", "step", 0);
364        ctx.set_result("Gather", "data");
365        ctx.set("tenant_id", "acme");
366        // Built-ins (flow_name, persona_name, unit_index, result,
367        // step_name, step_type, step_index) are excluded; only the
368        // `let`/result bindings remain, sorted by name.
369        let bindings = ctx.user_bindings();
370        assert_eq!(
371            bindings,
372            vec![
373                ("Gather".to_string(), "data".to_string()),
374                ("tenant_id".to_string(), "acme".to_string()),
375            ]
376        );
377    }
378
379    #[test]
380    fn user_bindings_empty_for_fresh_context() {
381        let ctx = ExecContext::new("F", "P", 0);
382        assert!(ctx.user_bindings().is_empty());
383    }
384}