diff --git a/Cargo.lock b/Cargo.lock index a77d1e68c01131b6c135552010a913645610634e..aeacdd899685e46ebd1d38df7cd58b19810de9c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,14 +292,12 @@ dependencies = [ "anyhow", "client", "collections", - "context_server", "env_logger 0.11.8", "fs", "futures 0.3.31", "gpui", "gpui_tokio", "indoc", - "itertools 0.14.0", "language", "language_model", "language_models", @@ -309,7 +307,6 @@ dependencies = [ "node_runtime", "paths", "project", - "rand 0.8.5", "reqwest_client", "schemars", "semver", @@ -317,12 +314,10 @@ dependencies = [ "serde_json", "settings", "smol", - "strum 0.27.1", "tempfile", "thiserror 2.0.12", "ui", "util", - "uuid", "watch", "which 6.0.3", "workspace-hack", diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 3e6bae104ce339f371b2ea69afebecbc6c1cec27..222feb9aaa31a6ace1e13ba8943f416942e8918c 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -25,14 +25,12 @@ agent_settings.workspace = true anyhow.workspace = true client = { workspace = true, optional = true } collections.workspace = true -context_server.workspace = true env_logger = { workspace = true, optional = true } fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio = { workspace = true, optional = true } indoc.workspace = true -itertools.workspace = true language.workspace = true language_model.workspace = true language_models.workspace = true @@ -40,7 +38,6 @@ log.workspace = true node_runtime.workspace = true paths.workspace = true project.workspace = true -rand.workspace = true reqwest_client = { workspace = true, optional = true } schemars.workspace = true semver.workspace = true @@ -48,12 +45,10 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true -strum.workspace = true tempfile.workspace = true thiserror.workspace = true ui.workspace = true util.workspace = true -uuid.workspace = true watch.workspace = true which.workspace = true workspace-hack.workspace = true diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index e5d954b071a86f39a44e7c370dbc841c2f58d706..e1b4057b71b0b4aee84548df74935d9b0598f598 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -57,16 +57,10 @@ impl AgentServerDelegate { binary_name: SharedString, package_name: SharedString, entrypoint_path: PathBuf, - settings: Option, + ignore_system_version: bool, minimum_version: Option, cx: &mut App, ) -> Task> { - if let Some(settings) = &settings - && let Some(command) = settings.clone().custom_command() - { - return Task::ready(Ok(command)); - } - let project = self.project; let fs = project.read(cx).fs().clone(); let Some(node_runtime) = project.read(cx).node_runtime().cloned() else { @@ -75,7 +69,7 @@ impl AgentServerDelegate { let mut status_tx = self.status_tx; cx.spawn(async move |cx| { - if let Some(settings) = settings && !settings.ignore_system_version.unwrap_or(true) { + if !ignore_system_version { if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await { return Ok(AgentServerCommand { path: bin, args: Vec::new(), env: Default::default() }) } diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index b1832191480f112c7788e4e908c2e1594f08c0ad..db8853695ec798a8b146666292cd29f2c1fc145c 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -1,47 +1,23 @@ -mod edit_tool; -mod mcp_server; -mod permission_tool; -mod read_tool; -pub mod tools; -mod write_tool; - -use action_log::ActionLog; -use collections::HashMap; -use context_server::listener::McpServerTool; use language_models::provider::anthropic::AnthropicLanguageModelProvider; -use project::Project; use settings::SettingsStore; -use smol::process::Child; use std::any::Any; -use std::cell::RefCell; -use std::fmt::Display; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::rc::Rc; -use util::command::new_smol_command; -use uuid::Uuid; -use agent_client_protocol as acp; -use anyhow::{Context as _, Result, anyhow}; -use futures::channel::oneshot; -use futures::{AsyncBufReadExt, AsyncWriteExt}; -use futures::{ - AsyncRead, AsyncWrite, FutureExt, StreamExt, - channel::mpsc::{self, UnboundedReceiver, UnboundedSender}, - io::BufReader, - select_biased, -}; -use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity}; -use serde::{Deserialize, Serialize}; -use util::{ResultExt, debug_panic}; +use anyhow::Result; +use gpui::{App, AppContext as _, SharedString, Task}; -use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig}; -use crate::claude::tools::ClaudeTool; -use crate::{AgentServer, AgentServerCommand, AgentServerDelegate, AllAgentServersSettings}; -use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri}; +use crate::{AgentServer, AgentServerDelegate, AllAgentServersSettings}; +use acp_thread::AgentConnection; #[derive(Clone)] pub struct ClaudeCode; +impl ClaudeCode { + const BINARY_NAME: &'static str = "claude-code-acp"; + const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp"; +} + impl AgentServer for ClaudeCode { fn telemetry_id(&self) -> &'static str { "claude-code" @@ -57,1301 +33,49 @@ impl AgentServer for ClaudeCode { fn connect( &self, - _root_dir: &Path, - _delegate: AgentServerDelegate, - _cx: &mut App, + root_dir: &Path, + delegate: AgentServerDelegate, + cx: &mut App, ) -> Task>> { - let connection = ClaudeAgentConnection { - sessions: Default::default(), - }; - - Task::ready(Ok(Rc::new(connection) as _)) - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -struct ClaudeAgentConnection { - sessions: Rc>>, -} + let root_dir = root_dir.to_path_buf(); + let server_name = self.name(); + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); -impl AgentConnection for ClaudeAgentConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - let cwd = cwd.to_owned(); cx.spawn(async move |cx| { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).claude.clone() - })?; - - let Some(command) = AgentServerCommand::resolve( - "claude", - &[], - Some(&util::paths::home_dir().join(".claude/local/claude")), - settings, - &project, - cx, - ) - .await - else { - return Err(anyhow!("Failed to find Claude Code binary")); + let mut command = if let Some(settings) = settings { + settings.command + } else { + cx.update(|cx| { + delegate.get_or_npm_install_builtin_agent( + Self::BINARY_NAME.into(), + Self::PACKAGE_NAME.into(), + format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(), + true, + None, + cx, + ) + })? + .await? }; - let api_key = - cx.update(AnthropicLanguageModelProvider::api_key)? - .await - .map_err(|err| { - if err.is::() { - anyhow!(AuthRequired::new().with_language_model_provider( - language_model::ANTHROPIC_PROVIDER_ID - )) - } else { - anyhow!(err) - } - })?; - - let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid()); - let fs = project.read_with(cx, |project, _cx| project.fs().clone())?; - let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), fs, cx).await?; - - let mut mcp_servers = HashMap::default(); - mcp_servers.insert( - mcp_server::SERVER_NAME.to_string(), - permission_mcp_server.server_config()?, - ); - let mcp_config = McpConfig { mcp_servers }; - - let mcp_config_file = tempfile::NamedTempFile::new()?; - let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts(); - - let mut mcp_config_file = smol::fs::File::from(mcp_config_file); - mcp_config_file - .write_all(serde_json::to_string(&mcp_config)?.as_bytes()) - .await?; - mcp_config_file.flush().await?; - - let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded(); - let (outgoing_tx, outgoing_rx) = mpsc::unbounded(); - - let session_id = acp::SessionId(Uuid::new_v4().to_string().into()); - - log::trace!("Starting session with id: {}", session_id); - - let mut child = spawn_claude( - &command, - ClaudeSessionMode::Start, - session_id.clone(), - api_key, - &mcp_config_path, - &cwd, - )?; - - let stdout = child.stdout.take().context("Failed to take stdout")?; - let stdin = child.stdin.take().context("Failed to take stdin")?; - let stderr = child.stderr.take().context("Failed to take stderr")?; - - let pid = child.id(); - log::trace!("Spawned (pid: {})", pid); - - cx.background_spawn(async move { - let mut stderr = BufReader::new(stderr); - let mut line = String::new(); - while let Ok(n) = stderr.read_line(&mut line).await - && n > 0 - { - log::warn!("agent stderr: {}", &line); - line.clear(); - } - }) - .detach(); - - cx.background_spawn(async move { - let mut outgoing_rx = Some(outgoing_rx); - - ClaudeAgentSession::handle_io( - outgoing_rx.take().unwrap(), - incoming_message_tx.clone(), - stdin, - stdout, - ) - .await?; - - log::trace!("Stopped (pid: {})", pid); - - drop(mcp_config_path); - anyhow::Ok(()) - }) - .detach(); - - let turn_state = Rc::new(RefCell::new(TurnState::None)); - - let handler_task = cx.spawn({ - let turn_state = turn_state.clone(); - let mut thread_rx = thread_rx.clone(); - async move |cx| { - while let Some(message) = incoming_message_rx.next().await { - ClaudeAgentSession::handle_message( - thread_rx.clone(), - message, - turn_state.clone(), - cx, - ) - .await - } - - if let Some(status) = child.status().await.log_err() - && let Some(thread) = thread_rx.recv().await.ok() - { - let version = claude_version(command.path.clone(), cx).await.log_err(); - let help = claude_help(command.path.clone(), cx).await.log_err(); - thread - .update(cx, |thread, cx| { - let error = if let Some(version) = version - && let Some(help) = help - && (!help.contains("--input-format") - || !help.contains("--session-id")) - { - LoadError::Unsupported { - command: command.path.to_string_lossy().to_string().into(), - current_version: version.to_string().into(), - minimum_version: "1.0.0".into(), - } - } else { - LoadError::Exited { status } - }; - thread.emit_load_error(error, cx); - }) - .ok(); - } - } - }); - - let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|cx| { - AcpThread::new( - "Claude Code", - self.clone(), - project, - action_log, - session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: false, - embedded_context: true, - }), - cx, - ) - })?; - - thread_tx.send(thread.downgrade())?; - - let session = ClaudeAgentSession { - outgoing_tx, - turn_state, - _handler_task: handler_task, - _mcp_server: Some(permission_mcp_server), - }; - - self.sessions.borrow_mut().insert(session_id, session); + if let Some(api_key) = cx + .update(AnthropicLanguageModelProvider::api_key)? + .await + .ok() + { + command + .env + .get_or_insert_default() + .insert("ANTHROPIC_API_KEY".to_owned(), api_key.key); + } - Ok(thread) + crate::acp::connect(server_name, command.clone(), &root_dir, cx).await }) } - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task> { - Task::ready(Err(anyhow!("Authentication not supported"))) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let sessions = self.sessions.borrow(); - let Some(session) = sessions.get(¶ms.session_id) else { - return Task::ready(Err(anyhow!( - "Attempted to send message to nonexistent session {}", - params.session_id - ))); - }; - - let (end_tx, end_rx) = oneshot::channel(); - session.turn_state.replace(TurnState::InProgress { end_tx }); - - let content = acp_content_to_claude(params.prompt); - - if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User { - message: Message { - role: Role::User, - content: Content::Chunks(content), - id: None, - model: None, - stop_reason: None, - stop_sequence: None, - usage: None, - }, - session_id: Some(params.session_id.to_string()), - }) { - return Task::ready(Err(anyhow!(err))); - } - - cx.foreground_executor().spawn(async move { end_rx.await? }) - } - - fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { - let sessions = self.sessions.borrow(); - let Some(session) = sessions.get(session_id) else { - log::warn!("Attempted to cancel nonexistent session {}", session_id); - return; - }; - - let request_id = new_request_id(); - - let turn_state = session.turn_state.take(); - let TurnState::InProgress { end_tx } = turn_state else { - // Already canceled or idle, put it back - session.turn_state.replace(turn_state); - return; - }; - - session.turn_state.replace(TurnState::CancelRequested { - end_tx, - request_id: request_id.clone(), - }); - - session - .outgoing_tx - .unbounded_send(SdkMessage::ControlRequest { - request_id, - request: ControlRequest::Interrupt, - }) - .log_err(); - } - fn into_any(self: Rc) -> Rc { self } } - -#[derive(Clone, Copy)] -enum ClaudeSessionMode { - Start, - #[expect(dead_code)] - Resume, -} - -fn spawn_claude( - command: &AgentServerCommand, - mode: ClaudeSessionMode, - session_id: acp::SessionId, - api_key: language_models::provider::anthropic::ApiKey, - mcp_config_path: &Path, - root_dir: &Path, -) -> Result { - let child = util::command::new_smol_command(&command.path) - .args([ - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--print", - "--verbose", - "--mcp-config", - mcp_config_path.to_string_lossy().as_ref(), - "--permission-prompt-tool", - &format!( - "mcp__{}__{}", - mcp_server::SERVER_NAME, - permission_tool::PermissionTool::NAME, - ), - "--allowedTools", - &format!( - "mcp__{}__{}", - mcp_server::SERVER_NAME, - read_tool::ReadTool::NAME - ), - "--disallowedTools", - "Read,Write,Edit,MultiEdit", - ]) - .args(match mode { - ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()], - ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()], - }) - .args(command.args.iter().map(|arg| arg.as_str())) - .envs(command.env.iter().flatten()) - .env("ANTHROPIC_API_KEY", api_key.key) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - - Ok(child) -} - -fn claude_version(path: PathBuf, cx: &mut AsyncApp) -> Task> { - cx.background_spawn(async move { - let output = new_smol_command(path).arg("--version").output().await?; - let output = String::from_utf8(output.stdout)?; - let version = output - .trim() - .strip_suffix(" (Claude Code)") - .context("parsing Claude version")?; - let version = semver::Version::parse(version)?; - anyhow::Ok(version) - }) -} - -fn claude_help(path: PathBuf, cx: &mut AsyncApp) -> Task> { - cx.background_spawn(async move { - let output = new_smol_command(path).arg("--help").output().await?; - let output = String::from_utf8(output.stdout)?; - anyhow::Ok(output) - }) -} - -struct ClaudeAgentSession { - outgoing_tx: UnboundedSender, - turn_state: Rc>, - _mcp_server: Option, - _handler_task: Task<()>, -} - -#[derive(Debug, Default)] -enum TurnState { - #[default] - None, - InProgress { - end_tx: oneshot::Sender>, - }, - CancelRequested { - end_tx: oneshot::Sender>, - request_id: String, - }, - CancelConfirmed { - end_tx: oneshot::Sender>, - }, -} - -impl TurnState { - fn is_canceled(&self) -> bool { - matches!(self, TurnState::CancelConfirmed { .. }) - } - - fn end_tx(self) -> Option>> { - match self { - TurnState::None => None, - TurnState::InProgress { end_tx, .. } => Some(end_tx), - TurnState::CancelRequested { end_tx, .. } => Some(end_tx), - TurnState::CancelConfirmed { end_tx } => Some(end_tx), - } - } - - fn confirm_cancellation(self, id: &str) -> Self { - match self { - TurnState::CancelRequested { request_id, end_tx } if request_id == id => { - TurnState::CancelConfirmed { end_tx } - } - _ => self, - } - } -} - -impl ClaudeAgentSession { - async fn handle_message( - mut thread_rx: watch::Receiver>, - message: SdkMessage, - turn_state: Rc>, - cx: &mut AsyncApp, - ) { - match message { - // we should only be sending these out, they don't need to be in the thread - SdkMessage::ControlRequest { .. } => {} - SdkMessage::User { - message, - session_id: _, - } => { - let Some(thread) = thread_rx - .recv() - .await - .log_err() - .and_then(|entity| entity.upgrade()) - else { - log::error!("Received an SDK message but thread is gone"); - return; - }; - - for chunk in message.content.chunks() { - match chunk { - ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - if !turn_state.borrow().is_canceled() { - thread - .update(cx, |thread, cx| { - thread.push_user_content_block(None, text.into(), cx) - }) - .log_err(); - } - } - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - let content = content.to_string(); - thread - .update(cx, |thread, cx| { - let id = acp::ToolCallId(tool_use_id.into()); - let set_new_content = !content.is_empty() - && thread.tool_call(&id).is_none_or(|(_, tool_call)| { - // preserve rich diff if we have one - tool_call.diffs().next().is_none() - }); - - thread.update_tool_call( - acp::ToolCallUpdate { - id, - fields: acp::ToolCallUpdateFields { - status: if turn_state.borrow().is_canceled() { - // Do not set to completed if turn was canceled - None - } else { - Some(acp::ToolCallStatus::Completed) - }, - content: set_new_content - .then(|| vec![content.into()]), - ..Default::default() - }, - }, - cx, - ) - }) - .log_err(); - } - ContentChunk::Thinking { .. } - | ContentChunk::RedactedThinking - | ContentChunk::ToolUse { .. } => { - debug_panic!( - "Should not get {:?} with role: assistant. should we handle this?", - chunk - ); - } - ContentChunk::Image { source } => { - if !turn_state.borrow().is_canceled() { - thread - .update(cx, |thread, cx| { - thread.push_user_content_block(None, source.into(), cx) - }) - .log_err(); - } - } - - ContentChunk::Document | ContentChunk::WebSearchToolResult => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - format!("Unsupported content: {:?}", chunk).into(), - false, - cx, - ) - }) - .log_err(); - } - } - } - } - SdkMessage::Assistant { - message, - session_id: _, - } => { - let Some(thread) = thread_rx - .recv() - .await - .log_err() - .and_then(|entity| entity.upgrade()) - else { - log::error!("Received an SDK message but thread is gone"); - return; - }; - - for chunk in message.content.chunks() { - match chunk { - ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(text.into(), false, cx) - }) - .log_err(); - } - ContentChunk::Thinking { thinking } => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(thinking.into(), true, cx) - }) - .log_err(); - } - ContentChunk::RedactedThinking => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - "[REDACTED]".into(), - true, - cx, - ) - }) - .log_err(); - } - ContentChunk::ToolUse { id, name, input } => { - let claude_tool = ClaudeTool::infer(&name, input); - - thread - .update(cx, |thread, cx| { - if let ClaudeTool::TodoWrite(Some(params)) = claude_tool { - thread.update_plan( - acp::Plan { - entries: params - .todos - .into_iter() - .map(Into::into) - .collect(), - }, - cx, - ) - } else { - thread.upsert_tool_call( - claude_tool.as_acp(acp::ToolCallId(id.into())), - cx, - )?; - } - anyhow::Ok(()) - }) - .log_err(); - } - ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => { - debug_panic!( - "Should not get tool results with role: assistant. should we handle this?" - ); - } - ContentChunk::Image { source } => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block(source.into(), false, cx) - }) - .log_err(); - } - ContentChunk::Document => { - thread - .update(cx, |thread, cx| { - thread.push_assistant_content_block( - format!("Unsupported content: {:?}", chunk).into(), - false, - cx, - ) - }) - .log_err(); - } - } - } - } - SdkMessage::Result { - is_error, - subtype, - result, - .. - } => { - let turn_state = turn_state.take(); - let was_canceled = turn_state.is_canceled(); - let Some(end_turn_tx) = turn_state.end_tx() else { - debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn"); - return; - }; - - if is_error || (!was_canceled && subtype == ResultErrorType::ErrorDuringExecution) { - end_turn_tx - .send(Err(anyhow!( - "Error: {}", - result.unwrap_or_else(|| subtype.to_string()) - ))) - .ok(); - } else { - let stop_reason = match subtype { - ResultErrorType::Success => acp::StopReason::EndTurn, - ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests, - ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled, - }; - end_turn_tx - .send(Ok(acp::PromptResponse { stop_reason })) - .ok(); - } - } - SdkMessage::ControlResponse { response } => { - if matches!(response.subtype, ResultErrorType::Success) { - let new_state = turn_state.take().confirm_cancellation(&response.request_id); - turn_state.replace(new_state); - } else { - log::error!("Control response error: {:?}", response); - } - } - SdkMessage::System { .. } => {} - } - } - - async fn handle_io( - mut outgoing_rx: UnboundedReceiver, - incoming_tx: UnboundedSender, - mut outgoing_bytes: impl Unpin + AsyncWrite, - incoming_bytes: impl Unpin + AsyncRead, - ) -> Result> { - let mut output_reader = BufReader::new(incoming_bytes); - let mut outgoing_line = Vec::new(); - let mut incoming_line = String::new(); - loop { - select_biased! { - message = outgoing_rx.next() => { - if let Some(message) = message { - outgoing_line.clear(); - serde_json::to_writer(&mut outgoing_line, &message)?; - log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line)); - outgoing_line.push(b'\n'); - outgoing_bytes.write_all(&outgoing_line).await.ok(); - } else { - break; - } - } - bytes_read = output_reader.read_line(&mut incoming_line).fuse() => { - if bytes_read? == 0 { - break - } - log::trace!("recv: {}", &incoming_line); - match serde_json::from_str::(&incoming_line) { - Ok(message) => { - incoming_tx.unbounded_send(message).log_err(); - } - Err(error) => { - log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}"); - } - } - incoming_line.clear(); - } - } - } - - Ok(outgoing_rx) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Message { - role: Role, - content: Content, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_reason: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_sequence: Option, - #[serde(skip_serializing_if = "Option::is_none")] - usage: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -enum Content { - UntaggedText(String), - Chunks(Vec), -} - -impl Content { - pub fn chunks(self) -> impl Iterator { - match self { - Self::Chunks(chunks) => chunks.into_iter(), - Self::UntaggedText(text) => vec![ContentChunk::Text { text }].into_iter(), - } - } -} - -impl Display for Content { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Content::UntaggedText(txt) => write!(f, "{}", txt), - Content::Chunks(chunks) => { - for chunk in chunks { - write!(f, "{}", chunk)?; - } - Ok(()) - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ContentChunk { - Text { - text: String, - }, - ToolUse { - id: String, - name: String, - input: serde_json::Value, - }, - ToolResult { - content: Content, - tool_use_id: String, - }, - Thinking { - thinking: String, - }, - RedactedThinking, - Image { - source: ImageSource, - }, - // TODO - Document, - WebSearchToolResult, - #[serde(untagged)] - UntaggedText(String), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ImageSource { - Base64 { data: String, media_type: String }, - Url { url: String }, -} - -impl Into for ImageSource { - fn into(self) -> acp::ContentBlock { - match self { - ImageSource::Base64 { data, media_type } => { - acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data, - mime_type: media_type, - uri: None, - }) - } - ImageSource::Url { url } => acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data: "".to_string(), - mime_type: "".to_string(), - uri: Some(url), - }), - } - } -} - -impl Display for ContentChunk { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ContentChunk::Text { text } => write!(f, "{}", text), - ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking), - ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"), - ContentChunk::UntaggedText(text) => write!(f, "{}", text), - ContentChunk::ToolResult { content, .. } => write!(f, "{}", content), - ContentChunk::Image { .. } - | ContentChunk::Document - | ContentChunk::ToolUse { .. } - | ContentChunk::WebSearchToolResult => { - write!(f, "\n{:?}\n", &self) - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Usage { - input_tokens: u32, - cache_creation_input_tokens: u32, - cache_read_input_tokens: u32, - output_tokens: u32, - service_tier: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum Role { - System, - Assistant, - User, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct MessageParam { - role: Role, - content: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum SdkMessage { - // An assistant message - Assistant { - message: Message, // from Anthropic SDK - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - }, - // A user message - User { - message: Message, // from Anthropic SDK - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - }, - // Emitted as the last message in a conversation - Result { - subtype: ResultErrorType, - duration_ms: f64, - duration_api_ms: f64, - is_error: bool, - num_turns: i32, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - session_id: String, - total_cost_usd: f64, - }, - // Emitted as the first message at the start of a conversation - System { - cwd: String, - session_id: String, - tools: Vec, - model: String, - mcp_servers: Vec, - #[serde(rename = "apiKeySource")] - api_key_source: String, - #[serde(rename = "permissionMode")] - permission_mode: PermissionMode, - }, - /// Messages used to control the conversation, outside of chat messages to the model - ControlRequest { - request_id: String, - request: ControlRequest, - }, - /// Response to a control request - ControlResponse { response: ControlResponse }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "subtype", rename_all = "snake_case")] -enum ControlRequest { - /// Cancel the current conversation - Interrupt, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ControlResponse { - request_id: String, - subtype: ResultErrorType, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -enum ResultErrorType { - Success, - ErrorMaxTurns, - ErrorDuringExecution, -} - -impl Display for ResultErrorType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ResultErrorType::Success => write!(f, "success"), - ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"), - ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"), - } - } -} - -fn acp_content_to_claude(prompt: Vec) -> Vec { - let mut content = Vec::with_capacity(prompt.len()); - let mut context = Vec::with_capacity(prompt.len()); - - for chunk in prompt { - match chunk { - acp::ContentBlock::Text(text_content) => { - content.push(ContentChunk::Text { - text: text_content.text, - }); - } - acp::ContentBlock::ResourceLink(resource_link) => { - match MentionUri::parse(&resource_link.uri) { - Ok(uri) => { - content.push(ContentChunk::Text { - text: format!("{}", uri.as_link()), - }); - } - Err(_) => { - content.push(ContentChunk::Text { - text: resource_link.uri, - }); - } - } - } - acp::ContentBlock::Resource(resource) => match resource.resource { - acp::EmbeddedResourceResource::TextResourceContents(resource) => { - match MentionUri::parse(&resource.uri) { - Ok(uri) => { - content.push(ContentChunk::Text { - text: format!("{}", uri.as_link()), - }); - } - Err(_) => { - content.push(ContentChunk::Text { - text: resource.uri.clone(), - }); - } - } - - context.push(ContentChunk::Text { - text: format!( - "\n\n{}\n", - resource.uri, resource.text - ), - }); - } - acp::EmbeddedResourceResource::BlobResourceContents(_) => { - // Unsupported by SDK - } - }, - acp::ContentBlock::Image(acp::ImageContent { - data, mime_type, .. - }) => content.push(ContentChunk::Image { - source: ImageSource::Base64 { - data, - media_type: mime_type, - }, - }), - acp::ContentBlock::Audio(_) => { - // Unsupported by SDK - } - } - } - - content.extend(context); - content -} - -fn new_request_id() -> String { - use rand::Rng; - // In the Claude Code TS SDK they just generate a random 12 character string, - // `Math.random().toString(36).substring(2, 15)` - rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(12) - .map(char::from) - .collect() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct McpServer { - name: String, - status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -enum PermissionMode { - Default, - AcceptEdits, - BypassPermissions, - Plan, -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::e2e_tests; - use gpui::TestAppContext; - use serde_json::json; - - crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow"); - - pub fn local_command() -> AgentServerCommand { - AgentServerCommand { - path: "claude".into(), - args: vec![], - env: None, - } - } - - #[gpui::test] - #[cfg_attr(not(feature = "e2e"), ignore)] - async fn test_todo_plan(cx: &mut TestAppContext) { - let fs = e2e_tests::init_test(cx).await; - let project = Project::test(fs, [], cx).await; - let thread = - e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await; - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.", - cx, - ) - }) - .await - .unwrap(); - - let mut entries_len = 0; - - thread.read_with(cx, |thread, _| { - entries_len = thread.plan().entries.len(); - assert!(!thread.plan().entries.is_empty(), "Empty plan"); - }); - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Mark the first entry status as in progress without acting on it.", - cx, - ) - }) - .await - .unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(matches!( - thread.plan().entries[0].status, - acp::PlanEntryStatus::InProgress - )); - assert_eq!(thread.plan().entries.len(), entries_len); - }); - - thread - .update(cx, |thread, cx| { - thread.send_raw( - "Now mark the first entry as completed without acting on it.", - cx, - ) - }) - .await - .unwrap(); - - thread.read_with(cx, |thread, _| { - assert!(matches!( - thread.plan().entries[0].status, - acp::PlanEntryStatus::Completed - )); - assert_eq!(thread.plan().entries.len(), entries_len); - }); - } - - #[test] - fn test_deserialize_content_untagged_text() { - let json = json!("Hello, world!"); - let content: Content = serde_json::from_value(json).unwrap(); - match content { - Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"), - _ => panic!("Expected UntaggedText variant"), - } - } - - #[test] - fn test_deserialize_content_chunks() { - let json = json!([ - { - "type": "text", - "text": "Hello" - }, - { - "type": "tool_use", - "id": "tool_123", - "name": "calculator", - "input": {"operation": "add", "a": 1, "b": 2} - } - ]); - let content: Content = serde_json::from_value(json).unwrap(); - match content { - Content::Chunks(chunks) => { - assert_eq!(chunks.len(), 2); - match &chunks[0] { - ContentChunk::Text { text } => assert_eq!(text, "Hello"), - _ => panic!("Expected Text chunk"), - } - match &chunks[1] { - ContentChunk::ToolUse { id, name, input } => { - assert_eq!(id, "tool_123"); - assert_eq!(name, "calculator"); - assert_eq!(input["operation"], "add"); - assert_eq!(input["a"], 1); - assert_eq!(input["b"], 2); - } - _ => panic!("Expected ToolUse chunk"), - } - } - _ => panic!("Expected Chunks variant"), - } - } - - #[test] - fn test_deserialize_tool_result_untagged_text() { - let json = json!({ - "type": "tool_result", - "content": "Result content", - "tool_use_id": "tool_456" - }); - let chunk: ContentChunk = serde_json::from_value(json).unwrap(); - match chunk { - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - match content { - Content::UntaggedText(text) => assert_eq!(text, "Result content"), - _ => panic!("Expected UntaggedText content"), - } - assert_eq!(tool_use_id, "tool_456"); - } - _ => panic!("Expected ToolResult variant"), - } - } - - #[test] - fn test_deserialize_tool_result_chunks() { - let json = json!({ - "type": "tool_result", - "content": [ - { - "type": "text", - "text": "Processing complete" - }, - { - "type": "text", - "text": "Result: 42" - } - ], - "tool_use_id": "tool_789" - }); - let chunk: ContentChunk = serde_json::from_value(json).unwrap(); - match chunk { - ContentChunk::ToolResult { - content, - tool_use_id, - } => { - match content { - Content::Chunks(chunks) => { - assert_eq!(chunks.len(), 2); - match &chunks[0] { - ContentChunk::Text { text } => assert_eq!(text, "Processing complete"), - _ => panic!("Expected Text chunk"), - } - match &chunks[1] { - ContentChunk::Text { text } => assert_eq!(text, "Result: 42"), - _ => panic!("Expected Text chunk"), - } - } - _ => panic!("Expected Chunks content"), - } - assert_eq!(tool_use_id, "tool_789"); - } - _ => panic!("Expected ToolResult variant"), - } - } - - #[test] - fn test_acp_content_to_claude() { - let acp_content = vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "Hello world".to_string(), - annotations: None, - }), - acp::ContentBlock::Image(acp::ImageContent { - data: "base64data".to_string(), - mime_type: "image/png".to_string(), - annotations: None, - uri: None, - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "file:///path/to/example.rs".to_string(), - name: "example.rs".to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - }), - acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: "fn main() { println!(\"Hello!\"); }".to_string(), - uri: "file:///path/to/code.rs".to_string(), - }, - ), - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "invalid_uri_format".to_string(), - name: "invalid.txt".to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - }), - ]; - - let claude_content = acp_content_to_claude(acp_content); - - assert_eq!(claude_content.len(), 6); - - match &claude_content[0] { - ContentChunk::Text { text } => assert_eq!(text, "Hello world"), - _ => panic!("Expected Text chunk"), - } - - match &claude_content[1] { - ContentChunk::Image { source } => match source { - ImageSource::Base64 { data, media_type } => { - assert_eq!(data, "base64data"); - assert_eq!(media_type, "image/png"); - } - _ => panic!("Expected Base64 image source"), - }, - _ => panic!("Expected Image chunk"), - } - - match &claude_content[2] { - ContentChunk::Text { text } => { - assert!(text.contains("example.rs")); - assert!(text.contains("file:///path/to/example.rs")); - } - _ => panic!("Expected Text chunk for ResourceLink"), - } - - match &claude_content[3] { - ContentChunk::Text { text } => { - assert!(text.contains("code.rs")); - assert!(text.contains("file:///path/to/code.rs")); - } - _ => panic!("Expected Text chunk for Resource"), - } - - match &claude_content[4] { - ContentChunk::Text { text } => { - assert_eq!(text, "invalid_uri_format"); - } - _ => panic!("Expected Text chunk for invalid URI"), - } - - match &claude_content[5] { - ContentChunk::Text { text } => { - assert!(text.contains("")); - assert!(text.contains("fn main() { println!(\"Hello!\"); }")); - assert!(text.contains("")); - } - _ => panic!("Expected Text chunk for context"), - } - } -} diff --git a/crates/agent_servers/src/claude/edit_tool.rs b/crates/agent_servers/src/claude/edit_tool.rs deleted file mode 100644 index a8d93c3f3d5579173709b3ace6194059745885a7..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/edit_tool.rs +++ /dev/null @@ -1,178 +0,0 @@ -use acp_thread::AcpThread; -use anyhow::Result; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::{ToolAnnotations, ToolResponseContent}, -}; -use gpui::{AsyncApp, WeakEntity}; -use language::unified_diff; -use util::markdown::MarkdownCodeBlock; - -use crate::tools::EditToolParams; - -#[derive(Clone)] -pub struct EditTool { - thread_rx: watch::Receiver>, -} - -impl EditTool { - pub fn new(thread_rx: watch::Receiver>) -> Self { - Self { thread_rx } - } -} - -impl McpServerTool for EditTool { - type Input = EditToolParams; - type Output = (); - - const NAME: &'static str = "Edit"; - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Edit file".to_string()), - read_only_hint: Some(false), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: Some(false), - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let content = thread - .update(cx, |thread, cx| { - thread.read_text_file(input.abs_path.clone(), None, None, true, cx) - })? - .await?; - - let (new_content, diff) = cx - .background_executor() - .spawn(async move { - let new_content = content.replace(&input.old_text, &input.new_text); - if new_content == content { - return Err(anyhow::anyhow!("Failed to find `old_text`",)); - } - let diff = unified_diff(&content, &new_content); - - Ok((new_content, diff)) - }) - .await?; - - thread - .update(cx, |thread, cx| { - thread.write_text_file(input.abs_path, new_content, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { - text: MarkdownCodeBlock { - tag: "diff", - text: diff.as_str().trim_end_matches('\n'), - } - .to_string(), - }], - structured_content: (), - }) - } -} - -#[cfg(test)] -mod tests { - use std::rc::Rc; - - use acp_thread::{AgentConnection, StubAgentConnection}; - use gpui::{Entity, TestAppContext}; - use indoc::indoc; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - use super::*; - - #[gpui::test] - async fn old_text_not_found(cx: &mut TestAppContext) { - let (_thread, tool) = init_test(cx).await; - - let result = tool - .run( - EditToolParams { - abs_path: path!("/root/file.txt").into(), - old_text: "hi".into(), - new_text: "bye".into(), - }, - &mut cx.to_async(), - ) - .await; - - assert_eq!(result.unwrap_err().to_string(), "Failed to find `old_text`"); - } - - #[gpui::test] - async fn found_and_replaced(cx: &mut TestAppContext) { - let (_thread, tool) = init_test(cx).await; - - let result = tool - .run( - EditToolParams { - abs_path: path!("/root/file.txt").into(), - old_text: "hello".into(), - new_text: "hi".into(), - }, - &mut cx.to_async(), - ) - .await; - - assert_eq!( - result.unwrap().content[0].text().unwrap(), - indoc! { - r" - ```diff - @@ -1,1 +1,1 @@ - -hello - +hi - ``` - " - } - ); - } - - async fn init_test(cx: &mut TestAppContext) -> (Entity, EditTool) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - Project::init_settings(cx); - }); - - let connection = Rc::new(StubAgentConnection::new()); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid()); - - let thread = cx - .update(|cx| connection.new_thread(project, path!("/test").as_ref(), cx)) - .await - .unwrap(); - - thread_tx.send(thread.downgrade()).unwrap(); - - (thread, EditTool::new(thread_rx)) - } -} diff --git a/crates/agent_servers/src/claude/mcp_server.rs b/crates/agent_servers/src/claude/mcp_server.rs deleted file mode 100644 index 6442c784b59a655def92bcae108c27c503daa630..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/mcp_server.rs +++ /dev/null @@ -1,99 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use crate::claude::edit_tool::EditTool; -use crate::claude::permission_tool::PermissionTool; -use crate::claude::read_tool::ReadTool; -use crate::claude::write_tool::WriteTool; -use acp_thread::AcpThread; -#[cfg(not(test))] -use anyhow::Context as _; -use anyhow::Result; -use collections::HashMap; -use context_server::types::{ - Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities, - ToolsCapabilities, requests, -}; -use gpui::{App, AsyncApp, Task, WeakEntity}; -use project::Fs; -use serde::Serialize; - -pub struct ClaudeZedMcpServer { - server: context_server::listener::McpServer, -} - -pub const SERVER_NAME: &str = "zed"; - -impl ClaudeZedMcpServer { - pub async fn new( - thread_rx: watch::Receiver>, - fs: Arc, - cx: &AsyncApp, - ) -> Result { - let mut mcp_server = context_server::listener::McpServer::new(cx).await?; - mcp_server.handle_request::(Self::handle_initialize); - - mcp_server.add_tool(PermissionTool::new(fs.clone(), thread_rx.clone())); - mcp_server.add_tool(ReadTool::new(thread_rx.clone())); - mcp_server.add_tool(EditTool::new(thread_rx.clone())); - mcp_server.add_tool(WriteTool::new(thread_rx.clone())); - - Ok(Self { server: mcp_server }) - } - - pub fn server_config(&self) -> Result { - #[cfg(not(test))] - let zed_path = std::env::current_exe() - .context("finding current executable path for use in mcp_server")?; - - #[cfg(test)] - let zed_path = crate::e2e_tests::get_zed_path(); - - Ok(McpServerConfig { - command: zed_path, - args: vec![ - "--nc".into(), - self.server.socket_path().display().to_string(), - ], - env: None, - }) - } - - fn handle_initialize(_: InitializeParams, cx: &App) -> Task> { - cx.foreground_executor().spawn(async move { - Ok(InitializeResponse { - protocol_version: ProtocolVersion("2025-06-18".into()), - capabilities: ServerCapabilities { - experimental: None, - logging: None, - completions: None, - prompts: None, - resources: None, - tools: Some(ToolsCapabilities { - list_changed: Some(false), - }), - }, - server_info: Implementation { - name: SERVER_NAME.into(), - version: "0.1.0".into(), - }, - meta: None, - }) - }) - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct McpConfig { - pub mcp_servers: HashMap, -} - -#[derive(Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct McpServerConfig { - pub command: PathBuf, - pub args: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub env: Option>, -} diff --git a/crates/agent_servers/src/claude/permission_tool.rs b/crates/agent_servers/src/claude/permission_tool.rs deleted file mode 100644 index 96a24105e87bd99b46ec16e39dc32df57557f882..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/permission_tool.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::sync::Arc; - -use acp_thread::AcpThread; -use agent_client_protocol as acp; -use agent_settings::AgentSettings; -use anyhow::{Context as _, Result}; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::ToolResponseContent, -}; -use gpui::{AsyncApp, WeakEntity}; -use project::Fs; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings as _, update_settings_file}; -use util::debug_panic; - -use crate::tools::ClaudeTool; - -#[derive(Clone)] -pub struct PermissionTool { - fs: Arc, - thread_rx: watch::Receiver>, -} - -/// Request permission for tool calls -#[derive(Deserialize, JsonSchema, Debug)] -pub struct PermissionToolParams { - tool_name: String, - input: serde_json::Value, - tool_use_id: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PermissionToolResponse { - behavior: PermissionToolBehavior, - updated_input: serde_json::Value, -} - -#[derive(Serialize)] -#[serde(rename_all = "snake_case")] -enum PermissionToolBehavior { - Allow, - Deny, -} - -impl PermissionTool { - pub fn new(fs: Arc, thread_rx: watch::Receiver>) -> Self { - Self { fs, thread_rx } - } -} - -impl McpServerTool for PermissionTool { - type Input = PermissionToolParams; - type Output = (); - - const NAME: &'static str = "Confirmation"; - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - if agent_settings::AgentSettings::try_read_global(cx, |settings| { - settings.always_allow_tool_actions - }) - .unwrap_or(false) - { - let response = PermissionToolResponse { - behavior: PermissionToolBehavior::Allow, - updated_input: input.input, - }; - - return Ok(ToolResponse { - content: vec![ToolResponseContent::Text { - text: serde_json::to_string(&response)?, - }], - structured_content: (), - }); - } - - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone()); - let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into()); - - const ALWAYS_ALLOW: &str = "always_allow"; - const ALLOW: &str = "allow"; - const REJECT: &str = "reject"; - - let chosen_option = thread - .update(cx, |thread, cx| { - thread.request_tool_call_authorization( - claude_tool.as_acp(tool_call_id).into(), - vec![ - acp::PermissionOption { - id: acp::PermissionOptionId(ALWAYS_ALLOW.into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - }, - acp::PermissionOption { - id: acp::PermissionOptionId(ALLOW.into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - }, - acp::PermissionOption { - id: acp::PermissionOptionId(REJECT.into()), - name: "Reject".into(), - kind: acp::PermissionOptionKind::RejectOnce, - }, - ], - cx, - ) - })?? - .await?; - - let response = match chosen_option.0.as_ref() { - ALWAYS_ALLOW => { - cx.update(|cx| { - update_settings_file::(self.fs.clone(), cx, |settings, _| { - settings.set_always_allow_tool_actions(true); - }); - })?; - - PermissionToolResponse { - behavior: PermissionToolBehavior::Allow, - updated_input: input.input, - } - } - ALLOW => PermissionToolResponse { - behavior: PermissionToolBehavior::Allow, - updated_input: input.input, - }, - REJECT => PermissionToolResponse { - behavior: PermissionToolBehavior::Deny, - updated_input: input.input, - }, - opt => { - debug_panic!("Unexpected option: {}", opt); - PermissionToolResponse { - behavior: PermissionToolBehavior::Deny, - updated_input: input.input, - } - } - }; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { - text: serde_json::to_string(&response)?, - }], - structured_content: (), - }) - } -} diff --git a/crates/agent_servers/src/claude/read_tool.rs b/crates/agent_servers/src/claude/read_tool.rs deleted file mode 100644 index cbe25876b3deabc32d1d30f7d8ad4de90fb21494..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/read_tool.rs +++ /dev/null @@ -1,59 +0,0 @@ -use acp_thread::AcpThread; -use anyhow::Result; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::{ToolAnnotations, ToolResponseContent}, -}; -use gpui::{AsyncApp, WeakEntity}; - -use crate::tools::ReadToolParams; - -#[derive(Clone)] -pub struct ReadTool { - thread_rx: watch::Receiver>, -} - -impl ReadTool { - pub fn new(thread_rx: watch::Receiver>) -> Self { - Self { thread_rx } - } -} - -impl McpServerTool for ReadTool { - type Input = ReadToolParams; - type Output = (); - - const NAME: &'static str = "Read"; - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Read file".to_string()), - read_only_hint: Some(true), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: None, - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - let content = thread - .update(cx, |thread, cx| { - thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![ToolResponseContent::Text { text: content }], - structured_content: (), - }) - } -} diff --git a/crates/agent_servers/src/claude/tools.rs b/crates/agent_servers/src/claude/tools.rs deleted file mode 100644 index 323190300131c87a443b95e4bb18424cf034e667..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/tools.rs +++ /dev/null @@ -1,688 +0,0 @@ -use std::path::PathBuf; - -use agent_client_protocol as acp; -use itertools::Itertools; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use util::ResultExt; - -pub enum ClaudeTool { - Task(Option), - NotebookRead(Option), - NotebookEdit(Option), - Edit(Option), - MultiEdit(Option), - ReadFile(Option), - Write(Option), - Ls(Option), - Glob(Option), - Grep(Option), - Terminal(Option), - WebFetch(Option), - WebSearch(Option), - TodoWrite(Option), - ExitPlanMode(Option), - Other { - name: String, - input: serde_json::Value, - }, -} - -impl ClaudeTool { - pub fn infer(tool_name: &str, input: serde_json::Value) -> Self { - match tool_name { - // Known tools - "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()), - "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()), - "mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()), - "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()), - "Write" => Self::Write(serde_json::from_value(input).log_err()), - "LS" => Self::Ls(serde_json::from_value(input).log_err()), - "Glob" => Self::Glob(serde_json::from_value(input).log_err()), - "Grep" => Self::Grep(serde_json::from_value(input).log_err()), - "Bash" => Self::Terminal(serde_json::from_value(input).log_err()), - "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()), - "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()), - "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()), - "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()), - "Task" => Self::Task(serde_json::from_value(input).log_err()), - "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()), - "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()), - // Inferred from name - _ => { - let tool_name = tool_name.to_lowercase(); - - if tool_name.contains("edit") || tool_name.contains("write") { - Self::Edit(None) - } else if tool_name.contains("terminal") { - Self::Terminal(None) - } else { - Self::Other { - name: tool_name, - input, - } - } - } - } - } - - pub fn label(&self) -> String { - match &self { - Self::Task(Some(params)) => params.description.clone(), - Self::Task(None) => "Task".into(), - Self::NotebookRead(Some(params)) => { - format!("Read Notebook {}", params.notebook_path.display()) - } - Self::NotebookRead(None) => "Read Notebook".into(), - Self::NotebookEdit(Some(params)) => { - format!("Edit Notebook {}", params.notebook_path.display()) - } - Self::NotebookEdit(None) => "Edit Notebook".into(), - Self::Terminal(Some(params)) => format!("`{}`", params.command), - Self::Terminal(None) => "Terminal".into(), - Self::ReadFile(_) => "Read File".into(), - Self::Ls(Some(params)) => { - format!("List Directory {}", params.path.display()) - } - Self::Ls(None) => "List Directory".into(), - Self::Edit(Some(params)) => { - format!("Edit {}", params.abs_path.display()) - } - Self::Edit(None) => "Edit".into(), - Self::MultiEdit(Some(params)) => { - format!("Multi Edit {}", params.file_path.display()) - } - Self::MultiEdit(None) => "Multi Edit".into(), - Self::Write(Some(params)) => { - format!("Write {}", params.abs_path.display()) - } - Self::Write(None) => "Write".into(), - Self::Glob(Some(params)) => { - format!("Glob `{params}`") - } - Self::Glob(None) => "Glob".into(), - Self::Grep(Some(params)) => format!("`{params}`"), - Self::Grep(None) => "Grep".into(), - Self::WebFetch(Some(params)) => format!("Fetch {}", params.url), - Self::WebFetch(None) => "Fetch".into(), - Self::WebSearch(Some(params)) => format!("Web Search: {}", params), - Self::WebSearch(None) => "Web Search".into(), - Self::TodoWrite(Some(params)) => format!( - "Update TODOs: {}", - params.todos.iter().map(|todo| &todo.content).join(", ") - ), - Self::TodoWrite(None) => "Update TODOs".into(), - Self::ExitPlanMode(_) => "Exit Plan Mode".into(), - Self::Other { name, .. } => name.clone(), - } - } - pub fn content(&self) -> Vec { - match &self { - Self::Other { input, .. } => vec![ - format!( - "```json\n{}```", - serde_json::to_string_pretty(&input).unwrap_or("{}".to_string()) - ) - .into(), - ], - Self::Task(Some(params)) => vec![params.prompt.clone().into()], - Self::NotebookRead(Some(params)) => { - vec![params.notebook_path.display().to_string().into()] - } - Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()], - Self::Terminal(Some(params)) => vec![ - format!( - "`{}`\n\n{}", - params.command, - params.description.as_deref().unwrap_or_default() - ) - .into(), - ], - Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()], - Self::Ls(Some(params)) => vec![params.path.display().to_string().into()], - Self::Glob(Some(params)) => vec![params.to_string().into()], - Self::Grep(Some(params)) => vec![format!("`{params}`").into()], - Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()], - Self::WebSearch(Some(params)) => vec![params.to_string().into()], - Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()], - Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.abs_path.clone(), - old_text: Some(params.old_text.clone()), - new_text: params.new_text.clone(), - }, - }], - Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.abs_path.clone(), - old_text: None, - new_text: params.content.clone(), - }, - }], - Self::MultiEdit(Some(params)) => { - // todo: show multiple edits in a multibuffer? - params - .edits - .first() - .map(|edit| { - vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: params.file_path.clone(), - old_text: Some(edit.old_string.clone()), - new_text: edit.new_string.clone(), - }, - }] - }) - .unwrap_or_default() - } - Self::TodoWrite(Some(_)) => { - // These are mapped to plan updates later - vec![] - } - Self::Task(None) - | Self::NotebookRead(None) - | Self::NotebookEdit(None) - | Self::Terminal(None) - | Self::ReadFile(None) - | Self::Ls(None) - | Self::Glob(None) - | Self::Grep(None) - | Self::WebFetch(None) - | Self::WebSearch(None) - | Self::TodoWrite(None) - | Self::ExitPlanMode(None) - | Self::Edit(None) - | Self::Write(None) - | Self::MultiEdit(None) => vec![], - } - } - - pub fn kind(&self) -> acp::ToolKind { - match self { - Self::Task(_) => acp::ToolKind::Think, - Self::NotebookRead(_) => acp::ToolKind::Read, - Self::NotebookEdit(_) => acp::ToolKind::Edit, - Self::Edit(_) => acp::ToolKind::Edit, - Self::MultiEdit(_) => acp::ToolKind::Edit, - Self::Write(_) => acp::ToolKind::Edit, - Self::ReadFile(_) => acp::ToolKind::Read, - Self::Ls(_) => acp::ToolKind::Search, - Self::Glob(_) => acp::ToolKind::Search, - Self::Grep(_) => acp::ToolKind::Search, - Self::Terminal(_) => acp::ToolKind::Execute, - Self::WebSearch(_) => acp::ToolKind::Search, - Self::WebFetch(_) => acp::ToolKind::Fetch, - Self::TodoWrite(_) => acp::ToolKind::Think, - Self::ExitPlanMode(_) => acp::ToolKind::Think, - Self::Other { .. } => acp::ToolKind::Other, - } - } - - pub fn locations(&self) -> Vec { - match &self { - Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: None, - }], - Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => { - vec![acp::ToolCallLocation { - path: file_path.clone(), - line: None, - }] - } - Self::Write(Some(WriteToolParams { - abs_path: file_path, - .. - })) => { - vec![acp::ToolCallLocation { - path: file_path.clone(), - line: None, - }] - } - Self::ReadFile(Some(ReadToolParams { - abs_path, offset, .. - })) => vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: *offset, - }], - Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => { - vec![acp::ToolCallLocation { - path: notebook_path.clone(), - line: None, - }] - } - Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => { - vec![acp::ToolCallLocation { - path: notebook_path.clone(), - line: None, - }] - } - Self::Glob(Some(GlobToolParams { - path: Some(path), .. - })) => vec![acp::ToolCallLocation { - path: path.clone(), - line: None, - }], - Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation { - path: path.clone(), - line: None, - }], - Self::Grep(Some(GrepToolParams { - path: Some(path), .. - })) => vec![acp::ToolCallLocation { - path: PathBuf::from(path), - line: None, - }], - Self::Task(_) - | Self::NotebookRead(None) - | Self::NotebookEdit(None) - | Self::Edit(None) - | Self::MultiEdit(None) - | Self::Write(None) - | Self::ReadFile(None) - | Self::Ls(None) - | Self::Glob(_) - | Self::Grep(_) - | Self::Terminal(_) - | Self::WebFetch(_) - | Self::WebSearch(_) - | Self::TodoWrite(_) - | Self::ExitPlanMode(_) - | Self::Other { .. } => vec![], - } - } - - pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall { - acp::ToolCall { - id, - kind: self.kind(), - status: acp::ToolCallStatus::InProgress, - title: self.label(), - content: self.content(), - locations: self.locations(), - raw_input: None, - raw_output: None, - } - } -} - -/// Edit a file. -/// -/// In sessions with mcp__zed__Edit always use it instead of Edit as it will -/// allow the user to conveniently review changes. -/// -/// File editing instructions: -/// - The `old_text` param must match existing file content, including indentation. -/// - The `old_text` param must come from the actual file, not an outline. -/// - The `old_text` section must not be empty. -/// - Be minimal with replacements: -/// - For unique lines, include only those lines. -/// - For non-unique lines, include enough context to identify them. -/// - Do not escape quotes, newlines, or other characters. -/// - Only edit the specified file. -#[derive(Deserialize, JsonSchema, Debug)] -pub struct EditToolParams { - /// The absolute path to the file to read. - pub abs_path: PathBuf, - /// The old text to replace (must be unique in the file) - pub old_text: String, - /// The new text. - pub new_text: String, -} - -/// Reads the content of the given file in the project. -/// -/// Never attempt to read a path that hasn't been previously mentioned. -/// -/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents. -#[derive(Deserialize, JsonSchema, Debug)] -pub struct ReadToolParams { - /// The absolute path to the file to read. - pub abs_path: PathBuf, - /// Which line to start reading from. Omit to start from the beginning. - #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, - /// How many lines to read. Omit for the whole file. - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, -} - -/// Writes content to the specified file in the project. -/// -/// In sessions with mcp__zed__Write always use it instead of Write as it will -/// allow the user to conveniently review changes. -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WriteToolParams { - /// The absolute path of the file to write. - pub abs_path: PathBuf, - /// The full content to write. - pub content: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct BashToolParams { - /// Shell command to execute - pub command: String, - /// 5-10 word description of what command does - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Timeout in ms (max 600000ms/10min, default 120000ms) - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct GlobToolParams { - /// Glob pattern like **/*.js or src/**/*.ts - pub pattern: String, - /// Directory to search in (omit for current directory) - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -impl std::fmt::Display for GlobToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(path) = &self.path { - write!(f, "{}", path.display())?; - } - write!(f, "{}", self.pattern) - } -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct LsToolParams { - /// Absolute path to directory - pub path: PathBuf, - /// Array of glob patterns to ignore - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ignore: Vec, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct GrepToolParams { - /// Regex pattern to search for - pub pattern: String, - /// File/directory to search (defaults to current directory) - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - /// "content" (shows lines), "files_with_matches" (default), "count" - #[serde(skip_serializing_if = "Option::is_none")] - pub output_mode: Option, - /// Filter files with glob pattern like "*.js" - #[serde(skip_serializing_if = "Option::is_none")] - pub glob: Option, - /// File type filter like "js", "py", "rust" - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub file_type: Option, - /// Case insensitive search - #[serde(rename = "-i", default, skip_serializing_if = "is_false")] - pub case_insensitive: bool, - /// Show line numbers (content mode only) - #[serde(rename = "-n", default, skip_serializing_if = "is_false")] - pub line_numbers: bool, - /// Lines after match (content mode only) - #[serde(rename = "-A", skip_serializing_if = "Option::is_none")] - pub after_context: Option, - /// Lines before match (content mode only) - #[serde(rename = "-B", skip_serializing_if = "Option::is_none")] - pub before_context: Option, - /// Lines before and after match (content mode only) - #[serde(rename = "-C", skip_serializing_if = "Option::is_none")] - pub context: Option, - /// Enable multiline/cross-line matching - #[serde(default, skip_serializing_if = "is_false")] - pub multiline: bool, - /// Limit output to first N results - #[serde(skip_serializing_if = "Option::is_none")] - pub head_limit: Option, -} - -impl std::fmt::Display for GrepToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "grep")?; - - // Boolean flags - if self.case_insensitive { - write!(f, " -i")?; - } - if self.line_numbers { - write!(f, " -n")?; - } - - // Context options - if let Some(after) = self.after_context { - write!(f, " -A {}", after)?; - } - if let Some(before) = self.before_context { - write!(f, " -B {}", before)?; - } - if let Some(context) = self.context { - write!(f, " -C {}", context)?; - } - - // Output mode - if let Some(mode) = &self.output_mode { - match mode { - GrepOutputMode::FilesWithMatches => write!(f, " -l")?, - GrepOutputMode::Count => write!(f, " -c")?, - GrepOutputMode::Content => {} // Default mode - } - } - - // Head limit - if let Some(limit) = self.head_limit { - write!(f, " | head -{}", limit)?; - } - - // Glob pattern - if let Some(glob) = &self.glob { - write!(f, " --include=\"{}\"", glob)?; - } - - // File type - if let Some(file_type) = &self.file_type { - write!(f, " --type={}", file_type)?; - } - - // Multiline - if self.multiline { - write!(f, " -P")?; // Perl-compatible regex for multiline - } - - // Pattern (escaped if contains special characters) - write!(f, " \"{}\"", self.pattern)?; - - // Path - if let Some(path) = &self.path { - write!(f, " {}", path)?; - } - - Ok(()) - } -} - -#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)] -#[serde(rename_all = "snake_case")] -pub enum TodoPriority { - High, - #[default] - Medium, - Low, -} - -impl Into for TodoPriority { - fn into(self) -> acp::PlanEntryPriority { - match self { - TodoPriority::High => acp::PlanEntryPriority::High, - TodoPriority::Medium => acp::PlanEntryPriority::Medium, - TodoPriority::Low => acp::PlanEntryPriority::Low, - } - } -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum TodoStatus { - Pending, - InProgress, - Completed, -} - -impl Into for TodoStatus { - fn into(self) -> acp::PlanEntryStatus { - match self { - TodoStatus::Pending => acp::PlanEntryStatus::Pending, - TodoStatus::InProgress => acp::PlanEntryStatus::InProgress, - TodoStatus::Completed => acp::PlanEntryStatus::Completed, - } - } -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -pub struct Todo { - /// Task description - pub content: String, - /// Current status of the todo - pub status: TodoStatus, - /// Priority level of the todo - #[serde(default)] - pub priority: TodoPriority, -} - -impl Into for Todo { - fn into(self) -> acp::PlanEntry { - acp::PlanEntry { - content: self.content, - priority: self.priority.into(), - status: self.status.into(), - } - } -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct TodoWriteToolParams { - pub todos: Vec, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct ExitPlanModeToolParams { - /// Implementation plan in markdown format - pub plan: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct TaskToolParams { - /// Short 3-5 word description of task - pub description: String, - /// Detailed task for agent to perform - pub prompt: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct NotebookReadToolParams { - /// Absolute path to .ipynb file - pub notebook_path: PathBuf, - /// Specific cell ID to read - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_id: Option, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum CellType { - Code, - Markdown, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum EditMode { - Replace, - Insert, - Delete, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct NotebookEditToolParams { - /// Absolute path to .ipynb file - pub notebook_path: PathBuf, - /// New cell content - pub new_source: String, - /// Cell ID to edit - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_id: Option, - /// Type of cell (code or markdown) - #[serde(skip_serializing_if = "Option::is_none")] - pub cell_type: Option, - /// Edit operation mode - #[serde(skip_serializing_if = "Option::is_none")] - pub edit_mode: Option, -} - -#[derive(Deserialize, Serialize, JsonSchema, Debug)] -pub struct MultiEditItem { - /// The text to search for and replace - pub old_string: String, - /// The replacement text - pub new_string: String, - /// Whether to replace all occurrences or just the first - #[serde(default, skip_serializing_if = "is_false")] - pub replace_all: bool, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct MultiEditToolParams { - /// Absolute path to file - pub file_path: PathBuf, - /// List of edits to apply - pub edits: Vec, -} - -fn is_false(v: &bool) -> bool { - !*v -} - -#[derive(Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub enum GrepOutputMode { - Content, - FilesWithMatches, - Count, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WebFetchToolParams { - /// Valid URL to fetch - #[serde(rename = "url")] - pub url: String, - /// What to extract from content - pub prompt: String, -} - -#[derive(Deserialize, JsonSchema, Debug)] -pub struct WebSearchToolParams { - /// Search query (min 2 chars) - pub query: String, - /// Only include these domains - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_domains: Vec, - /// Exclude these domains - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub blocked_domains: Vec, -} - -impl std::fmt::Display for WebSearchToolParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "\"{}\"", self.query)?; - - if !self.allowed_domains.is_empty() { - write!(f, " (allowed: {})", self.allowed_domains.join(", "))?; - } - - if !self.blocked_domains.is_empty() { - write!(f, " (blocked: {})", self.blocked_domains.join(", "))?; - } - - Ok(()) - } -} diff --git a/crates/agent_servers/src/claude/write_tool.rs b/crates/agent_servers/src/claude/write_tool.rs deleted file mode 100644 index 39479a9c38ba616b3d0f2e4197c112bcbea68261..0000000000000000000000000000000000000000 --- a/crates/agent_servers/src/claude/write_tool.rs +++ /dev/null @@ -1,59 +0,0 @@ -use acp_thread::AcpThread; -use anyhow::Result; -use context_server::{ - listener::{McpServerTool, ToolResponse}, - types::ToolAnnotations, -}; -use gpui::{AsyncApp, WeakEntity}; - -use crate::tools::WriteToolParams; - -#[derive(Clone)] -pub struct WriteTool { - thread_rx: watch::Receiver>, -} - -impl WriteTool { - pub fn new(thread_rx: watch::Receiver>) -> Self { - Self { thread_rx } - } -} - -impl McpServerTool for WriteTool { - type Input = WriteToolParams; - type Output = (); - - const NAME: &'static str = "Write"; - - fn annotations(&self) -> ToolAnnotations { - ToolAnnotations { - title: Some("Write file".to_string()), - read_only_hint: Some(false), - destructive_hint: Some(false), - open_world_hint: Some(false), - idempotent_hint: Some(false), - } - } - - async fn run( - &self, - input: Self::Input, - cx: &mut AsyncApp, - ) -> Result> { - let mut thread_rx = self.thread_rx.clone(); - let Some(thread) = thread_rx.recv().await?.upgrade() else { - anyhow::bail!("Thread closed"); - }; - - thread - .update(cx, |thread, cx| { - thread.write_text_file(input.abs_path, input.content, cx) - })? - .await?; - - Ok(ToolResponse { - content: vec![], - structured_content: (), - }) - } -} diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index a481a850ff70018ff2e6b72446ca24e78732137a..8d9670473a619eea9dc6730d04a4c807937aa393 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -2,7 +2,6 @@ use crate::{AgentServerCommand, AgentServerDelegate}; use acp_thread::AgentConnection; use anyhow::Result; use gpui::{App, SharedString, Task}; -use language_models::provider::anthropic::AnthropicLanguageModelProvider; use std::{path::Path, rc::Rc}; use ui::IconName; @@ -38,24 +37,9 @@ impl crate::AgentServer for CustomAgentServer { cx: &mut App, ) -> Task>> { let server_name = self.name(); - let mut command = self.command.clone(); + let command = self.command.clone(); let root_dir = root_dir.to_path_buf(); - - // TODO: Remove this once we have Claude properly - cx.spawn(async move |mut cx| { - if let Some(api_key) = cx - .update(AnthropicLanguageModelProvider::api_key)? - .await - .ok() - { - command - .env - .get_or_insert_default() - .insert("ANTHROPIC_API_KEY".to_owned(), api_key.key); - } - - crate::acp::connect(server_name, command, &root_dir, &mut cx).await - }) + cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await) } fn into_any(self: Rc) -> Rc { diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index d310870c23c957900e3cdcdb8a88084f26520208..5d2becf0ccc4b30cfeca27f4eb5ee08c2d0bb7d1 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,4 +1,6 @@ use crate::{AgentServer, AgentServerDelegate}; +#[cfg(test)] +use crate::{AgentServerCommand, CustomAgentServerSettings}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; use agent_client_protocol as acp; use futures::{FutureExt, StreamExt, channel::mpsc, select}; @@ -471,7 +473,13 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { #[cfg(test)] crate::AllAgentServersSettings::override_global( crate::AllAgentServersSettings { - claude: Some(crate::claude::tests::local_command().into()), + claude: Some(CustomAgentServerSettings { + command: AgentServerCommand { + path: "claude-code-acp".into(), + args: vec![], + env: None, + }, + }), gemini: Some(crate::gemini::tests::local_command().into()), custom: collections::HashMap::default(), }, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 84dc6750b1a8e74b509e467d811ec790cdc0dea9..5e958f686959d78e6ceaf8b8ea7d8404ffba166a 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -5,7 +5,7 @@ use crate::acp::AcpConnection; use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AgentConnection, LoadError}; use anyhow::Result; -use gpui::{App, SharedString, Task}; +use gpui::{App, AppContext as _, SharedString, Task}; use language_models::provider::google::GoogleLanguageModelProvider; use settings::SettingsStore; @@ -37,23 +37,32 @@ impl AgentServer for Gemini { ) -> Task>> { let root_dir = root_dir.to_path_buf(); let server_name = self.name(); - cx.spawn(async move |cx| { - let settings = cx.read_global(|settings: &SettingsStore, _| { - settings.get::(None).gemini.clone() - })?; + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).gemini.clone() + }); - let mut command = cx - .update(|cx| { + cx.spawn(async move |cx| { + let ignore_system_version = settings + .as_ref() + .and_then(|settings| settings.ignore_system_version) + .unwrap_or(true); + let mut command = if let Some(settings) = settings + && let Some(command) = settings.custom_command() + { + command + } else { + cx.update(|cx| { delegate.get_or_npm_install_builtin_agent( Self::BINARY_NAME.into(), Self::PACKAGE_NAME.into(), format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(), - settings, - Some("0.2.1".parse().unwrap()), + ignore_system_version, + Some(Self::MINIMUM_VERSION.parse().unwrap()), cx, ) })? - .await?; + .await? + }; command.args.push("--experimental-acp".into()); if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() { diff --git a/crates/agent_servers/src/settings.rs b/crates/agent_servers/src/settings.rs index 59f3b4b54089a5598bc77d0ba127f2c54e9ec986..81f80a7d7d9581b8c1862ae3393c4a5d5e6706b6 100644 --- a/crates/agent_servers/src/settings.rs +++ b/crates/agent_servers/src/settings.rs @@ -15,7 +15,7 @@ pub fn init(cx: &mut App) { #[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)] pub struct AllAgentServersSettings { pub gemini: Option, - pub claude: Option, + pub claude: Option, /// Custom agent servers configured by the user #[serde(flatten)]