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 codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
100 let memory_backend = ctx.capabilities.memory_backend.clone();
101 let timeout_secs = params["timeout"]
102 .as_u64()
103 .unwrap_or(ctx.limits.subagent_timeout);
104
105 let label = agent_name.as_deref().unwrap_or("inline").to_string();
106 let task_preview: String = task.chars().take(80).collect();
107
108 tracing::info!(
109 "Dispatching subagent '{}' (id={}) with model {}",
110 label,
111 subagent_id,
112 model
113 );
114
115 if let Some(ref tx) = ctx.channels.tx_events {
116 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
117 subagent_id,
118 agent_name: label.clone(),
119 task_preview: task_preview.clone(),
120 }));
121 }
122
123 let start_time = std::time::Instant::now();
124
125 let (result_tx, result_rx) =
126 tokio::sync::oneshot::channel::<std::result::Result<SubagentResult, String>>();
127 let label_inner = label.clone();
128 let model_inner = model.clone();
129 let tx_events_inner = ctx.channels.tx_events.clone();
130
131 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
132 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
133 if let Err(error) = orchestration.mark_starting(&orchestration_id) {
134 orchestration.rollback(&orchestration_id);
135 return Err(RuntimeError::Tool(error));
136 }
137
138 let thread_handle = match std::thread::Builder::new().spawn(move || {
139 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
140 let rt = match tokio::runtime::Builder::new_current_thread()
141 .enable_all()
142 .build()
143 {
144 Ok(rt) => rt,
145 Err(_) => {
146 let _ = result_tx.send(Err("runtime initialization failed".into()));
147 return;
148 }
149 };
150
151 let result = rt.block_on(async move {
152 use futures::StreamExt;
153
154 let mut runtime = match super::spawn_runtime().await {
157 Ok(r) => r,
158 Err(_) => return Err("subagent runtime initialization failed".into()),
159 };
160
161 super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config(), memory_backend.as_ref());
166 runtime.set_system_prompt(super::compose_system_prompt(system_prompt, runtime.memory_backend_is_axel()));
167 runtime.set_model(model);
168 super::apply_codex_worker_reasoning(&mut runtime, codex_parent_plan.as_ref());
169
170 let cancel = crate::CancellationToken::new();
171 let cancel_inner = cancel.clone();
172
173 tokio::spawn(async move {
174 let _ = shutdown_rx.await;
175 cancel_inner.cancel();
176 });
177
178 let mut stream = runtime.run_stream(task, cancel).await;
179
180 let mut final_text = String::new();
181 let mut tool_count = 0u32;
182 let mut tool_log: Vec<String> = Vec::new();
183 let mut response_baseline: (usize, usize, u32) = (0, 0, 0);
184 let mut total_input_tokens = 0u64;
185 let mut total_output_tokens = 0u64;
186 let mut total_cache_read = 0u64;
187 let mut total_cache_creation = 0u64;
188 let mut total_cache_5m: Option<u64> = None;
190 let mut total_cache_1h: Option<u64> = None;
191
192 let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
193 tokio::pin!(timeout_fut);
194
195 loop {
196 tokio::select! {
197 event = stream.next() => {
198 let Some(event) = event else { break };
199 match event {
200 crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
201 if let Some(ref tx) = tx_events_inner {
202 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
203 subagent_id,
204 agent_name: label_inner.clone(),
205 status: "💭 thinking...".to_string(),
206 }));
207 }
208 }
209 crate::StreamEvent::Llm(LlmEvent::ResponseStart) => {
210 response_baseline = (final_text.len(), tool_log.len(), tool_count);
211 }
212 crate::StreamEvent::Llm(LlmEvent::ResponseReset) => {
213 final_text.truncate(response_baseline.0);
214 tool_log.truncate(response_baseline.1);
215 tool_count = response_baseline.2;
216 }
217 crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
218 final_text.push_str(&text);
219 }
220 crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
221 tool_count += 1;
222 if let Some(ref tx) = tx_events_inner {
223 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
224 subagent_id,
225 agent_name: label_inner.clone(),
226 status: format!("⚙ {} (tool #{})", name, tool_count),
227 }));
228 }
229 }
230 crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
231 let input_str = input.to_string();
232 let input_preview: String = input_str.chars().take(200).collect();
233 tool_log.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
234 let detail = match tool_name.as_str() {
236 "bash" => {
237 let cmd = input["command"].as_str().unwrap_or("");
238 let preview: String = cmd.chars().take(60).collect();
239 format!("$ {}", preview)
240 }
241 "read" => {
242 let path = input["path"].as_str().unwrap_or("?");
243 let short = path.rsplit('/').next().unwrap_or(path);
244 format!("reading {}", short)
245 }
246 "write" => {
247 let path = input["path"].as_str().unwrap_or("?");
248 let short = path.rsplit('/').next().unwrap_or(path);
249 format!("writing {}", short)
250 }
251 "edit" => {
252 let path = input["path"].as_str().unwrap_or("?");
253 let short = path.rsplit('/').next().unwrap_or(path);
254 format!("editing {}", short)
255 }
256 "grep" => {
257 let pat = input["pattern"].as_str().unwrap_or("?");
258 let preview: String = pat.chars().take(30).collect();
259 format!("grep /{}/", preview)
260 }
261 "find" => {
262 let pat = input["pattern"].as_str().unwrap_or("?");
263 format!("find {}", pat)
264 }
265 "ls" => {
266 let path = input["path"].as_str().unwrap_or(".");
267 let short = path.rsplit('/').next().unwrap_or(path);
268 format!("ls {}", short)
269 }
270 "subagent" => {
271 let name = input["agent"].as_str()
272 .or_else(|| input["system_prompt"].as_str().map(|s| if s.len() > 20 { "inline" } else { s }))
273 .unwrap_or("?");
274 format!("spawning {}", name)
275 }
276 other => {
277 let short_name = if other.starts_with("ext__") {
279 other.splitn(3, "__").last().unwrap_or(other)
280 } else {
281 other
282 };
283 short_name.to_string()
284 }
285 };
286 if let Some(ref tx) = tx_events_inner {
287 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
288 subagent_id,
289 agent_name: label_inner.clone(),
290 status: detail,
291 }));
292 }
293 }
294 crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
295 let preview: String = result.chars().take(300).collect();
296 tool_log.push(format!("[tool_result]: {}", preview));
297 }
298 crate::StreamEvent::Session(SessionEvent::Usage {
299 input_tokens, output_tokens,
300 cache_read_input_tokens, cache_creation_input_tokens,
301 cache_creation_5m, cache_creation_1h,
302 model: _,
303 }) => {
304 total_input_tokens += input_tokens;
305 total_output_tokens += output_tokens;
306 total_cache_read += cache_read_input_tokens;
307 total_cache_creation += cache_creation_input_tokens;
308 crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
309 crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
310 }
311 crate::StreamEvent::Session(SessionEvent::Error(e)) => {
312 return Err(format!("{} [{}]", e.message, e.category_label()));
316 }
317 crate::StreamEvent::Session(SessionEvent::Done) => break,
318 _ => {}
319 }
320 }
321 _ = &mut timeout_fut => {
322 let mut partial = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
324 if !tool_log.is_empty() {
325 partial.push_str(&tool_log.join("\n"));
326 partial.push('\n');
327 }
328 if !final_text.is_empty() {
329 partial.push_str("\n[partial response]:\n");
330 partial.push_str(&final_text);
331 }
332 return Ok(SubagentResult {
333 text: partial,
334 model: model_inner,
335 input_tokens: total_input_tokens,
336 output_tokens: total_output_tokens,
337 cache_read: total_cache_read,
338 cache_creation: total_cache_creation,
339 cache_creation_5m: total_cache_5m,
340 cache_creation_1h: total_cache_1h,
341 tool_count,
342 timed_out: true,
343 });
344 }
345 }
346 }
347
348 Ok(SubagentResult {
349 text: final_text,
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: false,
359 })
360 });
361
362 let _ = result_tx.send(result);
363 }));
364
365 if let Err(panic_info) = result {
366 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
367 s.to_string()
368 } else if let Some(s) = panic_info.downcast_ref::<String>() {
369 s.clone()
370 } else {
371 "unknown panic".to_string()
372 };
373 tracing::error!("Subagent thread panicked: {}", msg);
374 }
377 }) {
378 Ok(handle) => handle,
379 Err(_) => {
380 orchestration.rollback(&orchestration_id);
381 return Err(RuntimeError::Tool("worker thread initialization failed".into()));
382 }
383 };
384 if let Err(error) = orchestration.mark_running(&orchestration_id) {
385 drop(shutdown_tx);
386 let _ = thread_handle.join();
387 orchestration.rollback(&orchestration_id);
388 return Err(RuntimeError::Tool(error));
389 }
390
391 let result = result_rx.await;
392 let elapsed = start_time.elapsed().as_secs_f64();
393
394 drop(shutdown_tx);
395 let _ = thread_handle.join();
396
397 if let Some(policy) = &ctx.capabilities.orchestration {
400 let terminal = match &result {
401 Ok(Ok(result)) if result.timed_out => {
402 agent_core::orchestration::WorkerTerminal::TimedOut
403 }
404 Ok(Ok(_)) => agent_core::orchestration::WorkerTerminal::Completed,
405 Ok(Err(_)) | Err(_) => agent_core::orchestration::WorkerTerminal::Failed,
406 };
407 policy
408 .finish_one_shot(&orchestration_id, terminal)
409 .map_err(RuntimeError::Tool)?;
410 }
411
412 let log_dir = crate::config::base_dir().join("logs").join("subagents");
413 let _ = tokio::fs::create_dir_all(&log_dir).await;
414 let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
415
416 match result {
417 Ok(Ok(sa_result)) => {
418 let preview: String = sa_result.text.chars().take(120).collect();
419
420 if let Some(ref tx) = ctx.channels.tx_events {
421 let _ = tx.send(crate::StreamEvent::Session(SessionEvent::Usage {
422 input_tokens: sa_result.input_tokens,
423 output_tokens: sa_result.output_tokens,
424 cache_read_input_tokens: sa_result.cache_read,
425 cache_creation_input_tokens: sa_result.cache_creation,
426 cache_creation_5m: sa_result.cache_creation_5m,
429 cache_creation_1h: sa_result.cache_creation_1h,
430 model: Some(sa_result.model),
431 }));
432 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
433 subagent_id,
434 agent_name: label.clone(),
435 result_preview: preview,
436 duration_secs: elapsed,
437 }));
438 }
439
440 let log_content = format!(
441 "# Subagent: {}\nDate: {}\nModel: {}\nTask: {}\nDuration: {:.1}s\nTokens: {}in/{}out ({}cr/{}cw)\nTools used: {}\n\n## Result\n\n{}\n",
442 label, timestamp, params["model"].as_str().unwrap_or("sonnet"),
443 task_preview, elapsed,
444 sa_result.input_tokens, sa_result.output_tokens,
445 sa_result.cache_read, sa_result.cache_creation,
446 sa_result.tool_count, sa_result.text,
447 );
448 let log_path = log_dir.join(format!("{}-{}.md", timestamp, label));
449 let _ = tokio::fs::write(&log_path, &log_content).await;
450
451 Ok(format!("[subagent:{}] {}", label, sa_result.text))
452 }
453 Ok(Err(e)) => {
454 if let Some(ref tx) = ctx.channels.tx_events {
455 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
456 subagent_id,
457 agent_name: label.clone(),
458 result_preview: format!("ERROR: {}", e),
459 duration_secs: elapsed,
460 }));
461 }
462 let log_path = log_dir.join(format!("{}-{}-error.md", timestamp, label));
463 let _ = tokio::fs::write(
464 &log_path,
465 format!(
466 "# Subagent ERROR: {}\nTask: {}\nError: {}\n",
467 label, task_preview, e
468 ),
469 )
470 .await;
471 Ok(format!("[subagent:{} ERROR] {}", label, e))
472 }
473 Err(_) => {
474 if let Some(ref tx) = ctx.channels.tx_events {
475 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
476 subagent_id,
477 agent_name: label.clone(),
478 result_preview: "Task panicked or dropped".to_string(),
479 duration_secs: elapsed,
480 }));
481 }
482 Ok(format!(
483 "[subagent:{} ERROR] Subagent task panicked or was dropped",
484 label
485 ))
486 }
487 }
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::tools::test_helpers::create_tool_context;
495 use crate::tools::Tool;
496 use serde_json::json;
497
498 #[test]
499 fn test_subagent_tool_schema() {
500 let tool = SubagentTool;
501 assert_eq!(tool.name(), "subagent");
502 assert!(!tool.description().is_empty());
503
504 let params = tool.parameters();
505 assert_eq!(params["type"], "object");
506 assert!(params["properties"].is_object());
507 assert!(params["required"].is_array());
508 }
509
510 #[tokio::test]
511 async fn test_subagent_blank_agent_uses_system_prompt() {
512 let tool = SubagentTool;
513 let ctx = create_tool_context();
514 let params = json!({
515 "agent": "",
516 "system_prompt": "You are a concise test subagent. Reply with only: ok",
517 "task": "Say ok",
518 "model": "anthropic/claude-sonnet-4-6",
519 "timeout": 1
520 });
521
522 let result = tool.execute(params, ctx).await;
523 assert!(
524 result.is_ok(),
525 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
526 );
527 }
528}