agent: Fix issue with duplicated tool names from MCP servers (#33811)
Closes #33792 Follow up to #33237 - Turns out my fix for this was not correct Release Notes: - agent: Fixed an issue where tools would not work when two MCP servers provided a tool with the same name
This commit is contained in:
parent
f27b508e4b
commit
0553dc0d49
6 changed files with 386 additions and 273 deletions
|
@ -1,7 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use agent_settings::{AgentProfileId, AgentProfileSettings, AgentSettings};
|
||||
use assistant_tool::{Tool, ToolSource, ToolWorkingSet};
|
||||
use assistant_tool::{Tool, ToolSource, ToolWorkingSet, UniqueToolName};
|
||||
use collections::IndexMap;
|
||||
use convert_case::{Case, Casing};
|
||||
use fs::Fs;
|
||||
|
@ -72,7 +72,7 @@ impl AgentProfile {
|
|||
&self.id
|
||||
}
|
||||
|
||||
pub fn enabled_tools(&self, cx: &App) -> Vec<Arc<dyn Tool>> {
|
||||
pub fn enabled_tools(&self, cx: &App) -> Vec<(UniqueToolName, Arc<dyn Tool>)> {
|
||||
let Some(settings) = AgentSettings::get_global(cx).profiles.get(&self.id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
@ -81,7 +81,7 @@ impl AgentProfile {
|
|||
.read(cx)
|
||||
.tools(cx)
|
||||
.into_iter()
|
||||
.filter(|tool| Self::is_enabled(settings, tool.source(), tool.name()))
|
||||
.filter(|(_, tool)| Self::is_enabled(settings, tool.source(), tool.name()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
@ -137,7 +137,7 @@ mod tests {
|
|||
let mut enabled_tools = cx
|
||||
.read(|cx| profile.enabled_tools(cx))
|
||||
.into_iter()
|
||||
.map(|tool| tool.name())
|
||||
.map(|(_, tool)| tool.name())
|
||||
.collect::<Vec<_>>();
|
||||
enabled_tools.sort();
|
||||
|
||||
|
@ -174,7 +174,7 @@ mod tests {
|
|||
let mut enabled_tools = cx
|
||||
.read(|cx| profile.enabled_tools(cx))
|
||||
.into_iter()
|
||||
.map(|tool| tool.name())
|
||||
.map(|(_, tool)| tool.name())
|
||||
.collect::<Vec<_>>();
|
||||
enabled_tools.sort();
|
||||
|
||||
|
@ -207,7 +207,7 @@ mod tests {
|
|||
let mut enabled_tools = cx
|
||||
.read(|cx| profile.enabled_tools(cx))
|
||||
.into_iter()
|
||||
.map(|tool| tool.name())
|
||||
.map(|(_, tool)| tool.name())
|
||||
.collect::<Vec<_>>();
|
||||
enabled_tools.sort();
|
||||
|
||||
|
@ -267,10 +267,10 @@ mod tests {
|
|||
}
|
||||
|
||||
fn default_tool_set(cx: &mut TestAppContext) -> Entity<ToolWorkingSet> {
|
||||
cx.new(|_| {
|
||||
cx.new(|cx| {
|
||||
let mut tool_set = ToolWorkingSet::default();
|
||||
tool_set.insert(Arc::new(FakeTool::new("enabled_mcp_tool", "mcp")));
|
||||
tool_set.insert(Arc::new(FakeTool::new("disabled_mcp_tool", "mcp")));
|
||||
tool_set.insert(Arc::new(FakeTool::new("enabled_mcp_tool", "mcp")), cx);
|
||||
tool_set.insert(Arc::new(FakeTool::new("disabled_mcp_tool", "mcp")), cx);
|
||||
tool_set
|
||||
})
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ use anyhow::{Result, anyhow};
|
|||
use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet};
|
||||
use chrono::{DateTime, Utc};
|
||||
use client::{ModelRequestUsage, RequestUsage};
|
||||
use collections::{HashMap, HashSet};
|
||||
use collections::HashMap;
|
||||
use feature_flags::{self, FeatureFlagAppExt};
|
||||
use futures::{FutureExt, StreamExt as _, future::Shared};
|
||||
use git::repository::DiffType;
|
||||
|
@ -960,13 +960,14 @@ impl Thread {
|
|||
model: Arc<dyn LanguageModel>,
|
||||
) -> Vec<LanguageModelRequestTool> {
|
||||
if model.supports_tools() {
|
||||
resolve_tool_name_conflicts(self.profile.enabled_tools(cx).as_slice())
|
||||
self.profile
|
||||
.enabled_tools(cx)
|
||||
.into_iter()
|
||||
.filter_map(|(name, tool)| {
|
||||
// Skip tools that cannot be supported
|
||||
let input_schema = tool.input_schema(model.tool_input_format()).ok()?;
|
||||
Some(LanguageModelRequestTool {
|
||||
name,
|
||||
name: name.into(),
|
||||
description: tool.description(),
|
||||
input_schema,
|
||||
})
|
||||
|
@ -2386,7 +2387,7 @@ impl Thread {
|
|||
|
||||
let tool_list = available_tools
|
||||
.iter()
|
||||
.map(|tool| format!("- {}: {}", tool.name(), tool.description()))
|
||||
.map(|(name, tool)| format!("- {}: {}", name, tool.description()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
|
@ -2606,7 +2607,7 @@ impl Thread {
|
|||
.profile
|
||||
.enabled_tools(cx)
|
||||
.iter()
|
||||
.map(|tool| tool.name())
|
||||
.map(|(name, _)| name.clone().into())
|
||||
.collect();
|
||||
|
||||
self.message_feedback.insert(message_id, feedback);
|
||||
|
@ -3144,85 +3145,6 @@ struct PendingCompletion {
|
|||
_task: Task<()>,
|
||||
}
|
||||
|
||||
/// Resolves tool name conflicts by ensuring all tool names are unique.
|
||||
///
|
||||
/// When multiple tools have the same name, this function applies the following rules:
|
||||
/// 1. Native tools always keep their original name
|
||||
/// 2. Context server tools get prefixed with their server ID and an underscore
|
||||
/// 3. All tool names are truncated to MAX_TOOL_NAME_LENGTH (64 characters)
|
||||
/// 4. If conflicts still exist after prefixing, the conflicting tools are filtered out
|
||||
///
|
||||
/// Note: This function assumes that built-in tools occur before MCP tools in the tools list.
|
||||
fn resolve_tool_name_conflicts(tools: &[Arc<dyn Tool>]) -> Vec<(String, Arc<dyn Tool>)> {
|
||||
fn resolve_tool_name(tool: &Arc<dyn Tool>) -> String {
|
||||
let mut tool_name = tool.name();
|
||||
tool_name.truncate(MAX_TOOL_NAME_LENGTH);
|
||||
tool_name
|
||||
}
|
||||
|
||||
const MAX_TOOL_NAME_LENGTH: usize = 64;
|
||||
|
||||
let mut duplicated_tool_names = HashSet::default();
|
||||
let mut seen_tool_names = HashSet::default();
|
||||
for tool in tools {
|
||||
let tool_name = resolve_tool_name(tool);
|
||||
if seen_tool_names.contains(&tool_name) {
|
||||
debug_assert!(
|
||||
tool.source() != assistant_tool::ToolSource::Native,
|
||||
"There are two built-in tools with the same name: {}",
|
||||
tool_name
|
||||
);
|
||||
duplicated_tool_names.insert(tool_name);
|
||||
} else {
|
||||
seen_tool_names.insert(tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
if duplicated_tool_names.is_empty() {
|
||||
return tools
|
||||
.into_iter()
|
||||
.map(|tool| (resolve_tool_name(tool), tool.clone()))
|
||||
.collect();
|
||||
}
|
||||
|
||||
tools
|
||||
.into_iter()
|
||||
.filter_map(|tool| {
|
||||
let mut tool_name = resolve_tool_name(tool);
|
||||
if !duplicated_tool_names.contains(&tool_name) {
|
||||
return Some((tool_name, tool.clone()));
|
||||
}
|
||||
match tool.source() {
|
||||
assistant_tool::ToolSource::Native => {
|
||||
// Built-in tools always keep their original name
|
||||
Some((tool_name, tool.clone()))
|
||||
}
|
||||
assistant_tool::ToolSource::ContextServer { id } => {
|
||||
// Context server tools are prefixed with the context server ID, and truncated if necessary
|
||||
tool_name.insert(0, '_');
|
||||
if tool_name.len() + id.len() > MAX_TOOL_NAME_LENGTH {
|
||||
let len = MAX_TOOL_NAME_LENGTH - tool_name.len();
|
||||
let mut id = id.to_string();
|
||||
id.truncate(len);
|
||||
tool_name.insert_str(0, &id);
|
||||
} else {
|
||||
tool_name.insert_str(0, &id);
|
||||
}
|
||||
|
||||
tool_name.truncate(MAX_TOOL_NAME_LENGTH);
|
||||
|
||||
if seen_tool_names.contains(&tool_name) {
|
||||
log::error!("Cannot resolve tool name conflict for tool {}", tool.name());
|
||||
None
|
||||
} else {
|
||||
Some((tool_name, tool.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -3238,7 +3160,6 @@ mod tests {
|
|||
use futures::future::BoxFuture;
|
||||
use futures::stream::BoxStream;
|
||||
use gpui::TestAppContext;
|
||||
use icons::IconName;
|
||||
use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider};
|
||||
use language_model::{
|
||||
LanguageModelCompletionError, LanguageModelName, LanguageModelProviderId,
|
||||
|
@ -3883,148 +3804,6 @@ fn main() {{
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_resolve_tool_name_conflicts() {
|
||||
use assistant_tool::{Tool, ToolSource};
|
||||
|
||||
assert_resolve_tool_name_conflicts(
|
||||
vec![
|
||||
TestTool::new("tool1", ToolSource::Native),
|
||||
TestTool::new("tool2", ToolSource::Native),
|
||||
TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }),
|
||||
],
|
||||
vec!["tool1", "tool2", "tool3"],
|
||||
);
|
||||
|
||||
assert_resolve_tool_name_conflicts(
|
||||
vec![
|
||||
TestTool::new("tool1", ToolSource::Native),
|
||||
TestTool::new("tool2", ToolSource::Native),
|
||||
TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }),
|
||||
TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }),
|
||||
],
|
||||
vec!["tool1", "tool2", "mcp-1_tool3", "mcp-2_tool3"],
|
||||
);
|
||||
|
||||
assert_resolve_tool_name_conflicts(
|
||||
vec![
|
||||
TestTool::new("tool1", ToolSource::Native),
|
||||
TestTool::new("tool2", ToolSource::Native),
|
||||
TestTool::new("tool3", ToolSource::Native),
|
||||
TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-1".into() }),
|
||||
TestTool::new("tool3", ToolSource::ContextServer { id: "mcp-2".into() }),
|
||||
],
|
||||
vec!["tool1", "tool2", "tool3", "mcp-1_tool3", "mcp-2_tool3"],
|
||||
);
|
||||
|
||||
// Test that tool with very long name is always truncated
|
||||
assert_resolve_tool_name_conflicts(
|
||||
vec![TestTool::new(
|
||||
"tool-with-more-then-64-characters-blah-blah-blah-blah-blah-blah-blah-blah",
|
||||
ToolSource::Native,
|
||||
)],
|
||||
vec!["tool-with-more-then-64-characters-blah-blah-blah-blah-blah-blah-"],
|
||||
);
|
||||
|
||||
// Test deduplication of tools with very long names, in this case the mcp server name should be truncated
|
||||
assert_resolve_tool_name_conflicts(
|
||||
vec![
|
||||
TestTool::new("tool-with-very-very-very-long-name", ToolSource::Native),
|
||||
TestTool::new(
|
||||
"tool-with-very-very-very-long-name",
|
||||
ToolSource::ContextServer {
|
||||
id: "mcp-with-very-very-very-long-name".into(),
|
||||
},
|
||||
),
|
||||
],
|
||||
vec![
|
||||
"tool-with-very-very-very-long-name",
|
||||
"mcp-with-very-very-very-long-_tool-with-very-very-very-long-name",
|
||||
],
|
||||
);
|
||||
|
||||
fn assert_resolve_tool_name_conflicts(
|
||||
tools: Vec<TestTool>,
|
||||
expected: Vec<impl Into<String>>,
|
||||
) {
|
||||
let tools: Vec<Arc<dyn Tool>> = tools
|
||||
.into_iter()
|
||||
.map(|t| Arc::new(t) as Arc<dyn Tool>)
|
||||
.collect();
|
||||
let tools = resolve_tool_name_conflicts(&tools);
|
||||
assert_eq!(tools.len(), expected.len());
|
||||
for (i, expected_name) in expected.into_iter().enumerate() {
|
||||
let expected_name = expected_name.into();
|
||||
let actual_name = &tools[i].0;
|
||||
assert_eq!(
|
||||
actual_name, &expected_name,
|
||||
"Expected '{}' got '{}' at index {}",
|
||||
expected_name, actual_name, i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct TestTool {
|
||||
name: String,
|
||||
source: ToolSource,
|
||||
}
|
||||
|
||||
impl TestTool {
|
||||
fn new(name: impl Into<String>, source: ToolSource) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for TestTool {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
IconName::Ai
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn source(&self) -> ToolSource {
|
||||
self.source.clone()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Test tool".to_string()
|
||||
}
|
||||
|
||||
fn ui_text(&self, _input: &serde_json::Value) -> String {
|
||||
"Test tool".to_string()
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
_input: serde_json::Value,
|
||||
_request: Arc<LanguageModelRequest>,
|
||||
_project: Entity<Project>,
|
||||
_action_log: Entity<ActionLog>,
|
||||
_model: Arc<dyn LanguageModel>,
|
||||
_window: Option<AnyWindowHandle>,
|
||||
_cx: &mut App,
|
||||
) -> assistant_tool::ToolResult {
|
||||
assistant_tool::ToolResult {
|
||||
output: Task::ready(Err(anyhow::anyhow!("No content"))),
|
||||
card: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create a model that returns errors
|
||||
enum TestError {
|
||||
Overloaded,
|
||||
|
|
|
@ -6,7 +6,7 @@ use crate::{
|
|||
};
|
||||
use agent_settings::{AgentProfileId, CompletionMode};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use assistant_tool::{ToolId, ToolWorkingSet};
|
||||
use assistant_tool::{Tool, ToolId, ToolWorkingSet};
|
||||
use chrono::{DateTime, Utc};
|
||||
use collections::HashMap;
|
||||
use context_server::ContextServerId;
|
||||
|
@ -537,8 +537,8 @@ impl ThreadStore {
|
|||
}
|
||||
ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
|
||||
if let Some(tool_ids) = self.context_server_tool_ids.remove(server_id) {
|
||||
tool_working_set.update(cx, |tool_working_set, _| {
|
||||
tool_working_set.remove(&tool_ids);
|
||||
tool_working_set.update(cx, |tool_working_set, cx| {
|
||||
tool_working_set.remove(&tool_ids, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -569,19 +569,17 @@ impl ThreadStore {
|
|||
.log_err()
|
||||
{
|
||||
let tool_ids = tool_working_set
|
||||
.update(cx, |tool_working_set, _| {
|
||||
response
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
log::info!("registering context server tool: {:?}", tool.name);
|
||||
tool_working_set.insert(Arc::new(ContextServerTool::new(
|
||||
.update(cx, |tool_working_set, cx| {
|
||||
tool_working_set.extend(
|
||||
response.tools.into_iter().map(|tool| {
|
||||
Arc::new(ContextServerTool::new(
|
||||
context_server_store.clone(),
|
||||
server.id(),
|
||||
tool,
|
||||
)))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
)) as Arc<dyn Tool>
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.log_err();
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue