From 2e7cd6581e86da38d62afbd528e40bf0874ca626 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 11 Sep 2026 22:42:07 -0600 Subject: [PATCH] feat: seed variables into initial graph state --- graph.example.yaml | 10 +-- src/graph/executor.rs | 139 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/graph.example.yaml b/graph.example.yaml index 70d62f3..fa018b9 100644 --- a/graph.example.yaml +++ b/graph.example.yaml @@ -98,10 +98,12 @@ conversation_starters: # Suggested prompts surfaced in the UI # --------------------------------------------------------------------------- # Agent variables (optional) # Declared the same way as a normal agent's config.yaml `variables:` block. -# Each variable becomes available to: -# - LLM nodes via the template form `{{name}}` once seeded into state -# (see initial_state below). -# - Script nodes via the env var `LLM_AGENT_VAR_`. +# At run start each declared variable is automatically seeded into graph +# state under its name, resolved as: runtime override, else declared default. +# An explicit `initial_state` entry or dispatcher input with the same key +# takes precedence over the seeded value. Nodes can then reference the +# variable via the template form `{{name}}`; script nodes additionally +# receive the env var `LLM_AGENT_VAR_`. # Values may be overridden at runtime with # `coyote -a --agent-variable "..."`. # --------------------------------------------------------------------------- diff --git a/src/graph/executor.rs b/src/graph/executor.rs index e441e21..f6838ae 100644 --- a/src/graph/executor.rs +++ b/src/graph/executor.rs @@ -10,7 +10,7 @@ use super::types::{EndNode, Graph, Node, NodeType}; use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor}; use super::validator::{AgentValidationContext, GraphValidator}; use super::wall_clock; -use crate::config::{RenderMode, RequestContext}; +use crate::config::{AgentVariable, AgentVariables, RenderMode, RequestContext}; use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id}; use crate::utils::{AbortSignal, wait_abort_signal, wait_user_interrupt}; use anyhow::{Context, Result, anyhow, bail}; @@ -100,7 +100,13 @@ impl GraphExecutor { result.into_result()?; } - let mut state = StateManager::new(graph.initial_state.clone()); + let mut initial_state = graph.initial_state.clone(); + seed_variables( + &mut initial_state, + &graph.variables, + ctx.agent.as_ref().map(|a| a.variables()), + ); + let mut state = StateManager::new(initial_state); let agent_envs = ctx .agent .as_ref() @@ -444,6 +450,24 @@ fn sorted_frontier(frontier: &HashSet) -> Vec { v } +fn seed_variables( + initial_state: &mut HashMap, + variables: &[AgentVariable], + agent_vars: Option<&AgentVariables>, +) { + for var in variables { + let resolved = agent_vars + .and_then(|vars| vars.get(&var.name)) + .cloned() + .or_else(|| var.default.clone()); + if let Some(value) = resolved { + initial_state + .entry(var.name.clone()) + .or_insert(Value::String(value)); + } + } +} + fn teammate_flagged_nodes(graph: &Graph, frontier: &HashSet) -> Vec<(String, String)> { frontier .iter() @@ -737,6 +761,62 @@ mod tests { assert_eq!(state.state().get("k"), Some(&json!("new-old"))); } + fn variable(name: &str, default: Option<&str>) -> AgentVariable { + AgentVariable { + name: name.into(), + default: default.map(Into::into), + ..Default::default() + } + } + + #[test] + fn seed_variables_fills_declared_default_when_key_absent() { + let mut state = HashMap::new(); + + seed_variables(&mut state, &[variable("foo", Some("bar"))], None); + + assert_eq!(state.get("foo"), Some(&json!("bar"))); + } + + #[test] + fn seed_variables_agent_value_wins_over_declared_default() { + let mut state = HashMap::new(); + let agent_vars: AgentVariables = + IndexMap::from([("foo".to_string(), "override".to_string())]); + + seed_variables( + &mut state, + &[variable("foo", Some("bar"))], + Some(&agent_vars), + ); + + assert_eq!(state.get("foo"), Some(&json!("override"))); + } + + #[test] + fn seed_variables_never_overwrites_existing_key() { + let mut state = HashMap::from([("foo".to_string(), json!("explicit"))]); + let agent_vars: AgentVariables = + IndexMap::from([("foo".to_string(), "override".to_string())]); + + seed_variables( + &mut state, + &[variable("foo", Some("bar"))], + Some(&agent_vars), + ); + + assert_eq!(state.get("foo"), Some(&json!("explicit"))); + } + + #[test] + fn seed_variables_skips_variable_with_no_value_anywhere() { + let mut state = HashMap::new(); + + seed_variables(&mut state, &[variable("foo", None)], None); + + assert!(!state.contains_key("foo")); + } + fn agent_node(id: &str, teammates: bool) -> Node { Node { id: id.into(), @@ -1066,6 +1146,61 @@ nodes: assert_eq!(result, "held|held"); } + /// Declared variables are seeded into state at run start, so the agent + /// node's strict prompt interpolation of `{{foo}}` succeeds instead of + /// bailing with "not found in state". `ctx.agent` is None here, so each + /// variable resolves to its declared default — except `kept`, whose + /// explicit `initial_state` entry wins over seeding. + #[tokio::test] + #[serial] + async fn declared_variables_are_seeded_into_state() { + if !cmd_available("python3") { + eprintln!("skipping: python3 not available"); + return; + } + let _guard = TestConfigDirGuard::new(); + materialize_probe_agent(0.0); + let ws = TestWorkspace::new(); + + let yaml = format!( + r#" +name: variable_seeding_test +settings: + validate_before_run: false +start: worker +variables: + - name: foo + description: seeded from its default + default: bar + - name: kept + description: shadowed by initial_state + default: from-default +initial_state: + kept: explicit +nodes: + worker: + type: agent + agent: {PROBE_AGENT} + prompt: "value is {{{{foo}}}}" + state_updates: + note: "{{{{foo}}}}-{{{{kept}}}}" + next: done + done: + type: end + output: "{{{{note}}}}" +"# + ); + let graph: Graph = serde_yaml::from_str(&yaml).unwrap(); + let mut ctx = make_ctx(); + let abort = create_abort_signal(); + let result = GraphExecutor::new(graph, &ws.dir) + .execute(&mut ctx, abort) + .await + .unwrap_or_else(|e| panic!("executor failed: {e:#}")); + + assert_eq!(result, "bar-explicit"); + } + #[tokio::test] async fn map_over_list_collects_outputs_in_input_order() { if !cmd_available("python3") {