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.
128/// §Fase 66 (Q1) — resolve a `${...}` variable reference, supporting dotted
129/// FIELD-ACCESS on a binding whose value is a JSON object (`${e.to_id}` where
130/// `e` is a `for e in List<Record>` loop element).
131///
132/// Resolution order (back-compatible — the dotted path only fires on a miss):
133/// 1. EXACT key lookup (`vars.get("e.to_id")`) — preserves any literal dotted
134///    key a flow might have bound, and is the only path for plain `${name}`.
135/// 2. If the key contains `.` and the BASE segment (before the first `.`)
136///    resolves to a JSON object, walk the remaining `.field` path into it and
137///    render the leaf (a JSON string yields its inner text; any other JSON
138///    value yields its compact form). A non-JSON base, a missing field, or a
139///    non-object intermediate falls through to `None` (the caller keeps the
140///    `${…}` literal, exactly as for an unknown plain variable).
141pub(crate) fn resolve_dotted_var(vars: &HashMap<String, String>, key: &str) -> Option<String> {
142    if let Some(val) = vars.get(key) {
143        return Some(val.clone());
144    }
145    let (base, rest) = key.split_once('.')?;
146    let base_val = vars.get(base)?;
147    let mut cur: serde_json::Value = serde_json::from_str(base_val).ok()?;
148    for field in rest.split('.') {
149        cur = match cur {
150            serde_json::Value::Object(mut m) => m.remove(field)?,
151            _ => return None,
152        };
153    }
154    Some(match cur {
155        serde_json::Value::String(s) => s,
156        other => other.to_string(),
157    })
158}
159
160pub fn interpolate_vars(text: &str, vars: &HashMap<String, String>) -> String {
161    let bytes = text.as_bytes();
162    let mut out = String::with_capacity(text.len());
163    let mut i = 0;
164
165    while i < bytes.len() {
166        if bytes[i] == b'$' && i + 1 < bytes.len() {
167            if bytes[i + 1] == b'{' {
168                // ${name} form — incl. §66 dotted field-access (${e.field}).
169                if let Some(close) = text[i + 2..].find('}') {
170                    let var_name = &text[i + 2..i + 2 + close];
171                    if let Some(val) = resolve_dotted_var(vars, var_name) {
172                        out.push_str(&val);
173                    } else {
174                        // Unknown variable — keep literal
175                        out.push_str(&text[i..i + 3 + close]);
176                    }
177                    i += 3 + close;
178                    continue;
179                }
180            } else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
181                // $name form — consume alphanumeric + underscore
182                let start = i + 1;
183                let mut end = start;
184                while end < bytes.len()
185                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
186                {
187                    end += 1;
188                }
189                let var_name = &text[start..end];
190                if let Some(val) = vars.get(var_name) {
191                    out.push_str(val);
192                } else {
193                    out.push_str(&text[i..end]);
194                }
195                i = end;
196                continue;
197            }
198        }
199        out.push(bytes[i] as char);
200        i += 1;
201    }
202
203    out
204}
205
206/// §Fase 60 — resolve a `use Tool(k = v)` keyword-argument VALUE against the
207/// runtime bindings, by its frontend-classified `value_kind`:
208///
209/// - `"reference"` — a bare identifier (`company`), a `let` name, or a
210///   `Step.output` — resolved by binding lookup, mirroring the `let` reference
211///   handler ([`crate::flow_dispatcher::orchestration`]). Steps bind their output
212///   under their bare name, so a trailing `.output` maps to the step-name key.
213///   An unbound reference yields the empty string (the type-checker §60.c rejects
214///   unknown references at compile time, so a type-checked program never hits
215///   this) — never a silent passthrough of the literal name (the pre-60 bug).
216/// - anything else (`"literal"`) — `${…}` / `$name` interpolation, as before.
217///
218/// Shared by both dispatch paths (sync runner + streaming dispatcher) so kwarg
219/// value resolution is byte-identical (D5).
220pub fn resolve_named_arg_value(
221    value: &str,
222    value_kind: &str,
223    vars: &HashMap<String, String>,
224) -> String {
225    if value_kind == "reference" {
226        vars.get(value)
227            .or_else(|| value.strip_suffix(".output").and_then(|step| vars.get(step)))
228            .cloned()
229            .unwrap_or_default()
230    } else {
231        interpolate_vars(value, vars)
232    }
233}
234
235/// §Fase 66.1 — resolve a VALUE-POSITION expression (a `for … in <expr>`
236/// iterable, a `return <expr>`) against the runtime bindings. These positions
237/// carry no frontend `value_kind` classification (unlike a §60 kwarg), so this
238/// resolves the three reference forms a flow author writes, in order:
239///
240///   1. `"${X}"` / `"${e.field}"` / `$name` — string interpolation (incl. the
241///      §66 dotted field-access). Detected by a `$` anywhere in the expr.
242///   2. `Step.output` — a step's output. Steps bind their output under their
243///      BARE NAME (`pure_shape` / the §36.x.e contract), so a trailing
244///      `.output` maps to the step-name key. This is the canonical form an
245///      author writes for `for e in ClassifyEdges.output` / `return Step.output`
246///      (the same `.output` sugar `resolve_named_arg_value` handles for kwargs).
247///   3. `name` — a bare `let` / flow-param / step binding.
248///
249/// Falls back to the verbatim expr when nothing resolves (a genuine literal).
250/// Mirrors the persist field-value resolution (`store_row` → `interpolate_vars`)
251/// so a reference resolves identically in EVERY value position (the §66.1 fix:
252/// a `for`-iterable + a `return` previously did a bare exact-key lookup, so
253/// `ClassifyEdges.output` / `${Summarize}` reached the runtime as the literal).
254pub fn resolve_value_reference(expr: &str, vars: &HashMap<String, String>) -> String {
255    if expr.contains('$') {
256        return interpolate_vars(expr, vars);
257    }
258    if let Some(v) = vars.get(expr) {
259        return v.clone();
260    }
261    if let Some(step) = expr.strip_suffix(".output") {
262        if let Some(v) = vars.get(step) {
263            return v.clone();
264        }
265    }
266    expr.to_string()
267}
268
269// ── Tests ──────────────────────────────────────────────────────────────────
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    // ── §Fase 60 — resolve_named_arg_value ──────────────────────────────────
276
277    fn bindings() -> HashMap<String, String> {
278        let mut m = HashMap::new();
279        m.insert("user_input".to_string(), "analiza https://acme.com".to_string());
280        m.insert("company".to_string(), "Acme".to_string());
281        // A step's output is bound under its (bare) step name in both paths.
282        m.insert("ExtractUrl".to_string(), "https://acme.com".to_string());
283        m
284    }
285
286    #[test]
287    fn reference_resolves_bare_flow_param() {
288        // The pre-60 bug: a bare identifier was passed literally. Now it resolves.
289        assert_eq!(
290            resolve_named_arg_value("company", "reference", &bindings()),
291            "Acme"
292        );
293    }
294
295    #[test]
296    fn reference_resolves_step_output_dotted_to_step_name_key() {
297        // `ExtractUrl.output` → strip `.output` → the step-name binding.
298        assert_eq!(
299            resolve_named_arg_value("ExtractUrl.output", "reference", &bindings()),
300            "https://acme.com"
301        );
302    }
303
304    #[test]
305    fn reference_resolves_bare_step_name() {
306        assert_eq!(
307            resolve_named_arg_value("ExtractUrl", "reference", &bindings()),
308            "https://acme.com"
309        );
310    }
311
312    #[test]
313    fn reference_unbound_is_empty_not_literal_name() {
314        // D6 — honest empty, never the literal name passthrough (the old bug).
315        assert_eq!(resolve_named_arg_value("nope", "reference", &bindings()), "");
316    }
317
318    #[test]
319    fn literal_keeps_interpolation_and_verbatim() {
320        // A `"literal"` value keeps `${…}` interpolation (back-compat, D5).
321        assert_eq!(
322            resolve_named_arg_value("${company}", "literal", &bindings()),
323            "Acme"
324        );
325        // A bare literal string is verbatim (NOT a binding lookup).
326        assert_eq!(
327            resolve_named_arg_value("Acme", "literal", &bindings()),
328            "Acme"
329        );
330    }
331
332    #[test]
333    fn new_context_has_unit_vars() {
334        let ctx = ExecContext::new("Analyze", "Expert", 0);
335        assert_eq!(ctx.get("flow_name"), Some("Analyze"));
336        assert_eq!(ctx.get("persona_name"), Some("Expert"));
337        assert_eq!(ctx.get("unit_index"), Some("1"));
338        assert_eq!(ctx.get("result"), Some(""));
339    }
340
341    #[test]
342    fn set_step_updates_vars() {
343        let mut ctx = ExecContext::new("F", "P", 0);
344        ctx.set_step("Gather", "step", 0);
345        assert_eq!(ctx.get("step_name"), Some("Gather"));
346        assert_eq!(ctx.get("step_type"), Some("step"));
347        assert_eq!(ctx.get("step_index"), Some("1"));
348    }
349
350    #[test]
351    fn set_result_updates_both() {
352        let mut ctx = ExecContext::new("F", "P", 0);
353        ctx.set_result("Analyze", "The answer is 42");
354        assert_eq!(ctx.get("result"), Some("The answer is 42"));
355        assert_eq!(ctx.get("Analyze"), Some("The answer is 42"));
356    }
357
358    #[test]
359    fn interpolate_dollar_name() {
360        let mut ctx = ExecContext::new("F", "P", 0);
361        ctx.set_result("Analyze", "42");
362        let out = ctx.interpolate("The result is $result from step $step_name");
363        // $step_name not set yet — left as-is
364        assert!(out.contains("The result is 42"));
365    }
366
367    #[test]
368    fn interpolate_braced() {
369        let mut ctx = ExecContext::new("F", "P", 0);
370        ctx.set_result("Analyze", "42");
371        let out = ctx.interpolate("Previous: ${Analyze}, flow: ${flow_name}");
372        assert_eq!(out, "Previous: 42, flow: F");
373    }
374
375    #[test]
376    fn interpolate_unknown_kept_literal() {
377        let ctx = ExecContext::new("F", "P", 0);
378        let out = ctx.interpolate("Value: $unknown and ${also_unknown}");
379        assert_eq!(out, "Value: $unknown and ${also_unknown}");
380    }
381
382    #[test]
383    fn interpolate_no_vars() {
384        let ctx = ExecContext::new("F", "P", 0);
385        let out = ctx.interpolate("No variables here.");
386        assert_eq!(out, "No variables here.");
387    }
388
389    #[test]
390    fn interpolate_adjacent_vars() {
391        let mut ctx = ExecContext::new("F", "P", 0);
392        ctx.set("a", "hello");
393        ctx.set("b", "world");
394        let out = ctx.interpolate("$a$b");
395        assert_eq!(out, "helloworld");
396    }
397
398    #[test]
399    fn interpolate_dollar_at_end() {
400        let ctx = ExecContext::new("F", "P", 0);
401        let out = ctx.interpolate("price is $");
402        assert_eq!(out, "price is $");
403    }
404
405    #[test]
406    fn interpolate_dollar_number() {
407        let ctx = ExecContext::new("F", "P", 0);
408        let out = ctx.interpolate("cost: $100");
409        assert_eq!(out, "cost: $100");
410    }
411
412    #[test]
413    fn set_and_get_custom() {
414        let mut ctx = ExecContext::new("F", "P", 0);
415        ctx.set("custom_key", "custom_value");
416        assert_eq!(ctx.get("custom_key"), Some("custom_value"));
417    }
418
419    #[test]
420    fn var_count() {
421        let ctx = ExecContext::new("F", "P", 0);
422        // flow_name, persona_name, unit_index, result = 4
423        assert_eq!(ctx.var_count(), 4);
424    }
425
426    #[test]
427    fn user_bindings_excludes_builtins() {
428        let mut ctx = ExecContext::new("F", "P", 0);
429        ctx.set_step("Gather", "step", 0);
430        ctx.set_result("Gather", "data");
431        ctx.set("tenant_id", "acme");
432        // Built-ins (flow_name, persona_name, unit_index, result,
433        // step_name, step_type, step_index) are excluded; only the
434        // `let`/result bindings remain, sorted by name.
435        let bindings = ctx.user_bindings();
436        assert_eq!(
437            bindings,
438            vec![
439                ("Gather".to_string(), "data".to_string()),
440                ("tenant_id".to_string(), "acme".to_string()),
441            ]
442        );
443    }
444
445    #[test]
446    fn user_bindings_empty_for_fresh_context() {
447        let ctx = ExecContext::new("F", "P", 0);
448        assert!(ctx.user_bindings().is_empty());
449    }
450
451    // ── §Fase 66 (Q1) — dotted field-access interpolation ───────────────
452
453    #[test]
454    fn interpolate_resolves_dotted_field_of_a_json_object_binding() {
455        // The `for e in List<Record>` element: `e` binds to a JSON object;
456        // `${e.to_id}` must resolve to the field's inner string value (not the
457        // literal `${e.to_id}`, the pre-§66 behavior the kivi brief #27 hit).
458        let mut vars = HashMap::new();
459        vars.insert(
460            "e".to_string(),
461            r#"{"to_id":"abc-123","etype":"cite","weight":0.9}"#.to_string(),
462        );
463        assert_eq!(
464            interpolate_vars("${e.to_id}", &vars),
465            "abc-123",
466            "dotted field-access must resolve the JSON object's field"
467        );
468        assert_eq!(interpolate_vars("${e.etype}", &vars), "cite");
469        // A numeric leaf renders as its compact JSON form.
470        assert_eq!(interpolate_vars("${e.weight}", &vars), "0.9");
471        // Mixed with a literal + a plain var.
472        vars.insert("tid".to_string(), "T1".to_string());
473        assert_eq!(
474            interpolate_vars("row ${tid}/${e.to_id}", &vars),
475            "row T1/abc-123"
476        );
477    }
478
479    #[test]
480    fn interpolate_dotted_misses_stay_literal_and_exact_keys_win() {
481        let mut vars = HashMap::new();
482        // Base is not JSON → keep the literal (never panics, never half-resolves).
483        vars.insert("e".to_string(), "not json".to_string());
484        assert_eq!(interpolate_vars("${e.to_id}", &vars), "${e.to_id}");
485        // Unknown base → literal.
486        assert_eq!(interpolate_vars("${missing.x}", &vars), "${missing.x}");
487        // Missing field on a valid object → literal.
488        vars.insert("o".to_string(), r#"{"a":"1"}"#.to_string());
489        assert_eq!(interpolate_vars("${o.b}", &vars), "${o.b}");
490        // Back-compat: an EXACT dotted key (a literal binding) still wins over
491        // the JSON walk.
492        vars.insert("o.b".to_string(), "exact".to_string());
493        assert_eq!(interpolate_vars("${o.b}", &vars), "exact");
494        // A plain (non-dotted) var is unchanged.
495        assert_eq!(interpolate_vars("${o}", &vars), r#"{"a":"1"}"#);
496    }
497
498    // ── §Fase 66.1 — value-position reference resolution ────────────────
499
500    #[test]
501    fn resolve_value_reference_handles_step_output_and_interpolation() {
502        let mut vars = HashMap::new();
503        // Steps bind their output under the BARE NAME.
504        vars.insert("ClassifyEdges".to_string(), r#"[{"to_id":"x"}]"#.to_string());
505        vars.insert("Summarize".to_string(), "the summary".to_string());
506        vars.insert("q".to_string(), "hi".to_string());
507
508        // `Step.output` → the step's output (the `.output` maps to the name key)
509        // — the kivi #28 `for e in ClassifyEdges.output` + `return Step.output`.
510        assert_eq!(
511            resolve_value_reference("ClassifyEdges.output", &vars),
512            r#"[{"to_id":"x"}]"#
513        );
514        // `${Step}` interpolation — the `return "${Summarize}"` case (#28 §C).
515        assert_eq!(
516            resolve_value_reference("${Summarize}", &vars),
517            "the summary"
518        );
519        // A bare binding name.
520        assert_eq!(resolve_value_reference("q", &vars), "hi");
521        // A genuine literal stays verbatim.
522        assert_eq!(resolve_value_reference("plain literal", &vars), "plain literal");
523        // An unknown `Step.output` falls back to the literal (not a half-resolve).
524        assert_eq!(resolve_value_reference("Missing.output", &vars), "Missing.output");
525    }
526}