feat: new spawnable_agents field in agents to let users restrict what agents can be spawned by a parent agent
This commit is contained in:
@@ -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,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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user