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 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
225pub const AGENT_SWARM_TOOL_NAME: &str = "agent_swarm";
239
240pub const AGENT_SWARM_PROMPT_PLACEHOLDER: &str = "{{item}}";
242
243pub const AGENT_SWARM_MAX_SUBAGENTS: usize = 128;
245pub const AGENT_SWARM_INITIAL_LAUNCH_LIMIT: usize = 5;
247pub const AGENT_SWARM_LAUNCH_INTERVAL_MS: u64 = 700;
249pub const AGENT_SWARM_RATE_LIMIT_MAX_RETRIES: usize = 4;
252pub const AGENT_SWARM_RATE_LIMIT_BASE_BACKOFF_MS: u64 = 3_000;
255pub const AGENT_SWARM_RATE_LIMIT_MAX_RETRIES_CAP: usize = 8;
258pub const AGENT_SWARM_RATE_LIMIT_SHRINK_INTERVAL_MS: u64 = 2_000;
263pub 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
284pub 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#[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#[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 pub child_count: usize,
318 #[serde(with = "time::serde::rfc3339")]
319 pub timestamp: time::OffsetDateTime,
320}
321
322#[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#[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 pub fn resolved(&self) -> usize {
350 self.completed + self.failed + self.aborted
351 }
352}
353
354#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum AgentSwarmBatchViolation {
385 MultipleSwarms {
387 has_other_tools: bool,
389 },
390 MixedWithOtherTools,
392}
393
394impl AgentSwarmBatchViolation {
395 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
422pub 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#[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 pub fn should_auto_exit(self) -> bool {
472 matches!(self, Self::Task | Self::Tool)
473 }
474}
475
476#[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#[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#[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#[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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
542 pub resume_agent_ids: BTreeMap<String, String>,
543}
544
545#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct AgentSwarmChildSpec {
548 pub index: usize,
550 pub kind: AgentSwarmChildKind,
551 pub item: Option<String>,
553 pub prompt: String,
555 pub resume_agent_id: Option<String>,
557}
558
559#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct AgentSwarmConfig {
562 pub max_subagents: usize,
565 pub initial_launch_limit: usize,
567 pub launch_interval_ms: u64,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub max_concurrency: Option<usize>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub child_timeout_seconds: Option<u64>,
575 #[serde(default = "default_rate_limit_max_retries")]
577 pub rate_limit_max_retries: usize,
578 #[serde(default = "default_rate_limit_base_backoff_ms")]
581 pub rate_limit_base_backoff_ms: u64,
582 #[serde(default = "default_rate_limit_shrink_interval_ms")]
586 pub rate_limit_shrink_interval_ms: u64,
587 #[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 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#[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#[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 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 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 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('&', "&")
748 .replace('"', """)
749 .replace('<', "<")
750 .replace('>', ">")
751}
752
753fn escape_xml_text(value: &str) -> String {
754 value
755 .replace('&', "&")
756 .replace('<', "<")
757 .replace('>', ">")
758}
759
760pub 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#[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 & c.rs\""));
1122 assert!(text.contains("boom <fatal>"));
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}