Skip to main content

hh_cli/tool/
task.rs

1use crate::agent::{AgentLoader, AgentMode, AgentRegistry};
2use crate::config::Settings;
3use crate::core::agent::subagent_manager::{SubagentManager, SubagentRequest};
4use crate::session::SessionStore;
5use crate::tool::{Tool, ToolResult, ToolSchema, parse_tool_args};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use std::path::PathBuf;
10use std::sync::Arc;
11
12#[derive(Clone)]
13pub struct TaskToolRuntimeContext {
14    pub manager: Arc<SubagentManager>,
15    pub settings: Settings,
16    pub workspace_root: PathBuf,
17    pub parent_session_id: String,
18    pub parent_task_id: Option<String>,
19    pub depth: usize,
20}
21
22pub struct TaskTool {
23    context: TaskToolRuntimeContext,
24    available_subagents: Vec<AvailableSubagent>,
25}
26
27impl TaskTool {
28    pub fn new(context: TaskToolRuntimeContext) -> Self {
29        let available_subagents = discover_available_subagents();
30        Self {
31            context,
32            available_subagents,
33        }
34    }
35}
36
37#[derive(Debug, Clone)]
38struct AvailableSubagent {
39    name: String,
40    description: String,
41}
42
43#[derive(Debug, Serialize)]
44struct TaskToolOutput {
45    task_id: String,
46    name: String,
47    description: String,
48    status: String,
49    message: String,
50    agent_name: String,
51    prompt: String,
52    depth: usize,
53    parent_task_id: Option<String>,
54    started_at: u64,
55    finished_at: Option<u64>,
56    summary: Option<String>,
57    error: Option<String>,
58}
59
60#[derive(Debug, Deserialize)]
61struct TaskToolArgs {
62    name: String,
63    description: String,
64    prompt: String,
65    subagent_type: String,
66    #[serde(default)]
67    task_id: Option<String>,
68}
69
70#[async_trait]
71impl Tool for TaskTool {
72    fn schema(&self) -> ToolSchema {
73        let subagent_names: Vec<String> = self
74            .available_subagents
75            .iter()
76            .map(|agent| agent.name.clone())
77            .collect();
78        let mut subagent_type_schema = json!({
79            "type": "string",
80            "description": "Registered sub-agent name"
81        });
82        if !subagent_names.is_empty() {
83            subagent_type_schema["enum"] = json!(subagent_names);
84        }
85
86        ToolSchema {
87            name: "task".to_string(),
88            description: format!(
89                "Spawn or resume a sub-agent task.\n\nParameter contract:\n- `name` (required): human-readable task label shown in UI.\n- `description` (required): short statement of delegated intent.\n- `prompt` (required): full instructions for the child agent.\n- `subagent_type` (required): which registered sub-agent to run.\n- `task_id` (optional): if provided, resume that existing child task in this parent session; if omitted, create a new task.\n\nReturn semantics:\n- Returns terminal status for this task (`done`/`error`/`cancelled`) after execution completes.\n\n{}",
90                format_available_subagents(&self.available_subagents),
91            ),
92            capability: Some("task".to_string()),
93            mutating: Some(false),
94            parameters: json!({
95                "type": "object",
96                "properties": {
97                    "name": {
98                        "type": "string",
99                        "description": "Required. Human-readable task name shown in UI subagent list"
100                    },
101                    "description": {
102                        "type": "string",
103                        "description": "Required. Short summary of what this delegated task should achieve"
104                    },
105                    "prompt": {
106                        "type": "string",
107                        "description": "Required. Full prompt/instructions executed by the selected sub-agent. Ask the child to keep output concise (short summary with only essential facts)."
108                    },
109                    "subagent_type": subagent_type_schema,
110                    "task_id": {
111                        "type": "string",
112                        "description": "Optional. Existing task id to resume within the current parent session; omit to start a new task"
113                    }
114                },
115                "required": ["name", "description", "prompt", "subagent_type"],
116                "additionalProperties": false
117            }),
118        }
119    }
120
121    async fn execute(&self, args: serde_json::Value) -> ToolResult {
122        let parsed: TaskToolArgs = match parse_tool_args(args, "task") {
123            Ok(value) => value,
124            Err(err) => return err,
125        };
126
127        if let Err(err) = validate_non_empty("name", &parsed.name) {
128            return err;
129        }
130        if let Err(err) = validate_non_empty("description", &parsed.description) {
131            return err;
132        }
133        if let Err(err) = validate_non_empty("prompt", &parsed.prompt) {
134            return err;
135        }
136
137        let registry = match load_agent_registry() {
138            Ok(registry) => registry,
139            Err(err) => return ToolResult::error(err),
140        };
141
142        let Some(agent) = registry.get_agent(&parsed.subagent_type) else {
143            return ToolResult::error(format!("unknown subagent_type: {}", parsed.subagent_type));
144        };
145        if agent.mode != AgentMode::Subagent {
146            return ToolResult::error(format!(
147                "agent '{}' is not a subagent (mode is {:?})",
148                agent.name, agent.mode
149            ));
150        }
151
152        let parent_session = match SessionStore::new(
153            &self.context.settings.session.root,
154            &self.context.workspace_root,
155            Some(&self.context.parent_session_id),
156            None,
157        ) {
158            Ok(store) => store,
159            Err(err) => return ToolResult::error(format!("failed to open parent session: {err}")),
160        };
161
162        let task_description = parsed.description.clone();
163
164        let accepted = match self
165            .context
166            .manager
167            .start_or_resume(
168                SubagentRequest {
169                    name: parsed.name.clone(),
170                    description: parsed.description,
171                    prompt: parsed.prompt.clone(),
172                    subagent_type: parsed.subagent_type.clone(),
173                    resume_task_id: parsed.task_id,
174                    parent_session_id: self.context.parent_session_id.clone(),
175                    parent_task_id: self.context.parent_task_id.clone(),
176                    depth: self.context.depth,
177                },
178                parent_session,
179            )
180            .await
181        {
182            Ok(accepted) => accepted,
183            Err(err) => return ToolResult::error(err.to_string()),
184        };
185
186        let task_id = accepted.task_id;
187        let message = accepted.message;
188
189        let completed = match self
190            .context
191            .manager
192            .wait_for_terminal(&self.context.parent_session_id, &task_id)
193            .await
194        {
195            Ok(node) => node,
196            Err(err) => return ToolResult::error(err.to_string()),
197        };
198
199        let output = TaskToolOutput {
200            task_id,
201            name: completed.name,
202            description: task_description,
203            status: completed.status.label().to_string(),
204            message,
205            agent_name: completed.agent_name,
206            prompt: completed.prompt,
207            depth: completed.depth,
208            parent_task_id: completed.parent_task_id,
209            started_at: completed.started_at,
210            finished_at: Some(completed.updated_at),
211            summary: completed.summary,
212            error: completed.error,
213        };
214
215        ToolResult::ok_json_typed_serializable(
216            "sub-agent completed",
217            "application/vnd.hh.subagent.task+json",
218            &output,
219        )
220    }
221}
222
223fn load_agent_registry() -> Result<AgentRegistry, String> {
224    let loader =
225        AgentLoader::new().map_err(|err| format!("failed to load agent registry: {err}"))?;
226    let agents = loader
227        .load_agents()
228        .map_err(|err| format!("failed to load agents: {err}"))?;
229    Ok(AgentRegistry::new(agents))
230}
231
232fn validate_non_empty(field: &str, value: &str) -> Result<(), ToolResult> {
233    if value.trim().is_empty() {
234        return Err(ToolResult::error(format!("{field} must not be empty")));
235    }
236    Ok(())
237}
238
239fn discover_available_subagents() -> Vec<AvailableSubagent> {
240    let registry = match load_agent_registry() {
241        Ok(registry) => registry,
242        Err(_) => return Vec::new(),
243    };
244
245    let mut subagents = registry
246        .list_agents()
247        .into_iter()
248        .filter(|agent| agent.mode == AgentMode::Subagent)
249        .map(|agent| AvailableSubagent {
250            name: agent.name.clone(),
251            description: agent.description.clone(),
252        })
253        .collect::<Vec<_>>();
254    subagents.sort_by(|left, right| left.name.cmp(&right.name));
255    subagents
256}
257
258fn format_available_subagents(subagents: &[AvailableSubagent]) -> String {
259    if subagents.is_empty() {
260        return "<available_subagents>none</available_subagents>".to_string();
261    }
262
263    let mut description = String::from("<available_subagents>");
264    for subagent in subagents {
265        description.push_str("\n<subagent>");
266        description.push_str("\n<name>");
267        description.push_str(&subagent.name);
268        description.push_str("</name>");
269        description.push_str("\n<description>");
270        description.push_str(&subagent.description);
271        description.push_str("</description>");
272        description.push_str("\n</subagent>");
273    }
274    description.push_str("\n</available_subagents>");
275    description
276}