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