Hoist the three copies of apply_state_updates (llm, agent, rag) into
graph::state_updates::apply(state, output, has_schema, updates). The
helper computes every state_updates value first, restores the transient
`output` binding, and only then writes the computed updates, so a
`state_updates: { output: "{{output}}" }` entry is no longer clobbered
by the restore step.
The per-file wrappers stay as one-line calls so their existing tests are
unchanged; new direct helper tests live in state_updates.rs, including
the regression test for the clobber.
Design: plans/map-subgraph-branches-design.md §2.6, §3.7 (C5, D3).
249 lines
7.5 KiB
Rust
249 lines
7.5 KiB
Rust
use super::state::StateManager;
|
|
use super::state_updates;
|
|
use super::structured;
|
|
use super::types::AgentNode;
|
|
use crate::config::RequestContext;
|
|
use crate::function::agents::run_agent_for_graph;
|
|
use anyhow::{Context, Result};
|
|
use serde_json::Value;
|
|
use std::time::Duration;
|
|
use tokio::time::timeout;
|
|
|
|
const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
|
|
|
pub struct AgentNodeExecutor;
|
|
|
|
impl AgentNodeExecutor {
|
|
pub async fn execute(
|
|
node: &AgentNode,
|
|
state_manager: &mut StateManager,
|
|
parent_ctx: &mut RequestContext,
|
|
) -> Result<String> {
|
|
let prompt = state_manager
|
|
.interpolate(&node.prompt)
|
|
.with_context(|| format!("Failed to interpolate prompt for agent '{}'", node.agent))?;
|
|
|
|
let timeout_dur = Duration::from_secs(node.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS));
|
|
|
|
let raw = timeout(
|
|
timeout_dur,
|
|
run_agent_for_graph(parent_ctx, &node.agent, &prompt),
|
|
)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"Agent '{}' timed out after {}s",
|
|
node.agent,
|
|
timeout_dur.as_secs()
|
|
)
|
|
})?
|
|
.with_context(|| format!("Agent '{}' failed", node.agent))?;
|
|
|
|
let output_value = match &node.output_schema {
|
|
Some(schema) => structured::extract(&raw, schema, parent_ctx)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"Agent '{}' output failed structured-output extraction",
|
|
node.agent
|
|
)
|
|
})?,
|
|
None => Value::String(raw.clone()),
|
|
};
|
|
|
|
apply_state_updates(node, state_manager, &output_value);
|
|
|
|
Ok(raw)
|
|
}
|
|
}
|
|
|
|
fn apply_state_updates(node: &AgentNode, state_manager: &mut StateManager, output: &Value) {
|
|
state_updates::apply(
|
|
state_manager,
|
|
output,
|
|
node.output_schema.is_some(),
|
|
node.state_updates.as_ref(),
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::types::AgentNode;
|
|
use super::*;
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
|
|
fn manager_with(pairs: &[(&str, Value)]) -> StateManager {
|
|
let mut map = HashMap::new();
|
|
for (k, v) in pairs {
|
|
map.insert((*k).into(), v.clone());
|
|
}
|
|
StateManager::new(map)
|
|
}
|
|
|
|
fn node_with(prompt: &str, updates: Option<HashMap<String, String>>) -> AgentNode {
|
|
AgentNode {
|
|
agent: "test_agent".into(),
|
|
prompt: prompt.into(),
|
|
state_updates: updates,
|
|
output_schema: None,
|
|
timeout: None,
|
|
teammates: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn state_updates_use_output_placeholder() {
|
|
let node = {
|
|
let mut u = HashMap::new();
|
|
u.insert("findings".into(), "{{output}}".into());
|
|
node_with("hi", Some(u))
|
|
};
|
|
let mut state = manager_with(&[]);
|
|
|
|
apply_state_updates(&node, &mut state, &json!("agent finished its work"));
|
|
|
|
assert_eq!(
|
|
state.state().get("findings"),
|
|
Some(&json!("agent finished its work"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn state_updates_can_reference_existing_keys_and_output() {
|
|
let node = {
|
|
let mut u = HashMap::new();
|
|
u.insert("summary".into(), "{{topic}}: {{output}}".into());
|
|
node_with("hi", Some(u))
|
|
};
|
|
let mut state = manager_with(&[("topic", json!("auth"))]);
|
|
|
|
apply_state_updates(&node, &mut state, &json!("JWT vs sessions"));
|
|
|
|
assert_eq!(
|
|
state.state().get("summary"),
|
|
Some(&json!("auth: JWT vs sessions"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn output_key_is_cleaned_up_after_state_updates() {
|
|
let node = {
|
|
let mut u = HashMap::new();
|
|
u.insert("findings".into(), "{{output}}".into());
|
|
node_with("hi", Some(u))
|
|
};
|
|
let mut state = manager_with(&[]);
|
|
|
|
apply_state_updates(&node, &mut state, &json!("anything"));
|
|
|
|
assert_eq!(state.state().get("output"), Some(&Value::Null));
|
|
}
|
|
|
|
#[test]
|
|
fn pre_existing_output_value_is_preserved() {
|
|
let node = {
|
|
let mut u = HashMap::new();
|
|
u.insert("greeting".into(), "{{output}}".into());
|
|
node_with("hi", Some(u))
|
|
};
|
|
let mut state = manager_with(&[("output", json!("preserved"))]);
|
|
|
|
apply_state_updates(&node, &mut state, &json!("new agent output"));
|
|
|
|
assert_eq!(
|
|
state.state().get("greeting"),
|
|
Some(&json!("new agent output"))
|
|
);
|
|
assert_eq!(state.state().get("output"), Some(&json!("preserved")));
|
|
}
|
|
|
|
#[test]
|
|
fn no_state_updates_is_a_noop() {
|
|
let node = node_with("hi", None);
|
|
let mut state = manager_with(&[("k", json!("v"))]);
|
|
|
|
apply_state_updates(&node, &mut state, &json!("ignored"));
|
|
|
|
assert_eq!(state.state().get("k"), Some(&json!("v")));
|
|
assert!(state.state().get("output").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn interpolate_lenient_on_state_updates_handles_missing_keys() {
|
|
let node = {
|
|
let mut u = HashMap::new();
|
|
u.insert("decorated".into(), "[{{missing}}] {{output}}".into());
|
|
node_with("hi", Some(u))
|
|
};
|
|
let mut state = manager_with(&[]);
|
|
|
|
apply_state_updates(&node, &mut state, &json!("DATA"));
|
|
|
|
assert_eq!(state.state().get("decorated"), Some(&json!("[] DATA")));
|
|
}
|
|
|
|
fn node_with_schema(
|
|
prompt: &str,
|
|
updates: Option<HashMap<String, String>>,
|
|
schema: Value,
|
|
) -> AgentNode {
|
|
let mut n = node_with(prompt, updates);
|
|
n.output_schema = Some(schema);
|
|
n
|
|
}
|
|
|
|
#[test]
|
|
fn output_schema_auto_merges_top_level_keys() {
|
|
let node = node_with_schema("hi", None, json!({"type": "object"}));
|
|
let mut state = manager_with(&[]);
|
|
let output = json!({"goal": "do X", "summary": "details"});
|
|
|
|
apply_state_updates(&node, &mut state, &output);
|
|
|
|
assert_eq!(state.state().get("goal"), Some(&json!("do X")));
|
|
assert_eq!(state.state().get("summary"), Some(&json!("details")));
|
|
}
|
|
|
|
#[test]
|
|
fn output_schema_preserves_nested_value_types() {
|
|
let node = node_with_schema("hi", None, json!({"type": "object"}));
|
|
let mut state = manager_with(&[]);
|
|
let output = json!({
|
|
"tags": ["a", "b"],
|
|
"config": { "key": "value" },
|
|
"count": 42
|
|
});
|
|
|
|
apply_state_updates(&node, &mut state, &output);
|
|
|
|
assert_eq!(state.state().get("tags"), Some(&json!(["a", "b"])));
|
|
assert_eq!(state.state().get("config"), Some(&json!({"key": "value"})));
|
|
assert_eq!(state.state().get("count"), Some(&json!(42)));
|
|
}
|
|
|
|
#[test]
|
|
fn output_schema_explicit_state_updates_override_auto_merge() {
|
|
let mut u = HashMap::new();
|
|
u.insert("goal".into(), "renamed-{{output.goal}}".into());
|
|
let node = node_with_schema("hi", Some(u), json!({"type": "object"}));
|
|
let mut state = manager_with(&[]);
|
|
let output = json!({"goal": "do X"});
|
|
|
|
apply_state_updates(&node, &mut state, &output);
|
|
|
|
assert_eq!(state.state().get("goal"), Some(&json!("renamed-do X")));
|
|
}
|
|
|
|
#[test]
|
|
fn no_schema_does_not_auto_merge() {
|
|
let node = node_with("hi", None);
|
|
let mut state = manager_with(&[]);
|
|
let output = json!({"goal": "do X"});
|
|
|
|
apply_state_updates(&node, &mut state, &output);
|
|
|
|
assert!(state.state().get("goal").is_none());
|
|
}
|
|
}
|