diff --git a/graph.example.yaml b/graph.example.yaml index 6791810..029766e 100644 --- a/graph.example.yaml +++ b/graph.example.yaml @@ -38,12 +38,23 @@ reasoning_effort: null # Default reasoning effort for `llm` nodes th max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once across the # whole graph run: every `llm` node (including parallel branches) - # draws from this one pool, so the budget is graph-wide — there is + # draws from this one pool, so the budget is graph-wide. There is # no per-node override. Jobs themselves are node-local: the node # that starts a job must collect or cancel it before it ends, and # anything left running at node exit is cancelled. Overrides the # global setting; 0 disables background jobs for this graph agent. +can_spawn_agents: true # Whether `llm` nodes may spawn dynamic sub-agents via the + # agent__* tools. Defaults to true iff the graph has an `agent` + # node; an explicit value overrides that. Set it true on a graph + # with only `llm` nodes to build an orchestrator graph. Setting + # it false on a graph with `agent` nodes still runs those nodes, + # only dynamic spawning from `llm` nodes is disabled. +max_concurrent_agents: 4 # Max dynamically spawned sub-agents running at once. Explicit + # value overrides the default of 4. +max_agent_depth: 3 # Max agent nesting depth (agents spawning agents). Explicit + # value overrides the default of 3. + global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from - web_search_coyote.sh - fetch_url_via_curl.sh diff --git a/src/config/agent.rs b/src/config/agent.rs index f0de372..5dd3f64 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -835,7 +835,7 @@ fn default_max_concurrent_agents() -> usize { 4 } -fn default_max_agent_depth() -> usize { +pub(crate) fn default_max_agent_depth() -> usize { 3 } @@ -878,10 +878,16 @@ impl AgentConfig { skill_instructions: graph.skill_instructions.clone(), conversation_starters: graph.conversation_starters.clone(), variables: graph.variables.clone(), - can_spawn_agents: graph.has_agent_node(), - max_concurrent_agents: default_max_concurrent_agents(), + can_spawn_agents: graph + .can_spawn_agents + .unwrap_or_else(|| graph.has_agent_node()), + max_concurrent_agents: graph + .max_concurrent_agents + .unwrap_or_else(default_max_concurrent_agents), max_concurrent_jobs: graph.max_concurrent_jobs, - max_agent_depth: default_max_agent_depth(), + max_agent_depth: graph + .max_agent_depth + .unwrap_or_else(default_max_agent_depth), escalation_timeout: default_escalation_timeout(), ..AgentConfig::default() } @@ -1460,6 +1466,67 @@ variables: assert_eq!(config.escalation_timeout, default_escalation_timeout()); } + #[test] + fn from_graph_explicit_can_spawn_agents_enables_spawning_without_agent_nodes() { + let yaml = formatdoc! {r#" + name: g + can_spawn_agents: true + start: x + nodes: + x: + id: x + type: end + output: ok + "#}; + let graph: Graph = serde_yaml::from_str(&yaml).unwrap(); + + assert!(AgentConfig::from_graph("d", &graph).can_spawn_agents); + } + + #[test] + fn from_graph_explicit_can_spawn_agents_false_wins_over_agent_nodes() { + let yaml = formatdoc! {r#" + name: g + can_spawn_agents: false + 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(&yaml).unwrap(); + + assert!(!AgentConfig::from_graph("d", &graph).can_spawn_agents); + } + + #[test] + fn from_graph_propagates_explicit_agent_limits() { + let yaml = formatdoc! {r#" + name: g + max_concurrent_agents: 7 + max_agent_depth: 3 + start: x + nodes: + x: + id: x + type: end + output: ok + "#}; + let graph: Graph = serde_yaml::from_str(&yaml).unwrap(); + + let config = AgentConfig::from_graph("d", &graph); + + assert_eq!(config.max_concurrent_agents, 7); + assert_eq!(config.max_agent_depth, 3); + } + #[test] fn agent_metadata_stub_extracts_description_from_graph_yaml() { let yaml = r#" diff --git a/src/graph/types.rs b/src/graph/types.rs index f598f53..01c7220 100644 --- a/src/graph/types.rs +++ b/src/graph/types.rs @@ -31,6 +31,15 @@ pub struct Graph { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrent_jobs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub can_spawn_agents: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrent_agents: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_agent_depth: Option, + #[serde(default)] pub global_tools: Vec, @@ -946,11 +955,34 @@ nodes: assert!(graph.temperature.is_none()); assert!(graph.top_p.is_none()); assert!(graph.max_concurrent_jobs.is_none()); + assert!(graph.can_spawn_agents.is_none()); + assert!(graph.max_concurrent_agents.is_none()); + assert!(graph.max_agent_depth.is_none()); assert!(graph.global_tools.is_empty()); assert!(graph.mcp_servers.is_empty()); assert!(graph.conversation_starters.is_empty()); } + #[test] + fn graph_parses_agent_orchestration_keys() { + let yaml = r#" +name: g +can_spawn_agents: true +max_concurrent_agents: 6 +max_agent_depth: 2 +start: x +nodes: + x: + id: x + type: end + output: ok +"#; + let graph: Graph = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(graph.can_spawn_agents, Some(true)); + assert_eq!(graph.max_concurrent_agents, Some(6)); + assert_eq!(graph.max_agent_depth, Some(2)); + } + #[test] fn node_ids_lists_nodes_in_order() { let yaml = r#"