feat: Agent Client Protocol implementation of goose (#4511)

This commit is contained in:
Michael Neale
2025-09-05 11:49:26 +10:00
committed by GitHub
parent b5749d6457
commit c55ee2c26f
8 changed files with 707 additions and 2 deletions
Generated
+66
View File
@@ -28,6 +28,22 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "agent-client-protocol"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b91e5ec3ce05e8effb2a7a3b7b1a587daa6699b9f98bbde6a35e44b8c6c773a"
dependencies = [
"anyhow",
"async-broadcast",
"futures",
"log",
"parking_lot",
"schemars",
"serde",
"serde_json",
]
[[package]]
name = "ahash"
version = "0.8.11"
@@ -427,6 +443,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-compression"
version = "0.4.20"
@@ -1525,6 +1553,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "config"
version = "0.14.1"
@@ -2146,6 +2183,27 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "eventsource-client"
version = "0.12.2"
@@ -2644,6 +2702,7 @@ dependencies = [
name = "goose-cli"
version = "1.7.0"
dependencies = [
"agent-client-protocol",
"anstream",
"anyhow",
"async-trait",
@@ -4573,6 +4632,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.3"
@@ -6598,6 +6663,7 @@ checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"pin-project-lite",
"tokio",
+9
View File
@@ -0,0 +1,9 @@
# Goose Jokes 🦢
## Why did the Goose become a developer?
Because it wanted to help debug all those "fowl" errors in the code!
---
*Feel free to add more goose-related programming humor below!*
+6
View File
@@ -30,6 +30,12 @@ Designed for maximum flexibility, goose works with any LLM and supports multi-mo
- [Documentation](https://block.github.io/goose/docs/category/getting-started)
# A Little Goose Humor 🦢
> Why did the developer choose goose as their AI agent?
>
> Because it always helps them "migrate" their code to production! 🚀
# Goose Around with Us
- [Discord](https://discord.gg/block-opensource)
- [YouTube](https://www.youtube.com/@blockopensource)
+3 -2
View File
@@ -22,9 +22,11 @@ mcp-client = { path = "../mcp-client" }
mcp-server = { path = "../mcp-server" }
mcp-core = { path = "../mcp-core" }
rmcp = { workspace = true }
agent-client-protocol = "0.1.1"
clap = { version = "4.4", features = ["derive"] }
cliclack = "0.3.5"
console = "0.15.8"
uuid = { version = "1.11", features = ["v4"] }
dotenvy = "0.15.7"
bat = "0.24.0"
anyhow = "1.0"
@@ -47,7 +49,6 @@ shlex = "1.3.0"
async-trait = "0.1.86"
base64 = "0.22.1"
regex = "1.11.1"
uuid = { version = "1.11", features = ["v4"] }
nix = { version = "0.30.1", features = ["process", "signal"] }
tar = "0.4"
# Web server dependencies
@@ -56,7 +57,7 @@ tower-http = { version = "0.5", features = ["cors", "fs"] }
http = "1.0"
webbrowser = "1.0"
indicatif = "0.17.11"
tokio-util = "0.7.15"
tokio-util = { version = "0.7.15", features = ["compat"] }
is-terminal = "0.4.16"
anstream = "0.6.18"
+10
View File
@@ -3,6 +3,7 @@ use clap::{Args, Parser, Subcommand};
use goose::config::{Config, ExtensionConfig};
use crate::commands::acp::run_acp_agent;
use crate::commands::bench::agent_generator;
use crate::commands::configure::handle_configure;
use crate::commands::info::handle_info;
@@ -291,6 +292,10 @@ enum Command {
#[command(about = "Run one of the mcp servers bundled with goose")]
Mcp { name: String },
/// Run Goose as an ACP (Agent Client Protocol) agent
#[command(about = "Run Goose as an ACP agent server on stdio")]
Acp {},
/// Start or resume interactive chat sessions
#[command(
about = "Start or resume interactive chat sessions",
@@ -709,6 +714,7 @@ pub async fn cli() -> Result<()> {
Some(Command::Configure {}) => "configure",
Some(Command::Info { .. }) => "info",
Some(Command::Mcp { .. }) => "mcp",
Some(Command::Acp {}) => "acp",
Some(Command::Session { .. }) => "session",
Some(Command::Project {}) => "project",
Some(Command::Projects) => "projects",
@@ -739,6 +745,10 @@ pub async fn cli() -> Result<()> {
Some(Command::Mcp { name }) => {
let _ = run_server(&name).await;
}
Some(Command::Acp {}) => {
let _ = run_acp_agent().await;
return Ok(());
}
Some(Command::Session {
command,
identifier,
+495
View File
@@ -0,0 +1,495 @@
use agent_client_protocol::{self as acp, Client, SessionNotification};
use anyhow::Result;
use goose::agents::Agent;
use goose::config::{Config, ExtensionConfigManager};
use goose::conversation::message::{Message, MessageContent};
use goose::conversation::Conversation;
use goose::providers::create;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio::task::JoinSet;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
/// Represents a single Goose session for ACP
struct GooseSession {
agent: Agent,
messages: Conversation,
tool_call_ids: HashMap<String, String>, // Maps internal tool IDs to ACP tool call IDs
cancel_token: Option<CancellationToken>, // Active cancellation token for prompt processing
}
/// Goose ACP Agent implementation that connects to real Goose agents
struct GooseAcpAgent {
session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
sessions: Arc<Mutex<HashMap<String, GooseSession>>>,
provider: Arc<dyn goose::providers::base::Provider>,
}
impl GooseAcpAgent {
async fn new(
session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
) -> Result<Self> {
// Load config and create provider
let config = Config::global();
let provider_name: String = config
.get_param("GOOSE_PROVIDER")
.map_err(|e| anyhow::anyhow!("No provider configured: {}", e))?;
let model_name: String = config
.get_param("GOOSE_MODEL")
.map_err(|e| anyhow::anyhow!("No model configured: {}", e))?;
let model_config = goose::model::ModelConfig {
model_name: model_name.clone(),
context_limit: None,
temperature: None,
max_tokens: None,
toolshim: false,
toolshim_model: None,
fast_model: None,
};
let provider = create(&provider_name, model_config)?;
Ok(Self {
session_update_tx,
sessions: Arc::new(Mutex::new(HashMap::new())),
provider,
})
}
}
impl acp::Agent for GooseAcpAgent {
async fn initialize(
&self,
arguments: acp::InitializeRequest,
) -> Result<acp::InitializeResponse, acp::Error> {
info!("ACP: Received initialize request {:?}", arguments);
// Advertise Goose's capabilities
let agent_capabilities = acp::AgentCapabilities {
load_session: false, // TODO: Implement session persistence
prompt_capabilities: acp::PromptCapabilities {
image: true, // Goose supports image inputs via providers
audio: false, // TODO: Add audio support when providers support it
embedded_context: true, // Goose can handle embedded context resources
},
};
Ok(acp::InitializeResponse {
protocol_version: acp::V1,
agent_capabilities,
auth_methods: Vec::new(),
})
}
async fn authenticate(&self, arguments: acp::AuthenticateRequest) -> Result<(), acp::Error> {
info!("ACP: Received authenticate request {:?}", arguments);
Ok(())
}
async fn new_session(
&self,
arguments: acp::NewSessionRequest,
) -> Result<acp::NewSessionResponse, acp::Error> {
info!("ACP: Received new session request {:?}", arguments);
// Generate a unique session ID
let session_id = uuid::Uuid::new_v4().to_string();
// Create a new Agent and session for this ACP session
let agent = Agent::new();
agent
.update_provider(self.provider.clone())
.await
.map_err(|_| acp::Error::internal_error())?;
// Load and add extensions just like the normal CLI
// Get all enabled extensions from configuration
let extensions_to_run: Vec<_> = ExtensionConfigManager::get_all()
.map_err(|e| {
error!("Failed to load extensions: {}", e);
acp::Error::internal_error()
})?
.into_iter()
.filter(|ext| ext.enabled)
.map(|ext| ext.config)
.collect();
// Add extensions to the agent in parallel
let agent_ptr = Arc::new(agent);
let mut set = JoinSet::new();
let mut waiting_on = HashSet::new();
for extension in extensions_to_run {
waiting_on.insert(extension.name());
let agent_ptr_clone = agent_ptr.clone();
set.spawn(async move {
(
extension.name(),
agent_ptr_clone.add_extension(extension.clone()).await,
)
});
}
// Wait for all extensions to load
while let Some(result) = set.join_next().await {
match result {
Ok((name, Ok(_))) => {
waiting_on.remove(&name);
info!("Loaded extension: {}", name);
}
Ok((name, Err(e))) => {
warn!("Failed to load extension '{}': {}", name, e);
waiting_on.remove(&name);
}
Err(e) => {
error!("Task error while loading extension: {}", e);
}
}
}
// Unwrap the Arc to get the agent back
let agent = Arc::try_unwrap(agent_ptr).map_err(|_| {
error!("Failed to unwrap agent Arc");
acp::Error::internal_error()
})?;
let session = GooseSession {
agent,
messages: Conversation::new_unvalidated(Vec::new()),
tool_call_ids: HashMap::new(),
cancel_token: None,
};
// Store the session
let mut sessions = self.sessions.lock().await;
sessions.insert(session_id.clone(), session);
info!("Created new session with ID: {}", session_id);
Ok(acp::NewSessionResponse {
session_id: acp::SessionId(session_id.into()),
})
}
async fn load_session(&self, arguments: acp::LoadSessionRequest) -> Result<(), acp::Error> {
info!("ACP: Received load session request {:?}", arguments);
// For now, will start a new session. We could use goose session storage as an enhancement
// we would need to map ACP session IDs to goose session ids (which by default are auto generated)
// normal goose session restore in CLI doesn't load conversation visually.
//
// Example flow:
// - Load session file by session_id (might need to map ACP session IDs to Goose session paths)
// - For each message in history:
// - If user message: send user_message_chunk notification
// - If assistant message: send agent_message_chunk notification
// - If tool calls/responses: send appropriate notifications
// For now, we don't support loading previous sessions
Err(acp::Error::method_not_found())
}
#[allow(clippy::too_many_lines)]
async fn prompt(
&self,
arguments: acp::PromptRequest,
) -> Result<acp::PromptResponse, acp::Error> {
info!("ACP: Received prompt request {:?}", arguments);
// Get the session
let session_id = arguments.session_id.0.to_string();
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(&session_id)
.ok_or_else(acp::Error::invalid_params)?;
// Convert ACP prompt to Goose message
let mut user_message = Message::user();
// Process all content blocks from the prompt
for block in arguments.prompt {
match block {
acp::ContentBlock::Text(text) => {
user_message = user_message.with_text(&text.text);
}
acp::ContentBlock::Image(image) => {
// Goose supports images via base64 encoded data
// The ACP ImageContent has data as a String directly
user_message = user_message.with_image(&image.data, &image.mime_type);
}
acp::ContentBlock::Resource(resource) => {
// Embed resource content as text with context
match &resource.resource {
acp::EmbeddedResourceResource::TextResourceContents(text_resource) => {
let header = format!("--- Resource: {} ---\n", text_resource.uri);
let content = format!("{}{}\n---\n", header, text_resource.text);
user_message = user_message.with_text(&content);
}
_ => {
// Ignore non-text resources for now
}
}
}
_ => {
// Ignore unsupported content types for now
}
}
}
// Add message to conversation
session.messages.push(user_message);
// Create and store cancellation token for this prompt
let cancel_token = CancellationToken::new();
session.cancel_token = Some(cancel_token.clone());
// Get agent's reply through the Goose agent
let mut stream = session
.agent
.reply(session.messages.clone(), None, Some(cancel_token.clone()))
.await
.map_err(|e| {
error!("Error getting agent reply: {}", e);
acp::Error::internal_error()
})?;
use futures::StreamExt;
// Track if we were cancelled
let mut was_cancelled = false;
// Process the agent's response stream
while let Some(event) = stream.next().await {
// Check if we've been cancelled
if cancel_token.is_cancelled() {
was_cancelled = true;
break;
}
match event {
Ok(goose::agents::AgentEvent::Message(message)) => {
// Add to conversation
session.messages.push(message.clone());
// Process message content, including tool calls
for content_item in &message.content {
match content_item {
MessageContent::Text(text) => {
// Stream text to the client
let (tx, rx) = oneshot::channel();
self.session_update_tx
.send((
SessionNotification {
session_id: arguments.session_id.clone(),
update: acp::SessionUpdate::AgentMessageChunk {
content: text.text.clone().into(),
},
},
tx,
))
.map_err(|_| acp::Error::internal_error())?;
rx.await.map_err(|_| acp::Error::internal_error())?;
}
MessageContent::ToolRequest(tool_request) => {
// Generate ACP tool call ID and track mapping
let acp_tool_id = format!("tool_{}", uuid::Uuid::new_v4());
session
.tool_call_ids
.insert(tool_request.id.clone(), acp_tool_id.clone());
// Extract tool name and parameters from the ToolCall if successful
let (tool_name, locations) = match &tool_request.tool_call {
Ok(tool_call) => {
let name = tool_call.name.clone();
// Extract file locations from certain tools for client tracking
let mut locs = Vec::new();
if name == "developer__text_editor" {
// Try to extract the path from the arguments
let args = &tool_call.arguments;
if let Some(path_str) =
args.get("path").and_then(|p| p.as_str())
{
locs.push(acp::ToolCallLocation {
path: path_str.into(),
line: Some(1),
});
}
}
(name, locs)
}
Err(_) => ("unknown".to_string(), Vec::new()),
};
// Send tool call notification
let (tx, rx) = oneshot::channel();
self.session_update_tx
.send((
SessionNotification {
session_id: arguments.session_id.clone(),
update: acp::SessionUpdate::ToolCall(acp::ToolCall {
id: acp::ToolCallId(acp_tool_id.clone().into()),
title: format!("Calling tool: {}", tool_name),
kind: acp::ToolKind::default(),
status: acp::ToolCallStatus::Pending,
content: Vec::new(),
locations,
raw_input: None,
raw_output: None,
}),
},
tx,
))
.map_err(|_| acp::Error::internal_error())?;
rx.await.map_err(|_| acp::Error::internal_error())?;
// No need for separate update - status is already set to Pending
}
MessageContent::ToolResponse(tool_response) => {
// Look up the ACP tool call ID
if let Some(acp_tool_id) =
session.tool_call_ids.get(&tool_response.id)
{
// Determine if the tool call succeeded or failed
let status = if tool_response.tool_result.is_ok() {
acp::ToolCallStatus::Completed
} else {
acp::ToolCallStatus::Failed
};
// Send status update (completed or failed)
let (tx, rx) = oneshot::channel();
self.session_update_tx
.send((
SessionNotification {
session_id: arguments.session_id.clone(),
update: acp::SessionUpdate::ToolCallUpdate(
acp::ToolCallUpdate {
id: acp::ToolCallId(
acp_tool_id.clone().into(),
),
fields: acp::ToolCallUpdateFields {
status: Some(status),
..Default::default()
},
},
),
},
tx,
))
.map_err(|_| acp::Error::internal_error())?;
rx.await.map_err(|_| acp::Error::internal_error())?;
}
}
MessageContent::Thinking(thinking) => {
// Stream thinking/reasoning content as thought chunks
let (tx, rx) = oneshot::channel();
self.session_update_tx
.send((
SessionNotification {
session_id: arguments.session_id.clone(),
update: acp::SessionUpdate::AgentThoughtChunk {
content: thinking.thinking.clone().into(),
},
},
tx,
))
.map_err(|_| acp::Error::internal_error())?;
rx.await.map_err(|_| acp::Error::internal_error())?;
}
_ => {
// Ignore other content types for now
}
}
}
}
Ok(_) => {
// Ignore other events for now
}
Err(e) => {
error!("Error in agent response stream: {}", e);
return Err(acp::Error::internal_error());
}
}
}
// Clear the cancel token since we're done
session.cancel_token = None;
Ok(acp::PromptResponse {
stop_reason: if was_cancelled {
acp::StopReason::Cancelled
} else {
acp::StopReason::EndTurn
},
})
}
async fn cancel(&self, args: acp::CancelNotification) -> Result<(), acp::Error> {
info!("ACP: Received cancel request {:?}", args);
// Get the session and cancel its active operation
let session_id = args.session_id.0.to_string();
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(&session_id) {
if let Some(ref token) = session.cancel_token {
info!("Cancelling active prompt for session {}", session_id);
token.cancel();
}
} else {
warn!("Cancel request for non-existent session: {}", session_id);
}
Ok(())
}
}
/// Run the ACP agent server
pub async fn run_acp_agent() -> Result<()> {
info!("Starting Goose ACP agent server on stdio");
eprintln!("Goose ACP agent started. Listening on stdio...");
let outgoing = tokio::io::stdout().compat_write();
let incoming = tokio::io::stdin().compat();
// The AgentSideConnection will spawn futures onto our Tokio runtime.
// LocalSet and spawn_local are used because the futures from the
// agent-client-protocol crate are not Send.
let local_set = tokio::task::LocalSet::new();
local_set
.run_until(async move {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
// Start up the GooseAcpAgent connected to stdio.
let agent = GooseAcpAgent::new(tx)
.await
.map_err(|e| anyhow::anyhow!("Failed to create ACP agent: {}", e))?;
let (conn, handle_io) =
acp::AgentSideConnection::new(agent, outgoing, incoming, |fut| {
tokio::task::spawn_local(fut);
});
// Kick off a background task to send the agent's session notifications to the client.
tokio::task::spawn_local(async move {
while let Some((session_notification, tx)) = rx.recv().await {
let result = conn.session_notification(session_notification).await;
if let Err(e) = result {
error!("ACP session notification error: {}", e);
break;
}
tx.send(()).ok();
}
});
// Run until stdin/stdout are closed.
handle_io.await
})
.await?;
Ok(())
}
+1
View File
@@ -1,3 +1,4 @@
pub mod acp;
pub mod bench;
pub mod configure;
pub mod info;
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Simple ACP client to test the Goose ACP agent.
Connects to goose acp running on stdio.
"""
import subprocess
import json
import sys
import uuid
class AcpClient:
def __init__(self):
# Start the goose acp process
self.process = subprocess.Popen(
['cargo', 'run', '-p', 'goose-cli', '--', 'acp'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=0
)
self.request_id = 0
def send_request(self, method, params=None):
self.request_id += 1
request = {
"jsonrpc": "2.0",
"method": method,
"id": self.request_id,
}
if params:
request["params"] = params
# Send the request
request_str = json.dumps(request)
print(f">>> Sending: {request_str}")
self.process.stdin.write(request_str + '\n')
self.process.stdin.flush()
# Read response
response_line = self.process.stdout.readline()
if not response_line:
return None
print(f"<<< Response: {response_line}")
return json.loads(response_line)
def initialize(self):
return self.send_request("initialize", {
"protocolVersion": "v1",
"clientCapabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
})
def new_session(self):
return self.send_request("newSession", {
"context": {}
})
def prompt(self, session_id, text):
return self.send_request("prompt", {
"sessionId": session_id,
"prompt": [
{
"type": "text",
"text": text
}
]
})
def close(self):
if self.process:
self.process.terminate()
self.process.wait()
def main():
print("Starting ACP client test...")
client = AcpClient()
try:
# Initialize the agent
print("\n1. Initializing agent...")
init_response = client.initialize()
if init_response and 'result' in init_response:
print(f" Initialized successfully: {init_response['result']}")
else:
print(f" Failed to initialize: {init_response}")
return
# Create a new session
print("\n2. Creating new session...")
session_response = client.new_session()
if session_response and 'result' in session_response:
session_id = session_response['result']['sessionId']
print(f" Created session: {session_id}")
else:
print(f" Failed to create session: {session_response}")
return
# Send a prompt
print("\n3. Sending prompt...")
prompt_response = client.prompt(session_id, "Hello! What is 2 + 2?")
if prompt_response:
print(f" Got response: {prompt_response}")
else:
print(" Failed to get prompt response")
finally:
client.close()
print("\nTest complete.")
if __name__ == "__main__":
main()