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