Update to new agent schema (#35578)
Release Notes: - N/A --------- Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
parent
dea64d3373
commit
f17943e4a3
23 changed files with 741 additions and 1168 deletions
|
@ -25,6 +25,7 @@ collections.workspace = true
|
|||
context_server.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
indoc.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
paths.workspace = true
|
||||
|
@ -37,11 +38,11 @@ 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
|
||||
indoc.workspace = true
|
||||
which.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
|
|
34
crates/agent_servers/src/acp.rs
Normal file
34
crates/agent_servers/src/acp.rs
Normal file
|
@ -0,0 +1,34 @@
|
|||
use std::{path::Path, rc::Rc};
|
||||
|
||||
use crate::AgentServerCommand;
|
||||
use acp_thread::AgentConnection;
|
||||
use anyhow::Result;
|
||||
use gpui::AsyncApp;
|
||||
use thiserror::Error;
|
||||
|
||||
mod v0;
|
||||
mod v1;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("Unsupported version")]
|
||||
pub struct UnsupportedVersion;
|
||||
|
||||
pub async fn connect(
|
||||
server_name: &'static str,
|
||||
command: AgentServerCommand,
|
||||
root_dir: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Rc<dyn AgentConnection>> {
|
||||
let conn = v1::AcpConnection::stdio(server_name, command.clone(), &root_dir, cx).await;
|
||||
|
||||
match conn {
|
||||
Ok(conn) => Ok(Rc::new(conn) as _),
|
||||
Err(err) if err.is::<UnsupportedVersion>() => {
|
||||
// Consider re-using initialize response and subprocess when adding another version here
|
||||
let conn: Rc<dyn AgentConnection> =
|
||||
Rc::new(v0::AcpConnection::stdio(server_name, command, &root_dir, cx).await?);
|
||||
Ok(conn)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
501
crates/agent_servers/src/acp/v0.rs
Normal file
501
crates/agent_servers/src/acp/v0.rs
Normal file
|
@ -0,0 +1,501 @@
|
|||
// Translates old acp agents into the new schema
|
||||
use agent_client_protocol as acp;
|
||||
use agentic_coding_protocol::{self as acp_old, AgentRequest as _};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
||||
use project::Project;
|
||||
use std::{cell::RefCell, path::Path, rc::Rc};
|
||||
use ui::App;
|
||||
use util::ResultExt as _;
|
||||
|
||||
use crate::AgentServerCommand;
|
||||
use acp_thread::{AcpThread, AgentConnection, AuthRequired};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OldAcpClientDelegate {
|
||||
thread: Rc<RefCell<WeakEntity<AcpThread>>>,
|
||||
cx: AsyncApp,
|
||||
next_tool_call_id: Rc<RefCell<u64>>,
|
||||
// sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
|
||||
}
|
||||
|
||||
impl OldAcpClientDelegate {
|
||||
fn new(thread: Rc<RefCell<WeakEntity<AcpThread>>>, cx: AsyncApp) -> Self {
|
||||
Self {
|
||||
thread,
|
||||
cx,
|
||||
next_tool_call_id: Rc::new(RefCell::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl acp_old::Client for OldAcpClientDelegate {
|
||||
async fn stream_assistant_message_chunk(
|
||||
&self,
|
||||
params: acp_old::StreamAssistantMessageChunkParams,
|
||||
) -> Result<(), acp_old::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
cx.update(|cx| {
|
||||
self.thread
|
||||
.borrow()
|
||||
.update(cx, |thread, cx| match params.chunk {
|
||||
acp_old::AssistantMessageChunk::Text { text } => {
|
||||
thread.push_assistant_content_block(text.into(), false, cx)
|
||||
}
|
||||
acp_old::AssistantMessageChunk::Thought { thought } => {
|
||||
thread.push_assistant_content_block(thought.into(), true, cx)
|
||||
}
|
||||
})
|
||||
.log_err();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn request_tool_call_confirmation(
|
||||
&self,
|
||||
request: acp_old::RequestToolCallConfirmationParams,
|
||||
) -> Result<acp_old::RequestToolCallConfirmationResponse, acp_old::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
let old_acp_id = *self.next_tool_call_id.borrow() + 1;
|
||||
self.next_tool_call_id.replace(old_acp_id);
|
||||
|
||||
let tool_call = into_new_tool_call(
|
||||
acp::ToolCallId(old_acp_id.to_string().into()),
|
||||
request.tool_call,
|
||||
);
|
||||
|
||||
let mut options = match request.confirmation {
|
||||
acp_old::ToolCallConfirmation::Edit { .. } => vec![(
|
||||
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
acp::PermissionOptionKind::AllowAlways,
|
||||
"Always Allow Edits".to_string(),
|
||||
)],
|
||||
acp_old::ToolCallConfirmation::Execute { root_command, .. } => vec![(
|
||||
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
acp::PermissionOptionKind::AllowAlways,
|
||||
format!("Always Allow {}", root_command),
|
||||
)],
|
||||
acp_old::ToolCallConfirmation::Mcp {
|
||||
server_name,
|
||||
tool_name,
|
||||
..
|
||||
} => vec![
|
||||
(
|
||||
acp_old::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
|
||||
acp::PermissionOptionKind::AllowAlways,
|
||||
format!("Always Allow {}", server_name),
|
||||
),
|
||||
(
|
||||
acp_old::ToolCallConfirmationOutcome::AlwaysAllowTool,
|
||||
acp::PermissionOptionKind::AllowAlways,
|
||||
format!("Always Allow {}", tool_name),
|
||||
),
|
||||
],
|
||||
acp_old::ToolCallConfirmation::Fetch { .. } => vec![(
|
||||
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
acp::PermissionOptionKind::AllowAlways,
|
||||
"Always Allow".to_string(),
|
||||
)],
|
||||
acp_old::ToolCallConfirmation::Other { .. } => vec![(
|
||||
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
|
||||
acp::PermissionOptionKind::AllowAlways,
|
||||
"Always Allow".to_string(),
|
||||
)],
|
||||
};
|
||||
|
||||
options.extend([
|
||||
(
|
||||
acp_old::ToolCallConfirmationOutcome::Allow,
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
"Allow".to_string(),
|
||||
),
|
||||
(
|
||||
acp_old::ToolCallConfirmationOutcome::Reject,
|
||||
acp::PermissionOptionKind::RejectOnce,
|
||||
"Reject".to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
let mut outcomes = Vec::with_capacity(options.len());
|
||||
let mut acp_options = Vec::with_capacity(options.len());
|
||||
|
||||
for (index, (outcome, kind, label)) in options.into_iter().enumerate() {
|
||||
outcomes.push(outcome);
|
||||
acp_options.push(acp::PermissionOption {
|
||||
id: acp::PermissionOptionId(index.to_string().into()),
|
||||
label,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
let response = cx
|
||||
.update(|cx| {
|
||||
self.thread.borrow().update(cx, |thread, cx| {
|
||||
thread.request_tool_call_permission(tool_call, acp_options, cx)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")?
|
||||
.await;
|
||||
|
||||
let outcome = match response {
|
||||
Ok(option_id) => outcomes[option_id.0.parse::<usize>().unwrap_or(0)],
|
||||
Err(oneshot::Canceled) => acp_old::ToolCallConfirmationOutcome::Cancel,
|
||||
};
|
||||
|
||||
Ok(acp_old::RequestToolCallConfirmationResponse {
|
||||
id: acp_old::ToolCallId(old_acp_id),
|
||||
outcome: outcome,
|
||||
})
|
||||
}
|
||||
|
||||
async fn push_tool_call(
|
||||
&self,
|
||||
request: acp_old::PushToolCallParams,
|
||||
) -> Result<acp_old::PushToolCallResponse, acp_old::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
let old_acp_id = *self.next_tool_call_id.borrow() + 1;
|
||||
self.next_tool_call_id.replace(old_acp_id);
|
||||
|
||||
cx.update(|cx| {
|
||||
self.thread.borrow().update(cx, |thread, cx| {
|
||||
thread.upsert_tool_call(
|
||||
into_new_tool_call(acp::ToolCallId(old_acp_id.to_string().into()), request),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")?;
|
||||
|
||||
Ok(acp_old::PushToolCallResponse {
|
||||
id: acp_old::ToolCallId(old_acp_id),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_tool_call(
|
||||
&self,
|
||||
request: acp_old::UpdateToolCallParams,
|
||||
) -> Result<(), acp_old::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
cx.update(|cx| {
|
||||
self.thread.borrow().update(cx, |thread, cx| {
|
||||
thread.update_tool_call(
|
||||
acp::ToolCallUpdate {
|
||||
id: acp::ToolCallId(request.tool_call_id.0.to_string().into()),
|
||||
fields: acp::ToolCallUpdateFields {
|
||||
status: Some(into_new_tool_call_status(request.status)),
|
||||
content: Some(
|
||||
request
|
||||
.content
|
||||
.into_iter()
|
||||
.map(into_new_tool_call_content)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_plan(&self, request: acp_old::UpdatePlanParams) -> Result<(), acp_old::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
cx.update(|cx| {
|
||||
self.thread.borrow().update(cx, |thread, cx| {
|
||||
thread.update_plan(
|
||||
acp::Plan {
|
||||
entries: request
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(into_new_plan_entry)
|
||||
.collect(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_text_file(
|
||||
&self,
|
||||
acp_old::ReadTextFileParams { path, line, limit }: acp_old::ReadTextFileParams,
|
||||
) -> Result<acp_old::ReadTextFileResponse, acp_old::Error> {
|
||||
let content = self
|
||||
.cx
|
||||
.update(|cx| {
|
||||
self.thread.borrow().update(cx, |thread, cx| {
|
||||
thread.read_text_file(path, line, limit, false, cx)
|
||||
})
|
||||
})?
|
||||
.context("Failed to update thread")?
|
||||
.await?;
|
||||
Ok(acp_old::ReadTextFileResponse { content })
|
||||
}
|
||||
|
||||
async fn write_text_file(
|
||||
&self,
|
||||
acp_old::WriteTextFileParams { path, content }: acp_old::WriteTextFileParams,
|
||||
) -> Result<(), acp_old::Error> {
|
||||
self.cx
|
||||
.update(|cx| {
|
||||
self.thread
|
||||
.borrow()
|
||||
.update(cx, |thread, cx| thread.write_text_file(path, content, cx))
|
||||
})?
|
||||
.context("Failed to update thread")?
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) -> acp::ToolCall {
|
||||
acp::ToolCall {
|
||||
id: id,
|
||||
label: request.label,
|
||||
kind: acp_kind_from_old_icon(request.icon),
|
||||
status: acp::ToolCallStatus::InProgress,
|
||||
content: request
|
||||
.content
|
||||
.into_iter()
|
||||
.map(into_new_tool_call_content)
|
||||
.collect(),
|
||||
locations: request
|
||||
.locations
|
||||
.into_iter()
|
||||
.map(into_new_tool_call_location)
|
||||
.collect(),
|
||||
raw_input: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn acp_kind_from_old_icon(icon: acp_old::Icon) -> acp::ToolKind {
|
||||
match icon {
|
||||
acp_old::Icon::FileSearch => acp::ToolKind::Search,
|
||||
acp_old::Icon::Folder => acp::ToolKind::Search,
|
||||
acp_old::Icon::Globe => acp::ToolKind::Search,
|
||||
acp_old::Icon::Hammer => acp::ToolKind::Other,
|
||||
acp_old::Icon::LightBulb => acp::ToolKind::Think,
|
||||
acp_old::Icon::Pencil => acp::ToolKind::Edit,
|
||||
acp_old::Icon::Regex => acp::ToolKind::Search,
|
||||
acp_old::Icon::Terminal => acp::ToolKind::Execute,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallStatus {
|
||||
match status {
|
||||
acp_old::ToolCallStatus::Running => acp::ToolCallStatus::InProgress,
|
||||
acp_old::ToolCallStatus::Finished => acp::ToolCallStatus::Completed,
|
||||
acp_old::ToolCallStatus::Error => acp::ToolCallStatus::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent {
|
||||
match content {
|
||||
acp_old::ToolCallContent::Markdown { markdown } => markdown.into(),
|
||||
acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff {
|
||||
diff: into_new_diff(diff),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_diff(diff: acp_old::Diff) -> acp::Diff {
|
||||
acp::Diff {
|
||||
path: diff.path,
|
||||
old_text: diff.old_text,
|
||||
new_text: diff.new_text,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_tool_call_location(location: acp_old::ToolCallLocation) -> acp::ToolCallLocation {
|
||||
acp::ToolCallLocation {
|
||||
path: location.path,
|
||||
line: location.line,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_plan_entry(entry: acp_old::PlanEntry) -> acp::PlanEntry {
|
||||
acp::PlanEntry {
|
||||
content: entry.content,
|
||||
priority: into_new_plan_priority(entry.priority),
|
||||
status: into_new_plan_status(entry.status),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_plan_priority(priority: acp_old::PlanEntryPriority) -> acp::PlanEntryPriority {
|
||||
match priority {
|
||||
acp_old::PlanEntryPriority::Low => acp::PlanEntryPriority::Low,
|
||||
acp_old::PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium,
|
||||
acp_old::PlanEntryPriority::High => acp::PlanEntryPriority::High,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_new_plan_status(status: acp_old::PlanEntryStatus) -> acp::PlanEntryStatus {
|
||||
match status {
|
||||
acp_old::PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending,
|
||||
acp_old::PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress,
|
||||
acp_old::PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AcpConnection {
|
||||
pub name: &'static str,
|
||||
pub connection: acp_old::AgentConnection,
|
||||
pub _child_status: Task<Result<()>>,
|
||||
pub current_thread: Rc<RefCell<WeakEntity<AcpThread>>>,
|
||||
}
|
||||
|
||||
impl AcpConnection {
|
||||
pub fn stdio(
|
||||
name: &'static str,
|
||||
command: AgentServerCommand,
|
||||
root_dir: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<Self>> {
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let mut child = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
|
||||
let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid()));
|
||||
|
||||
let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent(
|
||||
OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()),
|
||||
stdin,
|
||||
stdout,
|
||||
move |fut| foreground_executor.spawn(fut).detach(),
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn(async move {
|
||||
io_fut.await.log_err();
|
||||
});
|
||||
|
||||
let child_status = cx.background_spawn(async move {
|
||||
let result = match child.status().await {
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
Ok(result) if result.success() => Ok(()),
|
||||
Ok(result) => Err(anyhow!(result)),
|
||||
};
|
||||
drop(io_task);
|
||||
result
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
connection,
|
||||
_child_status: child_status,
|
||||
current_thread: thread_rc,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConnection for AcpConnection {
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let task = self.connection.request_any(
|
||||
acp_old::InitializeParams {
|
||||
protocol_version: acp_old::ProtocolVersion::latest(),
|
||||
}
|
||||
.into_any(),
|
||||
);
|
||||
let current_thread = self.current_thread.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
let result = task.await?;
|
||||
let result = acp_old::InitializeParams::response_from_any(result)?;
|
||||
|
||||
if !result.is_authenticated {
|
||||
anyhow::bail!(AuthRequired)
|
||||
}
|
||||
|
||||
cx.update(|cx| {
|
||||
let thread = cx.new(|cx| {
|
||||
let session_id = acp::SessionId("acp-old-no-id".into());
|
||||
AcpThread::new(self.name, self.clone(), project, session_id, cx)
|
||||
});
|
||||
current_thread.replace(thread.downgrade());
|
||||
thread
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn authenticate(&self, _method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
|
||||
let task = self
|
||||
.connection
|
||||
.request_any(acp_old::AuthenticateParams.into_any());
|
||||
cx.foreground_executor().spawn(async move {
|
||||
task.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt(&self, params: acp::PromptRequest, cx: &mut App) -> Task<Result<()>> {
|
||||
let chunks = params
|
||||
.prompt
|
||||
.into_iter()
|
||||
.filter_map(|block| match block {
|
||||
acp::ContentBlock::Text(text) => {
|
||||
Some(acp_old::UserMessageChunk::Text { text: text.text })
|
||||
}
|
||||
acp::ContentBlock::ResourceLink(link) => Some(acp_old::UserMessageChunk::Path {
|
||||
path: link.uri.into(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let task = self
|
||||
.connection
|
||||
.request_any(acp_old::SendUserMessageParams { chunks }.into_any());
|
||||
cx.foreground_executor().spawn(async move {
|
||||
task.await?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&self, _session_id: &acp::SessionId, cx: &mut App) {
|
||||
let task = self
|
||||
.connection
|
||||
.request_any(acp_old::CancelSendMessageParams.into_any());
|
||||
cx.foreground_executor()
|
||||
.spawn(async move {
|
||||
task.await?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx)
|
||||
}
|
||||
}
|
254
crates/agent_servers/src/acp/v1.rs
Normal file
254
crates/agent_servers/src/acp/v1.rs
Normal file
|
@ -0,0 +1,254 @@
|
|||
use agent_client_protocol::{self as acp, Agent as _};
|
||||
use collections::HashMap;
|
||||
use futures::channel::oneshot;
|
||||
use project::Project;
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
||||
|
||||
use crate::{AgentServerCommand, acp::UnsupportedVersion};
|
||||
use acp_thread::{AcpThread, AgentConnection, AuthRequired};
|
||||
|
||||
pub struct AcpConnection {
|
||||
server_name: &'static str,
|
||||
connection: Rc<acp::ClientSideConnection>,
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
auth_methods: Vec<acp::AuthMethod>,
|
||||
_io_task: Task<Result<()>>,
|
||||
_child: smol::process::Child,
|
||||
}
|
||||
|
||||
pub struct AcpSession {
|
||||
thread: WeakEntity<AcpThread>,
|
||||
}
|
||||
|
||||
const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1;
|
||||
|
||||
impl AcpConnection {
|
||||
pub async fn stdio(
|
||||
server_name: &'static str,
|
||||
command: AgentServerCommand,
|
||||
root_dir: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let mut child = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter().map(|arg| arg.as_str()))
|
||||
.envs(command.env.iter().flatten())
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take().expect("Failed to take stdout");
|
||||
let stdin = child.stdin.take().expect("Failed to take stdin");
|
||||
|
||||
let sessions = Rc::new(RefCell::new(HashMap::default()));
|
||||
|
||||
let client = ClientDelegate {
|
||||
sessions: sessions.clone(),
|
||||
cx: cx.clone(),
|
||||
};
|
||||
let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, {
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
move |fut| {
|
||||
foreground_executor.spawn(fut).detach();
|
||||
}
|
||||
});
|
||||
|
||||
let io_task = cx.background_spawn(io_task);
|
||||
|
||||
let response = connection
|
||||
.initialize(acp::InitializeRequest {
|
||||
protocol_version: acp::VERSION,
|
||||
client_capabilities: acp::ClientCapabilities {
|
||||
fs: acp::FileSystemCapability {
|
||||
read_text_file: true,
|
||||
write_text_file: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
if response.protocol_version < MINIMUM_SUPPORTED_VERSION {
|
||||
return Err(UnsupportedVersion.into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
auth_methods: response.auth_methods,
|
||||
connection: connection.into(),
|
||||
server_name,
|
||||
sessions,
|
||||
_child: child,
|
||||
_io_task: io_task,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConnection for AcpConnection {
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let conn = self.connection.clone();
|
||||
let sessions = self.sessions.clone();
|
||||
let cwd = cwd.to_path_buf();
|
||||
cx.spawn(async move |cx| {
|
||||
let response = conn
|
||||
.new_session(acp::NewSessionRequest {
|
||||
mcp_servers: vec![],
|
||||
cwd,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let Some(session_id) = response.session_id else {
|
||||
anyhow::bail!(AuthRequired);
|
||||
};
|
||||
|
||||
let thread = cx.new(|cx| {
|
||||
AcpThread::new(
|
||||
self.server_name,
|
||||
self.clone(),
|
||||
project,
|
||||
session_id.clone(),
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
|
||||
let session = AcpSession {
|
||||
thread: thread.downgrade(),
|
||||
};
|
||||
sessions.borrow_mut().insert(session_id, session);
|
||||
|
||||
Ok(thread)
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&self.auth_methods
|
||||
}
|
||||
|
||||
fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
|
||||
let conn = self.connection.clone();
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let result = conn
|
||||
.authenticate(acp::AuthenticateRequest {
|
||||
method_id: method_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt(&self, params: acp::PromptRequest, cx: &mut App) -> Task<Result<()>> {
|
||||
let conn = self.connection.clone();
|
||||
cx.foreground_executor()
|
||||
.spawn(async move { Ok(conn.prompt(params).await?) })
|
||||
}
|
||||
|
||||
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
|
||||
let conn = self.connection.clone();
|
||||
let params = acp::CancelledNotification {
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
cx.foreground_executor()
|
||||
.spawn(async move { conn.cancelled(params).await })
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientDelegate {
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
cx: AsyncApp,
|
||||
}
|
||||
|
||||
impl acp::Client for ClientDelegate {
|
||||
async fn request_permission(
|
||||
&self,
|
||||
arguments: acp::RequestPermissionRequest,
|
||||
) -> Result<acp::RequestPermissionResponse, acp::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let rx = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?
|
||||
.thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_permission(arguments.tool_call, arguments.options, cx)
|
||||
})?;
|
||||
|
||||
let result = rx.await;
|
||||
|
||||
let outcome = match result {
|
||||
Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option },
|
||||
Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled,
|
||||
};
|
||||
|
||||
Ok(acp::RequestPermissionResponse { outcome })
|
||||
}
|
||||
|
||||
async fn write_text_file(
|
||||
&self,
|
||||
arguments: acp::WriteTextFileRequest,
|
||||
) -> Result<(), acp::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let task = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?
|
||||
.thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.write_text_file(arguments.path, arguments.content, cx)
|
||||
})?;
|
||||
|
||||
task.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_text_file(
|
||||
&self,
|
||||
arguments: acp::ReadTextFileRequest,
|
||||
) -> Result<acp::ReadTextFileResponse, acp::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let task = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?
|
||||
.thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx)
|
||||
})?;
|
||||
|
||||
let content = task.await?;
|
||||
|
||||
Ok(acp::ReadTextFileResponse { content })
|
||||
}
|
||||
|
||||
async fn session_notification(
|
||||
&self,
|
||||
notification: acp::SessionNotification,
|
||||
) -> Result<(), acp::Error> {
|
||||
let cx = &mut self.cx.clone();
|
||||
let sessions = self.sessions.borrow();
|
||||
let session = sessions
|
||||
.get(¬ification.session_id)
|
||||
.context("Failed to get session")?;
|
||||
|
||||
session.thread.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(notification.update, cx)
|
||||
})??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,14 +1,12 @@
|
|||
mod acp;
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod mcp_server;
|
||||
mod settings;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
|
||||
pub use claude::*;
|
||||
pub use codex::*;
|
||||
pub use gemini::*;
|
||||
pub use settings::*;
|
||||
|
||||
|
@ -38,7 +36,6 @@ pub trait AgentServer: Send {
|
|||
|
||||
fn connect(
|
||||
&self,
|
||||
// these will go away when old_acp is fully removed
|
||||
root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
|
|
|
@ -70,10 +70,6 @@ struct ClaudeAgentConnection {
|
|||
}
|
||||
|
||||
impl AgentConnection for ClaudeAgentConnection {
|
||||
fn name(&self) -> &'static str {
|
||||
ClaudeCode.name()
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
|
@ -168,8 +164,9 @@ impl AgentConnection for ClaudeAgentConnection {
|
|||
}
|
||||
});
|
||||
|
||||
let thread =
|
||||
cx.new(|cx| AcpThread::new(self.clone(), project, session_id.clone(), cx))?;
|
||||
let thread = cx.new(|cx| {
|
||||
AcpThread::new("Claude Code", self.clone(), project, session_id.clone(), cx)
|
||||
})?;
|
||||
|
||||
thread_tx.send(thread.downgrade())?;
|
||||
|
||||
|
@ -186,11 +183,15 @@ impl AgentConnection for ClaudeAgentConnection {
|
|||
})
|
||||
}
|
||||
|
||||
fn authenticate(&self, _cx: &mut App) -> Task<Result<()>> {
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
|
||||
Task::ready(Err(anyhow!("Authentication not supported")))
|
||||
}
|
||||
|
||||
fn prompt(&self, params: acp::PromptArguments, cx: &mut App) -> Task<Result<()>> {
|
||||
fn prompt(&self, params: acp::PromptRequest, cx: &mut App) -> Task<Result<()>> {
|
||||
let sessions = self.sessions.borrow();
|
||||
let Some(session) = sessions.get(¶ms.session_id) else {
|
||||
return Task::ready(Err(anyhow!(
|
||||
|
|
|
@ -1,319 +0,0 @@
|
|||
use agent_client_protocol as acp;
|
||||
use anyhow::anyhow;
|
||||
use collections::HashMap;
|
||||
use context_server::listener::McpServerTool;
|
||||
use context_server::types::requests;
|
||||
use context_server::{ContextServer, ContextServerCommand, ContextServerId};
|
||||
use futures::channel::{mpsc, oneshot};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use smol::stream::StreamExt as _;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::{path::Path, sync::Arc};
|
||||
use util::ResultExt;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
||||
|
||||
use crate::mcp_server::ZedMcpServer;
|
||||
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings, mcp_server};
|
||||
use acp_thread::{AcpThread, AgentConnection};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Codex;
|
||||
|
||||
impl AgentServer for Codex {
|
||||
fn name(&self) -> &'static str {
|
||||
"Codex"
|
||||
}
|
||||
|
||||
fn empty_state_headline(&self) -> &'static str {
|
||||
"Welcome to Codex"
|
||||
}
|
||||
|
||||
fn empty_state_message(&self) -> &'static str {
|
||||
"What can I help with?"
|
||||
}
|
||||
|
||||
fn logo(&self) -> ui::IconName {
|
||||
ui::IconName::AiOpenAi
|
||||
}
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
_root_dir: &Path,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Rc<dyn AgentConnection>>> {
|
||||
let project = project.clone();
|
||||
let working_directory = project.read(cx).active_project_directory(cx);
|
||||
cx.spawn(async move |cx| {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).codex.clone()
|
||||
})?;
|
||||
|
||||
let Some(command) =
|
||||
AgentServerCommand::resolve("codex", &["mcp"], settings, &project, cx).await
|
||||
else {
|
||||
anyhow::bail!("Failed to find codex binary");
|
||||
};
|
||||
|
||||
let client: Arc<ContextServer> = ContextServer::stdio(
|
||||
ContextServerId("codex-mcp-server".into()),
|
||||
ContextServerCommand {
|
||||
path: command.path,
|
||||
args: command.args,
|
||||
env: command.env,
|
||||
},
|
||||
working_directory,
|
||||
)
|
||||
.into();
|
||||
ContextServer::start(client.clone(), cx).await?;
|
||||
|
||||
let (notification_tx, mut notification_rx) = mpsc::unbounded();
|
||||
client
|
||||
.client()
|
||||
.context("Failed to subscribe")?
|
||||
.on_notification(acp::SESSION_UPDATE_METHOD_NAME, {
|
||||
move |notification, _cx| {
|
||||
let notification_tx = notification_tx.clone();
|
||||
log::trace!(
|
||||
"ACP Notification: {}",
|
||||
serde_json::to_string_pretty(¬ification).unwrap()
|
||||
);
|
||||
|
||||
if let Some(notification) =
|
||||
serde_json::from_value::<acp::SessionNotification>(notification)
|
||||
.log_err()
|
||||
{
|
||||
notification_tx.unbounded_send(notification).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let sessions = Rc::new(RefCell::new(HashMap::default()));
|
||||
|
||||
let notification_handler_task = cx.spawn({
|
||||
let sessions = sessions.clone();
|
||||
async move |cx| {
|
||||
while let Some(notification) = notification_rx.next().await {
|
||||
CodexConnection::handle_session_notification(
|
||||
notification,
|
||||
sessions.clone(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let connection = CodexConnection {
|
||||
client,
|
||||
sessions,
|
||||
_notification_handler_task: notification_handler_task,
|
||||
};
|
||||
Ok(Rc::new(connection) as _)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexConnection {
|
||||
client: Arc<context_server::ContextServer>,
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, CodexSession>>>,
|
||||
_notification_handler_task: Task<()>,
|
||||
}
|
||||
|
||||
struct CodexSession {
|
||||
thread: WeakEntity<AcpThread>,
|
||||
cancel_tx: Option<oneshot::Sender<()>>,
|
||||
_mcp_server: ZedMcpServer,
|
||||
}
|
||||
|
||||
impl AgentConnection for CodexConnection {
|
||||
fn name(&self) -> &'static str {
|
||||
"Codex"
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let client = self.client.client();
|
||||
let sessions = self.sessions.clone();
|
||||
let cwd = cwd.to_path_buf();
|
||||
cx.spawn(async move |cx| {
|
||||
let client = client.context("MCP server is not initialized yet")?;
|
||||
let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
|
||||
|
||||
let mcp_server = ZedMcpServer::new(thread_rx, cx).await?;
|
||||
|
||||
let response = client
|
||||
.request::<requests::CallTool>(context_server::types::CallToolParams {
|
||||
name: acp::NEW_SESSION_TOOL_NAME.into(),
|
||||
arguments: Some(serde_json::to_value(acp::NewSessionArguments {
|
||||
mcp_servers: [(
|
||||
mcp_server::SERVER_NAME.to_string(),
|
||||
mcp_server.server_config()?,
|
||||
)]
|
||||
.into(),
|
||||
client_tools: acp::ClientTools {
|
||||
request_permission: Some(acp::McpToolId {
|
||||
mcp_server: mcp_server::SERVER_NAME.into(),
|
||||
tool_name: mcp_server::RequestPermissionTool::NAME.into(),
|
||||
}),
|
||||
read_text_file: Some(acp::McpToolId {
|
||||
mcp_server: mcp_server::SERVER_NAME.into(),
|
||||
tool_name: mcp_server::ReadTextFileTool::NAME.into(),
|
||||
}),
|
||||
write_text_file: Some(acp::McpToolId {
|
||||
mcp_server: mcp_server::SERVER_NAME.into(),
|
||||
tool_name: mcp_server::WriteTextFileTool::NAME.into(),
|
||||
}),
|
||||
},
|
||||
cwd,
|
||||
})?),
|
||||
meta: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
if response.is_error.unwrap_or_default() {
|
||||
return Err(anyhow!(response.text_contents()));
|
||||
}
|
||||
|
||||
let result = serde_json::from_value::<acp::NewSessionOutput>(
|
||||
response.structured_content.context("Empty response")?,
|
||||
)?;
|
||||
|
||||
let thread =
|
||||
cx.new(|cx| AcpThread::new(self.clone(), project, result.session_id.clone(), cx))?;
|
||||
|
||||
thread_tx.send(thread.downgrade())?;
|
||||
|
||||
let session = CodexSession {
|
||||
thread: thread.downgrade(),
|
||||
cancel_tx: None,
|
||||
_mcp_server: mcp_server,
|
||||
};
|
||||
sessions.borrow_mut().insert(result.session_id, session);
|
||||
|
||||
Ok(thread)
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticate(&self, _cx: &mut App) -> Task<Result<()>> {
|
||||
Task::ready(Err(anyhow!("Authentication not supported")))
|
||||
}
|
||||
|
||||
fn prompt(
|
||||
&self,
|
||||
params: agent_client_protocol::PromptArguments,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<()>> {
|
||||
let client = self.client.client();
|
||||
let sessions = self.sessions.clone();
|
||||
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let client = client.context("MCP server is not initialized yet")?;
|
||||
|
||||
let (new_cancel_tx, cancel_rx) = oneshot::channel();
|
||||
{
|
||||
let mut sessions = sessions.borrow_mut();
|
||||
let session = sessions
|
||||
.get_mut(¶ms.session_id)
|
||||
.context("Session not found")?;
|
||||
session.cancel_tx.replace(new_cancel_tx);
|
||||
}
|
||||
|
||||
let result = client
|
||||
.request_with::<requests::CallTool>(
|
||||
context_server::types::CallToolParams {
|
||||
name: acp::PROMPT_TOOL_NAME.into(),
|
||||
arguments: Some(serde_json::to_value(params)?),
|
||||
meta: None,
|
||||
},
|
||||
Some(cancel_rx),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(err) = &result
|
||||
&& err.is::<context_server::client::RequestCanceled>()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let response = result?;
|
||||
|
||||
if response.is_error.unwrap_or_default() {
|
||||
return Err(anyhow!(response.text_contents()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&self, session_id: &agent_client_protocol::SessionId, _cx: &mut App) {
|
||||
let mut sessions = self.sessions.borrow_mut();
|
||||
|
||||
if let Some(cancel_tx) = sessions
|
||||
.get_mut(session_id)
|
||||
.and_then(|session| session.cancel_tx.take())
|
||||
{
|
||||
cancel_tx.send(()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexConnection {
|
||||
pub fn handle_session_notification(
|
||||
notification: acp::SessionNotification,
|
||||
threads: Rc<RefCell<HashMap<acp::SessionId, CodexSession>>>,
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
let threads = threads.borrow();
|
||||
let Some(thread) = threads
|
||||
.get(¬ification.session_id)
|
||||
.and_then(|session| session.thread.upgrade())
|
||||
else {
|
||||
log::error!(
|
||||
"Thread not found for session ID: {}",
|
||||
notification.session_id
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(notification.update, cx)
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CodexConnection {
|
||||
fn drop(&mut self) {
|
||||
self.client.stop().log_err();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::AgentServerCommand;
|
||||
use std::path::Path;
|
||||
|
||||
crate::common_e2e_tests!(Codex, allow_option_id = "approve");
|
||||
|
||||
pub fn local_command() -> AgentServerCommand {
|
||||
let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../codex/codex-rs/target/debug/codex");
|
||||
|
||||
AgentServerCommand {
|
||||
path: cli_path,
|
||||
args: vec![],
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -375,9 +375,6 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
|
|||
gemini: Some(AgentServerSettings {
|
||||
command: crate::gemini::tests::local_command(),
|
||||
}),
|
||||
codex: Some(AgentServerSettings {
|
||||
command: crate::codex::tests::local_command(),
|
||||
}),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
use anyhow::anyhow;
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use util::ResultExt as _;
|
||||
|
||||
use crate::{AgentServer, AgentServerCommand, AgentServerVersion};
|
||||
use acp_thread::{AgentConnection, LoadError, OldAcpAgentConnection, OldAcpClientDelegate};
|
||||
use agentic_coding_protocol as acp_old;
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
||||
use crate::{AgentServer, AgentServerCommand};
|
||||
use acp_thread::AgentConnection;
|
||||
use anyhow::Result;
|
||||
use gpui::{Entity, Task};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use ui::App;
|
||||
|
@ -43,146 +39,25 @@ impl AgentServer for Gemini {
|
|||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Rc<dyn AgentConnection>>> {
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let project = project.clone();
|
||||
let this = self.clone();
|
||||
let name = self.name();
|
||||
|
||||
let root_dir = root_dir.to_path_buf();
|
||||
let server_name = self.name();
|
||||
cx.spawn(async move |cx| {
|
||||
let command = this.command(&project, cx).await?;
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).gemini.clone()
|
||||
})?;
|
||||
|
||||
let mut child = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
let Some(command) =
|
||||
AgentServerCommand::resolve("gemini", &[ACP_ARG], settings, &project, cx).await
|
||||
else {
|
||||
anyhow::bail!("Failed to find gemini binary");
|
||||
};
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
|
||||
let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid()));
|
||||
|
||||
let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent(
|
||||
OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()),
|
||||
stdin,
|
||||
stdout,
|
||||
move |fut| foreground_executor.spawn(fut).detach(),
|
||||
);
|
||||
|
||||
let io_task = cx.background_spawn(async move {
|
||||
io_fut.await.log_err();
|
||||
});
|
||||
|
||||
let child_status = cx.background_spawn(async move {
|
||||
let result = match child.status().await {
|
||||
Err(e) => Err(anyhow!(e)),
|
||||
Ok(result) if result.success() => Ok(()),
|
||||
Ok(result) => {
|
||||
if let Some(AgentServerVersion::Unsupported {
|
||||
error_message,
|
||||
upgrade_message,
|
||||
upgrade_command,
|
||||
}) = this.version(&command).await.log_err()
|
||||
{
|
||||
Err(anyhow!(LoadError::Unsupported {
|
||||
error_message,
|
||||
upgrade_message,
|
||||
upgrade_command
|
||||
}))
|
||||
} else {
|
||||
Err(anyhow!(LoadError::Exited(result.code().unwrap_or(-127))))
|
||||
}
|
||||
}
|
||||
};
|
||||
drop(io_task);
|
||||
result
|
||||
});
|
||||
|
||||
let connection: Rc<dyn AgentConnection> = Rc::new(OldAcpAgentConnection {
|
||||
name,
|
||||
connection,
|
||||
child_status,
|
||||
current_thread: thread_rc,
|
||||
});
|
||||
|
||||
Ok(connection)
|
||||
crate::acp::connect(server_name, command, &root_dir, cx).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Gemini {
|
||||
async fn command(
|
||||
&self,
|
||||
project: &Entity<Project>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<AgentServerCommand> {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).gemini.clone()
|
||||
})?;
|
||||
|
||||
if let Some(command) =
|
||||
AgentServerCommand::resolve("gemini", &[ACP_ARG], settings, &project, cx).await
|
||||
{
|
||||
return Ok(command);
|
||||
};
|
||||
|
||||
let (fs, node_runtime) = project.update(cx, |project, _| {
|
||||
(project.fs().clone(), project.node_runtime().cloned())
|
||||
})?;
|
||||
let node_runtime = node_runtime.context("gemini not found on path")?;
|
||||
|
||||
let directory = ::paths::agent_servers_dir().join("gemini");
|
||||
fs.create_dir(&directory).await?;
|
||||
node_runtime
|
||||
.npm_install_packages(&directory, &[("@google/gemini-cli", "latest")])
|
||||
.await?;
|
||||
let path = directory.join("node_modules/.bin/gemini");
|
||||
|
||||
Ok(AgentServerCommand {
|
||||
path,
|
||||
args: vec![ACP_ARG.into()],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn version(&self, command: &AgentServerCommand) -> Result<AgentServerVersion> {
|
||||
let version_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--version")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let help_fut = util::command::new_smol_command(&command.path)
|
||||
.args(command.args.iter())
|
||||
.arg("--help")
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
|
||||
let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
|
||||
|
||||
let current_version = String::from_utf8(version_output?.stdout)?;
|
||||
let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
|
||||
|
||||
if supported {
|
||||
Ok(AgentServerVersion::Supported)
|
||||
} else {
|
||||
Ok(AgentServerVersion::Unsupported {
|
||||
error_message: format!(
|
||||
"Your installed version of Gemini {} doesn't support the Agentic Coding Protocol (ACP).",
|
||||
current_version
|
||||
).into(),
|
||||
upgrade_message: "Upgrade Gemini to Latest".into(),
|
||||
upgrade_command: "npm install -g @google/gemini-cli@latest".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
@ -199,7 +74,7 @@ pub(crate) mod tests {
|
|||
|
||||
AgentServerCommand {
|
||||
path: "node".into(),
|
||||
args: vec![cli_path, ACP_ARG.into()],
|
||||
args: vec![cli_path],
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,207 +0,0 @@
|
|||
use acp_thread::AcpThread;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use context_server::listener::{McpServerTool, ToolResponse};
|
||||
use context_server::types::{
|
||||
Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities,
|
||||
ToolsCapabilities, requests,
|
||||
};
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{App, AsyncApp, Task, WeakEntity};
|
||||
use indoc::indoc;
|
||||
|
||||
pub struct ZedMcpServer {
|
||||
server: context_server::listener::McpServer,
|
||||
}
|
||||
|
||||
pub const SERVER_NAME: &str = "zed";
|
||||
|
||||
impl ZedMcpServer {
|
||||
pub async fn new(
|
||||
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
|
||||
mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
|
||||
|
||||
mcp_server.add_tool(RequestPermissionTool {
|
||||
thread_rx: thread_rx.clone(),
|
||||
});
|
||||
mcp_server.add_tool(ReadTextFileTool {
|
||||
thread_rx: thread_rx.clone(),
|
||||
});
|
||||
mcp_server.add_tool(WriteTextFileTool {
|
||||
thread_rx: thread_rx.clone(),
|
||||
});
|
||||
|
||||
Ok(Self { server: mcp_server })
|
||||
}
|
||||
|
||||
pub fn server_config(&self) -> Result<acp::McpServerConfig> {
|
||||
#[cfg(not(test))]
|
||||
let zed_path = anyhow::Context::context(
|
||||
std::env::current_exe(),
|
||||
"finding current executable path for use in mcp_server",
|
||||
)?;
|
||||
|
||||
#[cfg(test)]
|
||||
let zed_path = crate::e2e_tests::get_zed_path();
|
||||
|
||||
Ok(acp::McpServerConfig {
|
||||
command: zed_path,
|
||||
args: vec![
|
||||
"--nc".into(),
|
||||
self.server.socket_path().display().to_string(),
|
||||
],
|
||||
env: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
|
||||
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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Tools
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RequestPermissionTool {
|
||||
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
|
||||
}
|
||||
|
||||
impl McpServerTool for RequestPermissionTool {
|
||||
type Input = acp::RequestPermissionArguments;
|
||||
type Output = acp::RequestPermissionOutput;
|
||||
|
||||
const NAME: &'static str = "Confirmation";
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
indoc! {"
|
||||
Request permission for tool calls.
|
||||
|
||||
This tool is meant to be called programmatically by the agent loop, not the LLM.
|
||||
"}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
input: Self::Input,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<ToolResponse<Self::Output>> {
|
||||
let mut thread_rx = self.thread_rx.clone();
|
||||
let Some(thread) = thread_rx.recv().await?.upgrade() else {
|
||||
anyhow::bail!("Thread closed");
|
||||
};
|
||||
|
||||
let result = thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_permission(input.tool_call, input.options, cx)
|
||||
})?
|
||||
.await;
|
||||
|
||||
let outcome = match result {
|
||||
Ok(option_id) => acp::RequestPermissionOutcome::Selected { option_id },
|
||||
Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Canceled,
|
||||
};
|
||||
|
||||
Ok(ToolResponse {
|
||||
content: vec![],
|
||||
structured_content: acp::RequestPermissionOutput { outcome },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ReadTextFileTool {
|
||||
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
|
||||
}
|
||||
|
||||
impl McpServerTool for ReadTextFileTool {
|
||||
type Input = acp::ReadTextFileArguments;
|
||||
type Output = acp::ReadTextFileOutput;
|
||||
|
||||
const NAME: &'static str = "Read";
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Reads the content of the given file in the project including unsaved changes."
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
input: Self::Input,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<ToolResponse<Self::Output>> {
|
||||
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.path, input.line, input.limit, false, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
Ok(ToolResponse {
|
||||
content: vec![],
|
||||
structured_content: acp::ReadTextFileOutput { content },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WriteTextFileTool {
|
||||
thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
|
||||
}
|
||||
|
||||
impl McpServerTool for WriteTextFileTool {
|
||||
type Input = acp::WriteTextFileArguments;
|
||||
type Output = ();
|
||||
|
||||
const NAME: &'static str = "Write";
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Write to a file replacing its contents"
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
input: Self::Input,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<ToolResponse<Self::Output>> {
|
||||
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.path, input.content, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
Ok(ToolResponse {
|
||||
content: vec![],
|
||||
structured_content: (),
|
||||
})
|
||||
}
|
||||
}
|
|
@ -13,7 +13,6 @@ pub fn init(cx: &mut App) {
|
|||
pub struct AllAgentServersSettings {
|
||||
pub gemini: Option<AgentServerSettings>,
|
||||
pub claude: Option<AgentServerSettings>,
|
||||
pub codex: Option<AgentServerSettings>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug)]
|
||||
|
@ -30,21 +29,13 @@ impl settings::Settings for AllAgentServersSettings {
|
|||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
|
||||
let mut settings = AllAgentServersSettings::default();
|
||||
|
||||
for AllAgentServersSettings {
|
||||
gemini,
|
||||
claude,
|
||||
codex,
|
||||
} in sources.defaults_and_customizations()
|
||||
{
|
||||
for AllAgentServersSettings { gemini, claude } in sources.defaults_and_customizations() {
|
||||
if gemini.is_some() {
|
||||
settings.gemini = gemini.clone();
|
||||
}
|
||||
if claude.is_some() {
|
||||
settings.claude = claude.clone();
|
||||
}
|
||||
if codex.is_some() {
|
||||
settings.codex = codex.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue