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:
+155
-18
@@ -27,22 +27,23 @@ use crate::source_roots::SourceRoot;
|
|||||||
use crate::utils::sanitize_unicode_tags;
|
use crate::utils::sanitize_unicode_tags;
|
||||||
use agent_client_protocol::schema::{
|
use agent_client_protocol::schema::{
|
||||||
AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest,
|
AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest,
|
||||||
AuthenticateResponse, BlobResourceContents, CancelNotification, CloseSessionRequest,
|
AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate,
|
||||||
CloseSessionResponse, ConfigOptionUpdate, Content, ContentBlock, ContentChunk,
|
BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse,
|
||||||
CurrentModeUpdate, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities,
|
ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource,
|
||||||
ForkSessionRequest, ForkSessionResponse, ImageContent, InitializeRequest, InitializeResponse,
|
EmbeddedResourceResource, FileSystemCapabilities, ForkSessionRequest, ForkSessionResponse,
|
||||||
ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
|
ImageContent, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
|
||||||
McpCapabilities, McpServer, Meta, ModelId, ModelInfo, NewSessionRequest, NewSessionResponse,
|
LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, Meta, ModelId, ModelInfo,
|
||||||
PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse,
|
NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind,
|
||||||
RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities,
|
PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome,
|
||||||
SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory,
|
RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionCloseCapabilities,
|
||||||
SessionConfigSelectOption, SessionId, SessionInfo, SessionInfoUpdate, SessionListCapabilities,
|
SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId,
|
||||||
SessionMode, SessionModeId, SessionModeState, SessionModelState, SessionNotification,
|
SessionInfo, SessionInfoUpdate, SessionListCapabilities, SessionMode, SessionModeId,
|
||||||
SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse,
|
SessionModeState, SessionModelState, SessionNotification, SessionUpdate,
|
||||||
SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse,
|
SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
|
||||||
StopReason, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId,
|
SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason,
|
||||||
ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, Usage,
|
TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation,
|
||||||
UsageUpdate,
|
ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
|
||||||
|
Usage, UsageUpdate,
|
||||||
};
|
};
|
||||||
use agent_client_protocol::util::MatchDispatchFrom;
|
use agent_client_protocol::util::MatchDispatchFrom;
|
||||||
use agent_client_protocol::{
|
use agent_client_protocol::{
|
||||||
@@ -2151,6 +2152,104 @@ impl GooseAcpAgent {
|
|||||||
|
|
||||||
Ok(())
|
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 {
|
fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation {
|
||||||
@@ -2417,10 +2516,14 @@ impl GooseAcpAgent {
|
|||||||
}
|
}
|
||||||
if let Some(usage_update) = initial_usage_update {
|
if let Some(usage_update) = initial_usage_update {
|
||||||
cx.send_notification(SessionNotification::new(
|
cx.send_notification(SessionNotification::new(
|
||||||
acp_session_id,
|
acp_session_id.clone(),
|
||||||
SessionUpdate::UsageUpdate(usage_update),
|
SessionUpdate::UsageUpdate(usage_update),
|
||||||
))?;
|
))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.send_available_commands_update(cx, &acp_session_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
target: "perf",
|
target: "perf",
|
||||||
sid = %sid,
|
sid = %sid,
|
||||||
@@ -2785,6 +2888,10 @@ impl GooseAcpAgent {
|
|||||||
SessionUpdate::UsageUpdate(usage_update),
|
SessionUpdate::UsageUpdate(usage_update),
|
||||||
))?;
|
))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.send_available_commands_update(cx, &args.session_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
target: "perf",
|
target: "perf",
|
||||||
sid = %sid,
|
sid = %sid,
|
||||||
@@ -2811,6 +2918,29 @@ impl GooseAcpAgent {
|
|||||||
|
|
||||||
let user_message = Self::convert_acp_prompt_to_message(&args.prompt);
|
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 {
|
let session_config = SessionConfig {
|
||||||
id: session_id.clone(),
|
id: session_id.clone(),
|
||||||
schedule_id: None,
|
schedule_id: None,
|
||||||
@@ -3254,15 +3384,22 @@ impl GooseAcpAgent {
|
|||||||
|
|
||||||
let meta = session_meta(&new_session);
|
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)
|
.modes(mode_state)
|
||||||
.meta(meta);
|
.meta(meta);
|
||||||
|
|
||||||
if let Some(ms) = model_state {
|
if let Some(ms) = model_state {
|
||||||
response = response.models(ms);
|
response = response.models(ms);
|
||||||
}
|
}
|
||||||
if let Some(co) = config_options {
|
if let Some(co) = config_options {
|
||||||
response = response.config_options(co);
|
response = response.config_options(co);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.send_available_commands_update(cx, &acp_session_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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] {
|
pub fn list_commands() -> &'static [CommandDef] {
|
||||||
COMMANDS
|
COMMANDS
|
||||||
}
|
}
|
||||||
@@ -53,21 +81,12 @@ impl Agent {
|
|||||||
message_text: &str,
|
message_text: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<Option<Message>> {
|
) -> Result<Option<Message>> {
|
||||||
let mut trimmed = message_text.trim().to_string();
|
let Some(parsed) = parse_slash_command(message_text) else {
|
||||||
|
|
||||||
if COMPACT_TRIGGERS.contains(&trimmed.as_str()) {
|
|
||||||
trimmed = COMPACT_TRIGGERS[0].to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !trimmed.starts_with('/') {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
};
|
||||||
|
|
||||||
let command_str = trimmed.strip_prefix('/').unwrap_or(&trimmed);
|
let command = parsed.command;
|
||||||
let (command, params_str) = command_str
|
let params_str = parsed.params_str;
|
||||||
.split_once(' ')
|
|
||||||
.map(|(cmd, p)| (cmd, p.trim()))
|
|
||||||
.unwrap_or((command_str, ""));
|
|
||||||
|
|
||||||
let params: Vec<&str> = if params_str.is_empty() {
|
let params: Vec<&str> = if params_str.is_empty() {
|
||||||
vec![]
|
vec![]
|
||||||
@@ -442,3 +461,27 @@ impl Agent {
|
|||||||
Ok(Some(Message::user().with_text(prompt)))
|
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, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user