rust acp client for extension methods (#8227)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "goose-sdk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Rust SDK for talking to Goose over the Agent Client Protocol (ACP)"
|
||||
|
||||
[dependencies]
|
||||
sacp = { workspace = true, features = ["unstable"] }
|
||||
agent-client-protocol-schema = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
schemars = { workspace = true, features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["compat", "rt"] }
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
# Used to provide extras imports for sacp
|
||||
ignored = ["agent-client-protocol-schema"]
|
||||
@@ -0,0 +1,156 @@
|
||||
//! ACP Client Example
|
||||
//!
|
||||
//! Spawns `goose acp` as a child process and sends it a completion request
|
||||
//! using the Agent Client Protocol over stdio.
|
||||
//!
|
||||
//! # Prerequisites
|
||||
//!
|
||||
//! You must have goose built and a provider configured (`goose configure`).
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run -p goose-sdk --example acp_client -- "What is 2 + 2?"
|
||||
//! ```
|
||||
//!
|
||||
//! Or with a custom goose binary path:
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run -p goose-sdk --example acp_client -- --goose-bin ./target/debug/goose "Explain Rust's ownership model in one sentence"
|
||||
//! ```
|
||||
|
||||
use goose_sdk::custom_requests::GetExtensionsRequest;
|
||||
use sacp::schema::{
|
||||
ContentBlock, InitializeRequest, ProtocolVersion, RequestPermissionOutcome,
|
||||
RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
|
||||
SessionNotification, SessionUpdate,
|
||||
};
|
||||
use sacp::{Client, ConnectionTo};
|
||||
use std::path::PathBuf;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Parse args: [--goose-bin PATH] PROMPT
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let (goose_bin, prompt) = parse_args(&args)?;
|
||||
|
||||
eprintln!("🚀 Spawning: {} acp", goose_bin.display());
|
||||
|
||||
let mut child = tokio::process::Command::new(&goose_bin)
|
||||
.arg("acp")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn '{}': {e}", goose_bin.display()))?;
|
||||
|
||||
let child_stdin = child.stdin.take().expect("stdin should be piped");
|
||||
let child_stdout = child.stdout.take().expect("stdout should be piped");
|
||||
|
||||
let transport = sacp::ByteStreams::new(child_stdin.compat_write(), child_stdout.compat());
|
||||
|
||||
let prompt_clone = prompt.clone();
|
||||
|
||||
Client
|
||||
.builder()
|
||||
.name("acp-client-example")
|
||||
// Print session notifications (agent text, tool calls, etc.)
|
||||
.on_receive_notification(
|
||||
async move |notification: SessionNotification, _cx| {
|
||||
match ¬ification.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => {
|
||||
if let ContentBlock::Text(text) = &chunk.content {
|
||||
print!("{}", text.text);
|
||||
}
|
||||
}
|
||||
SessionUpdate::ToolCall(tool_call) => {
|
||||
eprintln!("🔧 Tool call: {}", tool_call.title);
|
||||
}
|
||||
SessionUpdate::ToolCallUpdate(update) => {
|
||||
if let Some(status) = &update.fields.status {
|
||||
eprintln!(" Tool status: {:?}", status);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
sacp::on_receive_notification!(),
|
||||
)
|
||||
// Auto-approve all permission requests
|
||||
.on_receive_request(
|
||||
async move |request: RequestPermissionRequest, responder, _cx| {
|
||||
eprintln!("✅ Auto-approving permission request");
|
||||
let option_id = request.options.first().map(|opt| opt.option_id.clone());
|
||||
match option_id {
|
||||
Some(id) => responder.respond(RequestPermissionResponse::new(
|
||||
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id)),
|
||||
)),
|
||||
None => responder.respond(RequestPermissionResponse::new(
|
||||
RequestPermissionOutcome::Cancelled,
|
||||
)),
|
||||
}
|
||||
},
|
||||
sacp::on_receive_request!(),
|
||||
)
|
||||
.connect_with(transport, async move |cx: ConnectionTo<sacp::Agent>| {
|
||||
// Step 1: Initialize
|
||||
eprintln!("🤝 Initializing...");
|
||||
let init_response = cx
|
||||
.send_request(InitializeRequest::new(ProtocolVersion::LATEST))
|
||||
.block_task()
|
||||
.await?;
|
||||
eprintln!("✓ Agent initialized: {:?}", init_response.agent_info);
|
||||
|
||||
let response = cx
|
||||
.send_request(GetExtensionsRequest {})
|
||||
.block_task()
|
||||
.await?;
|
||||
eprintln!("Extensions: {:?}", response.extensions);
|
||||
|
||||
// Step 2: Create a session and send the prompt
|
||||
eprintln!("💬 Sending prompt: \"{}\"", prompt_clone);
|
||||
cx.build_session_cwd()?
|
||||
.block_task()
|
||||
.run_until(async |mut session| {
|
||||
session.send_prompt(&prompt_clone)?;
|
||||
let response = session.read_to_string().await?;
|
||||
|
||||
// read_to_string collects text; we already printed chunks above,
|
||||
// so just print a newline to finish.
|
||||
println!();
|
||||
eprintln!("✅ Done ({} chars)", response.len());
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
|
||||
let _ = child.kill().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(args: &[String]) -> Result<(PathBuf, String), String> {
|
||||
let mut goose_bin = PathBuf::from("goose");
|
||||
let mut i = 0;
|
||||
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--goose-bin" => {
|
||||
i += 1;
|
||||
goose_bin = PathBuf::from(args.get(i).ok_or("--goose-bin requires a value")?);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let prompt = args[i..].join(" ");
|
||||
|
||||
if prompt.is_empty() {
|
||||
return Err("Usage: acp_client [--goose-bin PATH] PROMPT".into());
|
||||
}
|
||||
|
||||
Ok((goose_bin, prompt))
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use sacp::{JsonRpcRequest, JsonRpcResponse};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Schema descriptor for a single custom method, produced by the
|
||||
/// `#[custom_methods]` macro's generated `custom_method_schemas()` function.
|
||||
///
|
||||
/// `params_schema` / `response_schema` hold `$ref` pointers or inline schemas
|
||||
/// produced by `SchemaGenerator::subschema_for`. All referenced types are
|
||||
/// collected in the generator's `$defs` map.
|
||||
///
|
||||
/// `params_type_name` / `response_type_name` carry the Rust struct name so the
|
||||
/// binary can key `$defs` entries and annotate them with `x-method` / `x-side`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CustomMethodSchema {
|
||||
pub method: String,
|
||||
pub params_schema: Option<schemars::Schema>,
|
||||
pub params_type_name: Option<String>,
|
||||
pub response_schema: Option<schemars::Schema>,
|
||||
pub response_type_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Add an extension to an active session.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/extensions/add", response = EmptyResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddExtensionRequest {
|
||||
pub session_id: String,
|
||||
/// Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform).
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Remove an extension from an active session.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/extensions/remove", response = EmptyResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoveExtensionRequest {
|
||||
pub session_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// List all tools available in a session.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/tools", response = GetToolsResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetToolsRequest {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Tools response.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
pub struct GetToolsResponse {
|
||||
/// Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`.
|
||||
pub tools: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Read a resource from an extension.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/resource/read", response = ReadResourceResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadResourceRequest {
|
||||
pub session_id: String,
|
||||
pub uri: String,
|
||||
pub extension_name: String,
|
||||
}
|
||||
|
||||
/// Resource read response.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
pub struct ReadResourceResponse {
|
||||
/// The resource result from the extension (MCP ReadResourceResult).
|
||||
#[serde(default)]
|
||||
pub result: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Update the working directory for a session.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/working_dir/update", response = EmptyResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateWorkingDirRequest {
|
||||
pub session_id: String,
|
||||
pub working_dir: String,
|
||||
}
|
||||
|
||||
/// Get a session by ID.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "session/get", response = GetSessionResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetSessionRequest {
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub include_messages: bool,
|
||||
}
|
||||
|
||||
/// Get a session response.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
pub struct GetSessionResponse {
|
||||
/// The session object with id, name, working_dir, timestamps, tokens, etc.
|
||||
#[serde(default)]
|
||||
pub session: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Delete a session.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "session/delete", response = EmptyResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteSessionRequest {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Export a session as a JSON string.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/session/export", response = ExportSessionResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportSessionRequest {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Export session response.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
pub struct ExportSessionResponse {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Import a session from a JSON string.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/session/import", response = ImportSessionResponse)]
|
||||
pub struct ImportSessionRequest {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Import session response.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
pub struct ImportSessionResponse {
|
||||
/// The imported session object.
|
||||
#[serde(default)]
|
||||
pub session: serde_json::Value,
|
||||
}
|
||||
|
||||
/// List configured extensions and any warnings.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/config/extensions", response = GetExtensionsResponse)]
|
||||
pub struct GetExtensionsRequest {}
|
||||
|
||||
/// List configured extensions and any warnings.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
pub struct GetExtensionsResponse {
|
||||
/// Array of ExtensionEntry objects with `enabled` flag and config details.
|
||||
pub extensions: Vec<serde_json::Value>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Empty success response for operations that return no data.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
pub struct EmptyResponse {}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod custom_requests;
|
||||
Reference in New Issue
Block a user