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