feat: merged normal agent config and graph agent configs into one file (either/or)

This commit is contained in:
2026-05-15 12:57:08 -06:00
parent 984a073730
commit bf6b2f718c
8 changed files with 277 additions and 41 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ pub async fn run_active_agent_graph(
log::info!("Agent '{agent_name}' has graph.yaml; routing to graph executor");
let agent_dir = paths::agent_data_dir(&agent_name);
let graph_path = paths::agent_graph_path(&agent_name);
let graph_path = paths::agent_graph_file(&agent_name);
let parser = GraphParser::new(&agent_dir);
let mut graph = parser
+1 -1
View File
@@ -17,7 +17,7 @@ pub use agent::AgentNodeExecutor;
pub use dispatch::{active_agent_graph_name, run_active_agent_graph};
pub use executor::GraphExecutor;
pub use llm::LlmNodeExecutor;
pub use parser::{GraphParser, agent_has_graph, load_agent_graph};
pub use parser::{GraphParser, agent_has_graph};
pub use script::ScriptExecutor;
pub use state::{StateManager, StateRepresentation};
pub use types::{
+1 -20
View File
@@ -125,20 +125,7 @@ fn enhance_yaml_error(error: serde_yaml::Error) -> Error {
/// Returns true if the named agent has a `graph.yaml` in its data directory.
pub fn agent_has_graph(agent_name: &str) -> bool {
paths::agent_graph_path(agent_name).exists()
}
/// Load `graph.yaml` from the named agent's data directory. Returns `Ok(None)`
/// if no graph file exists.
pub fn load_agent_graph(agent_name: &str) -> Result<Option<Graph>> {
let graph_path = paths::agent_graph_path(agent_name);
if !graph_path.exists() {
return Ok(None);
}
let parser = GraphParser::new(paths::agent_data_dir(agent_name));
let graph = parser.load_from_file(&graph_path)?;
Ok(Some(graph))
paths::agent_graph_file(agent_name).exists()
}
#[cfg(test)]
@@ -448,10 +435,4 @@ nodes:
fn agent_has_graph_false_for_unknown_agent() {
assert!(!agent_has_graph("__nonexistent_agent_for_test__"));
}
#[test]
fn load_agent_graph_returns_none_when_absent() {
let result = load_agent_graph("__nonexistent_agent_for_test__").unwrap();
assert!(result.is_none());
}
}
+111
View File
@@ -17,6 +17,36 @@ pub struct Graph {
#[serde(default = "default_schema_version")]
pub version: String,
/// Default chat model for the agent. Used when an `llm` node does not
/// set its own `model`. Consulted in single-file mode (an agent with
/// a `graph.yaml` and no `config.yaml`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Default sampling temperature. Single-file mode only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
/// Default sampling top-p. Single-file mode only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
/// Session to start the agent in (e.g. `temp`). Single-file mode only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session: Option<String>,
/// Global tools available to the agent's nodes. Single-file mode only.
#[serde(default)]
pub global_tools: Vec<String>,
/// MCP servers available to the agent's nodes. Single-file mode only.
#[serde(default)]
pub mcp_servers: Vec<String>,
/// Suggested prompts surfaced in the UI. Single-file mode only.
#[serde(default)]
pub conversation_starters: Vec<String>,
#[serde(default)]
pub settings: GraphSettings,
@@ -40,6 +70,15 @@ impl Graph {
pub fn node_ids(&self) -> Vec<&str> {
self.nodes.keys().map(|s| s.as_str()).collect()
}
/// Returns true if any node is an `agent`-type node. Used to derive
/// `can_spawn_agents` when synthesizing an agent config from a
/// single-file graph.
pub fn has_agent_node(&self) -> bool {
self.nodes
.values()
.any(|n| matches!(n.node_type, NodeType::Agent(_)))
}
}
fn default_schema_version() -> String {
@@ -734,4 +773,76 @@ instructions: "System only — no user prompt."
let result: std::result::Result<Node, _> = serde_yaml::from_str(yaml);
assert!(result.is_err());
}
#[test]
fn graph_parses_agent_level_top_level_fields() {
let yaml = r#"
name: single_file
start: e
model: anthropic:claude-sonnet-4-6
temperature: 0.2
top_p: 0.9
agent_session: temp
global_tools:
- web_search_loki.sh
mcp_servers:
- pubmed-search
conversation_starters:
- "Look up 2160-0"
nodes:
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert_eq!(graph.model.as_deref(), Some("anthropic:claude-sonnet-4-6"));
assert_eq!(graph.temperature, Some(0.2));
assert_eq!(graph.top_p, Some(0.9));
assert_eq!(graph.agent_session.as_deref(), Some("temp"));
assert_eq!(graph.global_tools, vec!["web_search_loki.sh"]);
assert_eq!(graph.mcp_servers, vec!["pubmed-search"]);
assert_eq!(graph.conversation_starters, vec!["Look up 2160-0"]);
}
#[test]
fn graph_agent_level_fields_default_when_absent() {
let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n";
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert!(graph.model.is_none());
assert!(graph.temperature.is_none());
assert!(graph.top_p.is_none());
assert!(graph.agent_session.is_none());
assert!(graph.global_tools.is_empty());
assert!(graph.mcp_servers.is_empty());
assert!(graph.conversation_starters.is_empty());
}
#[test]
fn has_agent_node_detects_agent_nodes() {
let with_agent = r#"
name: g
start: a
nodes:
a:
id: a
type: agent
agent: helper
prompt: hi
next: e
e:
id: e
type: end
output: done
"#;
let graph: Graph = serde_yaml::from_str(with_agent).unwrap();
assert!(graph.has_agent_node());
}
#[test]
fn has_agent_node_false_without_agent_nodes() {
let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n";
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert!(!graph.has_agent_node());
}
}
+14 -3
View File
@@ -195,16 +195,20 @@ impl GraphValidator {
for (node_id, node) in &graph.nodes {
if let NodeType::Agent(a) = &node.node_type {
let agent_dir = paths::agent_data_dir(&a.agent);
let config_path = paths::agent_config_file(&a.agent);
let has_config = paths::agent_config_file(&a.agent).exists();
let has_graph = paths::agent_graph_file(&a.agent).exists();
if !agent_dir.exists() {
result.error(ValidationError::with_node(
node_id,
format!("Agent '{}' not found (directory missing)", a.agent),
));
} else if !config_path.exists() {
} else if !has_config && !has_graph {
result.error(ValidationError::with_node(
node_id,
format!("Agent '{}' has no config.yaml", a.agent),
format!(
"Agent '{}' has neither a config.yaml nor a graph.yaml",
a.agent
),
));
}
}
@@ -351,6 +355,13 @@ mod tests {
name: "t".into(),
description: String::new(),
version: "1.0".into(),
model: None,
temperature: None,
top_p: None,
agent_session: None,
global_tools: Vec::new(),
mcp_servers: Vec::new(),
conversation_starters: Vec::new(),
settings: GraphSettings::default(),
initial_state: HashMap::new(),
start: start.into(),