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": "Wall-clock limit in seconds. Omit for the session default (config `subagent_timeout`); 0 = no limit."
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                // 0 = no limit (tokio clamps Duration::MAX to its far-future instant).
204                let timeout_fut = tokio::time::sleep(if timeout_secs == 0 {
205                    Duration::MAX
206                } else {
207                    Duration::from_secs(timeout_secs)
208                });
209                tokio::pin!(timeout_fut);
210
211                loop {
212                    tokio::select! {
213                        event = stream.next() => {
214                            let Some(event) = event else { break };
215                            match event {
216                                crate::StreamEvent::Llm(LlmEvent::Thinking(_)) => {
217                                    if let Some(ref tx) = tx_events_inner {
218                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
219                                            subagent_id,
220                                            agent_name: label_inner.clone(),
221                                            status: "💭 thinking...".to_string(),
222                                        }));
223                                    }
224                                }
225                                crate::StreamEvent::Llm(LlmEvent::ResponseStart) => {
226                                    response_baseline = (final_text.len(), tool_log.len(), tool_count);
227                                }
228                                crate::StreamEvent::Llm(LlmEvent::ResponseReset) => {
229                                    final_text.truncate(response_baseline.0);
230                                    tool_log.truncate(response_baseline.1);
231                                    tool_count = response_baseline.2;
232                                }
233                                crate::StreamEvent::Llm(LlmEvent::Text(text)) => {
234                                    final_text.push_str(&text);
235                                }
236                                crate::StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: name, .. }) => {
237                                    tool_count += 1;
238                                    if let Some(ref tx) = tx_events_inner {
239                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
240                                            subagent_id,
241                                            agent_name: label_inner.clone(),
242                                            status: format!("⚙ {} (tool #{})", name, tool_count),
243                                        }));
244                                    }
245                                }
246                                crate::StreamEvent::Llm(LlmEvent::ToolUse { tool_name, input, .. }) => {
247                                    let input_str = input.to_string();
248                                    let input_preview: String = input_str.chars().take(200).collect();
249                                    tool_log.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
250                                    // Build a rich status from the tool input
251                                    let detail = match tool_name.as_str() {
252                                        "bash" => {
253                                            let cmd = input["command"].as_str().unwrap_or("");
254                                            let preview: String = cmd.chars().take(60).collect();
255                                            format!("$ {}", preview)
256                                        }
257                                        "read" => {
258                                            let path = input["path"].as_str().unwrap_or("?");
259                                            let short = path.rsplit('/').next().unwrap_or(path);
260                                            format!("reading {}", short)
261                                        }
262                                        "write" => {
263                                            let path = input["path"].as_str().unwrap_or("?");
264                                            let short = path.rsplit('/').next().unwrap_or(path);
265                                            format!("writing {}", short)
266                                        }
267                                        "edit" => {
268                                            let path = input["path"].as_str().unwrap_or("?");
269                                            let short = path.rsplit('/').next().unwrap_or(path);
270                                            format!("editing {}", short)
271                                        }
272                                        "grep" => {
273                                            let pat = input["pattern"].as_str().unwrap_or("?");
274                                            let preview: String = pat.chars().take(30).collect();
275                                            format!("grep /{}/", preview)
276                                        }
277                                        "find" => {
278                                            let pat = input["pattern"].as_str().unwrap_or("?");
279                                            format!("find {}", pat)
280                                        }
281                                        "ls" => {
282                                            let path = input["path"].as_str().unwrap_or(".");
283                                            let short = path.rsplit('/').next().unwrap_or(path);
284                                            format!("ls {}", short)
285                                        }
286                                        "subagent" => {
287                                            let agent = input["agent"].as_str()
288                                                .filter(|s| !s.trim().is_empty());
289                                            let name = super::subagent_label(agent, input["name"].as_str());
290                                            format!("spawning {}", name)
291                                        }
292                                        other => {
293                                            // MCP or unknown tools — show tool name + first param
294                                            let short_name = if other.starts_with("ext__") {
295                                                other.splitn(3, "__").last().unwrap_or(other)
296                                            } else {
297                                                other
298                                            };
299                                            short_name.to_string()
300                                        }
301                                    };
302                                    if let Some(ref tx) = tx_events_inner {
303                                        let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentUpdate {
304                                            subagent_id,
305                                            agent_name: label_inner.clone(),
306                                            status: detail,
307                                        }));
308                                    }
309                                }
310                                crate::StreamEvent::Llm(LlmEvent::ToolResult { result, .. }) => {
311                                    let preview: String = result.chars().take(300).collect();
312                                    tool_log.push(format!("[tool_result]: {}", preview));
313                                }
314                                crate::StreamEvent::Session(SessionEvent::Usage {
315                                    input_tokens, output_tokens,
316                                    cache_read_input_tokens, cache_creation_input_tokens,
317                                    cache_creation_5m, cache_creation_1h,
318                                    model: _,
319                                }) => {
320                                    total_input_tokens += input_tokens;
321                                    total_output_tokens += output_tokens;
322                                    total_cache_read += cache_read_input_tokens;
323                                    total_cache_creation += cache_creation_input_tokens;
324                                    crate::core::rpc_dispatch::merge_split(&mut total_cache_5m, cache_creation_5m);
325                                    crate::core::rpc_dispatch::merge_split(&mut total_cache_1h, cache_creation_1h);
326                                }
327                                crate::StreamEvent::Session(SessionEvent::Error(e)) => {
328                                    // Typed outcome from the engine — keep the
329                                    // terminal category + correlation ID in the
330                                    // subagent's failure state (spec §5.2).
331                                    return Err(format!("{} [{}]", e.message, e.category_label()));
332                                }
333                                crate::StreamEvent::Session(SessionEvent::Done) => break,
334                                _ => {}
335                            }
336                        }
337                        _ = &mut timeout_fut => {
338                            // Return partial work instead of just an error
339                            let mut partial = format!("[TIMED OUT after {}s — partial results below]\n\n", timeout_secs);
340                            if !tool_log.is_empty() {
341                                partial.push_str(&tool_log.join("\n"));
342                                partial.push('\n');
343                            }
344                            if !final_text.is_empty() {
345                                partial.push_str("\n[partial response]:\n");
346                                partial.push_str(&final_text);
347                            }
348                            return Ok(SubagentResult {
349                                text: partial,
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: true,
359                            });
360                        }
361                    }
362                }
363
364                Ok(SubagentResult {
365                    text: final_text,
366                    model: model_inner,
367                    input_tokens: total_input_tokens,
368                    output_tokens: total_output_tokens,
369                    cache_read: total_cache_read,
370                    cache_creation: total_cache_creation,
371                    cache_creation_5m: total_cache_5m,
372                    cache_creation_1h: total_cache_1h,
373                    tool_count,
374                    timed_out: false,
375                })
376            });
377
378                let _ = result_tx.send(result);
379            }));
380
381            if let Err(panic_info) = result {
382                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
383                    s.to_string()
384                } else if let Some(s) = panic_info.downcast_ref::<String>() {
385                    s.clone()
386                } else {
387                    "unknown panic".to_string()
388                };
389                tracing::error!("Subagent thread panicked: {}", msg);
390                // result_tx is consumed inside the closure, so we can't send here —
391                // the oneshot receiver will see a RecvError, handled below.
392            }
393        }) {
394            Ok(handle) => handle,
395            Err(_) => {
396                orchestration.rollback(&orchestration_id);
397                return Err(RuntimeError::Tool("worker thread initialization failed".into()));
398            }
399        };
400        if let Err(error) = orchestration.mark_running(&orchestration_id) {
401            drop(shutdown_tx);
402            let _ = thread_handle.join();
403            orchestration.rollback(&orchestration_id);
404            return Err(RuntimeError::Tool(error));
405        }
406
407        let result = result_rx.await;
408        let elapsed = start_time.elapsed().as_secs_f64();
409
410        drop(shutdown_tx);
411        let _ = thread_handle.join();
412
413        // One-shot workers have no separate collect call: every outcome must pass
414        // through terminal, collected, and reconciled before this tool exits.
415        if let Some(policy) = &ctx.capabilities.orchestration {
416            let terminal = match &result {
417                Ok(Ok(result)) if result.timed_out => {
418                    agent_core::orchestration::WorkerTerminal::TimedOut
419                }
420                Ok(Ok(_)) => agent_core::orchestration::WorkerTerminal::Completed,
421                Ok(Err(_)) | Err(_) => agent_core::orchestration::WorkerTerminal::Failed,
422            };
423            policy
424                .finish_one_shot(&orchestration_id, terminal)
425                .map_err(RuntimeError::Tool)?;
426        }
427
428        let log_dir = crate::config::base_dir().join("logs").join("subagents");
429        let _ = tokio::fs::create_dir_all(&log_dir).await;
430        let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
431
432        match result {
433            Ok(Ok(sa_result)) => {
434                let preview: String = sa_result.text.chars().take(120).collect();
435
436                if let Some(ref tx) = ctx.channels.tx_events {
437                    let _ = tx.send(crate::StreamEvent::Session(SessionEvent::Usage {
438                        input_tokens: sa_result.input_tokens,
439                        output_tokens: sa_result.output_tokens,
440                        cache_read_input_tokens: sa_result.cache_read,
441                        cache_creation_input_tokens: sa_result.cache_creation,
442                        // TTL split aggregated across the subagent's turns —
443                        // forwarded so the parent bills 1h writes at 2.0x, not 1.25x.
444                        cache_creation_5m: sa_result.cache_creation_5m,
445                        cache_creation_1h: sa_result.cache_creation_1h,
446                        model: Some(sa_result.model),
447                    }));
448                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
449                        subagent_id,
450                        agent_name: label.clone(),
451                        result_preview: preview,
452                        duration_secs: elapsed,
453                    }));
454                }
455
456                let log_content = format!(
457                    "# Subagent: {}\nDate: {}\nModel: {}\nTask: {}\nDuration: {:.1}s\nTokens: {}in/{}out ({}cr/{}cw)\nTools used: {}\n\n## Result\n\n{}\n",
458                    label, timestamp, params["model"].as_str().unwrap_or("sonnet"),
459                    task_preview, elapsed,
460                    sa_result.input_tokens, sa_result.output_tokens,
461                    sa_result.cache_read, sa_result.cache_creation,
462                    sa_result.tool_count, sa_result.text,
463                );
464                let log_path = super::subagent_log_path(&log_dir, &timestamp, &label, false);
465                let _ = tokio::fs::write(&log_path, &log_content).await;
466
467                Ok(format!("[subagent:{}] {}", label, sa_result.text))
468            }
469            Ok(Err(e)) => {
470                if let Some(ref tx) = ctx.channels.tx_events {
471                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
472                        subagent_id,
473                        agent_name: label.clone(),
474                        result_preview: format!("ERROR: {}", e),
475                        duration_secs: elapsed,
476                    }));
477                }
478                let log_path = super::subagent_log_path(&log_dir, &timestamp, &label, true);
479                let _ = tokio::fs::write(
480                    &log_path,
481                    format!(
482                        "# Subagent ERROR: {}\nTask: {}\nError: {}\n",
483                        label, task_preview, e
484                    ),
485                )
486                .await;
487                Ok(format!("[subagent:{} ERROR] {}", label, e))
488            }
489            Err(_) => {
490                if let Some(ref tx) = ctx.channels.tx_events {
491                    let _ = tx.send(crate::StreamEvent::Agent(AgentEvent::SubagentDone {
492                        subagent_id,
493                        agent_name: label.clone(),
494                        result_preview: "Task panicked or dropped".to_string(),
495                        duration_secs: elapsed,
496                    }));
497                }
498                Ok(format!(
499                    "[subagent:{} ERROR] Subagent task panicked or was dropped",
500                    label
501                ))
502            }
503        }
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::tools::test_helpers::create_tool_context;
511    use crate::tools::Tool;
512    use serde_json::json;
513
514    #[test]
515    fn test_subagent_tool_schema() {
516        let tool = SubagentTool;
517        assert_eq!(tool.name(), "subagent");
518        assert!(!tool.description().is_empty());
519
520        let params = tool.parameters();
521        assert_eq!(params["type"], "object");
522        assert!(params["properties"].is_object());
523        assert!(params["required"].is_array());
524    }
525
526    #[tokio::test]
527    async fn test_subagent_blank_agent_uses_system_prompt() {
528        let tool = SubagentTool;
529        let ctx = create_tool_context();
530        let params = json!({
531            "agent": "",
532            "system_prompt": "You are a concise test subagent. Reply with only: ok",
533            "task": "Say ok",
534            "model": "anthropic/claude-sonnet-4-6",
535            "timeout": 1
536        });
537
538        let result = tool.execute(params, ctx).await;
539        assert!(
540            result.is_ok(),
541            "blank agent should not be resolved as ~/.synaps-cli/agents/.md: {result:?}"
542        );
543    }
544}