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