subagent_tool.rs

  1use acp_thread::SUBAGENT_SESSION_ID_META_KEY;
  2use agent_client_protocol as acp;
  3use anyhow::{Result, anyhow};
  4use futures::FutureExt as _;
  5use gpui::{App, Entity, SharedString, Task, WeakEntity};
  6use language_model::LanguageModelToolResultContent;
  7use schemars::JsonSchema;
  8use serde::{Deserialize, Serialize};
  9use std::sync::Arc;
 10use std::{rc::Rc, time::Duration};
 11
 12use crate::{AgentTool, Thread, ThreadEnvironment, ToolCallEventStream};
 13
 14/// Spawns a subagent with its own context window to perform a delegated task.
 15///
 16/// Use this tool when you want to do any of the following:
 17/// - Perform an investigation where all you need to know is the outcome, not the research that led to that outcome.
 18/// - Complete a self-contained task where you need to know if it succeeded or failed (and how), but none of its intermediate output.
 19/// - Run multiple tasks in parallel that would take significantly longer to run sequentially.
 20///
 21/// You control what the subagent does by providing:
 22/// 1. A task prompt describing what the subagent should do
 23/// 2. A summary prompt that tells the subagent how to summarize its work when done
 24/// 3. A "context running out" prompt for when the subagent is low on tokens
 25///
 26/// Each subagent has access to the same tools you do. You can optionally restrict
 27/// which tools each subagent can use.
 28///
 29/// Note:
 30/// - Maximum 8 subagents can run in parallel
 31/// - Subagents cannot use tools you don't have access to
 32/// - If spawning multiple subagents that might write to the filesystem, provide
 33///   guidance on how to avoid conflicts (e.g. assign each to different directories)
 34/// - Instruct subagents to be concise in their summaries to conserve your context
 35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
 36pub struct SubagentToolInput {
 37    /// Short label displayed in the UI while the subagent runs (e.g., "Researching alternatives")
 38    pub label: String,
 39
 40    /// The initial prompt that tells the subagent what task to perform.
 41    /// Be specific about what you want the subagent to accomplish.
 42    pub task_prompt: String,
 43
 44    /// The prompt sent to the subagent when it completes its task, asking it
 45    /// to summarize what it did and return results. This summary becomes the
 46    /// tool result you receive.
 47    ///
 48    /// Example: "Summarize what you found, listing the top 3 alternatives with pros/cons."
 49    pub summary_prompt: String,
 50
 51    /// Optional: Maximum runtime in milliseconds. If exceeded, the subagent is
 52    /// asked to summarize and return. No timeout by default.
 53    #[serde(default)]
 54    pub timeout_ms: Option<u64>,
 55
 56    /// Optional: List of tool names the subagent is allowed to use.
 57    /// If not provided, the subagent can use all tools available to the parent.
 58    /// Tools listed here must be a subset of the parent's available tools.
 59    #[serde(default)]
 60    pub allowed_tools: Option<Vec<String>>,
 61}
 62
 63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
 64pub struct SubagentToolOutput {
 65    pub subagent_session_id: acp::SessionId,
 66    pub summary: String,
 67}
 68
 69impl From<SubagentToolOutput> for LanguageModelToolResultContent {
 70    fn from(output: SubagentToolOutput) -> Self {
 71        output.summary.into()
 72    }
 73}
 74
 75/// Tool that spawns a subagent thread to work on a task.
 76pub struct SubagentTool {
 77    parent_thread: WeakEntity<Thread>,
 78    environment: Rc<dyn ThreadEnvironment>,
 79}
 80
 81impl SubagentTool {
 82    pub fn new(parent_thread: WeakEntity<Thread>, environment: Rc<dyn ThreadEnvironment>) -> Self {
 83        Self {
 84            parent_thread,
 85            environment,
 86        }
 87    }
 88
 89    fn validate_allowed_tools(
 90        allowed_tools: &Option<Vec<String>>,
 91        parent_thread: &Entity<Thread>,
 92        cx: &App,
 93    ) -> Result<()> {
 94        let Some(allowed_tools) = allowed_tools else {
 95            return Ok(());
 96        };
 97
 98        let thread = parent_thread.read(cx);
 99        let invalid_tools: Vec<_> = allowed_tools
100            .iter()
101            .filter(|tool| !thread.tools.contains_key(tool.as_str()))
102            .map(|s| format!("'{s}'"))
103            .collect::<Vec<_>>();
104
105        if !invalid_tools.is_empty() {
106            return Err(anyhow!(
107                "The following tools do not exist: {}",
108                invalid_tools.join(", ")
109            ));
110        }
111
112        Ok(())
113    }
114}
115
116impl AgentTool for SubagentTool {
117    type Input = SubagentToolInput;
118    type Output = SubagentToolOutput;
119
120    const NAME: &'static str = "subagent";
121
122    fn kind() -> acp::ToolKind {
123        acp::ToolKind::Other
124    }
125
126    fn initial_title(
127        &self,
128        input: Result<Self::Input, serde_json::Value>,
129        _cx: &mut App,
130    ) -> SharedString {
131        input
132            .map(|i| i.label.into())
133            .unwrap_or_else(|_| "Subagent".into())
134    }
135
136    fn run(
137        self: Arc<Self>,
138        input: Self::Input,
139        event_stream: ToolCallEventStream,
140        cx: &mut App,
141    ) -> Task<Result<SubagentToolOutput>> {
142        let Some(parent_thread_entity) = self.parent_thread.upgrade() else {
143            return Task::ready(Err(anyhow!("Parent thread no longer exists")));
144        };
145
146        if let Err(e) =
147            Self::validate_allowed_tools(&input.allowed_tools, &parent_thread_entity, cx)
148        {
149            return Task::ready(Err(e));
150        }
151
152        let subagent = match self.environment.create_subagent(
153            parent_thread_entity,
154            input.label,
155            input.task_prompt,
156            input.timeout_ms.map(|ms| Duration::from_millis(ms)),
157            input.allowed_tools,
158            cx,
159        ) {
160            Ok(subagent) => subagent,
161            Err(err) => return Task::ready(Err(err)),
162        };
163
164        let subagent_session_id = subagent.id();
165
166        event_stream.subagent_spawned(subagent_session_id.clone());
167        let meta = acp::Meta::from_iter([(
168            SUBAGENT_SESSION_ID_META_KEY.into(),
169            subagent_session_id.to_string().into(),
170        )]);
171        event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta));
172
173        cx.spawn(async move |cx| {
174            let summary_task = subagent.wait_for_summary(input.summary_prompt, cx);
175
176            futures::select_biased! {
177                summary = summary_task.fuse() => summary.map(|summary| SubagentToolOutput {
178                    summary,
179                    subagent_session_id,
180                }),
181                _ = event_stream.cancelled_by_user().fuse() => {
182                    Err(anyhow!("Subagent was cancelled by user"))
183                }
184            }
185        })
186    }
187
188    fn replay(
189        &self,
190        _input: Self::Input,
191        output: Self::Output,
192        event_stream: ToolCallEventStream,
193        _cx: &mut App,
194    ) -> Result<()> {
195        event_stream.subagent_spawned(output.subagent_session_id.clone());
196        let meta = acp::Meta::from_iter([(
197            SUBAGENT_SESSION_ID_META_KEY.into(),
198            output.subagent_session_id.to_string().into(),
199        )]);
200        event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta));
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::{ContextServerRegistry, Templates, Thread};
209    use fs::FakeFs;
210    use gpui::{AppContext as _, TestAppContext};
211    use project::Project;
212    use prompt_store::ProjectContext;
213    use serde_json::json;
214    use settings::SettingsStore;
215    use util::path;
216
217    async fn create_thread_with_tools(cx: &mut TestAppContext) -> Entity<Thread> {
218        cx.update(|cx| {
219            let settings_store = SettingsStore::test(cx);
220            cx.set_global(settings_store);
221        });
222        let fs = FakeFs::new(cx.executor());
223        fs.insert_tree(path!("/test"), json!({})).await;
224        let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
225        let project_context = cx.new(|_cx| ProjectContext::default());
226        let context_server_store =
227            project.read_with(cx, |project, _| project.context_server_store());
228        let context_server_registry =
229            cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
230
231        cx.new(|cx| {
232            let mut thread = Thread::new(
233                project,
234                project_context,
235                context_server_registry,
236                Templates::new(),
237                None,
238                cx,
239            );
240            thread.add_tool(crate::NowTool, None);
241            thread.add_tool(crate::WebSearchTool, None);
242            thread
243        })
244    }
245
246    #[gpui::test]
247    async fn test_validate_allowed_tools_succeeds_for_valid_tools(cx: &mut TestAppContext) {
248        let thread = create_thread_with_tools(cx).await;
249
250        cx.update(|cx| {
251            assert!(SubagentTool::validate_allowed_tools(&None, &thread, cx).is_ok());
252
253            let valid_tools = Some(vec!["now".to_string()]);
254            assert!(SubagentTool::validate_allowed_tools(&valid_tools, &thread, cx).is_ok());
255
256            let both_tools = Some(vec!["now".to_string(), "web_search".to_string()]);
257            assert!(SubagentTool::validate_allowed_tools(&both_tools, &thread, cx).is_ok());
258        });
259    }
260
261    #[gpui::test]
262    async fn test_validate_allowed_tools_fails_for_unknown_tools(cx: &mut TestAppContext) {
263        let thread = create_thread_with_tools(cx).await;
264
265        cx.update(|cx| {
266            let unknown_tools = Some(vec!["nonexistent_tool".to_string()]);
267            let result = SubagentTool::validate_allowed_tools(&unknown_tools, &thread, cx);
268            assert!(result.is_err());
269            let error_message = result.unwrap_err().to_string();
270            assert!(
271                error_message.contains("'nonexistent_tool'"),
272                "Expected error to mention the invalid tool name, got: {error_message}"
273            );
274
275            let mixed_tools = Some(vec![
276                "now".to_string(),
277                "fake_tool_a".to_string(),
278                "fake_tool_b".to_string(),
279            ]);
280            let result = SubagentTool::validate_allowed_tools(&mixed_tools, &thread, cx);
281            assert!(result.is_err());
282            let error_message = result.unwrap_err().to_string();
283            assert!(
284                error_message.contains("'fake_tool_a'") && error_message.contains("'fake_tool_b'"),
285                "Expected error to mention both invalid tool names, got: {error_message}"
286            );
287            assert!(
288                !error_message.contains("'now'"),
289                "Expected error to not mention valid tool 'now', got: {error_message}"
290            );
291        });
292    }
293}