merge goose-acp crate into goose (#8726)
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
//! Shared adapter classes for converting mpsc channels to AsyncRead/AsyncWrite streams
|
||||
//! Used by both HTTP and WebSocket transports
|
||||
|
||||
use std::{
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Converts an mpsc::Receiver<String> to AsyncRead
|
||||
/// Each message is terminated with a newline for JSON-RPC framing
|
||||
pub(crate) struct ReceiverToAsyncRead {
|
||||
rx: mpsc::Receiver<String>,
|
||||
buffer: Vec<u8>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl ReceiverToAsyncRead {
|
||||
pub(crate) fn new(rx: mpsc::Receiver<String>) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
buffer: Vec::new(),
|
||||
pos: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for ReceiverToAsyncRead {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
if self.pos < self.buffer.len() {
|
||||
let remaining = &self.buffer[self.pos..];
|
||||
let to_copy = remaining.len().min(buf.remaining());
|
||||
buf.put_slice(&remaining[..to_copy]);
|
||||
self.pos += to_copy;
|
||||
if self.pos >= self.buffer.len() {
|
||||
self.buffer.clear();
|
||||
self.pos = 0;
|
||||
}
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
match Pin::new(&mut self.rx).poll_recv(cx) {
|
||||
Poll::Ready(Some(msg)) => {
|
||||
let bytes = format!("{}\n", msg).into_bytes();
|
||||
let to_copy = bytes.len().min(buf.remaining());
|
||||
buf.put_slice(&bytes[..to_copy]);
|
||||
if to_copy < bytes.len() {
|
||||
self.buffer = bytes[to_copy..].to_vec();
|
||||
self.pos = 0;
|
||||
}
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(None) => Poll::Ready(Ok(())),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an unbounded mpsc::Sender<String> to AsyncWrite.
|
||||
/// Splits incoming data on newlines for JSON-RPC framing.
|
||||
///
|
||||
/// Uses an unbounded sender so that bursts of outgoing messages (e.g. replaying
|
||||
/// a long session history) are never silently dropped due to backpressure.
|
||||
pub(crate) struct SenderToAsyncWrite {
|
||||
tx: mpsc::UnboundedSender<String>,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SenderToAsyncWrite {
|
||||
pub(crate) fn new(tx: mpsc::UnboundedSender<String>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for SenderToAsyncWrite {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
self.buffer.extend_from_slice(buf);
|
||||
|
||||
while let Some(pos) = self.buffer.iter().position(|&b| b == b'\n') {
|
||||
let line = String::from_utf8_lossy(&self.buffer[..pos]).to_string();
|
||||
self.buffer.drain(..=pos);
|
||||
|
||||
if !line.is_empty() && self.tx.send(line).is_err() {
|
||||
return Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"Channel closed",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
use crate::acp::tools::AcpAwareToolMeta;
|
||||
use crate::agents::mcp_client::{Error as McpError, McpClientTrait};
|
||||
use crate::agents::platform_extensions::developer::edit::{
|
||||
resolve_path, string_replace, FileEditParams, FileReadParams, FileWriteParams,
|
||||
};
|
||||
use crate::agents::platform_extensions::developer::shell::{ShellParams, OUTPUT_LIMIT_BYTES};
|
||||
use crate::agents::platform_extensions::developer::DeveloperClient;
|
||||
use agent_client_protocol_schema::TerminalId;
|
||||
use async_trait::async_trait;
|
||||
use fs_err as fs;
|
||||
use rmcp::model::{CallToolResult, Content as RmcpContent, Tool, ToolAnnotations};
|
||||
use sacp::schema::{
|
||||
CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest,
|
||||
SessionId, SessionNotification, SessionUpdate, Terminal, TerminalOutputRequest,
|
||||
ToolCallContent, ToolCallId, ToolCallLocation, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
|
||||
WaitForTerminalExitRequest, WriteTextFileRequest,
|
||||
};
|
||||
use sacp::{Client, ConnectionTo};
|
||||
use schemars::schema_for;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
async fn acp_read_text_file(
|
||||
cx: &ConnectionTo<Client>,
|
||||
session_id: &SessionId,
|
||||
path: &Path,
|
||||
line: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
let mut request = ReadTextFileRequest::new(session_id.clone(), path.to_path_buf());
|
||||
if let Some(l) = line {
|
||||
request = request.line(l);
|
||||
}
|
||||
if let Some(l) = limit {
|
||||
request = request.limit(l);
|
||||
}
|
||||
let response = cx
|
||||
.send_request(request)
|
||||
.block_task()
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(response.content)
|
||||
}
|
||||
|
||||
async fn acp_write_text_file(
|
||||
cx: &ConnectionTo<Client>,
|
||||
session_id: &SessionId,
|
||||
path: &Path,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let request =
|
||||
WriteTextFileRequest::new(session_id.clone(), path.to_path_buf(), content.to_string());
|
||||
cx.send_request(request)
|
||||
.block_task()
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct AcpTools {
|
||||
pub(crate) inner: Arc<dyn McpClientTrait>,
|
||||
pub(crate) cx: ConnectionTo<Client>,
|
||||
pub(crate) session_id: SessionId,
|
||||
pub(crate) fs_read: bool,
|
||||
pub(crate) fs_write: bool,
|
||||
pub(crate) terminal: bool,
|
||||
}
|
||||
|
||||
fn error_result(msg: impl std::fmt::Display) -> CallToolResult {
|
||||
CallToolResult::error(vec![RmcpContent::text(msg.to_string()).with_priority(0.0)])
|
||||
}
|
||||
|
||||
fn fail(action: &str, path: &str, err: impl std::fmt::Display) -> CallToolResult {
|
||||
error_result(format!("Failed to {action} {path}: {err}"))
|
||||
}
|
||||
|
||||
fn read_tool() -> Tool {
|
||||
let schema = serde_json::to_value(schema_for!(FileReadParams))
|
||||
.expect("schema serialization should succeed")
|
||||
.as_object()
|
||||
.expect("schema should serialize to an object")
|
||||
.clone();
|
||||
Tool::new("read", "Read a text file from disk.", schema).annotate(
|
||||
ToolAnnotations::with_title("Read")
|
||||
.read_only(true)
|
||||
.destructive(false)
|
||||
.idempotent(false)
|
||||
.open_world(false),
|
||||
)
|
||||
}
|
||||
|
||||
impl AcpTools {
|
||||
fn update_tool_call(&self, ctx: &crate::agents::ToolCallContext, fields: ToolCallUpdateFields) {
|
||||
if let Some(ref req_id) = ctx.tool_call_request_id {
|
||||
let _ = self
|
||||
.cx
|
||||
.send_notification(SessionNotification::new(
|
||||
self.session_id.clone(),
|
||||
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
|
||||
ToolCallId::new(req_id.clone()),
|
||||
fields,
|
||||
)),
|
||||
))
|
||||
.inspect_err(|e| tracing::error!("error updating tool call with client: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args<T: serde::de::DeserializeOwned>(
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
) -> Result<T, String> {
|
||||
DeveloperClient::parse_args(arguments).map_err(|e| format!("Error: {e}"))
|
||||
}
|
||||
|
||||
async fn read_content(&self, path: &Path) -> Result<String, String> {
|
||||
if self.fs_read {
|
||||
acp_read_text_file(&self.cx, &self.session_id, path, None, None).await
|
||||
} else {
|
||||
fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn acp_read(
|
||||
&self,
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
ctx: &crate::agents::ToolCallContext,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let params: FileReadParams = match Self::parse_args(arguments) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(error_result(e)),
|
||||
};
|
||||
let path = resolve_path(¶ms.path, ctx.working_dir.as_deref());
|
||||
self.update_tool_call(
|
||||
ctx,
|
||||
ToolCallUpdateFields::new()
|
||||
.kind(ToolKind::Read)
|
||||
.locations(vec![ToolCallLocation::new(&path)]),
|
||||
);
|
||||
match acp_read_text_file(&self.cx, &self.session_id, &path, params.line, params.limit).await
|
||||
{
|
||||
Ok(content) => Ok(CallToolResult::success(vec![
|
||||
RmcpContent::text(content).with_priority(0.0)
|
||||
])),
|
||||
Err(e) => Ok(fail("read", ¶ms.path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn acp_write(
|
||||
&self,
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
ctx: &crate::agents::ToolCallContext,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let params: FileWriteParams = match Self::parse_args(arguments) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(error_result(e)),
|
||||
};
|
||||
let path = resolve_path(¶ms.path, ctx.working_dir.as_deref());
|
||||
self.update_tool_call(
|
||||
ctx,
|
||||
ToolCallUpdateFields::new()
|
||||
.kind(ToolKind::Edit)
|
||||
.locations(vec![ToolCallLocation::new(&path)]),
|
||||
);
|
||||
match acp_write_text_file(&self.cx, &self.session_id, &path, ¶ms.content).await {
|
||||
Ok(()) => {
|
||||
self.update_tool_call(
|
||||
ctx,
|
||||
ToolCallUpdateFields::new().content(vec![ToolCallContent::Diff(Diff::new(
|
||||
&path,
|
||||
¶ms.content,
|
||||
))]),
|
||||
);
|
||||
let line_count = params.content.lines().count();
|
||||
let action = if path.exists() { "Wrote" } else { "Created" };
|
||||
Ok(CallToolResult::success(vec![RmcpContent::text(format!(
|
||||
"{action} {} ({line_count} lines)",
|
||||
params.path
|
||||
))
|
||||
.with_priority(0.0)]))
|
||||
}
|
||||
Err(e) => Ok(fail("write", ¶ms.path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn acp_edit(
|
||||
&self,
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
ctx: &crate::agents::ToolCallContext,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let params: FileEditParams = match Self::parse_args(arguments) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(error_result(e)),
|
||||
};
|
||||
let path = resolve_path(¶ms.path, ctx.working_dir.as_deref());
|
||||
self.update_tool_call(
|
||||
ctx,
|
||||
ToolCallUpdateFields::new()
|
||||
.kind(ToolKind::Edit)
|
||||
.locations(vec![ToolCallLocation::new(&path)]),
|
||||
);
|
||||
|
||||
let content = match self.read_content(&path).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(fail("read", ¶ms.path, e)),
|
||||
};
|
||||
|
||||
let new_content = match string_replace(&content, ¶ms.before, ¶ms.after) {
|
||||
Ok(c) => c,
|
||||
Err(msg) => return Ok(error_result(msg)),
|
||||
};
|
||||
|
||||
let write_result = if self.fs_write {
|
||||
acp_write_text_file(&self.cx, &self.session_id, &path, &new_content).await
|
||||
} else {
|
||||
fs::write(&path, &new_content).map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
match write_result {
|
||||
Ok(()) => {
|
||||
self.update_tool_call(
|
||||
ctx,
|
||||
ToolCallUpdateFields::new().content(vec![ToolCallContent::Diff(
|
||||
Diff::new(&path, &new_content).old_text(&content),
|
||||
)]),
|
||||
);
|
||||
let old_lines = params.before.lines().count();
|
||||
let new_lines = params.after.lines().count();
|
||||
Ok(CallToolResult::success(vec![RmcpContent::text(format!(
|
||||
"Edited {} ({old_lines} lines -> {new_lines} lines)",
|
||||
params.path
|
||||
))
|
||||
.with_priority(0.0)]))
|
||||
}
|
||||
Err(e) => Ok(fail("write", ¶ms.path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn acp_shell(
|
||||
&self,
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
ctx: &crate::agents::ToolCallContext,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let params: ShellParams = match Self::parse_args(arguments) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(error_result(e)),
|
||||
};
|
||||
self.update_tool_call(ctx, ToolCallUpdateFields::new().kind(ToolKind::Execute));
|
||||
|
||||
let create_res = self
|
||||
.cx
|
||||
.send_request(
|
||||
CreateTerminalRequest::new(self.session_id.clone(), ¶ms.command)
|
||||
.cwd(ctx.working_dir.clone())
|
||||
.output_byte_limit(OUTPUT_LIMIT_BYTES as u64),
|
||||
)
|
||||
.block_task()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
McpError::McpError(rmcp::model::ErrorData::new(
|
||||
rmcp::model::ErrorCode::INTERNAL_ERROR,
|
||||
format!("failed to create terminal: {e:?}"),
|
||||
None,
|
||||
))
|
||||
})?;
|
||||
let terminal_id = create_res.terminal_id;
|
||||
|
||||
self.update_tool_call(
|
||||
ctx,
|
||||
ToolCallUpdateFields::new().content(vec![ToolCallContent::Terminal(Terminal::new(
|
||||
terminal_id.clone(),
|
||||
))]),
|
||||
);
|
||||
|
||||
let result = self
|
||||
.run_terminal_to_completion(&terminal_id, params.timeout_secs)
|
||||
.await;
|
||||
|
||||
// Always release the terminal, even if we hit errors above.
|
||||
let _ = self
|
||||
.cx
|
||||
.send_request(ReleaseTerminalRequest::new(
|
||||
self.session_id.clone(),
|
||||
terminal_id.clone(),
|
||||
))
|
||||
.block_task()
|
||||
.await
|
||||
.inspect_err(|e| tracing::error!("failed to release terminal: {e:?}"));
|
||||
|
||||
let output_res = result?;
|
||||
|
||||
let exit_code = output_res
|
||||
.exit_status
|
||||
.and_then(|s| s.exit_code)
|
||||
.unwrap_or_default();
|
||||
|
||||
let content = vec![
|
||||
RmcpContent::text(format!("exit code: {exit_code}")).with_priority(0.0),
|
||||
RmcpContent::text(output_res.output).with_priority(0.0),
|
||||
];
|
||||
|
||||
if exit_code != 0 {
|
||||
Ok(CallToolResult::error(content))
|
||||
} else {
|
||||
Ok(CallToolResult::success(content))
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_terminal_to_completion(
|
||||
&self,
|
||||
terminal_id: &TerminalId,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<sacp::schema::TerminalOutputResponse, McpError> {
|
||||
let wait_fut = self
|
||||
.cx
|
||||
.send_request(WaitForTerminalExitRequest::new(
|
||||
self.session_id.clone(),
|
||||
terminal_id.clone(),
|
||||
))
|
||||
.block_task();
|
||||
|
||||
let timed_out = match timeout_secs {
|
||||
Some(secs) if secs > 0 => match timeout(Duration::from_secs(secs), wait_fut).await {
|
||||
Ok(res) => {
|
||||
res.map_err(|e| {
|
||||
McpError::McpError(rmcp::model::ErrorData::new(
|
||||
rmcp::model::ErrorCode::INTERNAL_ERROR,
|
||||
format!("failed to wait for terminal exit: {e:?}"),
|
||||
None,
|
||||
))
|
||||
})?;
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = self
|
||||
.cx
|
||||
.send_request(KillTerminalRequest::new(
|
||||
self.session_id.clone(),
|
||||
terminal_id.clone(),
|
||||
))
|
||||
.block_task()
|
||||
.await
|
||||
.inspect_err(|e| tracing::error!("failed to kill terminal: {e:?}"));
|
||||
true
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
wait_fut.await.map_err(|e| {
|
||||
McpError::McpError(rmcp::model::ErrorData::new(
|
||||
rmcp::model::ErrorCode::INTERNAL_ERROR,
|
||||
format!("failed to wait for terminal exit: {e:?}"),
|
||||
None,
|
||||
))
|
||||
})?;
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let mut output_res = self
|
||||
.cx
|
||||
.send_request(TerminalOutputRequest::new(
|
||||
self.session_id.clone(),
|
||||
terminal_id.clone(),
|
||||
))
|
||||
.block_task()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
McpError::McpError(rmcp::model::ErrorData::new(
|
||||
rmcp::model::ErrorCode::INTERNAL_ERROR,
|
||||
format!("failed to get terminal output: {e:?}"),
|
||||
None,
|
||||
))
|
||||
})?;
|
||||
|
||||
if timed_out {
|
||||
output_res.output.push_str(&format!(
|
||||
"\n\nCommand timed out after {} seconds",
|
||||
timeout_secs.unwrap_or(0)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(output_res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClientTrait for AcpTools {
|
||||
async fn list_tools(
|
||||
&self,
|
||||
session_id: &str,
|
||||
next_cursor: Option<String>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<rmcp::model::ListToolsResult, McpError> {
|
||||
let mut result = self
|
||||
.inner
|
||||
.list_tools(session_id, next_cursor, cancellation_token)
|
||||
.await?;
|
||||
if self.fs_read {
|
||||
result.tools.insert(0, read_tool());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
ctx: &crate::agents::ToolCallContext,
|
||||
name: &str,
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
match name {
|
||||
"read" if self.fs_read => self
|
||||
.acp_read(arguments, ctx)
|
||||
.await
|
||||
.map(|r| r.with_acp_aware_meta()),
|
||||
"write" if self.fs_write => self
|
||||
.acp_write(arguments, ctx)
|
||||
.await
|
||||
.map(|r| r.with_acp_aware_meta()),
|
||||
"edit" if self.fs_read && self.fs_write => self
|
||||
.acp_edit(arguments, ctx)
|
||||
.await
|
||||
.map(|r| r.with_acp_aware_meta()),
|
||||
"shell" if self.terminal => self
|
||||
.acp_shell(arguments, ctx)
|
||||
.await
|
||||
.map(|r| r.with_acp_aware_meta()),
|
||||
_ => {
|
||||
self.inner
|
||||
.call_tool(ctx, name, arguments, cancellation_token)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_info(&self) -> Option<&rmcp::model::InitializeResult> {
|
||||
self.inner.get_info()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
mod adapters;
|
||||
mod common;
|
||||
pub(crate) mod fs;
|
||||
mod provider;
|
||||
pub mod server;
|
||||
pub mod server_factory;
|
||||
pub(crate) mod tools;
|
||||
pub mod transport;
|
||||
|
||||
pub use common::{map_permission_response, PermissionDecision};
|
||||
pub use goose_sdk::custom_requests;
|
||||
pub use provider::{
|
||||
extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, ACP_CURRENT_MODEL,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
use crate::acp::server::{AcpProviderFactory, GooseAcpAgent};
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
pub struct AcpServerFactoryConfig {
|
||||
pub builtins: Vec<String>,
|
||||
pub data_dir: std::path::PathBuf,
|
||||
pub config_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
pub struct AcpServer {
|
||||
config: AcpServerFactoryConfig,
|
||||
}
|
||||
|
||||
impl AcpServer {
|
||||
pub fn new(config: AcpServerFactoryConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub async fn create_agent(&self) -> Result<Arc<GooseAcpAgent>> {
|
||||
let config_path = self
|
||||
.config
|
||||
.config_dir
|
||||
.join(crate::config::base::CONFIG_YAML_NAME);
|
||||
let config = crate::config::Config::new(&config_path, "goose")?;
|
||||
|
||||
let goose_mode = config
|
||||
.get_goose_mode()
|
||||
.unwrap_or(crate::config::GooseMode::Auto);
|
||||
let disable_session_naming = config.get_goose_disable_session_naming().unwrap_or(false);
|
||||
|
||||
let provider_factory: AcpProviderFactory =
|
||||
Arc::new(move |provider_name, model_config, extensions| {
|
||||
Box::pin(async move {
|
||||
crate::providers::create(&provider_name, model_config, extensions).await
|
||||
})
|
||||
});
|
||||
|
||||
let agent = GooseAcpAgent::new(
|
||||
provider_factory,
|
||||
self.config.builtins.clone(),
|
||||
self.config.data_dir.clone(),
|
||||
self.config.config_dir.clone(),
|
||||
goose_mode,
|
||||
disable_session_naming,
|
||||
)
|
||||
.await?;
|
||||
info!("Created new ACP agent");
|
||||
|
||||
Ok(Arc::new(agent))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use rmcp::{
|
||||
model::{CallToolResult, Meta},
|
||||
object,
|
||||
};
|
||||
|
||||
const ACP_AWARE_META_KEY: &str = "_goose/acp-aware";
|
||||
|
||||
pub trait AcpAwareToolMeta {
|
||||
fn with_acp_aware_meta(self) -> Self;
|
||||
fn is_acp_aware(&self) -> bool;
|
||||
}
|
||||
|
||||
impl AcpAwareToolMeta for CallToolResult {
|
||||
fn with_acp_aware_meta(self) -> Self {
|
||||
self.with_meta(Some(Meta(object!({ACP_AWARE_META_KEY: true}))))
|
||||
}
|
||||
|
||||
fn is_acp_aware(&self) -> bool {
|
||||
self.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(ACP_AWARE_META_KEY))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, StatusCode},
|
||||
response::{IntoResponse, Response, Sse},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, convert::Infallible, sync::Arc, time::Duration};
|
||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::*;
|
||||
use crate::acp::adapters::{ReceiverToAsyncRead, SenderToAsyncWrite};
|
||||
use crate::acp::server_factory::AcpServer;
|
||||
|
||||
pub(crate) struct HttpState {
|
||||
server: Arc<AcpServer>,
|
||||
// Keyed by acp_session_id: a connection-scoped UUID serving many Goose sessions.
|
||||
sessions: RwLock<HashMap<String, TransportSession>>,
|
||||
}
|
||||
|
||||
impl HttpState {
|
||||
pub fn new(server: Arc<AcpServer>) -> Self {
|
||||
Self {
|
||||
server,
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_session(&self) -> Result<String, StatusCode> {
|
||||
let (to_agent_tx, to_agent_rx) = mpsc::channel::<String>(256);
|
||||
let (from_agent_tx, from_agent_rx) = mpsc::unbounded_channel::<String>();
|
||||
|
||||
let agent = self.server.create_agent().await.map_err(|e| {
|
||||
error!("Failed to create agent: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let acp_session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let read_stream = ReceiverToAsyncRead::new(to_agent_rx);
|
||||
let write_stream = SenderToAsyncWrite::new(from_agent_tx);
|
||||
let fut =
|
||||
crate::acp::server::serve(agent, read_stream.compat(), write_stream.compat_write());
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = fut.await {
|
||||
error!("ACP session error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
self.sessions.write().await.insert(
|
||||
acp_session_id.clone(),
|
||||
TransportSession {
|
||||
to_agent_tx,
|
||||
from_agent_rx: Arc::new(Mutex::new(from_agent_rx)),
|
||||
handle,
|
||||
},
|
||||
);
|
||||
|
||||
info!(acp_session_id = %acp_session_id, "Session created");
|
||||
Ok(acp_session_id)
|
||||
}
|
||||
|
||||
async fn has_session(&self, acp_session_id: &str) -> bool {
|
||||
self.sessions.read().await.contains_key(acp_session_id)
|
||||
}
|
||||
|
||||
async fn remove_session(&self, acp_session_id: &str) {
|
||||
if let Some(session) = self.sessions.write().await.remove(acp_session_id) {
|
||||
session.handle.abort();
|
||||
info!(acp_session_id = %acp_session_id, "Session removed");
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_message(&self, acp_session_id: &str, message: String) -> Result<(), StatusCode> {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session = sessions.get(acp_session_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
session
|
||||
.to_agent_tx
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
async fn get_receiver(
|
||||
&self,
|
||||
acp_session_id: &str,
|
||||
) -> Result<Arc<Mutex<mpsc::UnboundedReceiver<String>>>, StatusCode> {
|
||||
let sessions = self.sessions.read().await;
|
||||
let session = sessions.get(acp_session_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(session.from_agent_rx.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn create_sse_stream(
|
||||
receiver: Arc<Mutex<mpsc::UnboundedReceiver<String>>>,
|
||||
cleanup: Option<(Arc<HttpState>, String)>,
|
||||
) -> Sse<impl futures::Stream<Item = Result<axum::response::sse::Event, Infallible>>> {
|
||||
let stream = async_stream::stream! {
|
||||
let mut rx = receiver.lock().await;
|
||||
while let Some(msg) = rx.recv().await {
|
||||
yield Ok::<_, Infallible>(axum::response::sse::Event::default().data(msg));
|
||||
}
|
||||
if let Some((state, acp_session_id)) = cleanup {
|
||||
state.remove_session(&acp_session_id).await;
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(
|
||||
axum::response::sse::KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text(""),
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_initialize(state: Arc<HttpState>, json_message: &Value) -> Response {
|
||||
let acp_session_id = match state.create_session().await {
|
||||
Ok(id) => id,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let message_str = serde_json::to_string(json_message).unwrap();
|
||||
if let Err(status) = state.send_message(&acp_session_id, message_str).await {
|
||||
state.remove_session(&acp_session_id).await;
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
let receiver = match state.get_receiver(&acp_session_id).await {
|
||||
Ok(r) => r,
|
||||
Err(status) => {
|
||||
state.remove_session(&acp_session_id).await;
|
||||
return status.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let sse = create_sse_stream(receiver, Some((state.clone(), acp_session_id.clone())));
|
||||
let mut response = sse.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HEADER_SESSION_ID, acp_session_id.parse().unwrap());
|
||||
response
|
||||
}
|
||||
|
||||
async fn handle_request(
|
||||
state: Arc<HttpState>,
|
||||
acp_session_id: String,
|
||||
json_message: &Value,
|
||||
) -> Response {
|
||||
if !state.has_session(&acp_session_id).await {
|
||||
return (StatusCode::NOT_FOUND, "Session not found").into_response();
|
||||
}
|
||||
|
||||
let message_str = serde_json::to_string(json_message).unwrap();
|
||||
if let Err(status) = state.send_message(&acp_session_id, message_str).await {
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
let receiver = match state.get_receiver(&acp_session_id).await {
|
||||
Ok(r) => r,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
create_sse_stream(receiver, None).into_response()
|
||||
}
|
||||
|
||||
async fn handle_notification_or_response(
|
||||
state: Arc<HttpState>,
|
||||
acp_session_id: String,
|
||||
json_message: &Value,
|
||||
) -> Response {
|
||||
if !state.has_session(&acp_session_id).await {
|
||||
return (StatusCode::NOT_FOUND, "Session not found").into_response();
|
||||
}
|
||||
|
||||
let message_str = serde_json::to_string(json_message).unwrap();
|
||||
if let Err(status) = state.send_message(&acp_session_id, message_str).await {
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_post(
|
||||
State(state): State<Arc<HttpState>>,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
if !accepts_json_and_sse(&request) {
|
||||
return (
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"Not Acceptable: Client must accept both application/json and text/event-stream",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if !content_type_is_json(&request) {
|
||||
return (
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"Unsupported Media Type: Content-Type must be application/json",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let acp_session_id = get_session_id(&request);
|
||||
|
||||
let body_bytes = match request.into_body().collect().await {
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(e) => {
|
||||
error!("Failed to read request body: {}", e);
|
||||
return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let json_message: Value = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Failed to parse JSON: {}", e);
|
||||
return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if json_message.is_array() {
|
||||
return (
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Batch requests are not supported",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if is_initialize_request(&json_message) {
|
||||
handle_initialize(state.clone(), &json_message).await
|
||||
} else if is_jsonrpc_request(&json_message) {
|
||||
let Some(id) = acp_session_id else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Bad Request: Acp-Session-Id header required",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
handle_request(state.clone(), id, &json_message).await
|
||||
} else if is_jsonrpc_notification(&json_message) || is_jsonrpc_response(&json_message) {
|
||||
let Some(id) = acp_session_id else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Bad Request: Acp-Session-Id header required",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
handle_notification_or_response(state.clone(), id, &json_message).await
|
||||
} else {
|
||||
(StatusCode::BAD_REQUEST, "Invalid JSON-RPC message").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_get(state: Arc<HttpState>, request: Request<Body>) -> Response {
|
||||
if !accepts_mime_type(&request, EVENT_STREAM_MIME_TYPE) {
|
||||
return (
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"Not Acceptable: Client must accept text/event-stream",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let acp_session_id = match get_session_id(&request) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Bad Request: Acp-Session-Id header required",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !state.has_session(&acp_session_id).await {
|
||||
return (StatusCode::NOT_FOUND, "Session not found").into_response();
|
||||
}
|
||||
|
||||
let receiver = match state.get_receiver(&acp_session_id).await {
|
||||
Ok(r) => r,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
let mut rx = receiver.lock().await;
|
||||
while let Some(msg) = rx.recv().await {
|
||||
yield Ok::<_, Infallible>(axum::response::sse::Event::default().data(msg));
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream)
|
||||
.keep_alive(
|
||||
axum::response::sse::KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text(""),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_delete(
|
||||
State(state): State<Arc<HttpState>>,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
let acp_session_id = match get_session_id(&request) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Bad Request: Acp-Session-Id header required",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !state.has_session(&acp_session_id).await {
|
||||
return (StatusCode::NOT_FOUND, "Session not found").into_response();
|
||||
}
|
||||
|
||||
state.remove_session(&acp_session_id).await;
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
pub mod http;
|
||||
pub mod websocket;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{
|
||||
ws::{rejection::WebSocketUpgradeRejection, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
http::{header, Method, Request},
|
||||
response::Response,
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::acp::server_factory::AcpServer;
|
||||
|
||||
pub(crate) const HEADER_SESSION_ID: &str = "Acp-Session-Id";
|
||||
pub(crate) const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream";
|
||||
pub(crate) const JSON_MIME_TYPE: &str = "application/json";
|
||||
|
||||
pub(crate) struct TransportSession {
|
||||
pub to_agent_tx: mpsc::Sender<String>,
|
||||
pub from_agent_rx: Arc<Mutex<mpsc::UnboundedReceiver<String>>>,
|
||||
pub handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub(crate) fn accepts_mime_type(request: &Request<Body>, mime_type: &str) -> bool {
|
||||
request
|
||||
.headers()
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|accept| accept.contains(mime_type))
|
||||
}
|
||||
|
||||
pub(crate) fn accepts_json_and_sse(request: &Request<Body>) -> bool {
|
||||
request
|
||||
.headers()
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|accept| {
|
||||
accept.contains(JSON_MIME_TYPE) && accept.contains(EVENT_STREAM_MIME_TYPE)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn content_type_is_json(request: &Request<Body>) -> bool {
|
||||
request
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|ct| ct.starts_with(JSON_MIME_TYPE))
|
||||
}
|
||||
|
||||
pub(crate) fn get_session_id(request: &Request<Body>) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.get(HEADER_SESSION_ID)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn is_jsonrpc_request(value: &Value) -> bool {
|
||||
value.get("method").is_some() && value.get("id").is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn is_jsonrpc_notification(value: &Value) -> bool {
|
||||
value.get("method").is_some() && value.get("id").is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn is_jsonrpc_response(value: &Value) -> bool {
|
||||
value.get("id").is_some() && (value.get("result").is_some() || value.get("error").is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn is_initialize_request(value: &Value) -> bool {
|
||||
value.get("method").is_some_and(|m| m == "initialize") && value.get("id").is_some()
|
||||
}
|
||||
|
||||
async fn handle_get(
|
||||
ws_upgrade: Result<WebSocketUpgrade, WebSocketUpgradeRejection>,
|
||||
State(state): State<(Arc<http::HttpState>, Arc<websocket::WsState>)>,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
match ws_upgrade {
|
||||
Ok(ws) => websocket::handle_get(state.1, ws).await,
|
||||
Err(_) => http::handle_get(state.0, request).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
pub fn create_router(server: Arc<AcpServer>) -> Router {
|
||||
let http_state = Arc::new(http::HttpState::new(server.clone()));
|
||||
let ws_state = Arc::new(websocket::WsState::new(server));
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
|
||||
.allow_headers([
|
||||
header::CONTENT_TYPE,
|
||||
header::ACCEPT,
|
||||
HEADER_SESSION_ID.parse().unwrap(),
|
||||
header::SEC_WEBSOCKET_VERSION,
|
||||
header::SEC_WEBSOCKET_KEY,
|
||||
header::CONNECTION,
|
||||
header::UPGRADE,
|
||||
]);
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/status", get(health))
|
||||
.route(
|
||||
"/acp",
|
||||
post(http::handle_post).with_state(http_state.clone()),
|
||||
)
|
||||
.route(
|
||||
"/acp",
|
||||
get(handle_get).with_state((http_state.clone(), ws_state)),
|
||||
)
|
||||
.route("/acp", delete(http::handle_delete).with_state(http_state))
|
||||
.layer(cors)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::{TransportSession, HEADER_SESSION_ID};
|
||||
use crate::acp::adapters::{ReceiverToAsyncRead, SenderToAsyncWrite};
|
||||
use crate::acp::server_factory::AcpServer;
|
||||
|
||||
pub(crate) struct WsState {
|
||||
server: Arc<AcpServer>,
|
||||
// Keyed by acp_session_id: a connection-scoped UUID serving many Goose sessions.
|
||||
sessions: RwLock<HashMap<String, TransportSession>>,
|
||||
}
|
||||
|
||||
impl WsState {
|
||||
pub fn new(server: Arc<AcpServer>) -> Self {
|
||||
Self {
|
||||
server,
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_connection(&self) -> Result<String> {
|
||||
let (to_agent_tx, to_agent_rx) = mpsc::channel::<String>(256);
|
||||
let (from_agent_tx, from_agent_rx) = mpsc::unbounded_channel::<String>();
|
||||
|
||||
let agent = self.server.create_agent().await?;
|
||||
|
||||
let acp_session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let read_stream = ReceiverToAsyncRead::new(to_agent_rx);
|
||||
let write_stream = SenderToAsyncWrite::new(from_agent_tx);
|
||||
let fut =
|
||||
crate::acp::server::serve(agent, read_stream.compat(), write_stream.compat_write());
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = fut.await {
|
||||
error!("ACP WebSocket session error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
self.sessions.write().await.insert(
|
||||
acp_session_id.clone(),
|
||||
TransportSession {
|
||||
to_agent_tx,
|
||||
from_agent_rx: Arc::new(Mutex::new(from_agent_rx)),
|
||||
handle,
|
||||
},
|
||||
);
|
||||
|
||||
info!(acp_session_id = %acp_session_id, "WebSocket connection created");
|
||||
Ok(acp_session_id)
|
||||
}
|
||||
|
||||
async fn remove_connection(&self, acp_session_id: &str) {
|
||||
if let Some(session) = self.sessions.write().await.remove(acp_session_id) {
|
||||
session.handle.abort();
|
||||
info!(acp_session_id = %acp_session_id, "WebSocket connection removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_get(state: Arc<WsState>, ws: WebSocketUpgrade) -> Response {
|
||||
let acp_session_id = match state.create_connection().await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebSocket connection: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to create WebSocket connection",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut response = ws.on_upgrade({
|
||||
let acp_session_id = acp_session_id.clone();
|
||||
move |socket| handle_ws(socket, state, acp_session_id)
|
||||
});
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(HEADER_SESSION_ID, acp_session_id.parse().unwrap());
|
||||
response
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_ws(socket: WebSocket, state: Arc<WsState>, acp_session_id: String) {
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
let (to_agent, from_agent) = {
|
||||
let sessions = state.sessions.read().await;
|
||||
match sessions.get(&acp_session_id) {
|
||||
Some(session) => (session.to_agent_tx.clone(), session.from_agent_rx.clone()),
|
||||
None => {
|
||||
error!(acp_session_id = %acp_session_id, "Session not found after creation");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!(acp_session_id = %acp_session_id, "Starting bidirectional message loop");
|
||||
|
||||
let mut from_agent_rx = from_agent.lock().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(msg_result) = ws_rx.next() => {
|
||||
match msg_result {
|
||||
Ok(Message::Text(text)) => {
|
||||
let text_str = text.to_string();
|
||||
debug!(acp_session_id = %acp_session_id, "Client → Agent: {} bytes", text_str.len());
|
||||
if let Err(e) = to_agent.send(text_str).await {
|
||||
error!(acp_session_id = %acp_session_id, "Failed to send to agent: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(frame)) => {
|
||||
debug!(acp_session_id = %acp_session_id, "Client closed connection: {:?}", frame);
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {
|
||||
// Axum handles ping/pong automatically
|
||||
continue;
|
||||
}
|
||||
Ok(Message::Binary(_)) => {
|
||||
warn!(acp_session_id = %acp_session_id, "Ignoring binary message (ACP uses text)");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(acp_session_id = %acp_session_id, "WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(text) = from_agent_rx.recv() => {
|
||||
debug!(acp_session_id = %acp_session_id, "Agent → Client: {} bytes", text.len());
|
||||
if let Err(e) = ws_tx.send(Message::Text(text.into())).await {
|
||||
error!(acp_session_id = %acp_session_id, "Failed to send to client: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
else => {
|
||||
debug!(acp_session_id = %acp_session_id, "Both channels closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(acp_session_id = %acp_session_id, "Cleaning up connection");
|
||||
state.remove_connection(&acp_session_id).await;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use goose::acp::server::GooseAcpAgent;
|
||||
use schemars::SchemaGenerator;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let mut generator = SchemaGenerator::default();
|
||||
let methods = GooseAcpAgent::custom_method_schemas(&mut generator);
|
||||
|
||||
// Collect $defs from the generator (all types referenced via subschema_for).
|
||||
let mut defs: Map<String, Value> = generator
|
||||
.take_definitions(true)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(json!({}))))
|
||||
.collect();
|
||||
|
||||
// Track which types map to which methods so we can detect shared types.
|
||||
let mut type_methods: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for m in &methods {
|
||||
let method = m.method.clone();
|
||||
if let Some(name) = &m.params_type_name {
|
||||
type_methods
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(method.clone());
|
||||
}
|
||||
if let Some(name) = &m.response_type_name {
|
||||
type_methods
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(method.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Replace `true` with `{}` throughout $defs. Both mean "accept any value" in
|
||||
// JSON Schema, but many TS codegen tools (e.g. @hey-api/openapi-ts Zod plugin)
|
||||
// silently drop properties whose schema is the bare `true` literal.
|
||||
//
|
||||
// Also strip "format": "uint64" / "int64" from integer types — these cause TS
|
||||
// codegen to emit BigInt validators, but JS/TS uses `number` for all integers.
|
||||
for def in defs.values_mut() {
|
||||
replace_true_schemas(def);
|
||||
strip_integer_formats(def);
|
||||
}
|
||||
|
||||
// Annotate $defs entries with x-method/x-side. Only set x-method for types
|
||||
// used by exactly one method (shared types like EmptyResponse skip x-method).
|
||||
for (name, methods_list) in &type_methods {
|
||||
if let Some(def) = defs.get_mut(name) {
|
||||
if let Some(obj) = def.as_object_mut() {
|
||||
obj.insert("x-side".into(), json!("agent"));
|
||||
if methods_list.len() == 1 {
|
||||
obj.insert("x-method".into(), json!(methods_list[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build ExtRequest.params and ExtResponse.result anyOf arrays,
|
||||
// deduplicating response variants (e.g. EmptyResponse appears once).
|
||||
let mut request_variants: Vec<Value> = Vec::new();
|
||||
let mut response_variants: Vec<Value> = Vec::new();
|
||||
let mut seen_response_types: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
for m in &methods {
|
||||
if let Some(name) = &m.params_type_name {
|
||||
request_variants.push(json!({
|
||||
"allOf": [{ "$ref": format!("#/$defs/{name}") }],
|
||||
"description": format!("Params for {}", m.method),
|
||||
"title": name,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(name) = &m.response_type_name {
|
||||
if seen_response_types.insert(name.clone()) {
|
||||
response_variants.push(json!({
|
||||
"allOf": [{ "$ref": format!("#/$defs/{name}") }],
|
||||
"title": name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build ExtRequest — mirrors AgentRequest structure.
|
||||
defs.insert(
|
||||
"ExtRequest".into(),
|
||||
json!({
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"method": { "type": "string" },
|
||||
"params": {
|
||||
"anyOf": [
|
||||
{ "anyOf": request_variants },
|
||||
{ "description": "Untyped params", "type": ["object", "null"] },
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["id", "method"],
|
||||
"type": "object",
|
||||
"x-docs-ignore": true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Build ExtResponse — mirrors AgentResponse structure.
|
||||
defs.insert(
|
||||
"ExtResponse".into(),
|
||||
json!({
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"result": {
|
||||
"anyOf": [
|
||||
{ "anyOf": response_variants },
|
||||
{ "description": "Untyped result" },
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"title": "Success",
|
||||
"type": "object",
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "integer" },
|
||||
"message": { "type": "string" },
|
||||
"data": {}
|
||||
},
|
||||
"required": ["code", "message"],
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
},
|
||||
"required": ["id", "error"],
|
||||
"title": "Error",
|
||||
"type": "object",
|
||||
}
|
||||
],
|
||||
"x-docs-ignore": true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Assemble the root schema document.
|
||||
let root = json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "GooseExtensions",
|
||||
"$defs": defs,
|
||||
"anyOf": [
|
||||
{
|
||||
"allOf": [{ "$ref": "#/$defs/ExtRequest" }],
|
||||
"description": "Extension request (client → agent)",
|
||||
"title": "Request",
|
||||
},
|
||||
{
|
||||
"allOf": [{ "$ref": "#/$defs/ExtResponse" }],
|
||||
"description": "Extension response (agent → client)",
|
||||
"title": "Response",
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
let json_str = serde_json::to_string_pretty(&root).expect("failed to serialize schema");
|
||||
|
||||
let package_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let package_path = PathBuf::from(&package_dir);
|
||||
|
||||
let schema_path = package_path.join("acp-schema.json");
|
||||
fs::write(&schema_path, format!("{json_str}\n")).expect("failed to write schema file");
|
||||
eprintln!("Generated ACP schema at {}", schema_path.display());
|
||||
|
||||
// Build meta.json with method→type mappings (consumed by TS codegen).
|
||||
let method_entries: Vec<Value> = methods
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"method": &m.method,
|
||||
"requestType": m.params_type_name,
|
||||
"responseType": m.response_type_name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let meta = json!({ "methods": method_entries });
|
||||
let meta_str = serde_json::to_string_pretty(&meta).expect("failed to serialize meta");
|
||||
let meta_path = package_path.join("acp-meta.json");
|
||||
fs::write(&meta_path, format!("{meta_str}\n")).expect("failed to write meta file");
|
||||
eprintln!("Generated ACP meta at {}", meta_path.display());
|
||||
|
||||
println!("{json_str}");
|
||||
}
|
||||
|
||||
/// Recursively strip `"format"` from integer-typed schemas.
|
||||
///
|
||||
/// schemars emits `"format": "uint64"` / `"int64"` etc. for Rust integer types.
|
||||
/// TS codegen tools interpret these as BigInt, but JS/TS uses `number` everywhere.
|
||||
fn strip_integer_formats(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let is_integer = map.get("type").and_then(|v| v.as_str()) == Some("integer");
|
||||
if is_integer {
|
||||
map.remove("format");
|
||||
}
|
||||
for v in map.values_mut() {
|
||||
strip_integer_formats(v);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr.iter_mut() {
|
||||
strip_integer_formats(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively replace `true` with `{}` in a JSON value.
|
||||
///
|
||||
/// In JSON Schema, `true` and `{}` both mean "accept any value", but many
|
||||
/// TypeScript codegen tools only handle the object form.
|
||||
fn replace_true_schemas(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for v in map.values_mut() {
|
||||
if *v == Value::Bool(true) {
|
||||
*v = json!({});
|
||||
} else {
|
||||
replace_true_schemas(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr.iter_mut() {
|
||||
if *v == Value::Bool(true) {
|
||||
*v = json!({});
|
||||
} else {
|
||||
replace_true_schemas(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ compile_error!("At least one of `rustls-tls` or `native-tls` features must be en
|
||||
compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive");
|
||||
|
||||
pub mod acp;
|
||||
pub use goose_sdk::custom_requests;
|
||||
pub mod action_required_manager;
|
||||
pub mod agents;
|
||||
pub mod builtin_extension;
|
||||
|
||||
Reference in New Issue
Block a user