Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 1 addition & 33 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ 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;
Expand Down Expand Up @@ -459,17 +458,10 @@ impl Agent {
}
let initial_messages = conversation.messages().clone();

let (tools, toolshim_tools, mut system_prompt) = self
let (tools, toolshim_tools, system_prompt) = self
.prepare_tools_and_prompt(session_id, working_dir)
.await?;
Comment on lines +461 to 463

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep @mention routing when tool calls are disabled

Removing resolve_at_mention from reply-context setup means @agent now relies entirely on the model calling delegate, but in Goose chat mode all non-frontend tool calls are skipped (GooseMode::Chat path in agent.rs), so explicit @name requests can no longer activate the target subagent instructions. Before this commit, @name injected matching source content directly into the system prompt and worked without tool execution; with this change, the same input can silently degrade to a normal response in chat-mode sessions.

Useful? React with 👍 / 👎.


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 {
Expand Down Expand Up @@ -503,30 +495,6 @@ 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(
&self,
response: &Message,
Expand Down
141 changes: 113 additions & 28 deletions crates/goose/src/agents/platform_extensions/summon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::recipe::{Recipe, Settings, RECIPE_FILE_EXTENSIONS};
use crate::session::extension_data::EnabledExtensionsState;
use crate::session::SessionType;
use crate::sources::parse_frontmatter;
use crate::utils::safe_truncate;
use anyhow::Result;
use async_trait::async_trait;
use goose_sdk::custom_requests::{SourceEntry, SourceType};
Expand All @@ -34,6 +35,10 @@ use tracing::{info, warn};

pub static EXTENSION_NAME: &str = "summon";

const SUBAGENT_DESCRIPTION_BUDGET: usize = 160;

const TASK_LABEL_BUDGET: usize = 60;

fn kind_plural(kind: SourceType) -> &'static str {
match kind {
SourceType::Subrecipe => "Subrecipes",
Expand All @@ -43,17 +48,6 @@ fn kind_plural(kind: SourceType) -> &'static str {
}
}

fn truncate(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else if max_len <= 3 {
"...".to_string()
} else {
let truncated: String = s.chars().take(max_len - 3).collect();
format!("{}...", truncated)
}
}

#[derive(Debug, Default, Deserialize)]
pub struct DelegateParams {
pub instructions: Option<String>,
Expand Down Expand Up @@ -291,6 +285,99 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<SourceEntry> {
sources
}

fn build_subagent_instructions(session: Option<&crate::session::Session>) -> String {
let Some(session) = session else {
return String::new();
};

// filter the sources down to what we want even though currently that is what we get
let mut sources: Vec<SourceEntry> = discover_filesystem_sources(&session.working_dir)
.into_iter()
.filter(|s| {
matches!(
s.source_type,
SourceType::Agent | SourceType::Recipe | SourceType::Subrecipe
)
})
.collect();

// If the session is started from a recipe, also use the subrecipes for
// that recipe as delegate targets
if let Some(recipe) = session.recipe.as_ref() {
if let Some(subs) = recipe.sub_recipes.as_ref() {
let mut seen: std::collections::HashSet<String> =
sources.iter().map(|s| s.name.clone()).collect();
for sr in subs {
if !seen.insert(sr.name.clone()) {
continue;
Comment on lines +308 to +312

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align instruction precedence with delegate source resolution

When a filesystem source and a recipe subrecipe share the same name, build_subagent_instructions pre-populates seen from filesystem sources and drops the subrecipe entry, but runtime resolution in get_sources gives subrecipes precedence by adding them first. This creates a mismatch where the model is shown one source description but delegate(source: "name") executes a different source, which can route work to unintended instructions for name-collision cases.

Useful? React with 👍 / 👎.

}
sources.push(SourceEntry {
source_type: SourceType::Subrecipe,
name: sr.name.clone(),
description: sr.description.clone().unwrap_or_default(),
content: String::new(),
path: sr.path.clone(),
global: false,
writable: false,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
});
}
}
}

if sources.is_empty() {
return String::new();
}

sources.sort_by(|a, b| (&a.source_type, &a.name).cmp(&(&b.source_type, &b.name)));
let subagents: Vec<&SourceEntry> = sources.iter().collect();

let names = subagents
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ");

let mut out = String::new();
out.push_str(
"\n\nThe following named subagents are available in this session and \
can be invoked through the `delegate` tool (run as a subagent) or \
the `load` tool (read their instructions into your own context):\n",
);

let mut current_kind: Option<SourceType> = None;
for s in &subagents {
if current_kind != Some(s.source_type) {
out.push_str(&format!("\n{}:", kind_plural(s.source_type)));
current_kind = Some(s.source_type);
}
out.push_str(&format!(
"\n• {} — {}",
s.name,
safe_truncate(&s.description, SUBAGENT_DESCRIPTION_BUDGET)
));
}

out.push_str(&format!(
"\n\nWhen to call a subagent (one of [{names}]):\n\
• `@<name>` in the user's message — always call that subagent.\n\
• The user mentions a subagent by name without `@` — infer from \
context whether they want it invoked, and if so, call it.\n\
• The user's request strongly matches a subagent's description — \
call it.\n\n\
Calling a subagent normally means `delegate(source: \"<name>\", \
instructions: ...)`, which runs it as an isolated subagent and \
returns its result. Use `load(source: \"<name>\")` instead if you \
only want to read the subagent's instructions into your own \
context. For long-running work, pass `async: true` to `delegate` — \
it returns a task id immediately, and you collect the result later \
with `load(source: \"<task_id>\")`, which waits for completion.",
));

out
}

fn round_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
Expand Down Expand Up @@ -341,8 +428,11 @@ impl Drop for SummonClient {

impl SummonClient {
pub fn new(context: PlatformExtensionContext) -> Result<Self> {
let instructions = build_subagent_instructions(context.session.as_deref());

let info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new(EXTENSION_NAME, "1.0.0").with_title("Summon"));
.with_server_info(Implementation::new(EXTENSION_NAME, "1.0.0").with_title("Summon"))
.with_instructions(instructions);
Comment on lines +431 to +435

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh summon instructions when source files change

Build the summon instruction text lazily or refresh it per turn instead of freezing it at client construction. with_instructions(...) is populated once from a startup scan, so if a user adds/renames an agent or recipe during the same session, the model keeps an outdated name list and routing guidance and can miss explicit mentions (for example a newly created @agent) until the session/extension is recreated.

Useful? React with 👍 / 👎.


Ok(Self {
info,
Expand Down Expand Up @@ -859,7 +949,7 @@ impl SummonClient {
output.push_str(&format!(
"• {} - {}\n",
source.name,
truncate(&source.description, 60)
safe_truncate(&source.description, SUBAGENT_DESCRIPTION_BUDGET)
));
}
}
Expand Down Expand Up @@ -1397,16 +1487,11 @@ impl SummonClient {
}

fn get_task_description(params: &DelegateParams) -> String {
if let Some(source) = &params.source {
if let Some(instructions) = &params.instructions {
format!("{}: {}", source, truncate(instructions, 30))
} else {
source.clone()
}
} else if let Some(instructions) = &params.instructions {
truncate(instructions, 40)
} else {
"Unknown task".to_string()
match (&params.source, &params.instructions) {
(Some(source), Some(instructions)) => format!("{}: {}", source, instructions),
(Some(source), None) => source.clone(),
(None, Some(instructions)) => instructions.clone(),
(None, None) => "Unknown task".to_string(),
}
}

Expand Down Expand Up @@ -1441,7 +1526,7 @@ impl SummonClient {
.await
.map_err(|e| format!("Failed to build task config: {}", e))?;

let description = truncate(&Self::get_task_description(&params), 40);
let description = safe_truncate(&Self::get_task_description(&params), TASK_LABEL_BUDGET);

// Subagents must use Auto until get_agent_messages forwards
// ActionRequired messages to the parent. Until then, any mode
Expand Down Expand Up @@ -1931,10 +2016,10 @@ You review code."#;
SummonClient::get_task_description(&make_params(Some("r"), Some("task"))),
"r: task"
);

let long = "x".repeat(100);
let desc = SummonClient::get_task_description(&make_params(None, Some(&long)));
assert!(desc.len() <= 43 && desc.ends_with("..."));
assert_eq!(
SummonClient::get_task_description(&make_params(None, None)),
"Unknown task"
);
}

#[test]
Expand Down
Loading
Loading