返回 DeepSeek-TUI-2026
mod.rs
根目录 / crates / tui / src / tools / subagent / mod.rs
1 //! Sub-agent spawning system.
2 //!
3 //! Provides tools to spawn background sub-agents, query their status,
4 //! and retrieve results. Sub-agents run with a filtered toolset and
5 //! inherit the workspace configuration from the main session.
6
7 use std::collections::{HashMap, VecDeque};
8 use std::fs;
9 use std::path::{Path, PathBuf};
10 use std::sync::Arc;
11 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12 use tokio::sync::{Mutex, RwLock};
13
14 use anyhow::{Result, anyhow};
15 use async_trait::async_trait;
16 use serde::{Deserialize, Serialize};
17 use serde_json::{Value, json};
18 use tokio::{sync::mpsc, task::JoinHandle};
19 use tokio_util::sync::CancellationToken;
20 use uuid::Uuid;
21
22 use crate::client::DeepSeekClient;
23 use crate::config::MAX_SUBAGENTS;
24 use crate::core::events::Event;
25 use crate::llm_client::LlmClient;
26 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Tool};
27 use crate::tools::plan::{PlanState, SharedPlanState};
28 use crate::tools::registry::{ToolRegistry, ToolRegistryBuilder};
29 use crate::tools::spec::{
30 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
31 optional_bool, optional_u64, required_str,
32 };
33 use crate::tools::todo::{SharedTodoList, TodoList};
34 use crate::utils::spawn_supervised;
35
36 pub mod mailbox;
37 #[allow(unused_imports)]
38 pub use mailbox::{Mailbox, MailboxEnvelope, MailboxMessage, MailboxReceiver};
39
40 // === Constants ===
41
42 /// Global ownership table for cache-aware resident file sub-agents (#529).
43 /// Maps file path → agent id. Agents hold a lease on a file while running;
44 /// the lease is released when the agent reaches a terminal state.
45 static RESIDENT_LEASES: std::sync::OnceLock<
46 std::sync::Mutex<std::collections::HashMap<String, String>>,
47 > = std::sync::OnceLock::new();
48
49 /// Release all resident file leases held by `agent_id`. Called when an
50 /// agent transitions to a terminal state (completed, failed, cancelled).
51 fn release_resident_leases_for(agent_id: &str) {
52 if let Some(lock) = RESIDENT_LEASES.get()
53 && let Ok(mut guard) = lock.lock()
54 {
55 guard.retain(|_, owner| owner != agent_id);
56 }
57 }
58
59 const DEFAULT_MAX_STEPS: u32 = 100;
60 const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
61 /// Per-step LLM API call timeout. Each `create_message` request must complete
62 /// within this window or the step is treated as timed out. Prevents a single
63 /// stuck API call from blocking the sub-agent indefinitely.
64 const STEP_API_TIMEOUT: Duration = Duration::from_secs(120);
65 const RESULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
66 const DEFAULT_RESULT_TIMEOUT_MS: u64 = 30_000;
67 const MIN_WAIT_TIMEOUT_MS: u64 = 10_000;
68 const MAX_RESULT_TIMEOUT_MS: u64 = 3_600_000;
69 const COMPLETED_AGENT_RETENTION: Duration = Duration::from_secs(60 * 60);
70 const SUBAGENT_STATE_SCHEMA_VERSION: u32 = 1;
71 const SUBAGENT_STATE_FILE: &str = "subagents.v1.json";
72 const SUBAGENT_RESTART_REASON: &str = "Interrupted by process restart";
73
74 const VALID_SUBAGENT_TYPES: &str = "general, explore, plan, review, implementer, verifier, custom, \
75 worker, explorer, awaiter, default, implement, builder, verify, validator, tester";
76 /// Whale species names rotated through `whale_nickname_for_index` to label
77 /// sub-agents in the UI. English and Simplified-Chinese names are interleaved
78 /// so any newly spawned agent has a roughly even chance of either — the goal
79 /// is friendly variety, not a strict locale match.
80 pub const WHALE_NICKNAMES: &[&str] = &[
81 "Blue",
82 "蓝鲸",
83 "Humpback",
84 "座头鲸",
85 "Sperm",
86 "抹香鲸",
87 "Fin",
88 "长须鲸",
89 "Sei",
90 "塞鲸",
91 "Bryde's",
92 "布氏鲸",
93 "Minke",
94 "小须鲸",
95 "Antarctic Minke",
96 "南极小须鲸",
97 "Gray",
98 "灰鲸",
99 "Bowhead",
100 "弓头鲸",
101 "North Atlantic Right",
102 "北大西洋露脊鲸",
103 "North Pacific Right",
104 "北太平洋露脊鲸",
105 "Southern Right",
106 "南露脊鲸",
107 "Beluga",
108 "白鲸",
109 "Narwhal",
110 "独角鲸",
111 "Orca",
112 "虎鲸",
113 "Pilot",
114 "领航鲸",
115 "False Killer",
116 "伪虎鲸",
117 "Pygmy Killer",
118 "小虎鲸",
119 "Melon-headed",
120 "瓜头鲸",
121 "Beaked",
122 "喙鲸",
123 "Cuvier's Beaked",
124 "柯氏喙鲸",
125 "Baird's Beaked",
126 "贝氏喙鲸",
127 "Blainville's Beaked",
128 "柏氏喙鲸",
129 ];
130
131 /// Removal version for deprecated tool aliases.
132 const DEPRECATION_REMOVAL_VERSION: &str = "0.8.0";
133
134 #[must_use]
135 pub fn whale_nickname_for_index(index: usize) -> String {
136 let base = WHALE_NICKNAMES[index % WHALE_NICKNAMES.len()];
137 if index < WHALE_NICKNAMES.len() {
138 base.to_string()
139 } else {
140 format!("{base} {}", index / WHALE_NICKNAMES.len() + 1)
141 }
142 }
143
144 // === Deprecation helpers ===
145
146 /// Wrap a `ToolResult` with a `_deprecation` block in its metadata.
147 ///
148 /// Applied exclusively on alias paths (not on canonical tool names) so the
149 /// model can detect and migrate away from the old name before removal in
150 /// v`DEPRECATION_REMOVAL_VERSION`.
151 ///
152 /// The `_deprecation` key is merged into any existing metadata so other
153 /// metadata (e.g. `status`, `timed_out`) is preserved unchanged.
154 fn wrap_with_deprecation_notice(
155 mut result: ToolResult,
156 this_tool: &str,
157 use_instead: &str,
158 ) -> ToolResult {
159 tracing::warn!(
160 "Deprecated tool '{}' invoked — use '{}' instead (removal: v{})",
161 this_tool,
162 use_instead,
163 DEPRECATION_REMOVAL_VERSION,
164 );
165
166 let notice = json!({
167 "_deprecation": {
168 "this_tool": this_tool,
169 "use_instead": use_instead,
170 "removed_in": DEPRECATION_REMOVAL_VERSION,
171 "message": format!(
172 "Tool '{}' is deprecated; switch to '{}' before v{}.",
173 this_tool, use_instead, DEPRECATION_REMOVAL_VERSION
174 )
175 }
176 });
177
178 result.metadata = Some(match result.metadata.take() {
179 Some(Value::Object(mut map)) => {
180 if let Value::Object(notice_map) = notice {
181 map.extend(notice_map);
182 }
183 Value::Object(map)
184 }
185 Some(other) => {
186 // Existing metadata was not an object — keep it as-is and add
187 // the deprecation notice as a sibling under a wrapper.
188 json!({ "_deprecation": notice["_deprecation"].clone(), "_original_metadata": other })
189 }
190 None => notice,
191 });
192
193 result
194 }
195
196 // === Types ===
197
198 /// Assignment metadata for sub-agent orchestration.
199 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200 pub struct SubAgentAssignment {
201 pub objective: String,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub role: Option<String>,
204 }
205
206 impl SubAgentAssignment {
207 fn new(objective: String, role: Option<String>) -> Self {
208 Self { objective, role }
209 }
210 }
211
212 /// Sub-agent execution types with specialized behavior and tool access.
213 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
214 #[serde(rename_all = "snake_case")]
215 pub enum SubAgentType {
216 /// General purpose - full tool access for multi-step tasks.
217 #[default]
218 General,
219 /// Fast exploration - read-only tools for codebase search.
220 Explore,
221 /// Planning - analysis tools only for architectural planning.
222 Plan,
223 /// Code review - read + analysis tools.
224 Review,
225 /// Implementation — focused on writing / patching code to satisfy
226 /// a specific change. Distinct from `General` in that the prompt
227 /// posture pushes hard on landing the change cleanly with the
228 /// minimum surrounding edit (#404).
229 Implementer,
230 /// Verification — focused on running the test suite or other
231 /// validation gates and reporting pass/fail with evidence.
232 /// Distinct from `Review` in that Review reads code and grades it;
233 /// Verifier *runs* tests and reports the outcome (#404).
234 Verifier,
235 /// Custom tool access defined at spawn time.
236 Custom,
237 }
238
239 impl SubAgentType {
240 /// Parse a sub-agent type from user input.
241 #[must_use]
242 pub fn from_str(s: &str) -> Option<Self> {
243 match s.to_lowercase().as_str() {
244 "general" | "general-purpose" | "general_purpose" | "worker" | "default" => {
245 Some(Self::General)
246 }
247 "explore" | "exploration" | "explorer" => Some(Self::Explore),
248 "plan" | "planning" | "awaiter" => Some(Self::Plan),
249 "review" | "code-review" | "code_review" | "reviewer" => Some(Self::Review),
250 "implementer" | "implement" | "implementation" | "builder" => Some(Self::Implementer),
251 "verifier" | "verify" | "verification" | "validator" | "tester" => Some(Self::Verifier),
252 "custom" => Some(Self::Custom),
253 _ => None,
254 }
255 }
256
257 #[must_use]
258 pub fn as_str(&self) -> &'static str {
259 match self {
260 Self::General => "general",
261 Self::Explore => "explore",
262 Self::Plan => "plan",
263 Self::Review => "review",
264 Self::Implementer => "implementer",
265 Self::Verifier => "verifier",
266 Self::Custom => "custom",
267 }
268 }
269
270 /// Get the system prompt for this agent type.
271 #[must_use]
272 pub fn system_prompt(&self) -> String {
273 match self {
274 Self::General => GENERAL_AGENT_PROMPT.to_string(),
275 Self::Explore => EXPLORE_AGENT_PROMPT.to_string(),
276 Self::Plan => PLAN_AGENT_PROMPT.to_string(),
277 Self::Review => REVIEW_AGENT_PROMPT.to_string(),
278 Self::Implementer => IMPLEMENTER_AGENT_PROMPT.to_string(),
279 Self::Verifier => VERIFIER_AGENT_PROMPT.to_string(),
280 Self::Custom => CUSTOM_AGENT_PROMPT.to_string(),
281 }
282 }
283
284 /// Get the default allowed tools for this agent type.
285 ///
286 /// **Deprecated since v0.6.6.** Default sub-agents now inherit the full
287 /// parent registry; the per-type allowlist is advisory only. Pass an explicit
288 /// `allowed_tools` array for narrow Custom roles instead.
289 #[must_use]
290 #[deprecated(
291 since = "0.6.6",
292 note = "Default sub-agents inherit the full parent registry; pass an explicit allowed_tools list only for narrow Custom roles."
293 )]
294 pub fn allowed_tools(&self) -> Vec<&'static str> {
295 match self {
296 Self::General => vec![
297 "list_dir",
298 "read_file",
299 "write_file",
300 "edit_file",
301 "apply_patch",
302 "grep_files",
303 "file_search",
304 "web.run",
305 "web_search",
306 "exec_shell",
307 "exec_shell_wait",
308 "exec_shell_interact",
309 "exec_wait",
310 "exec_interact",
311 "note",
312 "checklist_write",
313 "checklist_add",
314 "checklist_update",
315 "checklist_list",
316 "todo_write",
317 "todo_add",
318 "todo_update",
319 "todo_list",
320 "update_plan",
321 ],
322 Self::Explore => vec![
323 "list_dir",
324 "read_file",
325 "grep_files",
326 "file_search",
327 "web.run",
328 "web_search",
329 "exec_shell",
330 "exec_shell_wait",
331 "exec_shell_interact",
332 "exec_wait",
333 "exec_interact",
334 ],
335 Self::Plan => vec![
336 "list_dir",
337 "read_file",
338 "grep_files",
339 "file_search",
340 "web.run",
341 "note",
342 "update_plan",
343 "checklist_write",
344 "checklist_add",
345 "checklist_update",
346 "checklist_list",
347 "todo_write",
348 "todo_add",
349 "todo_update",
350 "todo_list",
351 ],
352 Self::Review => vec!["list_dir", "read_file", "grep_files", "file_search", "note"],
353 Self::Implementer => vec![
354 "list_dir",
355 "read_file",
356 "write_file",
357 "edit_file",
358 "apply_patch",
359 "grep_files",
360 "file_search",
361 "exec_shell",
362 "exec_shell_wait",
363 "exec_shell_interact",
364 "exec_wait",
365 "exec_interact",
366 "note",
367 "checklist_write",
368 "checklist_add",
369 "checklist_update",
370 "checklist_list",
371 "todo_write",
372 "todo_add",
373 "todo_update",
374 "todo_list",
375 "update_plan",
376 ],
377 Self::Verifier => vec![
378 "list_dir",
379 "read_file",
380 "grep_files",
381 "file_search",
382 "exec_shell",
383 "exec_shell_wait",
384 "exec_shell_interact",
385 "exec_wait",
386 "exec_interact",
387 "run_tests",
388 "diagnostics",
389 "note",
390 ],
391 Self::Custom => vec![], // Must be provided by caller.
392 }
393 }
394 }
395
396 /// Status of a sub-agent execution.
397 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
398 pub enum SubAgentStatus {
399 Running,
400 Completed,
401 Interrupted(String),
402 Failed(String),
403 Cancelled,
404 }
405
406 /// Snapshot of sub-agent state for tool results.
407 #[derive(Debug, Clone, Serialize, Deserialize)]
408 pub struct SubAgentResult {
409 pub agent_id: String,
410 pub agent_type: SubAgentType,
411 pub assignment: SubAgentAssignment,
412 #[serde(default)]
413 pub model: String,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub nickname: Option<String>,
416 pub status: SubAgentStatus,
417 pub result: Option<String>,
418 pub steps_taken: u32,
419 pub duration_ms: u64,
420 /// `true` when this agent was loaded from a prior-session persisted
421 /// state file rather than spawned in the current session (#405).
422 /// Lets `agent_list` filter out historical noise by default while
423 /// keeping the records reachable via `include_archived=true`.
424 #[serde(default, skip_serializing_if = "is_false")]
425 pub from_prior_session: bool,
426 }
427
428 fn is_false(b: &bool) -> bool {
429 !*b
430 }
431
432 #[derive(Debug, Clone, Default)]
433 pub(crate) struct SubAgentSpawnOptions {
434 pub model: Option<String>,
435 pub nickname: Option<String>,
436 }
437
438 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
439 enum WaitMode {
440 Any,
441 All,
442 }
443
444 impl WaitMode {
445 fn from_str(value: &str) -> Option<Self> {
446 match value.to_ascii_lowercase().as_str() {
447 "any" | "first" => Some(Self::Any),
448 "all" => Some(Self::All),
449 _ => None,
450 }
451 }
452
453 fn as_str(self) -> &'static str {
454 match self {
455 Self::Any => "any",
456 Self::All => "all",
457 }
458 }
459
460 fn condition_met(self, snapshots: &[SubAgentResult]) -> bool {
461 match self {
462 Self::Any => snapshots
463 .iter()
464 .any(|snapshot| snapshot.status != SubAgentStatus::Running),
465 Self::All => snapshots
466 .iter()
467 .all(|snapshot| snapshot.status != SubAgentStatus::Running),
468 }
469 }
470 }
471
472 #[derive(Debug, Clone)]
473 struct SubAgentInput {
474 text: String,
475 interrupt: bool,
476 }
477
478 #[derive(Debug, Clone)]
479 struct SpawnRequest {
480 prompt: String,
481 agent_type: SubAgentType,
482 assignment: SubAgentAssignment,
483 allowed_tools: Option<Vec<String>>,
484 model: Option<String>,
485 /// Optional working directory for the child. Must canonicalize to a
486 /// path inside the parent's workspace. Used to dispatch parallel work
487 /// into separate git worktrees: parent runs `git worktree add` first,
488 /// then spawns children with the worktree path as `cwd`.
489 cwd: Option<PathBuf>,
490 /// Optional file path for cache-aware resident mode (#529). When set,
491 /// the child's prompt is prefixed with the file contents for prefix-cache
492 /// locality. A global ownership table prevents two agents from holding
493 /// a resident lease on the same file simultaneously.
494 resident_file: Option<String>,
495 }
496
497 #[derive(Debug, Clone)]
498 struct AssignRequest {
499 agent_id: String,
500 objective: Option<String>,
501 role: Option<String>,
502 message: Option<String>,
503 interrupt: bool,
504 }
505
506 #[derive(Debug, Clone, Serialize, Deserialize)]
507 struct PersistedSubAgent {
508 id: String,
509 agent_type: SubAgentType,
510 prompt: String,
511 assignment: SubAgentAssignment,
512 #[serde(default)]
513 model: String,
514 #[serde(default)]
515 nickname: Option<String>,
516 status: SubAgentStatus,
517 result: Option<String>,
518 steps_taken: u32,
519 duration_ms: u64,
520 allowed_tools: Vec<String>,
521 updated_at_ms: u64,
522 /// Stable id of the manager / process boot that spawned this agent
523 /// (#405). Lets a fresh manager filter out agents that were
524 /// persisted by a prior session. Optional with `#[serde(default)]`
525 /// for backward compatibility — older records lack the field and
526 /// load with an empty string, which the manager treats as
527 /// "from_prior_session" because it can't match any current id.
528 #[serde(default)]
529 session_boot_id: String,
530 }
531
532 #[derive(Debug, Clone, Serialize, Deserialize)]
533 struct PersistedSubAgentState {
534 schema_version: u32,
535 agents: Vec<PersistedSubAgent>,
536 }
537
538 impl Default for PersistedSubAgentState {
539 fn default() -> Self {
540 Self {
541 schema_version: SUBAGENT_STATE_SCHEMA_VERSION,
542 agents: Vec::new(),
543 }
544 }
545 }
546
547 /// Default cap on sub-agent recursion depth. Override via
548 /// `[runtime] max_spawn_depth = N` in `~/.deepseek/config.toml`.
549 pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 3;
550
551 /// Terminal-state notification emitted to the engine's parent turn loop
552 /// when one of its direct children finishes (issue #756). Carries the
553 /// already-rendered `<deepseek:subagent.done>` sentinel that the model
554 /// expects in the transcript per `prompts/base.md`.
555 #[derive(Debug, Clone)]
556 pub struct SubAgentCompletion {
557 /// The completing child's agent id. Held for routing/logging — the
558 /// engine's turn loop does not currently key on it (it just injects
559 /// the payload), but downstream tooling and tests need the field.
560 #[allow(dead_code)]
561 pub agent_id: String,
562 /// Human summary on line 1, sentinel on line 2. Same payload shape as
563 /// `Event::AgentComplete::result`.
564 pub payload: String,
565 }
566
567 /// Runtime configuration for spawning sub-agents.
568 ///
569 /// Carries everything a child needs to (a) build its own tool registry —
570 /// including the manager so grandchildren can spawn — and (b) cooperate
571 /// with the rest of the spawn tree on cancellation and depth cap.
572 #[derive(Clone)]
573 pub struct SubAgentRuntime {
574 pub client: DeepSeekClient,
575 pub model: String,
576 pub auto_model: bool,
577 pub reasoning_effort: Option<String>,
578 pub reasoning_effort_auto: bool,
579 pub role_models: HashMap<String, String>,
580 pub context: ToolContext,
581 pub allow_shell: bool,
582 pub event_tx: Option<mpsc::Sender<Event>>,
583 /// Manager handle so children can recurse via `agent_spawn`. All agents
584 /// at every depth share the same manager.
585 pub manager: SharedSubAgentManager,
586 /// Depth in the spawn tree. 0 = top-level user turn; 1 = direct child;
587 /// etc. Children clone the parent runtime and increment this on spawn.
588 pub spawn_depth: u32,
589 /// Hard cap on recursion depth. A child whose `spawn_depth + 1` would
590 /// exceed this is rejected at the spawn entry. Use `>` (strictly
591 /// greater than) so equality is allowed — matches codex's pattern.
592 pub max_spawn_depth: u32,
593 /// Cooperative cancellation token. Children derive a child_token() from
594 /// the parent so cancelling the root cascades down.
595 pub cancel_token: CancellationToken,
596 /// Structured progress / lifecycle stream. Cloned across children so the
597 /// whole spawn tree publishes into one ordered, fan-out-able mailbox.
598 /// `None` only when no consumer is wired (legacy entry points / tests).
599 pub mailbox: Option<Mailbox>,
600 /// Wakeup channel for the engine's parent turn loop (issue #756). Only
601 /// the engine's direct children fire on this — propagated to descendants
602 /// via clone but gated to `spawn_depth == 1` at the send site so the
603 /// parent isn't flooded with grandchild completions it didn't directly
604 /// orchestrate. `None` when no consumer is wired (tests / legacy paths).
605 pub parent_completion_tx: Option<mpsc::UnboundedSender<SubAgentCompletion>>,
606 }
607
608 impl SubAgentRuntime {
609 /// Create a top-level runtime configuration for sub-agent execution.
610 /// Use this from the engine when constructing the runtime that the
611 /// parent's tool registry passes through. Children should derive their
612 /// runtime via `Self::child_runtime` instead.
613 #[must_use]
614 pub fn new(
615 client: DeepSeekClient,
616 model: String,
617 context: ToolContext,
618 allow_shell: bool,
619 event_tx: Option<mpsc::Sender<Event>>,
620 manager: SharedSubAgentManager,
621 ) -> Self {
622 Self {
623 client,
624 model,
625 auto_model: false,
626 reasoning_effort: None,
627 reasoning_effort_auto: false,
628 role_models: HashMap::new(),
629 context,
630 allow_shell,
631 event_tx,
632 manager,
633 spawn_depth: 0,
634 max_spawn_depth: DEFAULT_MAX_SPAWN_DEPTH,
635 cancel_token: CancellationToken::new(),
636 mailbox: None,
637 parent_completion_tx: None,
638 }
639 }
640
641 /// Attach the wakeup channel so the engine's parent turn loop can resume
642 /// when this runtime's direct children finish (issue #756). The channel
643 /// is propagated to descendants via clone, but only `spawn_depth == 1`
644 /// agents fire on it — see `run_subagent_task`.
645 #[must_use]
646 pub fn with_parent_completion_tx(
647 mut self,
648 tx: mpsc::UnboundedSender<SubAgentCompletion>,
649 ) -> Self {
650 self.parent_completion_tx = Some(tx);
651 self
652 }
653
654 /// Attach a `Mailbox` so this runtime (and every descendant — children
655 /// clone it) publishes structured `MailboxMessage` envelopes alongside
656 /// the legacy `Event` stream. Pair with [`Self::with_cancel_token`] when
657 /// you want close-as-cancel to propagate the same way.
658 #[must_use]
659 #[allow(dead_code)] // wired by #128 (in-transcript cards) when it lands.
660 pub fn with_mailbox(mut self, mailbox: Mailbox) -> Self {
661 self.mailbox = Some(mailbox);
662 self
663 }
664
665 /// Replace the cancellation token (e.g. when the engine constructs the
666 /// runtime alongside a mailbox bound to the same token).
667 #[must_use]
668 #[allow(dead_code)] // wired by #128 alongside `with_mailbox`.
669 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
670 self.cancel_token = token;
671 self
672 }
673
674 /// Override the maximum spawn depth (default `DEFAULT_MAX_SPAWN_DEPTH`).
675 /// Used by config wiring (`[runtime] max_spawn_depth = N`) and tests.
676 #[must_use]
677 #[allow(dead_code)]
678 pub fn with_max_spawn_depth(mut self, max: u32) -> Self {
679 self.max_spawn_depth = max;
680 self
681 }
682
683 /// Attach raw role/type model overrides. Values are intentionally
684 /// validated at spawn time so bad config fails before a partial spawn.
685 #[must_use]
686 pub fn with_role_models(mut self, role_models: HashMap<String, String>) -> Self {
687 self.role_models = role_models;
688 self
689 }
690
691 /// Preserve whether the parent session is using per-turn model routing.
692 #[must_use]
693 pub fn with_auto_model(mut self, auto_model: bool) -> Self {
694 self.auto_model = auto_model;
695 self
696 }
697
698 /// Preserve the parent's thinking configuration. `reasoning_effort_auto`
699 /// stays true even when the parent turn itself was sent with a concrete
700 /// flash-router recommendation, so children can resolve their own tier.
701 #[must_use]
702 pub fn with_reasoning_effort(
703 mut self,
704 reasoning_effort: Option<String>,
705 reasoning_effort_auto: bool,
706 ) -> Self {
707 self.reasoning_effort = reasoning_effort;
708 self.reasoning_effort_auto = reasoning_effort_auto;
709 self
710 }
711
712 /// Return a child runtime that is deliberately detached from the parent
713 /// turn cancellation token. Background sub-agents should keep running when
714 /// the parent turn is cancelled; explicit agent cancellation still
715 /// aborts their task handles through the manager.
716 #[must_use]
717 pub fn background_runtime(&self) -> Self {
718 let mut runtime = self.child_runtime();
719 let token = CancellationToken::new();
720 runtime.cancel_token = token.clone();
721 runtime.context.cancel_token = Some(token);
722 runtime
723 }
724
725 /// Build a child runtime cloning this one, incrementing `spawn_depth`,
726 /// deriving a child cancellation token, and forcing `auto_approve` on
727 /// the child's `ToolContext`. Used at spawn entry to construct the
728 /// runtime the new sub-agent will see.
729 ///
730 /// The `auto_approve` override is deliberate: spawning IS the approval.
731 /// Per-tool prompts inside a child would break delegation, so children
732 /// inherit a YOLO-equivalent context regardless of the parent's mode.
733 /// The workspace boundary + sandbox profile still apply.
734 #[must_use]
735 pub fn child_runtime(&self) -> Self {
736 let mut child_context = self.context.clone();
737 child_context.auto_approve = true;
738 Self {
739 client: self.client.clone(),
740 model: self.model.clone(),
741 auto_model: self.auto_model,
742 reasoning_effort: self.reasoning_effort.clone(),
743 reasoning_effort_auto: self.reasoning_effort_auto,
744 role_models: self.role_models.clone(),
745 context: child_context,
746 allow_shell: self.allow_shell,
747 event_tx: self.event_tx.clone(),
748 manager: self.manager.clone(),
749 spawn_depth: self.spawn_depth + 1,
750 max_spawn_depth: self.max_spawn_depth,
751 cancel_token: self.cancel_token.child_token(),
752 mailbox: self.mailbox.clone(),
753 parent_completion_tx: self.parent_completion_tx.clone(),
754 }
755 }
756
757 /// Whether the next spawn would exceed the depth cap.
758 #[must_use]
759 pub fn would_exceed_depth(&self) -> bool {
760 self.spawn_depth + 1 > self.max_spawn_depth
761 }
762 }
763
764 /// A running sub-agent instance.
765 pub struct SubAgent {
766 pub id: String,
767 pub agent_type: SubAgentType,
768 pub prompt: String,
769 pub assignment: SubAgentAssignment,
770 pub model: String,
771 pub nickname: Option<String>,
772 pub status: SubAgentStatus,
773 pub result: Option<String>,
774 pub steps_taken: u32,
775 pub started_at: Instant,
776 /// `None` = full registry inheritance (v0.6.6 default).
777 /// `Some(list)` = explicit narrow allowlist (Custom agents, legacy).
778 pub allowed_tools: Option<Vec<String>>,
779 /// Stable id of the manager that spawned this agent (#405). Compared
780 /// against the manager's `current_session_boot_id` to classify the
781 /// agent as in-session vs prior-session at list time.
782 pub session_boot_id: String,
783 input_tx: Option<mpsc::UnboundedSender<SubAgentInput>>,
784 task_handle: Option<JoinHandle<()>>,
785 }
786
787 impl SubAgent {
788 /// Create a new sub-agent.
789 #[allow(clippy::too_many_arguments)]
790 fn new(
791 agent_type: SubAgentType,
792 prompt: String,
793 assignment: SubAgentAssignment,
794 model: String,
795 nickname: Option<String>,
796 allowed_tools: Option<Vec<String>>,
797 input_tx: mpsc::UnboundedSender<SubAgentInput>,
798 session_boot_id: String,
799 ) -> Self {
800 let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
801
802 Self {
803 id,
804 agent_type,
805 prompt,
806 assignment,
807 model,
808 nickname,
809 status: SubAgentStatus::Running,
810 result: None,
811 steps_taken: 0,
812 started_at: Instant::now(),
813 allowed_tools,
814 session_boot_id,
815 input_tx: Some(input_tx),
816 task_handle: None,
817 }
818 }
819
820 /// Get a snapshot of the current state.
821 #[must_use]
822 pub fn snapshot(&self) -> SubAgentResult {
823 SubAgentResult {
824 agent_id: self.id.clone(),
825 agent_type: self.agent_type.clone(),
826 assignment: self.assignment.clone(),
827 model: self.model.clone(),
828 nickname: self.nickname.clone(),
829 status: self.status.clone(),
830 result: self.result.clone(),
831 steps_taken: self.steps_taken,
832 duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
833 // Snapshots from the agent itself don't know the manager's
834 // current boot id, so default to false. The manager fills
835 // this in when it produces a snapshot via its own
836 // `snapshot_for_listing` helper (#405).
837 from_prior_session: false,
838 }
839 }
840 }
841
842 /// Manager for active sub-agents.
843 pub struct SubAgentManager {
844 agents: HashMap<String, SubAgent>,
845 #[allow(dead_code)] // Stored for future workspace-scoped operations
846 workspace: PathBuf,
847 state_path: Option<PathBuf>,
848 max_steps: u32,
849 max_agents: usize,
850 /// Stable id assigned at manager construction (#405). Stamped on
851 /// every agent the manager spawns; agents loaded from the
852 /// persisted state file carry whatever id the prior session
853 /// stamped (or empty for pre-#405 records). The manager classifies
854 /// agents whose `session_boot_id` doesn't match this value as
855 /// "from prior session" so `agent_list` can hide them by default.
856 current_session_boot_id: String,
857 }
858
859 impl SubAgentManager {
860 /// Create a new manager for sub-agents.
861 #[must_use]
862 pub fn new(workspace: PathBuf, max_agents: usize) -> Self {
863 Self {
864 agents: HashMap::new(),
865 workspace,
866 state_path: None,
867 max_steps: DEFAULT_MAX_STEPS,
868 max_agents,
869 // Fresh boot id per manager. Used by #405 to classify
870 // re-loaded persisted agents as "prior session".
871 current_session_boot_id: format!("boot_{}", &Uuid::new_v4().to_string()[..12]),
872 }
873 }
874
875 /// Return the boot id this manager stamps on agents it spawns.
876 /// Exposed for tests; internal callers use the field directly.
877 #[cfg(test)]
878 pub fn session_boot_id(&self) -> &str {
879 &self.current_session_boot_id
880 }
881
882 /// Classify an agent by its `session_boot_id`: `true` when the
883 /// agent was either (a) loaded from disk with no id, or (b) carries
884 /// a different id than the manager's current boot. Filters
885 /// `agent_list` output by default (#405).
886 fn is_from_prior_session(&self, agent: &SubAgent) -> bool {
887 agent.session_boot_id.is_empty() || agent.session_boot_id != self.current_session_boot_id
888 }
889
890 #[must_use]
891 fn with_state_path(mut self, path: PathBuf) -> Self {
892 self.state_path = Some(path);
893 self
894 }
895
896 fn persist_state(&self) -> Result<()> {
897 let Some(path) = self.state_path.as_ref() else {
898 return Ok(());
899 };
900 let now_ms = epoch_millis_now();
901 let mut agents = Vec::with_capacity(self.agents.len());
902 for agent in self.agents.values() {
903 agents.push(PersistedSubAgent {
904 id: agent.id.clone(),
905 agent_type: agent.agent_type.clone(),
906 prompt: agent.prompt.clone(),
907 assignment: agent.assignment.clone(),
908 model: agent.model.clone(),
909 nickname: agent.nickname.clone(),
910 status: agent.status.clone(),
911 result: agent.result.clone(),
912 steps_taken: agent.steps_taken,
913 duration_ms: u64::try_from(agent.started_at.elapsed().as_millis())
914 .unwrap_or(u64::MAX),
915 // Backward-compat: Vec on disk. None → empty vec; Some(list) → list.
916 // Reload converts empty vec back to None (full inheritance).
917 allowed_tools: agent.allowed_tools.clone().unwrap_or_default(),
918 updated_at_ms: now_ms,
919 session_boot_id: agent.session_boot_id.clone(),
920 });
921 }
922 agents.sort_by(|a, b| a.id.cmp(&b.id));
923
924 let payload = PersistedSubAgentState {
925 schema_version: SUBAGENT_STATE_SCHEMA_VERSION,
926 agents,
927 };
928 write_json_atomic(path, &payload)
929 }
930
931 fn persist_state_best_effort(&self) {
932 if let Err(err) = self.persist_state() {
933 eprintln!("Failed to persist sub-agent state: {err}");
934 }
935 }
936
937 fn load_state(&mut self) -> Result<()> {
938 let Some(path) = self.state_path.as_ref() else {
939 return Ok(());
940 };
941 if !path.exists() {
942 return Ok(());
943 }
944
945 let raw = fs::read_to_string(path)?;
946 let state = serde_json::from_str::<PersistedSubAgentState>(&raw)?;
947 if state.schema_version != SUBAGENT_STATE_SCHEMA_VERSION {
948 return Err(anyhow!(
949 "Unsupported sub-agent state schema {}",
950 state.schema_version
951 ));
952 }
953
954 self.agents.clear();
955 for persisted in state.agents {
956 let mut status = persisted.status;
957 if matches!(status, SubAgentStatus::Running) {
958 status = SubAgentStatus::Interrupted(SUBAGENT_RESTART_REASON.to_string());
959 }
960
961 let started_at = instant_from_duration(Duration::from_millis(persisted.duration_ms));
962 // Empty vec on disk → None (full inheritance, v0.6.6 default).
963 // Non-empty vec → Some(list) (preserves narrow scope from older sessions).
964 let allowed_tools = if persisted.allowed_tools.is_empty() {
965 None
966 } else {
967 Some(persisted.allowed_tools)
968 };
969 let agent = SubAgent {
970 id: persisted.id.clone(),
971 agent_type: persisted.agent_type,
972 prompt: persisted.prompt,
973 assignment: persisted.assignment,
974 model: if persisted.model.is_empty() {
975 "unknown".to_string()
976 } else {
977 persisted.model
978 },
979 nickname: persisted.nickname,
980 status,
981 result: persisted.result,
982 steps_taken: persisted.steps_taken,
983 started_at,
984 allowed_tools,
985 // Empty string when loading pre-#405 records; the
986 // manager treats that the same as a non-matching id —
987 // i.e. agent classified as prior-session.
988 session_boot_id: persisted.session_boot_id,
989 input_tx: None,
990 task_handle: None,
991 };
992 self.agents.insert(persisted.id, agent);
993 }
994
995 Ok(())
996 }
997
998 /// Count running agents.
999 pub fn running_count(&self) -> usize {
1000 self.agents
1001 .values()
1002 .filter(|agent| {
1003 // Exclude non-running statuses
1004 if agent.status != SubAgentStatus::Running {
1005 return false;
1006 }
1007 // Exclude persisted agents with no task_handle (they're not actually running)
1008 let Some(handle) = agent.task_handle.as_ref() else {
1009 return false;
1010 };
1011 // Exclude agents whose task has finished (status will be updated to Completed shortly)
1012 !handle.is_finished()
1013 })
1014 .count()
1015 }
1016
1017 /// Spawn a new background sub-agent.
1018 pub fn spawn_background(
1019 &mut self,
1020 manager_handle: SharedSubAgentManager,
1021 runtime: SubAgentRuntime,
1022 agent_type: SubAgentType,
1023 prompt: String,
1024 allowed_tools: Option<Vec<String>>,
1025 ) -> Result<SubAgentResult> {
1026 self.spawn_background_with_assignment(
1027 manager_handle,
1028 runtime,
1029 agent_type,
1030 prompt.clone(),
1031 SubAgentAssignment::new(prompt, None),
1032 allowed_tools,
1033 )
1034 }
1035
1036 /// Spawn a new background sub-agent with explicit assignment metadata.
1037 pub fn spawn_background_with_assignment(
1038 &mut self,
1039 manager_handle: SharedSubAgentManager,
1040 runtime: SubAgentRuntime,
1041 agent_type: SubAgentType,
1042 prompt: String,
1043 assignment: SubAgentAssignment,
1044 allowed_tools: Option<Vec<String>>,
1045 ) -> Result<SubAgentResult> {
1046 self.spawn_background_with_assignment_options(
1047 manager_handle,
1048 runtime,
1049 agent_type,
1050 prompt,
1051 assignment,
1052 allowed_tools,
1053 SubAgentSpawnOptions::default(),
1054 )
1055 }
1056
1057 /// Spawn a new background sub-agent with explicit assignment and display
1058 /// metadata.
1059 #[allow(clippy::too_many_arguments)]
1060 pub(crate) fn spawn_background_with_assignment_options(
1061 &mut self,
1062 manager_handle: SharedSubAgentManager,
1063 mut runtime: SubAgentRuntime,
1064 agent_type: SubAgentType,
1065 prompt: String,
1066 assignment: SubAgentAssignment,
1067 allowed_tools: Option<Vec<String>>,
1068 options: SubAgentSpawnOptions,
1069 ) -> Result<SubAgentResult> {
1070 self.cleanup(COMPLETED_AGENT_RETENTION);
1071
1072 if self.running_count() >= self.max_agents {
1073 return Err(anyhow!(
1074 "Sub-agent limit reached (max {}, running {}). Cancel, close, or wait for an existing agent to finish. Consider issuing multiple tool calls in one turn (the dispatcher runs them in parallel) for parallel one-shot work.",
1075 self.max_agents,
1076 self.running_count()
1077 ));
1078 }
1079
1080 if let Some(model) = options.model.as_deref() {
1081 runtime.model = model.to_string();
1082 }
1083 let effective_model = runtime.model.clone();
1084 let nickname = options
1085 .nickname
1086 .or_else(|| Some(whale_nickname_for_index(self.agents.len())));
1087 let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?;
1088 let (input_tx, input_rx) = mpsc::unbounded_channel();
1089 let mut agent = SubAgent::new(
1090 agent_type.clone(),
1091 prompt.clone(),
1092 assignment.clone(),
1093 effective_model,
1094 nickname,
1095 tools.clone(),
1096 input_tx,
1097 self.current_session_boot_id.clone(),
1098 );
1099 let agent_id = agent.id.clone();
1100 let started_at = agent.started_at;
1101 let max_steps = self.max_steps;
1102
1103 if let Some(event_tx) = runtime.event_tx.clone() {
1104 let _ = event_tx.try_send(Event::AgentSpawned {
1105 id: agent_id.clone(),
1106 prompt: prompt.clone(),
1107 });
1108 }
1109
1110 let task = SubAgentTask {
1111 manager_handle,
1112 runtime,
1113 agent_id: agent_id.clone(),
1114 agent_type,
1115 prompt,
1116 assignment,
1117 allowed_tools: tools,
1118 started_at,
1119 max_steps,
1120 input_rx,
1121 };
1122 let handle = spawn_supervised(
1123 "subagent-task",
1124 std::panic::Location::caller(),
1125 run_subagent_task(task),
1126 );
1127 agent.task_handle = Some(handle);
1128 self.agents.insert(agent_id.clone(), agent);
1129 self.persist_state_best_effort();
1130
1131 Ok(self
1132 .agents
1133 .get(&agent_id)
1134 .expect("agent should exist after spawn")
1135 .snapshot())
1136 }
1137
1138 /// Get the current snapshot for an agent.
1139 pub fn get_result(&self, agent_id: &str) -> Result<SubAgentResult> {
1140 let agent = self
1141 .agents
1142 .get(agent_id)
1143 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
1144 Ok(agent.snapshot())
1145 }
1146
1147 /// Cancel a running sub-agent.
1148 pub fn cancel(&mut self, agent_id: &str) -> Result<SubAgentResult> {
1149 let (snapshot, changed) = {
1150 let agent = self
1151 .agents
1152 .get_mut(agent_id)
1153 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
1154
1155 let mut changed = false;
1156 if agent.status == SubAgentStatus::Running {
1157 agent.status = SubAgentStatus::Cancelled;
1158 release_resident_leases_for(&agent.id);
1159 if let Some(handle) = agent.task_handle.take() {
1160 handle.abort();
1161 }
1162 changed = true;
1163 }
1164 (agent.snapshot(), changed)
1165 };
1166
1167 if changed {
1168 self.persist_state_best_effort();
1169 }
1170 Ok(snapshot)
1171 }
1172
1173 /// Resume a non-running sub-agent by restarting it with the original assignment.
1174 pub fn resume(
1175 &mut self,
1176 manager_handle: SharedSubAgentManager,
1177 runtime: SubAgentRuntime,
1178 agent_id: &str,
1179 ) -> Result<SubAgentResult> {
1180 let status = self
1181 .agents
1182 .get(agent_id)
1183 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?
1184 .status
1185 .clone();
1186
1187 if status == SubAgentStatus::Running {
1188 let agent = self
1189 .agents
1190 .get(agent_id)
1191 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
1192 return Ok(agent.snapshot());
1193 }
1194
1195 if self.running_count() >= self.max_agents {
1196 return Err(anyhow!(
1197 "Sub-agent limit reached (max {}, running {}). Close or wait for an existing agent before resuming. Consider issuing multiple tool calls in one turn (the dispatcher runs them in parallel) for parallel one-shot work.",
1198 self.max_agents,
1199 self.running_count()
1200 ));
1201 }
1202
1203 let snapshot = {
1204 let agent = self
1205 .agents
1206 .get_mut(agent_id)
1207 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
1208
1209 let (input_tx, input_rx) = mpsc::unbounded_channel();
1210 let restarted_at = Instant::now();
1211 let mut restart_runtime = runtime.clone();
1212 if !agent.model.trim().is_empty() && agent.model != "unknown" {
1213 restart_runtime.model.clone_from(&agent.model);
1214 }
1215 let task = SubAgentTask {
1216 manager_handle,
1217 runtime: restart_runtime,
1218 agent_id: agent.id.clone(),
1219 agent_type: agent.agent_type.clone(),
1220 prompt: agent.prompt.clone(),
1221 assignment: agent.assignment.clone(),
1222 allowed_tools: agent.allowed_tools.clone(),
1223 started_at: restarted_at,
1224 max_steps: self.max_steps,
1225 input_rx,
1226 };
1227 let handle = spawn_supervised(
1228 "subagent-task-resume",
1229 std::panic::Location::caller(),
1230 run_subagent_task(task),
1231 );
1232
1233 agent.status = SubAgentStatus::Running;
1234 agent.result = None;
1235 agent.steps_taken = 0;
1236 agent.started_at = restarted_at;
1237 agent.input_tx = Some(input_tx);
1238 agent.task_handle = Some(handle);
1239
1240 if let Some(event_tx) = runtime.event_tx {
1241 let _ = event_tx.try_send(Event::AgentSpawned {
1242 id: agent.id.clone(),
1243 prompt: format!("(resumed) {}", agent.prompt),
1244 });
1245 }
1246
1247 agent.snapshot()
1248 };
1249 self.persist_state_best_effort();
1250
1251 Ok(snapshot)
1252 }
1253
1254 /// Send input to a running sub-agent.
1255 pub fn send_input(&mut self, agent_id: &str, text: String, interrupt: bool) -> Result<()> {
1256 let agent = self
1257 .agents
1258 .get_mut(agent_id)
1259 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
1260
1261 if agent.status != SubAgentStatus::Running {
1262 return Err(anyhow!("Agent {agent_id} is not running"));
1263 }
1264
1265 let tx = agent
1266 .input_tx
1267 .as_ref()
1268 .ok_or_else(|| anyhow!("Agent {agent_id} cannot accept input"))?;
1269
1270 tx.send(SubAgentInput { text, interrupt })
1271 .map_err(|_| anyhow!("Failed to send input to agent {agent_id}"))?;
1272
1273 Ok(())
1274 }
1275
1276 /// Update assignment metadata and optionally send immediate guidance.
1277 pub fn assign(
1278 &mut self,
1279 agent_id: &str,
1280 objective: Option<String>,
1281 role: Option<String>,
1282 message: Option<String>,
1283 interrupt: bool,
1284 ) -> Result<SubAgentResult> {
1285 if objective.is_none() && role.is_none() && message.is_none() {
1286 return Err(anyhow!(
1287 "Provide at least one of objective, role, or message"
1288 ));
1289 }
1290
1291 if message.is_some() {
1292 let status = self
1293 .agents
1294 .get(agent_id)
1295 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?
1296 .status
1297 .clone();
1298 if status != SubAgentStatus::Running {
1299 return Err(anyhow!(
1300 "Agent {agent_id} is not running; cannot deliver assignment message"
1301 ));
1302 }
1303 }
1304
1305 let mut changed = false;
1306 let (input_tx, payload) = {
1307 let agent = self
1308 .agents
1309 .get_mut(agent_id)
1310 .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
1311
1312 let mut assignment_lines = Vec::new();
1313 if let Some(objective) = objective {
1314 let objective = objective.trim();
1315 if objective.is_empty() {
1316 return Err(anyhow!("objective cannot be empty"));
1317 }
1318 if agent.assignment.objective != objective {
1319 agent.assignment.objective = objective.to_string();
1320 changed = true;
1321 }
1322 assignment_lines.push(format!("- objective: {}", agent.assignment.objective));
1323 }
1324
1325 if let Some(role) = role {
1326 let normalized = normalize_role_alias(&role)
1327 .ok_or_else(|| {
1328 anyhow!(
1329 "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default"
1330 )
1331 })?
1332 .to_string();
1333 if agent.assignment.role.as_deref() != Some(normalized.as_str()) {
1334 agent.assignment.role = Some(normalized.clone());
1335 changed = true;
1336 }
1337 assignment_lines.push(format!("- role: {normalized}"));
1338 }
1339
1340 let mut payload_parts = Vec::new();
1341 if !assignment_lines.is_empty() && agent.status == SubAgentStatus::Running {
1342 payload_parts.push(format!(
1343 "Assignment updated:\n{}",
1344 assignment_lines.join("\n")
1345 ));
1346 }
1347 if let Some(message) = message {
1348 let message = message.trim();
1349 if message.is_empty() {
1350 return Err(anyhow!("message cannot be empty"));
1351 }
1352 payload_parts.push(format!("Coordinator note:\n{message}"));
1353 }
1354
1355 let payload = if payload_parts.is_empty() {
1356 None
1357 } else {
1358 Some(payload_parts.join("\n\n"))
1359 };
1360
1361 (agent.input_tx.clone(), payload)
1362 };
1363
1364 if let Some(payload) = payload {
1365 let tx = input_tx
1366 .ok_or_else(|| anyhow!("Agent {agent_id} cannot accept assignment input"))?;
1367 tx.send(SubAgentInput {
1368 text: payload,
1369 interrupt,
1370 })
1371 .map_err(|_| anyhow!("Failed to send assignment to agent {agent_id}"))?;
1372 }
1373
1374 if changed {
1375 self.persist_state_best_effort();
1376 }
1377
1378 self.get_result(agent_id)
1379 }
1380
1381 /// List all agents and their status.
1382 #[must_use]
1383 /// Snapshot a single agent and tag it with the manager's
1384 /// classification. The bare `SubAgent::snapshot` defaults
1385 /// `from_prior_session` to `false`; only the manager knows the
1386 /// matching boot id, so listing goes through here.
1387 fn snapshot_for_listing(&self, agent: &SubAgent) -> SubAgentResult {
1388 let mut snap = agent.snapshot();
1389 snap.from_prior_session = self.is_from_prior_session(agent);
1390 snap
1391 }
1392
1393 /// List all agents currently held by the manager, regardless of
1394 /// session origin. Use [`Self::list_filtered`] in user-facing tool
1395 /// paths so prior-session agents stay hidden by default (#405).
1396 pub fn list(&self) -> Vec<SubAgentResult> {
1397 self.agents
1398 .values()
1399 .map(|agent| self.snapshot_for_listing(agent))
1400 .collect()
1401 }
1402
1403 /// List agents respecting the session-boundary filter (#405).
1404 ///
1405 /// `include_archived = false` (the default for `agent_list`) drops
1406 /// any prior-session agent that is no longer running. Prior-session
1407 /// agents that are still `Running` (e.g. interrupted by a process
1408 /// restart) stay visible — they may matter for ongoing recovery.
1409 ///
1410 /// `include_archived = true` returns everything, with the
1411 /// `from_prior_session` flag on each `SubAgentResult` so the model
1412 /// can tell active and archived apart at a glance.
1413 pub fn list_filtered(&self, include_archived: bool) -> Vec<SubAgentResult> {
1414 self.agents
1415 .values()
1416 .filter(|agent| {
1417 if include_archived {
1418 return true;
1419 }
1420 if agent.status == SubAgentStatus::Running {
1421 return true;
1422 }
1423 !self.is_from_prior_session(agent)
1424 })
1425 .map(|agent| self.snapshot_for_listing(agent))
1426 .collect()
1427 }
1428
1429 /// Clean up completed agents older than the given duration.
1430 pub fn cleanup(&mut self, max_age: Duration) {
1431 let before = self.agents.len();
1432 self.agents.retain(|_, agent| {
1433 if agent.status == SubAgentStatus::Running {
1434 true
1435 } else {
1436 agent.started_at.elapsed() < max_age
1437 }
1438 });
1439 if self.agents.len() != before {
1440 self.persist_state_best_effort();
1441 }
1442 }
1443
1444 fn update_from_result(&mut self, agent_id: &str, result: SubAgentResult) {
1445 let mut changed = false;
1446 if let Some(agent) = self.agents.get_mut(agent_id) {
1447 agent.status = result.status;
1448 agent.assignment = result.assignment;
1449 agent.result = result.result;
1450 agent.steps_taken = result.steps_taken;
1451 agent.task_handle = None;
1452 changed = true;
1453 }
1454 if changed {
1455 self.persist_state_best_effort();
1456 }
1457 }
1458
1459 fn update_failed(&mut self, agent_id: &str, error: String) {
1460 let mut changed = false;
1461 if let Some(agent) = self.agents.get_mut(agent_id) {
1462 agent.status = SubAgentStatus::Failed(error);
1463 release_resident_leases_for(agent_id);
1464 agent.task_handle = None;
1465 changed = true;
1466 }
1467 if changed {
1468 self.persist_state_best_effort();
1469 }
1470 }
1471 }
1472
1473 /// Thread-safe wrapper for `SubAgentManager`.
1474 pub type SharedSubAgentManager = Arc<RwLock<SubAgentManager>>;
1475
1476 fn default_state_path(workspace: &Path) -> PathBuf {
1477 workspace
1478 .join(".deepseek")
1479 .join("state")
1480 .join(SUBAGENT_STATE_FILE)
1481 }
1482
1483 fn epoch_millis_now() -> u64 {
1484 match SystemTime::now().duration_since(UNIX_EPOCH) {
1485 Ok(duration) => u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
1486 Err(_) => 0,
1487 }
1488 }
1489
1490 fn instant_from_duration(duration: Duration) -> Instant {
1491 Instant::now()
1492 .checked_sub(duration)
1493 .unwrap_or_else(Instant::now)
1494 }
1495
1496 fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
1497 if let Some(parent) = path.parent() {
1498 fs::create_dir_all(parent)?;
1499 }
1500 let payload = serde_json::to_string_pretty(value)?;
1501 let tmp_path = path.with_extension("tmp");
1502 fs::write(&tmp_path, payload)?;
1503 fs::rename(tmp_path, path)?;
1504 Ok(())
1505 }
1506
1507 /// Create a shared sub-agent manager with a configurable limit.
1508 #[must_use]
1509 pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> SharedSubAgentManager {
1510 let max_agents = max_agents.clamp(1, MAX_SUBAGENTS);
1511 let state_path = default_state_path(&workspace);
1512 let mut manager = SubAgentManager::new(workspace, max_agents).with_state_path(state_path);
1513 if let Err(err) = manager.load_state() {
1514 eprintln!("Failed to load sub-agent state: {err}");
1515 }
1516 Arc::new(RwLock::new(manager))
1517 }
1518
1519 // === Tool Implementations ===
1520
1521 /// Tool to spawn a background sub-agent.
1522 pub struct AgentSpawnTool {
1523 manager: SharedSubAgentManager,
1524 runtime: SubAgentRuntime,
1525 name: &'static str,
1526 }
1527
1528 impl AgentSpawnTool {
1529 /// Create a new spawn tool.
1530 #[must_use]
1531 pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
1532 Self::with_name(manager, runtime, "agent_spawn")
1533 }
1534
1535 /// Create a new spawn tool with a custom tool name alias.
1536 #[must_use]
1537 pub fn with_name(
1538 manager: SharedSubAgentManager,
1539 runtime: SubAgentRuntime,
1540 name: &'static str,
1541 ) -> Self {
1542 Self {
1543 manager,
1544 runtime,
1545 name,
1546 }
1547 }
1548 }
1549
1550 #[async_trait]
1551 impl ToolSpec for AgentSpawnTool {
1552 fn name(&self) -> &'static str {
1553 self.name
1554 }
1555
1556 fn description(&self) -> &'static str {
1557 "Spawn a background sub-agent for a focused task. Returns an agent_id immediately; \
1558 follow with agent_result to retrieve the final result. Default cap of 10 concurrent \
1559 sub-agents (configurable via `[subagents].max_concurrent`); each is a full sub-agent \
1560 loop, so cancel or wait if you hit the cap. For parallel one-shot LLM queries, just \
1561 emit multiple tool calls in one turn — the dispatcher runs them in parallel."
1562 }
1563
1564 fn input_schema(&self) -> Value {
1565 json!({
1566 "type": "object",
1567 "properties": {
1568 "prompt": {
1569 "type": "string",
1570 "description": "Task description for the sub-agent"
1571 },
1572 "message": {
1573 "type": "string",
1574 "description": "Alias for prompt"
1575 },
1576 "objective": {
1577 "type": "string",
1578 "description": "Alias for prompt"
1579 },
1580 "items": {
1581 "type": "array",
1582 "description": "Structured input items (text, mention, skill, local_image, image)",
1583 "items": {
1584 "type": "object"
1585 }
1586 },
1587 "type": {
1588 "type": "string",
1589 "description": "Sub-agent type: general, explore, plan, review, implementer, verifier, custom. See docs/SUBAGENTS.md for posture per role."
1590 },
1591 "agent_type": {
1592 "type": "string",
1593 "description": "Alias for type"
1594 },
1595 "agent_name": {
1596 "type": "string",
1597 "description": "Alias for type"
1598 },
1599 "role": {
1600 "type": "string",
1601 "description": "Role alias: worker, explorer, awaiter, default"
1602 },
1603 "agent_role": {
1604 "type": "string",
1605 "description": "Alias for role"
1606 },
1607 "allowed_tools": {
1608 "type": "array",
1609 "items": { "type": "string" },
1610 "description": "Explicit tool allowlist (required for custom type). Default behavior is full registry inheritance from the parent."
1611 },
1612 "model": {
1613 "type": "string",
1614 "description": "Optional DeepSeek model id for this child. Explicit model wins over role/type defaults; omit to inherit."
1615 },
1616 "cwd": {
1617 "type": "string",
1618 "description": "Optional working directory for the child. Must be inside the parent's workspace (use a relative path or an absolute path under the workspace root). Used for the parallel-worktree pattern: parent runs `git worktree add .worktrees/feature-x ...` then spawns the child with `cwd: \".worktrees/feature-x\"`."
1619 },
1620 "resident_file": {
1621 "type": "string",
1622 "description": "Optional file path for cache-aware resident mode. When set, the child's system prefix is augmented with the full contents of this file so DeepSeek's prefix cache stays warm across follow-up send_input calls. Only one agent may hold a resident lease on a given file at a time — a second spawn with the same path receives a conflict warning in the result."
1623 }
1624 }
1625 })
1626 }
1627
1628 fn capabilities(&self) -> Vec<ToolCapability> {
1629 vec![
1630 ToolCapability::ExecutesCode,
1631 ToolCapability::RequiresApproval,
1632 ]
1633 }
1634
1635 fn approval_requirement(&self) -> ApprovalRequirement {
1636 ApprovalRequirement::Required
1637 }
1638
1639 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
1640 let spawn_request = parse_spawn_request(&input)?;
1641
1642 // Depth cap: reject before locking the manager so we don't introduce
1643 // unnecessary contention. Mirrors codex's pattern (allow-equal at the
1644 // boundary; reject when `next > max`).
1645 if self.runtime.would_exceed_depth() {
1646 return Err(ToolError::execution_failed(format!(
1647 "Sub-agent depth limit reached (current depth {}, max {}). \
1648 Increase via [runtime] max_spawn_depth in config.toml.",
1649 self.runtime.spawn_depth, self.runtime.max_spawn_depth
1650 )));
1651 }
1652
1653 // Validate cwd if supplied: must canonicalize inside the parent
1654 // workspace. Catches accidents like `cwd: "/etc"`.
1655 let validated_cwd = if let Some(requested_cwd) = spawn_request.cwd.as_ref() {
1656 let parent_workspace = &self.runtime.context.workspace;
1657 let resolved = if requested_cwd.is_absolute() {
1658 requested_cwd.clone()
1659 } else {
1660 parent_workspace.join(requested_cwd)
1661 };
1662 let canonical = resolved.canonicalize().map_err(|e| {
1663 ToolError::invalid_input(format!(
1664 "Invalid cwd '{}': {e} (path may not exist yet — create the worktree first)",
1665 requested_cwd.display()
1666 ))
1667 })?;
1668 let workspace_canonical = parent_workspace
1669 .canonicalize()
1670 .unwrap_or_else(|_| parent_workspace.clone());
1671 if !canonical.starts_with(&workspace_canonical) {
1672 return Err(ToolError::invalid_input(format!(
1673 "cwd must be inside the parent workspace: {} is not under {}",
1674 canonical.display(),
1675 workspace_canonical.display()
1676 )));
1677 }
1678 Some(canonical)
1679 } else {
1680 None
1681 };
1682
1683 // Derive the child's runtime as a durable background job: it keeps
1684 // its own cancellation token, forces auto_approve, and optionally
1685 // overrides cwd if the caller passed one (used for the parallel-
1686 // worktree pattern).
1687 let mut child_runtime = self.runtime.background_runtime();
1688 if let Some(cwd) = validated_cwd {
1689 child_runtime.context.workspace = cwd;
1690 }
1691 let configured_model = match spawn_request.model.clone() {
1692 Some(model) => Some(model),
1693 None => configured_model_for_role_or_type(
1694 &self.runtime,
1695 spawn_request.assignment.role.as_deref(),
1696 &spawn_request.agent_type,
1697 )?,
1698 };
1699
1700 // Cache-aware resident mode (#529): prepend file contents to the prompt
1701 // so the child's prefix is byte-stable for DeepSeek prefix caching.
1702 let (effective_prompt, resident_conflict) =
1703 if let Some(ref file_path) = spawn_request.resident_file {
1704 let abs_path = if std::path::Path::new(file_path).is_absolute() {
1705 std::path::PathBuf::from(file_path)
1706 } else {
1707 self.runtime.context.workspace.join(file_path)
1708 };
1709 let file_contents = std::fs::read_to_string(&abs_path)
1710 .unwrap_or_else(|e| format!("<!-- resident_file read error: {e} -->"));
1711 let prefixed = format!(
1712 "<!-- resident_file: {file_path} -->\n```\n{file_contents}\n```\n\n{}",
1713 spawn_request.prompt
1714 );
1715 // Check ownership (best-effort, non-blocking).
1716 let conflict = {
1717 let leases = RESIDENT_LEASES
1718 .get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
1719 let mut guard = leases.lock().unwrap_or_else(|p| p.into_inner());
1720 if let Some(owner) = guard.get(file_path) {
1721 Some(format!(
1722 "Warning: agent {owner} already holds a resident lease on {file_path}"
1723 ))
1724 } else {
1725 guard.insert(file_path.clone(), "pending".to_string());
1726 None
1727 }
1728 };
1729 (prefixed, conflict)
1730 } else {
1731 (spawn_request.prompt, None)
1732 };
1733
1734 let route =
1735 resolve_subagent_assignment_route(&self.runtime, configured_model, &effective_prompt)
1736 .await;
1737 child_runtime.model = route.model.clone();
1738 child_runtime.reasoning_effort = route.reasoning_effort.clone();
1739 child_runtime.reasoning_effort_auto = false;
1740 let effective_model = route.model;
1741
1742 let mut manager = self.manager.write().await;
1743
1744 let result = manager
1745 .spawn_background_with_assignment_options(
1746 Arc::clone(&self.manager),
1747 child_runtime,
1748 spawn_request.agent_type,
1749 effective_prompt,
1750 spawn_request.assignment,
1751 spawn_request.allowed_tools,
1752 SubAgentSpawnOptions {
1753 model: Some(effective_model),
1754 nickname: None,
1755 },
1756 )
1757 .map_err(|e| ToolError::execution_failed(format!("Failed to spawn sub-agent: {e}")))?;
1758
1759 // Replace the "pending" lease placeholder with the real agent id now that
1760 // the manager has assigned one. Without this, `release_resident_leases_for`
1761 // (which matches by agent id at terminal-state transitions) can never find
1762 // the entry — leases would stay stamped as "pending" forever, defeating the
1763 // release machinery added in #660.
1764 if let Some(ref file_path) = spawn_request.resident_file
1765 && let Some(lock) = RESIDENT_LEASES.get()
1766 && let Ok(mut guard) = lock.lock()
1767 && let Some(owner) = guard.get_mut(file_path)
1768 && owner == "pending"
1769 {
1770 *owner = result.agent_id.clone();
1771 }
1772
1773 let mut tool_result = if self.name == "spawn_agent" {
1774 let mut payload = json!({
1775 "agent_id": result.agent_id.clone(),
1776 "nickname": result.nickname.clone(),
1777 "model": result.model.clone()
1778 });
1779 if let Some(ref warning) = resident_conflict {
1780 payload["resident_conflict"] = json!(warning);
1781 }
1782 ToolResult::json(&payload).map_err(|e| ToolError::execution_failed(e.to_string()))?
1783 } else {
1784 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?
1785 };
1786 if result.status == SubAgentStatus::Running {
1787 if self.name == "spawn_agent" {
1788 tool_result.metadata = Some(json!({
1789 "status": "Running",
1790 "snapshot": result
1791 }));
1792 } else {
1793 tool_result.metadata = Some(json!({ "status": "Running" }));
1794 }
1795 }
1796 // Annotate alias invocations with a deprecation notice so the model
1797 // can migrate to the canonical name before removal in v0.8.0.
1798 if self.name == "spawn_agent" {
1799 tool_result = wrap_with_deprecation_notice(tool_result, "spawn_agent", "agent_spawn");
1800 }
1801 Ok(tool_result)
1802 }
1803 }
1804
1805 /// Tool to fetch a sub-agent's result.
1806 pub struct AgentResultTool {
1807 manager: SharedSubAgentManager,
1808 }
1809
1810 impl AgentResultTool {
1811 /// Create a new result tool.
1812 #[must_use]
1813 pub fn new(manager: SharedSubAgentManager) -> Self {
1814 Self { manager }
1815 }
1816 }
1817
1818 #[async_trait]
1819 impl ToolSpec for AgentResultTool {
1820 fn name(&self) -> &'static str {
1821 "agent_result"
1822 }
1823
1824 fn description(&self) -> &'static str {
1825 "Get the latest status or final result for a sub-agent. Set `block: true` to wait until the \
1826 agent reaches a terminal state (respects `timeout_ms`)."
1827 }
1828
1829 fn input_schema(&self) -> Value {
1830 json!({
1831 "type": "object",
1832 "properties": {
1833 "agent_id": {
1834 "type": "string",
1835 "description": "ID returned by agent_spawn"
1836 },
1837 "id": {
1838 "type": "string",
1839 "description": "Alias for agent_id"
1840 },
1841 "block": {
1842 "type": "boolean",
1843 "description": "Wait for completion (default: false)"
1844 },
1845 "timeout_ms": {
1846 "type": "integer",
1847 "description": "Max wait time in milliseconds (default: 30000, clamped to 1000-3600000)"
1848 }
1849 }
1850 })
1851 }
1852
1853 fn capabilities(&self) -> Vec<ToolCapability> {
1854 vec![ToolCapability::ReadOnly]
1855 }
1856
1857 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
1858 let agent_id = input
1859 .get("agent_id")
1860 .or_else(|| input.get("id"))
1861 .and_then(|v| v.as_str())
1862 .ok_or_else(|| ToolError::missing_field("agent_id"))?;
1863 let block = optional_bool(&input, "block", false);
1864 let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_RESULT_TIMEOUT_MS)
1865 .clamp(1000, MAX_RESULT_TIMEOUT_MS);
1866
1867 let (result, timed_out) = if block {
1868 wait_for_result(&self.manager, agent_id, Duration::from_millis(timeout_ms)).await?
1869 } else {
1870 let manager = self.manager.read().await;
1871 (
1872 manager
1873 .get_result(agent_id)
1874 .map_err(|e| ToolError::execution_failed(e.to_string()))?,
1875 false,
1876 )
1877 };
1878
1879 let mut tool_result =
1880 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
1881 if timed_out {
1882 tool_result.metadata = Some(json!({
1883 "status": "TimedOut",
1884 "timed_out": true,
1885 "timeout_ms": timeout_ms
1886 }));
1887 } else if result.status == SubAgentStatus::Running {
1888 tool_result.metadata = Some(json!({ "status": "Running" }));
1889 }
1890 Ok(tool_result)
1891 }
1892 }
1893
1894 /// Tool to cancel a sub-agent.
1895 pub struct AgentCancelTool {
1896 manager: SharedSubAgentManager,
1897 }
1898
1899 impl AgentCancelTool {
1900 /// Create a new cancel tool.
1901 #[must_use]
1902 pub fn new(manager: SharedSubAgentManager) -> Self {
1903 Self { manager }
1904 }
1905 }
1906
1907 #[async_trait]
1908 impl ToolSpec for AgentCancelTool {
1909 fn name(&self) -> &'static str {
1910 "agent_cancel"
1911 }
1912
1913 fn description(&self) -> &'static str {
1914 "Cancel a running sub-agent. Returns the final snapshot with the cancelled status."
1915 }
1916
1917 fn input_schema(&self) -> Value {
1918 json!({
1919 "type": "object",
1920 "properties": {
1921 "agent_id": {
1922 "type": "string",
1923 "description": "ID returned by agent_spawn"
1924 }
1925 },
1926 "required": ["agent_id"]
1927 })
1928 }
1929
1930 fn capabilities(&self) -> Vec<ToolCapability> {
1931 vec![
1932 ToolCapability::ExecutesCode,
1933 ToolCapability::RequiresApproval,
1934 ]
1935 }
1936
1937 fn approval_requirement(&self) -> ApprovalRequirement {
1938 ApprovalRequirement::Required
1939 }
1940
1941 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
1942 let agent_id = required_str(&input, "agent_id")?;
1943 let mut manager = self.manager.write().await;
1944 let result = manager
1945 .cancel(agent_id)
1946 .map_err(|e| ToolError::execution_failed(format!("Failed to cancel sub-agent: {e}")))?;
1947
1948 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
1949 }
1950 }
1951
1952 /// Tool to list all sub-agents.
1953 pub struct AgentListTool {
1954 manager: SharedSubAgentManager,
1955 }
1956
1957 /// Tool to close a running sub-agent (alias for cancel).
1958 pub struct AgentCloseTool {
1959 manager: SharedSubAgentManager,
1960 }
1961
1962 impl AgentCloseTool {
1963 /// Create a new close tool.
1964 #[must_use]
1965 pub fn new(manager: SharedSubAgentManager) -> Self {
1966 Self { manager }
1967 }
1968 }
1969
1970 #[async_trait]
1971 impl ToolSpec for AgentCloseTool {
1972 fn name(&self) -> &'static str {
1973 "close_agent"
1974 }
1975
1976 fn description(&self) -> &'static str {
1977 "Close a running sub-agent. Alias for agent_cancel."
1978 }
1979
1980 fn input_schema(&self) -> Value {
1981 json!({
1982 "type": "object",
1983 "properties": {
1984 "id": {
1985 "type": "string",
1986 "description": "Agent id returned by agent_spawn"
1987 },
1988 "agent_id": {
1989 "type": "string",
1990 "description": "Alias for id"
1991 }
1992 }
1993 })
1994 }
1995
1996 fn capabilities(&self) -> Vec<ToolCapability> {
1997 vec![
1998 ToolCapability::ExecutesCode,
1999 ToolCapability::RequiresApproval,
2000 ]
2001 }
2002
2003 fn approval_requirement(&self) -> ApprovalRequirement {
2004 ApprovalRequirement::Required
2005 }
2006
2007 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
2008 let agent_id = input
2009 .get("id")
2010 .or_else(|| input.get("agent_id"))
2011 .and_then(|v| v.as_str())
2012 .ok_or_else(|| ToolError::missing_field("id"))?;
2013 let mut manager = self.manager.write().await;
2014 let result = manager
2015 .cancel(agent_id)
2016 .map_err(|e| ToolError::execution_failed(format!("Failed to close sub-agent: {e}")))?;
2017 let tool_result =
2018 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
2019 Ok(wrap_with_deprecation_notice(
2020 tool_result,
2021 "close_agent",
2022 "agent_cancel",
2023 ))
2024 }
2025 }
2026
2027 /// Tool to resume an existing sub-agent.
2028 pub struct AgentResumeTool {
2029 manager: SharedSubAgentManager,
2030 runtime: SubAgentRuntime,
2031 }
2032
2033 impl AgentResumeTool {
2034 /// Create a new resume tool.
2035 #[must_use]
2036 pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
2037 Self { manager, runtime }
2038 }
2039 }
2040
2041 #[async_trait]
2042 impl ToolSpec for AgentResumeTool {
2043 fn name(&self) -> &'static str {
2044 "resume_agent"
2045 }
2046
2047 fn description(&self) -> &'static str {
2048 "Resume a previously closed or completed sub-agent by restarting its assignment."
2049 }
2050
2051 fn input_schema(&self) -> Value {
2052 json!({
2053 "type": "object",
2054 "properties": {
2055 "id": {
2056 "type": "string",
2057 "description": "Agent id to resume"
2058 },
2059 "agent_id": {
2060 "type": "string",
2061 "description": "Alias for id"
2062 }
2063 }
2064 })
2065 }
2066
2067 fn capabilities(&self) -> Vec<ToolCapability> {
2068 vec![
2069 ToolCapability::ExecutesCode,
2070 ToolCapability::RequiresApproval,
2071 ]
2072 }
2073
2074 fn approval_requirement(&self) -> ApprovalRequirement {
2075 ApprovalRequirement::Required
2076 }
2077
2078 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
2079 let agent_id = input
2080 .get("id")
2081 .or_else(|| input.get("agent_id"))
2082 .and_then(|v| v.as_str())
2083 .ok_or_else(|| ToolError::missing_field("id"))?;
2084 let mut manager = self.manager.write().await;
2085 let result = manager
2086 .resume(Arc::clone(&self.manager), self.runtime.clone(), agent_id)
2087 .map_err(|e| ToolError::execution_failed(format!("Failed to resume sub-agent: {e}")))?;
2088 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
2089 }
2090 }
2091
2092 impl AgentListTool {
2093 /// Create a new list tool.
2094 #[must_use]
2095 pub fn new(manager: SharedSubAgentManager) -> Self {
2096 Self { manager }
2097 }
2098 }
2099
2100 #[async_trait]
2101 impl ToolSpec for AgentListTool {
2102 fn name(&self) -> &'static str {
2103 "agent_list"
2104 }
2105
2106 fn description(&self) -> &'static str {
2107 "List sub-agents from the current session with their status, type, assignment, steps, \
2108 and duration. Pass `include_archived=true` to also see agents that were spawned in a \
2109 prior session (e.g. before the TUI restarted) and persisted on disk; those carry \
2110 `from_prior_session: true` in the result. Default is the current-session view because \
2111 prior-session agents almost never matter for the live turn."
2112 }
2113
2114 fn input_schema(&self) -> Value {
2115 json!({
2116 "type": "object",
2117 "properties": {
2118 "include_archived": {
2119 "type": "boolean",
2120 "description": "When true, include agents from prior sessions in the listing. Default false."
2121 }
2122 }
2123 })
2124 }
2125
2126 fn capabilities(&self) -> Vec<ToolCapability> {
2127 vec![ToolCapability::ReadOnly]
2128 }
2129
2130 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
2131 let include_archived = input
2132 .get("include_archived")
2133 .and_then(Value::as_bool)
2134 .unwrap_or(false);
2135 let mut manager = self.manager.write().await;
2136 manager.cleanup(COMPLETED_AGENT_RETENTION);
2137 let results = manager.list_filtered(include_archived);
2138 ToolResult::json(&results).map_err(|e| ToolError::execution_failed(e.to_string()))
2139 }
2140 }
2141
2142 /// Tool to send input to a running sub-agent.
2143 pub struct AgentSendInputTool {
2144 manager: SharedSubAgentManager,
2145 name: &'static str,
2146 }
2147
2148 impl AgentSendInputTool {
2149 /// Create a new send-input tool.
2150 #[must_use]
2151 pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self {
2152 Self { manager, name }
2153 }
2154 }
2155
2156 #[async_trait]
2157 impl ToolSpec for AgentSendInputTool {
2158 fn name(&self) -> &'static str {
2159 self.name
2160 }
2161
2162 fn description(&self) -> &'static str {
2163 "Send input to a running sub-agent. Returns the agent's current snapshot after delivery."
2164 }
2165
2166 fn input_schema(&self) -> Value {
2167 json!({
2168 "type": "object",
2169 "properties": {
2170 "agent_id": {
2171 "type": "string",
2172 "description": "ID returned by agent_spawn"
2173 },
2174 "id": {
2175 "type": "string",
2176 "description": "Alias for agent_id"
2177 },
2178 "message": {
2179 "type": "string",
2180 "description": "Message to deliver to the agent"
2181 },
2182 "input": {
2183 "type": "string",
2184 "description": "Alias for message"
2185 },
2186 "items": {
2187 "type": "array",
2188 "description": "Structured input items (text, mention, skill, local_image, image)",
2189 "items": {
2190 "type": "object"
2191 }
2192 },
2193 "interrupt": {
2194 "type": "boolean",
2195 "description": "Prioritize this message over pending inputs"
2196 }
2197 }
2198 })
2199 }
2200
2201 fn capabilities(&self) -> Vec<ToolCapability> {
2202 vec![]
2203 }
2204
2205 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
2206 let agent_id = input
2207 .get("agent_id")
2208 .or_else(|| input.get("id"))
2209 .and_then(|v| v.as_str())
2210 .ok_or_else(|| ToolError::missing_field("agent_id"))?;
2211 let message = parse_text_or_items(&input, &["message", "input"], "items", "message")?;
2212 let interrupt = optional_bool(&input, "interrupt", false);
2213
2214 let mut manager = self.manager.write().await;
2215 manager
2216 .send_input(agent_id, message, interrupt)
2217 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
2218 let snapshot = manager
2219 .get_result(agent_id)
2220 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
2221
2222 let tool_result =
2223 ToolResult::json(&snapshot).map_err(|e| ToolError::execution_failed(e.to_string()))?;
2224 // Annotate the alias name "send_input" with a deprecation notice;
2225 // the canonical name "agent_send_input" passes through unchanged.
2226 if self.name == "send_input" {
2227 Ok(wrap_with_deprecation_notice(
2228 tool_result,
2229 "send_input",
2230 "agent_send_input",
2231 ))
2232 } else {
2233 Ok(tool_result)
2234 }
2235 }
2236 }
2237
2238 /// Tool to update assignment metadata for a sub-agent.
2239 pub struct AgentAssignTool {
2240 manager: SharedSubAgentManager,
2241 name: &'static str,
2242 }
2243
2244 impl AgentAssignTool {
2245 /// Create a new assignment tool.
2246 #[must_use]
2247 pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self {
2248 Self { manager, name }
2249 }
2250 }
2251
2252 #[async_trait]
2253 impl ToolSpec for AgentAssignTool {
2254 fn name(&self) -> &'static str {
2255 self.name
2256 }
2257
2258 fn description(&self) -> &'static str {
2259 "Update a sub-agent's assignment (objective, role) and optionally deliver an immediate \
2260 coordinator note. The update is delivered as a high-priority message when `interrupt` is \
2261 true (the default). Returns the agent's current snapshot."
2262 }
2263
2264 fn input_schema(&self) -> Value {
2265 json!({
2266 "type": "object",
2267 "properties": {
2268 "agent_id": {
2269 "type": "string",
2270 "description": "Agent id returned by agent_spawn"
2271 },
2272 "id": {
2273 "type": "string",
2274 "description": "Alias for agent_id"
2275 },
2276 "objective": {
2277 "type": "string",
2278 "description": "Updated assignment objective"
2279 },
2280 "role": {
2281 "type": "string",
2282 "description": "Updated role alias: worker, explorer, awaiter, default"
2283 },
2284 "agent_role": {
2285 "type": "string",
2286 "description": "Alias for role"
2287 },
2288 "message": {
2289 "type": "string",
2290 "description": "Optional coordinator note to send to the agent"
2291 },
2292 "input": {
2293 "type": "string",
2294 "description": "Alias for message"
2295 },
2296 "items": {
2297 "type": "array",
2298 "description": "Structured input items (text, mention, skill, local_image, image)",
2299 "items": {
2300 "type": "object"
2301 }
2302 },
2303 "interrupt": {
2304 "type": "boolean",
2305 "description": "Prioritize this assignment update in the agent inbox (default: true)"
2306 }
2307 }
2308 })
2309 }
2310
2311 fn capabilities(&self) -> Vec<ToolCapability> {
2312 vec![]
2313 }
2314
2315 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
2316 let request = parse_assign_request(&input)?;
2317 let mut manager = self.manager.write().await;
2318 let result = manager
2319 .assign(
2320 &request.agent_id,
2321 request.objective,
2322 request.role,
2323 request.message,
2324 request.interrupt,
2325 )
2326 .map_err(|e| ToolError::execution_failed(format!("Failed to assign sub-agent: {e}")))?;
2327
2328 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
2329 }
2330 }
2331
2332 /// Tool to wait for sub-agents to complete.
2333 pub struct AgentWaitTool {
2334 manager: SharedSubAgentManager,
2335 name: &'static str,
2336 }
2337
2338 impl AgentWaitTool {
2339 /// Create a new wait tool.
2340 #[must_use]
2341 pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self {
2342 Self { manager, name }
2343 }
2344 }
2345
2346 #[async_trait]
2347 impl ToolSpec for AgentWaitTool {
2348 fn name(&self) -> &'static str {
2349 self.name
2350 }
2351
2352 fn description(&self) -> &'static str {
2353 "Wait for one or more sub-agents to reach a terminal status. Use `wait_mode: \"all\"` to block \
2354 until every listed agent finishes, or `wait_mode: \"any\"` (default) to return as soon as \
2355 one finishes. When no ids are given, waits on all currently running sub-agents."
2356 }
2357
2358 fn input_schema(&self) -> Value {
2359 json!({
2360 "type": "object",
2361 "properties": {
2362 "ids": {
2363 "type": "array",
2364 "items": { "type": "string" },
2365 "description": "Agent IDs to wait on. When omitted, waits on all currently running sub-agents."
2366 },
2367 "agent_ids": {
2368 "type": "array",
2369 "items": { "type": "string" },
2370 "description": "Alias for ids"
2371 },
2372 "agent_id": {
2373 "type": "string",
2374 "description": "Single agent ID"
2375 },
2376 "id": {
2377 "type": "string",
2378 "description": "Alias for agent_id"
2379 },
2380 "wait_mode": {
2381 "type": "string",
2382 "description": "Wait behavior: any (default) or all"
2383 },
2384 "timeout_ms": {
2385 "type": "integer",
2386 "description": "Max wait time in milliseconds (default: 30000, clamped to 10000-3600000)"
2387 }
2388 }
2389 })
2390 }
2391
2392 fn capabilities(&self) -> Vec<ToolCapability> {
2393 vec![ToolCapability::ReadOnly]
2394 }
2395
2396 async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
2397 let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_RESULT_TIMEOUT_MS)
2398 .clamp(MIN_WAIT_TIMEOUT_MS, MAX_RESULT_TIMEOUT_MS);
2399 let mut ids = parse_wait_ids(&input);
2400 if ids.is_empty() {
2401 let manager = self.manager.read().await;
2402 ids = manager
2403 .list()
2404 .into_iter()
2405 .filter(|snapshot| snapshot.status == SubAgentStatus::Running)
2406 .map(|snapshot| snapshot.agent_id)
2407 .collect();
2408 }
2409 let wait_mode = parse_wait_mode(&input)?;
2410
2411 if ids.is_empty() {
2412 let empty: Vec<SubAgentResult> = Vec::new();
2413 let mut result =
2414 ToolResult::json(&empty).map_err(|e| ToolError::execution_failed(e.to_string()))?;
2415 result.metadata = Some(json!({
2416 "wait_mode": wait_mode.as_str(),
2417 "timed_out": false,
2418 "status": "Completed",
2419 "timeout_ms": timeout_ms,
2420 "waited_ids": [],
2421 "completed_ids": [],
2422 "running_ids": [],
2423 "status_by_id": {}
2424 }));
2425 return Ok(result);
2426 }
2427
2428 let waited_ids = ids.clone();
2429
2430 let (snapshots, timed_out) = wait_for_agents(
2431 &self.manager,
2432 &ids,
2433 wait_mode,
2434 Duration::from_millis(timeout_ms),
2435 )
2436 .await?;
2437
2438 let all_done = snapshots
2439 .iter()
2440 .all(|snapshot| snapshot.status != SubAgentStatus::Running);
2441 let completed_ids = snapshots
2442 .iter()
2443 .filter(|snapshot| snapshot.status != SubAgentStatus::Running)
2444 .map(|snapshot| snapshot.agent_id.clone())
2445 .collect::<Vec<_>>();
2446 let running_ids = snapshots
2447 .iter()
2448 .filter(|snapshot| snapshot.status == SubAgentStatus::Running)
2449 .map(|snapshot| snapshot.agent_id.clone())
2450 .collect::<Vec<_>>();
2451 let status_by_id = snapshots
2452 .iter()
2453 .map(|snapshot| {
2454 (
2455 snapshot.agent_id.clone(),
2456 subagent_status_name(&snapshot.status).to_string(),
2457 )
2458 })
2459 .collect::<HashMap<_, _>>();
2460
2461 let mut result =
2462 ToolResult::json(&snapshots).map_err(|e| ToolError::execution_failed(e.to_string()))?;
2463 result.metadata = Some(json!({
2464 "wait_mode": wait_mode.as_str(),
2465 "timed_out": timed_out,
2466 "status": if timed_out { "TimedOut" } else if all_done { "Completed" } else { "Partial" },
2467 "timeout_ms": timeout_ms,
2468 "waited_ids": waited_ids,
2469 "completed_ids": completed_ids,
2470 "running_ids": running_ids,
2471 "status_by_id": status_by_id
2472 }));
2473 Ok(result)
2474 }
2475 }
2476
2477 /// Tool to delegate a task to a specialized agent (alias for agent_spawn).
2478 pub struct DelegateToAgentTool {
2479 manager: SharedSubAgentManager,
2480 runtime: SubAgentRuntime,
2481 }
2482
2483 impl DelegateToAgentTool {
2484 /// Create a new delegation tool.
2485 #[must_use]
2486 pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
2487 Self { manager, runtime }
2488 }
2489 }
2490
2491 #[async_trait]
2492 impl ToolSpec for DelegateToAgentTool {
2493 fn name(&self) -> &'static str {
2494 "delegate_to_agent"
2495 }
2496
2497 fn description(&self) -> &'static str {
2498 "Delegate a task to a specialized sub-agent. This is an alias for agent_spawn — same schema, \
2499 same behavior. Use `type` (or `agent_name`, `agent_type`) to pick the agent flavor."
2500 }
2501
2502 fn input_schema(&self) -> Value {
2503 json!({
2504 "type": "object",
2505 "properties": {
2506 "agent_name": {
2507 "type": "string",
2508 "description": "Name/type alias for the agent (general, explore, plan, review, implementer, verifier, worker, explorer, awaiter, builder, validator, tester)"
2509 },
2510 "type": {
2511 "type": "string",
2512 "description": "Alias for agent_name"
2513 },
2514 "agent_type": {
2515 "type": "string",
2516 "description": "Alias for agent_name"
2517 },
2518 "role": {
2519 "type": "string",
2520 "description": "Role alias: worker, explorer, awaiter, default"
2521 },
2522 "agent_role": {
2523 "type": "string",
2524 "description": "Alias for role"
2525 },
2526 "objective": {
2527 "type": "string",
2528 "description": "The goal or task description for the agent"
2529 },
2530 "prompt": {
2531 "type": "string",
2532 "description": "Alias for objective"
2533 },
2534 "message": {
2535 "type": "string",
2536 "description": "Alias for objective"
2537 },
2538 "items": {
2539 "type": "array",
2540 "description": "Structured input items (text, mention, skill, local_image, image)",
2541 "items": {
2542 "type": "object"
2543 }
2544 },
2545 "allowed_tools": {
2546 "type": "array",
2547 "items": { "type": "string" },
2548 "description": "Explicit tool allowlist (required for custom type)"
2549 }
2550 }
2551 })
2552 }
2553
2554 fn capabilities(&self) -> Vec<ToolCapability> {
2555 vec![
2556 ToolCapability::ExecutesCode,
2557 ToolCapability::RequiresApproval,
2558 ]
2559 }
2560
2561 fn approval_requirement(&self) -> ApprovalRequirement {
2562 ApprovalRequirement::Required
2563 }
2564
2565 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
2566 let spawn_tool = AgentSpawnTool::new(self.manager.clone(), self.runtime.clone());
2567 let result = spawn_tool.execute(input, context).await?;
2568 Ok(wrap_with_deprecation_notice(
2569 result,
2570 "delegate_to_agent",
2571 "agent_spawn",
2572 ))
2573 }
2574 }
2575
2576 // === Sub-agent Execution ===
2577
2578 /// Build the system prompt for a sub-agent.
2579 ///
2580 /// Starts with the per-type prompt (`SubAgentType::system_prompt`) and
2581 /// appends a one-line role overlay when `assignment.role` is set. The
2582 /// full role library — TOML overlays from `~/.deepseek/roles/`, the
2583 /// `/roles` slash command, model overrides per role — lands in 0.6.7.
2584 /// For 0.6.6 we just don't drop the role on the floor: the model sees
2585 /// "You are operating in the role of `{name}`." as a final line so its
2586 /// behavior reflects the user's choice.
2587 fn build_subagent_system_prompt(
2588 agent_type: &SubAgentType,
2589 assignment: &SubAgentAssignment,
2590 ) -> String {
2591 let base = agent_type.system_prompt();
2592 match assignment.role.as_deref() {
2593 Some(role) if !role.trim().is_empty() => {
2594 format!(
2595 "{base}\n\nYou are operating in the role of `{}`.",
2596 role.trim()
2597 )
2598 }
2599 _ => base,
2600 }
2601 }
2602
2603 struct SubAgentTask {
2604 manager_handle: SharedSubAgentManager,
2605 runtime: SubAgentRuntime,
2606 agent_id: String,
2607 agent_type: SubAgentType,
2608 prompt: String,
2609 assignment: SubAgentAssignment,
2610 /// `None` = full registry inheritance. `Some(list)` = explicit narrow.
2611 allowed_tools: Option<Vec<String>>,
2612 started_at: Instant,
2613 max_steps: u32,
2614 input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
2615 }
2616
2617 #[allow(clippy::too_many_lines)]
2618 async fn run_subagent_task(task: SubAgentTask) {
2619 let result = run_subagent(
2620 &task.runtime,
2621 task.agent_id.clone(),
2622 task.agent_type,
2623 task.prompt,
2624 task.assignment,
2625 task.allowed_tools,
2626 task.started_at,
2627 task.max_steps,
2628 task.input_rx,
2629 )
2630 .await;
2631
2632 let mut manager = task.manager_handle.write().await;
2633 match &result {
2634 Ok(res) => manager.update_from_result(&task.agent_id, res.clone()),
2635 Err(err) => manager.update_failed(&task.agent_id, err.to_string()),
2636 }
2637
2638 // Emit BOTH a human-friendly summary (rendered in the parent's
2639 // sidebar / cell) AND a structured sentinel the model can recognize
2640 // on its next turn. Format: human summary on the first line,
2641 // sentinel on the second. The sentinel uses an opaque tag
2642 // (`deepseek:subagent.done`) to avoid collision with normal user
2643 // text.
2644 let (summary, sentinel) = match &result {
2645 Ok(res) => (
2646 summarize_subagent_result(res),
2647 subagent_done_sentinel(&task.agent_id, res),
2648 ),
2649 Err(err) => (
2650 format!("Failed: {err}"),
2651 subagent_failed_sentinel(&task.agent_id, &err.to_string()),
2652 ),
2653 };
2654
2655 if let Some(mb) = task.runtime.mailbox.as_ref() {
2656 let envelope = match &result {
2657 Ok(_) => MailboxMessage::Completed {
2658 agent_id: task.agent_id.clone(),
2659 summary: summary.clone(),
2660 },
2661 Err(err) => MailboxMessage::Failed {
2662 agent_id: task.agent_id.clone(),
2663 error: err.to_string(),
2664 },
2665 };
2666 let _ = mb.send(envelope);
2667 }
2668
2669 let payload = format!("{summary}\n{sentinel}");
2670
2671 // Wake the engine's parent turn loop if this is one of its direct
2672 // children (issue #756). Gating by `spawn_depth == 1` means the parent
2673 // only sees completions for agents it directly orchestrated, not for
2674 // grandchildren spawned recursively inside its children.
2675 emit_parent_completion(&task.runtime, &task.agent_id, &payload);
2676
2677 if let Some(event_tx) = task.runtime.event_tx {
2678 let _ = event_tx.try_send(Event::AgentComplete {
2679 id: task.agent_id,
2680 result: payload,
2681 });
2682 }
2683 }
2684
2685 /// Notify the engine's parent turn loop that a direct child finished
2686 /// (issue #756). Returns `true` if a send was attempted, `false` if the
2687 /// notification was skipped because this isn't a direct child or no channel
2688 /// is wired. Skips silently when the channel sender has no receiver — the
2689 /// engine outlives the runtime, so a dropped receiver means we're shutting
2690 /// down anyway.
2691 pub(crate) fn emit_parent_completion(
2692 runtime: &SubAgentRuntime,
2693 agent_id: &str,
2694 payload: &str,
2695 ) -> bool {
2696 if runtime.spawn_depth != 1 {
2697 return false;
2698 }
2699 let Some(tx) = runtime.parent_completion_tx.as_ref() else {
2700 return false;
2701 };
2702 let _ = tx.send(SubAgentCompletion {
2703 agent_id: agent_id.to_string(),
2704 payload: payload.to_string(),
2705 });
2706 true
2707 }
2708
2709 /// Build a `<deepseek:subagent.done>` JSON sentinel for a successful child.
2710 /// Intended to surface in the parent's transcript so the model recognizes
2711 /// child completion and can decide whether to read the full result via
2712 /// `agent_result`.
2713 fn subagent_done_sentinel(agent_id: &str, res: &SubAgentResult) -> String {
2714 let payload = json!({
2715 "agent_id": agent_id,
2716 "agent_type": res.agent_type.as_str(),
2717 "status": subagent_status_name(&res.status),
2718 "duration_ms": res.duration_ms,
2719 "steps": res.steps_taken,
2720 "summary": summarize_subagent_result(res),
2721 });
2722 format!("<deepseek:subagent.done>{payload}</deepseek:subagent.done>")
2723 }
2724
2725 /// Build a `<deepseek:subagent.done>` sentinel for a failed child.
2726 fn subagent_failed_sentinel(agent_id: &str, err: &str) -> String {
2727 let payload = json!({
2728 "agent_id": agent_id,
2729 "status": "failed",
2730 "error": err,
2731 });
2732 format!("<deepseek:subagent.done>{payload}</deepseek:subagent.done>")
2733 }
2734
2735 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2736 async fn run_subagent(
2737 runtime: &SubAgentRuntime,
2738 agent_id: String,
2739 agent_type: SubAgentType,
2740 prompt: String,
2741 assignment: SubAgentAssignment,
2742 allowed_tools: Option<Vec<String>>,
2743 started_at: Instant,
2744 max_steps: u32,
2745 mut input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
2746 ) -> Result<SubAgentResult> {
2747 let system_prompt = build_subagent_system_prompt(&agent_type, &assignment);
2748 let tool_registry = SubAgentToolRegistry::new(
2749 runtime.clone(),
2750 allowed_tools.clone(),
2751 Arc::new(Mutex::new(TodoList::new())),
2752 Arc::new(Mutex::new(PlanState::default())),
2753 );
2754 let unavailable_tools = tool_registry.unavailable_allowed_tools();
2755 if !unavailable_tools.is_empty() {
2756 return Err(anyhow!(
2757 "Sub-agent requested unavailable tools: {}",
2758 unavailable_tools.join(", ")
2759 ));
2760 }
2761 let tools = tool_registry.tools_for_model();
2762 if let Some(mb) = runtime.mailbox.as_ref() {
2763 let _ = mb.send(MailboxMessage::started(&agent_id, agent_type.clone()));
2764 }
2765 emit_agent_progress(
2766 runtime.event_tx.as_ref(),
2767 runtime.mailbox.as_ref(),
2768 &agent_id,
2769 format!("started ({})", agent_type.as_str()),
2770 );
2771
2772 let mut messages = vec![Message {
2773 role: "user".to_string(),
2774 content: vec![ContentBlock::Text {
2775 text: build_assignment_prompt(&prompt, &assignment, &agent_type),
2776 cache_control: None,
2777 }],
2778 }];
2779
2780 let mut steps = 0;
2781 let mut final_result: Option<String> = None;
2782 let mut pending_inputs: VecDeque<SubAgentInput> = VecDeque::new();
2783
2784 for _step in 0..max_steps {
2785 // Cooperative cancellation: bail if the parent (or root) cancelled
2786 // us while we were between steps. Children derive their token from
2787 // the parent's via `child_token()` so this propagates the whole tree.
2788 if runtime.cancel_token.is_cancelled() {
2789 emit_agent_progress(
2790 runtime.event_tx.as_ref(),
2791 runtime.mailbox.as_ref(),
2792 &agent_id,
2793 format!("step {steps}/{max_steps}: cancelled"),
2794 );
2795 if let Some(mb) = runtime.mailbox.as_ref() {
2796 let _ = mb.send(MailboxMessage::Cancelled {
2797 agent_id: agent_id.clone(),
2798 });
2799 }
2800 return Ok(SubAgentResult {
2801 agent_id: agent_id.clone(),
2802 agent_type: agent_type.clone(),
2803 assignment: assignment.clone(),
2804 model: runtime.model.clone(),
2805 nickname: None,
2806 status: SubAgentStatus::Cancelled,
2807 result: None,
2808 steps_taken: steps,
2809 duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
2810 from_prior_session: false,
2811 });
2812 }
2813
2814 steps += 1;
2815 emit_agent_progress(
2816 runtime.event_tx.as_ref(),
2817 runtime.mailbox.as_ref(),
2818 &agent_id,
2819 format!("step {steps}/{max_steps}: requesting model response"),
2820 );
2821
2822 while let Ok(input) = input_rx.try_recv() {
2823 if input.interrupt {
2824 pending_inputs.clear();
2825 }
2826 pending_inputs.push_back(input);
2827 }
2828
2829 while let Some(input) = pending_inputs.pop_front() {
2830 if !input.text.trim().is_empty() {
2831 messages.push(Message {
2832 role: "user".to_string(),
2833 content: vec![ContentBlock::Text {
2834 text: input.text,
2835 cache_control: None,
2836 }],
2837 });
2838 }
2839 }
2840
2841 let request = MessageRequest {
2842 model: runtime.model.clone(),
2843 messages: messages.clone(),
2844 max_tokens: 4096,
2845 system: Some(SystemPrompt::Text(system_prompt.clone())),
2846 tools: Some(tools.clone()),
2847 tool_choice: Some(json!({ "type": "auto" })),
2848 metadata: None,
2849 thinking: None,
2850 reasoning_effort: runtime.reasoning_effort.clone(),
2851 stream: Some(false),
2852 temperature: None,
2853 top_p: None,
2854 };
2855
2856 // Race the API call against the cancellation token so a parent
2857 // cancel during a long thinking turn doesn't have to wait for the
2858 // step timeout.
2859 let response = tokio::select! {
2860 biased;
2861 () = runtime.cancel_token.cancelled() => {
2862 emit_agent_progress(
2863 runtime.event_tx.as_ref(),
2864 runtime.mailbox.as_ref(),
2865 &agent_id,
2866 format!("step {steps}/{max_steps}: cancelled mid-request"),
2867 );
2868 if let Some(mb) = runtime.mailbox.as_ref() {
2869 let _ = mb.send(MailboxMessage::Cancelled {
2870 agent_id: agent_id.clone(),
2871 });
2872 }
2873 return Ok(SubAgentResult {
2874 agent_id: agent_id.clone(),
2875 agent_type: agent_type.clone(),
2876 assignment: assignment.clone(),
2877 model: runtime.model.clone(),
2878 nickname: None,
2879 status: SubAgentStatus::Cancelled,
2880 result: None,
2881 steps_taken: steps,
2882 duration_ms: u64::try_from(started_at.elapsed().as_millis())
2883 .unwrap_or(u64::MAX),
2884 from_prior_session: false,
2885 });
2886 }
2887 api = tokio::time::timeout(STEP_API_TIMEOUT, runtime.client.create_message(request)) => {
2888 api.map_err(|_| anyhow!("API call timed out after {}s", STEP_API_TIMEOUT.as_secs()))??
2889 }
2890 };
2891
2892 let mut tool_uses = Vec::new();
2893
2894 // Report token usage so the parent's cost counter updates live.
2895 if let Some(mb) = runtime.mailbox.as_ref() {
2896 let _ = mb.send(MailboxMessage::token_usage(
2897 &agent_id,
2898 response.model.clone(),
2899 response.usage.clone(),
2900 ));
2901 }
2902
2903 for block in &response.content {
2904 match block {
2905 ContentBlock::Text { text, .. } if !text.trim().is_empty() => {
2906 final_result = Some(text.clone());
2907 }
2908 ContentBlock::ToolUse {
2909 id, name, input, ..
2910 } => {
2911 tool_uses.push((id.clone(), name.clone(), input.clone()));
2912 }
2913 _ => {}
2914 }
2915 }
2916
2917 messages.push(Message {
2918 role: "assistant".to_string(),
2919 content: response.content.clone(),
2920 });
2921
2922 if tool_uses.is_empty() {
2923 while let Ok(input) = input_rx.try_recv() {
2924 if input.interrupt {
2925 pending_inputs.clear();
2926 }
2927 pending_inputs.push_back(input);
2928 }
2929 if pending_inputs.is_empty() {
2930 emit_agent_progress(
2931 runtime.event_tx.as_ref(),
2932 runtime.mailbox.as_ref(),
2933 &agent_id,
2934 format!("step {steps}/{max_steps}: complete"),
2935 );
2936 break;
2937 }
2938 continue;
2939 }
2940
2941 emit_agent_progress(
2942 runtime.event_tx.as_ref(),
2943 runtime.mailbox.as_ref(),
2944 &agent_id,
2945 format!(
2946 "step {steps}/{max_steps}: executing {} tool call(s)",
2947 tool_uses.len()
2948 ),
2949 );
2950 let mut tool_results: Vec<ContentBlock> = Vec::new();
2951 for (tool_id, tool_name, tool_input) in tool_uses {
2952 emit_agent_progress(
2953 runtime.event_tx.as_ref(),
2954 runtime.mailbox.as_ref(),
2955 &agent_id,
2956 format!("step {steps}/{max_steps}: running tool '{tool_name}'"),
2957 );
2958 if let Some(mb) = runtime.mailbox.as_ref() {
2959 let _ = mb.send(MailboxMessage::ToolCallStarted {
2960 agent_id: agent_id.clone(),
2961 tool_name: tool_name.clone(),
2962 step: steps,
2963 });
2964 }
2965 let result = match tokio::time::timeout(TOOL_TIMEOUT, async {
2966 tool_registry
2967 .execute(&agent_id, &tool_name, tool_input)
2968 .await
2969 })
2970 .await
2971 {
2972 Ok(Ok(output)) => output,
2973 Ok(Err(e)) => format!("Error: {e}"),
2974 Err(_) => format!("Error: Tool {tool_name} timed out"),
2975 };
2976 let tool_ok = !result.starts_with("Error:");
2977 emit_agent_progress(
2978 runtime.event_tx.as_ref(),
2979 runtime.mailbox.as_ref(),
2980 &agent_id,
2981 format!("step {steps}/{max_steps}: finished tool '{tool_name}'"),
2982 );
2983 if let Some(mb) = runtime.mailbox.as_ref() {
2984 let _ = mb.send(MailboxMessage::ToolCallCompleted {
2985 agent_id: agent_id.clone(),
2986 tool_name: tool_name.clone(),
2987 step: steps,
2988 ok: tool_ok,
2989 });
2990 }
2991
2992 tool_results.push(ContentBlock::ToolResult {
2993 tool_use_id: tool_id,
2994 content: result,
2995 is_error: None,
2996 content_blocks: None,
2997 });
2998 }
2999
3000 if !tool_results.is_empty() {
3001 messages.push(Message {
3002 role: "user".to_string(),
3003 content: tool_results,
3004 });
3005 }
3006 }
3007
3008 release_resident_leases_for(&agent_id);
3009
3010 Ok(SubAgentResult {
3011 agent_id,
3012 agent_type,
3013 assignment,
3014 model: runtime.model.clone(),
3015 nickname: None,
3016 status: SubAgentStatus::Completed,
3017 result: final_result,
3018 steps_taken: steps,
3019 duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
3020 from_prior_session: false,
3021 })
3022 }
3023
3024 async fn wait_for_result(
3025 manager: &SharedSubAgentManager,
3026 agent_id: &str,
3027 timeout: Duration,
3028 ) -> Result<(SubAgentResult, bool), ToolError> {
3029 let deadline = Instant::now() + timeout;
3030
3031 loop {
3032 let snapshot = {
3033 let manager = manager.read().await;
3034 manager
3035 .get_result(agent_id)
3036 .map_err(|e| ToolError::execution_failed(e.to_string()))?
3037 };
3038
3039 if snapshot.status != SubAgentStatus::Running {
3040 return Ok((snapshot, false));
3041 }
3042 if Instant::now() >= deadline {
3043 return Ok((snapshot, true));
3044 }
3045
3046 tokio::time::sleep(RESULT_POLL_INTERVAL).await;
3047 }
3048 }
3049
3050 async fn wait_for_agents(
3051 manager: &SharedSubAgentManager,
3052 ids: &[String],
3053 wait_mode: WaitMode,
3054 timeout: Duration,
3055 ) -> Result<(Vec<SubAgentResult>, bool), ToolError> {
3056 let deadline = Instant::now() + timeout;
3057
3058 loop {
3059 let snapshots = {
3060 let manager = manager.read().await;
3061 ids.iter()
3062 .map(|id| {
3063 manager
3064 .get_result(id)
3065 .map_err(|e| ToolError::execution_failed(e.to_string()))
3066 })
3067 .collect::<Result<Vec<_>, _>>()?
3068 };
3069
3070 if wait_mode.condition_met(&snapshots) {
3071 return Ok((snapshots, false));
3072 }
3073 if Instant::now() >= deadline {
3074 return Ok((snapshots, true));
3075 }
3076
3077 tokio::time::sleep(RESULT_POLL_INTERVAL).await;
3078 }
3079 }
3080
3081 fn parse_wait_mode(input: &Value) -> Result<WaitMode, ToolError> {
3082 let raw_mode = input
3083 .get("wait_mode")
3084 .and_then(|v| v.as_str())
3085 .unwrap_or("any");
3086 WaitMode::from_str(raw_mode).ok_or_else(|| {
3087 ToolError::invalid_input(format!("Invalid wait_mode '{raw_mode}'. Use: any or all"))
3088 })
3089 }
3090
3091 fn parse_wait_ids(input: &Value) -> Vec<String> {
3092 let mut ids = Vec::new();
3093 for key in ["ids", "agent_ids"] {
3094 if let Some(list) = input.get(key).and_then(|v| v.as_array()) {
3095 for value in list {
3096 if let Some(id) = value.as_str() {
3097 let id = id.trim();
3098 if !id.is_empty() && !ids.iter().any(|existing| existing == id) {
3099 ids.push(id.to_string());
3100 }
3101 }
3102 }
3103 }
3104 }
3105
3106 for key in ["agent_id", "id"] {
3107 if let Some(id) = input.get(key).and_then(|v| v.as_str()) {
3108 let id = id.trim();
3109 if !id.is_empty() && !ids.iter().any(|existing| existing == id) {
3110 ids.push(id.to_string());
3111 }
3112 }
3113 }
3114
3115 ids
3116 }
3117
3118 fn optional_input_str<'a>(input: &'a Value, keys: &[&str]) -> Option<&'a str> {
3119 keys.iter()
3120 .filter_map(|key| input.get(*key).and_then(Value::as_str))
3121 .map(str::trim)
3122 .find(|value| !value.is_empty())
3123 }
3124
3125 fn parse_text_or_items(
3126 input: &Value,
3127 text_keys: &[&str],
3128 items_key: &str,
3129 required_field: &str,
3130 ) -> Result<String, ToolError> {
3131 let text = optional_input_str(input, text_keys).map(str::to_string);
3132 let items = parse_items_text(input, items_key)?;
3133 match (text, items) {
3134 (Some(_), Some(_)) => Err(ToolError::invalid_input(format!(
3135 "Provide either {required_field} text or {items_key}, but not both"
3136 ))),
3137 (Some(text), None) => Ok(text),
3138 (None, Some(items)) => Ok(items),
3139 (None, None) => Err(ToolError::missing_field(required_field)),
3140 }
3141 }
3142
3143 fn parse_optional_text_or_items(
3144 input: &Value,
3145 text_keys: &[&str],
3146 items_key: &str,
3147 ) -> Result<Option<String>, ToolError> {
3148 let text = optional_input_str(input, text_keys).map(str::to_string);
3149 let items = parse_items_text(input, items_key)?;
3150 match (text, items) {
3151 (Some(_), Some(_)) => Err(ToolError::invalid_input(format!(
3152 "Provide either {} text or {}, but not both",
3153 text_keys[0], items_key
3154 ))),
3155 (Some(text), None) => Ok(Some(text)),
3156 (None, Some(items)) => Ok(Some(items)),
3157 (None, None) => Ok(None),
3158 }
3159 }
3160
3161 fn parse_items_text(input: &Value, key: &str) -> Result<Option<String>, ToolError> {
3162 let Some(items) = input.get(key) else {
3163 return Ok(None);
3164 };
3165 let array = items
3166 .as_array()
3167 .ok_or_else(|| ToolError::invalid_input(format!("'{key}' must be an array")))?;
3168 if array.is_empty() {
3169 return Err(ToolError::invalid_input(format!("'{key}' cannot be empty")));
3170 }
3171
3172 let mut lines = Vec::new();
3173 for item in array {
3174 let object = item
3175 .as_object()
3176 .ok_or_else(|| ToolError::invalid_input("each item must be an object"))?;
3177 let item_type = object
3178 .get("type")
3179 .and_then(Value::as_str)
3180 .unwrap_or("text")
3181 .trim();
3182 let rendered = match item_type {
3183 "text" => object
3184 .get("text")
3185 .and_then(Value::as_str)
3186 .map(str::trim)
3187 .filter(|text| !text.is_empty())
3188 .map(str::to_string)
3189 .ok_or_else(|| ToolError::invalid_input("text item requires non-empty text"))?,
3190 "mention" => {
3191 let name = object
3192 .get("name")
3193 .and_then(Value::as_str)
3194 .map(str::trim)
3195 .filter(|text| !text.is_empty())
3196 .ok_or_else(|| ToolError::invalid_input("mention item requires name"))?;
3197 let path = object
3198 .get("path")
3199 .and_then(Value::as_str)
3200 .map(str::trim)
3201 .filter(|text| !text.is_empty())
3202 .ok_or_else(|| ToolError::invalid_input("mention item requires path"))?;
3203 format!("[mention:${name}]({path})")
3204 }
3205 "skill" => {
3206 let name = object
3207 .get("name")
3208 .and_then(Value::as_str)
3209 .map(str::trim)
3210 .filter(|text| !text.is_empty())
3211 .ok_or_else(|| ToolError::invalid_input("skill item requires name"))?;
3212 let path = object
3213 .get("path")
3214 .and_then(Value::as_str)
3215 .map(str::trim)
3216 .filter(|text| !text.is_empty())
3217 .ok_or_else(|| ToolError::invalid_input("skill item requires path"))?;
3218 format!("[skill:${name}]({path})")
3219 }
3220 "local_image" => {
3221 let path = object
3222 .get("path")
3223 .and_then(Value::as_str)
3224 .map(str::trim)
3225 .filter(|text| !text.is_empty())
3226 .ok_or_else(|| ToolError::invalid_input("local_image item requires path"))?;
3227 format!("[local_image:{path}]")
3228 }
3229 "image" => {
3230 let url = object
3231 .get("image_url")
3232 .and_then(Value::as_str)
3233 .map(str::trim)
3234 .filter(|text| !text.is_empty())
3235 .ok_or_else(|| ToolError::invalid_input("image item requires image_url"))?;
3236 format!("[image:{url}]")
3237 }
3238 _ => object
3239 .get("text")
3240 .and_then(Value::as_str)
3241 .map(str::trim)
3242 .filter(|text| !text.is_empty())
3243 .map(str::to_string)
3244 .unwrap_or_else(|| "[input]".to_string()),
3245 };
3246 lines.push(rendered);
3247 }
3248
3249 Ok(Some(lines.join("\n")))
3250 }
3251
3252 fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
3253 let prompt = parse_text_or_items(
3254 input,
3255 &["prompt", "message", "objective"],
3256 "items",
3257 "prompt",
3258 )?;
3259
3260 let type_input = optional_input_str(input, &["type", "agent_type", "agent_name"]);
3261 let role_input = optional_input_str(input, &["role", "agent_role"]);
3262
3263 let parsed_type = type_input
3264 .map(|kind| {
3265 SubAgentType::from_str(kind).ok_or_else(|| {
3266 ToolError::invalid_input(format!(
3267 "Invalid sub-agent type '{kind}'. Use: {VALID_SUBAGENT_TYPES}"
3268 ))
3269 })
3270 })
3271 .transpose()?;
3272
3273 let parsed_role_type = role_input
3274 .map(|role| {
3275 SubAgentType::from_str(role).ok_or_else(|| {
3276 ToolError::invalid_input(format!(
3277 "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default"
3278 ))
3279 })
3280 })
3281 .transpose()?;
3282
3283 if let (Some(type_kind), Some(role_kind)) = (&parsed_type, &parsed_role_type)
3284 && type_kind != role_kind
3285 {
3286 return Err(ToolError::invalid_input(
3287 "Conflicting type/agent_type and role/agent_role values".to_string(),
3288 ));
3289 }
3290
3291 let agent_type = parsed_type
3292 .or(parsed_role_type)
3293 .unwrap_or(SubAgentType::General);
3294
3295 if let Some(role) = role_input
3296 && normalize_role_alias(role).is_none()
3297 {
3298 return Err(ToolError::invalid_input(format!(
3299 "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default"
3300 )));
3301 }
3302
3303 let role = role_input
3304 .and_then(normalize_role_alias)
3305 .or_else(|| type_input.and_then(normalize_role_alias))
3306 .map(str::to_string);
3307
3308 let allowed_tools = input
3309 .get("allowed_tools")
3310 .and_then(|v| v.as_array())
3311 .map(|items| {
3312 let mut tools = Vec::new();
3313 for item in items {
3314 if let Some(tool) = item.as_str() {
3315 let trimmed = tool.trim();
3316 if !trimmed.is_empty() && !tools.iter().any(|existing| existing == trimmed) {
3317 tools.push(trimmed.to_string());
3318 }
3319 }
3320 }
3321 tools
3322 });
3323
3324 let cwd = parse_optional_cwd(input)?;
3325 let model = parse_optional_subagent_model(input, "model")?;
3326 let resident_file = input
3327 .get("resident_file")
3328 .and_then(|v| v.as_str())
3329 .map(str::to_string)
3330 .filter(|s| !s.trim().is_empty());
3331
3332 Ok(SpawnRequest {
3333 prompt: prompt.clone(),
3334 agent_type,
3335 assignment: SubAgentAssignment::new(prompt, role),
3336 allowed_tools,
3337 model,
3338 cwd,
3339 resident_file,
3340 })
3341 }
3342
3343 pub(crate) fn normalize_requested_subagent_model(
3344 value: &str,
3345 field: &str,
3346 ) -> Result<String, ToolError> {
3347 let trimmed = value.trim();
3348 if trimmed.is_empty() {
3349 return Err(ToolError::invalid_input(format!("{field} cannot be blank")));
3350 }
3351 crate::config::normalize_model_name(trimmed).ok_or_else(|| {
3352 ToolError::invalid_input(format!(
3353 "Invalid {field} '{trimmed}'. Expected a DeepSeek model id such as deepseek-v4-pro or deepseek-v4-flash"
3354 ))
3355 })
3356 }
3357
3358 pub(crate) fn configured_model_for_role_or_type(
3359 runtime: &SubAgentRuntime,
3360 role: Option<&str>,
3361 agent_type: &SubAgentType,
3362 ) -> Result<Option<String>, ToolError> {
3363 let mut keys = Vec::new();
3364 if let Some(role) = role.map(str::trim).filter(|role| !role.is_empty()) {
3365 keys.push(role.to_ascii_lowercase());
3366 }
3367 keys.push(agent_type.as_str().to_string());
3368 keys.push("default".to_string());
3369
3370 for key in keys {
3371 if let Some(model) = runtime.role_models.get(&key) {
3372 return normalize_requested_subagent_model(model, &format!("subagents.{key}.model"))
3373 .map(Some);
3374 }
3375 }
3376 Ok(None)
3377 }
3378
3379 #[derive(Debug, Clone, PartialEq, Eq)]
3380 pub(crate) struct SubAgentResolvedRoute {
3381 pub(crate) model: String,
3382 pub(crate) reasoning_effort: Option<String>,
3383 }
3384
3385 pub(crate) async fn resolve_subagent_assignment_route(
3386 runtime: &SubAgentRuntime,
3387 configured_model: Option<String>,
3388 prompt: &str,
3389 ) -> SubAgentResolvedRoute {
3390 let explicit_model = configured_model.is_some();
3391 let mut route = fallback_subagent_assignment_route(runtime, configured_model, prompt);
3392
3393 if (runtime.auto_model || runtime.reasoning_effort_auto)
3394 && let Ok(Some(recommendation)) = subagent_flash_router(runtime, prompt).await
3395 {
3396 if runtime.auto_model && !explicit_model {
3397 route.model = recommendation.model;
3398 }
3399 if runtime.reasoning_effort_auto {
3400 route.reasoning_effort = recommendation
3401 .reasoning_effort
3402 .map(|effort| effort.as_setting().to_string())
3403 .or(route.reasoning_effort);
3404 }
3405 }
3406
3407 route
3408 }
3409
3410 fn fallback_subagent_assignment_route(
3411 runtime: &SubAgentRuntime,
3412 configured_model: Option<String>,
3413 prompt: &str,
3414 ) -> SubAgentResolvedRoute {
3415 let model = if let Some(model) = configured_model {
3416 model
3417 } else if runtime.auto_model {
3418 crate::commands::auto_model_heuristic(prompt, &runtime.model)
3419 } else {
3420 runtime.model.clone()
3421 };
3422
3423 let reasoning_effort = if runtime.reasoning_effort_auto {
3424 let effort = match crate::auto_reasoning::select(false, prompt) {
3425 crate::tui::app::ReasoningEffort::Low | crate::tui::app::ReasoningEffort::Medium => {
3426 crate::tui::app::ReasoningEffort::High
3427 }
3428 other => other,
3429 };
3430 Some(effort.as_setting().to_string())
3431 } else {
3432 runtime.reasoning_effort.clone()
3433 };
3434
3435 SubAgentResolvedRoute {
3436 model,
3437 reasoning_effort,
3438 }
3439 }
3440
3441 async fn subagent_flash_router(
3442 runtime: &SubAgentRuntime,
3443 prompt: &str,
3444 ) -> Result<Option<crate::commands::AutoRouteRecommendation>> {
3445 if cfg!(test) {
3446 return Ok(None);
3447 }
3448
3449 let request = MessageRequest {
3450 model: "deepseek-v4-flash".to_string(),
3451 messages: vec![Message {
3452 role: "user".to_string(),
3453 content: vec![ContentBlock::Text {
3454 text: subagent_router_prompt(runtime, prompt),
3455 cache_control: None,
3456 }],
3457 }],
3458 max_tokens: 96,
3459 system: Some(SystemPrompt::Text(
3460 SUBAGENT_ROUTER_SYSTEM_PROMPT.to_string(),
3461 )),
3462 tools: None,
3463 tool_choice: None,
3464 metadata: None,
3465 thinking: None,
3466 reasoning_effort: Some("off".to_string()),
3467 stream: Some(false),
3468 temperature: Some(0.0),
3469 top_p: None,
3470 };
3471
3472 let response = tokio::time::timeout(
3473 Duration::from_secs(4),
3474 runtime.client.create_message(request),
3475 )
3476 .await??;
3477 Ok(crate::commands::parse_auto_route_recommendation(
3478 &message_response_text(&response.content),
3479 ))
3480 }
3481
3482 const SUBAGENT_ROUTER_SYSTEM_PROMPT: &str = "\
3483 You are the DeepSeek TUI sub-agent routing manager. Return only compact JSON: \
3484 {\"model\":\"deepseek-v4-flash|deepseek-v4-pro\",\"thinking\":\"off|high|max\"}. \
3485 Treat each child assignment like a customer request entering a team queue: decide the least \
3486 sufficient worker and thinking budget for that assignment. Do not treat being a sub-agent as \
3487 important by itself. Use Flash for trivial, read-only, status, lookup, or single-step work. \
3488 Use Pro for coding, debugging, release work, multi-file changes, security, architecture, \
3489 high-risk decisions, ambiguous requests, or work likely to need tool-call judgment. Use thinking \
3490 off for trivial no-tool work, high for ordinary reasoning, and max only for hard, risky, \
3491 multi-step, uncertain, or tool-heavy work.";
3492
3493 fn subagent_router_prompt(runtime: &SubAgentRuntime, prompt: &str) -> String {
3494 format!(
3495 "Parent selected model mode: {}\nParent selected thinking mode: {}\n\nSub-agent assignment:\n{}\n\nReturn JSON only.",
3496 if runtime.auto_model { "auto" } else { "fixed" },
3497 if runtime.reasoning_effort_auto {
3498 "auto"
3499 } else {
3500 runtime
3501 .reasoning_effort
3502 .as_deref()
3503 .unwrap_or("provider-default")
3504 },
3505 truncate_subagent_router_prompt(prompt, 4_000)
3506 )
3507 }
3508
3509 fn truncate_subagent_router_prompt(text: &str, max_chars: usize) -> String {
3510 if text.chars().count() <= max_chars {
3511 return text.to_string();
3512 }
3513 let mut out = text.chars().take(max_chars).collect::<String>();
3514 out.push_str("\n[truncated]");
3515 out
3516 }
3517
3518 fn message_response_text(blocks: &[ContentBlock]) -> String {
3519 let mut out = String::new();
3520 for block in blocks {
3521 match block {
3522 ContentBlock::Text { text, .. } => {
3523 if !out.is_empty() {
3524 out.push('\n');
3525 }
3526 out.push_str(text);
3527 }
3528 ContentBlock::Thinking { thinking } => {
3529 if !out.is_empty() {
3530 out.push('\n');
3531 }
3532 out.push_str(thinking);
3533 }
3534 _ => {}
3535 }
3536 }
3537 out
3538 }
3539
3540 fn parse_optional_subagent_model(input: &Value, key: &str) -> Result<Option<String>, ToolError> {
3541 match input.get(key) {
3542 None | Some(Value::Null) => Ok(None),
3543 Some(Value::String(value)) => normalize_requested_subagent_model(value, key).map(Some),
3544 Some(_) => Err(ToolError::invalid_input(format!("{key} must be a string"))),
3545 }
3546 }
3547
3548 /// Extract an optional `cwd: String` from spawn input and convert to a
3549 /// `PathBuf`. Empty / absent → `None`. Workspace-boundary check happens
3550 /// at spawn time (the parent's workspace is known there, not here).
3551 fn parse_optional_cwd(input: &Value) -> Result<Option<PathBuf>, ToolError> {
3552 let raw = input.get("cwd").and_then(|v| v.as_str()).map(str::trim);
3553 match raw {
3554 None | Some("") => Ok(None),
3555 Some(s) => Ok(Some(PathBuf::from(s))),
3556 }
3557 }
3558
3559 fn parse_assign_request(input: &Value) -> Result<AssignRequest, ToolError> {
3560 let agent_id = input
3561 .get("agent_id")
3562 .or_else(|| input.get("id"))
3563 .and_then(Value::as_str)
3564 .map(str::trim)
3565 .filter(|id| !id.is_empty())
3566 .ok_or_else(|| ToolError::missing_field("agent_id"))?
3567 .to_string();
3568 let objective = optional_input_str(input, &["objective"]).map(str::to_string);
3569 let role = optional_input_str(input, &["role", "agent_role"])
3570 .map(|role| {
3571 normalize_role_alias(role).ok_or_else(|| {
3572 ToolError::invalid_input(format!(
3573 "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default"
3574 ))
3575 })
3576 })
3577 .transpose()?
3578 .map(str::to_string);
3579 let message = parse_optional_text_or_items(input, &["message", "input"], "items")?;
3580 let interrupt = optional_bool(input, "interrupt", true);
3581
3582 if objective.is_none() && role.is_none() && message.is_none() {
3583 return Err(ToolError::invalid_input(
3584 "Provide at least one of objective, role/agent_role, message/input, or items"
3585 .to_string(),
3586 ));
3587 }
3588
3589 Ok(AssignRequest {
3590 agent_id,
3591 objective,
3592 role,
3593 message,
3594 interrupt,
3595 })
3596 }
3597
3598 fn normalize_role_alias(input: &str) -> Option<&'static str> {
3599 match input.to_ascii_lowercase().as_str() {
3600 "default" => Some("default"),
3601 "worker" | "general" => Some("worker"),
3602 "explorer" | "explore" => Some("explorer"),
3603 "awaiter" | "plan" | "planner" => Some("awaiter"),
3604 _ => None,
3605 }
3606 }
3607
3608 fn build_assignment_prompt(
3609 prompt: &str,
3610 assignment: &SubAgentAssignment,
3611 agent_type: &SubAgentType,
3612 ) -> String {
3613 let role = assignment.role.as_deref().unwrap_or("default");
3614 format!(
3615 "Assignment metadata:\n- objective: {}\n- role: {}\n- resolved_type: {}\n\nTask:\n{}",
3616 assignment.objective,
3617 role,
3618 agent_type.as_str(),
3619 prompt
3620 )
3621 }
3622
3623 fn emit_agent_progress(
3624 event_tx: Option<&mpsc::Sender<Event>>,
3625 mailbox: Option<&Mailbox>,
3626 agent_id: &str,
3627 status: String,
3628 ) {
3629 if let Some(mb) = mailbox {
3630 let _ = mb.send(MailboxMessage::progress(agent_id, status.clone()));
3631 }
3632 if let Some(event_tx) = event_tx {
3633 let _ = event_tx.try_send(Event::AgentProgress {
3634 id: agent_id.to_string(),
3635 status,
3636 });
3637 }
3638 }
3639
3640 // === Tool Registry Helpers ===
3641
3642 /// Per-sub-agent tool registry.
3643 ///
3644 /// Two modes:
3645 /// - **Full inheritance** (`allowed_tools = None`): the child sees the same
3646 /// tool surface as the parent's Agent mode — every tool family including
3647 /// `with_subagent_tools` (so it can recurse). This is the v0.6.6 default.
3648 /// - **Explicit narrow** (`allowed_tools = Some(list)`): legacy / Custom
3649 /// path. The registry still builds the full surface, but only the listed
3650 /// tool names are visible to the model and callable.
3651 struct SubAgentToolRegistry {
3652 /// `None` → full inheritance (no filter applied). `Some(list)` →
3653 /// only the listed tools are visible to the model and callable.
3654 allowed_tools: Option<Vec<String>>,
3655 registry: ToolRegistry,
3656 }
3657
3658 impl SubAgentToolRegistry {
3659 fn new(
3660 runtime: SubAgentRuntime,
3661 explicit_allowed_tools: Option<Vec<String>>,
3662 todo_list: SharedTodoList,
3663 plan_state: SharedPlanState,
3664 ) -> Self {
3665 // Build the full agent surface — same as the parent's Agent mode.
3666 // Children inherit shell, file, patch, search, web, git, diagnostics,
3667 // review, RLM, sub-agent management (so grandchildren can spawn),
3668 // plus per-child fresh todo/plan state.
3669 let context = runtime.context.clone();
3670 let registry = ToolRegistryBuilder::new()
3671 .with_full_agent_surface(
3672 Some(runtime.client.clone()),
3673 runtime.model.clone(),
3674 runtime.manager.clone(),
3675 runtime.clone(),
3676 runtime.allow_shell,
3677 todo_list,
3678 plan_state,
3679 )
3680 .build(context);
3681
3682 Self {
3683 allowed_tools: explicit_allowed_tools,
3684 registry,
3685 }
3686 }
3687
3688 /// Whether a given tool name is permitted under this child's filter.
3689 /// `None` filter = everything permitted.
3690 fn is_tool_allowed(&self, name: &str) -> bool {
3691 match &self.allowed_tools {
3692 None => true,
3693 Some(list) => list.iter().any(|t| t == name),
3694 }
3695 }
3696
3697 fn tools_for_model(&self) -> Vec<Tool> {
3698 let api_tools = self.registry.to_api_tools();
3699 match &self.allowed_tools {
3700 None => api_tools,
3701 Some(list) => api_tools
3702 .into_iter()
3703 .filter(|tool| list.contains(&tool.name))
3704 .collect(),
3705 }
3706 }
3707
3708 fn unavailable_allowed_tools(&self) -> Vec<String> {
3709 match &self.allowed_tools {
3710 None => Vec::new(),
3711 Some(list) => list
3712 .iter()
3713 .filter(|name| !self.registry.contains(name))
3714 .cloned()
3715 .collect(),
3716 }
3717 }
3718
3719 async fn execute(&self, _agent_id: &str, name: &str, input: Value) -> Result<String> {
3720 if !self.is_tool_allowed(name) {
3721 return Err(anyhow!("Tool {name} not allowed for this sub-agent"));
3722 }
3723 self.registry
3724 .execute(name, input)
3725 .await
3726 .map_err(|e| anyhow!(e))
3727 }
3728 }
3729
3730 /// Resolve the effective allowed-tools list for a child.
3731 ///
3732 /// **v0.6.6 default: full inheritance.** Returning `Ok(None)` means the
3733 /// child sees the same tool surface as the parent's Agent mode — every
3734 /// family including `with_subagent_tools` so it can recurse. The narrowing
3735 /// path (`Ok(Some(list))`) is only used by:
3736 /// - `Custom` agent types (which require an explicit list).
3737 /// - Callers that pass `explicit_tools` (advanced / legacy use).
3738 ///
3739 /// `allow_shell = false` no longer narrows the tool LIST — the child's
3740 /// registry simply doesn't register shell tools, which has the same
3741 /// effect without papering over the parent's choice with a deny-list.
3742 fn build_allowed_tools(
3743 agent_type: &SubAgentType,
3744 explicit_tools: Option<Vec<String>>,
3745 _allow_shell: bool,
3746 ) -> Result<Option<Vec<String>>> {
3747 if let Some(tools) = explicit_tools {
3748 let mut deduped = Vec::new();
3749 for tool in tools {
3750 let name = tool.trim();
3751 if !name.is_empty() && !deduped.iter().any(|existing: &String| existing == name) {
3752 deduped.push(name.to_string());
3753 }
3754 }
3755 if matches!(agent_type, SubAgentType::Custom) && deduped.is_empty() {
3756 return Err(anyhow!(
3757 "Custom sub-agent requires a non-empty allowed_tools list"
3758 ));
3759 }
3760 return Ok(Some(deduped));
3761 }
3762
3763 if matches!(agent_type, SubAgentType::Custom) {
3764 return Err(anyhow!(
3765 "Custom sub-agent requires a non-empty allowed_tools list"
3766 ));
3767 }
3768
3769 // Default: full registry inheritance from the parent. The child sees
3770 // every tool the parent has, including the sub-agent management family
3771 // (so it can recurse). Sandbox + workspace + depth cap remain the
3772 // safety net.
3773 Ok(None)
3774 }
3775
3776 fn summarize_subagent_result(result: &SubAgentResult) -> String {
3777 match (&result.status, result.result.as_ref()) {
3778 (SubAgentStatus::Completed, Some(text)) => truncate_preview(text),
3779 (SubAgentStatus::Completed, None) => "Completed (no output)".to_string(),
3780 (SubAgentStatus::Interrupted(error), _) => format!("Interrupted: {error}"),
3781 (SubAgentStatus::Cancelled, _) => "Cancelled".to_string(),
3782 (SubAgentStatus::Failed(error), _) => format!("Failed: {error}"),
3783 (SubAgentStatus::Running, _) => "Running".to_string(),
3784 }
3785 }
3786
3787 fn subagent_status_name(status: &SubAgentStatus) -> &'static str {
3788 match status {
3789 SubAgentStatus::Running => "running",
3790 SubAgentStatus::Completed => "completed",
3791 SubAgentStatus::Interrupted(_) => "interrupted",
3792 SubAgentStatus::Failed(_) => "failed",
3793 SubAgentStatus::Cancelled => "cancelled",
3794 }
3795 }
3796
3797 fn truncate_preview(text: &str) -> String {
3798 const MAX_LEN: usize = 240;
3799 if text.len() <= MAX_LEN {
3800 text.to_string()
3801 } else {
3802 format!("{}...", text.chars().take(MAX_LEN).collect::<String>())
3803 }
3804 }
3805
3806 // === System prompts ===
3807 //
3808 // Each per-agent-type prompt is composed from two parts:
3809 //
3810 // 1. A short role-specific intro that names the agent's job, its scope,
3811 // and any role-specific tactics or stop conditions.
3812 // 2. The shared `subagent_output_format.md` block, which is the single
3813 // source of truth for the SUMMARY / EVIDENCE / CHANGES / RISKS /
3814 // BLOCKERS contract, the stop condition, and the typed-tool-surface
3815 // conventions. Tweaks to the contract live in that one file.
3816 //
3817 // `concat!` resolves at compile time, so the per-type constants remain
3818 // `&'static str` and `system_prompt()` keeps its `String` return type.
3819 // The `include_str!` calls inside each `concat!` all point at the same
3820 // file, so the format is defined once even though it's inlined many times.
3821
3822 const GENERAL_AGENT_PROMPT: &str = concat!(
3823 "You are a general-purpose sub-agent spawned to handle a specific task autonomously.\n",
3824 "\n",
3825 "Your scope is exactly what the parent assigned to you. Do not expand the\n",
3826 "objective — if you discover related work that needs doing, surface it under\n",
3827 "RISKS or BLOCKERS rather than starting it. Work autonomously: the parent is\n",
3828 "not available to answer questions mid-run.\n",
3829 "\n",
3830 "Plan before you act. Use `checklist_write` for any multi-step task so your work\n",
3831 "is visible in the parent's sidebar. For complex initiatives, layer\n",
3832 "`update_plan` (strategy) above `checklist_write` (tactics).\n",
3833 "\n",
3834 include_str!("../../prompts/subagent_output_format.md"),
3835 );
3836
3837 const EXPLORE_AGENT_PROMPT: &str = concat!(
3838 "You are an exploration sub-agent. Your job is to map the relevant region\n",
3839 "of the codebase fast and report what is there. You are read-only by\n",
3840 "convention — do not write, patch, or run side-effectful commands. If the\n",
3841 "task seems to require a write, stop and put it under BLOCKERS.\n",
3842 "\n",
3843 "Method:\n",
3844 "- Start with `list_dir` and `file_search` to orient.\n",
3845 "- Use `grep_files` (NOT `exec_shell rg`) to find call sites, type defs,\n",
3846 " and string literals. Prefer narrow, structured queries over broad scans.\n",
3847 "- Read each candidate file with `read_file`. Skim, then quote line ranges.\n",
3848 "- Stop reading once you have enough evidence — exhaustive sweeps are not\n",
3849 " the goal. The parent will spawn a follow-up explorer if needed.\n",
3850 "\n",
3851 "EVIDENCE is the load-bearing section for explorers. Cite every file you\n",
3852 "read with `path:line-range` and one line per finding. The parent uses your\n",
3853 "EVIDENCE list as a working set for the next turn, so be precise.\n",
3854 "\n",
3855 "CHANGES will almost always be \"None.\" for an explorer.\n",
3856 "\n",
3857 include_str!("../../prompts/subagent_output_format.md"),
3858 );
3859
3860 const PLAN_AGENT_PROMPT: &str = concat!(
3861 "You are a planning sub-agent. Your job is to take an objective and\n",
3862 "produce a prioritized, executable plan — not to execute it. Keep writes\n",
3863 "to a minimum (notes and plan artifacts only); avoid patches and shell\n",
3864 "side effects.\n",
3865 "\n",
3866 "Method:\n",
3867 "- Read enough of the codebase to ground the plan in reality. A plan\n",
3868 " written without `read_file` evidence is a guess.\n",
3869 "- Decompose the objective into ordered, verifiable steps. Each step names\n",
3870 " the artifact it produces and the check that proves it works.\n",
3871 "- Surface trade-offs explicitly. If two approaches are viable, name both\n",
3872 " and pick one with a reason — don't leave the parent with a fork.\n",
3873 "- Use `update_plan` to record the high-level strategy and `checklist_write` to\n",
3874 " emit the granular backlog. The parent (and the user) reads these from\n",
3875 " the sidebar after you finish.\n",
3876 "\n",
3877 "Prioritization: order todos by the dependency graph first, then by the\n",
3878 "ratio of risk reduced to effort spent. Tag each item with `[P0]` / `[P1]`\n",
3879 "/ `[P2]` so the parent can pick a slice without re-reading the whole plan.\n",
3880 "\n",
3881 "CHANGES should list the plan artifacts you wrote (e.g. `update_plan` rows,\n",
3882 "`checklist_write` ids, any notes). Do not include speculative future edits.\n",
3883 "\n",
3884 include_str!("../../prompts/subagent_output_format.md"),
3885 );
3886
3887 const REVIEW_AGENT_PROMPT: &str = concat!(
3888 "You are a code review sub-agent. Your job is to read the code under\n",
3889 "review and emit a severity-scored list of findings. You are read-only by\n",
3890 "convention — do not patch the code under review even if a fix is obvious;\n",
3891 "describe the fix in the finding so the parent can apply it.\n",
3892 "\n",
3893 "Method:\n",
3894 "- Read the diff or files end-to-end with `read_file` before scoring.\n",
3895 "- Use `grep_files` to check for sibling call sites, similar patterns\n",
3896 " elsewhere, and existing tests covering the same surface.\n",
3897 "- For each finding, score severity as one of:\n",
3898 " BLOCKER — correctness, security, data loss, or contract break.\n",
3899 " MAJOR — likely bug, missing error path, perf regression at scale.\n",
3900 " MINOR — style, naming, redundancy, suboptimal but correct code.\n",
3901 " NIT — taste; reasonable people may disagree.\n",
3902 "- Order EVIDENCE bullets by severity, BLOCKER first. Each bullet:\n",
3903 " `[SEVERITY] path:line-range — one-line description; suggested fix`.\n",
3904 "- Be constructive. Cite the failure mode, not the author.\n",
3905 "\n",
3906 "If you find no issues at MAJOR or above, say so plainly in SUMMARY — a\n",
3907 "clean review is a valid result and the parent benefits from knowing it.\n",
3908 "\n",
3909 "CHANGES will almost always be \"None.\" for a reviewer.\n",
3910 "\n",
3911 include_str!("../../prompts/subagent_output_format.md"),
3912 );
3913
3914 const CUSTOM_AGENT_PROMPT: &str = concat!(
3915 "You are a custom sub-agent. The parent has given you a narrowed tool\n",
3916 "registry — only the tools you see at runtime are available. Do not try\n",
3917 "to reach for a tool that is not registered; if the task needs one, put\n",
3918 "the gap under BLOCKERS and stop.\n",
3919 "\n",
3920 "Stay tightly scoped to the assigned objective. The parent chose Custom\n",
3921 "specifically to constrain you — do not expand into adjacent work.\n",
3922 "\n",
3923 include_str!("../../prompts/subagent_output_format.md"),
3924 );
3925
3926 const IMPLEMENTER_AGENT_PROMPT: &str = concat!(
3927 "You are an implementation sub-agent. Your job is to land the change\n",
3928 "the parent assigned to you — write the code, modify the files, satisfy\n",
3929 "the contract — with the *minimum* surrounding edit. You do not refactor\n",
3930 "adjacent code. You do not rename unused variables. You do not 'tidy up'\n",
3931 "while you're in the file. If you see related work that should happen,\n",
3932 "surface it under RISKS or BLOCKERS rather than starting it.\n",
3933 "\n",
3934 "Method:\n",
3935 "- Read the target file(s) end-to-end before editing. Edits made without\n",
3936 " reading the file produce structurally wrong patches.\n",
3937 "- Prefer `edit_file` (single search/replace) for narrow changes.\n",
3938 " Reach for `apply_patch` only when the change spans multiple hunks\n",
3939 " or is structurally tricky.\n",
3940 "- After every batch of edits, run a quick verification: a relevant\n",
3941 " `cargo check` / `npm run lint` / `pytest -k <test>` so you don't\n",
3942 " hand the parent a half-baked implementation.\n",
3943 "- If the change requires writing tests, write them first or alongside\n",
3944 " the implementation — never as a follow-up the parent has to ask for.\n",
3945 "\n",
3946 "CHANGES is the load-bearing section for implementers. List every file\n",
3947 "you modified with a one-line summary of what changed and why. The parent\n",
3948 "uses CHANGES to decide what to inspect next.\n",
3949 "\n",
3950 include_str!("../../prompts/subagent_output_format.md"),
3951 );
3952
3953 const VERIFIER_AGENT_PROMPT: &str = concat!(
3954 "You are a verification sub-agent. Your job is to *run* the project's\n",
3955 "test suite (or other validation gates) and report pass/fail with the\n",
3956 "evidence the parent needs to act. You are read-only by convention —\n",
3957 "do not patch failing tests, do not 'fix' lints, do not modify code.\n",
3958 "If a fix seems obvious, describe it under RISKS so the parent can\n",
3959 "spawn an Implementer.\n",
3960 "\n",
3961 "Method:\n",
3962 "- Run the right gate for the language: `cargo test --workspace`,\n",
3963 " `npm test`, `pytest`, `go test ./...`. Use `run_tests` when it's\n",
3964 " available; fall back to `exec_shell` when the project has a custom\n",
3965 " invocation.\n",
3966 "- Run lints if requested: `cargo clippy -- -D warnings`,\n",
3967 " `npm run lint`, `ruff check .`. Don't run lints the parent didn't\n",
3968 " ask for; lint noise drowns the signal you were spawned to surface.\n",
3969 "- Capture the exact failing assertion plus the stack trace / file:line\n",
3970 " in EVIDENCE. A failure summarised as 'cargo test failed' is useless;\n",
3971 " the parent needs the actual panic.\n",
3972 "\n",
3973 "OUTCOME goes at the top of SUMMARY: PASS / FAIL / FLAKY. If FLAKY,\n",
3974 "say which test and how many runs you tried.\n",
3975 "\n",
3976 "CHANGES will almost always be \"None.\" for a verifier.\n",
3977 "\n",
3978 include_str!("../../prompts/subagent_output_format.md"),
3979 );
3980
3981 // === Tests ===
3982
3983 #[cfg(test)]
3984 mod tests;
3985
3985 lines RUST