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 codex_parent_plan = ctx.capabilities.codex_parent_plan.clone();
100        let memory_backend = ctx.capabilities.memory_backend.clone();
101        let timeout_secs = params["timeout"]
102            .as_u64()
103            .unwrap_or(ctx.limits.subagent_timeout);
104
105        let label = agent_name.as_deref().unwrap_or("inline").to_string();
106        let task_preview: String = task.chars().take(80).collect();
107
108        tracing::info!(
109            "Dispatching subagent '{}' (id={}) with model {}",
110            label,
111            subagent_id,
112            model
113        );
114
115        if let Some(ref tx) = ctx.channels.tx_events {
116            let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentStart {
117                subagent_id,
118                agent_name: label.clone(),
119                task_preview: task_preview.clone(),
120            }));
121        }
122
123        let start_time = std::time::Instant::now();
124
125        let (result_tx, result_rx) =
126            tokio::sync::oneshot::channel::<std::result::Result<SubagentResult, String>>();
127        let label_inner = label.clone();
128        let model_inner = model.clone();
129        let tx_events_inner = ctx.channels.tx_events.clone();
130
131        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
132        let orchestration = ctx.capabilities.orchestration.as_ref().unwrap();
133        if let Err(error) = orchestration.mark_starting(&orchestration_id) {
134            orchestration.rollback(&orchestration_id);
135            return Err(RuntimeError::Tool(error));
136        }
137
138        let thread_handle = match std::thread::Builder::new().spawn(move || {
139            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
140                let rt = match tokio::runtime::Builder::new_current_thread()
141                    .enable_all()
142                    .build()
143                {
144                    Ok(rt) => rt,
145                    Err(_) => {
146                        let _ = result_tx.send(Err("runtime initialization failed".into()));
147                        return;
148                    }
149                };
150
151                let result = rt.block_on(async move {
152                    use futures::StreamExt;
153
154                    // Host-built worker (shared client/creds/token cache, cached
155                    // registry) or the legacy fresh runtime — see `spawn_runtime`.
156                    let mut runtime = match super::spawn_runtime().await {
157                        Ok(r) => r,
158                        Err(_) => return Err("subagent runtime initialization failed".into()),
159                    };
160
161                    // Apply subagent spawn policy: worker role, 5m cache TTL, worker
162                    // turn budget. Subagents are short-lived one-shots — paying the 1h
163                    // write premium (~2× input price) on them is unrecoverable waste
164                    // (~$0.23 per 10-spawn fan-out). (#110)
165                    super::apply_subagent_runtime_policy(&mut runtime, &crate::config::load_config(), memory_backend.as_ref());
166                    runtime.set_system_prompt(super::compose_system_prompt(system_prompt, runtime.memory_backend_is_axel()));
167                    runtime.set_model(model);
168                    super::apply_codex_worker_reasoning(&mut runtime, codex_parent_plan.as_ref());
169
170                    let cancel = crate::CancellationToken::new();
171                    let cancel_inner = cancel.clone();
172
173                    tokio::spawn(async move {
174                        let _ = shutdown_rx.await;
175                        cancel_inner.cancel();
176                    });
177
178                    let mut stream = runtime.run_stream(task, cancel).await;
179
180                let mut final_text = String::new();
181                let mut tool_count = 0u32;
182                let mut tool_log: Vec<String> = Vec::new();
183                let mut response_baseline: (usize, usize, u32) = (0, 0, 0);
184                let mut total_input_tokens = 0u64;
185                let mut total_output_tokens = 0u64;
186                let mut total_cache_read = 0u64;
187                let mut total_cache_creation = 0u64;
188                // TTL split: None only if no turn ever reported one; otherwise summed.
189                let mut total_cache_5m: Option<u64> = None;
190                let mut total_cache_1h: Option<u64> = None;
191
192                let timeout_fut = tokio::time::sleep(Duration::from_secs(timeout_secs));
193                tokio::pin!(timeout_fut);
194
195                loop {
196                    tokio::select! {
197                        event = stream.next() => {
198                            let Some(event) = event else { break };
199                            match event {
200                                crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
201                                    if let Some(ref tx) = tx_events_inner {
202                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
203                                            subagent_id,
204                                            agent_name: label_inner.clone(),
205                                            status: "💭 thinking...".to_string(),
206                                        }));
207                                    }
208                                }
209                                crate::StreamEvent::Llm(LlmEvent::ResponseStart) => {
210                                    response_baseline = (final_text.len(), tool_log.len(), tool_count);
211                                }
212                                crate::StreamEvent::Llm(LlmEvent::ResponseReset) => {
213                                    final_text.truncate(response_baseline.0);
214                                    tool_log.truncate(response_baseline.1);
215                                    tool_count = response_baseline.2;
216                                }
217                                crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
218                                    final_text.push_str(&text);
219                                }
220                                crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
221                                    tool_count += 1;
222                                    if let Some(ref tx) = tx_events_inner {
223                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
224                                            subagent_id,
225                                            agent_name: label_inner.clone(),
226                                            status: format!("⚙ {} (tool #{})", name, tool_count),
227                                        }));
228                                    }
229                                }
230                                crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
231                                    let input_str = input.to_string();
232                                    let input_preview: String = input_str.chars().take(200).collect();
233                                    tool_log.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
234                                    // Build a rich status from the tool input
235                                    let detail = match tool_name.as_str() {
236                                        "bash" => {
237                                            let cmd = input["command"].as_str().unwrap_or("");
238                                            let preview: String = cmd.chars().take(60).collect();
239                                            format!("$ {}", preview)
240                                        }
241                                        "read" => {
242                                            let path = input["path"].as_str().unwrap_or("?");
243                                            let short = path.rsplit('/').next().unwrap_or(path);
244                                            format!("reading {}", short)
245                                        }
246                                        "write" => {
247                                            let path = input["path"].as_str().unwrap_or("?");
248                                            let short = path.rsplit('/').next().unwrap_or(path);
249                                            format!("writing {}", short)
250                                        }
251                                        "edit" => {
252                                            let path = input["path"].as_str().unwrap_or("?");
253                                            let short = path.rsplit('/').next().unwrap_or(path);
254                                            format!("editing {}", short)
255                                        }
256                                        "grep" => {
257                                            let pat = input["pattern"].as_str().unwrap_or("?");
258                                            let preview: String = pat.chars().take(30).collect();
259                                            format!("grep /{}/", preview)
260                                        }
261                                        "find" => {
262                                            let pat = input["pattern"].as_str().unwrap_or("?");
263                                            format!("find {}", pat)
264                                        }
265                                        "ls" => {
266                                            let path = input["path"].as_str().unwrap_or(".");
267                                            let short = path.rsplit('/').next().unwrap_or(path);
268                                            format!("ls {}", short)
269                                        }
270                                        "subagent" => {
271                                            let name = input["agent"].as_str()
272                                                .or_else(|| input["system_prompt"].as_str().map(|s| if s.len() > 20 { "inline" } else { s }))
273                                                .unwrap_or("?");
274                                            format!("spawning {}", name)
275                                        }
276                                        other => {
277                                            // MCP or unknown tools — show tool name + first param
278                                            let short_name = if other.starts_with("ext__") {
279                                                other.splitn(3, "__").last().unwrap_or(other)
280                                            } else {
281                                                other
282                                            };
283                                            short_name.to_string()
284                                        }
285                                    };
286                                    if let Some(ref tx) = tx_events_inner {
287                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
288                                            subagent_id,
289                                            agent_name: label_inner.clone(),
290                                            status: detail,
291                                        }));
292                                    }
293                                }
294                                crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
295                                    let preview: String = result.chars().take(300).collect();
296                                    tool_log.push(format!("[tool_result]: {}", preview));
297                                }
298                                crate::StreamEvent::Session(SessionEvent::Usage {
299                                    input_tokens, output_tokens,
300                                    cache_read_input_tokens, cache_creation_input_tokens,
301                                    cache_creation_5m, cache_creation_1h,
302                                    model: _,
303                                }) => {
304                                    total_input_tokens += input_tokens;
305                                    total_output_tokens += output_tokens;
306                                    total_cache_read += cache_read_input_tokens;
307                                    total_cache_creation += cache_creation_input_tokens;
308                                    crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
309                                    crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
310                                }
311                                crate::StreamEvent::Session(SessionEvent::Error(e)) => {
312                                    // Typed outcome from the engine — keep the
313                                    // terminal category + correlation ID in the
314                                    // subagent's failure state (spec §5.2).
315                                    return Err(format!("{} [{}]", e.message, e.category_label()));
316                                }
317                                crate::StreamEvent::Session(SessionEvent::Done) => break,
318                                _ => {}
319                            }
320                        }
321                        _ = &mut timeout_fut => {
322                            // Return partial work instead of just an error
323                            let mut partial = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
324                            if !tool_log.is_empty() {
325                                partial.push_str(&tool_log.join("\n"));
326                                partial.push('\n');
327                            }
328                            if !final_text.is_empty() {
329                                partial.push_str("\n[partial response]:\n");
330                                partial.push_str(&final_text);
331                            }
332                            return Ok(SubagentResult {
333                                text: partial,
334                                model: model_inner,
335                                input_tokens: total_input_tokens,
336                                output_tokens: total_output_tokens,
337                                cache_read: total_cache_read,
338                                cache_creation: total_cache_creation,
339                                cache_creation_5m: total_cache_5m,
340                                cache_creation_1h: total_cache_1h,
341                                tool_count,
342                                timed_out: true,
343                            });
344                        }
345                    }
346                }
347
348                Ok(SubagentResult {
349                    text: final_text,
350                    model: model_inner,
351                    input_tokens: total_input_tokens,
352                    output_tokens: total_output_tokens,
353                    cache_read: total_cache_read,
354                    cache_creation: total_cache_creation,
355                    cache_creation_5m: total_cache_5m,
356                    cache_creation_1h: total_cache_1h,
357                    tool_count,
358                    timed_out: false,
359                })
360            });
361
362                let _ = result_tx.send(result);
363            }));
364
365            if let Err(panic_info) = result {
366                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
367                    s.to_string()
368                } else if let Some(s) = panic_info.downcast_ref::<String>() {
369                    s.clone()
370                } else {
371                    "unknown panic".to_string()
372                };
373                tracing::error!("Subagent thread panicked: {}", msg);
374                // result_tx is consumed inside the closure, so we can't send here —
375                // the oneshot receiver will see a RecvError, handled below.
376            }
377        }) {
378            Ok(handle) => handle,
379            Err(_) => {
380                orchestration.rollback(&orchestration_id);
381                return Err(RuntimeError::Tool("worker thread initialization failed".into()));
382            }
383        };
384        if let Err(error) = orchestration.mark_running(&orchestration_id) {
385            drop(shutdown_tx);
386            let _ = thread_handle.join();
387            orchestration.rollback(&orchestration_id);
388            return Err(RuntimeError::Tool(error));
389        }
390
391        let result = result_rx.await;
392        let elapsed = start_time.elapsed().as_secs_f64();
393
394        drop(shutdown_tx);
395        let _ = thread_handle.join();
396
397        // One-shot workers have no separate collect call: every outcome must pass
398        // through terminal, collected, and reconciled before this tool exits.
399        if let Some(policy) = &ctx.capabilities.orchestration {
400            let terminal = match &result {
401                Ok(Ok(result)) if result.timed_out => {
402                    agent_core::orchestration::WorkerTerminal::TimedOut
403                }
404                Ok(Ok(_)) => agent_core::orchestration::WorkerTerminal::Completed,
405                Ok(Err(_)) | Err(_) => agent_core::orchestration::WorkerTerminal::Failed,
406            };
407            policy
408                .finish_one_shot(&orchestration_id, terminal)
409                .map_err(RuntimeError::Tool)?;
410        }
411
412        let log_dir = crate::config::base_dir().join("logs").join("subagents");
413        let _ = tokio::fs::create_dir_all(&log_dir).await;
414        let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
415
416        match result {
417            Ok(Ok(sa_result)) => {
418                let preview: String = sa_result.text.chars().take(120).collect();
419
420                if let Some(ref tx) = ctx.channels.tx_events {
421                    let _ = tx.send(crate::StreamEvent::Session(SessionEvent::Usage {
422                        input_tokens: sa_result.input_tokens,
423                        output_tokens: sa_result.output_tokens,
424                        cache_read_input_tokens: sa_result.cache_read,
425                        cache_creation_input_tokens: sa_result.cache_creation,
426                        // TTL split aggregated across the subagent's turns —
427                        // forwarded so the parent bills 1h writes at 2.0x, not 1.25x.
428                        cache_creation_5m: sa_result.cache_creation_5m,
429                        cache_creation_1h: sa_result.cache_creation_1h,
430                        model: Some(sa_result.model),
431                    }));
432                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
433                        subagent_id,
434                        agent_name: label.clone(),
435                        result_preview: preview,
436                        duration_secs: elapsed,
437                    }));
438                }
439
440                let log_content = format!(
441                    "# Subagent: {}\nDate: {}\nModel: {}\nTask: {}\nDuration: {:.1}s\nTokens: {}in/{}out ({}cr/{}cw)\nTools used: {}\n\n## Result\n\n{}\n",
442                    label, timestamp, params["model"].as_str().unwrap_or("sonnet"),
443                    task_preview, elapsed,
444                    sa_result.input_tokens, sa_result.output_tokens,
445                    sa_result.cache_read, sa_result.cache_creation,
446                    sa_result.tool_count, sa_result.text,
447                );
448                let log_path = log_dir.join(format!("{}-{}.md", timestamp, label));
449                let _ = tokio::fs::write(&log_path, &log_content).await;
450
451                Ok(format!("[subagent:{}] {}", label, sa_result.text))
452            }
453            Ok(Err(e)) => {
454                if let Some(ref tx) = ctx.channels.tx_events {
455                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
456                        subagent_id,
457                        agent_name: label.clone(),
458                        result_preview: format!("ERROR: {}", e),
459                        duration_secs: elapsed,
460                    }));
461                }
462                let log_path = log_dir.join(format!("{}-{}-error.md", timestamp, label));
463                let _ = tokio::fs::write(
464                    &log_path,
465                    format!(
466                        "# Subagent ERROR: {}\nTask: {}\nError: {}\n",
467                        label, task_preview, e
468                    ),
469                )
470                .await;
471                Ok(format!("[subagent:{} ERROR] {}", label, e))
472            }
473            Err(_) => {
474                if let Some(ref tx) = ctx.channels.tx_events {
475                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
476                        subagent_id,
477                        agent_name: label.clone(),
478                        result_preview: "Task panicked or dropped".to_string(),
479                        duration_secs: elapsed,
480                    }));
481                }
482                Ok(format!(
483                    "[subagent:{} ERROR] Subagent task panicked or was dropped",
484                    label
485                ))
486            }
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::tools::test_helpers::create_tool_context;
495    use crate::tools::Tool;
496    use serde_json::json;
497
498    #[test]
499    fn test_subagent_tool_schema() {
500        let tool = SubagentTool;
501        assert_eq!(tool.name(), "subagent");
502        assert!(!tool.description().is_empty());
503
504        let params = tool.parameters();
505        assert_eq!(params["type"], "object");
506        assert!(params["properties"].is_object());
507        assert!(params["required"].is_array());
508    }
509
510    #[tokio::test]
511    async fn test_subagent_blank_agent_uses_system_prompt() {
512        let tool = SubagentTool;
513        let ctx = create_tool_context();
514        let params = json!({
515            "agent": "",
516            "system_prompt": "You are a concise test subagent. Reply with only: ok",
517            "task": "Say ok",
518            "model": "anthropic/claude-sonnet-4-6",
519            "timeout": 1
520        });
521
522        let result = tool.execute(params, ctx).await;
523        assert!(
524            result.is_ok(),
525            "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
526        );
527    }
528}