1use serde_json::{json, Value};
8use std::sync::atomic::Ordering;
9use std::sync::{Arc, RwLock};
10use std::time::Duration;
11use tokio::sync::{mpsc, oneshot};
12
13use crate::{Result, RuntimeError, LlmEvent, SessionEvent, AgentEvent};
14use super::super::{Tool, ToolContext, resolve_agent_prompt, NEXT_SUBAGENT_ID};
15use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentStatus, SubagentState};
16
17pub struct SubagentStartTool;
18
19#[async_trait::async_trait]
20impl Tool for SubagentStartTool {
21 fn name(&self) -> &str { "subagent_start" }
22
23 fn description(&self) -> &str {
24 "Dispatch a reactive subagent and return immediately with a handle_id. \
25 The subagent runs in the background — use subagent_status to poll, \
26 subagent_steer to inject guidance mid-run, and subagent_collect to poll for the result (non-blocking — call \
27 repeatedly until done). Use this for parallel execution or when you \
28 want to continue working while the subagent runs. For simple sequential \
29 delegation, use subagent instead. Provide either an agent name (resolves \
30 from ~/.synaps-cli/agents/<name>.md) or a system_prompt string directly."
31 }
32
33 fn parameters(&self) -> Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "agent": {
38 "type": "string",
39 "description": "Agent name — resolves to ~/.synaps-cli/agents/<name>.md. Mutually exclusive with system_prompt."
40 },
41 "system_prompt": {
42 "type": "string",
43 "description": "Inline system prompt for the subagent. Use when you don't have a named agent file."
44 },
45 "task": {
46 "type": "string",
47 "description": "The task/prompt to send to the subagent."
48 },
49 "model": {
50 "type": "string",
51 "description": "Model override (default: claude-sonnet-4-6). Use claude-opus-4-7 for complex tasks."
52 },
53 "timeout": {
54 "type": "integer",
55 "description": "Timeout in seconds (default: 300). Increase for long-running tasks."
56 }
57 },
58 "required": ["task"]
59 })
60 }
61
62 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
63 let task = params["task"].as_str()
65 .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
66 .to_string();
67
68 let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
74 let agent_name = params["agent"]
75 .as_str()
76 .map(|s| s.to_string())
77 .filter(|s| !is_blank(s));
78 let inline_prompt = params["system_prompt"]
79 .as_str()
80 .map(|s| s.to_string())
81 .filter(|s| !is_blank(s));
82 let model_override = params["model"].as_str().map(|s| s.to_string());
83 let timeout_secs = params["timeout"].as_u64().unwrap_or(ctx.limits.subagent_timeout);
84
85 let system_prompt = match (&agent_name, &inline_prompt) {
86 (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
87 (None, Some(p)) => p.clone(),
88 (None, None) => {
89 return Err(RuntimeError::Tool(
90 "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither.".to_string()
91 ));
92 }
93 };
94
95 let label = agent_name.as_deref().unwrap_or("inline").to_string();
96 let model = model_override.unwrap_or_else(|| crate::models::default_model().to_string());
97 let task_preview: String = task.chars().take(80).collect();
98 let task_full = task.clone();
99 let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
100 let handle_id = format!("sa_{}", subagent_id);
101
102 tracing::info!("subagent_start: dispatching '{}' (id={}) model={}", label, handle_id, model);
103
104 let state = Arc::new(RwLock::new(SubagentState::new()));
106
107 let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
109 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
110 let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
111
112 if let Some(ref tx) = ctx.channels.tx_events {
114 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
115 subagent_id,
116 agent_name: label.clone(),
117 task_preview: task_preview.clone(),
118 }));
119 }
120
121 let state_t = Arc::clone(&state);
123 let task_full_a = task_full.clone();
124 let label_inner = label.clone();
125 let model_inner = model.clone();
126 let tx_events_inner = ctx.channels.tx_events.clone();
127 let start_time = std::time::Instant::now();
128 let parent_queue = ctx.capabilities.event_queue.clone();
129 let handle_id_inner = handle_id.clone();
130
131 let registry = ctx.capabilities.subagent_registry.as_ref()
133 .ok_or_else(|| RuntimeError::Tool(
134 "subagent_start requires a subagent_registry in ToolContext".to_string()
135 ))?;
136
137 let system_prompt_for_handle = system_prompt.clone();
142 let handle = SubagentHandle::new(
143 handle_id.clone(),
144 subagent_id,
145 label.clone(),
146 task_preview,
147 model.clone(),
148 system_prompt_for_handle,
149 timeout_secs,
150 Arc::clone(&state),
151 Some(steer_tx),
152 Some(shutdown_tx),
153 Some(result_rx),
154 );
155 {
156 let mut reg = registry.lock().unwrap();
157 reg.register(handle);
158 }
159
160 let thread_handle = std::thread::spawn(move || {
162 let state_for_finalizer = Arc::clone(&state_t);
164 let label_for_finalizer = label_inner.clone();
165
166 let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
167 let rt = match tokio::runtime::Builder::new_current_thread()
168 .enable_all()
169 .build()
170 {
171 Ok(rt) => rt,
172 Err(e) => {
173 state_t.write().unwrap().status =
174 SubagentStatus::Failed(format!("tokio runtime: {}", e));
175 return;
176 }
177 };
178
179 let state_a = Arc::clone(&state_t);
181 let label_a = label_inner.clone();
182 let model_a = model_inner.clone();
183 let tx_events_a = tx_events_inner.clone();
184 let task_for_timeout = task_full_a.clone();
185 let task_for_complete = task_full_a;
186
187 let outcome: std::result::Result<SubagentResult, String> = rt.block_on(async move {
188 use futures::StreamExt;
189
190 let mut runtime = match crate::Runtime::new().await {
191 Ok(r) => r,
192 Err(e) => return Err(format!("Failed to create subagent runtime: {}", e)),
193 };
194
195 super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config());
200 runtime.set_system_prompt(system_prompt);
201 runtime.set_model(model_a.clone());
202 runtime.set_tools(super::subagent_tools().await);
203
204 let cancel = crate::CancellationToken::new();
205 let cancel_inner = cancel.clone();
206 tokio::spawn(async move {
207 let _ = shutdown_rx.await;
208 cancel_inner.cancel();
209 });
210
211 let mut stream = runtime.run_stream_with_messages(vec![std::sync::Arc::new(serde_json::json!({"role": "user", "content": task}))], cancel, Some(steer_rx), None, false).await;
212
213 let mut tool_count = 0u32;
214 let mut total_input_tokens = 0u64;
215 let mut total_output_tokens = 0u64;
216 let mut total_cache_read = 0u64;
217 let mut total_cache_creation = 0u64;
218 let mut total_cache_5m: Option<u64> = None;
220 let mut total_cache_1h: Option<u64> = None;
221
222 let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
223 tokio::pin!(timeout_fut);
224
225 loop {
226 tokio::select! {
227 event = stream.next() => {
228 let Some(event) = event else { break };
229 match event {
230 crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
231 if let Some(ref tx) = tx_events_a {
232 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
233 subagent_id,
234 agent_name: label_a.clone(),
235 status: "💭 thinking...".to_string(),
236 }));
237 }
238 }
239 crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
240 state_a.write().unwrap().partial_text.push_str(&text);
241 }
242 crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
243 tool_count += 1;
244 if let Some(ref tx) = tx_events_a {
245 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
246 subagent_id,
247 agent_name: label_a.clone(),
248 status: format!("⚙ {} (tool #{})", name, tool_count),
249 }));
250 }
251 }
252 crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
253 let input_str = input.to_string();
254 let input_preview: String = input_str.chars().take(200).collect();
255 state_a.write().unwrap().tool_log
256 .push(format!("[tool_use]: {} — {}", tool_name, input_preview));
257 let detail = match tool_name.as_str() {
258 "bash" => {
259 let cmd = input["command"].as_str().unwrap_or("");
260 let preview: String = cmd.chars().take(60).collect();
261 format!("$ {}", preview)
262 }
263 "read" => format!("reading {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
264 "write" => format!("writing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
265 "edit" => format!("editing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
266 "grep" => format!("grep /{}/", input["pattern"].as_str().unwrap_or("?").chars().take(30).collect::<String>()),
267 "find" => format!("find {}", input["pattern"].as_str().unwrap_or("?")),
268 "ls" => format!("ls {}", input["path"].as_str().unwrap_or(".").rsplit('/').next().unwrap_or(".")),
269 other => {
270 if other.starts_with("ext__") {
271 other.splitn(3, "__").last().unwrap_or(other).to_string()
272 } else {
273 other.to_string()
274 }
275 }
276 };
277 if let Some(ref tx) = tx_events_a {
278 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
279 subagent_id,
280 agent_name: label_a.clone(),
281 status: detail,
282 }));
283 }
284 }
285 crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
286 let preview: String = result.chars().take(300).collect();
287 state_a.write().unwrap().tool_log
288 .push(format!("[tool_result]: {}", preview));
289 }
290 crate::StreamEvent::Session(SessionEvent::Usage {
291 input_tokens, output_tokens,
292 cache_read_input_tokens, cache_creation_input_tokens,
293 cache_creation_5m, cache_creation_1h,
294 model: _,
295 }) => {
296 total_input_tokens += input_tokens;
297 total_output_tokens += output_tokens;
298 total_cache_read += cache_read_input_tokens;
299 total_cache_creation += cache_creation_input_tokens;
300 crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
301 crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
302 }
303 crate::StreamEvent::Session(SessionEvent::Error(e)) => return Err(e),
304 crate::StreamEvent::Session(SessionEvent::Done) => break,
305 _ => {}
306 }
307 }
308 _ = &mut timeout_fut => {
309 let (partial, log) = {
310 let mut s = state_a.write().unwrap();
311 s.status = SubagentStatus::TimedOut;
312 s.conversation_state = vec![
313 serde_json::json!({"role": "user", "content": task_for_timeout.clone()}),
314 serde_json::json!({"role": "assistant", "content": &s.partial_text}),
315 ];
316 (s.partial_text.clone(), s.tool_log.clone())
317 };
318 let mut text = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
319 if !log.is_empty() {
320 text.push_str(&log.join("\n"));
321 text.push('\n');
322 }
323 if !partial.is_empty() {
324 text.push_str("\n[partial response]:\n");
325 text.push_str(&partial);
326 }
327 state_a.write().unwrap_or_else(|p| p.into_inner()).partial_text = text.clone();
328 return Ok(SubagentResult {
329 text,
330 model: model_a.clone(),
331 input_tokens: total_input_tokens,
332 output_tokens: total_output_tokens,
333 cache_read: total_cache_read,
334 cache_creation: total_cache_creation,
335 cache_creation_5m: total_cache_5m,
336 cache_creation_1h: total_cache_1h,
337 tool_count,
338 });
339 }
340 }
341 }
342
343 Ok(SubagentResult {
344 text: state_a.write().unwrap().partial_text.clone(),
345 model: model_a.clone(),
346 input_tokens: total_input_tokens,
347 output_tokens: total_output_tokens,
348 cache_read: total_cache_read,
349 cache_creation: total_cache_creation,
350 cache_creation_5m: total_cache_5m,
351 cache_creation_1h: total_cache_1h,
352 tool_count,
353 })
354 });
355
356 match outcome {
357 Ok(sa_result) => {
358 {
360 let mut s = state_t.write().unwrap();
361 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
362 s.status = SubagentStatus::Completed;
363 s.conversation_state = vec![
364 serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
365 serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
366 ];
367 }
368 }
369 let elapsed = start_time.elapsed().as_secs_f64();
370 let preview: String = sa_result.text.chars().take(120).collect();
371 if let Some(ref tx) = tx_events_inner {
372 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
373 subagent_id,
374 agent_name: label_inner.clone(),
375 result_preview: preview,
376 duration_secs: elapsed,
377 }));
378 }
379 let _ = result_tx.send(sa_result);
380 }
381 Err(e) => {
382 state_t.write().unwrap().status = SubagentStatus::Failed(e.clone());
383 let elapsed = start_time.elapsed().as_secs_f64();
384 if let Some(ref tx) = tx_events_inner {
385 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
386 subagent_id,
387 agent_name: label_inner.clone(),
388 result_preview: format!("ERROR: {}", e),
389 duration_secs: elapsed,
390 }));
391 }
392 }
394 }
395 }));
396
397 if let Err(panic_info) = panic_result {
398 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
399 s.to_string()
400 } else if let Some(s) = panic_info.downcast_ref::<String>() {
401 s.clone()
402 } else {
403 "unknown panic".to_string()
404 };
405 tracing::error!("Subagent thread panicked: {}", msg);
406 state_t.write().unwrap_or_else(|p| p.into_inner()).status = SubagentStatus::Failed(format!("panic: {}", msg));
407 }
408
409 super::finalize::finalize_subagent(
412 &state_for_finalizer,
413 parent_queue.as_ref(),
414 &handle_id_inner,
415 subagent_id,
416 &label_for_finalizer,
417 start_time,
418 None, );
420 });
421
422 {
424 let mut reg = registry.lock().unwrap();
425 if let Some(h) = reg.get_mut(&handle_id) {
426 h.set_thread_handle(thread_handle);
427 }
428 }
429
430 Ok(json!({
431 "handle_id": handle_id,
432 "agent_name": label,
433 "status": "running"
434 }).to_string())
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::tools::test_helpers::create_tool_context;
442 use crate::tools::{SubagentRegistry, Tool};
443 use serde_json::json;
444 use std::sync::{Arc, Mutex};
445
446 #[tokio::test]
448 async fn test_subagent_start_no_registry_returns_err() {
449 let tool = SubagentStartTool;
450 let ctx = create_tool_context(); let params = json!({
453 "system_prompt": "You are a test subagent.",
454 "task": "Say ok",
455 });
456
457 let result = tool.execute(params, ctx).await;
458 assert!(result.is_err(), "missing registry must return Err before spawn");
459 let msg = format!("{:?}", result.unwrap_err());
460 assert!(
461 msg.contains("subagent_registry"),
462 "error must mention subagent_registry: {msg}"
463 );
464 }
465
466 #[tokio::test]
467 async fn test_subagent_start_blank_agent_uses_system_prompt() {
468 let tool = SubagentStartTool;
469 let mut ctx = create_tool_context();
470 ctx.capabilities.subagent_registry = Some(Arc::new(Mutex::new(SubagentRegistry::new())));
471
472 let params = json!({
473 "agent": "",
474 "system_prompt": "You are a concise test subagent. Reply with only: ok",
475 "task": "Say ok",
476 "model": "claude-sonnet-4-6",
477 "timeout": 1
478 });
479
480 let result = tool.execute(params, ctx).await;
481 assert!(result.is_ok(), "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}");
482 let body: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
483 assert_eq!(body["agent_name"], "inline");
484 assert!(body["handle_id"].as_str().unwrap_or_default().starts_with("sa_"));
485 }
486}