From 69f591322b60531f8e2641da7b2b8459e646022e Mon Sep 17 00:00:00 2001 From: natelastname <44933154+natelastname@users.noreply.github.com> Date: Tue, 12 May 2026 16:32:05 -0400 Subject: [PATCH] Added recipe discovery / execution to ACP server. (#8925) Signed-off-by: Douwe Osinga Co-authored-by: Douwe Osinga --- crates/goose/src/acp/server.rs | 173 ++++++++++++++++++-- crates/goose/src/agents/execute_commands.rs | 69 ++++++-- 2 files changed, 211 insertions(+), 31 deletions(-) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 5db5f7cd..c3b89f8f 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -27,22 +27,23 @@ use crate::source_roots::SourceRoot; use crate::utils::sanitize_unicode_tags; use agent_client_protocol::schema::{ AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest, - AuthenticateResponse, BlobResourceContents, CancelNotification, CloseSessionRequest, - CloseSessionResponse, ConfigOptionUpdate, Content, ContentBlock, ContentChunk, - CurrentModeUpdate, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities, - ForkSessionRequest, ForkSessionResponse, ImageContent, InitializeRequest, InitializeResponse, - ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, - McpCapabilities, McpServer, Meta, ModelId, ModelInfo, NewSessionRequest, NewSessionResponse, - PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, - RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, - SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory, - SessionConfigSelectOption, SessionId, SessionInfo, SessionInfoUpdate, SessionListCapabilities, - SessionMode, SessionModeId, SessionModeState, SessionModelState, SessionNotification, - SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, - SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, - StopReason, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, - ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, Usage, - UsageUpdate, + AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, + BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse, + ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource, + EmbeddedResourceResource, FileSystemCapabilities, ForkSessionRequest, ForkSessionResponse, + ImageContent, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, + LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, Meta, ModelId, ModelInfo, + NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, + PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, + RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionCloseCapabilities, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, + SessionInfo, SessionInfoUpdate, SessionListCapabilities, SessionMode, SessionModeId, + SessionModeState, SessionModelState, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason, + TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, + Usage, UsageUpdate, }; use agent_client_protocol::util::MatchDispatchFrom; use agent_client_protocol::{ @@ -2151,6 +2152,104 @@ impl GooseAcpAgent { Ok(()) } + + fn input_hint_for_recipe( + params: Option<&Vec>, + ) -> Option { + let params = params?; + + params + .iter() + .find(|p| p.key == "args") + .or_else(|| params.iter().find(|p| p.default.is_none())) + .or_else(|| params.first()) + .map(|p| p.description.clone()) + } + + async fn build_available_commands_from_slash_commands() -> Vec { + let mut commands = Vec::new(); + + for mapping in crate::slash_commands::list_commands() { + if Self::is_builtin_agent_command(&mapping.command) { + continue; + } + + let recipe_path = std::path::PathBuf::from(&mapping.recipe_path); + + if !recipe_path.exists() { + continue; + } + + let Ok(recipe_content) = tokio::fs::read_to_string(&recipe_path).await else { + continue; + }; + + let Some(recipe_dir) = recipe_path.parent() else { + continue; + }; + + let recipe_dir_str = recipe_dir.display().to_string(); + + let Ok(validation_result) = + crate::recipe::validate_recipe::validate_recipe_template_from_content( + &recipe_content, + Some(recipe_dir_str), + ) + else { + continue; + }; + + let required_param_count = validation_result + .parameters + .as_ref() + .map(|params| params.iter().filter(|p| p.default.is_none()).count()) + .unwrap_or(0); + + if required_param_count > 1 { + continue; + } + + let mut command = + AvailableCommand::new(mapping.command, validation_result.description.clone()); + + if let Some(hint) = Self::input_hint_for_recipe(validation_result.parameters.as_ref()) { + command = command.input(AvailableCommandInput::Unstructured( + UnstructuredCommandInput::new(hint), + )); + } + + commands.push(command); + } + + commands + } + + async fn send_available_commands_update( + &self, + cx: &ConnectionTo, + session_id: &SessionId, + ) -> Result<(), agent_client_protocol::Error> { + let commands = Self::build_available_commands_from_slash_commands().await; + + cx.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)), + ))?; + + Ok(()) + } + + fn is_builtin_agent_command(command: &str) -> bool { + let normalized = command.trim_start_matches('/'); + + crate::agents::execute_commands::list_commands() + .iter() + .any(|cmd| cmd.name == normalized) + || crate::agents::execute_commands::COMPACT_TRIGGERS + .iter() + .filter_map(|trigger| trigger.strip_prefix('/')) + .any(|trigger| trigger == normalized) + } } fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation { @@ -2417,10 +2516,14 @@ impl GooseAcpAgent { } if let Some(usage_update) = initial_usage_update { cx.send_notification(SessionNotification::new( - acp_session_id, + acp_session_id.clone(), SessionUpdate::UsageUpdate(usage_update), ))?; } + + self.send_available_commands_update(cx, &acp_session_id) + .await?; + debug!( target: "perf", sid = %sid, @@ -2785,6 +2888,10 @@ impl GooseAcpAgent { SessionUpdate::UsageUpdate(usage_update), ))?; } + + self.send_available_commands_update(cx, &args.session_id) + .await?; + debug!( target: "perf", sid = %sid, @@ -2811,6 +2918,29 @@ impl GooseAcpAgent { let user_message = Self::convert_acp_prompt_to_message(&args.prompt); + let message_text = user_message.as_concat_text(); + if let Some(parsed) = crate::agents::execute_commands::parse_slash_command(&message_text) { + let full_command = format!("/{}", parsed.command); + + if !Self::is_builtin_agent_command(parsed.command) { + if let Some(recipe_path) = + crate::slash_commands::get_recipe_for_command(&full_command) + { + if recipe_path.exists() { + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new( + ContentBlock::Text(TextContent::new(format!( + "Running recipe: {}", + full_command + ))), + )), + ))?; + } + } + } + } + let session_config = SessionConfig { id: session_id.clone(), schedule_id: None, @@ -3254,15 +3384,22 @@ impl GooseAcpAgent { let meta = session_meta(&new_session); - let mut response = ForkSessionResponse::new(SessionId::new(new_session_id)) + let acp_session_id = SessionId::new(new_session_id); + + let mut response = ForkSessionResponse::new(acp_session_id.clone()) .modes(mode_state) .meta(meta); + if let Some(ms) = model_state { response = response.models(ms); } if let Some(co) = config_options { response = response.config_options(co); } + + self.send_available_commands_update(cx, &acp_session_id) + .await?; + Ok(response) } diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index 66d5d34b..484cb22e 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -43,6 +43,34 @@ static COMMANDS: &[CommandDef] = &[ }, ]; +pub struct ParsedSlashCommand<'a> { + pub command: &'a str, + pub params_str: &'a str, +} + +pub fn parse_slash_command(message_text: &str) -> Option> { + let mut trimmed = message_text.trim(); + + if COMPACT_TRIGGERS.contains(&trimmed) { + trimmed = COMPACT_TRIGGERS[0]; + } + + if !trimmed.starts_with('/') { + return None; + } + + let command_str = trimmed.strip_prefix('/').unwrap_or(trimmed); + let (command, params_str) = command_str + .split_once(' ') + .map(|(cmd, p)| (cmd, p.trim())) + .unwrap_or((command_str, "")); + + Some(ParsedSlashCommand { + command, + params_str, + }) +} + pub fn list_commands() -> &'static [CommandDef] { COMMANDS } @@ -53,21 +81,12 @@ impl Agent { message_text: &str, session_id: &str, ) -> Result> { - let mut trimmed = message_text.trim().to_string(); - - if COMPACT_TRIGGERS.contains(&trimmed.as_str()) { - trimmed = COMPACT_TRIGGERS[0].to_string(); - } - - if !trimmed.starts_with('/') { + let Some(parsed) = parse_slash_command(message_text) else { return Ok(None); - } + }; - let command_str = trimmed.strip_prefix('/').unwrap_or(&trimmed); - let (command, params_str) = command_str - .split_once(' ') - .map(|(cmd, p)| (cmd, p.trim())) - .unwrap_or((command_str, "")); + let command = parsed.command; + let params_str = parsed.params_str; let params: Vec<&str> = if params_str.is_empty() { vec![] @@ -442,3 +461,27 @@ impl Agent { Ok(Some(Message::user().with_text(prompt))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_slash_command_splits_on_literal_space() { + let parsed = parse_slash_command("/speckit.plan hello world").unwrap(); + + assert_eq!(parsed.command, "speckit.plan"); + assert_eq!(parsed.params_str, "hello world"); + } + + #[test] + fn parse_slash_command_does_not_split_on_tab_or_newline() { + let parsed = parse_slash_command("/speckit.plan\thello").unwrap(); + assert_eq!(parsed.command, "speckit.plan\thello"); + assert_eq!(parsed.params_str, ""); + + let parsed = parse_slash_command("/speckit.plan\nhello").unwrap(); + assert_eq!(parsed.command, "speckit.plan\nhello"); + assert_eq!(parsed.params_str, ""); + } +}