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": "Wall-clock limit in seconds. Omit for the session default (config `subagent_timeout`); 0 = no limit."
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(if timeout_secs == 0 {
205 Duration::MAX
206 } else {
207 Duration::from_secs(timeout_secs)
208 });
209 tokio::pin!(timeout_fut);
210
211 loop {
212 tokio::select! {
213 event = stream.next() => {
214 let Some(event) = event else { break };
215 match event {
216 crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
217 if let Some(ref tx) = tx_events_inner {
218 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
219 subagent_id,
220 agent_name: label_inner.clone(),
221 status: "💭 thinking...".to_string(),
222 }));
223 }
224 }
225 crate::StreamEvent::Llm(LlmEvent::ResponseStart) => {
226 response_baseline = (final_text.len(), tool_log.len(), tool_count);
227 }
228 crate::StreamEvent::Llm(LlmEvent::ResponseReset) => {
229 final_text.truncate(response_baseline.0);
230 tool_log.truncate(response_baseline.1);
231 tool_count = response_baseline.2;
232 }
233 crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
234 final_text.push_str(&text);
235 }
236 crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
237 tool_count += 1;
238 if let Some(ref tx) = tx_events_inner {
239 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
240 subagent_id,
241 agent_name: label_inner.clone(),
242 status: format!("⚙ {} (tool #{})", name, tool_count),
243 }));
244 }
245 }
246 crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
247 let input_str = input.to_string();
248 let input_preview: String = input_str.chars().take(200).collect();
249 tool_log.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
250 let detail = match tool_name.as_str() {
252 "bash" => {
253 let cmd = input["command"].as_str().unwrap_or("");
254 let preview: String = cmd.chars().take(60).collect();
255 format!("$ {}", preview)
256 }
257 "read" => {
258 let path = input["path"].as_str().unwrap_or("?");
259 let short = path.rsplit('/').next().unwrap_or(path);
260 format!("reading {}", short)
261 }
262 "write" => {
263 let path = input["path"].as_str().unwrap_or("?");
264 let short = path.rsplit('/').next().unwrap_or(path);
265 format!("writing {}", short)
266 }
267 "edit" => {
268 let path = input["path"].as_str().unwrap_or("?");
269 let short = path.rsplit('/').next().unwrap_or(path);
270 format!("editing {}", short)
271 }
272 "grep" => {
273 let pat = input["pattern"].as_str().unwrap_or("?");
274 let preview: String = pat.chars().take(30).collect();
275 format!("grep /{}/", preview)
276 }
277 "find" => {
278 let pat = input["pattern"].as_str().unwrap_or("?");
279 format!("find {}", pat)
280 }
281 "ls" => {
282 let path = input["path"].as_str().unwrap_or(".");
283 let short = path.rsplit('/').next().unwrap_or(path);
284 format!("ls {}", short)
285 }
286 "subagent" => {
287 let agent = input["agent"].as_str()
288 .filter(|s| !s.trim().is_empty());
289 let name = super::subagent_label(agent, input["name"].as_str());
290 format!("spawning {}", name)
291 }
292 other => {
293 let short_name = if other.starts_with("ext__") {
295 other.splitn(3, "__").last().unwrap_or(other)
296 } else {
297 other
298 };
299 short_name.to_string()
300 }
301 };
302 if let Some(ref tx) = tx_events_inner {
303 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
304 subagent_id,
305 agent_name: label_inner.clone(),
306 status: detail,
307 }));
308 }
309 }
310 crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
311 let preview: String = result.chars().take(300).collect();
312 tool_log.push(format!("[tool_result]: {}", preview));
313 }
314 crate::StreamEvent::Session(SessionEvent::Usage {
315 input_tokens, output_tokens,
316 cache_read_input_tokens, cache_creation_input_tokens,
317 cache_creation_5m, cache_creation_1h,
318 model: _,
319 }) => {
320 total_input_tokens += input_tokens;
321 total_output_tokens += output_tokens;
322 total_cache_read += cache_read_input_tokens;
323 total_cache_creation += cache_creation_input_tokens;
324 crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
325 crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
326 }
327 crate::StreamEvent::Session(SessionEvent::Error(e)) => {
328 return Err(format!("{} [{}]", e.message, e.category_label()));
332 }
333 crate::StreamEvent::Session(SessionEvent::Done) => break,
334 _ => {}
335 }
336 }
337 _ = &mut timeout_fut => {
338 let mut partial = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
340 if !tool_log.is_empty() {
341 partial.push_str(&tool_log.join("\n"));
342 partial.push('\n');
343 }
344 if !final_text.is_empty() {
345 partial.push_str("\n[partial response]:\n");
346 partial.push_str(&final_text);
347 }
348 return Ok(SubagentResult {
349 text: partial,
350 model: model_inner,
351 input_tokens: total_input_tokens,
352 output_tokens: total_output_tokens,
353 cache_read: total_cache_read,
354 cache_creation: total_cache_creation,
355 cache_creation_5m: total_cache_5m,
356 cache_creation_1h: total_cache_1h,
357 tool_count,
358 timed_out: true,
359 });
360 }
361 }
362 }
363
364 Ok(SubagentResult {
365 text: final_text,
366 model: model_inner,
367 input_tokens: total_input_tokens,
368 output_tokens: total_output_tokens,
369 cache_read: total_cache_read,
370 cache_creation: total_cache_creation,
371 cache_creation_5m: total_cache_5m,
372 cache_creation_1h: total_cache_1h,
373 tool_count,
374 timed_out: false,
375 })
376 });
377
378 let _ = result_tx.send(result);
379 }));
380
381 if let Err(panic_info) = result {
382 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
383 s.to_string()
384 } else if let Some(s) = panic_info.downcast_ref::<String>() {
385 s.clone()
386 } else {
387 "unknown panic".to_string()
388 };
389 tracing::error!("Subagent thread panicked: {}", msg);
390 }
393 }) {
394 Ok(handle) => handle,
395 Err(_) => {
396 orchestration.rollback(&orchestration_id);
397 return Err(RuntimeError::Tool("worker thread initialization failed".into()));
398 }
399 };
400 if let Err(error) = orchestration.mark_running(&orchestration_id) {
401 drop(shutdown_tx);
402 let _ = thread_handle.join();
403 orchestration.rollback(&orchestration_id);
404 return Err(RuntimeError::Tool(error));
405 }
406
407 let result = result_rx.await;
408 let elapsed = start_time.elapsed().as_secs_f64();
409
410 drop(shutdown_tx);
411 let _ = thread_handle.join();
412
413 if let Some(policy) = &ctx.capabilities.orchestration {
416 let terminal = match &result {
417 Ok(Ok(result)) if result.timed_out => {
418 agent_core::orchestration::WorkerTerminal::TimedOut
419 }
420 Ok(Ok(_)) => agent_core::orchestration::WorkerTerminal::Completed,
421 Ok(Err(_)) | Err(_) => agent_core::orchestration::WorkerTerminal::Failed,
422 };
423 policy
424 .finish_one_shot(&orchestration_id, terminal)
425 .map_err(RuntimeError::Tool)?;
426 }
427
428 let log_dir = crate::config::base_dir().join("logs").join("subagents");
429 let _ = tokio::fs::create_dir_all(&log_dir).await;
430 let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
431
432 match result {
433 Ok(Ok(sa_result)) => {
434 let preview: String = sa_result.text.chars().take(120).collect();
435
436 if let Some(ref tx) = ctx.channels.tx_events {
437 let _ = tx.send(crate::StreamEvent::Session(SessionEvent::Usage {
438 input_tokens: sa_result.input_tokens,
439 output_tokens: sa_result.output_tokens,
440 cache_read_input_tokens: sa_result.cache_read,
441 cache_creation_input_tokens: sa_result.cache_creation,
442 cache_creation_5m: sa_result.cache_creation_5m,
445 cache_creation_1h: sa_result.cache_creation_1h,
446 model: Some(sa_result.model),
447 }));
448 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
449 subagent_id,
450 agent_name: label.clone(),
451 result_preview: preview,
452 duration_secs: elapsed,
453 }));
454 }
455
456 let log_content = format!(
457 "# Subagent: {}\nDate: {}\nModel: {}\nTask: {}\nDuration: {:.1}s\nTokens: {}in/{}out ({}cr/{}cw)\nTools used: {}\n\n## Result\n\n{}\n",
458 label, timestamp, params["model"].as_str().unwrap_or("sonnet"),
459 task_preview, elapsed,
460 sa_result.input_tokens, sa_result.output_tokens,
461 sa_result.cache_read, sa_result.cache_creation,
462 sa_result.tool_count, sa_result.text,
463 );
464 let log_path = super::subagent_log_path(&log_dir, ×tamp, &label, false);
465 let _ = tokio::fs::write(&log_path, &log_content).await;
466
467 Ok(format!("[subagent:{}] {}", label, sa_result.text))
468 }
469 Ok(Err(e)) => {
470 if let Some(ref tx) = ctx.channels.tx_events {
471 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
472 subagent_id,
473 agent_name: label.clone(),
474 result_preview: format!("ERROR: {}", e),
475 duration_secs: elapsed,
476 }));
477 }
478 let log_path = super::subagent_log_path(&log_dir, ×tamp, &label, true);
479 let _ = tokio::fs::write(
480 &log_path,
481 format!(
482 "# Subagent ERROR: {}\nTask: {}\nError: {}\n",
483 label, task_preview, e
484 ),
485 )
486 .await;
487 Ok(format!("[subagent:{} ERROR] {}", label, e))
488 }
489 Err(_) => {
490 if let Some(ref tx) = ctx.channels.tx_events {
491 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
492 subagent_id,
493 agent_name: label.clone(),
494 result_preview: "Task panicked or dropped".to_string(),
495 duration_secs: elapsed,
496 }));
497 }
498 Ok(format!(
499 "[subagent:{} ERROR] Subagent task panicked or was dropped",
500 label
501 ))
502 }
503 }
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::tools::test_helpers::create_tool_context;
511 use crate::tools::Tool;
512 use serde_json::json;
513
514 #[test]
515 fn test_subagent_tool_schema() {
516 let tool = SubagentTool;
517 assert_eq!(tool.name(), "subagent");
518 assert!(!tool.description().is_empty());
519
520 let params = tool.parameters();
521 assert_eq!(params["type"], "object");
522 assert!(params["properties"].is_object());
523 assert!(params["required"].is_array());
524 }
525
526 #[tokio::test]
527 async fn test_subagent_blank_agent_uses_system_prompt() {
528 let tool = SubagentTool;
529 let ctx = create_tool_context();
530 let params = json!({
531 "agent": "",
532 "system_prompt": "You are a concise test subagent. Reply with only: ok",
533 "task": "Say ok",
534 "model": "anthropic/claude-sonnet-4-6",
535 "timeout": 1
536 });
537
538 let result = tool.execute(params, ctx).await;
539 assert!(
540 result.is_ok(),
541 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
542 );
543 }
544}