1use serde_json::{json, Value};
8use std::sync::atomic::Ordering;
9use std::sync::{Arc, RwLock};
10use tokio::sync::{mpsc, oneshot};
11
12use super::super::{resolve_agent_prompt, Tool, ToolContext, NEXT_SUBAGENT_ID};
13use crate::runtime::subagent::{SubagentHandle, SubagentResult, SubagentState, SubagentStatus};
14use crate::{AgentEvent, Result, RuntimeError};
15
16pub struct SubagentStartTool;
17
18#[async_trait::async_trait]
19impl Tool for SubagentStartTool {
20 fn origin(&self) -> crate::tools::ToolOrigin {
21 crate::tools::ToolOrigin::Builtin
22 }
23
24 fn name(&self) -> &str {
25 "subagent_start"
26 }
27
28 fn description(&self) -> &str {
29 "Dispatch a reactive subagent and return immediately with a handle_id. \
30 The subagent runs in the background and is REACTIVE: when it finishes, a \
31 completion event is pushed into your queue and you are woken with its \
32 status — do NOT sleep, busy-wait, or poll in a loop waiting for it. After \
33 dispatching, end your turn or keep doing other work; on the completion \
34 event call subagent_collect once with reconciled=true. subagent_status is \
35 for an occasional progress peek, and subagent_steer injects guidance \
36 mid-run. Use this for parallel execution or when you want to continue \
37 working while the subagent runs. For simple sequential delegation that \
38 must block, use subagent instead. Provide either an agent name (resolves \
39 from ~/.synaps-cli/agents/<name>.md) or a system_prompt string directly; \
40 give an inline subagent a short name so it is recognisable in the \
41 subagent panel."
42 }
43
44 fn parameters(&self) -> Value {
45 json!({
46 "type": "object",
47 "properties": {
48 "agent": {
49 "type": "string",
50 "description": "Agent name — resolves to ~/.synaps-cli/agents/<name>.md. Mutually exclusive with system_prompt."
51 },
52 "system_prompt": {
53 "type": "string",
54 "description": "Inline system prompt for the subagent. Use when you don't have a named agent file."
55 },
56 "name": {
57 "type": "string",
58 "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\"."
59 },
60 "task": {
61 "type": "string",
62 "description": "The task/prompt to send to the subagent."
63 },
64 "model": {
65 "type": "string",
66 "description": "Omit to inherit the session foreground qualified identity. Explicit values must be one of subagent_models' listed exact choices."
67 },
68 "thinking": {
69 "type": "string",
70 "enum": ["low", "medium", "high", "xhigh", "max"],
71 "description": "Worker effort override. Omit for subagent_thinking (default xhigh), independent of foreground thinking. Unsupported model levels are refused."
72 },
73 "timeout": {
74 "type": "integer",
75 "description": "Wall-clock limit in seconds. Omit for the session default (config `subagent_timeout`); 0 = no limit. A timed-out worker keeps its full conversation: subagent_resume continues it where it stopped."
76 }
77 },
78 "required": ["task"]
79 })
80 }
81
82 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
83 let config = super::spawn_config();
85 let thinking = super::resolve_subagent_thinking(¶ms, None, &config)?;
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| RuntimeError::Tool(format!("delegation denied: {reason}")))?;
132 let decision = orchestration
133 .resolve_and_authorize(&handle_id, requested_model)
134 .map_err(|error| {
135 orchestration
136 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
137 RuntimeError::Tool(error.to_string())
138 })?;
139 let model = decision.model.as_str().to_owned();
140 let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
141 let memory_backend = ctx.capabilities.memory_backend.clone();
142 let session_allow_all = ctx.capabilities.session_allow_all.clone();
143 let telemetry = ctx.capabilities.telemetry.clone();
144 let workspace = super::WorkerWorkspace::from_capabilities(&ctx.capabilities);
145 let timeout_secs = params["timeout"]
146 .as_u64()
147 .unwrap_or(ctx.limits.subagent_timeout);
148
149 let label = super::subagent_label(agent_name.as_deref(), params["name"].as_str());
150 let telemetry = telemetry.map(|t| t.for_worker(&label, &handle_id));
151 let task_preview: String = task.chars().take(80).collect();
152 let task_full = task.clone();
153
154 tracing::info!(
155 "subagent_start: dispatching '{}' (id={}) model={}",
156 label,
157 handle_id,
158 model
159 );
160
161 let state = Arc::new(RwLock::new(SubagentState::new()));
163 state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
164 agent_name: label.clone(),
165 model: model.clone(),
166 system_prompt: system_prompt.clone(),
167 timeout_secs,
168 cwd: workspace
169 .cwd
170 .clone()
171 .or_else(|| std::env::current_dir().ok()),
172 thinking: Some(thinking.as_str().into()),
173 });
174
175 state.write().unwrap().archive_dir = Some(super::archive::process_dir());
176 let initial_messages = vec![Arc::new(
177 serde_json::json!({"role": "user", "content": task.clone()}),
178 )];
179 state.write().unwrap().history = Some(initial_messages.clone());
180
181 let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
183 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
184 let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
185
186 if let Some(ref tx) = ctx.channels.tx_events {
188 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
189 subagent_id,
190 agent_name: label.clone(),
191 task_preview: task_preview.clone(),
192 }));
193 }
194
195 let state_t = Arc::clone(&state);
197 let task_full_a = task_full.clone();
198 let label_inner = label.clone();
199 let model_inner = model.clone();
200 let tx_events_inner = ctx.channels.tx_events.clone();
201 let start_time = std::time::Instant::now();
202 let parent_queue = ctx.capabilities.event_queue.clone();
203 let handle_id_inner = handle_id.clone();
204 let child_parent_id = handle_id.clone();
205
206 let system_prompt_for_handle = system_prompt.clone();
211 let handle = SubagentHandle::new(
212 handle_id.clone(),
213 subagent_id,
214 label.clone(),
215 task_preview,
216 model.clone(),
217 system_prompt_for_handle,
218 timeout_secs,
219 Arc::clone(&state),
220 Some(steer_tx),
221 Some(shutdown_tx),
222 Some(result_rx),
223 )
224 .with_authorization(&decision);
225 {
226 let mut reg = registry.lock().unwrap();
227 reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
228 }
229
230 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
231 let orchestration_for_worker = Arc::clone(orchestration);
232 let parent_for_worker = ctx.capabilities.delegation_parent.clone();
233 if let Err(error) = orchestration.mark_starting(&handle_id) {
234 orchestration.rollback(&handle_id);
235 orchestration
236 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
237 return Err(RuntimeError::Tool(error));
238 }
239
240 let thread_handle = std::thread::spawn(move || {
242 let state_for_finalizer = Arc::clone(&state_t);
244 let label_for_finalizer = label_inner.clone();
245 let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
246
247 let mut pending_result = None;
248 let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
249 let rt = match tokio::runtime::Builder::new_current_thread()
250 .enable_all()
251 .build()
252 {
253 Ok(rt) => rt,
254 Err(_) => {
255 state_t.write().unwrap().status =
256 SubagentStatus::Failed("runtime initialization failed".into());
257 return;
258 }
259 };
260
261 let state_a = Arc::clone(&state_t);
263 let label_a = label_inner.clone();
264 let model_a = model_inner.clone();
265 let tx_events_a = tx_events_inner.clone();
266 let task_for_timeout = task_full_a.clone();
267 let task_for_complete = task_full_a;
268
269 let outcome: std::result::Result<SubagentResult, String> =
270 rt.block_on(async move {
271 let mut runtime = match super::spawn_runtime().await {
274 Ok(r) => r,
275 Err(_) => return Err("subagent runtime initialization failed".into()),
276 };
277
278 super::apply_subagent_runtime_policy(
283 &mut runtime,
284 &config,
285 memory_backend.as_ref(),
286 session_allow_all.as_ref(),
287 telemetry.as_ref(),
288 &workspace,
289 );
290 runtime.set_system_prompt(super::compose_system_prompt(system_prompt));
291 runtime.set_model(model_a.clone());
292 super::fit_subagent_context_window(&mut runtime);
293 super::apply_codex_worker_reasoning(
294 &mut runtime,
295 codex_parent_plan.as_ref(),
296 );
297 super::apply_subagent_thinking(&mut runtime, thinking)?;
298 runtime
299 .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
300 runtime.set_delegation_parent(Some(child_parent_id.clone()));
301 let archive_dir = state_a
304 .read()
305 .unwrap_or_else(|p| p.into_inner())
306 .archive_dir
307 .clone()
308 .unwrap_or_else(super::archive::process_dir);
309 super::attach_worker_transcripts(
310 &mut runtime,
311 archive_dir,
312 &child_parent_id,
313 Some(Arc::clone(&state_a)),
314 &initial_messages,
315 tx_events_a.clone(),
316 subagent_id,
317 &label_a,
318 );
319
320 let cancel = crate::CancellationToken::new();
321 let cancel_inner = cancel.clone();
322 tokio::spawn(async move {
323 let _ = shutdown_rx.await;
324 cancel_inner.cancel();
325 });
326
327 let cancel_on_timeout = cancel.clone();
328 let stream = runtime.run_stream_with_messages(
329 initial_messages,
330 cancel,
331 Some(steer_rx),
332 None,
333 false,
334 ).await;
335
336 let drive_outcome = super::drive::WorkerDrive {
337 state: Arc::clone(&state_a),
338 tx_events: tx_events_a.clone(),
339 subagent_id,
340 label: label_a.clone(),
341 model: model_a.clone(),
342 timeout_secs,
343 cancel: cancel_on_timeout,
344 task: task_for_timeout,
345 }
346 .run(stream)
347 .await;
348 super::archive::record_cache_anchor(&state_a, runtime.cache_anchor());
350 drive_outcome
351 });
352
353 match outcome {
354 Ok(sa_result) => {
355 {
357 let mut s = state_t.write().unwrap();
358 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
359 s.status = SubagentStatus::Completed;
360 s.conversation_state = vec![
361 serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
362 serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
363 ];
364 }
365 }
366 pending_result = Some(sa_result);
367 }
368 Err(e) => {
369 state_t.write().unwrap().status = SubagentStatus::Failed(e);
370 }
371 }
372 }));
373
374 if let Err(panic_info) = panic_result {
375 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
376 s.to_string()
377 } else if let Some(s) = panic_info.downcast_ref::<String>() {
378 s.clone()
379 } else {
380 "unknown panic".to_string()
381 };
382 tracing::error!("Subagent thread panicked: {}", msg);
383 state_t.write().unwrap_or_else(|p| p.into_inner()).status =
384 SubagentStatus::Failed(format!("panic: {}", msg));
385 }
386
387 super::finalize::finalize_subagent(
390 &state_for_finalizer,
391 parent_queue.as_ref(),
392 &handle_id_inner,
393 subagent_id,
394 &label_for_finalizer,
395 start_time,
396 None, );
398 super::finalize::retire_if_cancelled(
401 &state_for_finalizer,
402 &orchestration_for_worker,
403 &handle_id_inner,
404 );
405 if let Some(ref tx) = tx_events_inner {
407 let s = state_for_finalizer
408 .read()
409 .unwrap_or_else(|p| p.into_inner());
410 let preview = match s.status.failure_reason() {
411 Some(reason) => format!("ERROR: {reason}"),
412 None => s.partial_text.chars().take(120).collect(),
413 };
414 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
415 subagent_id,
416 agent_name: label_for_finalizer.clone(),
417 result_preview: preview,
418 duration_secs: start_time.elapsed().as_secs_f64(),
419 }));
420 }
421 if let Some(result) = pending_result {
422 let _ = result_tx.send(result);
423 }
424 orchestration_for_worker
425 .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
426 });
427
428 {
430 let mut reg = registry.lock().unwrap();
431 if let Some(h) = reg.get_mut(&handle_id) {
432 h.set_thread_handle(thread_handle);
433 }
434 }
435 if let Err(error) = ctx
436 .capabilities
437 .orchestration
438 .as_ref()
439 .unwrap()
440 .mark_running(&handle_id)
441 {
442 let thread = {
443 let mut reg = registry.lock().unwrap();
444 if let Some(handle) = reg.get_mut(&handle_id) {
445 handle.cancel();
446 }
447 reg.remove(&handle_id)
448 };
449 if let Some(handle) = thread {
450 let _ = handle.collect().await;
451 }
452 orchestration.rollback(&handle_id);
453 orchestration
454 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
455 return Err(RuntimeError::Tool(error));
456 }
457 super::finalize::retire_if_cancelled(&state, orchestration, &handle_id);
460
461 Ok(json!({
462 "handle_id": handle_id,
463 "agent_name": label,
464 "status": "running"
465 })
466 .to_string())
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::tools::test_helpers::create_tool_context;
474 use crate::tools::{SubagentRegistry, Tool};
475 use serde_json::json;
476 use std::sync::{Arc, Mutex};
477
478 #[tokio::test]
480 async fn test_subagent_start_no_registry_returns_err() {
481 let tool = SubagentStartTool;
482 let ctx = create_tool_context(); let params = json!({
485 "system_prompt": "You are a test subagent.",
486 "task": "Say ok",
487 });
488
489 let result = tool.execute(params, ctx).await;
490 assert!(
491 result.is_err(),
492 "missing registry must return Err before spawn"
493 );
494 let msg = format!("{:?}", result.unwrap_err());
495 assert!(
496 msg.contains("subagent_registry"),
497 "error must mention subagent_registry: {msg}"
498 );
499 }
500
501 fn join_worker(registry: &Mutex<SubagentRegistry>, handle_id: &str) {
508 let thread = registry
509 .lock()
510 .unwrap()
511 .get_mut(handle_id)
512 .and_then(|h| h.thread_handle.take());
513 if let Some(thread) = thread {
514 let _ = thread.join();
515 }
516 }
517
518 #[tokio::test]
519 #[serial_test::serial(synaps_base_dir)]
520 async fn test_subagent_start_blank_agent_uses_system_prompt() {
521 let _base = crate::test_env::BaseDirGuard::new();
522 let _offline = crate::test_env::OfflineProviderEnv::new();
523 let tool = SubagentStartTool;
524 let mut ctx = create_tool_context();
525 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
526 ctx.capabilities.subagent_registry = Some(registry.clone());
527
528 let params = json!({
529 "agent": "",
530 "system_prompt": "You are a concise test subagent. Reply with only: ok",
531 "task": "Say ok",
532 "model": "anthropic/claude-sonnet-4-6",
533 "timeout": 1
534 });
535
536 let result = tool.execute(params, ctx).await;
537 assert!(
538 result.is_ok(),
539 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
540 );
541 let body: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
542 assert_eq!(body["agent_name"], "inline");
543 let handle_id = body["handle_id"].as_str().unwrap_or_default();
544 assert!(handle_id.starts_with("sa_"));
545 join_worker(®istry, handle_id);
546 }
547
548 #[tokio::test]
549 #[serial_test::serial(synaps_base_dir)]
550 async fn test_subagent_start_inline_name_labels_the_handle() {
551 let _base = crate::test_env::BaseDirGuard::new();
552 let _offline = crate::test_env::OfflineProviderEnv::new();
553 let tool = SubagentStartTool;
554 let mut ctx = create_tool_context();
555 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
556 ctx.capabilities.subagent_registry = Some(registry.clone());
557
558 let params = json!({
559 "system_prompt": "You are a concise test subagent. Reply with only: ok",
560 "name": "gif recorder",
561 "task": "Say ok",
562 "model": "anthropic/claude-sonnet-4-6",
563 "timeout": 1
564 });
565
566 let result = tool.execute(params, ctx).await.expect("dispatch");
567 let body: serde_json::Value = serde_json::from_str(&result).unwrap();
568 assert_eq!(body["agent_name"], "gif-recorder");
569 let handle_id = body["handle_id"].as_str().unwrap();
570 {
571 let reg = registry.lock().unwrap();
572 assert_eq!(
573 reg.get(handle_id).expect("registered").agent_name,
574 "gif-recorder"
575 );
576 }
577 join_worker(®istry, handle_id);
578 }
579
580 #[tokio::test]
584 #[serial_test::serial(synaps_base_dir)]
585 async fn test_subagent_start_ignores_retired_role_and_write_policy() {
586 let _base = crate::test_env::BaseDirGuard::new();
587 let _offline = crate::test_env::OfflineProviderEnv::new();
588 let tool = SubagentStartTool;
589 let mut ctx = create_tool_context();
590 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
591 ctx.capabilities.subagent_registry = Some(registry.clone());
592 let orchestration = Arc::clone(ctx.capabilities.orchestration.as_ref().unwrap());
593
594 let params = json!({
595 "system_prompt": "You are a concise test subagent. Reply with only: ok",
596 "task": "Say ok",
597 "model": "anthropic/claude-sonnet-4-6",
598 "timeout": 1,
599 "role": "reviewer",
600 "write_policy": {"mode": "read_only"}
601 });
602
603 let result = tool
604 .execute(params, ctx)
605 .await
606 .expect("old params must be ignored, not refused");
607 let body: serde_json::Value = serde_json::from_str(&result).unwrap();
608 assert_eq!(body["status"], "running");
609 assert_eq!(body["agent_name"], "inline");
610 let handle_id = body["handle_id"].as_str().unwrap();
611 assert!(registry.lock().unwrap().get(handle_id).is_some());
612
613 let events: serde_json::Value =
614 serde_json::from_str(&orchestration.telemetry_json()).unwrap();
615 let dispatch = events
616 .as_array()
617 .unwrap()
618 .iter()
619 .find(|event| event["name"] == "worker.dispatch_allowed")
620 .expect("dispatch recorded");
621 assert_eq!(dispatch["worker_role"], "implementer");
622 join_worker(®istry, handle_id);
623 }
624}