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 "thinking": {
53 "type": "string",
54 "enum": ["low", "medium", "high", "xhigh", "max"],
55 "description": "Worker effort override. Omit for subagent_thinking (default xhigh), independent of foreground thinking. Unsupported model levels are refused."
56 },
57 "timeout": {
58 "type": "integer",
59 "description": "Wall-clock limit in seconds. Omit for the session default (config `subagent_timeout`); 0 = no limit."
60 }
61 },
62 "required": ["task"]
63 })
64 }
65
66 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
67 let config = super::spawn_config();
69 let thinking = super::resolve_subagent_thinking(¶ms, None, &config)?;
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 session_allow_all = ctx.capabilities.session_allow_all.clone();
113 let telemetry = ctx.capabilities.telemetry.clone();
114 let workspace = super::WorkerWorkspace::from_capabilities(&ctx.capabilities);
115 let timeout_secs = params["timeout"]
116 .as_u64()
117 .unwrap_or(ctx.limits.subagent_timeout);
118
119 let label = super::subagent_label(agent_name.as_deref(), params["name"].as_str());
120 let telemetry = telemetry.map(|t| t.for_worker(&label, &orchestration_id));
121 let task_preview: String = task.chars().take(80).collect();
122
123 tracing::info!(
124 "Dispatching subagent '{}' (id={}) with model {}",
125 label,
126 subagent_id,
127 model
128 );
129
130 if let Some(ref tx) = ctx.channels.tx_events {
131 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
132 subagent_id,
133 agent_name: label.clone(),
134 task_preview: task_preview.clone(),
135 }));
136 }
137
138 let start_time = std::time::Instant::now();
139
140 let (result_tx, result_rx) =
141 tokio::sync::oneshot::channel::<std::result::Result<SubagentResult, String>>();
142 let label_inner = label.clone();
143 let model_inner = model.clone();
144 let tx_events_inner = ctx.channels.tx_events.clone();
145
146 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
147 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
148 if let Err(error) = orchestration.mark_starting(&orchestration_id) {
149 orchestration.rollback(&orchestration_id);
150 return Err(RuntimeError::Tool(error));
151 }
152
153 let transcript_stem = orchestration_id.clone();
155 let thread_handle = match std::thread::Builder::new().spawn(move || {
156 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
157 let rt = match tokio::runtime::Builder::new_current_thread()
158 .enable_all()
159 .build()
160 {
161 Ok(rt) => rt,
162 Err(_) => {
163 let _ = result_tx.send(Err("runtime initialization failed".into()));
164 return;
165 }
166 };
167
168 let result = rt.block_on(async move {
169 use futures::StreamExt;
170
171 let mut runtime = match super::spawn_runtime().await {
174 Ok(r) => r,
175 Err(_) => return Err("subagent runtime initialization failed".into()),
176 };
177
178 super::apply_subagent_runtime_policy(&mut runtime, &config, memory_backend.as_ref(), session_allow_all.as_ref(), telemetry.as_ref(), &workspace);
183 runtime.set_system_prompt(super::compose_system_prompt(system_prompt));
184 runtime.set_model(model);
185 super::fit_subagent_context_window(&mut runtime);
186 super::apply_codex_worker_reasoning(&mut runtime, codex_parent_plan.as_ref());
187 super::apply_subagent_thinking(&mut runtime, thinking)?;
188 super::attach_worker_transcripts(
190 &mut runtime,
191 super::archive::process_dir(),
192 &transcript_stem,
193 None,
194 &[],
195 tx_events_inner.clone(),
196 subagent_id,
197 &label_inner,
198 );
199
200 let cancel = crate::CancellationToken::new();
201 let cancel_inner = cancel.clone();
202
203 tokio::spawn(async move {
204 let _ = shutdown_rx.await;
205 cancel_inner.cancel();
206 });
207
208 let mut stream = runtime.run_stream(task, cancel).await;
209
210 let mut final_text = String::new();
211 let mut tool_count = 0u32;
212 let mut tool_log: Vec<String> = Vec::new();
213 let mut response_baseline: (usize, usize, u32) = (0, 0, 0);
214 let mut new_response = false;
216 let mut total_input_tokens = 0u64;
217 let mut total_output_tokens = 0u64;
218 let mut total_cache_read = 0u64;
219 let mut total_cache_creation = 0u64;
220 let mut total_cache_5m: Option<u64> = None;
222 let mut total_cache_1h: Option<u64> = None;
223
224 let timeout_fut = tokio::time::sleep(if timeout_secs == 0 {
226 Duration::MAX
227 } else {
228 Duration::from_secs(timeout_secs)
229 });
230 tokio::pin!(timeout_fut);
231
232 loop {
233 tokio::select! {
234 event = stream.next() => {
235 let Some(event) = event else { break };
236 crate::runtime::openai::codex_wire::forward_worker_web_search(
237 &label_inner,
238 &event,
239 tx_events_inner.as_ref(),
240 );
241 match event {
242 crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
243 if let Some(ref tx) = tx_events_inner {
244 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
245 subagent_id,
246 agent_name: label_inner.clone(),
247 status: "💭 thinking...".to_string(),
248 }));
249 }
250 }
251 crate::StreamEvent::Llm(LlmEvent::ResponseStart) => {
252 response_baseline = (final_text.len(), tool_log.len(), tool_count);
253 new_response = true;
254 }
255 crate::StreamEvent::Llm(LlmEvent::ResponseReset) => {
256 final_text.truncate(response_baseline.0);
257 tool_log.truncate(response_baseline.1);
258 tool_count = response_baseline.2;
259 new_response = true;
260 }
261 crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
262 append_response_text(&mut final_text, &mut new_response, &text);
263 }
264 crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
265 tool_count += 1;
266 if let Some(ref tx) = tx_events_inner {
267 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
268 subagent_id,
269 agent_name: label_inner.clone(),
270 status: format!("⚙ {} (tool #{})", name, tool_count),
271 }));
272 }
273 }
274 crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
275 let input_str = input.to_string();
276 let input_preview: String = input_str.chars().take(200).collect();
277 tool_log.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
278 let detail = match tool_name.as_str() {
280 "bash" => {
281 let cmd = input["command"].as_str().unwrap_or("");
282 let preview: String = cmd.chars().take(60).collect();
283 format!("$ {}", preview)
284 }
285 "read" => {
286 let path = input["path"].as_str().unwrap_or("?");
287 let short = path.rsplit('/').next().unwrap_or(path);
288 format!("reading {}", short)
289 }
290 "write" => {
291 let path = input["path"].as_str().unwrap_or("?");
292 let short = path.rsplit('/').next().unwrap_or(path);
293 format!("writing {}", short)
294 }
295 "edit" => {
296 let path = input["path"].as_str().unwrap_or("?");
297 let short = path.rsplit('/').next().unwrap_or(path);
298 format!("editing {}", short)
299 }
300 "multi_edit" => crate::tools::multi_edit_label(&input),
301 "grep" => {
302 let pat = input["pattern"].as_str().unwrap_or("?");
303 let preview: String = pat.chars().take(30).collect();
304 format!("grep /{}/", preview)
305 }
306 "find" => {
307 let pat = input["pattern"].as_str().unwrap_or("?");
308 format!("find {}", pat)
309 }
310 "ls" => {
311 let path = input["path"].as_str().unwrap_or(".");
312 let short = path.rsplit('/').next().unwrap_or(path);
313 format!("ls {}", short)
314 }
315 "subagent" => {
316 let agent = input["agent"].as_str()
317 .filter(|s| !s.trim().is_empty());
318 let name = super::subagent_label(agent, input["name"].as_str());
319 format!("spawning {}", name)
320 }
321 other => {
322 let short_name = if other.starts_with("ext__") {
324 other.splitn(3, "__").last().unwrap_or(other)
325 } else {
326 other
327 };
328 short_name.to_string()
329 }
330 };
331 if let Some(ref tx) = tx_events_inner {
332 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
333 subagent_id,
334 agent_name: label_inner.clone(),
335 status: detail,
336 }));
337 }
338 }
339 crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
340 let preview: String = result.chars().take(300).collect();
341 tool_log.push(format!("[tool_result]: {}", preview));
342 }
343 crate::StreamEvent::Session(SessionEvent::Usage {
344 input_tokens, output_tokens,
345 cache_read_input_tokens, cache_creation_input_tokens,
346 cache_creation_5m, cache_creation_1h,
347 model: _,
348 }) => {
349 total_input_tokens += input_tokens;
350 total_output_tokens += output_tokens;
351 total_cache_read += cache_read_input_tokens;
352 total_cache_creation += cache_creation_input_tokens;
353 crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
354 crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
355 }
356 crate::StreamEvent::Session(SessionEvent::Error(e)) => {
357 return Err(format!("{} [{}]", e.message, e.category_label()));
361 }
362 crate::StreamEvent::Session(SessionEvent::Done) => break,
363 _ => {}
364 }
365 }
366 _ = &mut timeout_fut => {
367 let mut partial = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
369 if !tool_log.is_empty() {
370 partial.push_str(&tool_log.join("\n"));
371 partial.push('\n');
372 }
373 if !final_text.is_empty() {
374 partial.push_str("\n[partial response]:\n");
375 partial.push_str(&final_text);
376 }
377 return Ok(SubagentResult {
378 text: partial,
379 model: model_inner,
380 input_tokens: total_input_tokens,
381 output_tokens: total_output_tokens,
382 cache_read: total_cache_read,
383 cache_creation: total_cache_creation,
384 cache_creation_5m: total_cache_5m,
385 cache_creation_1h: total_cache_1h,
386 tool_count,
387 timed_out: true,
388 });
389 }
390 }
391 }
392
393 Ok(SubagentResult {
394 text: final_text,
395 model: model_inner,
396 input_tokens: total_input_tokens,
397 output_tokens: total_output_tokens,
398 cache_read: total_cache_read,
399 cache_creation: total_cache_creation,
400 cache_creation_5m: total_cache_5m,
401 cache_creation_1h: total_cache_1h,
402 tool_count,
403 timed_out: false,
404 })
405 });
406
407 let _ = result_tx.send(result);
408 }));
409
410 if let Err(panic_info) = result {
411 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
412 s.to_string()
413 } else if let Some(s) = panic_info.downcast_ref::<String>() {
414 s.clone()
415 } else {
416 "unknown panic".to_string()
417 };
418 tracing::error!("Subagent thread panicked: {}", msg);
419 }
422 }) {
423 Ok(handle) => handle,
424 Err(_) => {
425 orchestration.rollback(&orchestration_id);
426 return Err(RuntimeError::Tool("worker thread initialization failed".into()));
427 }
428 };
429 if let Err(error) = orchestration.mark_running(&orchestration_id) {
430 drop(shutdown_tx);
431 let _ = thread_handle.join();
432 orchestration.rollback(&orchestration_id);
433 return Err(RuntimeError::Tool(error));
434 }
435
436 let result = result_rx.await;
437 let elapsed = start_time.elapsed().as_secs_f64();
438
439 drop(shutdown_tx);
440 let _ = thread_handle.join();
441
442 if let Some(policy) = &ctx.capabilities.orchestration {
445 let terminal = match &result {
446 Ok(Ok(result)) if result.timed_out => {
447 agent_core::orchestration::WorkerTerminal::TimedOut
448 }
449 Ok(Ok(_)) => agent_core::orchestration::WorkerTerminal::Completed,
450 Ok(Err(_)) | Err(_) => agent_core::orchestration::WorkerTerminal::Failed,
451 };
452 policy
453 .finish_one_shot(&orchestration_id, terminal)
454 .map_err(RuntimeError::Tool)?;
455 }
456
457 let log_dir = crate::config::base_dir().join("logs").join("subagents");
458 let _ = tokio::fs::create_dir_all(&log_dir).await;
459 let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
460
461 match result {
462 Ok(Ok(sa_result)) => {
463 let preview: String = sa_result.text.chars().take(120).collect();
464
465 if let Some(ref tx) = ctx.channels.tx_events {
466 let _ = tx.send(crate::StreamEvent::Session(SessionEvent::Usage {
467 input_tokens: sa_result.input_tokens,
468 output_tokens: sa_result.output_tokens,
469 cache_read_input_tokens: sa_result.cache_read,
470 cache_creation_input_tokens: sa_result.cache_creation,
471 cache_creation_5m: sa_result.cache_creation_5m,
474 cache_creation_1h: sa_result.cache_creation_1h,
475 model: Some(sa_result.model),
476 }));
477 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
478 subagent_id,
479 agent_name: label.clone(),
480 result_preview: preview,
481 duration_secs: elapsed,
482 }));
483 }
484
485 let log_content = format!(
486 "# Subagent: {}\nDate: {}\nModel: {}\nTask: {}\nDuration: {:.1}s\nTokens: {}in/{}out ({}cr/{}cw)\nTools used: {}\n\n## Result\n\n{}\n",
487 label, timestamp, params["model"].as_str().unwrap_or("sonnet"),
488 task_preview, elapsed,
489 sa_result.input_tokens, sa_result.output_tokens,
490 sa_result.cache_read, sa_result.cache_creation,
491 sa_result.tool_count, sa_result.text,
492 );
493 let log_path = super::subagent_log_path(&log_dir, ×tamp, &label, false);
494 let _ = tokio::fs::write(&log_path, &log_content).await;
495
496 Ok(format!("[subagent:{}] {}", label, sa_result.text))
497 }
498 Ok(Err(e)) => {
499 if let Some(ref tx) = ctx.channels.tx_events {
500 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
501 subagent_id,
502 agent_name: label.clone(),
503 result_preview: format!("ERROR: {}", e),
504 duration_secs: elapsed,
505 }));
506 }
507 let log_path = super::subagent_log_path(&log_dir, ×tamp, &label, true);
508 let _ = tokio::fs::write(
509 &log_path,
510 format!(
511 "# Subagent ERROR: {}\nTask: {}\nError: {}\n",
512 label, task_preview, e
513 ),
514 )
515 .await;
516 Ok(format!("[subagent:{} ERROR] {}", label, e))
517 }
518 Err(_) => {
519 if let Some(ref tx) = ctx.channels.tx_events {
520 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
521 subagent_id,
522 agent_name: label.clone(),
523 result_preview: "Task panicked or dropped".to_string(),
524 duration_secs: elapsed,
525 }));
526 }
527 Ok(format!(
528 "[subagent:{} ERROR] Subagent task panicked or was dropped",
529 label
530 ))
531 }
532 }
533 }
534}
535
536fn append_response_text(out: &mut String, new_response: &mut bool, text: &str) {
541 if text.is_empty() {
542 return;
543 }
544 if std::mem::take(new_response) && !out.is_empty() && !out.ends_with('\n') {
545 out.push_str("\n\n");
546 }
547 out.push_str(text);
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn each_response_starts_a_new_paragraph() {
556 let mut out = String::new();
557 let mut new_response = true;
558 for (start, text) in [
559 (true, "Let me find the files"),
560 (false, " and read them all."),
561 (true, ""),
562 (false, "Found 2 files."),
563 (true, "Here are the results:\n"),
564 (true, "Total: 14"),
565 ] {
566 new_response |= start;
567 append_response_text(&mut out, &mut new_response, text);
568 }
569 assert_eq!(
570 out,
571 "Let me find the files and read them all.\n\nFound 2 files.\n\nHere are the results:\nTotal: 14"
572 );
573 }
574 use crate::tools::test_helpers::create_tool_context;
575 use crate::tools::Tool;
576 use serde_json::json;
577
578 #[test]
579 fn test_subagent_tool_schema() {
580 let tool = SubagentTool;
581 assert_eq!(tool.name(), "subagent");
582 assert!(!tool.description().is_empty());
583
584 let params = tool.parameters();
585 assert_eq!(params["type"], "object");
586 assert!(params["properties"].is_object());
587 assert!(params["required"].is_array());
588 }
589
590 #[tokio::test]
595 #[serial_test::serial(synaps_base_dir)]
596 async fn test_subagent_blank_agent_uses_system_prompt() {
597 let _base = crate::test_env::BaseDirGuard::new();
598 let _offline = crate::test_env::OfflineProviderEnv::new();
599 let tool = SubagentTool;
600 let ctx = create_tool_context();
601 let params = json!({
602 "agent": "",
603 "system_prompt": "You are a concise test subagent. Reply with only: ok",
604 "task": "Say ok",
605 "model": "anthropic/claude-sonnet-4-6",
606 "timeout": 1
607 });
608
609 let result = tool.execute(params, ctx).await;
610 assert!(
611 result.is_ok(),
612 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
613 );
614 }
615
616 #[tokio::test]
620 #[serial_test::serial(synaps_base_dir)]
621 async fn test_subagent_ignores_retired_role_and_write_policy() {
622 let _base = crate::test_env::BaseDirGuard::new();
623 let _offline = crate::test_env::OfflineProviderEnv::new();
624 let ctx = create_tool_context();
625 let orchestration = std::sync::Arc::clone(ctx.capabilities.orchestration.as_ref().unwrap());
626 let params = json!({
627 "system_prompt": "You are a concise test subagent. Reply with only: ok",
628 "task": "Say ok",
629 "model": "anthropic/claude-sonnet-4-6",
630 "timeout": 1,
631 "role": "reviewer",
632 "write_policy": {"mode": "read_only"}
633 });
634
635 let result = SubagentTool.execute(params, ctx).await;
636 assert!(
637 result.is_ok(),
638 "old params must be ignored, not refused: {result:?}"
639 );
640
641 let events: serde_json::Value =
642 serde_json::from_str(&orchestration.telemetry_json()).unwrap();
643 let dispatch = events
644 .as_array()
645 .unwrap()
646 .iter()
647 .find(|event| event["name"] == "worker.dispatch_allowed")
648 .expect("dispatch recorded");
649 assert_eq!(dispatch["worker_role"], "implementer");
650 }
651}