From 27a26d53b1ea1d83ab16c840a5ba1f05da96edea Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Fri, 22 Aug 2025 08:28:03 -0300
Subject: [PATCH 01/24] thread view: Inform when editing previous messages is
unavailable (#36727)
Release Notes:
- N/A
---
assets/icons/pencil_unavailable.svg | 6 ++
crates/agent_ui/src/acp/thread_view.rs | 97 ++++++++++++-------
crates/agent_ui/src/ui.rs | 2 +
.../src/ui/unavailable_editing_tooltip.rs | 29 ++++++
crates/icons/src/icons.rs | 1 +
5 files changed, 98 insertions(+), 37 deletions(-)
create mode 100644 assets/icons/pencil_unavailable.svg
create mode 100644 crates/agent_ui/src/ui/unavailable_editing_tooltip.rs
diff --git a/assets/icons/pencil_unavailable.svg b/assets/icons/pencil_unavailable.svg
new file mode 100644
index 0000000000..4241d766ac
--- /dev/null
+++ b/assets/icons/pencil_unavailable.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index dae89b3283..619885144a 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -57,7 +57,9 @@ use crate::agent_diff::AgentDiff;
use crate::profile_selector::{ProfileProvider, ProfileSelector};
use crate::ui::preview::UsageCallout;
-use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip};
+use crate::ui::{
+ AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
+};
use crate::{
AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
@@ -1239,6 +1241,8 @@ impl AcpThreadView {
None
};
+ let agent_name = self.agent.name();
+
v_flex()
.id(("user_message", entry_ix))
.pt_2()
@@ -1292,42 +1296,61 @@ impl AcpThreadView {
.text_xs()
.child(editor.clone().into_any_element()),
)
- .when(editing && editor_focus, |this|
- this.child(
- h_flex()
- .absolute()
- .top_neg_3p5()
- .right_3()
- .gap_1()
- .rounded_sm()
- .border_1()
- .border_color(cx.theme().colors().border)
- .bg(cx.theme().colors().editor_background)
- .overflow_hidden()
- .child(
- IconButton::new("cancel", IconName::Close)
- .icon_color(Color::Error)
- .icon_size(IconSize::XSmall)
- .on_click(cx.listener(Self::cancel_editing))
- )
- .child(
- IconButton::new("regenerate", IconName::Return)
- .icon_color(Color::Muted)
- .icon_size(IconSize::XSmall)
- .tooltip(Tooltip::text(
- "Editing will restart the thread from this point."
- ))
- .on_click(cx.listener({
- let editor = editor.clone();
- move |this, _, window, cx| {
- this.regenerate(
- entry_ix, &editor, window, cx,
- );
- }
- })),
- )
- )
- ),
+ .when(editor_focus, |this| {
+ let base_container = h_flex()
+ .absolute()
+ .top_neg_3p5()
+ .right_3()
+ .gap_1()
+ .rounded_sm()
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .bg(cx.theme().colors().editor_background)
+ .overflow_hidden();
+
+ if message.id.is_some() {
+ this.child(
+ base_container
+ .child(
+ IconButton::new("cancel", IconName::Close)
+ .icon_color(Color::Error)
+ .icon_size(IconSize::XSmall)
+ .on_click(cx.listener(Self::cancel_editing))
+ )
+ .child(
+ IconButton::new("regenerate", IconName::Return)
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::XSmall)
+ .tooltip(Tooltip::text(
+ "Editing will restart the thread from this point."
+ ))
+ .on_click(cx.listener({
+ let editor = editor.clone();
+ move |this, _, window, cx| {
+ this.regenerate(
+ entry_ix, &editor, window, cx,
+ );
+ }
+ })),
+ )
+ )
+ } else {
+ this.child(
+ base_container
+ .border_dashed()
+ .child(
+ IconButton::new("editing_unavailable", IconName::PencilUnavailable)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .style(ButtonStyle::Transparent)
+ .tooltip(move |_window, cx| {
+ cx.new(|_| UnavailableEditingTooltip::new(agent_name.into()))
+ .into()
+ })
+ )
+ )
+ }
+ }),
)
.into_any()
}
diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs
index e27a224240..ada973cddf 100644
--- a/crates/agent_ui/src/ui.rs
+++ b/crates/agent_ui/src/ui.rs
@@ -4,9 +4,11 @@ mod context_pill;
mod end_trial_upsell;
mod onboarding_modal;
pub mod preview;
+mod unavailable_editing_tooltip;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use context_pill::*;
pub use end_trial_upsell::*;
pub use onboarding_modal::*;
+pub use unavailable_editing_tooltip::*;
diff --git a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs
new file mode 100644
index 0000000000..78d4c64e0a
--- /dev/null
+++ b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs
@@ -0,0 +1,29 @@
+use gpui::{Context, IntoElement, Render, Window};
+use ui::{prelude::*, tooltip_container};
+
+pub struct UnavailableEditingTooltip {
+ agent_name: SharedString,
+}
+
+impl UnavailableEditingTooltip {
+ pub fn new(agent_name: SharedString) -> Self {
+ Self { agent_name }
+ }
+}
+
+impl Render for UnavailableEditingTooltip {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ tooltip_container(window, cx, |this, _, _| {
+ this.child(Label::new("Unavailable Editing")).child(
+ div().max_w_64().child(
+ Label::new(format!(
+ "Editing previous messages is not available for {} yet.",
+ self.agent_name
+ ))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ })
+ }
+}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 38f02c2206..b5f891713a 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -164,6 +164,7 @@ pub enum IconName {
PageDown,
PageUp,
Pencil,
+ PencilUnavailable,
Person,
Pin,
PlayOutlined,
From 3b7c1744b424c9127267e8935ac668ece52394e4 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Fri, 22 Aug 2025 09:52:44 -0300
Subject: [PATCH 02/24] thread view: Add more UI improvements (#36750)
Release Notes:
- N/A
---
assets/icons/attach.svg | 3 ++
assets/icons/tool_think.svg | 2 +-
crates/agent_servers/src/claude.rs | 2 +-
crates/agent_servers/src/gemini.rs | 2 +-
crates/agent_ui/src/acp/thread_view.rs | 68 +++++++++-----------------
crates/agent_ui/src/agent_panel.rs | 5 ++
crates/icons/src/icons.rs | 1 +
7 files changed, 36 insertions(+), 47 deletions(-)
create mode 100644 assets/icons/attach.svg
diff --git a/assets/icons/attach.svg b/assets/icons/attach.svg
new file mode 100644
index 0000000000..f923a3c7c8
--- /dev/null
+++ b/assets/icons/attach.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/tool_think.svg b/assets/icons/tool_think.svg
index efd5908a90..773f5e7fa7 100644
--- a/assets/icons/tool_think.svg
+++ b/assets/icons/tool_think.svg
@@ -1,3 +1,3 @@
diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs
index d6ccabb130..ef666974f1 100644
--- a/crates/agent_servers/src/claude.rs
+++ b/crates/agent_servers/src/claude.rs
@@ -44,7 +44,7 @@ pub struct ClaudeCode;
impl AgentServer for ClaudeCode {
fn name(&self) -> &'static str {
- "Welcome to Claude Code"
+ "Claude Code"
}
fn empty_state_headline(&self) -> &'static str {
diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs
index 3b892e7931..29120fff6e 100644
--- a/crates/agent_servers/src/gemini.rs
+++ b/crates/agent_servers/src/gemini.rs
@@ -23,7 +23,7 @@ impl AgentServer for Gemini {
}
fn empty_state_headline(&self) -> &'static str {
- "Welcome to Gemini CLI"
+ self.name()
}
fn empty_state_message(&self) -> &'static str {
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index 619885144a..d27dee1fe6 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -1697,7 +1697,7 @@ impl AcpThreadView {
.absolute()
.top_0()
.right_0()
- .w_12()
+ .w_16()
.h_full()
.bg(linear_gradient(
90.,
@@ -1837,6 +1837,7 @@ impl AcpThreadView {
.w_full()
.max_w_full()
.ml_1p5()
+ .overflow_hidden()
.child(h_flex().pr_8().child(self.render_markdown(
tool_call.label.clone(),
default_markdown_style(false, true, window, cx),
@@ -1906,13 +1907,10 @@ impl AcpThreadView {
.text_color(cx.theme().colors().text_muted)
.child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
.child(
- Button::new(button_id, "Collapse")
+ IconButton::new(button_id, IconName::ChevronUp)
.full_width()
.style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .icon(IconName::ChevronUp)
.icon_color(Color::Muted)
- .icon_position(IconPosition::Start)
.on_click(cx.listener({
move |this: &mut Self, _, _, cx: &mut Context| {
this.expanded_tool_calls.remove(&tool_call_id);
@@ -2414,39 +2412,32 @@ impl AcpThreadView {
return None;
}
+ let has_both = user_rules_text.is_some() && rules_file_text.is_some();
+
Some(
- v_flex()
+ h_flex()
.px_2p5()
- .gap_1()
+ .pb_1()
+ .child(
+ Icon::new(IconName::Attach)
+ .size(IconSize::XSmall)
+ .color(Color::Disabled),
+ )
.when_some(user_rules_text, |parent, user_rules_text| {
parent.child(
h_flex()
- .group("user-rules")
.id("user-rules")
- .w_full()
- .child(
- Icon::new(IconName::Reader)
- .size(IconSize::XSmall)
- .color(Color::Disabled),
- )
+ .ml_1()
+ .mr_1p5()
.child(
Label::new(user_rules_text)
.size(LabelSize::XSmall)
.color(Color::Muted)
.truncate()
- .buffer_font(cx)
- .ml_1p5()
- .mr_0p5(),
- )
- .child(
- IconButton::new("open-prompt-library", IconName::ArrowUpRight)
- .shape(ui::IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Ignored)
- .visible_on_hover("user-rules")
- // TODO: Figure out a way to pass focus handle here so we can display the `OpenRulesLibrary` keybinding
- .tooltip(Tooltip::text("View User Rules")),
+ .buffer_font(cx),
)
+ .hover(|s| s.bg(cx.theme().colors().element_hover))
+ .tooltip(Tooltip::text("View User Rules"))
.on_click(move |_event, window, cx| {
window.dispatch_action(
Box::new(OpenRulesLibrary {
@@ -2457,33 +2448,20 @@ impl AcpThreadView {
}),
)
})
+ .when(has_both, |this| this.child(Divider::vertical()))
.when_some(rules_file_text, |parent, rules_file_text| {
parent.child(
h_flex()
- .group("project-rules")
.id("project-rules")
- .w_full()
- .child(
- Icon::new(IconName::Reader)
- .size(IconSize::XSmall)
- .color(Color::Disabled),
- )
+ .ml_1p5()
.child(
Label::new(rules_file_text)
.size(LabelSize::XSmall)
.color(Color::Muted)
- .buffer_font(cx)
- .ml_1p5()
- .mr_0p5(),
- )
- .child(
- IconButton::new("open-rule", IconName::ArrowUpRight)
- .shape(ui::IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Ignored)
- .visible_on_hover("project-rules")
- .tooltip(Tooltip::text("View Project Rules")),
+ .buffer_font(cx),
)
+ .hover(|s| s.bg(cx.theme().colors().element_hover))
+ .tooltip(Tooltip::text("View Project Rules"))
.on_click(cx.listener(Self::handle_open_rules)),
)
})
@@ -4080,8 +4058,10 @@ impl AcpThreadView {
.group("thread-controls-container")
.w_full()
.mr_1()
+ .pt_1()
.pb_2()
.px(RESPONSE_PADDING_X)
+ .gap_px()
.opacity(0.4)
.hover(|style| style.opacity(1.))
.flex_wrap()
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index d2ff6aa4f3..469898d10f 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -2041,9 +2041,11 @@ impl AgentPanel {
match state {
ThreadSummary::Pending => Label::new(ThreadSummary::DEFAULT)
.truncate()
+ .color(Color::Muted)
.into_any_element(),
ThreadSummary::Generating => Label::new(LOADING_SUMMARY_PLACEHOLDER)
.truncate()
+ .color(Color::Muted)
.into_any_element(),
ThreadSummary::Ready(_) => div()
.w_full()
@@ -2098,6 +2100,7 @@ impl AgentPanel {
.into_any_element()
} else {
Label::new(thread_view.read(cx).title(cx))
+ .color(Color::Muted)
.truncate()
.into_any_element()
}
@@ -2111,6 +2114,7 @@ impl AgentPanel {
match summary {
ContextSummary::Pending => Label::new(ContextSummary::DEFAULT)
+ .color(Color::Muted)
.truncate()
.into_any_element(),
ContextSummary::Content(summary) => {
@@ -2122,6 +2126,7 @@ impl AgentPanel {
} else {
Label::new(LOADING_SUMMARY_PLACEHOLDER)
.truncate()
+ .color(Color::Muted)
.into_any_element()
}
}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index b5f891713a..4fc6039fd7 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -34,6 +34,7 @@ pub enum IconName {
ArrowRightLeft,
ArrowUp,
ArrowUpRight,
+ Attach,
AudioOff,
AudioOn,
Backspace,
From 4f0fad69960d0aad5cfd9840592d70fa82df5d91 Mon Sep 17 00:00:00 2001
From: Antonio Scandurra
Date: Fri, 22 Aug 2025 15:16:42 +0200
Subject: [PATCH 03/24] acp: Support calling tools provided by MCP servers
(#36752)
Release Notes:
- N/A
---
crates/agent2/src/tests/mod.rs | 441 +++++++++++++++++++++++++++++-
crates/agent2/src/thread.rs | 148 +++++++---
crates/context_server/src/test.rs | 36 ++-
3 files changed, 561 insertions(+), 64 deletions(-)
diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs
index 09048488c8..60b3198081 100644
--- a/crates/agent2/src/tests/mod.rs
+++ b/crates/agent2/src/tests/mod.rs
@@ -4,26 +4,35 @@ use agent_client_protocol::{self as acp};
use agent_settings::AgentProfileId;
use anyhow::Result;
use client::{Client, UserStore};
+use context_server::{ContextServer, ContextServerCommand, ContextServerId};
use fs::{FakeFs, Fs};
-use futures::{StreamExt, channel::mpsc::UnboundedReceiver};
+use futures::{
+ StreamExt,
+ channel::{
+ mpsc::{self, UnboundedReceiver},
+ oneshot,
+ },
+};
use gpui::{
App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient,
};
use indoc::indoc;
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
- LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequestMessage,
- LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, StopReason,
- fake_provider::FakeLanguageModel,
+ LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest,
+ LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolSchemaFormat,
+ LanguageModelToolUse, MessageContent, Role, StopReason, fake_provider::FakeLanguageModel,
};
use pretty_assertions::assert_eq;
-use project::Project;
+use project::{
+ Project, context_server_store::ContextServerStore, project_settings::ProjectSettings,
+};
use prompt_store::ProjectContext;
use reqwest_client::ReqwestClient;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
-use settings::SettingsStore;
+use settings::{Settings, SettingsStore};
use std::{path::Path, rc::Rc, sync::Arc, time::Duration};
use util::path;
@@ -931,6 +940,334 @@ async fn test_profiles(cx: &mut TestAppContext) {
assert_eq!(tool_names, vec![InfiniteTool::name()]);
}
+#[gpui::test]
+async fn test_mcp_tools(cx: &mut TestAppContext) {
+ let ThreadTest {
+ model,
+ thread,
+ context_server_store,
+ fs,
+ ..
+ } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ // Override profiles and wait for settings to be loaded.
+ fs.insert_file(
+ paths::settings_file(),
+ json!({
+ "agent": {
+ "profiles": {
+ "test": {
+ "name": "Test Profile",
+ "enable_all_context_servers": true,
+ "tools": {
+ EchoTool::name(): true,
+ }
+ },
+ }
+ }
+ })
+ .to_string()
+ .into_bytes(),
+ )
+ .await;
+ cx.run_until_parked();
+ thread.update(cx, |thread, _| {
+ thread.set_profile(AgentProfileId("test".into()))
+ });
+
+ let mut mcp_tool_calls = setup_context_server(
+ "test_server",
+ vec![context_server::types::Tool {
+ name: "echo".into(),
+ description: None,
+ input_schema: serde_json::to_value(
+ EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema),
+ )
+ .unwrap(),
+ output_schema: None,
+ annotations: None,
+ }],
+ &context_server_store,
+ cx,
+ );
+
+ let events = thread.update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Hey"], cx).unwrap()
+ });
+ cx.run_until_parked();
+
+ // Simulate the model calling the MCP tool.
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(tool_names_for_completion(&completion), vec!["echo"]);
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_1".into(),
+ name: "echo".into(),
+ raw_input: json!({"text": "test"}).to_string(),
+ input: json!({"text": "test"}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
+ assert_eq!(tool_call_params.name, "echo");
+ assert_eq!(tool_call_params.arguments, Some(json!({"text": "test"})));
+ tool_call_response
+ .send(context_server::types::CallToolResponse {
+ content: vec![context_server::types::ToolResponseContent::Text {
+ text: "test".into(),
+ }],
+ is_error: None,
+ meta: None,
+ structured_content: None,
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ assert_eq!(tool_names_for_completion(&completion), vec!["echo"]);
+ fake_model.send_last_completion_stream_text_chunk("Done!");
+ fake_model.end_last_completion_stream();
+ events.collect::>().await;
+
+ // Send again after adding the echo tool, ensuring the name collision is resolved.
+ let events = thread.update(cx, |thread, cx| {
+ thread.add_tool(EchoTool);
+ thread.send(UserMessageId::new(), ["Go"], cx).unwrap()
+ });
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ tool_names_for_completion(&completion),
+ vec!["echo", "test_server_echo"]
+ );
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_2".into(),
+ name: "test_server_echo".into(),
+ raw_input: json!({"text": "mcp"}).to_string(),
+ input: json!({"text": "mcp"}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: "tool_3".into(),
+ name: "echo".into(),
+ raw_input: json!({"text": "native"}).to_string(),
+ input: json!({"text": "native"}),
+ is_input_complete: true,
+ },
+ ));
+ fake_model.end_last_completion_stream();
+ cx.run_until_parked();
+
+ let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap();
+ assert_eq!(tool_call_params.name, "echo");
+ assert_eq!(tool_call_params.arguments, Some(json!({"text": "mcp"})));
+ tool_call_response
+ .send(context_server::types::CallToolResponse {
+ content: vec![context_server::types::ToolResponseContent::Text { text: "mcp".into() }],
+ is_error: None,
+ meta: None,
+ structured_content: None,
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ // Ensure the tool results were inserted with the correct names.
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ completion.messages.last().unwrap().content,
+ vec![
+ MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: "tool_3".into(),
+ tool_name: "echo".into(),
+ is_error: false,
+ content: "native".into(),
+ output: Some("native".into()),
+ },),
+ MessageContent::ToolResult(LanguageModelToolResult {
+ tool_use_id: "tool_2".into(),
+ tool_name: "test_server_echo".into(),
+ is_error: false,
+ content: "mcp".into(),
+ output: Some("mcp".into()),
+ },),
+ ]
+ );
+ fake_model.end_last_completion_stream();
+ events.collect::>().await;
+}
+
+#[gpui::test]
+async fn test_mcp_tool_truncation(cx: &mut TestAppContext) {
+ let ThreadTest {
+ model,
+ thread,
+ context_server_store,
+ fs,
+ ..
+ } = setup(cx, TestModel::Fake).await;
+ let fake_model = model.as_fake();
+
+ // Set up a profile with all tools enabled
+ fs.insert_file(
+ paths::settings_file(),
+ json!({
+ "agent": {
+ "profiles": {
+ "test": {
+ "name": "Test Profile",
+ "enable_all_context_servers": true,
+ "tools": {
+ EchoTool::name(): true,
+ DelayTool::name(): true,
+ WordListTool::name(): true,
+ ToolRequiringPermission::name(): true,
+ InfiniteTool::name(): true,
+ }
+ },
+ }
+ }
+ })
+ .to_string()
+ .into_bytes(),
+ )
+ .await;
+ cx.run_until_parked();
+
+ thread.update(cx, |thread, _| {
+ thread.set_profile(AgentProfileId("test".into()));
+ thread.add_tool(EchoTool);
+ thread.add_tool(DelayTool);
+ thread.add_tool(WordListTool);
+ thread.add_tool(ToolRequiringPermission);
+ thread.add_tool(InfiniteTool);
+ });
+
+ // Set up multiple context servers with some overlapping tool names
+ let _server1_calls = setup_context_server(
+ "xxx",
+ vec![
+ context_server::types::Tool {
+ name: "echo".into(), // Conflicts with native EchoTool
+ description: None,
+ input_schema: serde_json::to_value(
+ EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema),
+ )
+ .unwrap(),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "unique_tool_1".into(),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ ],
+ &context_server_store,
+ cx,
+ );
+
+ let _server2_calls = setup_context_server(
+ "yyy",
+ vec![
+ context_server::types::Tool {
+ name: "echo".into(), // Also conflicts with native EchoTool
+ description: None,
+ input_schema: serde_json::to_value(
+ EchoTool.input_schema(LanguageModelToolSchemaFormat::JsonSchema),
+ )
+ .unwrap(),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "unique_tool_2".into(),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ ],
+ &context_server_store,
+ cx,
+ );
+ let _server3_calls = setup_context_server(
+ "zzz",
+ vec![
+ context_server::types::Tool {
+ name: "a".repeat(MAX_TOOL_NAME_LENGTH - 2),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "b".repeat(MAX_TOOL_NAME_LENGTH - 1),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ context_server::types::Tool {
+ name: "c".repeat(MAX_TOOL_NAME_LENGTH + 1),
+ description: None,
+ input_schema: json!({"type": "object", "properties": {}}),
+ output_schema: None,
+ annotations: None,
+ },
+ ],
+ &context_server_store,
+ cx,
+ );
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.send(UserMessageId::new(), ["Go"], cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+ let completion = fake_model.pending_completions().pop().unwrap();
+ assert_eq!(
+ tool_names_for_completion(&completion),
+ vec![
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "delay",
+ "echo",
+ "infinite",
+ "tool_requiring_permission",
+ "unique_tool_1",
+ "unique_tool_2",
+ "word_list",
+ "xxx_echo",
+ "y_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "yyy_echo",
+ "z_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ ]
+ );
+}
+
#[gpui::test]
#[cfg_attr(not(feature = "e2e"), ignore)]
async fn test_cancellation(cx: &mut TestAppContext) {
@@ -1806,6 +2143,7 @@ struct ThreadTest {
model: Arc,
thread: Entity,
project_context: Entity,
+ context_server_store: Entity,
fs: Arc,
}
@@ -1844,6 +2182,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
WordListTool::name(): true,
ToolRequiringPermission::name(): true,
InfiniteTool::name(): true,
+ ThinkingTool::name(): true,
}
}
}
@@ -1900,8 +2239,9 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
.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(project.read(cx).context_server_store(), cx));
+ cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
let thread = cx.new(|cx| {
Thread::new(
project,
@@ -1916,6 +2256,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
model,
thread,
project_context,
+ context_server_store,
fs,
}
}
@@ -1950,3 +2291,89 @@ fn watch_settings(fs: Arc, cx: &mut App) {
})
.detach();
}
+
+fn tool_names_for_completion(completion: &LanguageModelRequest) -> Vec {
+ completion
+ .tools
+ .iter()
+ .map(|tool| tool.name.clone())
+ .collect()
+}
+
+fn setup_context_server(
+ name: &'static str,
+ tools: Vec,
+ context_server_store: &Entity,
+ cx: &mut TestAppContext,
+) -> mpsc::UnboundedReceiver<(
+ context_server::types::CallToolParams,
+ oneshot::Sender,
+)> {
+ cx.update(|cx| {
+ let mut settings = ProjectSettings::get_global(cx).clone();
+ settings.context_servers.insert(
+ name.into(),
+ project::project_settings::ContextServerSettings::Custom {
+ enabled: true,
+ command: ContextServerCommand {
+ path: "somebinary".into(),
+ args: Vec::new(),
+ env: None,
+ },
+ },
+ );
+ ProjectSettings::override_global(settings, cx);
+ });
+
+ let (mcp_tool_calls_tx, mcp_tool_calls_rx) = mpsc::unbounded();
+ let fake_transport = context_server::test::create_fake_transport(name, cx.executor())
+ .on_request::(move |_params| async move {
+ context_server::types::InitializeResponse {
+ protocol_version: context_server::types::ProtocolVersion(
+ context_server::types::LATEST_PROTOCOL_VERSION.to_string(),
+ ),
+ server_info: context_server::types::Implementation {
+ name: name.into(),
+ version: "1.0.0".to_string(),
+ },
+ capabilities: context_server::types::ServerCapabilities {
+ tools: Some(context_server::types::ToolsCapabilities {
+ list_changed: Some(true),
+ }),
+ ..Default::default()
+ },
+ meta: None,
+ }
+ })
+ .on_request::(move |_params| {
+ let tools = tools.clone();
+ async move {
+ context_server::types::ListToolsResponse {
+ tools,
+ next_cursor: None,
+ meta: None,
+ }
+ }
+ })
+ .on_request::(move |params| {
+ let mcp_tool_calls_tx = mcp_tool_calls_tx.clone();
+ async move {
+ let (response_tx, response_rx) = oneshot::channel();
+ mcp_tool_calls_tx
+ .unbounded_send((params, response_tx))
+ .unwrap();
+ response_rx.await.unwrap()
+ }
+ });
+ context_server_store.update(cx, |store, cx| {
+ store.start_server(
+ Arc::new(ContextServer::new(
+ ContextServerId(name.into()),
+ Arc::new(fake_transport),
+ )),
+ cx,
+ );
+ });
+ cx.run_until_parked();
+ mcp_tool_calls_rx
+}
diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs
index af18afa055..c89e5875f9 100644
--- a/crates/agent2/src/thread.rs
+++ b/crates/agent2/src/thread.rs
@@ -9,15 +9,15 @@ use action_log::ActionLog;
use agent::thread::{GitState, ProjectSnapshot, WorktreeSnapshot};
use agent_client_protocol as acp;
use agent_settings::{
- AgentProfileId, AgentSettings, CompletionMode, SUMMARIZE_THREAD_DETAILED_PROMPT,
- SUMMARIZE_THREAD_PROMPT,
+ AgentProfileId, AgentProfileSettings, AgentSettings, CompletionMode,
+ SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT,
};
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::adapt_schema_to_format;
use chrono::{DateTime, Utc};
use client::{ModelRequestUsage, RequestUsage};
use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit};
-use collections::{HashMap, IndexMap};
+use collections::{HashMap, HashSet, IndexMap};
use fs::Fs;
use futures::{
FutureExt,
@@ -56,6 +56,7 @@ use util::{ResultExt, markdown::MarkdownCodeBlock};
use uuid::Uuid;
const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user";
+pub const MAX_TOOL_NAME_LENGTH: usize = 64;
/// The ID of the user prompt that initiated a request.
///
@@ -627,7 +628,20 @@ impl Thread {
stream: &ThreadEventStream,
cx: &mut Context,
) {
- let Some(tool) = self.tools.get(tool_use.name.as_ref()) else {
+ let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| {
+ self.context_server_registry
+ .read(cx)
+ .servers()
+ .find_map(|(_, tools)| {
+ if let Some(tool) = tools.get(tool_use.name.as_ref()) {
+ Some(tool.clone())
+ } else {
+ None
+ }
+ })
+ });
+
+ let Some(tool) = tool else {
stream
.0
.unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall {
@@ -1079,6 +1093,10 @@ impl Thread {
self.cancel(cx);
let model = self.model.clone().context("No language model configured")?;
+ let profile = AgentSettings::get_global(cx)
+ .profiles
+ .get(&self.profile_id)
+ .context("Profile not found")?;
let (events_tx, events_rx) = mpsc::unbounded::>();
let event_stream = ThreadEventStream(events_tx);
let message_ix = self.messages.len().saturating_sub(1);
@@ -1086,6 +1104,7 @@ impl Thread {
self.summary = None;
self.running_turn = Some(RunningTurn {
event_stream: event_stream.clone(),
+ tools: self.enabled_tools(profile, &model, cx),
_task: cx.spawn(async move |this, cx| {
log::info!("Starting agent turn execution");
@@ -1417,7 +1436,7 @@ impl Thread {
) -> Option> {
cx.notify();
- let tool = self.tools.get(tool_use.name.as_ref()).cloned();
+ let tool = self.tool(tool_use.name.as_ref());
let mut title = SharedString::from(&tool_use.name);
let mut kind = acp::ToolKind::Other;
if let Some(tool) = tool.as_ref() {
@@ -1727,6 +1746,21 @@ impl Thread {
cx: &mut App,
) -> Result {
let model = self.model().context("No language model configured")?;
+ let tools = if let Some(turn) = self.running_turn.as_ref() {
+ turn.tools
+ .iter()
+ .filter_map(|(tool_name, tool)| {
+ log::trace!("Including tool: {}", tool_name);
+ Some(LanguageModelRequestTool {
+ name: tool_name.to_string(),
+ description: tool.description().to_string(),
+ input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
+ })
+ })
+ .collect::>()
+ } else {
+ Vec::new()
+ };
log::debug!("Building completion request");
log::debug!("Completion intent: {:?}", completion_intent);
@@ -1734,23 +1768,6 @@ impl Thread {
let messages = self.build_request_messages(cx);
log::info!("Request will include {} messages", messages.len());
-
- let tools = if let Some(tools) = self.tools(cx).log_err() {
- tools
- .filter_map(|tool| {
- let tool_name = tool.name().to_string();
- log::trace!("Including tool: {}", tool_name);
- Some(LanguageModelRequestTool {
- name: tool_name,
- description: tool.description().to_string(),
- input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
- })
- })
- .collect()
- } else {
- Vec::new()
- };
-
log::info!("Request includes {} tools", tools.len());
let request = LanguageModelRequest {
@@ -1770,37 +1787,76 @@ impl Thread {
Ok(request)
}
- fn tools<'a>(&'a self, cx: &'a App) -> Result>> {
- let model = self.model().context("No language model configured")?;
+ fn enabled_tools(
+ &self,
+ profile: &AgentProfileSettings,
+ model: &Arc,
+ cx: &App,
+ ) -> BTreeMap> {
+ fn truncate(tool_name: &SharedString) -> SharedString {
+ if tool_name.len() > MAX_TOOL_NAME_LENGTH {
+ let mut truncated = tool_name.to_string();
+ truncated.truncate(MAX_TOOL_NAME_LENGTH);
+ truncated.into()
+ } else {
+ tool_name.clone()
+ }
+ }
- let profile = AgentSettings::get_global(cx)
- .profiles
- .get(&self.profile_id)
- .context("profile not found")?;
- let provider_id = model.provider_id();
-
- Ok(self
+ let mut tools = self
.tools
.iter()
- .filter(move |(_, tool)| tool.supported_provider(&provider_id))
.filter_map(|(tool_name, tool)| {
- if profile.is_tool_enabled(tool_name) {
- Some(tool)
+ if tool.supported_provider(&model.provider_id())
+ && profile.is_tool_enabled(tool_name)
+ {
+ Some((truncate(tool_name), tool.clone()))
} else {
None
}
})
- .chain(self.context_server_registry.read(cx).servers().flat_map(
- |(server_id, tools)| {
- tools.iter().filter_map(|(tool_name, tool)| {
- if profile.is_context_server_tool_enabled(&server_id.0, tool_name) {
- Some(tool)
- } else {
- None
- }
- })
- },
- )))
+ .collect::>();
+
+ let mut context_server_tools = Vec::new();
+ let mut seen_tools = tools.keys().cloned().collect::>();
+ let mut duplicate_tool_names = HashSet::default();
+ for (server_id, server_tools) in self.context_server_registry.read(cx).servers() {
+ for (tool_name, tool) in server_tools {
+ if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) {
+ let tool_name = truncate(tool_name);
+ if !seen_tools.insert(tool_name.clone()) {
+ duplicate_tool_names.insert(tool_name.clone());
+ }
+ context_server_tools.push((server_id.clone(), tool_name, tool.clone()));
+ }
+ }
+ }
+
+ // When there are duplicate tool names, disambiguate by prefixing them
+ // with the server ID. In the rare case there isn't enough space for the
+ // disambiguated tool name, keep only the last tool with this name.
+ for (server_id, tool_name, tool) in context_server_tools {
+ if duplicate_tool_names.contains(&tool_name) {
+ let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len());
+ if available >= 2 {
+ let mut disambiguated = server_id.0.to_string();
+ disambiguated.truncate(available - 1);
+ disambiguated.push('_');
+ disambiguated.push_str(&tool_name);
+ tools.insert(disambiguated.into(), tool.clone());
+ } else {
+ tools.insert(tool_name, tool.clone());
+ }
+ } else {
+ tools.insert(tool_name, tool.clone());
+ }
+ }
+
+ tools
+ }
+
+ fn tool(&self, name: &str) -> Option> {
+ self.running_turn.as_ref()?.tools.get(name).cloned()
}
fn build_request_messages(&self, cx: &App) -> Vec {
@@ -1965,6 +2021,8 @@ struct RunningTurn {
/// The current event stream for the running turn. Used to report a final
/// cancellation event if we cancel the turn.
event_stream: ThreadEventStream,
+ /// The tools that were enabled for this turn.
+ tools: BTreeMap>,
}
impl RunningTurn {
diff --git a/crates/context_server/src/test.rs b/crates/context_server/src/test.rs
index dedf589664..008542ab24 100644
--- a/crates/context_server/src/test.rs
+++ b/crates/context_server/src/test.rs
@@ -1,6 +1,6 @@
use anyhow::Context as _;
use collections::HashMap;
-use futures::{Stream, StreamExt as _, lock::Mutex};
+use futures::{FutureExt, Stream, StreamExt as _, future::BoxFuture, lock::Mutex};
use gpui::BackgroundExecutor;
use std::{pin::Pin, sync::Arc};
@@ -14,9 +14,12 @@ pub fn create_fake_transport(
executor: BackgroundExecutor,
) -> FakeTransport {
let name = name.into();
- FakeTransport::new(executor).on_request::(move |_params| {
- create_initialize_response(name.clone())
- })
+ FakeTransport::new(executor).on_request::(
+ move |_params| {
+ let name = name.clone();
+ async move { create_initialize_response(name.clone()) }
+ },
+ )
}
fn create_initialize_response(server_name: String) -> InitializeResponse {
@@ -32,8 +35,10 @@ fn create_initialize_response(server_name: String) -> InitializeResponse {
}
pub struct FakeTransport {
- request_handlers:
- HashMap<&'static str, Arc serde_json::Value + Send + Sync>>,
+ request_handlers: HashMap<
+ &'static str,
+ Arc BoxFuture<'static, serde_json::Value>>,
+ >,
tx: futures::channel::mpsc::UnboundedSender,
rx: Arc>>,
executor: BackgroundExecutor,
@@ -50,18 +55,25 @@ impl FakeTransport {
}
}
- pub fn on_request(
+ pub fn on_request(
mut self,
- handler: impl Fn(T::Params) -> T::Response + Send + Sync + 'static,
- ) -> Self {
+ handler: impl 'static + Send + Sync + Fn(T::Params) -> Fut,
+ ) -> Self
+ where
+ T: crate::types::Request,
+ Fut: 'static + Send + Future