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