Skip to main content

agent_engine/tools/subagent/
oneshot.rs

1use super::super::{resolve_agent_prompt, Tool, ToolContext, NEXT_SUBAGENT_ID};
2pub use crate::runtime::subagent::SubagentResult;
3use crate::{AgentEvent, LlmEvent, Result, RuntimeError, SessionEvent};
4use serde_json::{json, Value};
5use std::sync::atomic::Ordering;
6use std::time::Duration;
7
8pub struct SubagentTool;
9
10#[async_trait::async_trait]
11impl Tool for SubagentTool {
12    fn origin(&self) -> crate::tools::ToolOrigin {
13        crate::tools::ToolOrigin::Builtin
14    }
15
16    fn name(&self) -> &str {
17        "subagent"
18    }
19
20    fn description(&self) -> &str {
21        "Dispatch a one-shot subagent with a specific system prompt to perform a task. The subagent gets its own tool suite (bash, read, write, edit, grep, find, ls) and runs autonomously until done. Use this when you need the result before continuing. Blocks until done. For parallel work, use subagent_start instead. Provide either an agent name (resolves from ~/.synaps-cli/agents/<name>.md) or a system_prompt string directly."
22    }
23
24    fn parameters(&self) -> Value {
25        json!({
26            "type": "object",
27            "properties": {
28                "agent": {
29                    "type": "string",
30                    "description": "Agent name — resolves to ~/.synaps-cli/agents/<name>.md. Mutually exclusive with system_prompt."
31                },
32                "system_prompt": {
33                    "type": "string",
34                    "description": "Inline system prompt for the subagent. Use when you don't have a named agent file."
35                },
36                "task": {
37                    "type": "string",
38                    "description": "The task/prompt to send to the subagent."
39                },
40                "model": {"type": "string", "description": "Omit to inherit the session foreground qualified identity. Explicit values must be one of subagent_models' listed exact choices."},
41                "role": {"type": "string", "enum": ["planner", "implementer", "tester", "reviewer", "researcher", "debugger"]},
42                "write_policy": {
43                    "oneOf": [
44                        {"type": "object", "properties": {"mode": {"const": "read_only"}}, "required": ["mode"]},
45                        {"type": "object", "properties": {"mode": {"const": "isolated_worktree"}}, "required": ["mode"]},
46                        {"type": "object", "properties": {"mode": {"const": "non_overlapping_paths"}, "scopes": {"type": "array", "items": {"type": "string"}}}, "required": ["mode", "scopes"]}
47                    ]
48                },
49                "timeout": {
50                    "type": "integer",
51                    "description": "Timeout in seconds (default: 300). Increase for long-running tasks."
52                }
53            },
54            "required": ["task"]
55        })
56    }
57
58    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
59        let task = params["task"]
60            .as_str()
61            .ok_or_else(|| RuntimeError::Tool("Missing 'task' parameter".to_string()))?
62            .to_string();
63
64        // Treat blank / whitespace / control-char strings as absent — see
65        // subagent_start.rs for full rationale. Models occasionally pass `agent: ""`
66        // (or "\u{0}", or " ") alongside a real `system_prompt`; without this filter
67        // we'd try to resolve an empty agent name and fail, instead of falling
68        // through to the inline prompt.
69        let is_blank = |s: &String| s.chars().all(|c| c.is_whitespace() || c.is_control());
70        let agent_name = params["agent"]
71            .as_str()
72            .map(|s| s.to_string())
73            .filter(|s| !is_blank(s));
74        let inline_prompt = params["system_prompt"]
75            .as_str()
76            .map(|s| s.to_string())
77            .filter(|s| !is_blank(s));
78        let requested_model = params["model"].as_str();
79        let system_prompt = match (&agent_name, &inline_prompt) {
80            (Some(name), _) => resolve_agent_prompt(name).map_err(RuntimeError::Tool)?,
81            (None, Some(prompt)) => prompt.clone(),
82            (None, None) => {
83                return Err(RuntimeError::Tool(
84                    "Must provide either 'agent' (name) or 'system_prompt' (inline). Got neither."
85                        .to_string(),
86                ));
87            }
88        };
89        let subagent_id = NEXT_SUBAGENT_ID.fetch_add(1, Ordering::Relaxed);
90        let orchestration_id = format!("sa_{}", subagent_id);
91        let decision = ctx
92            .capabilities
93            .orchestration
94            .as_ref()
95            .ok_or_else(|| RuntimeError::Tool("delegation policy unavailable".into()))?
96            .resolve_and_authorize(&orchestration_id, requested_model)
97            .map_err(|error| RuntimeError::Tool(error.to_string()))?;
98        let model = decision.model.as_str().to_owned();
99        let timeout_secs = params["timeout"]
100            .as_u64()
101            .unwrap_or(ctx.limits.subagent_timeout);
102
103        let label = agent_name.as_deref().unwrap_or("inline").to_string();
104        let task_preview: String = task.chars().take(80).collect();
105
106        tracing::info!(
107            "Dispatching subagent '{}' (id={}) with model {}",
108            label,
109            subagent_id,
110            model
111        );
112
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        let start_time = std::time::Instant::now();
122
123        let (result_tx, result_rx) =
124            tokio::sync::oneshot::channel::<std::result::Result<SubagentResult, String>>();
125        let label_inner = label.clone();
126        let model_inner = model.clone();
127        let tx_events_inner = ctx.channels.tx_events.clone();
128
129        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
130        let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
131        if let Err(error) = orchestration.mark_starting(&orchestration_id) {
132            orchestration.rollback(&orchestration_id);
133            return Err(RuntimeError::Tool(error));
134        }
135
136        let thread_handle = match std::thread::Builder::new().spawn(move || {
137            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
138                let rt = match tokio::runtime::Builder::new_current_thread()
139                    .enable_all()
140                    .build()
141                {
142                    Ok(rt) => rt,
143                    Err(_) => {
144                        let _ = result_tx.send(Err("runtime initialization failed".into()));
145                        return;
146                    }
147                };
148
149                let result = rt.block_on(async move {
150                    use futures::StreamExt;
151
152                    let mut runtime = match crate::Runtime::new().await {
153                        Ok(r) => r,
154                        Err(_) => return Err("subagent runtime initialization failed".into()),
155                    };
156
157                    // Apply subagent spawn policy: inherit credential source AND
158                    // unconditionally force cache TTL to 5m. Subagents are short-lived
159                    // one-shots — paying the 1h write premium (~2× input price) on them
160                    // is unrecoverable waste (~$0.23 per 10-spawn fan-out). (#110)
161                    super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config());
162                    runtime.set_system_prompt(system_prompt);
163                    runtime.set_model(model);
164                    runtime.set_tools(super::subagent_tools().await);
165
166                    let cancel = crate::CancellationToken::new();
167                    let cancel_inner = cancel.clone();
168
169                    tokio::spawn(async move {
170                        let _ = shutdown_rx.await;
171                        cancel_inner.cancel();
172                    });
173
174                    let mut stream = runtime.run_stream(task, cancel).await;
175
176                let mut final_text = String::new();
177                let mut tool_count = 0u32;
178                let mut tool_log: Vec<String> = Vec::new();
179                let mut total_input_tokens = 0u64;
180                let mut total_output_tokens = 0u64;
181                let mut total_cache_read = 0u64;
182                let mut total_cache_creation = 0u64;
183                // TTL split: None only if no turn ever reported one; otherwise summed.
184                let mut total_cache_5m: Option<u64> = None;
185                let mut total_cache_1h: Option<u64> = None;
186
187                let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
188                tokio::pin!(timeout_fut);
189
190                loop {
191                    tokio::select! {
192                        event = stream.next() => {
193                            let Some(event) = event else { break };
194                            match event {
195                                crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
196                                    if let Some(ref tx) = tx_events_inner {
197                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
198                                            subagent_id,
199                                            agent_name: label_inner.clone(),
200                                            status: "💭 thinking...".to_string(),
201                                        }));
202                                    }
203                                }
204                                crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
205                                    final_text.push_str(&text);
206                                }
207                                crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
208                                    tool_count += 1;
209                                    if let Some(ref tx) = tx_events_inner {
210                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
211                                            subagent_id,
212                                            agent_name: label_inner.clone(),
213                                            status: format!("⚙ {} (tool #{})", name, tool_count),
214                                        }));
215                                    }
216                                }
217                                crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
218                                    let input_str = input.to_string();
219                                    let input_preview: String = input_str.chars().take(200).collect();
220                                    tool_log.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
221                                    // Build a rich status from the tool input
222                                    let detail = match tool_name.as_str() {
223                                        "bash" => {
224                                            let cmd = input["command"].as_str().unwrap_or("");
225                                            let preview: String = cmd.chars().take(60).collect();
226                                            format!("$ {}", preview)
227                                        }
228                                        "read" => {
229                                            let path = input["path"].as_str().unwrap_or("?");
230                                            let short = path.rsplit('/').next().unwrap_or(path);
231                                            format!("reading {}", short)
232                                        }
233                                        "write" => {
234                                            let path = input["path"].as_str().unwrap_or("?");
235                                            let short = path.rsplit('/').next().unwrap_or(path);
236                                            format!("writing {}", short)
237                                        }
238                                        "edit" => {
239                                            let path = input["path"].as_str().unwrap_or("?");
240                                            let short = path.rsplit('/').next().unwrap_or(path);
241                                            format!("editing {}", short)
242                                        }
243                                        "grep" => {
244                                            let pat = input["pattern"].as_str().unwrap_or("?");
245                                            let preview: String = pat.chars().take(30).collect();
246                                            format!("grep /{}/", preview)
247                                        }
248                                        "find" => {
249                                            let pat = input["pattern"].as_str().unwrap_or("?");
250                                            format!("find {}", pat)
251                                        }
252                                        "ls" => {
253                                            let path = input["path"].as_str().unwrap_or(".");
254                                            let short = path.rsplit('/').next().unwrap_or(path);
255                                            format!("ls {}", short)
256                                        }
257                                        "subagent" => {
258                                            let name = input["agent"].as_str()
259                                                .or_else(|| input["system_prompt"].as_str().map(|s| if s.len() > 20 { "inline" } else { s }))
260                                                .unwrap_or("?");
261                                            format!("spawning {}", name)
262                                        }
263                                        other => {
264                                            // MCP or unknown tools — show tool name + first param
265                                            let short_name = if other.starts_with("ext__") {
266                                                other.splitn(3, "__").last().unwrap_or(other)
267                                            } else {
268                                                other
269                                            };
270                                            short_name.to_string()
271                                        }
272                                    };
273                                    if let Some(ref tx) = tx_events_inner {
274                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
275                                            subagent_id,
276                                            agent_name: label_inner.clone(),
277                                            status: detail,
278                                        }));
279                                    }
280                                }
281                                crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
282                                    let preview: String = result.chars().take(300).collect();
283                                    tool_log.push(format!("[tool_result]: {}", preview));
284                                }
285                                crate::StreamEvent::Session(SessionEvent::Usage {
286                                    input_tokens, output_tokens,
287                                    cache_read_input_tokens, cache_creation_input_tokens,
288                                    cache_creation_5m, cache_creation_1h,
289                                    model: _,
290                                }) => {
291                                    total_input_tokens += input_tokens;
292                                    total_output_tokens += output_tokens;
293                                    total_cache_read += cache_read_input_tokens;
294                                    total_cache_creation += cache_creation_input_tokens;
295                                    crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
296                                    crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
297                                }
298                                crate::StreamEvent::Session(SessionEvent::Error(e)) => {
299                                    // Typed outcome from the engine — keep the
300                                    // terminal category + correlation ID in the
301                                    // subagent's failure state (spec §5.2).
302                                    return Err(format!("{} [{}]", e.message, e.category_label()));
303                                }
304                                crate::StreamEvent::Session(SessionEvent::Done) => break,
305                                _ => {}
306                            }
307                        }
308                        _ = &mut timeout_fut => {
309                            // Return partial work instead of just an error
310                            let mut partial = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
311                            if !tool_log.is_empty() {
312                                partial.push_str(&tool_log.join("\n"));
313                                partial.push('\n');
314                            }
315                            if !final_text.is_empty() {
316                                partial.push_str("\n[partial response]:\n");
317                                partial.push_str(&final_text);
318                            }
319                            return Ok(SubagentResult {
320                                text: partial,
321                                model: model_inner,
322                                input_tokens: total_input_tokens,
323                                output_tokens: total_output_tokens,
324                                cache_read: total_cache_read,
325                                cache_creation: total_cache_creation,
326                                cache_creation_5m: total_cache_5m,
327                                cache_creation_1h: total_cache_1h,
328                                tool_count,
329                                timed_out: true,
330                            });
331                        }
332                    }
333                }
334
335                Ok(SubagentResult {
336                    text: final_text,
337                    model: model_inner,
338                    input_tokens: total_input_tokens,
339                    output_tokens: total_output_tokens,
340                    cache_read: total_cache_read,
341                    cache_creation: total_cache_creation,
342                    cache_creation_5m: total_cache_5m,
343                    cache_creation_1h: total_cache_1h,
344                    tool_count,
345                    timed_out: false,
346                })
347            });
348
349                let _ = result_tx.send(result);
350            }));
351
352            if let Err(panic_info) = result {
353                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
354                    s.to_string()
355                } else if let Some(s) = panic_info.downcast_ref::<String>() {
356                    s.clone()
357                } else {
358                    "unknown panic".to_string()
359                };
360                tracing::error!("Subagent thread panicked: {}", msg);
361                // result_tx is consumed inside the closure, so we can't send here —
362                // the oneshot receiver will see a RecvError, handled below.
363            }
364        }) {
365            Ok(handle) => handle,
366            Err(_) => {
367                orchestration.rollback(&orchestration_id);
368                return Err(RuntimeError::Tool("worker thread initialization failed".into()));
369            }
370        };
371        if let Err(error) = orchestration.mark_running(&orchestration_id) {
372            drop(shutdown_tx);
373            let _ = thread_handle.join();
374            orchestration.rollback(&orchestration_id);
375            return Err(RuntimeError::Tool(error));
376        }
377
378        let result = result_rx.await;
379        let elapsed = start_time.elapsed().as_secs_f64();
380
381        drop(shutdown_tx);
382        let _ = thread_handle.join();
383
384        // One-shot workers have no separate collect call: every outcome must pass
385        // through terminal, collected, and reconciled before this tool exits.
386        if let Some(policy) = &ctx.capabilities.orchestration {
387            let terminal = match &result {
388                Ok(Ok(result)) if result.timed_out => {
389                    agent_core::orchestration::WorkerTerminal::TimedOut
390                }
391                Ok(Ok(_)) => agent_core::orchestration::WorkerTerminal::Completed,
392                Ok(Err(_)) | Err(_) => agent_core::orchestration::WorkerTerminal::Failed,
393            };
394            policy
395                .finish_one_shot(&orchestration_id, terminal)
396                .map_err(RuntimeError::Tool)?;
397        }
398
399        let log_dir = crate::config::base_dir().join("logs").join("subagents");
400        let _ = tokio::fs::create_dir_all(&log_dir).await;
401        let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
402
403        match result {
404            Ok(Ok(sa_result)) => {
405                let preview: String = sa_result.text.chars().take(120).collect();
406
407                if let Some(ref tx) = ctx.channels.tx_events {
408                    let _ = tx.send(crate::StreamEvent::Session(SessionEvent::Usage {
409                        input_tokens: sa_result.input_tokens,
410                        output_tokens: sa_result.output_tokens,
411                        cache_read_input_tokens: sa_result.cache_read,
412                        cache_creation_input_tokens: sa_result.cache_creation,
413                        // TTL split aggregated across the subagent's turns —
414                        // forwarded so the parent bills 1h writes at 2.0x, not 1.25x.
415                        cache_creation_5m: sa_result.cache_creation_5m,
416                        cache_creation_1h: sa_result.cache_creation_1h,
417                        model: Some(sa_result.model),
418                    }));
419                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
420                        subagent_id,
421                        agent_name: label.clone(),
422                        result_preview: preview,
423                        duration_secs: elapsed,
424                    }));
425                }
426
427                let log_content = format!(
428                    "# Subagent: {}\nDate: {}\nModel: {}\nTask: {}\nDuration: {:.1}s\nTokens: {}in/{}out ({}cr/{}cw)\nTools used: {}\n\n## Result\n\n{}\n",
429                    label, timestamp, params["model"].as_str().unwrap_or("sonnet"),
430                    task_preview, elapsed,
431                    sa_result.input_tokens, sa_result.output_tokens,
432                    sa_result.cache_read, sa_result.cache_creation,
433                    sa_result.tool_count, sa_result.text,
434                );
435                let log_path = log_dir.join(format!("{}-{}.md", timestamp, label));
436                let _ = tokio::fs::write(&log_path, &log_content).await;
437
438                Ok(format!("[subagent:{}] {}", label, sa_result.text))
439            }
440            Ok(Err(e)) => {
441                if let Some(ref tx) = ctx.channels.tx_events {
442                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
443                        subagent_id,
444                        agent_name: label.clone(),
445                        result_preview: format!("ERROR: {}", e),
446                        duration_secs: elapsed,
447                    }));
448                }
449                let log_path = log_dir.join(format!("{}-{}-error.md", timestamp, label));
450                let _ = tokio::fs::write(
451                    &log_path,
452                    format!(
453                        "# Subagent ERROR: {}\nTask: {}\nError: {}\n",
454                        label, task_preview, e
455                    ),
456                )
457                .await;
458                Ok(format!("[subagent:{} ERROR] {}", label, e))
459            }
460            Err(_) => {
461                if let Some(ref tx) = ctx.channels.tx_events {
462                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
463                        subagent_id,
464                        agent_name: label.clone(),
465                        result_preview: "Task panicked or dropped".to_string(),
466                        duration_secs: elapsed,
467                    }));
468                }
469                Ok(format!(
470                    "[subagent:{} ERROR] Subagent task panicked or was dropped",
471                    label
472                ))
473            }
474        }
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::tools::test_helpers::create_tool_context;
482    use crate::tools::Tool;
483    use serde_json::json;
484
485    #[test]
486    fn test_subagent_tool_schema() {
487        let tool = SubagentTool;
488        assert_eq!(tool.name(), "subagent");
489        assert!(!tool.description().is_empty());
490
491        let params = tool.parameters();
492        assert_eq!(params["type"], "object");
493        assert!(params["properties"].is_object());
494        assert!(params["required"].is_array());
495    }
496
497    #[tokio::test]
498    async fn test_subagent_blank_agent_uses_system_prompt() {
499        let tool = SubagentTool;
500        let ctx = create_tool_context();
501        let params = json!({
502            "agent": "",
503            "system_prompt": "You are a concise test subagent. Reply with only: ok",
504            "task": "Say ok",
505            "model": "anthropic/claude-sonnet-4-6",
506            "timeout": 1
507        });
508
509        let result = tool.execute(params, ctx).await;
510        assert!(
511            result.is_ok(),
512            "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
513        );
514    }
515}