diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 8820ec409cf1..45fa3f660a6d 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -173,6 +173,7 @@ mod tests { response: None, sub_recipes: None, retry: None, + max_turns: None, } } @@ -216,6 +217,7 @@ mod tests { response: None, sub_recipes: None, retry: None, + max_turns: None, }; let secrets = discover_recipe_secrets(&recipe); @@ -260,6 +262,7 @@ mod tests { response: None, sub_recipes: None, retry: None, + max_turns: None, }; let secrets = discover_recipe_secrets(&recipe); @@ -312,6 +315,7 @@ mod tests { parameters: None, response: None, retry: None, + max_turns: None, }; let secrets = discover_recipe_secrets(&recipe); diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 98e87a524f58..d5fc5a8f9e67 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -183,7 +183,7 @@ fn get_agent_messages( let session_config = SessionConfig { id: session_id.clone(), schedule_id: None, - max_turns: task_config.max_turns.map(|v| v as u32), + max_turns: recipe.max_turns.or(task_config.max_turns.map(|v| v as u32)), retry_config: recipe.retry, }; diff --git a/crates/goose/src/agents/subagent_tool.rs b/crates/goose/src/agents/subagent_tool.rs index c775c070bc15..7832e8268d61 100644 --- a/crates/goose/src/agents/subagent_tool.rs +++ b/crates/goose/src/agents/subagent_tool.rs @@ -13,6 +13,7 @@ use crate::agents::subagent_handler::run_complete_subagent_task; use crate::agents::subagent_task_config::TaskConfig; use crate::agents::tool_execution::ToolCallResult; use crate::config::GooseMode; +use crate::prompt_template::{get_bundled_subrecipe_content, iter_bundled_subrecipes}; use crate::providers; use crate::recipe::build_recipe::build_recipe_from_template; use crate::recipe::local_recipes::load_local_recipe_file; @@ -102,7 +103,7 @@ pub fn create_subagent_tool(sub_recipes: &[SubRecipe]) -> Tool { ) } -fn build_tool_description(sub_recipes: &[SubRecipe]) -> String { +fn build_tool_description(user_sub_recipes: &[SubRecipe]) -> String { let mut desc = String::from( "Delegate a task to a subagent that runs independently with its own context.\n\n\ Modes:\n\ @@ -114,9 +115,23 @@ fn build_tool_description(sub_recipes: &[SubRecipe]) -> String { For parallel execution, make multiple `subagent` tool calls in the same message.", ); - if !sub_recipes.is_empty() { - desc.push_str("\n\nAvailable subrecipes:"); - for sr in sub_recipes { + let bundled: Vec<_> = iter_bundled_subrecipes() + .filter_map(|(name, content)| { + let recipe = Recipe::from_content(content).ok()?; + Some((name, recipe.description)) + }) + .collect(); + + if !bundled.is_empty() { + desc.push_str("\n\nBuilt-in subrecipes:"); + for (name, description) in &bundled { + desc.push_str(&format!("\n• {} - {}", name, description)); + } + } + + if !user_sub_recipes.is_empty() { + desc.push_str("\n\nUser-defined subrecipes:"); + for sr in user_sub_recipes { let params_info = get_subrecipe_params_description(sr); let sequential_hint = if sr.sequential_when_repeated { " [run sequentially, not in parallel]" @@ -305,40 +320,83 @@ fn build_recipe( Ok(recipe) } +fn params_to_values(params: &SubagentParams) -> Vec<(String, String)> { + params + .parameters + .as_ref() + .map(|p| { + p.iter() + .map(|(k, v)| { + let v = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), v) + }) + .collect() + }) + .unwrap_or_default() +} + +fn combine_instructions(recipe: &mut Recipe, extra: Option<&str>) { + let mut combined = recipe.instructions.take().unwrap_or_default(); + + if let Some(prompt) = &recipe.prompt { + if !combined.is_empty() { + combined.push_str("\n\n"); + } + combined.push_str(prompt); + } + + if let Some(extra) = extra { + if !combined.is_empty() { + combined.push_str("\n\n"); + } + combined.push_str("Additional context from parent agent:\n"); + combined.push_str(extra); + } + + if !combined.is_empty() { + recipe.instructions = Some(combined); + } +} + fn build_subrecipe( subrecipe_name: &str, params: &SubagentParams, - sub_recipes: &HashMap, + user_sub_recipes: &HashMap, ) -> Result { - let sub_recipe = sub_recipes.get(subrecipe_name).ok_or_else(|| { - let available: Vec<_> = sub_recipes.keys().cloned().collect(); + if let Some(content) = get_bundled_subrecipe_content(subrecipe_name) { + let mut recipe = build_recipe_from_template( + content.to_string(), + &PathBuf::new(), + params_to_values(params), + None:: Result>, + )?; + combine_instructions(&mut recipe, params.instructions.as_deref()); + return Ok(recipe); + } + + let sub_recipe = user_sub_recipes.get(subrecipe_name).ok_or_else(|| { + let bundled: Vec<_> = iter_bundled_subrecipes().map(|(n, _)| n).collect(); + let user: Vec<_> = user_sub_recipes.keys().map(|s| s.as_str()).collect(); anyhow!( - "Unknown subrecipe '{}'. Available: {}", + "Unknown subrecipe '{}'. Built-in: [{}]. User-defined: [{}]", subrecipe_name, - available.join(", ") + bundled.join(", "), + user.join(", ") ) })?; let recipe_file = load_local_recipe_file(&sub_recipe.path) .map_err(|e| anyhow!("Failed to load subrecipe '{}': {}", subrecipe_name, e))?; - let mut param_values: Vec<(String, String)> = Vec::new(); - - if let Some(values) = &sub_recipe.values { - for (k, v) in values { - param_values.push((k.clone(), v.clone())); - } - } - - if let Some(provided_params) = ¶ms.parameters { - for (k, v) in provided_params { - let value_str = match v { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - param_values.push((k.clone(), value_str)); - } - } + let mut param_values: Vec<(String, String)> = sub_recipe + .values + .as_ref() + .map(|v| v.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + param_values.extend(params_to_values(params)); let mut recipe = build_recipe_from_template( recipe_file.content, @@ -348,33 +406,7 @@ fn build_subrecipe( ) .map_err(|e| anyhow!("Failed to build subrecipe: {}", e))?; - // Merge prompt into instructions so the subagent gets the actual task. - // The subagent handler uses `instructions` as the user message. - let mut combined = String::new(); - - if let Some(instructions) = &recipe.instructions { - combined.push_str(instructions); - } - - if let Some(prompt) = &recipe.prompt { - if !combined.is_empty() { - combined.push_str("\n\n"); - } - combined.push_str(prompt); - } - - if let Some(extra_instructions) = ¶ms.instructions { - if !combined.is_empty() { - combined.push_str("\n\n"); - } - combined.push_str("Additional context from parent agent:\n"); - combined.push_str(extra_instructions); - } - - if !combined.is_empty() { - recipe.instructions = Some(combined); - } - + combine_instructions(&mut recipe, params.instructions.as_deref()); Ok(recipe) } @@ -462,19 +494,18 @@ mod tests { } #[test] - fn test_create_tool_without_subrecipes() { + fn test_create_tool_includes_bundled_subrecipes() { let tool = create_subagent_tool(&[]); assert_eq!(tool.name, "subagent"); - assert!(tool.description.as_ref().unwrap().contains("Ad-hoc")); - assert!(!tool - .description - .as_ref() - .unwrap() - .contains("Available subrecipes")); + let desc = tool.description.as_ref().unwrap(); + assert!(desc.contains("Ad-hoc")); + assert!(desc.contains("Built-in subrecipes:")); + assert!(desc.contains("investigator")); + assert!(desc.contains("planner")); } #[test] - fn test_create_tool_with_subrecipes() { + fn test_create_tool_with_user_subrecipes() { let sub_recipes = vec![SubRecipe { name: "test_recipe".to_string(), path: "test.yaml".to_string(), @@ -484,12 +515,10 @@ mod tests { }]; let tool = create_subagent_tool(&sub_recipes); - assert!(tool - .description - .as_ref() - .unwrap() - .contains("Available subrecipes")); - assert!(tool.description.as_ref().unwrap().contains("test_recipe")); + let desc = tool.description.as_ref().unwrap(); + assert!(desc.contains("Built-in subrecipes:")); + assert!(desc.contains("User-defined subrecipes:")); + assert!(desc.contains("test_recipe")); } #[test] @@ -538,4 +567,23 @@ mod tests { assert_eq!(params.extensions, Some(vec!["developer".to_string()])); assert!(!params.summary); } + + #[test] + fn test_build_subrecipe_bundled() { + let params = SubagentParams { + instructions: Some("Find the main entry point".to_string()), + subrecipe: Some("investigator".to_string()), + parameters: None, + extensions: None, + settings: None, + summary: true, + }; + + let recipe = build_subrecipe("investigator", ¶ms, &HashMap::new()).unwrap(); + assert!(recipe.instructions.is_some()); + let instructions = recipe.instructions.unwrap(); + assert!(instructions.contains("Investigator")); + assert!(instructions.contains("Additional context from parent agent:")); + assert!(instructions.contains("Find the main entry point")); + } } diff --git a/crates/goose/src/prompt_template.rs b/crates/goose/src/prompt_template.rs index 1bdafebb5bce..100b0941bec7 100644 --- a/crates/goose/src/prompt_template.rs +++ b/crates/goose/src/prompt_template.rs @@ -100,6 +100,29 @@ pub fn render_inline_once( Ok(rendered.trim().to_string()) } +pub fn get_bundled_subrecipe_content(name: &str) -> Option<&'static str> { + let path = format!("subrecipes/{}.yaml", name); + CORE_PROMPTS_DIR + .get_file(&path) + .and_then(|f| std::str::from_utf8(f.contents()).ok()) +} + +pub fn iter_bundled_subrecipes() -> impl Iterator { + CORE_PROMPTS_DIR + .get_dir("subrecipes") + .into_iter() + .flat_map(|dir| dir.files()) + .filter_map(|file| { + let ext = file.path().extension().and_then(|e| e.to_str()); + if ext != Some("yaml") && ext != Some("yml") { + return None; + } + let name = file.path().file_stem()?.to_str()?; + let content = std::str::from_utf8(file.contents()).ok()?; + Some((name, content)) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/goose/src/prompts/subrecipes/investigator.yaml b/crates/goose/src/prompts/subrecipes/investigator.yaml new file mode 100644 index 000000000000..3a934ca753b0 --- /dev/null +++ b/crates/goose/src/prompts/subrecipes/investigator.yaml @@ -0,0 +1,44 @@ +version: "1.0.0" +title: Investigator +description: > + Read-only research into codebases and systems. Use when you need to understand + how something works, trace bugs, or gather information before acting. + +instructions: | + You are Investigator, an expert at reverse-engineering complex software systems. + + Build a complete mental model: identify components, trace connections, understand + data flow, foresee consequences of changes. + + READ-ONLY. Never create, modify, or delete files or system state. Observe, analyze, report. + + Investigate whatever the task requires: + - Code: dependencies, call graphs, data structures, algorithms + - Configuration: env vars, config files, feature flags, secrets + - Infrastructure: containers, CI/CD, deployment pipelines, cloud resources + - Runtime: logs, metrics, error patterns, performance + - Data: schemas, migrations, data flow + - APIs: endpoints, contracts, authentication + + How to work: + - Start with high-value clues, broaden as needed + - Follow imports, trace calls, check configs + - Understand full subsystems, not just the first relevant file + - Consider side effects and ripple effects + - Question why code is written the way it is + - Check git history when context matters: git log, git blame + + Tools: + - developer__analyze for structure and call graphs + - developer__shell with rg for searching, git for history + - developer__text_editor for reading files + + Output a clear report: + - What you found and what it means + - Key locations and their purpose + - How components connect + - Recommendations for the parent agent + + If the next step is implementation, recommend consulting Planner first. + +max_turns: 50 diff --git a/crates/goose/src/prompts/subrecipes/planner.yaml b/crates/goose/src/prompts/subrecipes/planner.yaml new file mode 100644 index 000000000000..cceb123f366b --- /dev/null +++ b/crates/goose/src/prompts/subrecipes/planner.yaml @@ -0,0 +1,62 @@ +version: "1.0.0" +title: Planner +description: > + Strategic planning for multi-step tasks. Use to decompose goals, sequence + steps, identify dependencies, and anticipate failures before executing. + +instructions: | + You are Planner, a strategic reasoning specialist. + + Transform goals into actionable plans. Think through requirements, sequence, + dependencies, and failure modes so execution can proceed without backtracking. + + READ-ONLY. Never create, modify, or delete files or system state. Analyze, reason, plan. + + Adapt depth to complexity—simple tasks need light plans, complex tasks need thorough ones. + + 1. Requirements + - What must be true when done? What does success look like? + - What tests, specs, or acceptance criteria define success? + - What are the non-negotiable constraints? + + 2. Reconnaissance + - What existing patterns, code, or infrastructure is relevant? + - What similar work should be followed? + - If understanding is insufficient, request Investigator first. + + 3. Approach + - What's the simplest path satisfying all requirements? + - What are the major phases? What depends on what? + - What can be done in parallel vs must be sequential? + - What components need creation, modification, or extension? + - What interfaces or contracts between parts? + - What trade-offs are being made? + Prefer existing patterns over novel. Prefer simple over clever. + + 4. Failure Modes + - What might block, hang, or prompt for input? + - What external dependencies might fail? + - What assumptions could be wrong? + - What's the rollback plan? + For software: use screen/tmux/nohup for services (never &), set timeouts + on network ops, use non-interactive flags, verify writes. + + 5. Action Sequence + Ordered list of concrete actions: + - Each step independently verifiable + - Verification checks after steps that could fail silently + - Note reversible vs committed steps + - Flag steps needing human approval + + 6. Completion Criteria + - What tests must pass? + - What commands confirm success? + Stop when requirements are met. + + Output clear, structured prose. Scale detail to task complexity. + + If you lack information needed to plan effectively, stop and ask the parent + agent to run an Investigator with a specific objective. The parent can then + call Planner again with the findings. + +max_turns: 30 diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 243b8731e982..98a88d1dd926 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -69,10 +69,13 @@ pub struct Recipe { pub response: Option, // response configuration including JSON schema #[serde(skip_serializing_if = "Option::is_none")] - pub sub_recipes: Option>, // sub-recipes for the recipe + pub sub_recipes: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub retry: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub max_turns: Option, } #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] @@ -192,15 +195,11 @@ pub struct RecipeParameter { pub options: Option>, } -/// Builder for creating Recipe instances pub struct RecipeBuilder { - // Required fields with default values version: String, title: Option, description: Option, instructions: Option, - - // Optional fields prompt: Option, extensions: Option>, settings: Option, @@ -210,6 +209,7 @@ pub struct RecipeBuilder { response: Option, sub_recipes: Option>, retry: Option, + max_turns: Option, } impl Recipe { @@ -255,6 +255,7 @@ impl Recipe { response: None, sub_recipes: None, retry: None, + max_turns: None, } } @@ -357,6 +358,11 @@ impl RecipeBuilder { self } + pub fn max_turns(mut self, max_turns: u32) -> Self { + self.max_turns = Some(max_turns); + self + } + pub fn build(self) -> Result { let title = self.title.ok_or("Title is required")?; let description = self.description.ok_or("Description is required")?; @@ -379,6 +385,7 @@ impl RecipeBuilder { response: self.response, sub_recipes: self.sub_recipes, retry: self.retry, + max_turns: self.max_turns, }) } } @@ -717,6 +724,7 @@ isGlobal: true"#; response: None, sub_recipes: None, retry: None, + max_turns: None, }; assert!(!recipe.check_for_security_warnings()); diff --git a/crates/goose/tests/subagent_tool_tests.rs b/crates/goose/tests/subagent_tool_tests.rs index 2350d31598bd..803d72c50779 100644 --- a/crates/goose/tests/subagent_tool_tests.rs +++ b/crates/goose/tests/subagent_tool_tests.rs @@ -82,12 +82,11 @@ fn test_adhoc_tool_schema_properties() { let tool = create_subagent_tool(&[]); assert_eq!(tool.name, SUBAGENT_TOOL_NAME); - assert!(tool.description.as_ref().unwrap().contains("Ad-hoc")); - assert!(!tool - .description - .as_ref() - .unwrap() - .contains("Available subrecipes")); + let desc = tool.description.as_ref().unwrap(); + assert!(desc.contains("Ad-hoc")); + assert!(desc.contains("Built-in subrecipes:")); + assert!(desc.contains("investigator")); + assert!(desc.contains("planner")); let props = tool .input_schema diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 03c3c2553db3..9496f46edc9c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4474,6 +4474,12 @@ "type": "string", "nullable": true }, + "max_turns": { + "type": "integer", + "format": "int32", + "nullable": true, + "minimum": 0 + }, "parameters": { "type": "array", "items": { diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 81b9987e32d7..6e8b9803d049 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -631,6 +631,7 @@ export type Recipe = { description: string; extensions?: Array | null; instructions?: string | null; + max_turns?: number | null; parameters?: Array | null; prompt?: string | null; response?: Response | null;