Skip to main content

agent_engine/tools/subagent/
start.rs

1//! SubagentStartTool — dispatch a reactive subagent and return a handle_id immediately.
2//!
3//! Unlike the one-shot `subagent` tool, this tool returns *before* the subagent
4//! finishes. The caller gets a `handle_id` they can poll via `subagent_status`,
5//! steer via `subagent_steer`, or block on via `subagent_collect`.
6
7use 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        // One config snapshot for the whole spawn (`spawn_config`).
97        let config = super::spawn_config();
98        let thinking = super::resolve_subagent_thinking(&params, None, &config)?;
99        // ── Parse params ───────────────────────────────────────────────────────
100        let task = params["task"]
101            .as_str()
102            .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
103            .to_string();
104
105        // Treat blank / whitespace / control-char strings as absent.
106        // Some model providers serialize "unset" as "" rather than omitting the field,
107        // and we must not try to resolve "" (or " ", or "\u{0}") as an agent name —
108        // doing so produces a hard "Agent '' not found" error that the model then
109        // retries forever with sentinel values instead of falling back to system_prompt.
110        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        // Validate the registry before authorization, id allocation, or event emission;
121        // the same borrow is reused below when publishing the authorized handle.
122        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        // ── Shared state ───────────────────────────────────────────────────────
175        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        // ── Channels ───────────────────────────────────────────────────────────
195        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        // ── Forward SubagentStart event to TUI ─────────────────────────────────
200        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        // ── Clone state for the spawned thread ─────────────────────────────────
209        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        // ── Build and register handle BEFORE spawning ─────────────────────────
220        // This closes the publish-before-register race: finalize_subagent can
221        // push a completion event the instant the thread exits, but the registry
222        // must already contain the handle so the parent's collect succeeds.
223        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        // ── Spawn subagent thread (mirrors subagent.rs) ────────────────────────
254        let thread_handle = std::thread::spawn(move || {
255            // Pre-clone for finalizer — catch_unwind moves state_t and label_inner
256            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                // Clones for the async block — the outer closure still needs the originals.
275                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                        // Host-built worker (shared client/creds/token cache, cached
285                        // registry) or the legacy fresh runtime — see `spawn_runtime`.
286                        let mut runtime = match super::spawn_runtime().await {
287                            Ok(r) => r,
288                            Err(_) => return Err("subagent runtime initialization failed".into()),
289                        };
290
291                        // Apply subagent spawn policy: worker role, 5m cache TTL, worker
292                        // turn budget. Subagents are short-lived one-shots — paying the 1h
293                        // write premium (~2× input price) on them is unrecoverable waste
294                        // (~$0.23 per 10-spawn fan-out). (#110)
295                        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                        // Rolled-over context goes beside this worker's
318                        // resume archive (see `worker_context`).
319                        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                        // Date this run's cache for a later resume (cold check).
365                        super::archive::record_cache_anchor(&state_a, runtime.cache_anchor());
366                        drive_outcome
367                    });
368
369                match outcome {
370                    Ok(sa_result) => {
371                        // Only overwrite Running → Completed (don't stomp TimedOut or a cancellation).
372                        {
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            // ── Terminal finalizer — exactly once, outside catch_unwind ────────
404            // Covers all paths: Ok, Err, timeout, panic, early tokio-build failure.
405            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, // start.rs: not a resume
413            );
414            // A cancelled worker publishes no wake, so nothing would ever
415            // prompt a collect: retire it before any terminal publication.
416            super::finalize::retire_if_cancelled(
417                &state_for_finalizer,
418                &orchestration_for_worker,
419                &handle_id_inner,
420            );
421            // All terminal publications follow the recovery/finalization barrier.
422            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        // ── Attach the already-persisted handle to the started thread ──────────
445        {
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        // The thread may have been cancelled and finalized before it was
474        // marked running; its own retire attempt was then too early.
475        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    // V4: registry=None must return Err immediately, before any thread is spawned.
495    #[tokio::test]
496    async fn test_subagent_start_no_registry_returns_err() {
497        let tool = SubagentStartTool;
498        let ctx = create_tool_context(); // subagent_registry is None by default
499
500        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    /// These tests launch a real worker thread. Run unpinned, it read the
518    /// developer's real `auth.json` (and could reach the provider), and wrote
519    /// `logs/subagents` and `subagent-history` under the real `~/.synaps-cli`,
520    /// after the test returned. Pin an empty base dir and clear the
521    /// environment's provider credentials/endpoints (`OfflineProviderEnv`), so
522    /// the worker fails at auth, offline; keep both until it has exited.
523    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(&registry, 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(&registry, handle_id);
594    }
595}