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 allowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
92        self.allowed_tools = tools.into_iter().map(Into::into).collect();
93        self
94    }
95
96    /// Alias for `allowed_tools()` for convenience.
97    pub fn tools(self, t: impl IntoIterator<Item = impl Into<String>>) -> Self {
98        self.allowed_tools(t)
99    }
100
101    /// Set the content source (prompt).
102    pub fn source(mut self, source: ContentSource) -> Self {
103        self.source = source;
104        self
105    }
106
107    /// Set the source type.
108    pub fn source_type(mut self, source_type: SourceType) -> Self {
109        self.source_type = source_type;
110        self
111    }
112
113    /// Set the model alias or ID.
114    pub fn model(mut self, model: impl Into<String>) -> Self {
115        self.model = Some(model.into());
116        self
117    }
118
119    /// Set the model type.
120    pub fn model_type(mut self, model_type: ModelType) -> Self {
121        self.model_type = Some(model_type);
122        self
123    }
124
125    /// Set available skills.
126    pub fn skills(mut self, skills: impl IntoIterator<Item = impl Into<String>>) -> Self {
127        self.skills = skills.into_iter().map(Into::into).collect();
128        self
129    }
130
131    /// Resolve the model to use for this subagent.
132    ///
133    /// Supports both direct model IDs and aliases:
134    /// - `"opus"` → resolves to reasoning model (e.g., claude-opus-4-6)
135    /// - `"sonnet"` → resolves to primary model (e.g., claude-sonnet-4-5)
136    /// - `"haiku"` → resolves to small model (e.g., claude-haiku-4-5)
137    /// - Direct model ID → passed through unchanged
138    ///
139    /// Falls back to `model_type` if `model` is not set.
140    pub fn resolve_model<'a>(&'a self, config: &'a ModelConfig) -> &'a str {
141        if let Some(ref model) = self.model {
142            return config.resolve_alias(model);
143        }
144        config.get(self.model_type.unwrap_or_default())
145    }
146
147    /// Load the full prompt content.
148    pub async fn load_prompt(&self) -> crate::Result<String> {
149        self.source.load().await
150    }
151}
152
153impl Named for SubagentIndex {
154    fn name(&self) -> &str {
155        &self.name
156    }
157}
158
159impl ToolRestricted for SubagentIndex {
160    fn allowed_tools(&self) -> &[String] {
161        &self.allowed_tools
162    }
163}
164
165#[async_trait]
166impl Index for SubagentIndex {
167    fn source(&self) -> &ContentSource {
168        &self.source
169    }
170
171    fn source_type(&self) -> SourceType {
172        self.source_type
173    }
174
175    fn to_summary_line(&self) -> String {
176        let tools_str = if self.allowed_tools.is_empty() {
177            "*".to_string()
178        } else {
179            self.allowed_tools.join(", ")
180        };
181        format!(
182            "- {}: {} (Tools: {})",
183            self.name, self.description, tools_str
184        )
185    }
186
187    fn description(&self) -> &str {
188        &self.description
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_subagent_index_creation() {
198        let subagent = SubagentIndex::new("reviewer", "Code reviewer")
199            .source(ContentSource::in_memory("Review the code"))
200            .source_type(SourceType::Project)
201            .tools(["Read", "Grep", "Glob"])
202            .model("haiku");
203
204        assert_eq!(subagent.name, "reviewer");
205        assert!(subagent.has_tool_restrictions());
206        assert!(subagent.is_tool_allowed("Read"));
207        assert!(!subagent.is_tool_allowed("Bash"));
208    }
209
210    #[test]
211    fn test_summary_line() {
212        let subagent = SubagentIndex::new("Explore", "Fast codebase exploration")
213            .tools(["Read", "Grep", "Glob", "Bash"]);
214
215        let summary = subagent.to_summary_line();
216        assert!(summary.contains("Explore"));
217        assert!(summary.contains("Fast codebase exploration"));
218        assert!(summary.contains("Read, Grep, Glob, Bash"));
219    }
220
221    #[test]
222    fn test_summary_line_no_tools() {
223        let subagent = SubagentIndex::new("general-purpose", "General purpose agent");
224        let summary = subagent.to_summary_line();
225        assert!(summary.contains("(Tools: *)"));
226    }
227
228    #[tokio::test]
229    async fn test_load_prompt() {
230        let subagent = SubagentIndex::new("test", "Test agent")
231            .source(ContentSource::in_memory("You are a test agent."));
232
233        let prompt = subagent.load_prompt().await.unwrap();
234        assert_eq!(prompt, "You are a test agent.");
235    }
236
237    #[test]
238    fn test_resolve_model_with_alias() {
239        let config = ModelConfig::default();
240
241        let subagent = SubagentIndex::new("fast", "Fast agent")
242            .source(ContentSource::in_memory("Be quick"))
243            .model("haiku");
244        assert!(subagent.resolve_model(&config).contains("haiku"));
245
246        let subagent = SubagentIndex::new("smart", "Smart agent")
247            .source(ContentSource::in_memory("Think deep"))
248            .model("opus");
249        assert!(subagent.resolve_model(&config).contains("opus"));
250    }
251
252    #[test]
253    fn test_resolve_model_with_type() {
254        let config = ModelConfig::default();
255
256        let subagent = SubagentIndex::new("typed", "Typed agent")
257            .source(ContentSource::in_memory("Use type"))
258            .model_type(ModelType::Small);
259        assert!(subagent.resolve_model(&config).contains("haiku"));
260    }
261}