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