Skip to content
Open
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
10 changes: 9 additions & 1 deletion crates/forge_app/src/system_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,20 @@ impl<S: SkillFetchService + ShellService> SystemPrompt<S> {
// 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<String> = self
.tool_definitions
.iter()
.map(|def| def.name.to_string())
.collect();
let tool_names: Map<String, Value> = 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 {
Expand Down
1 change: 1 addition & 0 deletions crates/forge_config/.forge.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
146 changes: 135 additions & 11 deletions crates/forge_repo/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -41,34 +41,37 @@ impl<I> ForgeAgentRepository<I> {
}
}

impl<I: FileInfoInfra + EnvironmentInfra + DirectoryReaderInfra> ForgeAgentRepository<I> {
impl<I: FileInfoInfra + EnvironmentInfra<Config = ForgeConfig> + DirectoryReaderInfra>
ForgeAgentRepository<I>
{
/// Load all agent definitions from all available sources with conflict
/// resolution.
async fn load_agents(&self) -> anyhow::Result<Vec<AgentDefinition>> {
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<Vec<AgentDefinition>> {
async fn load_all_agents(&self, config: &ForgeConfig) -> anyhow::Result<Vec<AgentDefinition>> {
// 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
// This gives precedence order: CWD > Global Custom > Built-in
Ok(resolve_agent_conflicts(agents))
}

async fn init_default(&self) -> anyhow::Result<Vec<AgentDefinition>> {
async fn init_default(&self, config: &ForgeConfig) -> anyhow::Result<Vec<AgentDefinition>> {
parse_agent_iter(
[
("forge", include_str!("agents/forge.md")),
Expand All @@ -77,10 +80,15 @@ impl<I: FileInfoInfra + EnvironmentInfra + DirectoryReaderInfra> 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<Vec<AgentDefinition>> {
async fn init_agent_dir(
&self,
dir: &std::path::Path,
config: &ForgeConfig,
) -> anyhow::Result<Vec<AgentDefinition>> {
if !self.infra.exists(dir).await? {
return Ok(vec![]);
}
Expand All @@ -94,7 +102,7 @@ impl<I: FileInfoInfra + EnvironmentInfra + DirectoryReaderInfra> 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
Expand Down Expand Up @@ -126,14 +134,15 @@ fn resolve_agent_conflicts(agents: Vec<AgentDefinition>) -> Vec<AgentDefinition>

fn parse_agent_iter<I, Path: AsRef<str>, Content: AsRef<str>>(
contents: I,
config: &ForgeConfig,
) -> anyhow::Result<Vec<AgentDefinition>>
where
I: Iterator<Item = (Path, Content)>,
{
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);
Expand All @@ -142,6 +151,34 @@ where
Ok(agents)
}

fn apply_subagent_tool_config(
mut agent: AgentDefinition,
config: &ForgeConfig,
) -> Result<AgentDefinition> {
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<AgentDefinition> {
// Parse the frontmatter using gray_matter with type-safe deserialization
Expand Down Expand Up @@ -196,6 +233,8 @@ impl<F: FileInfoInfra + EnvironmentInfra<Config = ForgeConfig> + DirectoryReader

#[cfg(test)]
mod tests {
use forge_domain::AgentId;
use insta::{assert_snapshot, assert_yaml_snapshot};
use pretty_assertions::assert_eq;

use super::*;
Expand Down Expand Up @@ -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}}</{{event.name}}>
<system_date>{{current_date}}</system_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
);
}
}
8 changes: 5 additions & 3 deletions crates/forge_repo/src/agents/forge.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ description: "Hands-on implementation agent that executes software development t
reasoning:
enabled: true
tools:
- task
- sem_search
- fs_search
- read
Expand All @@ -19,6 +18,8 @@ tools:
- skill
- todo_write
- todo_read
- task
- sage
- mcp_*
user_prompt: |-
<{{event.name}}>{{event.value}}</{{event.name}}>
Expand Down Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: crates/forge_repo/src/agent.rs
expression: actual.user_prompt.unwrap().template
---
<{{event.name}}>{{event.value}}</{{event.name}}>
<system_date>{{current_date}}</system_date>
Original file line number Diff line number Diff line change
@@ -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_*
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
source: crates/forge_repo/src/agent.rs
expression: actual.system_prompt.unwrap().template
---
Body keeps {{tool_names.read}} untouched.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: crates/forge_repo/src/agent.rs
expression: actual.tools
---
- read
- sage
- mcp_*
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: crates/forge_repo/src/agent.rs
expression: actual.tools
---
- read
- task
- mcp_*
5 changes: 5 additions & 0 deletions forge.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading