claude_agent/subagents/
index.rs

1//! Subagent Index for Progressive Disclosure.
2//!
3//! `SubagentIndex` contains minimal metadata (name, description, tools) that is
4//! always loaded in the Task tool description. The full prompt content is loaded
5//! on-demand only when the subagent is spawned.
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9
10use crate::client::{ModelConfig, ModelType};
11use crate::common::{ContentSource, Index, Named, SourceType, ToolRestricted};
12
13/// Subagent index entry - minimal metadata always available in context.
14///
15/// This enables the progressive disclosure pattern where:
16/// - Metadata (~30 tokens per subagent) is always in the Task tool description
17/// - Full prompt (~200 tokens per subagent) is loaded only when spawned
18///
19/// # Token Efficiency
20///
21/// With 10 subagents:
22/// - Index only: 10 × 30 = ~300 tokens
23/// - Full load: 10 × 200 = ~2,000 tokens
24/// - Savings: ~85%
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct SubagentIndex {
27    /// Subagent name (unique identifier)
28    pub name: String,
29
30    /// Subagent description - used by Claude for semantic matching
31    pub description: String,
32
33    /// Allowed tools for this subagent (if restricted)
34    #[serde(default, alias = "tools")]
35    pub allowed_tools: Vec<String>,
36
37    /// Source location for loading full prompt
38    pub source: ContentSource,
39
40    /// Source type (builtin, user, project, managed)
41    #[serde(default)]
42    pub source_type: SourceType,
43
44    /// Optional model alias or ID
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub model: Option<String>,
47
48    /// Model type for resolution
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub model_type: Option<ModelType>,
51
52    /// Skills available to this subagent
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub skills: Vec<String>,
55}
56
57impl SubagentIndex {
58    /// Create a new subagent index entry.
59    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
60        Self {
61            name: name.into(),
62            description: description.into(),
63            allowed_tools: Vec::new(),
64            source: ContentSource::default(),
65            source_type: SourceType::default(),
66            model: None,
67            model_type: None,
68            skills: Vec::new(),
69        }
70    }
71
72    /// Set allowed tools.
73    pub fn with_allowed_tools(
74        mut self,
75        tools: impl IntoIterator<Item = impl Into<String>>,
76    ) -> Self {
77        self.allowed_tools = tools.into_iter().map(Into::into).collect();
78        self
79    }
80
81    /// Alias for `with_allowed_tools()` for convenience.
82    pub fn with_tools(self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
83        self.with_allowed_tools(tools)
84    }
85
86    /// Set the content source (prompt).
87    pub fn with_source(mut self, source: ContentSource) -> Self {
88        self.source = source;
89        self
90    }
91
92    /// Set the source type.
93    pub fn with_source_type(mut self, source_type: SourceType) -> Self {
94        self.source_type = source_type;
95        self
96    }
97
98    /// Set the model alias or ID.
99    pub fn with_model(mut self, model: impl Into<String>) -> Self {
100        self.model = Some(model.into());
101        self
102    }
103
104    /// Set the model type.
105    pub fn with_model_type(mut self, model_type: ModelType) -> Self {
106        self.model_type = Some(model_type);
107        self
108    }
109
110    /// Set available skills.
111    pub fn with_skills(mut self, skills: impl IntoIterator<Item = impl Into<String>>) -> Self {
112        self.skills = skills.into_iter().map(Into::into).collect();
113        self
114    }
115
116    /// Resolve the model to use for this subagent.
117    ///
118    /// Supports both direct model IDs and aliases:
119    /// - `"opus"` → resolves to reasoning model (e.g., claude-opus-4-5)
120    /// - `"sonnet"` → resolves to primary model (e.g., claude-sonnet-4-5)
121    /// - `"haiku"` → resolves to small model (e.g., claude-haiku-4-5)
122    /// - Direct model ID → passed through unchanged
123    ///
124    /// Falls back to `model_type` if `model` is not set.
125    pub fn resolve_model<'a>(&'a self, config: &'a ModelConfig) -> &'a str {
126        if let Some(ref model) = self.model {
127            return config.resolve_alias(model);
128        }
129        config.get(self.model_type.unwrap_or_default())
130    }
131
132    /// Load the full prompt content.
133    pub async fn load_prompt(&self) -> crate::Result<String> {
134        self.source.load().await
135    }
136}
137
138impl Named for SubagentIndex {
139    fn name(&self) -> &str {
140        &self.name
141    }
142}
143
144impl ToolRestricted for SubagentIndex {
145    fn allowed_tools(&self) -> &[String] {
146        &self.allowed_tools
147    }
148}
149
150#[async_trait]
151impl Index for SubagentIndex {
152    fn source(&self) -> &ContentSource {
153        &self.source
154    }
155
156    fn source_type(&self) -> SourceType {
157        self.source_type
158    }
159
160    fn to_summary_line(&self) -> String {
161        let tools_str = if self.allowed_tools.is_empty() {
162            "*".to_string()
163        } else {
164            self.allowed_tools.join(", ")
165        };
166        format!(
167            "- {}: {} (Tools: {})",
168            self.name, self.description, tools_str
169        )
170    }
171
172    fn description(&self) -> &str {
173        &self.description
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_subagent_index_creation() {
183        let subagent = SubagentIndex::new("reviewer", "Code reviewer")
184            .with_source(ContentSource::in_memory("Review the code"))
185            .with_source_type(SourceType::Project)
186            .with_tools(["Read", "Grep", "Glob"])
187            .with_model("haiku");
188
189        assert_eq!(subagent.name, "reviewer");
190        assert!(subagent.has_tool_restrictions());
191        assert!(subagent.is_tool_allowed("Read"));
192        assert!(!subagent.is_tool_allowed("Bash"));
193    }
194
195    #[test]
196    fn test_summary_line() {
197        let subagent = SubagentIndex::new("Explore", "Fast codebase exploration")
198            .with_tools(["Read", "Grep", "Glob", "Bash"]);
199
200        let summary = subagent.to_summary_line();
201        assert!(summary.contains("Explore"));
202        assert!(summary.contains("Fast codebase exploration"));
203        assert!(summary.contains("Read, Grep, Glob, Bash"));
204    }
205
206    #[test]
207    fn test_summary_line_no_tools() {
208        let subagent = SubagentIndex::new("general-purpose", "General purpose agent");
209        let summary = subagent.to_summary_line();
210        assert!(summary.contains("(Tools: *)"));
211    }
212
213    #[tokio::test]
214    async fn test_load_prompt() {
215        let subagent = SubagentIndex::new("test", "Test agent")
216            .with_source(ContentSource::in_memory("You are a test agent."));
217
218        let prompt = subagent.load_prompt().await.unwrap();
219        assert_eq!(prompt, "You are a test agent.");
220    }
221
222    #[test]
223    fn test_resolve_model_with_alias() {
224        let config = ModelConfig::default();
225
226        let subagent = SubagentIndex::new("fast", "Fast agent")
227            .with_source(ContentSource::in_memory("Be quick"))
228            .with_model("haiku");
229        assert!(subagent.resolve_model(&config).contains("haiku"));
230
231        let subagent = SubagentIndex::new("smart", "Smart agent")
232            .with_source(ContentSource::in_memory("Think deep"))
233            .with_model("opus");
234        assert!(subagent.resolve_model(&config).contains("opus"));
235    }
236
237    #[test]
238    fn test_resolve_model_with_type() {
239        let config = ModelConfig::default();
240
241        let subagent = SubagentIndex::new("typed", "Typed agent")
242            .with_source(ContentSource::in_memory("Use type"))
243            .with_model_type(ModelType::Small);
244        assert!(subagent.resolve_model(&config).contains("haiku"));
245    }
246}