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 super::super::{resolve_agent_prompt, Tool, ToolContext, NEXT_SUBAGENT_ID};
14use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
15use crate::{AgentEvent, LlmEvent, Result, RuntimeError, SessionEvent};
16
17pub struct SubagentStartTool;
18
19#[async_trait::async_trait]
20impl Tool for SubagentStartTool {
21 fn origin(&self) -> crate::tools::ToolOrigin {
22 crate::tools::ToolOrigin::Builtin
23 }
24
25 fn name(&self) -> &str {
26 "subagent_start"
27 }
28
29 fn description(&self) -> &str {
30 "Dispatch a reactive subagent and return immediately with a handle_id. \
31 The subagent runs in the background — poll with subagent_status until it \
32 reports a terminal status, use subagent_steer to inject guidance mid-run, \
33 then call subagent_collect once with reconciled=true to retrieve the result \
34 and attest reconciliation in the same call. Use this for parallel execution \
35 or when you want to continue working while the subagent runs. For simple sequential \
36 delegation, use subagent instead. Provide either an agent name (resolves \
37 from ~/.synaps-cli/agents/<name>.md) or a system_prompt string directly."
38 }
39
40 fn parameters(&self) -> Value {
41 json!({
42 "type": "object",
43 "properties": {
44 "agent": {
45 "type": "string",
46 "description": "Agent name — resolves to ~/.synaps-cli/agents/<name>.md. Mutually exclusive with system_prompt."
47 },
48 "system_prompt": {
49 "type": "string",
50 "description": "Inline system prompt for the subagent. Use when you don't have a named agent file."
51 },
52 "task": {
53 "type": "string",
54 "description": "The task/prompt to send to the subagent."
55 },
56 "model": {
57 "type": "string",
58 "description": "Omit to inherit the session foreground qualified identity. Explicit values must be one of subagent_models' listed exact choices."
59 },
60 "role": {
61 "type": "string",
62 "enum": ["planner", "implementer", "tester", "reviewer", "researcher", "debugger"],
63 "description": "Typed orchestration role."
64 },
65 "write_policy": {
66 "oneOf": [
67 {"type": "object", "properties": {"mode": {"const": "read_only"}}, "required": ["mode"]},
68 {"type": "object", "properties": {"mode": {"const": "isolated_worktree"}}, "required": ["mode"]},
69 {"type": "object", "properties": {"mode": {"const": "non_overlapping_paths"}, "scopes": {"type": "array", "items": {"type": "string"}}}, "required": ["mode", "scopes"]}
70 ],
71 "description": "Declared write isolation and path scopes."
72 },
73 "timeout": {
74 "type": "integer",
75 "description": "Timeout in seconds (default: 300). Increase for long-running tasks."
76 }
77 },
78 "required": ["task"]
79 })
80 }
81
82 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
83 let task = params["task"]
85 .as_str()
86 .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
87 .to_string();
88
89 let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
95 let agent_name = params["agent"]
96 .as_str()
97 .map(|s| s.to_string())
98 .filter(|s| !is_blank(s));
99 let inline_prompt = params["system_prompt"]
100 .as_str()
101 .map(|s| s.to_string())
102 .filter(|s| !is_blank(s));
103 let requested_model = params["model"].as_str();
104 let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
107 RuntimeError::Tool("subagent_start requires a subagent_registry in ToolContext".into())
108 })?;
109 let system_prompt = match (&agent_name, &inline_prompt) {
110 (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
111 (None, Some(p)) => p.clone(),
112 (None, None) => {
113 return Err(RuntimeError::Tool(
114 "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither."
115 .to_string(),
116 ));
117 }
118 };
119 let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
120 let handle_id = format!("sa_{}", subagent_id);
121 let orchestration = ctx
122 .capabilities
123 .orchestration
124 .as_ref()
125 .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
126 orchestration
127 .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
128 .map_err(|reason| {
129 RuntimeError::Tool(format!("delegation tree budget denied: {reason:?}"))
130 })?;
131 let decision = orchestration
132 .resolve_and_authorize(&handle_id, requested_model)
133 .map_err(|error| {
134 orchestration
135 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
136 RuntimeError::Tool(error.to_string())
137 })?;
138 let model = decision.model.as_str().to_owned();
139 let timeout_secs = params["timeout"]
140 .as_u64()
141 .unwrap_or(ctx.limits.subagent_timeout);
142
143 let label = agent_name.as_deref().unwrap_or("inline").to_string();
144 let task_preview: String = task.chars().take(80).collect();
145 let task_full = task.clone();
146
147 tracing::info!(
148 "subagent_start: dispatching '{}' (id={}) model={}",
149 label,
150 handle_id,
151 model
152 );
153
154 let state = Arc::new(RwLock::new(SubagentState::new()));
156
157 let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
159 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
160 let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
161
162 if let Some(ref tx) = ctx.channels.tx_events {
164 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
165 subagent_id,
166 agent_name: label.clone(),
167 task_preview: task_preview.clone(),
168 }));
169 }
170
171 let state_t = Arc::clone(&state);
173 let task_full_a = task_full.clone();
174 let label_inner = label.clone();
175 let model_inner = model.clone();
176 let tx_events_inner = ctx.channels.tx_events.clone();
177 let start_time = std::time::Instant::now();
178 let parent_queue = ctx.capabilities.event_queue.clone();
179 let handle_id_inner = handle_id.clone();
180 let child_parent_id = handle_id.clone();
181
182 let system_prompt_for_handle = system_prompt.clone();
187 let handle = SubagentHandle::new(
188 handle_id.clone(),
189 subagent_id,
190 label.clone(),
191 task_preview,
192 model.clone(),
193 system_prompt_for_handle,
194 timeout_secs,
195 Arc::clone(&state),
196 Some(steer_tx),
197 Some(shutdown_tx),
198 Some(result_rx),
199 )
200 .with_authorization(&decision);
201 {
202 let mut reg = registry.lock().unwrap();
203 reg.register(handle);
204 }
205
206 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
207 let orchestration_for_worker = Arc::clone(orchestration);
208 let parent_for_worker = ctx.capabilities.delegation_parent.clone();
209 if let Err(error) = orchestration.mark_starting(&handle_id) {
210 orchestration.rollback(&handle_id);
211 orchestration
212 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
213 return Err(RuntimeError::Tool(error));
214 }
215
216 let thread_handle = std::thread::spawn(move || {
218 let state_for_finalizer = Arc::clone(&state_t);
220 let label_for_finalizer = label_inner.clone();
221 let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
222
223 let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
224 let rt = match tokio::runtime::Builder::new_current_thread()
225 .enable_all()
226 .build()
227 {
228 Ok(rt) => rt,
229 Err(_) => {
230 state_t.write().unwrap().status =
231 SubagentStatus::Failed("runtime initialization failed".into());
232 return;
233 }
234 };
235
236 let state_a = Arc::clone(&state_t);
238 let label_a = label_inner.clone();
239 let model_a = model_inner.clone();
240 let tx_events_a = tx_events_inner.clone();
241 let task_for_timeout = task_full_a.clone();
242 let task_for_complete = task_full_a;
243
244 let outcome: std::result::Result<SubagentResult, String> = rt.block_on(async move {
245 use futures::StreamExt;
246
247 let mut runtime = match crate::Runtime::new().await {
248 Ok(r) => r,
249 Err(_) => return Err("subagent runtime initialization failed".into()),
250 };
251
252 super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config());
257 runtime.set_system_prompt(system_prompt);
258 runtime.set_model(model_a.clone());
259 runtime.set_tools(super::subagent_tools().await);
260 runtime.install_worker_orchestration(Arc::clone(
261 &orchestration_for_runtime,
262 ));
263 runtime.set_delegation_parent(Some(child_parent_id.clone()));
264
265 let cancel = crate::CancellationToken::new();
266 let cancel_inner = cancel.clone();
267 tokio::spawn(async move {
268 let _ = shutdown_rx.await;
269 cancel_inner.cancel();
270 });
271
272 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;
273
274 let mut tool_count = 0u32;
275 let mut total_input_tokens = 0u64;
276 let mut total_output_tokens = 0u64;
277 let mut total_cache_read = 0u64;
278 let mut total_cache_creation = 0u64;
279 let mut total_cache_5m: Option<u64> = None;
281 let mut total_cache_1h: Option<u64> = None;
282
283 let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
284 tokio::pin!(timeout_fut);
285
286 loop {
287 tokio::select! {
288 event = stream.next() => {
289 let Some(event) = event else { break };
290 match event {
291 crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
292 if let Some(ref tx) = tx_events_a {
293 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
294 subagent_id,
295 agent_name: label_a.clone(),
296 status: "💭 thinking...".to_string(),
297 }));
298 }
299 }
300 crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
301 state_a.write().unwrap().partial_text.push_str(&text);
302 }
303 crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
304 tool_count += 1;
305 if let Some(ref tx) = tx_events_a {
306 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
307 subagent_id,
308 agent_name: label_a.clone(),
309 status: format!("⚙ {} (tool #{})", name, tool_count),
310 }));
311 }
312 }
313 crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
314 let input_str = input.to_string();
315 let input_preview: String = input_str.chars().take(200).collect();
316 state_a.write().unwrap().tool_log
317 .push(format!("[tool_use]: {} — {}", tool_name, input_preview));
318 let detail = match tool_name.as_str() {
319 "bash" => {
320 let cmd = input["command"].as_str().unwrap_or("");
321 let preview: String = cmd.chars().take(60).collect();
322 format!("$ {}", preview)
323 }
324 "read" => format!("reading {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
325 "write" => format!("writing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
326 "edit" => format!("editing {}", input["path"].as_str().unwrap_or("?").rsplit('/').next().unwrap_or("?")),
327 "grep" => format!("grep /{}/", input["pattern"].as_str().unwrap_or("?").chars().take(30).collect::<String>()),
328 "find" => format!("find {}", input["pattern"].as_str().unwrap_or("?")),
329 "ls" => format!("ls {}", input["path"].as_str().unwrap_or(".").rsplit('/').next().unwrap_or(".")),
330 other => {
331 if other.starts_with("ext__") {
332 other.splitn(3, "__").last().unwrap_or(other).to_string()
333 } else {
334 other.to_string()
335 }
336 }
337 };
338 if let Some(ref tx) = tx_events_a {
339 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
340 subagent_id,
341 agent_name: label_a.clone(),
342 status: detail,
343 }));
344 }
345 }
346 crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
347 let preview: String = result.chars().take(300).collect();
348 state_a.write().unwrap().tool_log
349 .push(format!("[tool_result]: {}", preview));
350 }
351 crate::StreamEvent::Session(SessionEvent::Usage {
352 input_tokens, output_tokens,
353 cache_read_input_tokens, cache_creation_input_tokens,
354 cache_creation_5m, cache_creation_1h,
355 model: _,
356 }) => {
357 total_input_tokens += input_tokens;
358 total_output_tokens += output_tokens;
359 total_cache_read += cache_read_input_tokens;
360 total_cache_creation += cache_creation_input_tokens;
361 crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
362 crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
363 }
364 crate::StreamEvent::Session(SessionEvent::Error(e)) => return Err(format!("provider request failed [{}]", e.category_label())),
365 crate::StreamEvent::Session(SessionEvent::Done) => break,
366 _ => {}
367 }
368 }
369 _ = &mut timeout_fut => {
370 let (partial, log) = {
371 let mut s = state_a.write().unwrap();
372 s.status = SubagentStatus::TimedOut;
373 s.conversation_state = vec![
374 serde_json::json!({"role": "user", "content": task_for_timeout.clone()}),
375 serde_json::json!({"role": "assistant", "content": &s.partial_text}),
376 ];
377 (s.partial_text.clone(), s.tool_log.clone())
378 };
379 let mut text = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
380 if !log.is_empty() {
381 text.push_str(&log.join("\n"));
382 text.push('\n');
383 }
384 if !partial.is_empty() {
385 text.push_str("\n[partial response]:\n");
386 text.push_str(&partial);
387 }
388 state_a.write().unwrap_or_else(|p| p.into_inner()).partial_text = text.clone();
389 return Ok(SubagentResult {
390 text,
391 model: model_a.clone(),
392 input_tokens: total_input_tokens,
393 output_tokens: total_output_tokens,
394 cache_read: total_cache_read,
395 cache_creation: total_cache_creation,
396 cache_creation_5m: total_cache_5m,
397 cache_creation_1h: total_cache_1h,
398 tool_count,
399 timed_out: true,
400 });
401 }
402 }
403 }
404
405 Ok(SubagentResult {
406 text: state_a.write().unwrap().partial_text.clone(),
407 model: model_a.clone(),
408 input_tokens: total_input_tokens,
409 output_tokens: total_output_tokens,
410 cache_read: total_cache_read,
411 cache_creation: total_cache_creation,
412 cache_creation_5m: total_cache_5m,
413 cache_creation_1h: total_cache_1h,
414 tool_count,
415 timed_out: false,
416 })
417 });
418
419 match outcome {
420 Ok(sa_result) => {
421 {
423 let mut s = state_t.write().unwrap();
424 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
425 s.status = SubagentStatus::Completed;
426 s.conversation_state = vec![
427 serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
428 serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
429 ];
430 }
431 }
432 let elapsed = start_time.elapsed().as_secs_f64();
433 let preview: String = sa_result.text.chars().take(120).collect();
434 if let Some(ref tx) = tx_events_inner {
435 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
436 subagent_id,
437 agent_name: label_inner.clone(),
438 result_preview: preview,
439 duration_secs: elapsed,
440 }));
441 }
442 let _ = result_tx.send(sa_result);
443 }
444 Err(e) => {
445 state_t.write().unwrap().status = SubagentStatus::Failed(e.clone());
446 let elapsed = start_time.elapsed().as_secs_f64();
447 if let Some(ref tx) = tx_events_inner {
448 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
449 subagent_id,
450 agent_name: label_inner.clone(),
451 result_preview: format!("ERROR: {}", e),
452 duration_secs: elapsed,
453 }));
454 }
455 }
457 }
458 }));
459
460 if let Err(panic_info) = panic_result {
461 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
462 s.to_string()
463 } else if let Some(s) = panic_info.downcast_ref::<String>() {
464 s.clone()
465 } else {
466 "unknown panic".to_string()
467 };
468 tracing::error!("Subagent thread panicked: {}", msg);
469 state_t.write().unwrap_or_else(|p| p.into_inner()).status =
470 SubagentStatus::Failed(format!("panic: {}", msg));
471 }
472
473 super::finalize::finalize_subagent(
476 &state_for_finalizer,
477 parent_queue.as_ref(),
478 &handle_id_inner,
479 subagent_id,
480 &label_for_finalizer,
481 start_time,
482 None, );
484 orchestration_for_worker
485 .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
486 });
487
488 {
490 let mut reg = registry.lock().unwrap();
491 if let Some(h) = reg.get_mut(&handle_id) {
492 h.set_thread_handle(thread_handle);
493 }
494 }
495 if let Err(error) = ctx
496 .capabilities
497 .orchestration
498 .as_ref()
499 .unwrap()
500 .mark_running(&handle_id)
501 {
502 let thread = {
503 let mut reg = registry.lock().unwrap();
504 if let Some(handle) = reg.get_mut(&handle_id) {
505 handle.cancel();
506 }
507 reg.remove(&handle_id)
508 };
509 if let Some(handle) = thread {
510 let _ = handle.collect().await;
511 }
512 orchestration.rollback(&handle_id);
513 orchestration
514 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
515 return Err(RuntimeError::Tool(error));
516 }
517
518 Ok(json!({
519 "handle_id": handle_id,
520 "agent_name": label,
521 "status": "running"
522 })
523 .to_string())
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use crate::tools::test_helpers::create_tool_context;
531 use crate::tools::{SubagentRegistry, Tool};
532 use serde_json::json;
533 use std::sync::{Arc, Mutex};
534
535 #[tokio::test]
537 async fn test_subagent_start_no_registry_returns_err() {
538 let tool = SubagentStartTool;
539 let ctx = create_tool_context(); let params = json!({
542 "system_prompt": "You are a test subagent.",
543 "task": "Say ok",
544 });
545
546 let result = tool.execute(params, ctx).await;
547 assert!(
548 result.is_err(),
549 "missing registry must return Err before spawn"
550 );
551 let msg = format!("{:?}", result.unwrap_err());
552 assert!(
553 msg.contains("subagent_registry"),
554 "error must mention subagent_registry: {msg}"
555 );
556 }
557
558 #[tokio::test]
559 async fn test_subagent_start_blank_agent_uses_system_prompt() {
560 let tool = SubagentStartTool;
561 let mut ctx = create_tool_context();
562 ctx.capabilities.subagent_registry = Some(Arc::new(Mutex::new(SubagentRegistry::new())));
563
564 let params = json!({
565 "agent": "",
566 "system_prompt": "You are a concise test subagent. Reply with only: ok",
567 "task": "Say ok",
568 "model": "anthropic/claude-sonnet-4-6",
569 "timeout": 1
570 });
571
572 let result = tool.execute(params, ctx).await;
573 assert!(
574 result.is_ok(),
575 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
576 );
577 let body: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
578 assert_eq!(body["agent_name"], "inline");
579 assert!(body["handle_id"]
580 .as_str()
581 .unwrap_or_default()
582 .starts_with("sa_"));
583 }
584}