feat: added structured-output extraction for llm and agent nodes

This commit is contained in:
2026-05-14 15:36:10 -06:00
parent 7f620d469b
commit aa4babff56
7 changed files with 512 additions and 28 deletions
+75 -5
View File
@@ -13,13 +13,14 @@ use std::path::PathBuf;
use std::sync::LazyLock;
static TEMPLATE_VAR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{([a-zA-Z0-9_\.]+)\}\}").expect("invalid template regex"));
LazyLock::new(|| Regex::new(r"\{\{([a-zA-Z0-9_\.\[\]]+)\}\}").expect("invalid template regex"));
/// Wraps [`GraphState`] with template interpolation, script-output merging,
/// and a large-state temp-file fallback for use with scripts.
///
/// Template syntax: `{{key}}` for top-level keys, `{{a.b.c}}` for nested
/// JSON paths. Use [`StateManager::interpolate`] for strict interpolation
/// JSON paths, and `{{arr[0]}}` / `{{a.b[2].c}}` / `{{matrix[0][1]}}` for
/// array indices. Use [`StateManager::interpolate`] for strict interpolation
/// (errors on missing keys) or [`StateManager::interpolate_lenient`] for
/// best-effort (missing keys become empty strings).
pub struct StateManager {
@@ -89,10 +90,20 @@ impl StateManager {
fn get_nested_value(&self, key: &str) -> Option<&Value> {
let mut parts = key.split('.');
let root = parts.next()?;
let mut current = self.state.get(root)?;
let first = parts.next()?;
let (root_key, root_indices) = split_indices(first)?;
let mut current = self.state.get(root_key)?;
for idx in root_indices {
current = current.get(idx)?;
}
for part in parts {
current = current.get(part)?;
let (segment_key, indices) = split_indices(part)?;
if !segment_key.is_empty() {
current = current.get(segment_key)?;
}
for idx in indices {
current = current.get(idx)?;
}
}
Some(current)
}
@@ -203,6 +214,26 @@ impl StateRepresentation {
}
}
fn split_indices(segment: &str) -> Option<(&str, Vec<usize>)> {
let bracket_start = segment.find('[');
let key = match bracket_start {
Some(i) => &segment[..i],
None => return Some((segment, Vec::new())),
};
let mut indices = Vec::new();
let mut rest = &segment[bracket_start.unwrap()..];
while !rest.is_empty() {
if !rest.starts_with('[') {
return None;
}
let close = rest.find(']')?;
let idx: usize = rest[1..close].parse().ok()?;
indices.push(idx);
rest = &rest[close + 1..];
}
Some((key, indices))
}
fn value_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
@@ -321,6 +352,45 @@ mod tests {
assert_eq!(result, r#"{"key":"value"}"#);
}
#[test]
fn interpolates_array_indices() {
let manager = manager_with(&[("items", json!(["a", "b", "c"]))]);
assert_eq!(manager.interpolate("{{items[0]}}").unwrap(), "a");
assert_eq!(manager.interpolate("{{items[2]}}").unwrap(), "c");
}
#[test]
fn interpolates_array_indices_inside_nested_paths() {
let manager = manager_with(&[("outer", json!({ "inner": { "arr": ["x", "y", "z"] } }))]);
let result = manager
.interpolate("first={{outer.inner.arr[0]}} last={{outer.inner.arr[2]}}")
.unwrap();
assert_eq!(result, "first=x last=z");
}
#[test]
fn interpolates_object_fields_after_array_index() {
let manager = manager_with(&[("users", json!([{ "name": "Alice" }, { "name": "Bob" }]))]);
let result = manager
.interpolate("{{users[0].name}} and {{users[1].name}}")
.unwrap();
assert_eq!(result, "Alice and Bob");
}
#[test]
fn interpolates_nested_array_indices() {
let manager = manager_with(&[("matrix", json!([[1, 2], [3, 4]]))]);
assert_eq!(manager.interpolate("{{matrix[0][1]}}").unwrap(), "2");
assert_eq!(manager.interpolate("{{matrix[1][0]}}").unwrap(), "3");
}
#[test]
fn out_of_bounds_array_index_is_missing() {
let manager = manager_with(&[("items", json!(["a", "b"]))]);
let err = manager.interpolate("{{items[5]}}").unwrap_err().to_string();
assert!(err.contains("not found"), "got: {err}");
}
#[test]
fn replaces_all_occurrences_of_same_key() {
let manager = manager_with(&[("n", json!("Alice"))]);