feat: new spawnable_agents field in agents to let users restrict what agents can be spawned by a parent agent
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-07-22 14:29:55 -06:00
parent e5d0fcc764
commit df948c69bf
4 changed files with 93 additions and 4 deletions
+7
View File
@@ -8,6 +8,13 @@ max_auto_continues: 25
inject_todo_instructions: true inject_todo_instructions: true
can_spawn_agents: true can_spawn_agents: true
spawnable_agents:
- explore
- librarian
- coder
- oracle
- code-reviewer
- step-runner
max_concurrent_agents: 4 max_concurrent_agents: 4
max_agent_depth: 3 max_agent_depth: 3
inject_spawn_instructions: true inject_spawn_instructions: true
+7
View File
@@ -7,6 +7,7 @@
# - <agent-name>_TOP_P # - <agent-name>_TOP_P
# - <agent-name>_GLOBAL_TOOLS (as a JSON string array) # - <agent-name>_GLOBAL_TOOLS (as a JSON string array)
# - <agent-name>_MCP_SERVERS (as a JSON string array) # - <agent-name>_MCP_SERVERS (as a JSON string array)
# - <agent-name>_SPAWNABLE_AGENTS (as a JSON string array; see spawnable_agents below)
# - <agent-name>_AGENT_SESSION # - <agent-name>_AGENT_SESSION
# - <agent-name>_VARIABLES (as JSON array of key-value pairs; e.g. '[{"name": "username", "value": "alex"}]') # - <agent-name>_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. # Enable this agent to spawn and manage child agents in parallel.
# See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation. # See https://github.com/Dark-Alex-17/coyote/wiki/Agents for detailed documentation.
can_spawn_agents: false # Enable the agent to spawn child agents 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_concurrent_agents: 4 # Maximum number of agents that can run simultaneously
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning) 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 inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
+11
View File
@@ -367,6 +367,10 @@ impl Agent {
&self.config.mcp_servers &self.config.mcp_servers
} }
pub fn spawnable_agents(&self) -> Option<&[String]> {
self.config.spawnable_agents.as_deref()
}
pub fn skills_enabled(&self) -> Option<bool> { pub fn skills_enabled(&self) -> Option<bool> {
self.config.skills_enabled self.config.skills_enabled
} }
@@ -655,6 +659,8 @@ pub struct AgentConfig {
pub auto_continue: bool, pub auto_continue: bool,
#[serde(default)] #[serde(default)]
pub can_spawn_agents: bool, pub can_spawn_agents: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawnable_agents: Option<Vec<String>>,
#[serde(default = "default_max_concurrent_agents")] #[serde(default = "default_max_concurrent_agents")]
pub max_concurrent_agents: usize, pub max_concurrent_agents: usize,
#[serde(default = "default_max_agent_depth")] #[serde(default = "default_max_agent_depth")]
@@ -793,6 +799,11 @@ impl AgentConfig {
{ {
self.mcp_servers = v; 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::<String>(&with_prefix("agent_session")) { if let Some(v) = read_env_value::<String>(&with_prefix("agent_session")) {
self.agent_session = v; self.agent_session = v;
} }
+68 -4
View File
@@ -25,6 +25,13 @@ pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__";
pub const PENDING_AGENTS_GUARDRAIL_MAX: u32 = 3; 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 { pub enum GuardrailAction {
NoAction, NoAction,
Inject(String), Inject(String),
@@ -402,7 +409,7 @@ pub async fn handle_supervisor_tool(
"check" => handle_check(ctx, args).await, "check" => handle_check(ctx, args).await,
"collect" => handle_collect(ctx, args).await, "collect" => handle_collect(ctx, args).await,
"list_running" => handle_list_running(ctx), "list_running" => handle_list_running(ctx),
"list_available" => handle_list_available(), "list_available" => handle_list_available(ctx),
"cancel" => handle_cancel(ctx, args).await, "cancel" => handle_cancel(ctx, args).await,
"send_message" => handle_send_message(ctx, args), "send_message" => handle_send_message(ctx, args),
"check_inbox" => handle_check_inbox(ctx), "check_inbox" => handle_check_inbox(ctx),
@@ -642,6 +649,18 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
.to_string(); .to_string();
let _task_id = args.get("task_id").and_then(Value::as_str); 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 short_uuid = &Uuid::new_v4().to_string()[..8];
let agent_id = format!("agent_{agent_name}_{short_uuid}"); let agent_id = format!("agent_{agent_name}_{short_uuid}");
@@ -966,8 +985,17 @@ fn handle_list_running(ctx: &mut RequestContext) -> Result<Value> {
})) }))
} }
fn handle_list_available() -> Result<Value> { fn handle_list_available(ctx: &RequestContext) -> Result<Value> {
let entries = list_agents_with_descriptions(); let whitelist: Option<Vec<String>> = 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 count = entries.len();
let agents: Vec<Value> = entries let agents: Vec<Value> = entries
.into_iter() .into_iter()
@@ -1500,11 +1528,47 @@ mod tests {
#[test] #[test]
fn handle_list_available_returns_shape() { 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["count"].is_number());
assert!(result["agents"].is_array()); 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<String> = 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] #[test]
fn handle_check_unknown_agent() { fn handle_check_unknown_agent() {
let mut ctx = ctx_with_supervisor(4, 3); let mut ctx = ctx_with_supervisor(4, 3);