Skip to content

Commit bc6356a

Browse files
committed
feat: implement delegate tool for agent task management
- Add delegate tool for launching and managing asynchronous agent processes - Support agent validation with approval UI for security - Enable status checking for individual agents or all agents - Implement background process monitoring and execution tracking - Store execution state in ~/.aws/amazonq/.subagents/ - Support both global (~/.aws/amazonq/cli-agents/) and local (.amazonq/cli-agents/) agent configurations - Add experimental flag for delegate functionality - Clean error handling with proper agent not found messages - Unicode characters instead of emojis for better terminal compatibility The delegate tool allows users to offload tasks to specialized agents that run independently, enabling parallel work and better task organization. Agents require user approval before execution for security, and the tool provides comprehensive status tracking.
1 parent 0de1451 commit bc6356a

File tree

16 files changed

+950
-147
lines changed

16 files changed

+950
-147
lines changed

crates/chat-cli/src/cli/chat/cli/experiment.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ static AVAILABLE_EXPERIMENTS: &[Experiment] = &[
5050
description: "Enables Q to create todo lists that can be viewed and managed using /todos",
5151
setting_key: Setting::EnabledTodoList,
5252
},
53+
Experiment {
54+
name: "Delegate",
55+
description: "Enables launching and managing asynchronous subagent processes",
56+
setting_key: Setting::EnabledDelegate,
57+
},
5358
];
5459

5560
#[derive(Debug, PartialEq, Args)]

crates/chat-cli/src/cli/chat/tool_manager.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ use crate::cli::chat::server_messenger::{
7171
UpdateEventMessage,
7272
};
7373
use crate::cli::chat::tools::custom_tool::CustomTool;
74+
use crate::cli::chat::tools::delegate::Delegate;
7475
use crate::cli::chat::tools::execute::ExecuteCommand;
7576
use crate::cli::chat::tools::fs_read::FsRead;
7677
use crate::cli::chat::tools::fs_write::FsWrite;
@@ -728,6 +729,9 @@ impl ToolManager {
728729
if !crate::cli::chat::tools::todo::TodoList::is_enabled(os) {
729730
tool_specs.remove("todo_list");
730731
}
732+
if !os.database.settings.get_bool(Setting::EnabledDelegate).unwrap_or(false) {
733+
tool_specs.remove("delegate");
734+
}
731735

732736
#[cfg(windows)]
733737
{
@@ -873,6 +877,7 @@ impl ToolManager {
873877
"thinking" => Tool::Thinking(serde_json::from_value::<Thinking>(value.args).map_err(map_err)?),
874878
"knowledge" => Tool::Knowledge(serde_json::from_value::<Knowledge>(value.args).map_err(map_err)?),
875879
"todo_list" => Tool::Todo(serde_json::from_value::<TodoList>(value.args).map_err(map_err)?),
880+
"delegate" => Tool::Delegate(serde_json::from_value::<Delegate>(value.args).map_err(map_err)?),
876881
// Note that this name is namespaced with server_name{DELIMITER}tool_name
877882
name => {
878883
// Note: tn_map also has tools that underwent no transformation. In otherwords, if
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use std::collections::HashMap;
2+
3+
use eyre::{
4+
Result,
5+
eyre,
6+
};
7+
8+
use crate::cli::chat::tools::delegate::agent_paths::AgentPaths;
9+
use crate::cli::chat::tools::delegate::errors::AgentError;
10+
use crate::cli::chat::tools::delegate::file_ops::{
11+
load_json,
12+
save_json,
13+
};
14+
use crate::cli::chat::tools::delegate::types::{
15+
AgentConfig,
16+
AgentExecution,
17+
};
18+
use crate::cli::chat::tools::delegate::ui::{
19+
display_agent_info,
20+
display_default_agent_warning,
21+
get_user_confirmation,
22+
};
23+
use crate::os::Os;
24+
25+
const DEFAULT_AGENT: &str = "default";
26+
27+
pub async fn validate_agent_availability(os: &Os, agent: &str) -> Result<()> {
28+
if let Some(existing) = load_agent_execution(os, agent).await? {
29+
if existing.is_active() {
30+
return Err(eyre!("{}", AgentError::already_running(agent)));
31+
}
32+
}
33+
34+
if agent != DEFAULT_AGENT {
35+
let agents_config = load_available_agents(os).await?;
36+
if !agents_config.contains_key(agent) {
37+
let available: Vec<String> = agents_config.keys().cloned().collect();
38+
return Err(eyre!("{}", AgentError::not_found(agent, &available)));
39+
}
40+
}
41+
42+
Ok(())
43+
}
44+
45+
pub async fn request_user_approval(os: &Os, agent: &str, task: &str) -> Result<()> {
46+
if agent != DEFAULT_AGENT {
47+
let agents_config = load_available_agents(os).await?;
48+
if let Some(agent_config) = agents_config.get(agent) {
49+
display_agent_info(agent, task, agent_config)?;
50+
if !get_user_confirmation()? {
51+
return Err(eyre!("✗ Task delegation cancelled by user."));
52+
}
53+
}
54+
} else {
55+
display_default_agent_warning()?;
56+
}
57+
Ok(())
58+
}
59+
60+
pub async fn load_agent_execution(os: &Os, agent: &str) -> Result<Option<AgentExecution>> {
61+
let file_path = AgentPaths::agent_file(os, agent).await?;
62+
load_json(os, &file_path).await
63+
}
64+
65+
pub async fn save_agent_execution(os: &Os, execution: &AgentExecution) -> Result<()> {
66+
let file_path = AgentPaths::agent_file(os, &execution.agent).await?;
67+
save_json(os, &file_path, execution).await
68+
}
69+
70+
pub async fn load_available_agents(os: &Os) -> Result<HashMap<String, AgentConfig>> {
71+
let agents_dir = AgentPaths::cli_agents_dir(os).await?;
72+
let mut agents = HashMap::new();
73+
74+
if agents_dir.exists() {
75+
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
76+
for entry in entries.flatten() {
77+
if let Some(file_name) = entry.file_name().to_str() {
78+
if file_name.ends_with(".json") {
79+
let agent_name = file_name.trim_end_matches(".json");
80+
if let Ok(content) = std::fs::read_to_string(entry.path()) {
81+
if let Ok(config) = serde_json::from_str::<AgentConfig>(&content) {
82+
agents.insert(agent_name.to_string(), config);
83+
}
84+
}
85+
}
86+
}
87+
}
88+
}
89+
}
90+
91+
Ok(agents)
92+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use std::path::PathBuf;
2+
3+
use eyre::Result;
4+
5+
use crate::os::Os;
6+
7+
pub struct AgentPaths;
8+
9+
impl AgentPaths {
10+
pub async fn subagents_dir(os: &Os) -> Result<PathBuf> {
11+
let home_dir = os.env.home().unwrap_or_default();
12+
let agents_dir = home_dir.join(".aws").join("amazonq").join(".subagents");
13+
14+
if !agents_dir.exists() {
15+
std::fs::create_dir_all(&agents_dir)?;
16+
}
17+
18+
Ok(agents_dir)
19+
}
20+
21+
pub async fn cli_agents_dir(os: &Os) -> Result<PathBuf> {
22+
let home_dir = os.env.home().unwrap_or_default();
23+
Ok(home_dir.join(".aws").join("amazonq").join("cli-agents"))
24+
}
25+
26+
pub async fn agent_file(os: &Os, agent: &str) -> Result<PathBuf> {
27+
let agents_dir = Self::subagents_dir(os).await?;
28+
Ok(agents_dir.join(format!("{}.json", agent)))
29+
}
30+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
pub struct AgentError;
2+
3+
impl AgentError {
4+
pub fn not_found(agent: &str, available: &[String]) -> String {
5+
if available.is_empty() {
6+
format!(
7+
"✗ I can't find agent '{}'. No agents are configured. You need to set up agents first.",
8+
agent
9+
)
10+
} else {
11+
format!(
12+
"✗ I can't find agent '{}'. Available agents: {}\n\nPlease use one of the available agents or set up the '{}' agent first.",
13+
agent,
14+
available.join(", "),
15+
agent
16+
)
17+
}
18+
}
19+
20+
pub fn already_running(agent: &str) -> String {
21+
format!("Agent '{}' is already running a task", agent)
22+
}
23+
24+
pub fn no_execution_found(agent: &str) -> String {
25+
format!("No execution found for agent '{}'", agent)
26+
}
27+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use std::path::Path;
2+
3+
use eyre::Result;
4+
use serde::Serialize;
5+
use serde::de::DeserializeOwned;
6+
7+
use crate::os::Os;
8+
9+
pub async fn load_json<T: DeserializeOwned>(os: &Os, path: &Path) -> Result<Option<T>> {
10+
if !path.exists() {
11+
return Ok(None);
12+
}
13+
14+
let content = os.fs.read_to_string(path).await?;
15+
let data: T = serde_json::from_str(&content)?;
16+
Ok(Some(data))
17+
}
18+
19+
pub async fn save_json<T: Serialize>(os: &Os, path: &Path, data: &T) -> Result<()> {
20+
let content = serde_json::to_string_pretty(data)?;
21+
os.fs.write(path, content).await?;
22+
Ok(())
23+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
mod agent_manager;
2+
mod agent_paths;
3+
mod errors;
4+
mod file_ops;
5+
mod process;
6+
mod status;
7+
mod types;
8+
mod ui;
9+
10+
// Re-export types for external use
11+
use std::io::Write;
12+
13+
use agent_manager::{
14+
request_user_approval,
15+
validate_agent_availability,
16+
};
17+
use eyre::{
18+
Result,
19+
eyre,
20+
};
21+
use process::{
22+
format_launch_success,
23+
spawn_agent_process,
24+
start_monitoring,
25+
};
26+
use serde::{
27+
Deserialize,
28+
Serialize,
29+
};
30+
use status::{
31+
status_agent,
32+
status_all_agents,
33+
};
34+
#[allow(unused_imports)]
35+
pub use types::{
36+
AgentConfig,
37+
AgentExecution,
38+
AgentStatus,
39+
};
40+
41+
use crate::cli::chat::tools::{
42+
InvokeOutput,
43+
OutputKind,
44+
};
45+
use crate::database::settings::Setting;
46+
use crate::os::Os;
47+
48+
const OPERATION_LAUNCH: &str = "launch";
49+
const OPERATION_STATUS: &str = "status";
50+
const DEFAULT_AGENT: &str = "default";
51+
const ALL_AGENTS: &str = "all";
52+
53+
#[derive(Debug, Clone, Serialize, Deserialize)]
54+
pub struct Delegate {
55+
/// Operation to perform: launch or status
56+
pub operation: String,
57+
/// Agent name to use (optional - uses "default_agent" if not specified)
58+
#[serde(default)]
59+
pub agent: Option<String>,
60+
/// Task description (required for launch operation)
61+
#[serde(default)]
62+
pub task: Option<String>,
63+
}
64+
65+
impl Delegate {
66+
pub async fn invoke(&self, os: &Os, _stdout: &mut impl Write) -> Result<InvokeOutput> {
67+
if !is_enabled(os) {
68+
return Ok(InvokeOutput {
69+
output: OutputKind::Text(
70+
"Delegate tool is experimental and not enabled. Use /experiment to enable it.".to_string(),
71+
),
72+
});
73+
}
74+
75+
let agent_name = self.get_agent_name();
76+
let result = match self.operation.as_str() {
77+
OPERATION_LAUNCH => {
78+
let task = self
79+
.task
80+
.as_ref()
81+
.ok_or_else(|| eyre!("Task description required for launch operation"))?;
82+
launch_agent(os, agent_name, task).await?
83+
},
84+
OPERATION_STATUS => {
85+
if agent_name == ALL_AGENTS {
86+
status_all_agents(os).await?
87+
} else {
88+
status_agent(os, agent_name).await?
89+
}
90+
},
91+
_ => {
92+
return Err(eyre!(
93+
"Invalid operation. Use: {} or {}",
94+
OPERATION_LAUNCH,
95+
OPERATION_STATUS
96+
));
97+
},
98+
};
99+
100+
Ok(InvokeOutput {
101+
output: OutputKind::Text(result),
102+
})
103+
}
104+
105+
pub fn queue_description(&self, output: &mut impl Write) -> Result<()> {
106+
let agent_name = self.get_agent_name();
107+
match self.operation.as_str() {
108+
OPERATION_LAUNCH => writeln!(output, "Launching agent '{}'", agent_name)?,
109+
OPERATION_STATUS => writeln!(output, "Checking status of agent '{}'", agent_name)?,
110+
_ => writeln!(
111+
output,
112+
"Delegate operation '{}' on agent '{}'",
113+
self.operation, agent_name
114+
)?,
115+
}
116+
Ok(())
117+
}
118+
119+
fn get_agent_name(&self) -> &str {
120+
match self.operation.as_str() {
121+
OPERATION_LAUNCH => self.agent.as_deref().unwrap_or(DEFAULT_AGENT),
122+
OPERATION_STATUS => self.agent.as_deref().unwrap_or(ALL_AGENTS),
123+
_ => self.agent.as_deref().unwrap_or(DEFAULT_AGENT),
124+
}
125+
}
126+
}
127+
128+
async fn launch_agent(os: &Os, agent: &str, task: &str) -> Result<String> {
129+
validate_agent_availability(os, agent).await?;
130+
request_user_approval(os, agent, task).await?;
131+
let execution = spawn_agent_process(os, agent, task).await?;
132+
start_monitoring(execution, os.clone()).await;
133+
Ok(format_launch_success(agent, task))
134+
}
135+
136+
fn is_enabled(os: &Os) -> bool {
137+
os.database.settings.get_bool(Setting::EnabledDelegate).unwrap_or(false)
138+
}

0 commit comments

Comments
 (0)