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