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