From 7273d8e76dfc9a1688446005a7472ddd00661147 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Thu, 18 Dec 2025 08:44:51 -0700 Subject: [PATCH] Add SpawnSubagentTool --- crates/agent/src/thread.rs | 5 +- crates/agent/src/tools.rs | 3 + crates/agent/src/tools/spawn_subagent_tool.rs | 221 ++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 crates/agent/src/tools/spawn_subagent_tool.rs diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index bb22470b9e7db934f949a13b86fd13f9dc58beed..ef5f72f0ee3da9606200c01f1eeb163da72fded7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2,8 +2,8 @@ use crate::{ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, - RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool, - ThinkingTool, WebSearchTool, + RestoreFileFromDiskTool, SaveFileTool, SpawnSubagentTool, SystemPromptTemplate, Template, + Templates, TerminalTool, ThinkingTool, WebSearchTool, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; @@ -1011,6 +1011,7 @@ impl Thread { )); self.add_tool(SaveFileTool::new(self.project.clone())); self.add_tool(RestoreFileFromDiskTool::new(self.project.clone())); + self.add_tool(SpawnSubagentTool::new(None)); self.add_tool(TerminalTool::new(self.project.clone(), environment)); self.add_tool(ThinkingTool); self.add_tool(WebSearchTool); diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 358903a32baa5ead9b073642015e6829501307a2..c04fa8d1f9d93607610b57bb59a395eefd903030 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -14,6 +14,7 @@ mod open_tool; mod read_file_tool; mod restore_file_from_disk_tool; mod save_file_tool; +mod spawn_subagent_tool; mod terminal_tool; mod thinking_tool; @@ -38,6 +39,7 @@ pub use open_tool::*; pub use read_file_tool::*; pub use restore_file_from_disk_tool::*; pub use save_file_tool::*; +pub use spawn_subagent_tool::*; pub use terminal_tool::*; pub use thinking_tool::*; @@ -96,6 +98,7 @@ tools! { ReadFileTool, RestoreFileFromDiskTool, SaveFileTool, + SpawnSubagentTool, TerminalTool, ThinkingTool, WebSearchTool, diff --git a/crates/agent/src/tools/spawn_subagent_tool.rs b/crates/agent/src/tools/spawn_subagent_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca24d5f9ffaa8a2fe42aeab1f4bc240582151804 --- /dev/null +++ b/crates/agent/src/tools/spawn_subagent_tool.rs @@ -0,0 +1,221 @@ +use crate::{AgentTool, ToolCallEventStream}; +use agent_client_protocol as acp; +use anyhow::{Result, anyhow}; +use gpui::{App, AsyncApp, SharedString, Task}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Spawn a subagent (child thread) that can be visited while it runs, and returns a value to the parent. +/// +/// Note: This file intentionally defines only the tool surface and streaming updates. The actual +/// spawning/navigation plumbing requires a host capability (session manager + UI) that is not yet +/// present in the native agent tool environment. Until that capability is wired in, this tool will +/// fail with a clear error. +/// +/// Expected design (to be implemented in the host): +/// - The tool is constructed with a `SubagentHost` implementation that can: +/// - create a child session/thread +/// - stream child progress updates +/// - complete with a final return value +/// - provide a navigable URI for the UI (e.g. `zed://agent/thread/`) +/// +/// The tool then: +/// - emits a `ResourceLink` pointing at the child thread so users can open it +/// - streams progress into the tool call card as markdown +/// - resolves with the child's final return value (string) +#[derive(JsonSchema, Serialize, Deserialize)] +pub struct SpawnSubagentToolInput { + /// A short label/title for the subagent. + pub title: String, + + /// The instructions to run in the subagent. + pub prompt: String, + + /// Optional: profile id to use for the subagent. + #[serde(default)] + pub profile_id: Option, +} + +/// The final return value from the subagent. +pub type SpawnSubagentToolOutput = String; + +/// Host interface required to implement spawning + streaming + returning. +/// +/// This is intentionally minimal and object-safe to allow injecting a host backed by `NativeAgent`. +pub trait SubagentHost: Send + Sync + 'static { + /// Start a child subagent session and return a handle containing a navigable URI plus a stream + /// of progress updates and a final result. + /// + /// The returned `SubagentRun` must: + /// - yield `Progress` updates in-order + /// - eventually yield exactly one `Final` or `Error` + fn spawn_subagent( + &self, + title: String, + prompt: String, + profile_id: Option, + cx: &mut AsyncApp, + ) -> Task>; +} + +/// A handle for a running subagent. +pub struct SubagentRun { + /// URI that the UI can open to navigate to the child thread. + pub thread_uri: String, + + /// A human-friendly label for the link. + pub thread_label: String, + + /// Progress stream for tool UI updates. + pub updates: futures::channel::mpsc::UnboundedReceiver, +} + +pub enum SubagentUpdate { + /// A streaming progress chunk (e.g. "thinking…", partial summary, etc). + Progress(String), + + /// The final return value for the parent. + Final(String), + + /// Terminal error. + Error(anyhow::Error), +} + +pub struct SpawnSubagentTool { + host: Option>, +} + +impl SpawnSubagentTool { + pub fn new(host: Option>) -> Self { + Self { host } + } +} + +impl AgentTool for SpawnSubagentTool { + type Input = SpawnSubagentToolInput; + type Output = SpawnSubagentToolOutput; + + fn name() -> &'static str { + "spawn_subagent" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn description() -> SharedString { + "Spawns a child Zed Agent thread (subagent), streams its progress, and returns its final value to the parent." + .into() + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + if let Ok(input) = input { + format!("Spawn subagent: {}", input.title).into() + } else { + "Spawn subagent".into() + } + } + + fn run( + self: Arc, + input: Self::Input, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let Some(host) = self.host.clone() else { + return Task::ready(Err(anyhow!( + "spawn_subagent is not available: native agent host capability is not wired into tools yet" + ))); + }; + + let title = input.title; + let prompt = input.prompt; + let profile_id = input.profile_id; + + cx.spawn(async move |cx| { + // Start the child run via host. + let mut run = host + .spawn_subagent(title.clone(), prompt, profile_id, cx) + .await?; + + // Emit a link to the child thread so the user can open/visit it. + event_stream.update_fields( + acp::ToolCallUpdateFields::new().content(vec![acp::ToolCallContent::Content( + acp::Content::new(acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(run.thread_label.clone(), run.thread_uri.clone()) + .title(run.thread_label.clone()), + )), + )]), + ); + + // Stream progress as markdown appended below the link. + let mut accumulated_progress = String::new(); + while let Some(update) = run.updates.next().await { + match update { + SubagentUpdate::Progress(chunk) => { + if !accumulated_progress.is_empty() { + accumulated_progress.push('\n'); + } + accumulated_progress.push_str(&chunk); + + event_stream.update_fields( + acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink( + acp::ResourceLink::new( + run.thread_label.clone(), + run.thread_uri.clone(), + ) + .title(run.thread_label.clone()), + ), + )), + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new( + format!("### Subagent progress\n\n{}", accumulated_progress), + )), + )), + ]), + ); + } + SubagentUpdate::Final(value) => { + // Final update for UI (optional). + event_stream.update_fields( + acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink( + acp::ResourceLink::new( + run.thread_label.clone(), + run.thread_uri.clone(), + ) + .title(run.thread_label.clone()), + ), + )), + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new(format!( + "### Subagent returned\n\n{}", + value + ))), + )), + ]), + ); + + return Ok(value); + } + SubagentUpdate::Error(error) => { + return Err(error); + } + } + } + + Err(anyhow!("subagent stream ended without producing a final value")) + }) + } +} + +// futures::StreamExt is only needed in the async run implementation; keep it scoped here. +use futures::StreamExt as _;