Skip to content

Commit 19c0dcd

Browse files
committed
feat: implement delegate tool for background agent management
- Add delegate tool with list/launch/status operations - Validate agent names against available configs - Run agents silently in background with output capture - Store execution state in ~/.aws/amazonq/.subagents/ - Include PID monitoring for crash detection - Require experimental flag enablement
1 parent 0de1451 commit 19c0dcd

File tree

13 files changed

+848
-147
lines changed

13 files changed

+848
-147
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ xcuserdata/
88
.DS_Store
99
node_modules/
1010
.eslintcache
11+
12+
# Conversation/data files
13+
/delegate
14+
/delegatetool
1115
yarn-error.log
1216
yarn.lock
1317
package-lock.json

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: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
use std::path::PathBuf;
2+
use eyre::Result;
3+
use serde_json;
4+
5+
use crate::cli::chat::tools::delegate::types::{AgentConfig, AgentExecution};
6+
use crate::cli::chat::tools::delegate::ui::{display_agent_info, get_user_confirmation};
7+
use crate::os::Os;
8+
9+
fn home_dir(os: &Os) -> Result<PathBuf> {
10+
os.env.home().ok_or_else(|| eyre::eyre!("Could not determine home directory"))
11+
}
12+
13+
pub async fn validate_agent_availability(_os: &Os, _agent: &str) -> Result<()> {
14+
// For now, accept any agent name (no need to print here, will show in approval)
15+
Ok(())
16+
}
17+
18+
pub async fn request_user_approval(os: &Os, agent: &str, task: &str) -> Result<()> {
19+
let config = load_agent_config(os, agent).await;
20+
display_agent_info(agent, task, &config)?;
21+
get_user_confirmation()?;
22+
Ok(())
23+
}
24+
25+
async fn load_agent_config(os: &Os, agent: &str) -> AgentConfig {
26+
match load_real_agent_config(os, agent).await {
27+
Ok(config) => config,
28+
Err(_) => AgentConfig {
29+
description: Some(format!("Agent '{}' (no config found)", agent)),
30+
allowed_tools: vec!["No tools specified".to_string()],
31+
},
32+
}
33+
}
34+
35+
async fn load_real_agent_config(os: &Os, agent: &str) -> Result<AgentConfig> {
36+
let cli_agents_dir = home_dir(os)?
37+
.join(".aws")
38+
.join("amazonq")
39+
.join("cli-agents");
40+
41+
let config_path = cli_agents_dir.join(format!("{}.json", agent));
42+
if config_path.exists() {
43+
let content = os.fs.read_to_string(&config_path).await?;
44+
let config: serde_json::Value = serde_json::from_str(&content)?;
45+
46+
return Ok(AgentConfig {
47+
description: config.get("description")
48+
.and_then(|d| d.as_str())
49+
.map(|s| s.to_string()),
50+
allowed_tools: config.get("allowedTools")
51+
.and_then(|t| t.as_array())
52+
.map(|arr| arr.iter()
53+
.filter_map(|v| v.as_str())
54+
.map(|s| s.to_string())
55+
.collect())
56+
.or_else(|| {
57+
// Fallback to "tools" if "allowedTools" not found
58+
config.get("tools")
59+
.and_then(|t| t.as_array())
60+
.map(|arr| arr.iter()
61+
.filter_map(|v| v.as_str())
62+
.map(|s| s.to_string())
63+
.collect())
64+
})
65+
.unwrap_or_else(|| vec!["No tools specified".to_string()]),
66+
});
67+
}
68+
69+
Err(eyre::eyre!("Agent config not found"))
70+
}
71+
72+
pub async fn list_available_agents(os: &Os) -> Result<Vec<String>> {
73+
let cli_agents_dir = home_dir(os)?
74+
.join(".aws")
75+
.join("amazonq")
76+
.join("cli-agents");
77+
78+
if !cli_agents_dir.exists() {
79+
return Ok(vec![]);
80+
}
81+
82+
let mut agents = vec![];
83+
let mut entries = os.fs.read_dir(&cli_agents_dir).await?;
84+
85+
while let Some(entry) = entries.next_entry().await? {
86+
let path = entry.path();
87+
if let Some(extension) = path.extension() {
88+
if extension == "json" {
89+
if let Some(stem) = path.file_stem() {
90+
if let Some(agent_name) = stem.to_str() {
91+
agents.push(agent_name.to_string());
92+
}
93+
}
94+
}
95+
}
96+
}
97+
98+
agents.sort();
99+
Ok(agents)
100+
}
101+
102+
pub async fn load_agent_execution(os: &Os, agent: &str) -> Result<Option<AgentExecution>> {
103+
let file_path = agent_file_path(os, agent).await?;
104+
105+
if file_path.exists() {
106+
let content = os.fs.read_to_string(&file_path).await?;
107+
let execution: AgentExecution = serde_json::from_str(&content)?;
108+
Ok(Some(execution))
109+
} else {
110+
Ok(None)
111+
}
112+
}
113+
114+
pub async fn save_agent_execution(os: &Os, execution: &AgentExecution) -> Result<()> {
115+
let file_path = agent_file_path(os, &execution.agent).await?;
116+
let content = serde_json::to_string_pretty(execution)?;
117+
os.fs.write(&file_path, content).await?;
118+
Ok(())
119+
}
120+
121+
async fn agent_file_path(os: &Os, agent: &str) -> Result<PathBuf> {
122+
let subagents_dir = subagents_dir(os).await?;
123+
Ok(subagents_dir.join(format!("{}.json", agent)))
124+
}
125+
126+
async fn subagents_dir(os: &Os) -> Result<PathBuf> {
127+
let subagents_dir = home_dir(os)?.join(".aws").join("amazonq").join(".subagents");
128+
if !subagents_dir.exists() {
129+
os.fs.create_dir_all(&subagents_dir).await?;
130+
}
131+
Ok(subagents_dir)
132+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
use std::process::Command;
2+
use eyre::Result;
3+
use chrono::Utc;
4+
5+
use crate::cli::chat::tools::delegate::types::{AgentExecution, AgentStatus};
6+
use crate::cli::chat::tools::delegate::agent::{save_agent_execution, load_agent_execution};
7+
use crate::os::Os;
8+
9+
pub async fn spawn_agent_process(os: &Os, agent: &str, task: &str) -> Result<AgentExecution> {
10+
let now = Utc::now().to_rfc3339();
11+
12+
// Run Q chat with specific agent in background, non-interactive
13+
let mut cmd = tokio::process::Command::new("q");
14+
cmd.args(["chat", "--agent", agent, task]);
15+
16+
// Redirect to capture output (runs silently)
17+
cmd.stdout(std::process::Stdio::piped());
18+
cmd.stderr(std::process::Stdio::piped());
19+
cmd.stdin(std::process::Stdio::null()); // No user input
20+
21+
let child = cmd.spawn()?;
22+
let pid = child.id().unwrap_or(0);
23+
24+
let execution = AgentExecution {
25+
agent: agent.to_string(),
26+
task: task.to_string(),
27+
status: AgentStatus::Running,
28+
launched_at: now,
29+
completed_at: None,
30+
pid,
31+
exit_code: None,
32+
output: String::new(),
33+
};
34+
35+
save_agent_execution(os, &execution).await?;
36+
37+
// Start monitoring with the actual child process
38+
tokio::spawn(monitor_child_process(child, execution.clone(), os.clone()));
39+
40+
Ok(execution)
41+
}
42+
43+
async fn monitor_child_process(child: tokio::process::Child, mut execution: AgentExecution, os: Os) {
44+
match child.wait_with_output().await {
45+
Ok(output) => {
46+
execution.status = if output.status.success() {
47+
AgentStatus::Completed
48+
} else {
49+
AgentStatus::Failed
50+
};
51+
execution.completed_at = Some(Utc::now().to_rfc3339());
52+
execution.exit_code = output.status.code();
53+
54+
// Combine stdout and stderr into the output field
55+
let stdout = String::from_utf8_lossy(&output.stdout);
56+
let stderr = String::from_utf8_lossy(&output.stderr);
57+
execution.output = if stderr.is_empty() {
58+
stdout.to_string()
59+
} else {
60+
format!("STDOUT:\n{}\n\nSTDERR:\n{}", stdout, stderr)
61+
};
62+
63+
// Save to ~/.aws/amazonq/.subagents/{agent}.json
64+
if let Err(e) = save_agent_execution(&os, &execution).await {
65+
eprintln!("Failed to save agent execution: {}", e);
66+
}
67+
}
68+
Err(e) => {
69+
execution.status = AgentStatus::Failed;
70+
execution.completed_at = Some(Utc::now().to_rfc3339());
71+
execution.exit_code = Some(-1);
72+
execution.output = format!("Failed to wait for process: {}", e);
73+
74+
// Save to ~/.aws/amazonq/.subagents/{agent}.json
75+
if let Err(e) = save_agent_execution(&os, &execution).await {
76+
eprintln!("Failed to save agent execution: {}", e);
77+
}
78+
}
79+
}
80+
}
81+
82+
pub async fn status_agent(os: &Os, agent: &str) -> Result<String> {
83+
match load_agent_execution(os, agent).await? {
84+
Some(mut execution) => {
85+
// If status is running, check if PID is still alive
86+
if execution.status == AgentStatus::Running {
87+
if execution.pid != 0 && !is_process_alive(execution.pid) {
88+
// Process died, mark as failed
89+
execution.status = AgentStatus::Failed;
90+
execution.completed_at = Some(chrono::Utc::now().to_rfc3339());
91+
execution.exit_code = Some(-1);
92+
execution.output = "Process terminated unexpectedly (PID not found)".to_string();
93+
94+
// Save the updated status
95+
save_agent_execution(os, &execution).await?;
96+
}
97+
}
98+
99+
Ok(execution.format_status())
100+
},
101+
None => Ok(format!("No execution found for agent '{}'", agent)),
102+
}
103+
}
104+
105+
pub async fn status_all_agents(_os: &Os) -> Result<String> {
106+
// For now, just return a simple message
107+
Ok("Use --agent <name> to check specific agent status".to_string())
108+
}
109+
110+
fn is_process_alive(pid: u32) -> bool {
111+
#[cfg(unix)]
112+
{
113+
// Use `kill -0` to check if process exists without actually killing it
114+
Command::new("kill")
115+
.args(["-0", &pid.to_string()])
116+
.output()
117+
.map(|output| output.status.success())
118+
.unwrap_or(false)
119+
}
120+
121+
#[cfg(not(unix))]
122+
{
123+
// For non-Unix systems, assume process is alive (fallback)
124+
true
125+
}
126+
}

0 commit comments

Comments
 (0)