Feat/summon subagent instructions (#9325)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -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;
|
||||
@@ -587,17 +586,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?;
|
||||
|
||||
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 {
|
||||
@@ -631,30 +623,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,
|
||||
|
||||
@@ -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};
|
||||
@@ -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",
|
||||
@@ -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>,
|
||||
@@ -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;
|
||||
}
|
||||
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 {
|
||||
@@ -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);
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
@@ -859,7 +949,7 @@ impl SummonClient {
|
||||
output.push_str(&format!(
|
||||
"• {} - {}\n",
|
||||
source.name,
|
||||
truncate(&source.description, 60)
|
||||
safe_truncate(&source.description, SUBAGENT_DESCRIPTION_BUDGET)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1430,16 +1520,11 @@ impl SummonClient {
|
||||
}
|
||||
|
||||
fn get_task_description(params: &DelegateParams) -> String {
|
||||
if let Some(source) = ¶ms.source {
|
||||
if let Some(instructions) = ¶ms.instructions {
|
||||
format!("{}: {}", source, truncate(instructions, 30))
|
||||
} else {
|
||||
source.clone()
|
||||
}
|
||||
} else if let Some(instructions) = ¶ms.instructions {
|
||||
truncate(instructions, 40)
|
||||
} else {
|
||||
"Unknown task".to_string()
|
||||
match (¶ms.source, ¶ms.instructions) {
|
||||
(Some(source), Some(instructions)) => format!("{}: {}", source, instructions),
|
||||
(Some(source), None) => source.clone(),
|
||||
(None, Some(instructions)) => instructions.clone(),
|
||||
(None, None) => "Unknown task".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1474,7 +1559,7 @@ impl SummonClient {
|
||||
.await
|
||||
.map_err(|e| format!("Failed to build task config: {}", e))?;
|
||||
|
||||
let description = truncate(&Self::get_task_description(¶ms), 40);
|
||||
let description = safe_truncate(&Self::get_task_description(¶ms), TASK_LABEL_BUDGET);
|
||||
|
||||
// Subagents must use Auto until get_agent_messages forwards
|
||||
// ActionRequired messages to the parent. Until then, any mode
|
||||
@@ -1965,10 +2050,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]
|
||||
|
||||
Reference in New Issue
Block a user