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