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 "timeout": {
82 "type": "integer",
83 "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."
84 }
85 },
86 "required": ["task"]
87 })
88 }
89
90 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
91 let task = params["task"]
93 .as_str()
94 .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
95 .to_string();
96
97 let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
103 let agent_name = params["agent"]
104 .as_str()
105 .map(|s| s.to_string())
106 .filter(|s| !is_blank(s));
107 let inline_prompt = params["system_prompt"]
108 .as_str()
109 .map(|s| s.to_string())
110 .filter(|s| !is_blank(s));
111 let requested_model = params["model"].as_str();
112 let registry = ctx.capabilities.subagent_registry.as_ref().ok_or_else(|| {
115 RuntimeError::Tool("subagent_start requires a subagent_registry in ToolContext".into())
116 })?;
117 let system_prompt = match (&agent_name, &inline_prompt) {
118 (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
119 (None, Some(p)) => p.clone(),
120 (None, None) => {
121 return Err(RuntimeError::Tool(
122 "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither."
123 .to_string(),
124 ));
125 }
126 };
127 let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
128 let handle_id = format!("sa_{}", subagent_id);
129 let orchestration = ctx
130 .capabilities
131 .orchestration
132 .as_ref()
133 .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?;
134 orchestration
135 .reserve_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref())
136 .map_err(|reason| RuntimeError::Tool(format!("delegation denied: {reason}")))?;
137 let decision = orchestration
138 .resolve_and_authorize(&handle_id, requested_model)
139 .map_err(|error| {
140 orchestration
141 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
142 RuntimeError::Tool(error.to_string())
143 })?;
144 let model = decision.model.as_str().to_owned();
145 let codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
146 let memory_backend = ctx.capabilities.memory_backend.clone();
147 let timeout_secs = params["timeout"]
148 .as_u64()
149 .unwrap_or(ctx.limits.subagent_timeout);
150
151 let label = super::subagent_label(agent_name.as_deref(), params["name"].as_str());
152 let task_preview: String = task.chars().take(80).collect();
153 let task_full = task.clone();
154
155 tracing::info!(
156 "subagent_start: dispatching '{}' (id={}) model={}",
157 label,
158 handle_id,
159 model
160 );
161
162 let state = Arc::new(RwLock::new(SubagentState::new()));
164 state.write().unwrap().archive_meta = Some(crate::runtime::subagent::SubagentArchiveMeta {
165 agent_name: label.clone(),
166 model: model.clone(),
167 system_prompt: system_prompt.clone(),
168 timeout_secs,
169 });
170
171 state.write().unwrap().archive_dir = Some(super::archive::process_dir());
172 let initial_messages = vec![Arc::new(
173 serde_json::json!({"role": "user", "content": task.clone()}),
174 )];
175 state.write().unwrap().history = Some(initial_messages.clone());
176
177 let (steer_tx, steer_rx) = mpsc::unbounded_channel::<String>();
179 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
180 let (result_tx, result_rx) = oneshot::channel::<SubagentResult>();
181
182 if let Some(ref tx) = ctx.channels.tx_events {
184 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
185 subagent_id,
186 agent_name: label.clone(),
187 task_preview: task_preview.clone(),
188 }));
189 }
190
191 let state_t = Arc::clone(&state);
193 let task_full_a = task_full.clone();
194 let label_inner = label.clone();
195 let model_inner = model.clone();
196 let tx_events_inner = ctx.channels.tx_events.clone();
197 let start_time = std::time::Instant::now();
198 let parent_queue = ctx.capabilities.event_queue.clone();
199 let handle_id_inner = handle_id.clone();
200 let child_parent_id = handle_id.clone();
201
202 let system_prompt_for_handle = system_prompt.clone();
207 let handle = SubagentHandle::new(
208 handle_id.clone(),
209 subagent_id,
210 label.clone(),
211 task_preview,
212 model.clone(),
213 system_prompt_for_handle,
214 timeout_secs,
215 Arc::clone(&state),
216 Some(steer_tx),
217 Some(shutdown_tx),
218 Some(result_rx),
219 )
220 .with_authorization(&decision);
221 {
222 let mut reg = registry.lock().unwrap();
223 reg.register_with_cancellation(handle, ctx.capabilities.launch_cancel.as_ref());
224 }
225
226 let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
227 let orchestration_for_worker = Arc::clone(orchestration);
228 let parent_for_worker = ctx.capabilities.delegation_parent.clone();
229 if let Err(error) = orchestration.mark_starting(&handle_id) {
230 orchestration.rollback(&handle_id);
231 orchestration
232 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
233 return Err(RuntimeError::Tool(error));
234 }
235
236 let thread_handle = std::thread::spawn(move || {
238 let state_for_finalizer = Arc::clone(&state_t);
240 let label_for_finalizer = label_inner.clone();
241 let orchestration_for_runtime = Arc::clone(&orchestration_for_worker);
242
243 let mut pending_result = None;
244 let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
245 let rt = match tokio::runtime::Builder::new_current_thread()
246 .enable_all()
247 .build()
248 {
249 Ok(rt) => rt,
250 Err(_) => {
251 state_t.write().unwrap().status =
252 SubagentStatus::Failed("runtime initialization failed".into());
253 return;
254 }
255 };
256
257 let state_a = Arc::clone(&state_t);
259 let label_a = label_inner.clone();
260 let model_a = model_inner.clone();
261 let tx_events_a = tx_events_inner.clone();
262 let task_for_timeout = task_full_a.clone();
263 let task_for_complete = task_full_a;
264
265 let outcome: std::result::Result<SubagentResult, String> =
266 rt.block_on(async move {
267 let mut runtime = match super::spawn_runtime().await {
270 Ok(r) => r,
271 Err(_) => return Err("subagent runtime initialization failed".into()),
272 };
273
274 super::apply_subagent_runtime_policy(
279 &mut runtime,
280 &crate::config::load_config(),
281 memory_backend.as_ref(),
282 );
283 runtime.set_system_prompt(super::compose_system_prompt(
284 system_prompt,
285 runtime.memory_backend_is_axel(),
286 ));
287 runtime.set_model(model_a.clone());
288 super::apply_codex_worker_reasoning(
289 &mut runtime,
290 codex_parent_plan.as_ref(),
291 );
292 runtime
293 .install_worker_orchestration(Arc::clone(&orchestration_for_runtime));
294 runtime.set_delegation_parent(Some(child_parent_id.clone()));
295
296 let cancel = crate::CancellationToken::new();
297 let cancel_inner = cancel.clone();
298 tokio::spawn(async move {
299 let _ = shutdown_rx.await;
300 cancel_inner.cancel();
301 });
302
303 let cancel_on_timeout = cancel.clone();
304 let stream = runtime.run_stream_with_messages(
305 initial_messages,
306 cancel,
307 Some(steer_rx),
308 None,
309 false,
310 ).await;
311
312 super::drive::WorkerDrive {
313 state: Arc::clone(&state_a),
314 tx_events: tx_events_a.clone(),
315 subagent_id,
316 label: label_a.clone(),
317 model: model_a.clone(),
318 timeout_secs,
319 cancel: cancel_on_timeout,
320 task: task_for_timeout,
321 }
322 .run(stream)
323 .await
324 });
325
326 match outcome {
327 Ok(sa_result) => {
328 {
330 let mut s = state_t.write().unwrap();
331 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
332 s.status = SubagentStatus::Completed;
333 s.conversation_state = vec![
334 serde_json::json!({"role": "user", "content": task_for_complete.clone()}),
335 serde_json::json!({"role": "assistant", "content": sa_result.text.clone()}),
336 ];
337 }
338 }
339 pending_result = Some(sa_result);
340 }
341 Err(e) => {
342 state_t.write().unwrap().status = SubagentStatus::Failed(e);
343 }
344 }
345 }));
346
347 if let Err(panic_info) = panic_result {
348 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
349 s.to_string()
350 } else if let Some(s) = panic_info.downcast_ref::<String>() {
351 s.clone()
352 } else {
353 "unknown panic".to_string()
354 };
355 tracing::error!("Subagent thread panicked: {}", msg);
356 state_t.write().unwrap_or_else(|p| p.into_inner()).status =
357 SubagentStatus::Failed(format!("panic: {}", msg));
358 }
359
360 super::finalize::finalize_subagent(
363 &state_for_finalizer,
364 parent_queue.as_ref(),
365 &handle_id_inner,
366 subagent_id,
367 &label_for_finalizer,
368 start_time,
369 None, );
371 if let Some(ref tx) = tx_events_inner {
373 let s = state_for_finalizer
374 .read()
375 .unwrap_or_else(|p| p.into_inner());
376 let preview = match s.status.failure_reason() {
377 Some(reason) => format!("ERROR: {reason}"),
378 None => s.partial_text.chars().take(120).collect(),
379 };
380 let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
381 subagent_id,
382 agent_name: label_for_finalizer.clone(),
383 result_preview: preview,
384 duration_secs: start_time.elapsed().as_secs_f64(),
385 }));
386 }
387 if let Some(result) = pending_result {
388 let _ = result_tx.send(result);
389 }
390 orchestration_for_worker
391 .release_delegation(&handle_id_inner, parent_for_worker.as_deref());
392 });
393
394 {
396 let mut reg = registry.lock().unwrap();
397 if let Some(h) = reg.get_mut(&handle_id) {
398 h.set_thread_handle(thread_handle);
399 }
400 }
401 if let Err(error) = ctx
402 .capabilities
403 .orchestration
404 .as_ref()
405 .unwrap()
406 .mark_running(&handle_id)
407 {
408 let thread = {
409 let mut reg = registry.lock().unwrap();
410 if let Some(handle) = reg.get_mut(&handle_id) {
411 handle.cancel();
412 }
413 reg.remove(&handle_id)
414 };
415 if let Some(handle) = thread {
416 let _ = handle.collect().await;
417 }
418 orchestration.rollback(&handle_id);
419 orchestration
420 .release_delegation(&handle_id, ctx.capabilities.delegation_parent.as_deref());
421 return Err(RuntimeError::Tool(error));
422 }
423
424 Ok(json!({
425 "handle_id": handle_id,
426 "agent_name": label,
427 "status": "running"
428 })
429 .to_string())
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::tools::test_helpers::create_tool_context;
437 use crate::tools::{SubagentRegistry, Tool};
438 use serde_json::json;
439 use std::sync::{Arc, Mutex};
440
441 #[tokio::test]
443 async fn test_subagent_start_no_registry_returns_err() {
444 let tool = SubagentStartTool;
445 let ctx = create_tool_context(); let params = json!({
448 "system_prompt": "You are a test subagent.",
449 "task": "Say ok",
450 });
451
452 let result = tool.execute(params, ctx).await;
453 assert!(
454 result.is_err(),
455 "missing registry must return Err before spawn"
456 );
457 let msg = format!("{:?}", result.unwrap_err());
458 assert!(
459 msg.contains("subagent_registry"),
460 "error must mention subagent_registry: {msg}"
461 );
462 }
463
464 #[tokio::test]
465 async fn test_subagent_start_blank_agent_uses_system_prompt() {
466 let tool = SubagentStartTool;
467 let mut ctx = create_tool_context();
468 ctx.capabilities.subagent_registry = Some(Arc::new(Mutex::new(SubagentRegistry::new())));
469
470 let params = json!({
471 "agent": "",
472 "system_prompt": "You are a concise test subagent. Reply with only: ok",
473 "task": "Say ok",
474 "model": "anthropic/claude-sonnet-4-6",
475 "timeout": 1
476 });
477
478 let result = tool.execute(params, ctx).await;
479 assert!(
480 result.is_ok(),
481 "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
482 );
483 let body: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
484 assert_eq!(body["agent_name"], "inline");
485 assert!(body["handle_id"]
486 .as_str()
487 .unwrap_or_default()
488 .starts_with("sa_"));
489 }
490
491 #[tokio::test]
492 async fn test_subagent_start_inline_name_labels_the_handle() {
493 let tool = SubagentStartTool;
494 let mut ctx = create_tool_context();
495 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
496 ctx.capabilities.subagent_registry = Some(registry.clone());
497
498 let params = json!({
499 "system_prompt": "You are a concise test subagent. Reply with only: ok",
500 "name": "gif recorder",
501 "task": "Say ok",
502 "model": "anthropic/claude-sonnet-4-6",
503 "timeout": 1
504 });
505
506 let result = tool.execute(params, ctx).await.expect("dispatch");
507 let body: serde_json::Value = serde_json::from_str(&result).unwrap();
508 assert_eq!(body["agent_name"], "gif-recorder");
509 let handle_id = body["handle_id"].as_str().unwrap();
510 let reg = registry.lock().unwrap();
511 assert_eq!(
512 reg.get(handle_id).expect("registered").agent_name,
513 "gif-recorder"
514 );
515 }
516}