1use super::super::{resolve_agent_prompt, Tool, ToolContext, NEXT_SUBAGENT_ID};
2pub use crate::runtime::subagent::SubagentResult;
3use crate::{AgentEvent, LlmEvent, Result, RuntimeError, SessionEvent};
4use serde_json::{json, Value};
5use std::sync::atomic::Ordering;
6use std::time::Duration;
7
8pub struct SubagentTool;
9
10#[async_trait::async_trait]
11impl Tool for SubagentTool {
12 fn origin(&self) -> crate::tools::ToolOrigin {
13 crate::tools::ToolOrigin::Builtin
14 }
15
16 fn name(&self) -> &str {
17 "subagent"
18 }
19
20 fn description(&self) -> &str {
21 "Dispatch a one-shot subagent with a specific system prompt to perform a task. The subagent gets its own tool suite (bash, read, write, edit, grep, find, ls) and runs autonomously until done. Use this when you need the result before continuing. Blocks until done. For parallel work, use subagent_start instead. Provide either an agent name (resolves from ~/.synaps-cli/agents/<name>.md) or a system_prompt string directly."
22 }
23
24 fn parameters(&self) -> Value {
25 json!({
26 "type": "object",
27 "properties": {
28 "agent": {
29 "type": "string",
30 "description": "Agent name — resolves to ~/.synaps-cli/agents/<name>.md. Mutually exclusive with system_prompt."
31 },
32 "system_prompt": {
33 "type": "string",
34 "description": "Inline system prompt for the subagent. Use when you don't have a named agent file."
35 },
36 "task": {
37 "type": "string",
38 "description": "The task/prompt to send to the subagent."
39 },
40 "model": {"type": "string", "description": "Omit to inherit the session foreground qualified identity. Explicit values must be one of subagent_models' listed exact choices."},
41 "role": {"type": "string", "enum": ["planner", "implementer", "tester", "reviewer", "researcher", "debugger"]},
42 "write_policy": {
43 "oneOf": [
44 {"type": "object", "properties": {"mode": {"const": "read_only"}}, "required": ["mode"]},
45 {"type": "object", "properties": {"mode": {"const": "isolated_worktree"}}, "required": ["mode"]},
46 {"type": "object", "properties": {"mode": {"const": "non_overlapping_paths"}, "scopes": {"type": "array", "items": {"type": "string"}}}, "required": ["mode", "scopes"]}
47 ]
48 },
49 "timeout": {
50 "type": "integer",
51 "description": "Timeout in seconds (default: 300). Increase for long-running tasks."
52 }
53 },
54 "required": ["task"]
55 })
56 }
57
58 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
59 let task = params["task"]
60 .as_str()
61 .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
62 .to_string();
63
64 let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
70 let agent_name = params["agent"]
71 .as_str()
72 .map(|s| s.to_string())
73 .filter(|s| !is_blank(s));
74 let inline_prompt = params["system_prompt"]
75 .as_str()
76 .map(|s| s.to_string())
77 .filter(|s| !is_blank(s));
78 let requested_model = params["model"].as_str();
79 let system_prompt = match (&agent_name, &inline_prompt) {
80 (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
81 (None, Some(prompt)) => prompt.clone(),
82 (None, None) => {
83 return Err(RuntimeError::Tool(
84 "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither."
85 .to_string(),
86 ));
87 }
88 };
89 let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
90 let orchestration_id = format!("sa_{}", subagent_id);
91 let decision = ctx
92 .capabilities
93 .orchestration
94 .as_ref()
95 .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?
96 .resolve_and_authorize(&orchestration_id, requested_model)
97 .map_err(|error| RuntimeError::Tool(error.to_string()))?;
98 let model = decision.model.as_str().to_owned();
99 let timeout_secs = params["timeout"]
100 .as_u64()
101 .unwrap_or(ctx.limits.subagent_timeout);
102
103 let label = agent_name.as_deref().unwrap_or("inline").to_string();
104 let task_preview: String = task.chars().take(80).collect();
105
106 tracing::info!(
107 "Dispatching subagent '{}' (id={}) with model {}",
108 label,
109 subagent_id,
110 model
111 );
112
113 if let Some(ref tx) = ctx.channels.tx_events {
114 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
115 subagent_id,
116 agent_name: label.clone(),
117 task_preview: task_preview.clone(),
118 }));
119 }
120
121 let start_time = std::time::Instant::now();
122
123 let (result_tx, result_rx) =
124 tokio::sync::oneshot::channel::<std::result::Result<SubagentResult, String>>();
125 let label_inner = label.clone();
126 let model_inner = model.clone();
127 let tx_events_inner = ctx.channels.tx_events.clone();
128
129 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
130 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
131 if let Err(error) = orchestration.mark_starting(&orchestration_id) {
132 orchestration.rollback(&orchestration_id);
133 return Err(RuntimeError::Tool(error));
134 }
135
136 let thread_handle = match std::thread::Builder::new().spawn(move || {
137 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
138 let rt = match tokio::runtime::Builder::new_current_thread()
139 .enable_all()
140 .build()
141 {
142 Ok(rt) => rt,
143 Err(_) => {
144 let _ = result_tx.send(Err("runtime initialization failed".into()));
145 return;
146 }
147 };
148
149 let result = rt.block_on(async move {
150 use futures::StreamExt;
151
152 let mut runtime = match crate::Runtime::new().await {
153 Ok(r) => r,
154 Err(_) => return Err("subagent runtime initialization failed".into()),
155 };
156
157 super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config());
162 runtime.set_system_prompt(system_prompt);
163 runtime.set_model(model);
164 runtime.set_tools(super::subagent_tools().await);
165
166 let cancel = crate::CancellationToken::new();
167 let cancel_inner = cancel.clone();
168
169 tokio::spawn(async move {
170 let _ = shutdown_rx.await;
171 cancel_inner.cancel();
172 });
173
174 let mut stream = runtime.run_stream(task, cancel).await;
175
176 let mut final_text = String::new();
177 let mut tool_count = 0u32;
178 let mut tool_log: Vec<String> = Vec::new();
179 let mut total_input_tokens = 0u64;
180 let mut total_output_tokens = 0u64;
181 let mut total_cache_read = 0u64;
182 let mut total_cache_creation = 0u64;
183 let mut total_cache_5m: Option<u64> = None;
185 let mut total_cache_1h: Option<u64> = None;
186
187 let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
188 tokio::pin!(timeout_fut);
189
190 loop {
191 tokio::select! {
192 event = stream.next() => {
193 let Some(event) = event else { break };
194 match event {
195 crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
196 if let Some(ref tx) = tx_events_inner {
197 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
198 subagent_id,
199 agent_name: label_inner.clone(),
200 status: "💭 thinking...".to_string(),
201 }));
202 }
203 }
204 crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
205 final_text.push_str(&text);
206 }
207 crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
208 tool_count += 1;
209 if let Some(ref tx) = tx_events_inner {
210 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
211 subagent_id,
212 agent_name: label_inner.clone(),
213 status: format!("⚙ {} (tool #{})", name, tool_count),
214 }));
215 }
216 }
217 crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
218 let input_str = input.to_string();
219 let input_preview: String = input_str.chars().take(200).collect();
220 tool_log.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
221 let detail = match tool_name.as_str() {
223 "bash" => {
224 let cmd = input["command"].as_str().unwrap_or("");
225 let preview: String = cmd.chars().take(60).collect();
226 format!("$ {}", preview)
227 }
228 "read" => {
229 let path = input["path"].as_str().unwrap_or("?");
230 let short = path.rsplit('/').next().unwrap_or(path);
231 format!("reading {}", short)
232 }
233 "write" => {
234 let path = input["path"].as_str().unwrap_or("?");
235 let short = path.rsplit('/').next().unwrap_or(path);
236 format!("writing {}", short)
237 }
238 "edit" => {
239 let path = input["path"].as_str().unwrap_or("?");
240 let short = path.rsplit('/').next().unwrap_or(path);
241 format!("editing {}", short)
242 }
243 "grep" => {
244 let pat = input["pattern"].as_str().unwrap_or("?");
245 let preview: String = pat.chars().take(30).collect();
246 format!("grep /{}/", preview)
247 }
248 "find" => {
249 let pat = input["pattern"].as_str().unwrap_or("?");
250 format!("find {}", pat)
251 }
252 "ls" => {
253 let path = input["path"].as_str().unwrap_or(".");
254 let short = path.rsplit('/').next().unwrap_or(path);
255 format!("ls {}", short)
256 }
257 "subagent" => {
258 let name = input["agent"].as_str()
259 .or_else(|| input["system_prompt"].as_str().map(|s| if s.len() > 20 { "inline" } else { s }))
260 .unwrap_or("?");
261 format!("spawning {}", name)
262 }
263 other => {
264 let short_name = if other.starts_with("ext__") {
266 other.splitn(3, "__").last().unwrap_or(other)
267 } else {
268 other
269 };
270 short_name.to_string()
271 }
272 };
273 if let Some(ref tx) = tx_events_inner {
274 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
275 subagent_id,
276 agent_name: label_inner.clone(),
277 status: detail,
278 }));
279 }
280 }
281 crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
282 let preview: String = result.chars().take(300).collect();
283 tool_log.push(format!("[tool_result]: {}", preview));
284 }
285 crate::StreamEvent::Session(SessionEvent::Usage {
286 input_tokens, output_tokens,
287 cache_read_input_tokens, cache_creation_input_tokens,
288 cache_creation_5m, cache_creation_1h,
289 model: _,
290 }) => {
291 total_input_tokens += input_tokens;
292 total_output_tokens += output_tokens;
293 total_cache_read += cache_read_input_tokens;
294 total_cache_creation += cache_creation_input_tokens;
295 crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
296 crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
297 }
298 crate::StreamEvent::Session(SessionEvent::Error(e)) => {
299 return Err(format!("{} [{}]", e.message, e.category_label()));
303 }
304 crate::StreamEvent::Session(SessionEvent::Done) => break,
305 _ => {}
306 }
307 }
308 _ = &mut timeout_fut => {
309 let mut partial = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
311 if !tool_log.is_empty() {
312 partial.push_str(&tool_log.join("\n"));
313 partial.push('\n');
314 }
315 if !final_text.is_empty() {
316 partial.push_str("\n[partial response]:\n");
317 partial.push_str(&final_text);
318 }
319 return Ok(SubagentResult {
320 text: partial,
321 model: model_inner,
322 input_tokens: total_input_tokens,
323 output_tokens: total_output_tokens,
324 cache_read: total_cache_read,
325 cache_creation: total_cache_creation,
326 cache_creation_5m: total_cache_5m,
327 cache_creation_1h: total_cache_1h,
328 tool_count,
329 timed_out: true,
330 });
331 }
332 }
333 }
334
335 Ok(SubagentResult {
336 text: final_text,
337 model: model_inner,
338 input_tokens: total_input_tokens,
339 output_tokens: total_output_tokens,
340 cache_read: total_cache_read,
341 cache_creation: total_cache_creation,
342 cache_creation_5m: total_cache_5m,
343 cache_creation_1h: total_cache_1h,
344 tool_count,
345 timed_out: false,
346 })
347 });
348
349 let _ = result_tx.send(result);
350 }));
351
352 if let Err(panic_info) = result {
353 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
354 s.to_string()
355 } else if let Some(s) = panic_info.downcast_ref::<String>() {
356 s.clone()
357 } else {
358 "unknown panic".to_string()
359 };
360 tracing::error!("Subagent thread panicked: {}", msg);
361 }
364 }) {
365 Ok(handle) => handle,
366 Err(_) => {
367 orchestration.rollback(&orchestration_id);
368 return Err(RuntimeError::Tool("worker thread initialization failed".into()));
369 }
370 };
371 if let Err(error) = orchestration.mark_running(&orchestration_id) {
372 drop(shutdown_tx);
373 let _ = thread_handle.join();
374 orchestration.rollback(&orchestration_id);
375 return Err(RuntimeError::Tool(error));
376 }
377
378 let result = result_rx.await;
379 let elapsed = start_time.elapsed().as_secs_f64();
380
381 drop(shutdown_tx);
382 let _ = thread_handle.join();
383
384 if let Some(policy) = &ctx.capabilities.orchestration {
387 let terminal = match &result {
388 Ok(Ok(result)) if result.timed_out => {
389 agent_core::orchestration::WorkerTerminal::TimedOut
390 }
391 Ok(Ok(_)) => agent_core::orchestration::WorkerTerminal::Completed,
392 Ok(Err(_)) | Err(_) => agent_core::orchestration::WorkerTerminal::Failed,
393 };
394 policy
395 .finish_one_shot(&orchestration_id, terminal)
396 .map_err(RuntimeError::Tool)?;
397 }
398
399 let log_dir = crate::config::base_dir().join("logs").join("subagents");
400 let _ = tokio::fs::create_dir_all(&log_dir).await;
401 let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
402
403 match result {
404 Ok(Ok(sa_result)) => {
405 let preview: String = sa_result.text.chars().take(120).collect();
406
407 if let Some(ref tx) = ctx.channels.tx_events {
408 let _ = tx.send(crate::StreamEvent::Session(SessionEvent::Usage {
409 input_tokens: sa_result.input_tokens,
410 output_tokens: sa_result.output_tokens,
411 cache_read_input_tokens: sa_result.cache_read,
412 cache_creation_input_tokens: sa_result.cache_creation,
413 cache_creation_5m: sa_result.cache_creation_5m,
416 cache_creation_1h: sa_result.cache_creation_1h,
417 model: Some(sa_result.model),
418 }));
419 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
420 subagent_id,
421 agent_name: label.clone(),
422 result_preview: preview,
423 duration_secs: elapsed,
424 }));
425 }
426
427 let log_content = format!(
428 "# Subagent: {}\nDate: {}\nModel: {}\nTask: {}\nDuration: {:.1}s\nTokens: {}in/{}out ({}cr/{}cw)\nTools used: {}\n\n## Result\n\n{}\n",
429 label, timestamp, params["model"].as_str().unwrap_or("sonnet"),
430 task_preview, elapsed,
431 sa_result.input_tokens, sa_result.output_tokens,
432 sa_result.cache_read, sa_result.cache_creation,
433 sa_result.tool_count, sa_result.text,
434 );
435 let log_path = log_dir.join(format!("{}-{}.md", timestamp, label));
436 let _ = tokio::fs::write(&log_path, &log_content).await;
437
438 Ok(format!("[subagent:{}] {}", label, sa_result.text))
439 }
440 Ok(Err(e)) => {
441 if let Some(ref tx) = ctx.channels.tx_events {
442 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
443 subagent_id,
444 agent_name: label.clone(),
445 result_preview: format!("ERROR: {}", e),
446 duration_secs: elapsed,
447 }));
448 }
449 let log_path = log_dir.join(format!("{}-{}-error.md", timestamp, label));
450 let _ = tokio::fs::write(
451 &log_path,
452 format!(
453 "# Subagent ERROR: {}\nTask: {}\nError: {}\n",
454 label, task_preview, e
455 ),
456 )
457 .await;
458 Ok(format!("[subagent:{} ERROR] {}", label, e))
459 }
460 Err(_) => {
461 if let Some(ref tx) = ctx.channels.tx_events {
462 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
463 subagent_id,
464 agent_name: label.clone(),
465 result_preview: "Task panicked or dropped".to_string(),
466 duration_secs: elapsed,
467 }));
468 }
469 Ok(format!(
470 "[subagent:{} ERROR] Subagent task panicked or was dropped",
471 label
472 ))
473 }
474 }
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::tools::test_helpers::create_tool_context;
482 use crate::tools::Tool;
483 use serde_json::json;
484
485 #[test]
486 fn test_subagent_tool_schema() {
487 let tool = SubagentTool;
488 assert_eq!(tool.name(), "subagent");
489 assert!(!tool.description().is_empty());
490
491 let params = tool.parameters();
492 assert_eq!(params["type"], "object");
493 assert!(params["properties"].is_object());
494 assert!(params["required"].is_array());
495 }
496
497 #[tokio::test]
498 async fn test_subagent_blank_agent_uses_system_prompt() {
499 let tool = SubagentTool;
500 let ctx = create_tool_context();
501 let params = json!({
502 "agent": "",
503 "system_prompt": "You are a concise test subagent. Reply with only: ok",
504 "task": "Say ok",
505 "model": "anthropic/claude-sonnet-4-6",
506 "timeout": 1
507 });
508
509 let result = tool.execute(params, ctx).await;
510 assert!(
511 result.is_ok(),
512 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
513 );
514 }
515}