Skip to main content

roder_api/
subagents.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::events::{ThreadId, TurnId};
6use crate::extension::SubagentDispatcherId;
7use crate::inference::TokenUsage;
8use crate::trace::SubagentTraceSink;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct SubagentRequest {
12    pub description: String,
13    pub prompt: String,
14    pub subagent_type: Option<String>,
15    pub model: Option<String>,
16    pub tools: Option<Vec<String>>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub lane: Option<SubagentLane>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub max_concurrent: Option<usize>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub allowed_tools: Option<Vec<String>>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub parent_deadline_seconds: Option<u64>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub inputs: Option<serde_json::Value>,
27    pub timeout_seconds: Option<u64>,
28}
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
31#[serde(rename_all = "snake_case")]
32pub enum SubagentLane {
33    Scout,
34    Editor,
35    Reviewer,
36    Runner,
37}
38
39impl SubagentLane {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Scout => "scout",
43            Self::Editor => "editor",
44            Self::Reviewer => "reviewer",
45            Self::Runner => "runner",
46        }
47    }
48
49    pub fn preset(self) -> SubagentLanePreset {
50        match self {
51            Self::Scout => SubagentLanePreset {
52                lane: self,
53                description: "Read and search without changing state.",
54                max_concurrent: 4,
55                timeout_seconds: 120,
56                allowed_tools: &[
57                    "Read",
58                    "Grep",
59                    "Glob",
60                    "read_file",
61                    "grep",
62                    "glob",
63                    "list_files",
64                ],
65            },
66            Self::Editor => SubagentLanePreset {
67                lane: self,
68                description: "Make a bounded file-change slice.",
69                max_concurrent: 2,
70                timeout_seconds: 180,
71                allowed_tools: &[
72                    "Read",
73                    "Grep",
74                    "Glob",
75                    "read_file",
76                    "grep",
77                    "glob",
78                    "list_files",
79                    "write_file",
80                    "edit",
81                    "multi_edit",
82                    "apply_patch",
83                ],
84            },
85            Self::Reviewer => SubagentLanePreset {
86                lane: self,
87                description: "Review and verify with evidence.",
88                max_concurrent: 2,
89                timeout_seconds: 120,
90                allowed_tools: &[
91                    "Read",
92                    "Grep",
93                    "Glob",
94                    "read_file",
95                    "grep",
96                    "glob",
97                    "list_files",
98                ],
99            },
100            Self::Runner => SubagentLanePreset {
101                lane: self,
102                description: "Run commands or tests when process policy allows it.",
103                max_concurrent: 1,
104                timeout_seconds: 120,
105                allowed_tools: &["Shell", "shell", "exec_command", "run_command"],
106            },
107        }
108    }
109}
110
111#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
112#[serde(rename_all = "camelCase")]
113pub struct SubagentLanePreset {
114    pub lane: SubagentLane,
115    pub description: &'static str,
116    pub max_concurrent: usize,
117    pub timeout_seconds: u64,
118    pub allowed_tools: &'static [&'static str],
119}
120
121pub fn built_in_subagent_lane_presets() -> [SubagentLanePreset; 4] {
122    [
123        SubagentLane::Scout.preset(),
124        SubagentLane::Editor.preset(),
125        SubagentLane::Reviewer.preset(),
126        SubagentLane::Runner.preset(),
127    ]
128}
129
130pub const SUBAGENT_SUMMARY_CONTRACT: &str = "Child summary must include these labels: Conclusion, Evidence, Files inspected, Files changed, Remaining uncertainty.";
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct SubagentDefinition {
134    pub agent_type: String,
135    pub description: String,
136    pub tools: Vec<String>,
137    pub model: Option<String>,
138    pub system_prompt: Option<String>,
139    pub permission_mode: SubagentPermissionMode,
140    pub max_turns: Option<u32>,
141    pub max_result_chars: Option<usize>,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "snake_case")]
146pub enum SubagentPermissionMode {
147    ReadOnly,
148    #[default]
149    Default,
150    AutoEdit,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct SubagentResult {
155    pub thread_id: ThreadId,
156    pub turn_id: TurnId,
157    pub agent_type: String,
158    pub model: Option<String>,
159    pub final_message: String,
160    pub usage: Option<TokenUsage>,
161    pub exit_reason: SubagentExitReason,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub transcript: Option<serde_json::Value>,
164    #[serde(default)]
165    pub metadata: serde_json::Value,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum SubagentExitReason {
171    Completed,
172    MaxTurns,
173    Timeout,
174    Cancelled,
175    Failed,
176}
177
178#[async_trait::async_trait]
179pub trait SubagentDispatcher: Send + Sync + 'static {
180    fn id(&self) -> SubagentDispatcherId;
181
182    fn definitions(&self) -> Vec<SubagentDefinition>;
183
184    async fn dispatch(
185        &self,
186        parent_thread_id: ThreadId,
187        parent_turn_id: TurnId,
188        request: SubagentRequest,
189    ) -> anyhow::Result<SubagentResult>;
190
191    async fn dispatch_traced(
192        &self,
193        parent_thread_id: ThreadId,
194        parent_turn_id: TurnId,
195        request: SubagentRequest,
196        trace_sink: Option<std::sync::Arc<dyn SubagentTraceSink>>,
197    ) -> anyhow::Result<SubagentResult> {
198        let _ = trace_sink;
199        self.dispatch(parent_thread_id, parent_turn_id, request)
200            .await
201    }
202
203    /// Dispatch a child carrying the parent tool call's execution handles
204    /// (workspace, remote workspace, process runner, context artifacts). This
205    /// is how a subagent or swarm child operates on the same workspace as the
206    /// lead thread instead of failing with "workspace handle is not available".
207    ///
208    /// The default implementation ignores the handles and falls back to
209    /// [`Self::dispatch_traced`], so non-workspace dispatchers (e.g. remote or
210    /// fake ones) keep working unchanged.
211    async fn dispatch_with_context(
212        &self,
213        parent_thread_id: ThreadId,
214        parent_turn_id: TurnId,
215        request: SubagentRequest,
216        trace_sink: Option<std::sync::Arc<dyn SubagentTraceSink>>,
217        handles: crate::tools::ToolExecutionHandles,
218    ) -> anyhow::Result<SubagentResult> {
219        let _ = handles;
220        self.dispatch_traced(parent_thread_id, parent_turn_id, request, trace_sink)
221            .await
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Agent-swarm mode (roadmap phase 104)
227//
228// Swarm is a Roder-native composition over the canonical subagent dispatch
229// surface above. It lets a lead model (or `/agent-swarm` command) launch many
230// homogeneous child tasks from one prompt template, resume unfinished children,
231// and collect an ordered, machine-readable result. The types here are
232// provider-neutral and do not vendor any external implementation.
233// ---------------------------------------------------------------------------
234
235/// Canonical model-facing swarm tool name. Single source of truth shared by
236/// the tool registration (`roder-ext-subagents`) and the core turn loop's
237/// exclusivity enforcement (`roder-core`).
238pub const AGENT_SWARM_TOOL_NAME: &str = "agent_swarm";
239
240/// Literal placeholder replaced with each `agent_swarm` item value.
241pub const AGENT_SWARM_PROMPT_PLACEHOLDER: &str = "{{item}}";
242
243/// Default upper bound on swarm child count. Config may lower but not exceed it.
244pub const AGENT_SWARM_MAX_SUBAGENTS: usize = 128;
245/// Default number of children that may start immediately before pacing applies.
246pub const AGENT_SWARM_INITIAL_LAUNCH_LIMIT: usize = 5;
247/// Default pacing interval between additional child launches, in milliseconds.
248pub const AGENT_SWARM_LAUNCH_INTERVAL_MS: u64 = 700;
249/// Default number of times a child is retried after a provider rate limit
250/// before it is reported as failed.
251pub const AGENT_SWARM_RATE_LIMIT_MAX_RETRIES: usize = 4;
252/// Default base backoff between rate-limit retries, in milliseconds (doubled
253/// each subsequent attempt: 3s, 6s, 12s, ...).
254pub const AGENT_SWARM_RATE_LIMIT_BASE_BACKOFF_MS: u64 = 3_000;
255/// Hard cap on rate-limit retries so config/env can never make a swarm wait
256/// unboundedly.
257pub const AGENT_SWARM_RATE_LIMIT_MAX_RETRIES_CAP: usize = 8;
258/// Default minimum interval between successive global rate-limit capacity
259/// shrinks, in milliseconds. While the provider keeps rate-limiting, the swarm
260/// shrinks its global capacity by one at most this often, so it throttles
261/// smoothly instead of collapsing on the first burst of 429s.
262pub const AGENT_SWARM_RATE_LIMIT_SHRINK_INTERVAL_MS: u64 = 2_000;
263/// Default quiet window after which the swarm recovers one unit of global
264/// rate-limit capacity, in milliseconds (3 minutes). Any fresh rate limit
265/// restarts the window, and recovery happens at most once per quiet window.
266pub const AGENT_SWARM_RATE_LIMIT_RECOVERY_INTERVAL_MS: u64 = 180_000;
267
268fn default_rate_limit_max_retries() -> usize {
269    AGENT_SWARM_RATE_LIMIT_MAX_RETRIES
270}
271
272fn default_rate_limit_base_backoff_ms() -> u64 {
273    AGENT_SWARM_RATE_LIMIT_BASE_BACKOFF_MS
274}
275
276fn default_rate_limit_shrink_interval_ms() -> u64 {
277    AGENT_SWARM_RATE_LIMIT_SHRINK_INTERVAL_MS
278}
279
280fn default_rate_limit_recovery_interval_ms() -> u64 {
281    AGENT_SWARM_RATE_LIMIT_RECOVERY_INTERVAL_MS
282}
283
284/// Canonical swarm-mode reminder injected into a turn's developer instructions
285/// (server-side) while agent-swarm mode is active, so the model reaches for the
286/// `agent_swarm` fanout tool. Shared by the runtime injection and the TUI label
287/// so the wording stays in one place.
288pub const AGENT_SWARM_MODE_REMINDER: &str = "Agent-swarm mode is active. When the task splits into \
289several similarly-shaped subtasks over different inputs, call the agent_swarm tool exactly once \
290with a prompt_template containing {{item}} and an items array (or resume_agent_ids), and make it \
291the only tool call in that response. agent_swarm dispatches configured roles only: its \
292subagent_type must exactly match a role advertised in the tool schema, and lane names such as \
293scout are not role IDs. Do not rely on a lane to add missing tools; for generic repository work \
294use spawn_agent when available.";
295
296/// Emitted when agent-swarm mode is toggled on a runtime/thread.
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
298pub struct AgentSwarmModeChanged {
299    pub thread_id: ThreadId,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub turn_id: Option<TurnId>,
302    pub enabled: bool,
303    pub trigger: AgentSwarmModeTrigger,
304    #[serde(with = "time::serde::rfc3339")]
305    pub timestamp: time::OffsetDateTime,
306}
307
308/// Emitted when the `agent_swarm` tool begins fanning out children, so any
309/// app-server/SDK/TUI client can observe the swarm as a whole. Per-child
310/// progress flows through the existing `Subagent*` trace events.
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
312pub struct AgentSwarmStarted {
313    pub thread_id: ThreadId,
314    pub turn_id: TurnId,
315    pub tool_id: String,
316    /// Children the swarm will launch (item-based spawns plus resumes).
317    pub child_count: usize,
318    #[serde(with = "time::serde::rfc3339")]
319    pub timestamp: time::OffsetDateTime,
320}
321
322/// Emitted when the `agent_swarm` tool finishes, carrying the aggregate child
323/// outcome counts.
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325pub struct AgentSwarmCompleted {
326    pub thread_id: ThreadId,
327    pub turn_id: TurnId,
328    pub tool_id: String,
329    pub completed: usize,
330    pub failed: usize,
331    pub aborted: usize,
332    #[serde(with = "time::serde::rfc3339")]
333    pub timestamp: time::OffsetDateTime,
334}
335
336/// A running tally of swarm children as they resolve, used for live progress.
337/// `resolved` is `completed + failed + aborted`; clients can render
338/// `resolved/total` as a progress bar without tracking individual children.
339#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
340pub struct AgentSwarmProgressSnapshot {
341    pub total: usize,
342    pub completed: usize,
343    pub failed: usize,
344    pub aborted: usize,
345}
346
347impl AgentSwarmProgressSnapshot {
348    /// Children that have finished (in any outcome).
349    pub fn resolved(&self) -> usize {
350        self.completed + self.failed + self.aborted
351    }
352}
353
354/// Emitted each time a swarm child resolves, so a client can render a live
355/// "N/total done" progress tick between `AgentSwarmStarted` and
356/// `AgentSwarmCompleted` rather than only the final result.
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358pub struct AgentSwarmProgress {
359    pub thread_id: ThreadId,
360    pub turn_id: TurnId,
361    pub tool_id: String,
362    pub snapshot: AgentSwarmProgressSnapshot,
363    #[serde(with = "time::serde::rfc3339")]
364    pub timestamp: time::OffsetDateTime,
365}
366
367/// Sink the runtime supplies on a tool-execution context so the `agent_swarm`
368/// tool can publish live progress snapshots (carrying the runtime's thread/turn
369/// ids and the bus emitter) without the tool depending on `roder-core`.
370#[async_trait::async_trait]
371pub trait AgentSwarmProgressSink: Send + Sync {
372    async fn emit_progress(
373        &self,
374        thread_id: &str,
375        turn_id: &str,
376        tool_id: &str,
377        snapshot: AgentSwarmProgressSnapshot,
378    );
379}
380
381/// Why a batch of tool calls violates the `agent_swarm` exclusivity rule:
382/// `agent_swarm` must be the only tool call in a single model response.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum AgentSwarmBatchViolation {
385    /// More than one `agent_swarm` call appeared in the same response.
386    MultipleSwarms {
387        /// Whether non-swarm tool calls were also present in the batch.
388        has_other_tools: bool,
389    },
390    /// Exactly one `agent_swarm` call was mixed with other tool calls.
391    MixedWithOtherTools,
392}
393
394impl AgentSwarmBatchViolation {
395    /// Actionable retry text returned to the model for every call in the
396    /// denied batch, so it re-issues `agent_swarm` by itself.
397    pub fn deny_message(self) -> String {
398        match self {
399            Self::MultipleSwarms { has_other_tools } => {
400                let mut message = String::from(
401                    "agent_swarm must be called one swarm at a time. Multiple agent_swarm calls \
402                     are not forbidden, but issue them sequentially: call one agent_swarm, wait \
403                     for its result, then call the next; or merge the work into a single \
404                     agent_swarm when one swarm can cover it.",
405                );
406                if has_other_tools {
407                    message.push_str(
408                        " agent_swarm also must not be combined with other tools in the same \
409                         response.",
410                    );
411                }
412                message
413            }
414            Self::MixedWithOtherTools => String::from(
415                "agent_swarm must be the only tool call in a model response. Retry with a single \
416                 agent_swarm call by itself, then call any other tools after it returns.",
417            ),
418        }
419    }
420}
421
422/// Detect whether a batch of tool-call names violates the `agent_swarm`
423/// exclusivity rule. Returns `None` when the batch is valid: no `agent_swarm`
424/// call, or exactly one `agent_swarm` call by itself.
425pub fn agent_swarm_batch_violation<'a>(
426    tool_names: impl Iterator<Item = &'a str>,
427) -> Option<AgentSwarmBatchViolation> {
428    let mut total = 0usize;
429    let mut swarm = 0usize;
430    for name in tool_names {
431        total += 1;
432        if name == AGENT_SWARM_TOOL_NAME {
433            swarm += 1;
434        }
435    }
436    if swarm == 0 || (swarm == 1 && total == 1) {
437        return None;
438    }
439    if swarm > 1 {
440        Some(AgentSwarmBatchViolation::MultipleSwarms {
441            has_other_tools: total > swarm,
442        })
443    } else {
444        Some(AgentSwarmBatchViolation::MixedWithOtherTools)
445    }
446}
447
448/// How a swarm-mode session was entered.
449///
450/// `manual` is a persistent toggle (`/agent-swarm on`); `task` is a one-shot
451/// `/agent-swarm <prompt>`; `tool` is implicit entry from the `agent_swarm`
452/// tool call. Only `manual` stays active across turns.
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum AgentSwarmModeTrigger {
456    Manual,
457    Task,
458    Tool,
459}
460
461impl AgentSwarmModeTrigger {
462    pub fn as_str(self) -> &'static str {
463        match self {
464            Self::Manual => "manual",
465            Self::Task => "task",
466            Self::Tool => "tool",
467        }
468    }
469
470    /// One-shot triggers auto-exit at the end of the relevant turn.
471    pub fn should_auto_exit(self) -> bool {
472        matches!(self, Self::Task | Self::Tool)
473    }
474}
475
476/// Whether a swarm child is a fresh spawn or a resume of an existing agent.
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479pub enum AgentSwarmChildKind {
480    Spawn,
481    Resume,
482}
483
484impl AgentSwarmChildKind {
485    pub fn as_str(self) -> &'static str {
486        match self {
487            Self::Spawn => "spawn",
488            Self::Resume => "resume",
489        }
490    }
491}
492
493/// Final outcome of a single swarm child.
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "snake_case")]
496pub enum AgentSwarmChildOutcome {
497    Completed,
498    Failed,
499    Aborted,
500}
501
502impl AgentSwarmChildOutcome {
503    pub fn as_str(self) -> &'static str {
504        match self {
505            Self::Completed => "completed",
506            Self::Failed => "failed",
507            Self::Aborted => "aborted",
508        }
509    }
510}
511
512/// Whether an aborted child had started running before cancellation.
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum AgentSwarmChildState {
516    Started,
517    NotStarted,
518}
519
520impl AgentSwarmChildState {
521    pub fn as_str(self) -> &'static str {
522        match self {
523            Self::Started => "started",
524            Self::NotStarted => "not_started",
525        }
526    }
527}
528
529/// Parsed `agent_swarm` tool input, before any child is dispatched.
530#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
531pub struct AgentSwarmRequest {
532    pub description: String,
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub subagent_type: Option<String>,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub prompt_template: Option<String>,
537    #[serde(default, skip_serializing_if = "Vec::is_empty")]
538    pub items: Vec<String>,
539    /// Map of existing subagent agent_id to the prompt used to resume it.
540    /// Resumed children are dispatched before new item-based spawns.
541    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
542    pub resume_agent_ids: BTreeMap<String, String>,
543}
544
545/// One ordered child to dispatch as part of a swarm.
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct AgentSwarmChildSpec {
548    /// 1-based ordering index, stable across the whole swarm.
549    pub index: usize,
550    pub kind: AgentSwarmChildKind,
551    /// The item value (for spawns) used to render the prompt, when present.
552    pub item: Option<String>,
553    /// The fully-rendered prompt for this child.
554    pub prompt: String,
555    /// For resumes, the existing agent id to continue.
556    pub resume_agent_id: Option<String>,
557}
558
559/// Tunable scheduler/bounds for swarm fanout.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct AgentSwarmConfig {
562    /// Hard upper bound on children per swarm. Never exceeds
563    /// [`AGENT_SWARM_MAX_SUBAGENTS`].
564    pub max_subagents: usize,
565    /// Children allowed to start immediately before pacing applies.
566    pub initial_launch_limit: usize,
567    /// Pacing interval between additional launches, in milliseconds.
568    pub launch_interval_ms: u64,
569    /// Optional cap on simultaneously-active children (normal phase).
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub max_concurrency: Option<usize>,
572    /// Optional per-child timeout override, in seconds.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub child_timeout_seconds: Option<u64>,
575    /// Times a child is retried after a provider rate limit before failing.
576    #[serde(default = "default_rate_limit_max_retries")]
577    pub rate_limit_max_retries: usize,
578    /// Base backoff between rate-limit retries, in milliseconds (doubled each
579    /// subsequent attempt).
580    #[serde(default = "default_rate_limit_base_backoff_ms")]
581    pub rate_limit_base_backoff_ms: u64,
582    /// Minimum interval between successive global rate-limit capacity shrinks,
583    /// in milliseconds. Sustained rate limits shrink global capacity by one at
584    /// most this often.
585    #[serde(default = "default_rate_limit_shrink_interval_ms")]
586    pub rate_limit_shrink_interval_ms: u64,
587    /// Quiet window with no rate limit after which the swarm recovers one unit
588    /// of global rate-limit capacity, in milliseconds. A fresh rate limit
589    /// restarts the window.
590    #[serde(default = "default_rate_limit_recovery_interval_ms")]
591    pub rate_limit_recovery_interval_ms: u64,
592}
593
594impl Default for AgentSwarmConfig {
595    fn default() -> Self {
596        Self {
597            max_subagents: AGENT_SWARM_MAX_SUBAGENTS,
598            initial_launch_limit: AGENT_SWARM_INITIAL_LAUNCH_LIMIT,
599            launch_interval_ms: AGENT_SWARM_LAUNCH_INTERVAL_MS,
600            max_concurrency: None,
601            child_timeout_seconds: None,
602            rate_limit_max_retries: AGENT_SWARM_RATE_LIMIT_MAX_RETRIES,
603            rate_limit_base_backoff_ms: AGENT_SWARM_RATE_LIMIT_BASE_BACKOFF_MS,
604            rate_limit_shrink_interval_ms: AGENT_SWARM_RATE_LIMIT_SHRINK_INTERVAL_MS,
605            rate_limit_recovery_interval_ms: AGENT_SWARM_RATE_LIMIT_RECOVERY_INTERVAL_MS,
606        }
607    }
608}
609
610impl AgentSwarmConfig {
611    /// Clamp config into a bounded, deterministic range so config (or env) can
612    /// never request unbounded fanout, a zero-sized ramp, or an unbounded
613    /// rate-limit wait.
614    pub fn clamped(mut self) -> Self {
615        self.max_subagents = self.max_subagents.clamp(1, AGENT_SWARM_MAX_SUBAGENTS);
616        self.initial_launch_limit = self.initial_launch_limit.max(1);
617        if let Some(cap) = self.max_concurrency {
618            self.max_concurrency = Some(cap.max(1));
619        }
620        if let Some(timeout) = self.child_timeout_seconds {
621            self.child_timeout_seconds = Some(timeout.max(1));
622        }
623        self.rate_limit_max_retries = self
624            .rate_limit_max_retries
625            .min(AGENT_SWARM_RATE_LIMIT_MAX_RETRIES_CAP);
626        self
627    }
628}
629
630/// Outcome of a single resolved swarm child entry.
631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
632pub struct AgentSwarmChildResult {
633    pub index: usize,
634    pub kind: AgentSwarmChildKind,
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub item: Option<String>,
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub agent_id: Option<String>,
639    pub outcome: AgentSwarmChildOutcome,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub state: Option<AgentSwarmChildState>,
642    pub body: String,
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub usage: Option<TokenUsage>,
645}
646
647/// Aggregated, ordered swarm result returned to the lead model.
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
649pub struct AgentSwarmResult {
650    pub completed: usize,
651    pub failed: usize,
652    pub aborted: usize,
653    pub children: Vec<AgentSwarmChildResult>,
654}
655
656impl AgentSwarmResult {
657    pub fn from_children(children: Vec<AgentSwarmChildResult>) -> Self {
658        let mut completed = 0;
659        let mut failed = 0;
660        let mut aborted = 0;
661        for child in &children {
662            match child.outcome {
663                AgentSwarmChildOutcome::Completed => completed += 1,
664                AgentSwarmChildOutcome::Failed => failed += 1,
665                AgentSwarmChildOutcome::Aborted => aborted += 1,
666            }
667        }
668        Self {
669            completed,
670            failed,
671            aborted,
672            children,
673        }
674    }
675
676    /// `completed: 2, failed: 1` style summary; omits zero buckets.
677    pub fn summary_line(&self) -> String {
678        let mut parts = Vec::new();
679        if self.completed > 0 {
680            parts.push(format!("completed: {}", self.completed));
681        }
682        if self.failed > 0 {
683            parts.push(format!("failed: {}", self.failed));
684        }
685        if self.aborted > 0 {
686            parts.push(format!("aborted: {}", self.aborted));
687        }
688        if parts.is_empty() {
689            "completed: 0".to_string()
690        } else {
691            parts.join(", ")
692        }
693    }
694
695    /// A resume hint is useful when unfinished children carry agent ids.
696    pub fn needs_resume_hint(&self) -> bool {
697        self.children.iter().any(|child| {
698            child.outcome != AgentSwarmChildOutcome::Completed && child.agent_id.is_some()
699        })
700    }
701
702    /// Render the durable, transcript-safe `<agent_swarm_result>` text block.
703    pub fn render_text(&self) -> String {
704        let mut lines = vec![
705            "<agent_swarm_result>".to_string(),
706            format!("<summary>{}</summary>", self.summary_line()),
707        ];
708        if self.needs_resume_hint() {
709            lines.push(
710                "<resume_hint>Call agent_swarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.</resume_hint>"
711                    .to_string(),
712            );
713        }
714        for child in &self.children {
715            let mode = if child.kind == AgentSwarmChildKind::Resume {
716                " mode=\"resume\"".to_string()
717            } else {
718                String::new()
719            };
720            let agent_id = child
721                .agent_id
722                .as_deref()
723                .map(|id| format!(" agent_id=\"{}\"", escape_xml_attr(id)))
724                .unwrap_or_default();
725            let item = child
726                .item
727                .as_deref()
728                .map(|item| format!(" item=\"{}\"", escape_xml_attr(item)))
729                .unwrap_or_default();
730            let state = child
731                .state
732                .map(|state| format!(" state=\"{}\"", state.as_str()))
733                .unwrap_or_default();
734            lines.push(format!(
735                "<subagent{mode}{agent_id}{item}{state} outcome=\"{}\">{}</subagent>",
736                child.outcome.as_str(),
737                escape_xml_text(&child.body)
738            ));
739        }
740        lines.push("</agent_swarm_result>".to_string());
741        lines.join("\n")
742    }
743}
744
745fn escape_xml_attr(value: &str) -> String {
746    value
747        .replace('&', "&amp;")
748        .replace('"', "&quot;")
749        .replace('<', "&lt;")
750        .replace('>', "&gt;")
751}
752
753fn escape_xml_text(value: &str) -> String {
754    value
755        .replace('&', "&amp;")
756        .replace('<', "&lt;")
757        .replace('>', "&gt;")
758}
759
760/// Validate an [`AgentSwarmRequest`] and build the ordered child specs.
761///
762/// Mirrors the documented contract: at least two `items` unless
763/// `resume_agent_ids` is present; `prompt_template` is required whenever
764/// `items` are present and must contain the `{{item}}` placeholder; filled
765/// prompts must be distinct; resumed children are ordered before spawns; and
766/// the total child count must not exceed `config.max_subagents`. No child is
767/// dispatched if validation fails.
768pub fn build_agent_swarm_specs(
769    request: &AgentSwarmRequest,
770    config: &AgentSwarmConfig,
771) -> Result<Vec<AgentSwarmChildSpec>, AgentSwarmValidationError> {
772    if request.description.trim().is_empty() {
773        return Err(AgentSwarmValidationError::EmptyDescription);
774    }
775
776    let items: Vec<String> = request
777        .items
778        .iter()
779        .map(|item| item.trim().to_string())
780        .collect();
781    if items.iter().any(|item| item.is_empty()) {
782        return Err(AgentSwarmValidationError::EmptyItem);
783    }
784
785    let resume_entries: Vec<(String, String)> = request
786        .resume_agent_ids
787        .iter()
788        .map(|(id, prompt)| (id.trim().to_string(), prompt.trim().to_string()))
789        .collect();
790    if resume_entries
791        .iter()
792        .any(|(id, prompt)| id.is_empty() || prompt.is_empty())
793    {
794        return Err(AgentSwarmValidationError::EmptyResumeEntry);
795    }
796
797    let item_count = items.len();
798    let resume_count = resume_entries.len();
799    let total = item_count + resume_count;
800
801    if resume_count == 0 && item_count < 2 {
802        return Err(AgentSwarmValidationError::TooFewItems);
803    }
804    let max = config.max_subagents.clamp(1, AGENT_SWARM_MAX_SUBAGENTS);
805    if total > max {
806        return Err(AgentSwarmValidationError::TooManySubagents { total, max });
807    }
808
809    let prompt_template = request
810        .prompt_template
811        .as_ref()
812        .map(|template| template.trim().to_string())
813        .filter(|template| !template.is_empty());
814
815    if item_count > 0 {
816        let Some(template) = prompt_template.as_ref() else {
817            return Err(AgentSwarmValidationError::MissingPromptTemplate);
818        };
819        if !template.contains(AGENT_SWARM_PROMPT_PLACEHOLDER) {
820            return Err(AgentSwarmValidationError::MissingPlaceholder);
821        }
822    }
823
824    let mut specs = Vec::with_capacity(total);
825    for (agent_id, prompt) in &resume_entries {
826        specs.push(AgentSwarmChildSpec {
827            index: specs.len() + 1,
828            kind: AgentSwarmChildKind::Resume,
829            item: None,
830            prompt: prompt.clone(),
831            resume_agent_id: Some(agent_id.clone()),
832        });
833    }
834
835    if item_count > 0 {
836        let template = prompt_template.expect("prompt template validated above");
837        let mut seen: BTreeMap<String, usize> = BTreeMap::new();
838        for (offset, item) in items.iter().enumerate() {
839            let prompt = template.replace(AGENT_SWARM_PROMPT_PLACEHOLDER, item);
840            if let Some(previous) = seen.get(&prompt) {
841                return Err(AgentSwarmValidationError::DuplicatePrompt {
842                    first: *previous,
843                    second: offset + 1,
844                });
845            }
846            seen.insert(prompt.clone(), offset + 1);
847            specs.push(AgentSwarmChildSpec {
848                index: specs.len() + 1,
849                kind: AgentSwarmChildKind::Spawn,
850                item: Some(item.clone()),
851                prompt,
852                resume_agent_id: None,
853            });
854        }
855    }
856
857    Ok(specs)
858}
859
860/// Reasons an `agent_swarm` request is rejected before any child starts.
861#[derive(Debug, Clone, PartialEq, Eq)]
862pub enum AgentSwarmValidationError {
863    EmptyDescription,
864    EmptyItem,
865    EmptyResumeEntry,
866    TooFewItems,
867    TooManySubagents { total: usize, max: usize },
868    MissingPromptTemplate,
869    MissingPlaceholder,
870    DuplicatePrompt { first: usize, second: usize },
871}
872
873impl std::fmt::Display for AgentSwarmValidationError {
874    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
875        match self {
876            Self::EmptyDescription => write!(f, "description must not be empty"),
877            Self::EmptyItem => write!(f, "items must not contain empty values"),
878            Self::EmptyResumeEntry => {
879                write!(
880                    f,
881                    "resume_agent_ids entries must have non-empty ids and prompts"
882                )
883            }
884            Self::TooFewItems => write!(
885                f,
886                "agent_swarm requires at least 2 items unless resume_agent_ids is provided"
887            ),
888            Self::TooManySubagents { total, max } => {
889                write!(
890                    f,
891                    "agent_swarm supports at most {max} subagents (got {total})"
892                )
893            }
894            Self::MissingPromptTemplate => {
895                write!(f, "prompt_template is required when items are provided")
896            }
897            Self::MissingPlaceholder => write!(
898                f,
899                "prompt_template must include the {AGENT_SWARM_PROMPT_PLACEHOLDER} placeholder"
900            ),
901            Self::DuplicatePrompt { first, second } => write!(
902                f,
903                "duplicate subagent prompts from items {first} and {second}; agent_swarm requires distinct subagents"
904            ),
905        }
906    }
907}
908
909impl std::error::Error for AgentSwarmValidationError {}
910
911#[cfg(test)]
912mod tests {
913    use std::sync::Arc;
914
915    use super::*;
916
917    struct NoopDispatcher;
918
919    #[async_trait::async_trait]
920    impl SubagentDispatcher for NoopDispatcher {
921        fn id(&self) -> SubagentDispatcherId {
922            "noop".to_string()
923        }
924
925        fn definitions(&self) -> Vec<SubagentDefinition> {
926            vec![SubagentDefinition {
927                agent_type: "explore".to_string(),
928                description: "Explore the workspace".to_string(),
929                tools: vec!["Read".to_string()],
930                model: Some("test-model".to_string()),
931                system_prompt: Some("Report findings only".to_string()),
932                permission_mode: SubagentPermissionMode::ReadOnly,
933                max_turns: Some(4),
934                max_result_chars: Some(4000),
935            }]
936        }
937
938        async fn dispatch(
939            &self,
940            _parent_thread_id: ThreadId,
941            _parent_turn_id: TurnId,
942            request: SubagentRequest,
943        ) -> anyhow::Result<SubagentResult> {
944            Ok(SubagentResult {
945                thread_id: "child-thread".to_string(),
946                turn_id: "child-turn".to_string(),
947                agent_type: request
948                    .subagent_type
949                    .unwrap_or_else(|| "explore".to_string()),
950                model: request.model,
951                final_message: "done".to_string(),
952                usage: None,
953                exit_reason: SubagentExitReason::Completed,
954                transcript: None,
955                metadata: serde_json::json!({}),
956            })
957        }
958    }
959
960    #[tokio::test]
961    async fn subagent_dispatcher_trait_is_object_safe() {
962        let dispatcher: Arc<dyn SubagentDispatcher> = Arc::new(NoopDispatcher);
963
964        assert_eq!(dispatcher.id(), "noop");
965        assert_eq!(dispatcher.definitions()[0].agent_type, "explore");
966
967        let result = dispatcher
968            .dispatch(
969                "parent-thread".to_string(),
970                "parent-turn".to_string(),
971                SubagentRequest {
972                    description: "Check files".to_string(),
973                    prompt: "Find the API entrypoint".to_string(),
974                    subagent_type: Some("explore".to_string()),
975                    model: Some("test-model".to_string()),
976                    tools: Some(vec!["Read".to_string()]),
977                    lane: None,
978                    max_concurrent: None,
979                    allowed_tools: None,
980                    parent_deadline_seconds: None,
981                    inputs: None,
982                    timeout_seconds: Some(10),
983                },
984            )
985            .await
986            .unwrap();
987
988        assert_eq!(result.thread_id, "child-thread");
989        assert_eq!(result.exit_reason, SubagentExitReason::Completed);
990    }
991
992    fn swarm_request(items: &[&str]) -> AgentSwarmRequest {
993        AgentSwarmRequest {
994            description: "inspect files".to_string(),
995            subagent_type: Some("explore".to_string()),
996            prompt_template: Some("Read {{item}} and report.".to_string()),
997            items: items.iter().map(|item| item.to_string()).collect(),
998            resume_agent_ids: BTreeMap::new(),
999        }
1000    }
1001
1002    #[test]
1003    fn agent_swarm_config_clamps_into_bounds() {
1004        let clamped = AgentSwarmConfig {
1005            max_subagents: 9001,
1006            initial_launch_limit: 0,
1007            launch_interval_ms: 700,
1008            max_concurrency: Some(0),
1009            child_timeout_seconds: Some(0),
1010            rate_limit_max_retries: 9001,
1011            ..AgentSwarmConfig::default()
1012        }
1013        .clamped();
1014        assert_eq!(clamped.max_subagents, AGENT_SWARM_MAX_SUBAGENTS);
1015        assert_eq!(clamped.initial_launch_limit, 1);
1016        assert_eq!(clamped.max_concurrency, Some(1));
1017        assert_eq!(clamped.child_timeout_seconds, Some(1));
1018        assert_eq!(
1019            clamped.rate_limit_max_retries,
1020            AGENT_SWARM_RATE_LIMIT_MAX_RETRIES_CAP
1021        );
1022    }
1023
1024    #[test]
1025    fn build_specs_expands_items_in_order() {
1026        let specs = build_agent_swarm_specs(
1027            &swarm_request(&["a.rs", "b.rs"]),
1028            &AgentSwarmConfig::default(),
1029        )
1030        .unwrap();
1031        assert_eq!(specs.len(), 2);
1032        assert_eq!(specs[0].index, 1);
1033        assert_eq!(specs[0].kind, AgentSwarmChildKind::Spawn);
1034        assert_eq!(specs[0].prompt, "Read a.rs and report.");
1035        assert_eq!(specs[1].prompt, "Read b.rs and report.");
1036    }
1037
1038    #[test]
1039    fn build_specs_orders_resumes_before_spawns() {
1040        let mut request = swarm_request(&["a.rs"]);
1041        request
1042            .resume_agent_ids
1043            .insert("agent-9".to_string(), "continue".to_string());
1044        let specs = build_agent_swarm_specs(&request, &AgentSwarmConfig::default()).unwrap();
1045        assert_eq!(specs.len(), 2);
1046        assert_eq!(specs[0].kind, AgentSwarmChildKind::Resume);
1047        assert_eq!(specs[0].resume_agent_id.as_deref(), Some("agent-9"));
1048        assert_eq!(specs[1].kind, AgentSwarmChildKind::Spawn);
1049        assert_eq!(specs[1].index, 2);
1050    }
1051
1052    #[test]
1053    fn build_specs_rejects_single_item_without_resume() {
1054        let err =
1055            build_agent_swarm_specs(&swarm_request(&["only.rs"]), &AgentSwarmConfig::default())
1056                .unwrap_err();
1057        assert_eq!(err, AgentSwarmValidationError::TooFewItems);
1058    }
1059
1060    #[test]
1061    fn build_specs_rejects_missing_placeholder() {
1062        let mut request = swarm_request(&["a.rs", "b.rs"]);
1063        request.prompt_template = Some("no placeholder here".to_string());
1064        let err = build_agent_swarm_specs(&request, &AgentSwarmConfig::default()).unwrap_err();
1065        assert_eq!(err, AgentSwarmValidationError::MissingPlaceholder);
1066    }
1067
1068    #[test]
1069    fn build_specs_rejects_duplicate_prompts() {
1070        let request = swarm_request(&["dup", "dup"]);
1071        let err = build_agent_swarm_specs(&request, &AgentSwarmConfig::default()).unwrap_err();
1072        assert!(matches!(
1073            err,
1074            AgentSwarmValidationError::DuplicatePrompt { .. }
1075        ));
1076    }
1077
1078    #[test]
1079    fn build_specs_enforces_max_subagents() {
1080        let config = AgentSwarmConfig {
1081            max_subagents: 2,
1082            ..AgentSwarmConfig::default()
1083        };
1084        let err = build_agent_swarm_specs(&swarm_request(&["a", "b", "c"]), &config).unwrap_err();
1085        assert_eq!(
1086            err,
1087            AgentSwarmValidationError::TooManySubagents { total: 3, max: 2 }
1088        );
1089    }
1090
1091    #[test]
1092    fn agent_swarm_result_renders_summary_and_resume_hint() {
1093        let result = AgentSwarmResult::from_children(vec![
1094            AgentSwarmChildResult {
1095                index: 1,
1096                kind: AgentSwarmChildKind::Spawn,
1097                item: Some("a.rs".to_string()),
1098                agent_id: Some("agent-1".to_string()),
1099                outcome: AgentSwarmChildOutcome::Completed,
1100                state: None,
1101                body: "ok".to_string(),
1102                usage: None,
1103            },
1104            AgentSwarmChildResult {
1105                index: 2,
1106                kind: AgentSwarmChildKind::Spawn,
1107                item: Some("b & c.rs".to_string()),
1108                agent_id: Some("agent-2".to_string()),
1109                outcome: AgentSwarmChildOutcome::Failed,
1110                state: Some(AgentSwarmChildState::Started),
1111                body: "boom <fatal>".to_string(),
1112                usage: None,
1113            },
1114        ]);
1115        assert_eq!(result.completed, 1);
1116        assert_eq!(result.failed, 1);
1117        assert!(result.needs_resume_hint());
1118        let text = result.render_text();
1119        assert!(text.contains("<summary>completed: 1, failed: 1</summary>"));
1120        assert!(text.contains("<resume_hint>"));
1121        assert!(text.contains("item=\"b &amp; c.rs\""));
1122        assert!(text.contains("boom &lt;fatal&gt;"));
1123        assert!(text.contains("outcome=\"failed\""));
1124    }
1125
1126    #[test]
1127    fn agent_swarm_dtos_round_trip_json() {
1128        let result = AgentSwarmResult::from_children(vec![AgentSwarmChildResult {
1129            index: 1,
1130            kind: AgentSwarmChildKind::Resume,
1131            item: None,
1132            agent_id: Some("agent-1".to_string()),
1133            outcome: AgentSwarmChildOutcome::Aborted,
1134            state: Some(AgentSwarmChildState::NotStarted),
1135            body: "cancelled".to_string(),
1136            usage: None,
1137        }]);
1138        let json = serde_json::to_value(&result).unwrap();
1139        assert_eq!(json["aborted"], 1);
1140        assert_eq!(json["children"][0]["kind"], "resume");
1141        assert_eq!(json["children"][0]["outcome"], "aborted");
1142        assert_eq!(json["children"][0]["state"], "not_started");
1143        let round: AgentSwarmResult = serde_json::from_value(json).unwrap();
1144        assert_eq!(round, result);
1145    }
1146
1147    #[test]
1148    fn agent_swarm_trigger_auto_exit_rules() {
1149        assert!(!AgentSwarmModeTrigger::Manual.should_auto_exit());
1150        assert!(AgentSwarmModeTrigger::Task.should_auto_exit());
1151        assert!(AgentSwarmModeTrigger::Tool.should_auto_exit());
1152    }
1153
1154    #[test]
1155    fn batch_violation_allows_single_swarm_alone() {
1156        assert_eq!(
1157            agent_swarm_batch_violation(["agent_swarm"].into_iter()),
1158            None
1159        );
1160    }
1161
1162    #[test]
1163    fn batch_violation_allows_batches_without_swarm() {
1164        assert_eq!(
1165            agent_swarm_batch_violation(["read_file", "write_file"].into_iter()),
1166            None
1167        );
1168    }
1169
1170    #[test]
1171    fn batch_violation_flags_swarm_mixed_with_other_tools() {
1172        assert_eq!(
1173            agent_swarm_batch_violation(["agent_swarm", "read_file"].into_iter()),
1174            Some(AgentSwarmBatchViolation::MixedWithOtherTools)
1175        );
1176        let message = AgentSwarmBatchViolation::MixedWithOtherTools.deny_message();
1177        assert!(message.contains("only tool call"));
1178    }
1179
1180    #[test]
1181    fn batch_violation_flags_multiple_swarms() {
1182        assert_eq!(
1183            agent_swarm_batch_violation(["agent_swarm", "agent_swarm"].into_iter()),
1184            Some(AgentSwarmBatchViolation::MultipleSwarms {
1185                has_other_tools: false
1186            })
1187        );
1188        assert_eq!(
1189            agent_swarm_batch_violation(["agent_swarm", "agent_swarm", "read_file"].into_iter()),
1190            Some(AgentSwarmBatchViolation::MultipleSwarms {
1191                has_other_tools: true
1192            })
1193        );
1194        let message = AgentSwarmBatchViolation::MultipleSwarms {
1195            has_other_tools: true,
1196        }
1197        .deny_message();
1198        assert!(message.contains("one swarm at a time"));
1199        assert!(message.contains("combined with other tools"));
1200    }
1201}