Added recipe discovery / execution to ACP server. (#8925)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
natelastname
2026-05-12 16:32:05 -04:00
committed by GitHub
parent a23bbf24b5
commit 69f591322b
2 changed files with 211 additions and 31 deletions
+155 -18
View File
@@ -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<crate::recipe::RecipeParameter>>,
) -> Option<String> {
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<AvailableCommand> {
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<Client>,
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)
}
+56 -13
View File
@@ -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<ParsedSlashCommand<'_>> {
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<Option<Message>> {
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, "");
}
}