feat: Implemented initial scaffolding for built-in sub-agent spawning tool call operations

This commit is contained in:
2026-02-17 11:48:31 -07:00
parent af933bbb29
commit 44c03ccf4f
5 changed files with 598 additions and 0 deletions
+16
View File
@@ -208,6 +208,10 @@ impl Agent {
functions.append_todo_functions(); functions.append_todo_functions();
} }
if agent_config.can_spawn_agents {
functions.append_supervisor_functions();
}
Ok(Self { Ok(Self {
name: name.to_string(), name: name.to_string(),
config: agent_config, config: agent_config,
@@ -412,6 +416,18 @@ impl Agent {
self.config.max_auto_continues self.config.max_auto_continues
} }
pub fn can_spawn_agents(&self) -> bool {
self.config.can_spawn_agents
}
pub fn max_concurrent_agents(&self) -> usize {
self.config.max_concurrent_agents
}
pub fn max_agent_depth(&self) -> usize {
self.config.max_agent_depth
}
pub fn continuation_count(&self) -> usize { pub fn continuation_count(&self) -> usize {
self.continuation_count self.continuation_count
} }
+13
View File
@@ -28,6 +28,7 @@ use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX, MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpRegistry, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpRegistry,
}; };
use crate::supervisor::Supervisor;
use crate::vault::{GlobalVault, Vault, create_vault_password_file, interpolate_secrets}; use crate::vault::{GlobalVault, Vault, create_vault_password_file, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use fancy_regex::Regex; use fancy_regex::Regex;
@@ -207,6 +208,8 @@ pub struct Config {
pub agent: Option<Agent>, pub agent: Option<Agent>,
#[serde(skip)] #[serde(skip)]
pub(crate) tool_call_tracker: Option<ToolCallTracker>, pub(crate) tool_call_tracker: Option<ToolCallTracker>,
#[serde(skip)]
pub supervisor: Option<Arc<RwLock<Supervisor>>>,
} }
impl Default for Config { impl Default for Config {
@@ -280,6 +283,7 @@ impl Default for Config {
rag: None, rag: None,
agent: None, agent: None,
tool_call_tracker: Some(ToolCallTracker::default()), tool_call_tracker: Some(ToolCallTracker::default()),
supervisor: None,
} }
} }
} }
@@ -1818,8 +1822,17 @@ impl Config {
agent.agent_session().map(|v| v.to_string()) agent.agent_session().map(|v| v.to_string())
} }
}); });
let should_init_supervisor = agent.can_spawn_agents();
let max_concurrent = agent.max_concurrent_agents();
let max_depth = agent.max_agent_depth();
config.write().rag = agent.rag(); config.write().rag = agent.rag();
config.write().agent = Some(agent); config.write().agent = Some(agent);
if should_init_supervisor {
config.write().supervisor = Some(Arc::new(RwLock::new(Supervisor::new(
max_concurrent,
max_depth,
))));
}
if let Some(session) = session { if let Some(session) = session {
Config::use_session_safely(config, Some(&session), abort_signal).await?; Config::use_session_safely(config, Some(&session), abort_signal).await?;
} else { } else {
+16
View File
@@ -1,3 +1,4 @@
pub(crate) mod supervisor;
pub(crate) mod todo; pub(crate) mod todo;
use crate::{ use crate::{
@@ -28,6 +29,7 @@ use std::{
process::{Command, Stdio}, process::{Command, Stdio},
}; };
use strum_macros::AsRefStr; use strum_macros::AsRefStr;
use supervisor::SUPERVISOR_FUNCTION_PREFIX;
use todo::TODO_FUNCTION_PREFIX; use todo::TODO_FUNCTION_PREFIX;
#[derive(Embed)] #[derive(Embed)]
@@ -269,6 +271,11 @@ impl Functions {
self.declarations.extend(todo::todo_function_declarations()); self.declarations.extend(todo::todo_function_declarations());
} }
pub fn append_supervisor_functions(&mut self) {
self.declarations
.extend(supervisor::supervisor_function_declarations());
}
pub fn clear_mcp_meta_functions(&mut self) { pub fn clear_mcp_meta_functions(&mut self) {
self.declarations.retain(|d| { self.declarations.retain(|d| {
!d.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX) !d.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
@@ -886,6 +893,15 @@ impl ToolCall {
json!({"tool_call_error": error_msg}) json!({"tool_call_error": error_msg})
}) })
} }
_ if cmd_name.starts_with(SUPERVISOR_FUNCTION_PREFIX) => {
supervisor::handle_supervisor_tool(config, &cmd_name, &json_data).unwrap_or_else(
|e| {
let error_msg = format!("Supervisor tool failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
},
)
}
_ => match run_llm_function(cmd_name, cmd_args, envs, agent_name) { _ => match run_llm_function(cmd_name, cmd_args, envs, agent_name) {
Ok(Some(contents)) => serde_json::from_str(&contents) Ok(Some(contents)) => serde_json::from_str(&contents)
.ok() .ok()
+541
View File
@@ -0,0 +1,541 @@
use super::{FunctionDeclaration, JsonSchema};
use crate::config::GlobalConfig;
use crate::supervisor::mailbox::{Envelope, EnvelopePayload};
use anyhow::{Result, bail, anyhow};
use chrono::Utc;
use indexmap::IndexMap;
use serde_json::{Value, json};
pub const SUPERVISOR_FUNCTION_PREFIX: &str = "agent__";
pub fn supervisor_function_declarations() -> Vec<FunctionDeclaration> {
vec![
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}spawn"),
description: "Spawn a subagent to run in the background. Returns a task_id for tracking. The agent runs in parallel. You can continue working while it executes.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
(
"agent".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("Name of the agent to spawn (e.g. 'explore', 'coder', 'oracle')".into()),
..Default::default()
},
),
(
"prompt".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The task prompt to send to the agent".into()),
..Default::default()
},
),
(
"task_id".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("Optional task queue ID to associate with this agent".into()),
..Default::default()
},
),
])),
required: Some(vec!["agent".to_string(), "prompt".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}check"),
description: "Check if a spawned agent has finished. Non-blocking; returns PENDING if still running, or the result if complete.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"id".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The agent ID returned by agent__spawn".into()),
..Default::default()
},
)])),
required: Some(vec!["id".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}collect"),
description: "Wait for a spawned agent to finish and return its result. Blocks until the agent completes.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"id".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The agent ID returned by agent__spawn".into()),
..Default::default()
},
)])),
required: Some(vec!["id".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}list"),
description: "List all currently running subagents and their status.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}cancel"),
description: "Cancel a running subagent by its ID.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"id".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The agent ID to cancel".into()),
..Default::default()
},
)])),
required: Some(vec!["id".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}send_message"),
description: "Send a text message to a running subagent's inbox.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
(
"id".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The target agent ID".into()),
..Default::default()
},
),
(
"message".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The message text to send".into()),
..Default::default()
},
),
])),
required: Some(vec!["id".to_string(), "message".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}check_inbox"),
description: "Check for and drain all pending messages in your inbox.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}task_create"),
description: "Create a task in the task queue. Returns the task ID.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
(
"subject".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("Short title for the task".into()),
..Default::default()
},
),
(
"description".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("Detailed description of the task".into()),
..Default::default()
},
),
(
"blocked_by".to_string(),
JsonSchema {
type_value: Some("array".to_string()),
description: Some("Task IDs that must complete before this task can run".into()),
items: Some(Box::new(JsonSchema {
type_value: Some("string".to_string()),
..Default::default()
})),
..Default::default()
},
),
])),
required: Some(vec!["subject".to_string()]),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}task_list"),
description: "List all tasks in the task queue with their status and dependencies.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
..Default::default()
},
agent: false,
},
FunctionDeclaration {
name: format!("{SUPERVISOR_FUNCTION_PREFIX}task_complete"),
description: "Mark a task as completed. Returns any newly unblocked task IDs.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"task_id".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The task ID to mark complete".into()),
..Default::default()
},
)])),
required: Some(vec!["task_id".to_string()]),
..Default::default()
},
agent: false,
},
]
}
pub fn handle_supervisor_tool(
config: &GlobalConfig,
cmd_name: &str,
args: &Value,
) -> Result<Value> {
let action = cmd_name
.strip_prefix(SUPERVISOR_FUNCTION_PREFIX)
.unwrap_or(cmd_name);
match action {
"spawn" => handle_spawn(config, args),
"check" => handle_check(config, args),
"collect" => handle_collect(config, args),
"list" => handle_list(config),
"cancel" => handle_cancel(config, args),
"send_message" => handle_send_message(config, args),
"check_inbox" => handle_check_inbox(config),
"task_create" => handle_task_create(config, args),
"task_list" => handle_task_list(config),
"task_complete" => handle_task_complete(config, args),
_ => bail!("Unknown supervisor action: {action}"),
}
}
fn handle_spawn(_config: &GlobalConfig, args: &Value) -> Result<Value> {
let _agent_name = args
.get("agent")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'agent' is required"))?;
let _prompt = args
.get("prompt")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'prompt' is required"))?;
let _task_id = args.get("task_id").and_then(Value::as_str);
// TODO: Step 3 — actual agent spawning via tokio::spawn
// For now, return a placeholder so the tool declarations compile and wire up
Ok(json!({
"status": "error",
"message": "agent__spawn is not yet implemented — coming in Step 3"
}))
}
fn handle_check(config: &GlobalConfig, args: &Value) -> Result<Value> {
let id = args
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'id' is required"))?;
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let sup = supervisor.read();
match sup.is_finished(id) {
Some(true) => {
drop(sup);
drop(cfg);
handle_collect(config, args)
}
Some(false) => Ok(json!({
"status": "pending",
"id": id,
"message": "Agent is still running"
})),
None => Ok(json!({
"status": "error",
"message": format!("No agent found with id '{id}'")
})),
}
}
fn handle_collect(config: &GlobalConfig, args: &Value) -> Result<Value> {
let id = args
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'id' is required"))?;
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let mut sup = supervisor.write();
match sup.take_if_finished(id) {
Some(handle) => {
drop(sup);
drop(cfg);
let rt = tokio::runtime::Handle::current();
let result = rt
.block_on(handle.join_handle)
.map_err(|e| anyhow!("Agent task panicked: {e}"))?
.map_err(|e| anyhow!("Agent failed: {e}"))?;
Ok(json!({
"status": "completed",
"id": result.id,
"agent": result.agent_name,
"exit_status": format!("{:?}", result.exit_status),
"output": result.output,
}))
}
None => Ok(json!({
"status": "error",
"message": format!("Agent '{id}' not found or still running. Use agent__check first.")
})),
}
}
fn handle_list(config: &GlobalConfig) -> Result<Value> {
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let sup = supervisor.read();
let agents: Vec<Value> = sup
.list_agents()
.into_iter()
.map(|(id, name)| {
let finished = sup.is_finished(id).unwrap_or(false);
json!({
"id": id,
"agent": name,
"status": if finished { "finished" } else { "running" },
})
})
.collect();
Ok(json!({
"active_count": sup.active_count(),
"max_concurrent": sup.max_concurrent(),
"agents": agents,
}))
}
fn handle_cancel(config: &GlobalConfig, args: &Value) -> Result<Value> {
let id = args
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'id' is required"))?;
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let mut sup = supervisor.write();
match sup.take(id) {
Some(handle) => {
handle.abort_signal.set_ctrlc();
Ok(json!({
"status": "ok",
"message": format!("Cancelled agent '{}'", handle.agent_name),
}))
}
None => Ok(json!({
"status": "error",
"message": format!("No agent found with id '{id}'"),
})),
}
}
fn handle_send_message(config: &GlobalConfig, args: &Value) -> Result<Value> {
let id = args
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'id' is required"))?;
let message = args
.get("message")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'message' is required"))?;
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let sup = supervisor.read();
match sup.inbox(id) {
Some(inbox) => {
let parent_name = cfg
.agent
.as_ref()
.map(|a| a.name().to_string())
.unwrap_or_else(|| "parent".to_string());
inbox.deliver(Envelope {
from: parent_name,
to: id.to_string(),
payload: EnvelopePayload::Text {
content: message.to_string(),
},
timestamp: Utc::now(),
});
Ok(json!({
"status": "ok",
"message": format!("Message delivered to agent '{id}'"),
}))
}
None => Ok(json!({
"status": "error",
"message": format!("No agent found with id '{id}'"),
})),
}
}
fn handle_check_inbox(_config: &GlobalConfig) -> Result<Value> {
// The parent agent's own inbox — will be wired when we plumb Inbox into Agent
// For now, return empty
Ok(json!({
"messages": [],
"count": 0,
}))
}
fn handle_task_create(config: &GlobalConfig, args: &Value) -> Result<Value> {
let subject = args
.get("subject")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'subject' is required"))?;
let description = args
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let blocked_by: Vec<String> = args
.get("blocked_by")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default();
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let mut sup = supervisor.write();
let task_id = sup
.task_queue_mut()
.create(subject.to_string(), description.to_string());
let mut dep_errors = vec![];
for dep_id in &blocked_by {
if let Err(e) = sup.task_queue_mut().add_dependency(&task_id, dep_id) {
dep_errors.push(e);
}
}
let mut result = json!({
"status": "ok",
"task_id": task_id,
});
if !dep_errors.is_empty() {
result["warnings"] = json!(dep_errors);
}
Ok(result)
}
fn handle_task_list(config: &GlobalConfig) -> Result<Value> {
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let sup = supervisor.read();
let tasks: Vec<Value> = sup
.task_queue()
.list()
.into_iter()
.map(|t| {
json!({
"id": t.id,
"subject": t.subject,
"status": t.status,
"owner": t.owner,
"blocked_by": t.blocked_by.iter().collect::<Vec<_>>(),
"blocks": t.blocks.iter().collect::<Vec<_>>(),
})
})
.collect();
Ok(json!({ "tasks": tasks }))
}
fn handle_task_complete(config: &GlobalConfig, args: &Value) -> Result<Value> {
let task_id = args
.get("task_id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'task_id' is required"))?;
let cfg = config.read();
let supervisor = cfg
.supervisor
.as_ref()
.ok_or_else(|| anyhow!("No supervisor active"))?;
let mut sup = supervisor.write();
let newly_runnable = sup.task_queue_mut().complete(task_id);
Ok(json!({
"status": "ok",
"task_id": task_id,
"newly_runnable": newly_runnable,
}))
}
+12
View File
@@ -1,12 +1,14 @@
pub mod mailbox; pub mod mailbox;
pub mod taskqueue; pub mod taskqueue;
use fmt::{Debug, Formatter};
use crate::utils::AbortSignal; use crate::utils::AbortSignal;
use mailbox::Inbox; use mailbox::Inbox;
use taskqueue::TaskQueue; use taskqueue::TaskQueue;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt;
use std::sync::Arc; use std::sync::Arc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
@@ -128,3 +130,13 @@ impl Supervisor {
} }
} }
} }
impl Debug for Supervisor {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("Supervisor")
.field("active_agents", &self.handles.len())
.field("max_concurrent", &self.max_concurrent)
.field("max_depth", &self.max_depth)
.finish()
}
}