Skip to content

Commit d1ee066

Browse files
authored
feat(config): support workflow merge (#478)
1 parent fa2c437 commit d1ee066

File tree

19 files changed

+183
-94
lines changed

19 files changed

+183
-94
lines changed

Cargo.lock

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ url = { version = "2.5.4", features = ["serde"] }
8181
uuid = { version = "1.11.0", features = ["v4", "fast-rng", "macro-diagnostics", "serde"] }
8282
whoami = "1.5.2"
8383
blake3 = "1.6.1"
84+
merge = {version = "0.1", features = ["derive"]}
8485

8586
# Internal crates
8687
forge_api = { path = "crates/forge_api" }

README.md

+18-3
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,18 @@ Forge is a comprehensive coding agent that integrates AI capabilities with your
4242
- [ACT Mode (Default)](#act-mode-default)
4343
- [PLAN Mode](#plan-mode)
4444
- [Application Logs](#application-logs)
45+
- [Provider Configuration](#provider-configuration)
46+
- [Supported Providers](#supported-providers)
47+
- [Custom Provider URLs](#custom-provider-urls)
4548
- [Custom Workflows and Multi-Agent Systems](#custom-workflows-and-multi-agent-systems)
4649
- [Creating Custom Workflows](#creating-custom-workflows)
50+
- [Configuration Loading and Precedence](#configuration-loading-and-precedence)
4751
- [Workflow Configuration](#workflow-configuration)
4852
- [Event System](#event-system)
4953
- [Agent Tools](#agent-tools)
5054
- [Agent Configuration Options](#agent-configuration-options)
5155
- [Built-in Templates](#built-in-templates)
5256
- [Example Workflow Configuration](#example-workflow-configuration)
53-
- [Provider Configuration](#provider-configuration)
54-
- [Supported Providers](#supported-providers)
55-
- [Custom Provider URLs](#custom-provider-urls)
5657
- [Why Shell?](#why-shell)
5758
- [Community](#community)
5859
- [Support Us](#support-us)
@@ -309,6 +310,20 @@ You can configure your own workflows by creating a YAML file and pointing to it
309310
forge -w /path/to/your/workflow.yaml
310311
```
311312

313+
### Configuration Loading and Precedence
314+
315+
Forge loads workflow configurations using the following precedence rules:
316+
317+
1. **Explicit Path**: When a path is provided with the `-w` flag, Forge loads that configuration directly without any merging
318+
2. **Project Configuration**: If no explicit path is provided, Forge looks for `forge.yaml` in the current directory
319+
3. **Default Configuration**: An embedded default configuration is always available as a fallback
320+
321+
When a project configuration exists in the current directory, Forge creates a merged configuration where:
322+
- Project settings in `forge.yaml` take precedence over default settings
323+
- Any settings not specified in the project configuration inherit from defaults
324+
325+
This approach allows you to customize only the parts of the configuration you need while inheriting sensible defaults for everything else.
326+
312327
### Workflow Configuration
313328

314329
A workflow consists of agents connected via events. Each agent has specific capabilities and can perform designated tasks.

crates/forge_api/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ forge_infra.workspace = true
1414
forge_snaps.workspace = true
1515
serde_yaml.workspace = true
1616
serde_json.workspace = true
17+
merge.workspace = true
18+
bytes.workspace = true
1719

1820
[dev-dependencies]
1921
tempfile.workspace = true

crates/forge_api/src/loader.rs

+68-22
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,21 @@ use std::sync::Arc;
44
use anyhow::Context;
55
use forge_app::{FsReadService, Infrastructure};
66
use forge_domain::Workflow;
7+
use merge::Merge;
78

89
// Default forge.yaml content embedded in the binary
910
const DEFAULT_FORGE_WORKFLOW: &str = include_str!("../../../forge.default.yaml");
1011

12+
/// Represents the possible sources of a workflow configuration
13+
enum WorkflowSource<'a> {
14+
/// Explicitly provided path
15+
ExplicitPath(&'a Path),
16+
/// Default configuration embedded in the binary
17+
Default,
18+
/// Project-specific configuration in the current directory
19+
ProjectConfig,
20+
}
21+
1122
/// A workflow loader to load the workflow from the given path.
1223
/// It also resolves the internal paths specified in the workflow.
1324
pub struct ForgeLoaderService<F>(Arc<F>);
@@ -19,31 +30,66 @@ impl<F> ForgeLoaderService<F> {
1930
}
2031

2132
impl<F: Infrastructure> ForgeLoaderService<F> {
22-
/// loads the workflow from the given path.
23-
/// Loads the workflow from the given path if provided, otherwise tries to
24-
/// read from current directory's forge.yaml, and falls back to embedded
25-
/// default if neither exists.
33+
/// Loads the workflow from the given path.
34+
/// If a path is provided, uses that workflow directly without merging.
35+
/// If no path is provided:
36+
/// - Loads from current directory's forge.yaml merged with defaults (if
37+
/// forge.yaml exists)
38+
/// - Falls back to embedded default if forge.yaml doesn't exist
39+
///
40+
/// When merging, the project's forge.yaml values take precedence over
41+
/// defaults.
2642
pub async fn load(&self, path: Option<&Path>) -> anyhow::Result<Workflow> {
27-
let content = match path {
28-
Some(path) => String::from_utf8(self.0.file_read_service().read(path).await?.to_vec())?,
29-
None => {
30-
let current_dir_config = Path::new("forge.yaml");
31-
if current_dir_config.exists() {
32-
String::from_utf8(
33-
self.0
34-
.file_read_service()
35-
.read(current_dir_config)
36-
.await?
37-
.to_vec(),
38-
)?
39-
} else {
40-
DEFAULT_FORGE_WORKFLOW.to_string()
41-
}
42-
}
43+
// Determine the workflow source
44+
let source = match path {
45+
Some(path) => WorkflowSource::ExplicitPath(path),
46+
None if Path::new("forge.yaml").exists() => WorkflowSource::ProjectConfig,
47+
None => WorkflowSource::Default,
4348
};
4449

45-
let workflow: Workflow =
46-
serde_yaml::from_str(&content).with_context(|| "Failed to parse workflow")?;
50+
// Load the workflow based on its source
51+
match source {
52+
WorkflowSource::ExplicitPath(path) => self.load_from_explicit_path(path).await,
53+
WorkflowSource::Default => self.load_default_workflow(),
54+
WorkflowSource::ProjectConfig => self.load_with_project_config().await,
55+
}
56+
}
57+
58+
/// Loads a workflow from a specific file path
59+
async fn load_from_explicit_path(&self, path: &Path) -> anyhow::Result<Workflow> {
60+
let content = String::from_utf8(self.0.file_read_service().read(path).await?.to_vec())?;
61+
let workflow: Workflow = serde_yaml::from_str(&content)
62+
.with_context(|| format!("Failed to parse workflow from {}", path.display()))?;
63+
Ok(workflow)
64+
}
65+
66+
/// Loads the default workflow from embedded content
67+
fn load_default_workflow(&self) -> anyhow::Result<Workflow> {
68+
let workflow: Workflow = serde_yaml::from_str(DEFAULT_FORGE_WORKFLOW)
69+
.with_context(|| "Failed to parse default workflow")?;
4770
Ok(workflow)
4871
}
72+
73+
/// Loads workflow by merging project config with default workflow
74+
async fn load_with_project_config(&self) -> anyhow::Result<Workflow> {
75+
let default_workflow = self.load_default_workflow()?;
76+
let project_path = Path::new("forge.yaml");
77+
78+
let project_content = String::from_utf8(
79+
self.0
80+
.file_read_service()
81+
.read(project_path)
82+
.await?
83+
.to_vec(),
84+
)?;
85+
86+
let project_workflow: Workflow = serde_yaml::from_str(&project_content)
87+
.with_context(|| "Failed to parse project workflow")?;
88+
89+
// Merge workflows with project taking precedence
90+
let mut merged_workflow = default_workflow;
91+
merged_workflow.merge(project_workflow);
92+
93+
Ok(merged_workflow)
94+
}
4995
}

crates/forge_app/src/provider.rs

+3-3
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ impl ForgeProviderService {
2929
impl ProviderService for ForgeProviderService {
3030
async fn chat(
3131
&self,
32-
model_id: &ModelId,
32+
model: &ModelId,
3333
request: ChatContext,
3434
) -> ResultStream<ChatCompletionMessage, anyhow::Error> {
3535
self.client
36-
.chat(model_id, request)
36+
.chat(model, request)
3737
.await
38-
.with_context(|| format!("Failed to chat with model: {}", model_id))
38+
.with_context(|| format!("Failed to chat with model: {}", model))
3939
}
4040

4141
async fn models(&self) -> Result<Vec<Model>> {

crates/forge_app/src/tools/fetch.rs

-5
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ impl Default for Fetch {
2828
}
2929
}
3030

31-
fn default_max_length() -> Option<usize> {
32-
Some(5000)
33-
}
34-
3531
fn default_start_index() -> Option<usize> {
3632
Some(0)
3733
}
@@ -45,7 +41,6 @@ pub struct FetchInput {
4541
/// URL to fetch
4642
url: String,
4743
/// Maximum number of characters to return (default: 40000)
48-
#[serde(default = "default_max_length")]
4944
max_length: Option<usize>,
5045
/// Start content from this character index (default: 0),
5146
/// On return output starting at this character index, useful if a previous

crates/forge_domain/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ uuid.workspace = true
2323
async-recursion.workspace = true
2424
tracing.workspace = true
2525
url.workspace = true
26+
merge.workspace = true
2627

2728
[dev-dependencies]
2829
insta.workspace = true

crates/forge_domain/src/agent.rs

+24-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use derive_more::derive::Display;
22
use derive_setters::Setters;
3+
use merge::Merge;
34
use serde::{Deserialize, Serialize};
45

6+
use crate::merge::Key;
57
use crate::template::Template;
68
use crate::{Environment, EventContext, ModelId, ToolName};
79

@@ -48,13 +50,17 @@ fn truth() -> bool {
4850
true
4951
}
5052

51-
#[derive(Debug, Clone, Serialize, Deserialize)]
53+
#[derive(Debug, Clone, Serialize, Deserialize, Merge)]
5254
pub struct Agent {
5355
/// Flag to enable/disable tool support for this agent.
5456
#[serde(default)]
57+
#[merge(strategy = crate::merge::bool::overwrite_false)]
5558
pub tool_supported: bool,
59+
#[merge(strategy = crate::merge::std::overwrite)]
5660
pub id: AgentId,
57-
pub model: ModelId,
61+
62+
#[serde(skip_serializing_if = "Option::is_none")]
63+
pub model: Option<ModelId>,
5864
pub description: Option<String>,
5965
#[serde(skip_serializing_if = "Option::is_none")]
6066
pub system_prompt: Option<Template<SystemContext>>,
@@ -64,27 +70,33 @@ pub struct Agent {
6470
/// When set to true all user events will also contain a suggestions field
6571
/// that is prefilled with the matching information from vector store.
6672
#[serde(skip_serializing_if = "is_true", default)]
73+
#[merge(strategy = crate::merge::bool::overwrite_false)]
6774
pub suggestions: bool,
6875

6976
/// Suggests if the agent needs to maintain its state for the lifetime of
7077
/// the program.
71-
#[serde(skip_serializing_if = "is_true", default = "truth")]
78+
#[serde(skip_serializing_if = "is_true", default)]
79+
#[merge(strategy = crate::merge::bool::overwrite_false)]
7280
pub ephemeral: bool,
7381

7482
/// Flag to enable/disable the agent. When disabled (false), the agent will
7583
/// be completely ignored during orchestration execution.
7684
#[serde(skip_serializing_if = "is_true", default = "truth")]
85+
#[merge(strategy = crate::merge::bool::overwrite_false)]
7786
pub enable: bool,
7887

7988
/// Tools that the agent can use
80-
#[serde(skip_serializing_if = "Vec::is_empty")]
89+
#[serde(skip_serializing_if = "Vec::is_empty", default)]
90+
#[merge(strategy = crate::merge::vec::unify)]
8191
pub tools: Vec<ToolName>,
8292

8393
#[serde(skip_serializing_if = "Vec::is_empty", default)]
94+
#[merge(strategy = crate::merge::vec::append)]
8495
pub transforms: Vec<Transform>,
8596

8697
/// Used to specify the events the agent is interested in
8798
#[serde(skip_serializing_if = "Vec::is_empty", default)]
99+
#[merge(strategy = crate::merge::vec::unify)]
88100
pub subscribe: Vec<String>,
89101

90102
/// Maximum number of turns the agent can take
@@ -97,6 +109,14 @@ pub struct Agent {
97109
pub max_walker_depth: Option<usize>,
98110
}
99111

112+
impl Key for Agent {
113+
type Id = AgentId;
114+
115+
fn key(&self) -> &Self::Id {
116+
&self.id
117+
}
118+
}
119+
100120
/// Transformations that can be applied to the agent's context before sending it
101121
/// upstream to the provider.
102122
#[derive(Debug, Clone, Serialize, Deserialize)]

crates/forge_domain/src/error.rs

+3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub enum Error {
3636

3737
#[error("Conversation not found: {0}")]
3838
ConversationNotFound(ConversationId),
39+
40+
#[error("Missing model for agent: {0}")]
41+
MissingModel(AgentId),
3942
}
4043

4144
pub type Result<A> = std::result::Result<A, Error>;

crates/forge_domain/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod env;
1010
mod error;
1111
mod event;
1212
mod file;
13+
mod merge;
1314
mod message;
1415
mod model;
1516
mod orch;

crates/forge_domain/src/merge.rs

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
pub mod std {
2+
pub fn overwrite<T>(base: &mut T, other: T) {
3+
*base = other;
4+
}
5+
}
6+
pub mod vec {
7+
pub use merge::vec::*;
8+
use merge::Merge;
9+
10+
use super::Key;
11+
pub fn unify<T: PartialEq>(base: &mut Vec<T>, other: Vec<T>) {
12+
for other_item in other {
13+
if !base.contains(&other_item) {
14+
base.push(other_item);
15+
}
16+
}
17+
}
18+
19+
pub fn unify_by_key<T: Merge + Key>(base: &mut Vec<T>, other: Vec<T>) {
20+
for other_agent in other {
21+
if let Some(base_agent) = base.iter_mut().find(|a| a.key() == other_agent.key()) {
22+
// If the base contains an agent with the same Key, merge them
23+
base_agent.merge(other_agent);
24+
} else {
25+
// Otherwise, append the other agent to the base list
26+
base.push(other_agent);
27+
}
28+
}
29+
}
30+
}
31+
32+
pub mod bool {
33+
pub use merge::bool::*;
34+
}
35+
36+
pub trait Key {
37+
type Id: Eq;
38+
fn key(&self) -> &Self::Id;
39+
}

crates/forge_domain/src/orch.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,13 @@ impl<A: App> Orchestrator<A> {
338338
let response = self
339339
.app
340340
.provider_service()
341-
.chat(&agent.model, context.clone())
341+
.chat(
342+
agent
343+
.model
344+
.as_ref()
345+
.ok_or(Error::MissingModel(agent.id.clone()))?,
346+
context.clone(),
347+
)
342348
.await?;
343349
let ChatCompletionResult { tool_calls, content } =
344350
self.collect_messages(&agent.id, response).await?;

0 commit comments

Comments
 (0)