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 45a08e9231..c6c55cdd17 100644 --- a/crates/forge_config/.forge.toml +++ b/crates/forge_config/.forge.toml @@ -28,6 +28,7 @@ tool_timeout_secs = 300 top_k = 30 top_p = 0.8 verify_todos = true +subagents = true [retry] backoff_factor = 2 diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 6b9baaa213..42affcb572 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 subagents: bool, } impl ForgeConfig { diff --git a/crates/forge_repo/src/agent.rs b/crates/forge_repo/src/agent.rs index 2e225e8eb9..abf742a954 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,7 +41,9 @@ 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> { @@ -69,6 +71,7 @@ impl ForgeAgentRepos } async fn init_default(&self) -> anyhow::Result> { + let config = self.infra.get_config()?; parse_agent_iter( [ ("forge", include_str!("agents/forge.md")), @@ -77,10 +80,12 @@ 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> { + let config = self.infra.get_config()?; if !self.infra.exists(dir).await? { return Ok(vec![]); } @@ -94,7 +99,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 +131,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 +139,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 +148,31 @@ 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")); + + if config.subagents { + let insert_index = tools + .iter() + .position(|tool| tool.as_str() == "mcp_*") + .unwrap_or(tools.len()); + tools.insert(insert_index, ToolName::new("task")); + } + + 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 +227,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 +264,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 { 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 { 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 { 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 14fea74699..afe32eb5fc 100644 --- a/crates/forge_repo/src/agents/forge.md +++ b/crates/forge_repo/src/agents/forge.md @@ -134,9 +134,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..d44b699bde --- /dev/null +++ b/crates/forge_repo/src/snapshots/forge_repo__agent__tests__parse_agent_file_subagents_disabled_tools.snap @@ -0,0 +1,6 @@ +--- +source: crates/forge_repo/src/agent.rs +expression: actual.tools +--- +- read +- 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..d4edcd7555 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -280,6 +280,11 @@ } ] }, + "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 + }, "suggest": { "description": "Model and provider configuration used for shell command suggestion\ngeneration.", "anyOf": [