Skip to main content

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