From df948c69bf56d5dbdd50cefdbbed8f0929e4d98f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 22 Jul 2026 14:29:55 -0600 Subject: [PATCH] feat: new spawnable_agents field in agents to let users restrict what agents can be spawned by a parent agent --- assets/agents/sisyphus/config.yaml | 7 +++ config.agent.example.yaml | 7 +++ src/config/agent.rs | 11 +++++ src/function/supervisor.rs | 72 ++++++++++++++++++++++++++++-- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/assets/agents/sisyphus/config.yaml b/assets/agents/sisyphus/config.yaml index d5297fd..c0d6d68 100644 --- a/assets/agents/sisyphus/config.yaml +++ b/assets/agents/sisyphus/config.yaml @@ -8,6 +8,13 @@ max_auto_continues: 25 inject_todo_instructions: true can_spawn_agents: true +spawnable_agents: + - explore + - librarian + - coder + - oracle + - code-reviewer + - step-runner max_concurrent_agents: 4 max_agent_depth: 3 inject_spawn_instructions: true diff --git a/config.agent.example.yaml b/config.agent.example.yaml index 5a16ce7..d7c6d5a 100644 --- a/config.agent.example.yaml +++ b/config.agent.example.yaml @@ -7,6 +7,7 @@ # - _TOP_P # - _GLOBAL_TOOLS (as a JSON string array) # - _MCP_SERVERS (as a JSON string array) +# - _SPAWNABLE_AGENTS (as a JSON string array; see spawnable_agents below) # - _AGENT_SESSION # - _VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]') @@ -32,6 +33,12 @@ continuation_prompt: null # Custom prompt used when auto-continuing (opti # Enable this agent to spawn and manage child agents in parallel. # See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation. can_spawn_agents: false # Enable the agent to spawn child agents +# spawnable_agents: # Optional whitelist restricting which agents can be spawned via `agent__spawn`. +# - explore # If omitted (the default), ALL installed agents are spawnable. This is the unrestricted default. +# - coder # Provide a list to restrict. Match is exact and case-sensitive (use directory names). +# - oracle # An empty list ([]) means literally nothing spawnable. + # Also filters `agent__list_available` output so the LLM only sees what it can spawn. + # Graph agents (graph.yaml) ignore this; they declare spawn targets in agent nodes. max_concurrent_agents: 4 # Maximum number of agents that can run simultaneously max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning) inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt diff --git a/src/config/agent.rs b/src/config/agent.rs index f30b7cf..8edcd3b 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -367,6 +367,10 @@ impl Agent { &self.config.mcp_servers } + pub fn spawnable_agents(&self) -> Option<&[String]> { + self.config.spawnable_agents.as_deref() + } + pub fn skills_enabled(&self) -> Option { self.config.skills_enabled } @@ -655,6 +659,8 @@ pub struct AgentConfig { pub auto_continue: bool, #[serde(default)] pub can_spawn_agents: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawnable_agents: Option>, #[serde(default = "default_max_concurrent_agents")] pub max_concurrent_agents: usize, #[serde(default = "default_max_agent_depth")] @@ -793,6 +799,11 @@ impl AgentConfig { { self.mcp_servers = v; } + if let Ok(v) = env::var(with_prefix("spawnable_agents")) + && let Ok(v) = serde_json::from_str(&v) + { + self.spawnable_agents = Some(v); + } if let Some(v) = read_env_value::(&with_prefix("agent_session")) { self.agent_session = v; } diff --git a/src/function/supervisor.rs b/src/function/supervisor.rs index 8ee30ee..d0a1ea2 100644 --- a/src/function/supervisor.rs +++ b/src/function/supervisor.rs @@ -25,6 +25,13 @@ pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__"; pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3; +fn agent_permitted(whitelist: Option<&[String]>, target: &str) -> bool { + match whitelist { + None => true, + Some(w) => w.iter().any(|a| a == target), + } +} + pub enum GuardrailAction { NoAction, Inject(String), @@ -402,7 +409,7 @@ pub async fn handle_supervisor_tool( "check" => handle_check(ctx, args).await, "collect" => handle_collect(ctx, args).await, "list_running" => handle_list_running(ctx), - "list_available" => handle_list_available(), + "list_available" => handle_list_available(ctx), "cancel" => handle_cancel(ctx, args).await, "send_message" => handle_send_message(ctx, args), "check_inbox" => handle_check_inbox(ctx), @@ -642,6 +649,18 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result { .to_string(); let _task_id = args.get("task_id").and_then(Value::as_str); + if let Some(parent) = ctx.agent.as_ref() + && !agent_permitted(parent.spawnable_agents(), &agent_name) + { + let whitelist = parent.spawnable_agents().unwrap_or_default(); + return Ok(json!({ + "status": "error", + "message": format!( + "Agent '{agent_name}' is not in this agent's `spawnable_agents` whitelist. Allowed: {whitelist:?}. Call `agent__list_available` to see what you can spawn." + ), + })); + } + let short_uuid = &Uuid::new_v4().to_string()[..8]; let agent_id = format!("agent_{agent_name}_{short_uuid}"); @@ -966,8 +985,17 @@ fn handle_list_running(ctx: &mut RequestContext) -> Result { })) } -fn handle_list_available() -> Result { - let entries = list_agents_with_descriptions(); +fn handle_list_available(ctx: &RequestContext) -> Result { + let whitelist: Option> = ctx + .agent + .as_ref() + .and_then(|a| a.spawnable_agents()) + .map(<[String]>::to_vec); + + let entries: Vec<(String, String)> = list_agents_with_descriptions() + .into_iter() + .filter(|(name, _)| agent_permitted(whitelist.as_deref(), name)) + .collect(); let count = entries.len(); let agents: Vec = entries .into_iter() @@ -1500,11 +1528,47 @@ mod tests { #[test] fn handle_list_available_returns_shape() { - let result = handle_list_available().unwrap(); + let ctx = ctx_with_supervisor(4, 3); + + let result = handle_list_available(&ctx).unwrap(); + assert!(result["count"].is_number()); assert!(result["agents"].is_array()); } + #[test] + fn handle_list_available_unrestricted_when_no_whitelist() { + let ctx = ctx_with_supervisor(4, 3); + let result = handle_list_available(&ctx).unwrap(); + + let full_count = result["count"].as_u64().unwrap(); + + assert_eq!(full_count as usize, list_agents_with_descriptions().len()); + } + + #[test] + fn agent_permitted_none_whitelist_allows_all() { + assert!(agent_permitted(None, "explore")); + assert!(agent_permitted(None, "anything")); + } + + #[test] + fn agent_permitted_empty_whitelist_denies_all() { + let empty: Vec = vec![]; + + assert!(!agent_permitted(Some(&empty), "explore")); + } + + #[test] + fn agent_permitted_named_whitelist_matches_exact() { + let allowed = vec!["explore".to_string(), "coder".to_string()]; + + assert!(agent_permitted(Some(&allowed), "explore")); + assert!(agent_permitted(Some(&allowed), "coder")); + assert!(!agent_permitted(Some(&allowed), "oracle")); + assert!(!agent_permitted(Some(&allowed), "Explore")); + } + #[test] fn handle_check_unknown_agent() { let mut ctx = ctx_with_supervisor(4, 3);