From 04514c6d205809afd080495b563f389ab9b6fccd Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 22 Apr 2026 10:17:43 -0400 Subject: [PATCH] Feature/at agent mention (#8571) Signed-off-by: Douwe Osinga Co-authored-by: Douwe Osinga --- .../src/routes/config_management.rs | 21 ++++++++ crates/goose/src/agents/agent.rs | 34 ++++++++++++- .../src/agents/platform_extensions/summon.rs | 4 +- ui/desktop/openapi.json | 3 +- ui/desktop/src/api/types.gen.ts | 2 +- ui/desktop/src/components/ItemIcon.tsx | 2 + ui/desktop/src/components/MentionPopover.tsx | 51 ++++++++++++++----- 7 files changed, 99 insertions(+), 18 deletions(-) diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 1d18e2b40f..c8ed2d25b6 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -136,6 +136,7 @@ pub enum CommandType { Builtin, Recipe, Skill, + Agent, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -436,6 +437,26 @@ pub async fn get_slash_commands( }); } + let discover_dir = working_dir + .as_deref() + .unwrap_or_else(|| std::path::Path::new(".")); + for source in + goose::agents::platform_extensions::summon::discover_filesystem_sources(discover_dir) + { + use goose::agents::platform_extensions::SourceKind; + if matches!( + source.kind, + SourceKind::Agent | SourceKind::Recipe | SourceKind::Subrecipe + ) && !source.content.is_empty() + { + commands.push(SlashCommand { + command: source.name, + help: source.description, + command_type: CommandType::Agent, + }); + } + } + Ok(Json(SlashCommandsResponse { commands })) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 57446f07d1..990cfa20d4 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -21,6 +21,7 @@ use crate::agents::extension_manager::{ get_parameter_names, ExtensionManager, ExtensionManagerCapabilities, }; use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME}; +use crate::agents::platform_extensions::summon::discover_filesystem_sources; use crate::agents::platform_extensions::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE; use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME; use crate::agents::prompt_manager::PromptManager; @@ -354,10 +355,17 @@ impl Agent { } let initial_messages = conversation.messages().clone(); - let (tools, toolshim_tools, system_prompt) = self + let (tools, toolshim_tools, mut system_prompt) = self .prepare_tools_and_prompt(session_id, working_dir) .await?; + if let Some(instructions) = self.resolve_at_mention(&conversation, working_dir) { + system_prompt = format!( + "{}\n\n# Instructions from active agent:\n\n{}", + system_prompt, instructions + ); + } + let goose_mode = *self.current_goose_mode.lock().await; if goose_mode == GooseMode::SmartApprove { @@ -391,6 +399,30 @@ impl Agent { }) } + fn resolve_at_mention( + &self, + conversation: &Conversation, + working_dir: &std::path::Path, + ) -> Option { + let last_message = conversation.messages().last()?; + if last_message.role == rmcp::model::Role::User { + let after_at = last_message + .as_concat_text() + .trim() + .strip_prefix('@')? + .to_lowercase(); + + for source in discover_filesystem_sources(working_dir) { + let name = source.name.to_lowercase(); + let is_match = after_at == name || after_at.starts_with(&format!("{} ", name)); + if is_match && !source.content.is_empty() { + return Some(source.content.clone()); + } + } + } + None + } + async fn categorize_tools( &self, response: &Message, diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 8badb85793..f84a343025 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -218,7 +218,7 @@ fn scan_agents_from_dir( } } -fn discover_filesystem_sources(working_dir: &Path) -> Vec { +pub fn discover_filesystem_sources(working_dir: &Path) -> Vec { let mut sources: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); @@ -240,6 +240,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { }) .chain( [ + home.as_ref().map(|h| h.join(".goose/recipes")), Some(config.join("recipes")), home.as_ref().map(|h| h.join(".agents/recipes")), ] @@ -255,6 +256,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { ]; let global_agent_dirs: Vec = [ + home.as_ref().map(|h| h.join(".goose/agents")), home.as_ref().map(|h| h.join(".agents/agents")), Some(config.join("agents")), home.as_ref().map(|h| h.join(".claude/agents")), diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4611c8d861..206ee59654 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4165,7 +4165,8 @@ "enum": [ "Builtin", "Recipe", - "Skill" + "Skill", + "Agent" ] }, "ConfigKey": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index fc5487e7b9..0c458a5a7d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -80,7 +80,7 @@ export type CheckProviderRequest = { provider: string; }; -export type CommandType = 'Builtin' | 'Recipe' | 'Skill'; +export type CommandType = 'Builtin' | 'Recipe' | 'Skill' | 'Agent'; /** * Configuration key metadata for provider setup diff --git a/ui/desktop/src/components/ItemIcon.tsx b/ui/desktop/src/components/ItemIcon.tsx index deae40918e..1282f03eb2 100644 --- a/ui/desktop/src/components/ItemIcon.tsx +++ b/ui/desktop/src/components/ItemIcon.tsx @@ -36,6 +36,8 @@ export const getItemIcon = (item: DisplayItem): IconInfo => { return { Icon: BookOpen, color: '#10b981' }; // Green case 'Skill': return { Icon: Sparkles, color: '#8b5cf6' }; // Purple + case 'Agent': + return { Icon: Terminal, color: '#f59e0b' }; // Amber case 'Directory': return { Icon: Folder, color: '#f59e0b' }; // Amber default: { diff --git a/ui/desktop/src/components/MentionPopover.tsx b/ui/desktop/src/components/MentionPopover.tsx index 0ca65ba100..21f1f50eda 100644 --- a/ui/desktop/src/components/MentionPopover.tsx +++ b/ui/desktop/src/components/MentionPopover.tsx @@ -38,11 +38,12 @@ const i18n = defineMessages({ type DisplayItemType = CommandType | 'Directory' | 'File'; const typeOrder: Record = { - Directory: 0, - File: 1, - Builtin: 2, - Skill: 3, - Recipe: 4, + Agent: 0, + Directory: 1, + File: 2, + Builtin: 3, + Skill: 4, + Recipe: 5, }; export interface DisplayItem { @@ -426,7 +427,9 @@ const MentionPopover = forwardRef< ); let finalScore = bestMatch.score; - if (finalScore > 0 && currentWorkingDir) { + if (finalScore > 0 && file.itemType === 'Agent') { + finalScore += 100; + } else if (finalScore > 0 && currentWorkingDir) { const depth = file.extra.replace(currentWorkingDir, '').split('/').length - 1; finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0; } @@ -449,6 +452,9 @@ const MentionPopover = forwardRef< }, [items, query, currentWorkingDir]); const getSelectionText = (item: DisplayItem): string => { + if (item.itemType === 'Agent') { + return '@' + item.name + ' '; + } if (item.itemType === 'Skill') { return `Use the ${item.name} skill to `; } @@ -486,17 +492,34 @@ const MentionPopover = forwardRef< throwOnError: true, }); if (cancelled) return; - const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({ - name: cmd.command, - extra: cmd.help, - itemType: cmd.command_type, - relativePath: cmd.command, - })); + const commandItems: DisplayItem[] = (response.data?.commands || []) + .filter((cmd) => cmd.command_type !== 'Agent') + .map((cmd) => ({ + name: cmd.command, + extra: cmd.help, + itemType: cmd.command_type, + relativePath: cmd.command, + })); setItems(commandItems); } else { - const scannedFiles = await scanDirectoryFromRoot(currentWorkingDir || getDefaultStartPath()); + // Fetch agents from server and scan files in parallel + const [agentResponse, scannedFiles] = await Promise.all([ + getSlashCommands({ + query: { working_dir: currentWorkingDir }, + throwOnError: true, + }).catch(() => null), + scanDirectoryFromRoot(currentWorkingDir || getDefaultStartPath()), + ]); if (cancelled) return; - setItems(scannedFiles); + const agentItems: DisplayItem[] = (agentResponse?.data?.commands || []) + .filter((cmd) => cmd.command_type === 'Agent') + .map((cmd) => ({ + name: cmd.command, + extra: cmd.help, + itemType: cmd.command_type, + relativePath: cmd.command, + })); + setItems([...agentItems, ...scannedFiles]); } } catch (error) { if (!cancelled) {