diff --git a/crates/forge_app/src/system_prompt.rs b/crates/forge_app/src/system_prompt.rs index f4deb24643..a692f55bbf 100644 --- a/crates/forge_app/src/system_prompt.rs +++ b/crates/forge_app/src/system_prompt.rs @@ -97,12 +97,20 @@ impl SystemPrompt { // Fetch extension statistics from git let extensions = self.fetch_extensions(self.max_extensions).await; - // Build tool_names map from all available tools for template rendering + // Build tool_names map filtered to only the tools this agent actually has. + // This allows templates to use {{#if tool_names.task}} to conditionally + // render content based on whether the agent has access to a given tool. + let agent_tool_names: std::collections::HashSet = self + .tool_definitions + .iter() + .map(|def| def.name.to_string()) + .collect(); let tool_names: Map = ToolCatalog::iter() .map(|tool| { let def = tool.definition(); (def.name.to_string(), json!(def.name.to_string())) }) + .filter(|(name, _)| agent_tool_names.contains(name)) .collect(); let ctx = SystemContext { diff --git a/crates/forge_config/.forge.toml b/crates/forge_config/.forge.toml index 1abdbc915c..2e1e390c63 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -27,6 +27,7 @@ tool_timeout_secs = 300 top_k = 30 top_p = 0.8 verify_todos = true +enable_subagents = true [retry] backoff_factor = 2 diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 6b9baaa213..e282c26bd5 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -281,6 +281,13 @@ pub struct ForgeConfig { /// when a task ends and reminds the LLM about them. #[serde(default)] pub verify_todos: bool, + + /// Enables subagent support via the task tool; when true the forge agent + /// gains access to the `task` tool for delegating work to specialised + /// sub-agents, and the `sage` research-only agent tool is removed. + /// When false the `task` tool is disabled and `sage` is available instead. + #[serde(default)] + pub enable_subagents: bool, } impl ForgeConfig { diff --git a/crates/forge_repo/src/agent.rs b/crates/forge_repo/src/agent.rs index 2e225e8eb9..2993a8e8c6 100644 --- a/crates/forge_repo/src/agent.rs +++ b/crates/forge_repo/src/agent.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use forge_app::{AgentRepository, DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra}; use forge_config::ForgeConfig; -use forge_domain::{ModelId, ProviderId, Template}; +use forge_domain::{ModelId, ProviderId, Template, ToolName}; use gray_matter::Matter; use gray_matter::engine::YAML; @@ -41,26 +41,29 @@ impl ForgeAgentRepository { } } -impl ForgeAgentRepository { +impl + DirectoryReaderInfra> + ForgeAgentRepository +{ /// Load all agent definitions from all available sources with conflict /// resolution. async fn load_agents(&self) -> anyhow::Result> { - self.load_all_agents().await + let config = self.infra.get_config()?; + self.load_all_agents(&config).await } /// Load all agent definitions from all available sources - async fn load_all_agents(&self) -> anyhow::Result> { + async fn load_all_agents(&self, config: &ForgeConfig) -> anyhow::Result> { // Load built-in agents (no path - will display as "BUILT IN") - let mut agents = self.init_default().await?; + let mut agents = self.init_default(config).await?; // Load custom agents from global directory let dir = self.infra.get_environment().agent_path(); - let custom_agents = self.init_agent_dir(&dir).await?; + let custom_agents = self.init_agent_dir(&dir, config).await?; agents.extend(custom_agents); // Load custom agents from CWD let dir = self.infra.get_environment().agent_cwd_path(); - let cwd_agents = self.init_agent_dir(&dir).await?; + let cwd_agents = self.init_agent_dir(&dir, config).await?; agents.extend(cwd_agents); // Handle agent ID conflicts by keeping the last occurrence @@ -68,7 +71,7 @@ impl ForgeAgentRepos Ok(resolve_agent_conflicts(agents)) } - async fn init_default(&self) -> anyhow::Result> { + async fn init_default(&self, config: &ForgeConfig) -> anyhow::Result> { parse_agent_iter( [ ("forge", include_str!("agents/forge.md")), @@ -77,10 +80,15 @@ impl ForgeAgentRepos ] .into_iter() .map(|(name, content)| (name.to_string(), content.to_string())), + config, ) } - async fn init_agent_dir(&self, dir: &std::path::Path) -> anyhow::Result> { + async fn init_agent_dir( + &self, + dir: &std::path::Path, + config: &ForgeConfig, + ) -> anyhow::Result> { if !self.infra.exists(dir).await? { return Ok(vec![]); } @@ -94,7 +102,7 @@ impl ForgeAgentRepos let mut agents = Vec::new(); for (path, content) in files { - let mut agent = parse_agent_file(&content) + let mut agent = apply_subagent_tool_config(parse_agent_file(&content)?, config) .with_context(|| format!("Failed to parse agent: {}", path.display()))?; // Store the file path @@ -126,6 +134,7 @@ fn resolve_agent_conflicts(agents: Vec) -> Vec fn parse_agent_iter, Content: AsRef>( contents: I, + config: &ForgeConfig, ) -> anyhow::Result> where I: Iterator, @@ -133,7 +142,7 @@ where let mut agents = vec![]; for (name, content) in contents { - let agent = parse_agent_file(content.as_ref()) + let agent = apply_subagent_tool_config(parse_agent_file(content.as_ref())?, config) .with_context(|| format!("Failed to parse agent: {}", name.as_ref()))?; agents.push(agent); @@ -142,6 +151,34 @@ where Ok(agents) } +fn apply_subagent_tool_config( + mut agent: AgentDefinition, + config: &ForgeConfig, +) -> Result { + if agent.id.as_str() != "forge" { + return Ok(agent); + } + + let Some(tools) = agent.tools.as_mut() else { + return Ok(agent); + }; + + tools.retain(|tool| !matches!(tool.as_str(), "task" | "sage")); + + let delegated_tool = if config.enable_subagents { + ToolName::new("task") + } else { + ToolName::new("sage") + }; + let insert_index = tools + .iter() + .position(|tool| tool.as_str() == "mcp_*") + .unwrap_or(tools.len()); + tools.insert(insert_index, delegated_tool); + + Ok(agent) +} + /// Parse raw content into an AgentDefinition with YAML frontmatter fn parse_agent_file(content: &str) -> Result { // Parse the frontmatter using gray_matter with type-safe deserialization @@ -196,6 +233,8 @@ impl + DirectoryReader #[cfg(test)] mod tests { + use forge_domain::AgentId; + use insta::{assert_snapshot, assert_yaml_snapshot}; use pretty_assertions::assert_eq; use super::*; @@ -231,4 +270,89 @@ mod tests { "An advanced test agent with full configuration" ); } + + #[test] + fn test_parse_agent_file_renders_conditional_frontmatter_when_subagents_enabled() { + let fixture = r#"--- +id: "forge" +tools: + - read + - task + - sage + - mcp_* +--- +Body keeps {{tool_names.read}} untouched. +"#; + let config = ForgeConfig { enable_subagents: true, ..Default::default() }; + + let actual = + apply_subagent_tool_config(parse_agent_file(fixture).unwrap(), &config).unwrap(); + + assert_eq!(actual.id, AgentId::new("forge")); + assert_eq!( + actual.system_prompt.unwrap().template, + "Body keeps {{tool_names.read}} untouched." + ); + assert_yaml_snapshot!("parse_agent_file_subagents_enabled_tools", actual.tools); + } + + #[test] + fn test_parse_agent_file_renders_conditional_frontmatter_when_subagents_disabled() { + let fixture = r#"--- +id: "forge" +tools: + - read + - task + - sage + - mcp_* +--- +Body keeps {{tool_names.read}} untouched. +"#; + let config = ForgeConfig { enable_subagents: false, ..Default::default() }; + + let actual = + apply_subagent_tool_config(parse_agent_file(fixture).unwrap(), &config).unwrap(); + + assert_eq!(actual.id, AgentId::new("forge")); + assert_snapshot!( + "parse_agent_file_subagents_disabled_prompt", + actual.system_prompt.unwrap().template + ); + assert_yaml_snapshot!("parse_agent_file_subagents_disabled_tools", actual.tools); + } + + #[test] + fn test_parse_agent_file_preserves_runtime_user_prompt_variables() { + let fixture = r#"--- +id: "forge" +tools: + - read + - task + - sage + - mcp_* +user_prompt: |- + <{{event.name}}>{{event.value}} + {{current_date}} +--- +Body keeps {{tool_names.read}} untouched. +"#; + + let actual = parse_agent_file(fixture).unwrap(); + let actual_user_prompt = actual.user_prompt.clone().unwrap().template; + + assert_eq!(actual.id, AgentId::new("forge")); + assert_snapshot!( + "parse_agent_file_preserves_runtime_user_prompt_variables", + actual_user_prompt + ); + assert_yaml_snapshot!( + "parse_agent_file_preserves_runtime_user_prompt_variables_tools", + apply_subagent_tool_config( + actual, + &ForgeConfig { enable_subagents: true, ..Default::default() } + ) + .unwrap() + .tools + ); + } } diff --git a/crates/forge_repo/src/agents/forge.md b/crates/forge_repo/src/agents/forge.md index 1bb59d4b4e..7767177994 100644 --- a/crates/forge_repo/src/agents/forge.md +++ b/crates/forge_repo/src/agents/forge.md @@ -5,7 +5,6 @@ description: "Hands-on implementation agent that executes software development t reasoning: enabled: true tools: - - task - sem_search - fs_search - read @@ -19,6 +18,8 @@ tools: - skill - todo_write - todo_read + - task + - sage - mcp_* user_prompt: |- <{{event.name}}>{{event.value}} @@ -127,9 +128,10 @@ Choose tools based on the nature of the task: - **Read**: When you already know the file location and need to examine its contents. - You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. Never use placeholders or guess missing parameters in tool calls. -- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple {{tool_names.task}} tool calls. +{{#if tool_names.task}}- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple {{tool_names.task}} tool calls.{{/if}} - Use specialized tools instead of shell commands when possible. For file operations, use dedicated tools: {{tool_names.read}} for reading files instead of cat/head/tail, {{tool_names.patch}} for editing instead of sed/awk, and {{tool_names.write}} for creating files instead of echo redirection. Reserve {{tool_names.shell}} exclusively for actual system commands and terminal operations that require shell execution. -- When NOT to use the {{tool_names.task}} tool: Do NOT launch a sub-agent for initial codebase exploration or simple lookups. Always use semantic search directly first. +{{#if tool_names.task}}- When NOT to use the {{tool_names.task}} tool: Do NOT launch a sub-agent for initial codebase exploration or simple lookups. Always use semantic search directly first.{{/if}} +{{#if tool_names.sage}}- Use the {{tool_names.sage}} tool for deep research tasks that require comprehensive, read-only investigation across multiple files. Do NOT use it for code modifications — choose direct tools instead.{{/if}} ## Code Output Guidelines: diff --git a/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_preserves_runtime_user_prompt_variables.snap b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_preserves_runtime_user_prompt_variables.snap new file mode 100644 index 0000000000..38a4cbbcfa --- /dev/null +++ b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_preserves_runtime_user_prompt_variables.snap @@ -0,0 +1,6 @@ +--- +source: crates/forge_repo/src/agent.rs +expression: actual.user_prompt.unwrap().template +--- +<{{event.name}}>{{event.value}} +{{current_date}} diff --git a/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_preserves_runtime_user_prompt_variables_tools.snap b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_preserves_runtime_user_prompt_variables_tools.snap new file mode 100644 index 0000000000..8e3ef417e1 --- /dev/null +++ b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_preserves_runtime_user_prompt_variables_tools.snap @@ -0,0 +1,7 @@ +--- +source: crates/forge_repo/src/agent.rs +expression: "apply_subagent_tool_config(actual, &ForgeConfig\n{ enable_subagents: true, ..Default::default() }).unwrap().tools" +--- +- read +- task +- mcp_* diff --git a/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_disabled_prompt.snap b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_disabled_prompt.snap new file mode 100644 index 0000000000..f6f147b36b --- /dev/null +++ b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_disabled_prompt.snap @@ -0,0 +1,5 @@ +--- +source: crates/forge_repo/src/agent.rs +expression: actual.system_prompt.unwrap().template +--- +Body keeps {{tool_names.read}} untouched. diff --git a/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_disabled_tools.snap b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_disabled_tools.snap new file mode 100644 index 0000000000..2127c05e5d --- /dev/null +++ b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_disabled_tools.snap @@ -0,0 +1,7 @@ +--- +source: crates/forge_repo/src/agent.rs +expression: actual.tools +--- +- read +- sage +- mcp_* diff --git a/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_enabled_tools.snap b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_enabled_tools.snap new file mode 100644 index 0000000000..03d7176f33 --- /dev/null +++ b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_enabled_tools.snap @@ -0,0 +1,7 @@ +--- +source: crates/forge_repo/src/agent.rs +expression: actual.tools +--- +- read +- task +- mcp_* diff --git a/forge.schema.json b/forge.schema.json index 43cc190609..d0dd290974 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -66,6 +66,11 @@ "null" ] }, + "enable_subagents": { + "description": "Enables subagent support via the task tool; when true the forge agent\ngains access to the `task` tool for delegating work to specialised\nsub-agents, and the `sage` research-only agent tool is removed.\nWhen false the `task` tool is disabled and `sage` is available instead.", + "type": "boolean", + "default": false + }, "http": { "description": "HTTP client settings including proxy, TLS, and timeout configuration.", "anyOf": [