Skip to main content

a3s_flow/nodes/
assign.rs

1//! `"assign"` node — write key-value pairs into the flow's variable scope.
2//!
3//! Evaluates each entry in `data["assigns"]` and returns the result as a JSON
4//! object. The runner automatically merges this output into the live variable
5//! map so that all downstream nodes see the new values in `ctx.variables`.
6//!
7//! String values are rendered as Jinja2 templates (same context as the `"llm"`
8//! node: variables + upstream inputs). Non-string JSON values (numbers,
9//! booleans, objects, arrays) are used as-is.
10//!
11//! # Config schema
12//!
13//! ```json
14//! {
15//!   "assigns": {
16//!     "greeting": "Hello, {{ name }}!",
17//!     "count":    0,
18//!     "tags":     ["a", "b"]
19//!   }
20//! }
21//! ```
22//!
23//! | Field | Type | Required | Description |
24//! |-------|------|:--------:|-------------|
25//! | `assigns` | object | ✅ | Map of variable names to Jinja2 templates (strings) or literal JSON values |
26//!
27//! ## Template context
28//!
29//! Same as the `"llm"` node: all global `variables` plus all upstream node
30//! outputs keyed by node ID. Upstream inputs shadow variables with the same key.
31//!
32//! # Output schema
33//!
34//! The output is the resolved assignment map — identical to what is merged into
35//! the flow's variable scope:
36//!
37//! ```json
38//! { "greeting": "Hello, Alice!", "count": 0, "tags": ["a", "b"] }
39//! ```
40//!
41//! # Example
42//!
43//! ```json
44//! {
45//!   "id": "init",
46//!   "type": "assign",
47//!   "data": {
48//!     "assigns": {
49//!       "user_name": "{{ start.name }}",
50//!       "attempt":   1
51//!     }
52//!   }
53//! }
54//! ```
55
56use async_trait::async_trait;
57use serde_json::Value;
58
59use crate::error::{FlowError, Result};
60use crate::node::{ExecContext, Node};
61
62use super::llm::{build_jinja_context, render};
63
64/// Assign node — merges key-value pairs into the flow's variable scope.
65pub struct AssignNode;
66
67#[async_trait]
68impl Node for AssignNode {
69    fn node_type(&self) -> &str {
70        "assign"
71    }
72
73    async fn execute(&self, ctx: ExecContext) -> Result<Value> {
74        let assigns = ctx.data["assigns"].as_object().ok_or_else(|| {
75            FlowError::InvalidDefinition("assign: missing or non-object data.assigns".into())
76        })?;
77
78        let jinja_ctx = build_jinja_context(&ctx);
79        let mut out = serde_json::Map::new();
80
81        for (key, template_val) in assigns {
82            let resolved = if let Some(s) = template_val.as_str() {
83                // String values are Jinja2 templates.
84                Value::String(render(s, &jinja_ctx)?)
85            } else {
86                // Non-string values are used as-is (numbers, bools, objects, arrays).
87                template_val.clone()
88            };
89            out.insert(key.clone(), resolved);
90        }
91
92        Ok(Value::Object(out))
93    }
94}
95
96// ── Tests ─────────────────────────────────────────────────────────────────────
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use serde_json::json;
102    use std::collections::HashMap;
103
104    fn ctx(data: Value) -> ExecContext {
105        ExecContext {
106            data,
107            ..Default::default()
108        }
109    }
110
111    fn ctx_with_vars(data: Value, variables: HashMap<String, Value>) -> ExecContext {
112        ExecContext {
113            data,
114            variables,
115            ..Default::default()
116        }
117    }
118
119    fn ctx_with_inputs(
120        data: Value,
121        inputs: HashMap<String, Value>,
122        variables: HashMap<String, Value>,
123    ) -> ExecContext {
124        ExecContext {
125            data,
126            inputs,
127            variables,
128            ..Default::default()
129        }
130    }
131
132    #[tokio::test]
133    async fn assigns_string_template() {
134        let node = AssignNode;
135        let out = node
136            .execute(ctx_with_vars(
137                json!({ "assigns": { "greeting": "Hello, {{ name }}!" } }),
138                HashMap::from([("name".into(), json!("Alice"))]),
139            ))
140            .await
141            .unwrap();
142        assert_eq!(out["greeting"], json!("Hello, Alice!"));
143    }
144
145    #[tokio::test]
146    async fn assigns_literal_non_string_values() {
147        let node = AssignNode;
148        let out = node
149            .execute(ctx(json!({
150                "assigns": {
151                    "count":  0,
152                    "flag":   true,
153                    "tags":   ["a", "b"],
154                    "nested": { "x": 1 }
155                }
156            })))
157            .await
158            .unwrap();
159        assert_eq!(out["count"], json!(0));
160        assert_eq!(out["flag"], json!(true));
161        assert_eq!(out["tags"], json!(["a", "b"]));
162        assert_eq!(out["nested"], json!({ "x": 1 }));
163    }
164
165    #[tokio::test]
166    async fn template_reads_upstream_input() {
167        let node = AssignNode;
168        let out = node
169            .execute(ctx_with_inputs(
170                json!({ "assigns": { "msg": "status={{ fetch.status }}" } }),
171                HashMap::from([("fetch".into(), json!({ "status": 200 }))]),
172                HashMap::new(),
173            ))
174            .await
175            .unwrap();
176        assert_eq!(out["msg"], json!("status=200"));
177    }
178
179    #[tokio::test]
180    async fn inputs_shadow_variables_in_template() {
181        let node = AssignNode;
182        let out = node
183            .execute(ctx_with_inputs(
184                json!({ "assigns": { "val": "{{ x }}" } }),
185                HashMap::from([("x".into(), json!("from_input"))]),
186                HashMap::from([("x".into(), json!("from_var"))]),
187            ))
188            .await
189            .unwrap();
190        assert_eq!(out["val"], json!("from_input"));
191    }
192
193    #[tokio::test]
194    async fn empty_assigns_returns_empty_object() {
195        let node = AssignNode;
196        let out = node.execute(ctx(json!({ "assigns": {} }))).await.unwrap();
197        assert_eq!(out, json!({}));
198    }
199
200    #[tokio::test]
201    async fn missing_assigns_returns_error() {
202        let node = AssignNode;
203        let err = node.execute(ctx(json!({}))).await.unwrap_err();
204        assert!(matches!(err, FlowError::InvalidDefinition(_)));
205    }
206
207    #[tokio::test]
208    async fn non_object_assigns_returns_error() {
209        let node = AssignNode;
210        let err = node
211            .execute(ctx(json!({ "assigns": ["not", "an", "object"] })))
212            .await
213            .unwrap_err();
214        assert!(matches!(err, FlowError::InvalidDefinition(_)));
215    }
216
217    #[tokio::test]
218    async fn invalid_template_returns_error() {
219        let node = AssignNode;
220        let err = node
221            .execute(ctx(
222                json!({ "assigns": { "x": "{{ bad | unknown_filter }}" } }),
223            ))
224            .await
225            .unwrap_err();
226        assert!(matches!(err, FlowError::Internal(_)));
227    }
228}