use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
use crate::agent::{FinishReason, PlanningMode};
use crate::error::{Error, Result};
use crate::kernel::{AgentKernel, TurnContext};
use crate::llm::{LlmProvider, ToolSpec};
use crate::message::Message;
use crate::permissions::PermissionMode;
use crate::tools::PermissionHook;
use crate::tools::{Tool, ToolRegistry};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentType {
Explore,
GeneralPurpose,
}
impl AgentType {
pub fn parse(s: &str) -> Option<Self> {
match s {
"explore" => Some(Self::Explore),
"general_purpose" => Some(Self::GeneralPurpose),
_ => None,
}
}
pub fn is_read_only(self) -> bool {
matches!(self, Self::Explore)
}
pub fn system_prompt_hint(self) -> &'static str {
match self {
Self::Explore => {
"You are an exploration sub-agent. Use only read tools to gather \
information. Do NOT write or modify files. Be thorough and concise."
}
Self::GeneralPurpose => {
"You are a focused sub-agent. Complete the given task using the \
available tools. Be concise."
}
}
}
pub fn allowed_tool_names(self) -> Option<Vec<String>> {
match self {
Self::Explore => Some(vec![
"read_file".to_string(),
"list_dir".to_string(),
"search_files".to_string(),
"recall".to_string(),
"web_fetch".to_string(),
"sub_agent".to_string(),
]),
Self::GeneralPurpose => None,
}
}
}
pub struct SubAgent {
workspace: std::path::PathBuf,
provider: Arc<dyn LlmProvider>,
all_tools: ToolRegistry,
max_depth: usize,
current_depth: usize,
permission_hook: Option<Arc<dyn PermissionHook>>,
}
impl SubAgent {
pub fn new(
workspace: impl Into<std::path::PathBuf>,
provider: Arc<dyn LlmProvider>,
all_tools: ToolRegistry,
max_depth: usize,
current_depth: usize,
permission_hook: Option<Arc<dyn PermissionHook>>,
) -> Self {
Self {
workspace: workspace.into(),
provider,
all_tools,
max_depth,
current_depth,
permission_hook,
}
}
fn build_sub_registry(&self, tool_names: &[String]) -> ToolRegistry {
let mut reg = self.all_tools.with_same_transport();
for name in tool_names {
if let Some(tool) = self.all_tools.get(name) {
reg = reg.register(tool);
}
}
reg
}
fn default_tool_names() -> Vec<String> {
vec![
"read_file".to_string(),
"list_dir".to_string(),
"search_files".to_string(),
"web_fetch".to_string(),
]
}
}
#[async_trait]
impl Tool for SubAgent {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "sub_agent".into(),
description: "Spawn a fresh agent with its own transcript to complete a focused sub-task. Returns the sub-agent's final response.".into(),
parameters: json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The goal / prompt for the sub-agent"
},
"subagent_type": {
"type": "string",
"enum": ["explore", "general_purpose"],
"description": "Agent personality. 'explore': read-only tools only, can run in parallel with other explore agents. 'general_purpose' (default): full tool access, runs sequentially."
},
"max_steps": {
"type": "integer",
"description": "Maximum steps for the sub-agent (default 30, capped at parent's remaining budget)",
"default": 30
},
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional list of tool names to make available to the sub-agent. Ignored when subagent_type is 'explore' (which enforces its own read-only tool set). Default: read_file, list_dir, search_files, web_fetch"
}
},
"required": ["prompt"]
}),
}
}
fn is_readonly_for_args(&self, arguments: &serde_json::Value) -> bool {
arguments
.get("subagent_type")
.and_then(|v| v.as_str())
.and_then(AgentType::parse)
.map(AgentType::is_read_only)
.unwrap_or(false)
}
async fn execute(&self, arguments: Value) -> Result<String> {
let prompt = arguments["prompt"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "sub_agent".into(),
message: "missing required parameter: prompt".to_string(),
})?;
let agent_type = arguments
.get("subagent_type")
.and_then(|v| v.as_str())
.and_then(AgentType::parse)
.unwrap_or(AgentType::GeneralPurpose);
let max_steps = arguments["max_steps"].as_i64().unwrap_or(30).clamp(1, 100) as usize;
let tool_names: Vec<String> = if let Some(forced) = agent_type.allowed_tool_names() {
forced
} else {
arguments["tools"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_else(Self::default_tool_names)
};
if self.current_depth >= self.max_depth {
return Ok(format!(
"ERROR: sub-agent depth limit reached (max_depth={}). Cannot spawn deeper sub-agent.",
self.max_depth
));
}
let mut sub_registry = self.build_sub_registry(&tool_names);
let child_sub = SubAgent::new(
&self.workspace,
self.provider.clone(),
self.all_tools.clone(),
self.max_depth,
self.current_depth + 1,
self.permission_hook.clone(),
);
sub_registry = sub_registry.register(Arc::new(child_sub));
let kernel = AgentKernel::builder()
.llm(self.provider.clone())
.tools(sub_registry)
.max_steps(max_steps)
.build()
.map_err(|e| Error::Tool {
name: "sub_agent".into(),
message: format!("failed to build sub-agent kernel: {e}"),
})?;
let ctx = TurnContext {
messages: vec![
Message::system(agent_type.system_prompt_hint().to_string()),
Message::user(prompt.to_string()),
],
step_events_tx: None,
plan_confirmed: false,
plan_buffer: None,
tool_specs: kernel.tools().specs(),
streaming: false,
permission_hook: self.permission_hook.clone(),
planning_mode: PlanningMode::default(),
exploring_plan_mode: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
permission_mode: PermissionMode::Default,
mailbox: None,
};
let outcome = kernel.run(ctx).await.map_err(|e| Error::Tool {
name: "sub_agent".into(),
message: format!("sub-agent failed: {e}"),
})?;
let finish_label = match &outcome.finish_reason {
FinishReason::NoMoreToolCalls => "NoMoreToolCalls",
FinishReason::BudgetExceeded => "BudgetExceeded",
FinishReason::ProviderStop(r) => r,
FinishReason::Stuck { .. } => "Stuck",
FinishReason::TranscriptLimit { .. } => "TranscriptLimit",
FinishReason::PlanPending => "PlanPending",
FinishReason::Cancelled => "Cancelled",
FinishReason::PermissionDenialLimit => "PermissionDenialLimit",
};
let final_text = outcome
.final_text
.unwrap_or_else(|| "(no final message)".to_string());
Ok(format!(
"[sub-agent finished: {finish_label}]\n{final_text}"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{Completion, MockProvider, ToolCall};
use crate::tools::{
ApplyPatch, ListDir, LocalTransport, ReadFile, SearchFiles, ToolTransport, WriteFile,
};
fn mock_provider(script: Vec<Completion>) -> Arc<dyn LlmProvider> {
Arc::new(MockProvider::new(script))
}
fn full_tool_registry(workspace: &std::path::Path) -> ToolRegistry {
let transport: Arc<dyn ToolTransport> = Arc::new(LocalTransport);
ToolRegistry::new(transport)
.register(Arc::new(ReadFile::new(workspace)))
.register(Arc::new(ListDir::new(workspace)))
.register(Arc::new(SearchFiles::new(workspace)))
.register(Arc::new(WriteFile::new(workspace)))
.register(Arc::new(ApplyPatch::new(workspace)))
}
#[tokio::test]
async fn sub_agent_basic_dispatch() {
let provider = mock_provider(vec![Completion {
content: "The answer is 42.".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]);
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
let result = sub
.execute(json!({"prompt": "What is the meaning of life?"}))
.await
.unwrap();
assert!(result.contains("NoMoreToolCalls"));
assert!(result.contains("The answer is 42."));
}
#[tokio::test]
async fn sub_agent_depth_limit_enforced() {
let provider = mock_provider(vec![]);
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 2, None);
let result = sub
.execute(json!({"prompt": "do something"}))
.await
.unwrap();
assert!(result.contains("depth limit reached"));
assert!(result.contains("max_depth=2"));
}
#[tokio::test]
async fn sub_agent_tool_subset_respected() {
let provider = mock_provider(vec![Completion {
content: "done".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]);
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
let _ = sub
.execute(json!({"prompt": "read something", "tools": ["read_file"]}))
.await
.unwrap();
let defaults = SubAgent::default_tool_names();
assert!(!defaults.contains(&"apply_patch".to_string()));
assert!(!defaults.contains(&"write_file".to_string()));
assert!(defaults.contains(&"read_file".to_string()));
}
#[tokio::test]
async fn sub_agent_max_steps_capped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
let mut script = Vec::new();
for _ in 0..10 {
script.push(Completion {
content: "".to_string(),
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "read_file".into(),
arguments: json!({"path": "test.txt"}),
}],
finish_reason: Some("tool_calls".into()),
usage: None,
reasoning_content: None,
});
}
let provider = mock_provider(script);
let all_tools = full_tool_registry(tmp.path());
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
let result = sub
.execute(json!({"prompt": "loop", "max_steps": 5}))
.await
.unwrap();
assert!(result.contains("BudgetExceeded"));
}
#[tokio::test]
async fn sub_agent_default_tools_are_read_only() {
let defaults = SubAgent::default_tool_names();
assert!(defaults.contains(&"read_file".to_string()));
assert!(defaults.contains(&"list_dir".to_string()));
assert!(defaults.contains(&"search_files".to_string()));
assert!(defaults.contains(&"web_fetch".to_string()));
assert_eq!(defaults.len(), 4);
}
#[tokio::test]
async fn sub_agent_missing_prompt_returns_error() {
let provider = mock_provider(vec![]);
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
let result = sub.execute(json!({})).await;
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("missing required parameter: prompt"));
}
#[tokio::test]
async fn sub_agent_nested_depth_works() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
let provider = mock_provider(vec![
Completion {
content: "".to_string(),
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "sub_agent".into(),
arguments: json!({"prompt": "grandchild task"}),
}],
finish_reason: Some("tool_calls".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: "".to_string(),
tool_calls: vec![ToolCall {
id: "c2".into(),
name: "sub_agent".into(),
arguments: json!({"prompt": "great-grandchild task"}),
}],
finish_reason: Some("tool_calls".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: "grandchild done".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: "child done".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
},
]);
let all_tools = full_tool_registry(tmp.path());
let parent = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
let result = parent
.execute(json!({"prompt": "parent task", "tools": ["sub_agent", "read_file"]}))
.await
.unwrap();
assert!(result.contains("NoMoreToolCalls"), "result: {result}");
assert!(result.contains("child done"), "result: {result}");
}
#[test]
fn explore_agent_type_is_read_only() {
assert!(AgentType::Explore.is_read_only());
assert!(!AgentType::GeneralPurpose.is_read_only());
}
#[test]
fn agent_type_from_str_roundtrip() {
assert_eq!(AgentType::parse("explore"), Some(AgentType::Explore));
assert_eq!(
AgentType::parse("general_purpose"),
Some(AgentType::GeneralPurpose)
);
assert_eq!(AgentType::parse("unknown"), None);
assert_eq!(AgentType::parse(""), None);
}
#[test]
fn explore_agent_has_restricted_tool_list() {
let names = AgentType::Explore.allowed_tool_names().unwrap();
assert!(names.contains(&"read_file".to_string()));
assert!(names.contains(&"list_dir".to_string()));
assert!(names.contains(&"search_files".to_string()));
assert!(!names.contains(&"write_file".to_string()));
assert!(!names.contains(&"apply_patch".to_string()));
assert!(!names.contains(&"run_shell".to_string()));
}
#[test]
fn general_purpose_agent_has_no_forced_tool_list() {
assert!(AgentType::GeneralPurpose.allowed_tool_names().is_none());
}
#[test]
fn is_readonly_for_args_explore_returns_true() {
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let provider = mock_provider(vec![]);
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
assert!(sub.is_readonly_for_args(&json!({"subagent_type": "explore"})));
}
#[test]
fn is_readonly_for_args_general_purpose_returns_false() {
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let provider = mock_provider(vec![]);
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
assert!(!sub.is_readonly_for_args(&json!({"subagent_type": "general_purpose"})));
}
#[test]
fn is_readonly_for_args_missing_type_returns_false() {
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let provider = mock_provider(vec![]);
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
assert!(!sub.is_readonly_for_args(&json!({"prompt": "hello"})));
}
#[test]
fn is_readonly_for_args_unknown_type_returns_false() {
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let provider = mock_provider(vec![]);
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
assert!(!sub.is_readonly_for_args(&json!({"subagent_type": "super_agent"})));
}
#[tokio::test]
async fn explore_agent_dispatch_succeeds() {
let provider = mock_provider(vec![Completion {
content: "Exploration complete.".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]);
let tmp = tempfile::tempdir().unwrap();
let all_tools = full_tool_registry(tmp.path());
let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
let result = sub
.execute(json!({
"prompt": "Explore the workspace",
"subagent_type": "explore"
}))
.await
.unwrap();
assert!(result.contains("NoMoreToolCalls"));
assert!(result.contains("Exploration complete."));
}
}