Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
192 changes: 177 additions & 15 deletions crates/forge_repo/src/agent.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use std::sync::Arc;

use anyhow::{Context, Result};
use forge_app::{AgentRepository, DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra};
use forge_app::{
AgentRepository, DirectoryReaderInfra, EnvironmentInfra, FileInfoInfra, TemplateEngine,
};
use forge_config::ForgeConfig;
use forge_domain::{ModelId, ProviderId, Template};
use gray_matter::Matter;
use gray_matter::engine::YAML;
use serde::Serialize;

use crate::agent_definition::AgentDefinition;

Expand Down Expand Up @@ -41,34 +44,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 +83,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 +105,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 = parse_agent_file(&content, config)
.with_context(|| format!("Failed to parse agent: {}", path.display()))?;

// Store the file path
Expand Down Expand Up @@ -126,14 +137,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 = parse_agent_file(content.as_ref(), config)
.with_context(|| format!("Failed to parse agent: {}", name.as_ref()))?;

agents.push(agent);
Expand All @@ -142,11 +154,75 @@ where
Ok(agents)
}

#[derive(Serialize)]
struct AgentTemplateContext<'a> {
config: &'a ForgeConfig,
}

fn render_tools_frontmatter_block(content: &str, config: &ForgeConfig) -> Result<String> {
let Some((newline, content)) = content
.strip_prefix("---\r\n")
.map(|content| ("\r\n", content))
.or_else(|| content.strip_prefix("---\n").map(|content| ("\n", content)))
else {
return Ok(content.to_string());
};

let delimiter = format!("{newline}---{newline}");
let (frontmatter, body) = content
.split_once(&delimiter)
.context("Failed to find end of agent frontmatter")?;

let rendered_frontmatter = render_tools_block(frontmatter, config)?;

Ok(format!(
"---{newline}{rendered_frontmatter}{delimiter}{body}"
))
}

fn render_tools_block(frontmatter: &str, config: &ForgeConfig) -> Result<String> {
let lines = frontmatter
.split_inclusive('\n')
.map(ToString::to_string)
.collect::<Vec<_>>();

let Some(start) = lines.iter().position(|line| line.trim_end() == "tools:") else {
return Ok(frontmatter.to_string());
};

let end = lines[start + 1..]
.iter()
.position(|line| {
let trimmed = line.trim_end();

!trimmed.is_empty()
&& !line.starts_with([' ', '\t'])
&& !trimmed.starts_with("#")
&& !trimmed.starts_with("---")
})
.map(|index| start + 1 + index)
.unwrap_or(lines.len());

let rendered_tools_block = TemplateEngine::default().render_template(
Template::new(lines[start..end].join("")),
&AgentTemplateContext { config },
)?;

Ok(format!(
"{}{}{}",
lines[..start].join(""),
rendered_tools_block,
lines[end..].join("")
))
}

/// Parse raw content into an AgentDefinition with YAML frontmatter
fn parse_agent_file(content: &str) -> Result<AgentDefinition> {
fn parse_agent_file(content: &str, config: &ForgeConfig) -> Result<AgentDefinition> {
let rendered_content = render_tools_frontmatter_block(content, config)?;

// Parse the frontmatter using gray_matter with type-safe deserialization
let gray_matter = Matter::<YAML>::new();
let result = gray_matter.parse::<AgentDefinition>(content)?;
let result = gray_matter.parse::<AgentDefinition>(&rendered_content)?;

// Extract the frontmatter
let agent = result
Expand Down Expand Up @@ -196,6 +272,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 All @@ -204,7 +282,7 @@ mod tests {
async fn test_parse_basic_agent() {
let content = forge_test_kit::fixture!("/src/fixtures/agents/basic.md").await;

let actual = parse_agent_file(&content).unwrap();
let actual = parse_agent_file(&content, &ForgeConfig::default()).unwrap();

assert_eq!(actual.id.as_str(), "test-basic");
assert_eq!(actual.title.as_ref().unwrap(), "Basic Test Agent");
Expand All @@ -222,7 +300,7 @@ mod tests {
async fn test_parse_advanced_agent() {
let content = forge_test_kit::fixture!("/src/fixtures/agents/advanced.md").await;

let actual = parse_agent_file(&content).unwrap();
let actual = parse_agent_file(&content, &ForgeConfig::default()).unwrap();

assert_eq!(actual.id.as_str(), "test-advanced");
assert_eq!(actual.title.as_ref().unwrap(), "Advanced Test Agent");
Expand All @@ -231,4 +309,88 @@ 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: "test"
tools:
- read
{{#if config.enable_subagents}}
- task
{{else}}
- sage
{{/if}}
---
Body keeps {{tool_names.read}} untouched.
"#;
let config = ForgeConfig { enable_subagents: true, ..Default::default() };

let actual = parse_agent_file(fixture, &config).unwrap();

assert_eq!(actual.id, AgentId::new("test"));
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: "test"
tools:
- read
{{#if config.enable_subagents}}
- task
{{else}}
- sage
{{/if}}
---
Body keeps {{tool_names.read}} untouched.
"#;
let config = ForgeConfig { enable_subagents: false, ..Default::default() };

let actual = parse_agent_file(fixture, &config).unwrap();

assert_eq!(actual.id, AgentId::new("test"));
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: "test"
tools:
- read
{{#if config.enable_subagents}}
- task
{{else}}
- sage
{{/if}}
user_prompt: |-
<{{event.name}}>{{event.value}}</{{event.name}}>
<system_date>{{current_date}}</system_date>
---
Body keeps {{tool_names.read}} untouched.
"#;
let config = ForgeConfig { enable_subagents: true, ..Default::default() };

let actual = parse_agent_file(fixture, &config).unwrap();

assert_eq!(actual.id, AgentId::new("test"));
assert_snapshot!(
"parse_agent_file_preserves_runtime_user_prompt_variables",
actual.user_prompt.unwrap().template
);
assert_yaml_snapshot!(
"parse_agent_file_preserves_runtime_user_prompt_variables_tools",
actual.tools
);
}
}
11 changes: 8 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,11 @@ tools:
- skill
- todo_write
- todo_read
{{#if config.enable_subagents}}
- task
{{else}}
- sage
{{/if}}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use templating in frontmatter.

- mcp_*
user_prompt: |-
<{{event.name}}>{{event.value}}</{{event.name}}>
Expand Down Expand Up @@ -127,9 +131,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,6 @@
---
source: crates/forge_repo/src/agent.rs
expression: actual.tools
---
- read
- task
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.
Loading
Loading