Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/settings/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@
"tools": {
"copy_path": true,
"create_directory": true,
"create_thread": true,
"delete_path": true,
"diagnostics": true,
"apply_code_action": true,
Expand All @@ -1126,6 +1127,7 @@
"find_references": true,
"get_code_actions": true,
"go_to_definition": true,
"list_agents_and_models": true,
"list_directory": true,
"move_path": true,
"rename_symbol": true,
Expand All @@ -1144,8 +1146,10 @@
// We don't know which of the context server tools are safe for the "Ask" profile, so we don't enable them by default.
// "enable_all_context_servers": true,
"tools": {
"create_thread": true,
"diagnostics": true,
"fetch": true,
"list_agents_and_models": true,
"list_directory": true,
"find_path": true,
"find_references": true,
Expand Down
64 changes: 64 additions & 0 deletions crates/agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,25 @@ impl LanguageModels {
}
}

/// Implemented by the UI layer to provide the ability for agent tools to create
/// sibling threads that appear in the agent panel.
///
/// `agent_ui::AgentPanel` installs an implementation of this trait on the
/// `NativeAgent` when it sets up a connection. Tools in a native-agent thread
/// then discover and use the host via `NativeThreadEnvironment`. The UI side
/// is responsible for keeping the installed host current; a host whose
/// backing UI has been torn down will fail its first request with a clear
/// error rather than being detected up front.
pub trait SiblingThreadHost {
fn create_sibling_thread(
&self,
request: SiblingThreadRequest,
cx: &mut AsyncApp,
) -> Task<Result<SiblingThreadInfo>>;

fn list_available_agents(&self, cx: &mut App) -> Result<AvailableAgents>;
}

pub struct NativeAgent {
/// Session ID -> Session mapping
sessions: HashMap<acp::SessionId, Session>,
Expand All @@ -304,6 +323,8 @@ pub struct NativeAgent {
templates: Arc<Templates>,
/// Cached model information
models: LanguageModels,
/// Handler installed by the UI for `create_thread` / `list_agents_and_models` tools.
sibling_thread_host: Option<Rc<dyn SiblingThreadHost>>,
fs: Arc<dyn Fs>,
_subscriptions: Vec<Subscription>,
/// Tracks the lifecycle of global skills directory observation. We
Expand Down Expand Up @@ -372,6 +393,7 @@ impl NativeAgent {
projects: HashMap::default(),
templates,
models: LanguageModels::new(cx),
sibling_thread_host: None,
fs,
_subscriptions: subscriptions,
skills_state: SkillsState::default(),
Expand Down Expand Up @@ -498,6 +520,14 @@ impl NativeAgent {
}
}

pub fn set_sibling_thread_host(&mut self, host: Rc<dyn SiblingThreadHost>) {
self.sibling_thread_host = Some(host);
}

pub fn sibling_thread_host(&self) -> Option<Rc<dyn SiblingThreadHost>> {
self.sibling_thread_host.clone()
}

fn new_session(
&mut self,
project: Entity<Project>,
Expand Down Expand Up @@ -2693,6 +2723,40 @@ impl ThreadEnvironment for NativeThreadEnvironment {
) -> Result<Rc<dyn SubagentHandle>> {
self.resume_subagent_thread(session_id, cx)
}

fn create_sibling_thread(
&self,
request: SiblingThreadRequest,
cx: &mut AsyncApp,
) -> Task<Result<SiblingThreadInfo>> {
let host = match self
.agent
.read_with(cx, |agent, _| agent.sibling_thread_host())
{
Ok(Some(host)) => host,
Ok(None) => {
return Task::ready(Err(anyhow!(
"No sibling-thread host is registered. This usually means the \
agent panel hasn't been initialized in this workspace."
)));
}
Err(err) => return Task::ready(Err(err)),
};
host.create_sibling_thread(request, cx)
}

fn list_available_agents(&self, cx: &mut App) -> Result<AvailableAgents> {
let host = self
.agent
.read_with(cx, |agent, _| agent.sibling_thread_host())?
.ok_or_else(|| {
anyhow!(
"No sibling-thread host is registered. This usually means the \
agent panel hasn't been initialized in this workspace."
)
})?;
host.list_available_agents(cx)
}
}

#[derive(Debug, Clone)]
Expand Down
115 changes: 115 additions & 0 deletions crates/agent/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5964,6 +5964,121 @@ async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
);
}

#[gpui::test]
async fn test_sibling_thread_tools_gated_by_feature_flag(cx: &mut TestAppContext) {
init_test(cx);

// `CreateThreadToolFeatureFlag::enabled_for_staff()` returns true, which
// means tests in debug builds resolve it to ON unless we explicitly
// override it via `FeatureFlagsSettings`. Register the settings type and
// install an (empty) `FeatureFlagStore` global so the `cx.has_flag` path
// actually consults overrides instead of falling back to the
// staff-debug-build default.
cx.update(|cx| {
SettingsStore::update_global(cx, |store, _| {
store.register_setting::<feature_flags::FeatureFlagsSettings>();
});
cx.update_flags(false, vec![]);
});

fn set_flag_override(value: &str, cx: &mut TestAppContext) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |content| {
content
.feature_flags
.get_or_insert_default()
.insert("create-thread-tool".to_string(), value.to_string());
});
});
});
}

let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/test"), json!({})).await;
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
let project_context = cx.new(|_cx| ProjectContext::default());
let context_server_store = project.read_with(cx, |project, _| project.context_server_store());
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let model = Arc::new(FakeLanguageModel::default());
let environment = Rc::new(cx.update(|cx| {
FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx))
}));

let thread = cx.new(|cx| {
let mut thread = Thread::new(
project,
project_context,
context_server_registry,
Templates::new(),
Some(model.clone() as Arc<dyn LanguageModel>),
cx,
);
thread.add_default_tools(environment, cx);
thread
});

let sibling_tool_names = [CreateThreadTool::NAME, ListAgentsAndModelsTool::NAME];

// Like the LSP/rename tools, sibling-thread tools are registered
// unconditionally and gated only at exposure time. The registration must
// be visible regardless of the flag's current value.
thread.read_with(cx, |thread, _| {
for name in &sibling_tool_names {
assert!(
thread.has_registered_tool(name),
"expected sibling-thread tool {name} to be registered"
);
}
});

// Flag explicitly off: a completion request must omit the tools.
set_flag_override("off", cx);
thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["hello"], cx)
})
.unwrap();
cx.run_until_parked();

let completion = model.pending_completions().pop().unwrap();
let tool_names = tool_names_for_completion(&completion);
for name in &sibling_tool_names {
assert!(
!tool_names.iter().any(|t| t == name),
"expected {name} to be hidden when create-thread-tool flag is off, \
but completion tools were: {tool_names:?}"
);
}
// Sanity check: an unrelated default tool should still be exposed.
assert!(
tool_names.iter().any(|t| t == ReadFileTool::NAME),
"expected non-sibling-thread tools to still be exposed, got: {tool_names:?}"
);
model.end_last_completion_stream();
cx.run_until_parked();

// Flag explicitly on: the next completion request must include both tools.
set_flag_override("on", cx);
thread
.update(cx, |thread, cx| {
thread.send(UserMessageId::new(), ["hello again"], cx)
})
.unwrap();
cx.run_until_parked();

let completion = model.pending_completions().pop().unwrap();
let tool_names = tool_names_for_completion(&completion);
for name in &sibling_tool_names {
assert!(
tool_names.iter().any(|t| t == name),
"expected {name} to be exposed when create-thread-tool flag is on, \
but completion tools were: {tool_names:?}"
);
}
}

#[gpui::test]
async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
init_test(cx);
Expand Down
119 changes: 111 additions & 8 deletions crates/agent/src/thread.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
use crate::{
ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool,
DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool,
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SpawnAgentTool,
SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision,
UpdatePlanTool, UpdateTitleTool, WebSearchTool, WriteFileTool, decide_permission_from_settings,
CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool,
FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool,
RenameTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool,
ToolPermissionDecision, UpdatePlanTool, UpdateTitleTool, WebSearchTool, WriteFileTool,
decide_permission_from_settings,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
use agent_settings::UserAgentsMd;
use feature_flags::{
FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, UpdatePlanToolFeatureFlag,
UpdateTitleToolFeatureFlag,
CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag,
UpdatePlanToolFeatureFlag, UpdateTitleToolFeatureFlag,
};

use agent_client_protocol::schema as acp;
Expand Down Expand Up @@ -675,6 +676,97 @@ pub trait ThreadEnvironment {
"Resuming subagent sessions is not supported"
))
}

/// Creates an independent sibling thread visible in the agent sidebar.
/// Unlike subagents, sibling threads are first-class threads that persist
/// and run in parallel without reporting results back to the parent.
fn create_sibling_thread(
&self,
request: SiblingThreadRequest,
cx: &mut AsyncApp,
) -> Task<Result<SiblingThreadInfo>> {
let _ = request;
let _ = cx;
Task::ready(Err(anyhow::anyhow!(
"Creating sibling threads is not supported in this environment"
)))
}

/// Lists the agents and models available for use with `create_sibling_thread`.
fn list_available_agents(&self, cx: &mut App) -> Result<AvailableAgents> {
let _ = cx;
Err(anyhow::anyhow!(
"Listing available agents is not supported in this environment"
))
}
}

/// A request to create a new sibling thread.
#[derive(Debug, Clone)]
pub struct SiblingThreadRequest {
/// A short title for the new thread, shown in the sidebar.
pub title: SharedString,
/// The initial prompt to send to the new thread.
pub prompt: String,
/// Optional agent ID to use. Defaults to the native Zed agent.
pub agent_id: Option<String>,
/// Optional model override, as `provider/model-id`.
/// Defaults to the user's configured default model for the agent.
pub model: Option<String>,
/// Whether to create the thread in a new git worktree workspace.
pub use_new_worktree: bool,
/// Optional worktree directory name. When `None`, the UI generates a
/// random non-colliding name (matching the manual "Create worktree"
/// flow). Only relevant when `use_new_worktree` is true.
pub worktree_name: Option<String>,
/// Git ref (branch, tag, or commit) to base the new worktree on.
/// Only relevant when `use_new_worktree` is true.
pub base_ref: Option<String>,
}

/// Information returned when a sibling thread is successfully created.
#[derive(Debug, Clone)]
pub struct SiblingThreadInfo {
/// The title assigned to the thread.
pub title: SharedString,
/// The agent ID used for the thread.
pub agent_id: String,
/// The model ID used for the thread, if known.
pub model: Option<String>,
/// An optional, non-fatal heads-up about the created thread that the
/// caller should relay or take into account (e.g., the project had an
/// unusual worktree layout that affected how the new worktree was set
/// up). Empty when nothing noteworthy happened.
pub warning: Option<String>,
}

/// A list of agents and, for each, the models available for use.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableAgents {
pub agents: Vec<AvailableAgent>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableAgent {
/// Identifier used when creating a thread.
pub id: String,
/// Human-readable name shown in the UI.
pub name: SharedString,
/// Whether this is Zed's built-in native agent.
pub is_native: bool,
/// Models available for this agent. May be empty if models are not
/// enumerated up front (e.g., external agents that choose their own).
pub models: Vec<AvailableModel>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableModel {
/// Identifier to pass as the `model` field when creating a thread.
pub id: String,
/// Human-readable name.
pub name: SharedString,
/// Whether this is the default model for the agent.
pub is_default: bool,
}

#[derive(Debug)]
Expand Down Expand Up @@ -1735,8 +1827,16 @@ impl Thread {
self.add_tool(RenameTool::new(self.project.clone()));

if self.depth() < MAX_SUBAGENT_DEPTH {
self.add_tool(SpawnAgentTool::new(environment));
self.add_tool(SpawnAgentTool::new(environment.clone()));
}

// Sibling-thread tools are exposed at every depth: a subagent should
// still be able to kick off independent sibling work on behalf of the
// user, even when it can no longer nest further subagents. Visibility
// to the model is gated by `CreateThreadToolFeatureFlag` in
// `Thread::enabled_tools`.
self.add_tool(CreateThreadTool::new(environment.clone()));
self.add_tool(ListAgentsAndModelsTool::new(environment));
}

pub fn add_tool<T: AgentTool>(&mut self, tool: T) {
Expand Down Expand Up @@ -3062,6 +3162,9 @@ impl Thread {
| GetCodeActionsTool::NAME
| ApplyCodeActionTool::NAME
| GoToDefinitionTool::NAME => cx.has_flag::<LspToolFeatureFlag>(),
CreateThreadTool::NAME | ListAgentsAndModelsTool::NAME => {
cx.has_flag::<CreateThreadToolFeatureFlag>()
}
_ => true,
})
.collect::<BTreeMap<_, _>>();
Expand Down
Loading
Loading