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