Feature/at agent mention (#8571)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-04-22 10:17:43 -04:00
committed by GitHub
parent 2bbf4dd229
commit 04514c6d20
7 changed files with 99 additions and 18 deletions
@@ -136,6 +136,7 @@ pub enum CommandType {
Builtin, Builtin,
Recipe, Recipe,
Skill, Skill,
Agent,
} }
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[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 })) Ok(Json(SlashCommandsResponse { commands }))
} }
+33 -1
View File
@@ -21,6 +21,7 @@ use crate::agents::extension_manager::{
get_parameter_names, ExtensionManager, ExtensionManagerCapabilities, get_parameter_names, ExtensionManager, ExtensionManagerCapabilities,
}; };
use crate::agents::final_output_tool::{FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME}; 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_extensions::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME; use crate::agents::platform_tools::PLATFORM_MANAGE_SCHEDULE_TOOL_NAME;
use crate::agents::prompt_manager::PromptManager; use crate::agents::prompt_manager::PromptManager;
@@ -354,10 +355,17 @@ impl Agent {
} }
let initial_messages = conversation.messages().clone(); 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) .prepare_tools_and_prompt(session_id, working_dir)
.await?; .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; let goose_mode = *self.current_goose_mode.lock().await;
if goose_mode == GooseMode::SmartApprove { if goose_mode == GooseMode::SmartApprove {
@@ -391,6 +399,30 @@ impl Agent {
}) })
} }
fn resolve_at_mention(
&self,
conversation: &Conversation,
working_dir: &std::path::Path,
) -> Option<String> {
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( async fn categorize_tools(
&self, &self,
response: &Message, response: &Message,
@@ -218,7 +218,7 @@ fn scan_agents_from_dir(
} }
} }
fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> { pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
let mut sources: Vec<Source> = Vec::new(); let mut sources: Vec<Source> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
@@ -240,6 +240,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
}) })
.chain( .chain(
[ [
home.as_ref().map(|h| h.join(".goose/recipes")),
Some(config.join("recipes")), Some(config.join("recipes")),
home.as_ref().map(|h| h.join(".agents/recipes")), home.as_ref().map(|h| h.join(".agents/recipes")),
] ]
@@ -255,6 +256,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
]; ];
let global_agent_dirs: Vec<PathBuf> = [ let global_agent_dirs: Vec<PathBuf> = [
home.as_ref().map(|h| h.join(".goose/agents")),
home.as_ref().map(|h| h.join(".agents/agents")), home.as_ref().map(|h| h.join(".agents/agents")),
Some(config.join("agents")), Some(config.join("agents")),
home.as_ref().map(|h| h.join(".claude/agents")), home.as_ref().map(|h| h.join(".claude/agents")),
+2 -1
View File
@@ -4165,7 +4165,8 @@
"enum": [ "enum": [
"Builtin", "Builtin",
"Recipe", "Recipe",
"Skill" "Skill",
"Agent"
] ]
}, },
"ConfigKey": { "ConfigKey": {
+1 -1
View File
@@ -80,7 +80,7 @@ export type CheckProviderRequest = {
provider: string; provider: string;
}; };
export type CommandType = 'Builtin' | 'Recipe' | 'Skill'; export type CommandType = 'Builtin' | 'Recipe' | 'Skill' | 'Agent';
/** /**
* Configuration key metadata for provider setup * Configuration key metadata for provider setup
+2
View File
@@ -36,6 +36,8 @@ export const getItemIcon = (item: DisplayItem): IconInfo => {
return { Icon: BookOpen, color: '#10b981' }; // Green return { Icon: BookOpen, color: '#10b981' }; // Green
case 'Skill': case 'Skill':
return { Icon: Sparkles, color: '#8b5cf6' }; // Purple return { Icon: Sparkles, color: '#8b5cf6' }; // Purple
case 'Agent':
return { Icon: Terminal, color: '#f59e0b' }; // Amber
case 'Directory': case 'Directory':
return { Icon: Folder, color: '#f59e0b' }; // Amber return { Icon: Folder, color: '#f59e0b' }; // Amber
default: { default: {
+37 -14
View File
@@ -38,11 +38,12 @@ const i18n = defineMessages({
type DisplayItemType = CommandType | 'Directory' | 'File'; type DisplayItemType = CommandType | 'Directory' | 'File';
const typeOrder: Record<DisplayItemType, number> = { const typeOrder: Record<DisplayItemType, number> = {
Directory: 0, Agent: 0,
File: 1, Directory: 1,
Builtin: 2, File: 2,
Skill: 3, Builtin: 3,
Recipe: 4, Skill: 4,
Recipe: 5,
}; };
export interface DisplayItem { export interface DisplayItem {
@@ -426,7 +427,9 @@ const MentionPopover = forwardRef<
); );
let finalScore = bestMatch.score; 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; const depth = file.extra.replace(currentWorkingDir, '').split('/').length - 1;
finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0; finalScore += depth <= 1 ? 50 : depth <= 2 ? 30 : depth <= 3 ? 15 : 0;
} }
@@ -449,6 +452,9 @@ const MentionPopover = forwardRef<
}, [items, query, currentWorkingDir]); }, [items, query, currentWorkingDir]);
const getSelectionText = (item: DisplayItem): string => { const getSelectionText = (item: DisplayItem): string => {
if (item.itemType === 'Agent') {
return '@' + item.name + ' ';
}
if (item.itemType === 'Skill') { if (item.itemType === 'Skill') {
return `Use the ${item.name} skill to `; return `Use the ${item.name} skill to `;
} }
@@ -486,17 +492,34 @@ const MentionPopover = forwardRef<
throwOnError: true, throwOnError: true,
}); });
if (cancelled) return; if (cancelled) return;
const commandItems: DisplayItem[] = (response.data?.commands || []).map((cmd) => ({ const commandItems: DisplayItem[] = (response.data?.commands || [])
name: cmd.command, .filter((cmd) => cmd.command_type !== 'Agent')
extra: cmd.help, .map((cmd) => ({
itemType: cmd.command_type, name: cmd.command,
relativePath: cmd.command, extra: cmd.help,
})); itemType: cmd.command_type,
relativePath: cmd.command,
}));
setItems(commandItems); setItems(commandItems);
} else { } 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; 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) { } catch (error) {
if (!cancelled) { if (!cancelled) {