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 "role": {
69 "type": "string",
70 "enum": ["planner", "implementer", "tester", "reviewer", "researcher", "debugger"],
71 "description": "Typed orchestration role."
72 },
73 "write_policy": {
74 "oneOf": [
75 {"type": "object", "properties": {"mode": {"const": "read_only"}}, "required": ["mode"]},
76 {"type": "object", "properties": {"mode": {"const": "isolated_worktree"}}, "required": ["mode"]},
77 {"type": "object", "properties": {"mode": {"const": "non_overlapping_paths"}, "scopes": {"type": "array", "items": {"type": "string"}}}, "required": ["mode", "scopes"]}
78 ],
79 "description": "Declared write isolation and path scopes."
80 },
81 "thinking": {
82 "type": "string",
83 "enum": ["low", "medium", "high", "xhigh", "max"],
84 "description": "Worker effort override. Omit for subagent_thinking (default xhigh), independent of foreground thinking. Unsupported model levels are refused."
85 },
86 "timeout": {
87 "type": "integer",
88 "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."
89 }
90 },
91 "required": ["task"]
92 })
93 }
94
95 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
96 let config = super::spawn_config();
98 let thinking = super::resolve_subagent_thinking(¶ms, None, &config)?;
99 let task = params["task"]
101 .as_str()
102 .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
103 .to_string();
104
105 let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
111 let agent_name = params["agent"]
112 .as_str()
113 .map(|s| s.to_string())
114 .filter(|s| !is_blank(s));
115 let inline_prompt = params["system_prompt"]
116 .as_str()
117 .map(|s| s.to_string())
118 .filter(|s| !is_blank(s));
119 let requested_model = params["model"].as_str();
120 let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
123 RuntimeError::Tool("subagent_start requires a subagent_registry in ToolContext".into())
124 })?;
125 let system_prompt = match (&agent_name, &inline_prompt) {
126 (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
127 (None, Some(p)) => p.clone(),
128 (None, None) => {
129 return Err(RuntimeError::Tool(
130 "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither."
131 .to_string(),
132 ));
133 }
134 };
135 let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
136 let handle_id = format!("sa_{}", subagent_id);
137 let orchestration = ctx
138 .capabilities
139 .orchestration
140 .as_ref()
141 .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
142 orchestration
143 .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
144 .map_err(|reason| RuntimeError::Tool(format!("delegation denied: {reason}")))?;
145 let decision = orchestration
146 .resolve_and_authorize(&handle_id, requested_model)
147 .map_err(|error| {
148 orchestration
149 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
150 RuntimeError::Tool(error.to_string())
151 })?;
152 let model = decision.model.as_str().to_owned();
153 let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
154 let memory_backend = ctx.capabilities.memory_backend.clone();
155 let session_allow_all = ctx.capabilities.session_allow_all.clone();
156 let telemetry = ctx.capabilities.telemetry.clone();
157 let workspace = super::WorkerWorkspace::from_capabilities(&ctx.capabilities);
158 let timeout_secs = params["timeout"]
159 .as_u64()
160 .unwrap_or(ctx.limits.subagent_timeout);
161
162 let label = super::subagent_label(agent_name.as_deref(), params["name"].as_str());
163 let telemetry = telemetry.map(|t| t.for_worker(&label, &handle_id));
164 let task_preview: String = task.chars().take(80).collect();
165 let task_full = task.clone();
166
167 tracing::info!(
168 "subagent_start: dispatching '{}' (id={}) model={}",
169 label,
170 handle_id,
171 model
172 );
173
174 let state = Arc::new(RwLock::new(SubagentState::new()));
176 state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
177 agent_name: label.clone(),
178 model: model.clone(),
179 system_prompt: system_prompt.clone(),
180 timeout_secs,
181 cwd: workspace
182 .cwd
183 .clone()
184 .or_else(|| std::env::current_dir().ok()),
185 thinking: Some(thinking.as_str().into()),
186 });
187
188 state.write().unwrap().archive_dir = Some(super::archive::process_dir());
189 let initial_messages = vec![Arc::new(
190 serde_json::json!({"role": "user", "content": task.clone()}),
191 )];
192 state.write().unwrap().history = Some(initial_messages.clone());
193
194 let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
196 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
197 let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
198
199 if let Some(ref tx) = ctx.channels.tx_events {
201 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
202 subagent_id,
203 agent_name: label.clone(),
204 task_preview: task_preview.clone(),
205 }));
206 }
207
208 let state_t = Arc::clone(&state);
210 let task_full_a = task_full.clone();
211 let label_inner = label.clone();
212 let model_inner = model.clone();
213 let tx_events_inner = ctx.channels.tx_events.clone();
214 let start_time = std::time::Instant::now();
215 let parent_queue = ctx.capabilities.event_queue.clone();
216 let handle_id_inner = handle_id.clone();
217 let child_parent_id = handle_id.clone();
218
219 let system_prompt_for_handle = system_prompt.clone();
224 let handle = SubagentHandle::new(
225 handle_id.clone(),
226 subagent_id,
227 label.clone(),
228 task_preview,
229 model.clone(),
230 system_prompt_for_handle,
231 timeout_secs,
232 Arc::clone(&state),
233 Some(steer_tx),
234 Some(shutdown_tx),
235 Some(result_rx),
236 )
237 .with_authorization(&decision);
238 {
239 let mut reg = registry.lock().unwrap();
240 reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
241 }
242
243 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
244 let orchestration_for_worker = Arc::clone(orchestration);
245 let parent_for_worker = ctx.capabilities.delegation_parent.clone();
246 if let Err(error) = orchestration.mark_starting(&handle_id) {
247 orchestration.rollback(&handle_id);
248 orchestration
249 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
250 return Err(RuntimeError::Tool(error));
251 }
252
253 let thread_handle = std::thread::spawn(move || {
255 let state_for_finalizer = Arc::clone(&state_t);
257 let label_for_finalizer = label_inner.clone();
258 let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
259
260 let mut pending_result = None;
261 let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
262 let rt = match tokio::runtime::Builder::new_current_thread()
263 .enable_all()
264 .build()
265 {
266 Ok(rt) => rt,
267 Err(_) => {
268 state_t.write().unwrap().status =
269 SubagentStatus::Failed("runtime initialization failed".into());
270 return;
271 }
272 };
273
274 let state_a = Arc::clone(&state_t);
276 let label_a = label_inner.clone();
277 let model_a = model_inner.clone();
278 let tx_events_a = tx_events_inner.clone();
279 let task_for_timeout = task_full_a.clone();
280 let task_for_complete = task_full_a;
281
282 let outcome: std::result::Result<SubagentResult, String> =
283 rt.block_on(async move {
284 let mut runtime = match super::spawn_runtime().await {
287 Ok(r) => r,
288 Err(_) => return Err("subagent runtime initialization failed".into()),
289 };
290
291 super::apply_subagent_runtime_policy(
296 &mut runtime,
297 &config,
298 memory_backend.as_ref(),
299 session_allow_all.as_ref(),
300 telemetry.as_ref(),
301 &workspace,
302 );
303 runtime.set_system_prompt(super::compose_system_prompt(
304 system_prompt,
305 runtime.memory_backend_is_axel(),
306 ));
307 runtime.set_model(model_a.clone());
308 super::fit_subagent_context_window(&mut runtime);
309 super::apply_codex_worker_reasoning(
310 &mut runtime,
311 codex_parent_plan.as_ref(),
312 );
313 super::apply_subagent_thinking(&mut runtime, thinking)?;
314 runtime
315 .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
316 runtime.set_delegation_parent(Some(child_parent_id.clone()));
317 let archive_dir = state_a
320 .read()
321 .unwrap_or_else(|p| p.into_inner())
322 .archive_dir
323 .clone()
324 .unwrap_or_else(super::archive::process_dir);
325 super::attach_worker_transcripts(
326 &mut runtime,
327 archive_dir,
328 &child_parent_id,
329 Some(Arc::clone(&state_a)),
330 &initial_messages,
331 tx_events_a.clone(),
332 subagent_id,
333 &label_a,
334 );
335
336 let cancel = crate::CancellationToken::new();
337 let cancel_inner = cancel.clone();
338 tokio::spawn(async move {
339 let _ = shutdown_rx.await;
340 cancel_inner.cancel();
341 });
342
343 let cancel_on_timeout = cancel.clone();
344 let stream = runtime.run_stream_with_messages(
345 initial_messages,
346 cancel,
347 Some(steer_rx),
348 None,
349 false,
350 ).await;
351
352 let drive_outcome = super::drive::WorkerDrive {
353 state: Arc::clone(&state_a),
354 tx_events: tx_events_a.clone(),
355 subagent_id,
356 label: label_a.clone(),
357 model: model_a.clone(),
358 timeout_secs,
359 cancel: cancel_on_timeout,
360 task: task_for_timeout,
361 }
362 .run(stream)
363 .await;
364 super::archive::record_cache_anchor(&state_a, runtime.cache_anchor());
366 drive_outcome
367 });
368
369 match outcome {
370 Ok(sa_result) => {
371 {
373 let mut s = state_t.write().unwrap();
374 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
375 s.status = SubagentStatus::Completed;
376 s.conversation_state = vec![
377 serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
378 serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
379 ];
380 }
381 }
382 pending_result = Some(sa_result);
383 }
384 Err(e) => {
385 state_t.write().unwrap().status = SubagentStatus::Failed(e);
386 }
387 }
388 }));
389
390 if let Err(panic_info) = panic_result {
391 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
392 s.to_string()
393 } else if let Some(s) = panic_info.downcast_ref::<String>() {
394 s.clone()
395 } else {
396 "unknown panic".to_string()
397 };
398 tracing::error!("Subagent thread panicked: {}", msg);
399 state_t.write().unwrap_or_else(|p| p.into_inner()).status =
400 SubagentStatus::Failed(format!("panic: {}", msg));
401 }
402
403 super::finalize::finalize_subagent(
406 &state_for_finalizer,
407 parent_queue.as_ref(),
408 &handle_id_inner,
409 subagent_id,
410 &label_for_finalizer,
411 start_time,
412 None, );
414 super::finalize::retire_if_cancelled(
417 &state_for_finalizer,
418 &orchestration_for_worker,
419 &handle_id_inner,
420 );
421 if let Some(ref tx) = tx_events_inner {
423 let s = state_for_finalizer
424 .read()
425 .unwrap_or_else(|p| p.into_inner());
426 let preview = match s.status.failure_reason() {
427 Some(reason) => format!("ERROR: {reason}"),
428 None => s.partial_text.chars().take(120).collect(),
429 };
430 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
431 subagent_id,
432 agent_name: label_for_finalizer.clone(),
433 result_preview: preview,
434 duration_secs: start_time.elapsed().as_secs_f64(),
435 }));
436 }
437 if let Some(result) = pending_result {
438 let _ = result_tx.send(result);
439 }
440 orchestration_for_worker
441 .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
442 });
443
444 {
446 let mut reg = registry.lock().unwrap();
447 if let Some(h) = reg.get_mut(&handle_id) {
448 h.set_thread_handle(thread_handle);
449 }
450 }
451 if let Err(error) = ctx
452 .capabilities
453 .orchestration
454 .as_ref()
455 .unwrap()
456 .mark_running(&handle_id)
457 {
458 let thread = {
459 let mut reg = registry.lock().unwrap();
460 if let Some(handle) = reg.get_mut(&handle_id) {
461 handle.cancel();
462 }
463 reg.remove(&handle_id)
464 };
465 if let Some(handle) = thread {
466 let _ = handle.collect().await;
467 }
468 orchestration.rollback(&handle_id);
469 orchestration
470 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
471 return Err(RuntimeError::Tool(error));
472 }
473 super::finalize::retire_if_cancelled(&state, orchestration, &handle_id);
476
477 Ok(json!({
478 "handle_id": handle_id,
479 "agent_name": label,
480 "status": "running"
481 })
482 .to_string())
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::tools::test_helpers::create_tool_context;
490 use crate::tools::{SubagentRegistry, Tool};
491 use serde_json::json;
492 use std::sync::{Arc, Mutex};
493
494 #[tokio::test]
496 async fn test_subagent_start_no_registry_returns_err() {
497 let tool = SubagentStartTool;
498 let ctx = create_tool_context(); let params = json!({
501 "system_prompt": "You are a test subagent.",
502 "task": "Say ok",
503 });
504
505 let result = tool.execute(params, ctx).await;
506 assert!(
507 result.is_err(),
508 "missing registry must return Err before spawn"
509 );
510 let msg = format!("{:?}", result.unwrap_err());
511 assert!(
512 msg.contains("subagent_registry"),
513 "error must mention subagent_registry: {msg}"
514 );
515 }
516
517 fn join_worker(registry: &Mutex<SubagentRegistry>, handle_id: &str) {
524 let thread = registry
525 .lock()
526 .unwrap()
527 .get_mut(handle_id)
528 .and_then(|h| h.thread_handle.take());
529 if let Some(thread) = thread {
530 let _ = thread.join();
531 }
532 }
533
534 #[tokio::test]
535 #[serial_test::serial(synaps_base_dir)]
536 async fn test_subagent_start_blank_agent_uses_system_prompt() {
537 let _base = crate::test_env::BaseDirGuard::new();
538 let _offline = crate::test_env::OfflineProviderEnv::new();
539 let tool = SubagentStartTool;
540 let mut ctx = create_tool_context();
541 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
542 ctx.capabilities.subagent_registry = Some(registry.clone());
543
544 let params = json!({
545 "agent": "",
546 "system_prompt": "You are a concise test subagent. Reply with only: ok",
547 "task": "Say ok",
548 "model": "anthropic/claude-sonnet-4-6",
549 "timeout": 1
550 });
551
552 let result = tool.execute(params, ctx).await;
553 assert!(
554 result.is_ok(),
555 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
556 );
557 let body: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
558 assert_eq!(body["agent_name"], "inline");
559 let handle_id = body["handle_id"].as_str().unwrap_or_default();
560 assert!(handle_id.starts_with("sa_"));
561 join_worker(®istry, handle_id);
562 }
563
564 #[tokio::test]
565 #[serial_test::serial(synaps_base_dir)]
566 async fn test_subagent_start_inline_name_labels_the_handle() {
567 let _base = crate::test_env::BaseDirGuard::new();
568 let _offline = crate::test_env::OfflineProviderEnv::new();
569 let tool = SubagentStartTool;
570 let mut ctx = create_tool_context();
571 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
572 ctx.capabilities.subagent_registry = Some(registry.clone());
573
574 let params = json!({
575 "system_prompt": "You are a concise test subagent. Reply with only: ok",
576 "name": "gif recorder",
577 "task": "Say ok",
578 "model": "anthropic/claude-sonnet-4-6",
579 "timeout": 1
580 });
581
582 let result = tool.execute(params, ctx).await.expect("dispatch");
583 let body: serde_json::Value = serde_json::from_str(&result).unwrap();
584 assert_eq!(body["agent_name"], "gif-recorder");
585 let handle_id = body["handle_id"].as_str().unwrap();
586 {
587 let reg = registry.lock().unwrap();
588 assert_eq!(
589 reg.get(handle_id).expect("registered").agent_name,
590 "gif-recorder"
591 );
592 }
593 join_worker(®istry, handle_id);
594 }
595}