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