Skip to content

Commit fbeea84

Browse files
fix(config): avoid overwriting config defaults from legacy json (tailcallhq#2748)
1 parent 6a36b83 commit fbeea84

5 files changed

Lines changed: 236 additions & 126 deletions

File tree

crates/forge_config/src/config.rs

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,79 +16,104 @@ use crate::{AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, RetryConf
1616
#[setters(strip_option)]
1717
pub struct ForgeConfig {
1818
/// Retry settings applied at the system level to all IO operations.
19+
#[serde(default, skip_serializing_if = "Option::is_none")]
1920
pub retry: Option<RetryConfig>,
2021
/// Maximum number of lines returned by a single file search operation.
21-
pub max_search_lines: usize,
22+
#[serde(default, skip_serializing_if = "Option::is_none")]
23+
pub max_search_lines: Option<usize>,
2224
/// Maximum number of bytes returned by a single file search operation.
23-
pub max_search_result_bytes: usize,
25+
#[serde(default, skip_serializing_if = "Option::is_none")]
26+
pub max_search_result_bytes: Option<usize>,
2427
/// Maximum number of characters returned from a URL fetch.
25-
pub max_fetch_chars: usize,
28+
#[serde(default, skip_serializing_if = "Option::is_none")]
29+
pub max_fetch_chars: Option<usize>,
2630
/// Maximum number of lines captured from the leading portion of shell
2731
/// command output.
28-
pub max_stdout_prefix_lines: usize,
32+
#[serde(default, skip_serializing_if = "Option::is_none")]
33+
pub max_stdout_prefix_lines: Option<usize>,
2934
/// Maximum number of lines captured from the trailing portion of shell
3035
/// command output.
31-
pub max_stdout_suffix_lines: usize,
36+
#[serde(default, skip_serializing_if = "Option::is_none")]
37+
pub max_stdout_suffix_lines: Option<usize>,
3238
/// Maximum number of characters per line in shell command output.
33-
pub max_stdout_line_chars: usize,
39+
#[serde(default, skip_serializing_if = "Option::is_none")]
40+
pub max_stdout_line_chars: Option<usize>,
3441
/// Maximum number of characters per line when reading a file.
35-
pub max_line_chars: usize,
42+
#[serde(default, skip_serializing_if = "Option::is_none")]
43+
pub max_line_chars: Option<usize>,
3644
/// Maximum number of lines read from a file in a single operation.
37-
pub max_read_lines: u64,
45+
#[serde(default, skip_serializing_if = "Option::is_none")]
46+
pub max_read_lines: Option<u64>,
3847
/// Maximum number of files read in a single batch operation.
39-
pub max_file_read_batch_size: usize,
48+
#[serde(default, skip_serializing_if = "Option::is_none")]
49+
pub max_file_read_batch_size: Option<usize>,
4050
/// HTTP client settings including proxy, TLS, and timeout configuration.
51+
#[serde(default, skip_serializing_if = "Option::is_none")]
4152
pub http: Option<HttpConfig>,
4253
/// Maximum file size in bytes permitted for read operations.
43-
pub max_file_size_bytes: u64,
54+
#[serde(default, skip_serializing_if = "Option::is_none")]
55+
pub max_file_size_bytes: Option<u64>,
4456
/// Maximum image file size in bytes permitted for read operations.
45-
pub max_image_size_bytes: u64,
57+
#[serde(default, skip_serializing_if = "Option::is_none")]
58+
pub max_image_size_bytes: Option<u64>,
4659
/// Maximum time in seconds a single tool call may run before being
4760
/// cancelled.
48-
pub tool_timeout_secs: u64,
61+
#[serde(default, skip_serializing_if = "Option::is_none")]
62+
pub tool_timeout_secs: Option<u64>,
4963
/// Whether to automatically open HTML dump files in the browser after
5064
/// creation.
51-
pub auto_open_dump: bool,
65+
#[serde(default, skip_serializing_if = "Option::is_none")]
66+
pub auto_open_dump: Option<bool>,
5267
/// Directory where debug request files are written; disabled when absent.
68+
#[serde(default, skip_serializing_if = "Option::is_none")]
5369
pub debug_requests: Option<PathBuf>,
5470
/// Path to the conversation history file; defaults to the global history
5571
/// location when absent.
72+
#[serde(default, skip_serializing_if = "Option::is_none")]
5673
pub custom_history_path: Option<PathBuf>,
5774
/// Maximum number of conversations shown in the conversation list.
58-
pub max_conversations: usize,
75+
#[serde(default, skip_serializing_if = "Option::is_none")]
76+
pub max_conversations: Option<usize>,
5977
/// Maximum number of candidate results returned from the initial semantic
6078
/// search vector query.
61-
pub max_sem_search_results: usize,
79+
#[serde(default, skip_serializing_if = "Option::is_none")]
80+
pub max_sem_search_results: Option<usize>,
6281
/// Number of top results retained after re-ranking in semantic search.
63-
pub sem_search_top_k: usize,
82+
#[serde(default, skip_serializing_if = "Option::is_none")]
83+
pub sem_search_top_k: Option<usize>,
6484
/// Base URL of the Forge services API used for semantic search and
6585
/// indexing.
66-
#[dummy(expr = "\"https://example.com/api\".to_string()")]
67-
pub services_url: String,
86+
#[serde(default, skip_serializing_if = "Option::is_none")]
87+
#[dummy(expr = "Some(\"https://example.com/api\".to_string())")]
88+
pub services_url: Option<String>,
6889
/// Maximum number of file extensions included in the agent system prompt.
69-
pub max_extensions: usize,
90+
#[serde(default, skip_serializing_if = "Option::is_none")]
91+
pub max_extensions: Option<usize>,
7092
/// Format used when automatically creating a session dump after task
7193
/// completion; disabled when absent.
94+
#[serde(default, skip_serializing_if = "Option::is_none")]
7295
pub auto_dump: Option<AutoDumpFormat>,
7396
/// Maximum number of files read concurrently during batch operations.
74-
pub max_parallel_file_reads: usize,
97+
#[serde(default, skip_serializing_if = "Option::is_none")]
98+
pub max_parallel_file_reads: Option<usize>,
7599
/// Time-to-live in seconds for the cached model API list.
76-
pub model_cache_ttl_secs: u64,
100+
#[serde(default, skip_serializing_if = "Option::is_none")]
101+
pub model_cache_ttl_secs: Option<u64>,
77102
/// Default model and provider configuration used when not overridden by
78-
/// individual agents.
79-
#[serde(default)]
103+
/// individual agents.
104+
#[serde(default, skip_serializing_if = "Option::is_none")]
80105
pub session: Option<ModelConfig>,
81106
/// Model and provider configuration used for commit message generation.
82-
#[serde(default)]
107+
#[serde(default, skip_serializing_if = "Option::is_none")]
83108
pub commit: Option<ModelConfig>,
84109
/// Model and provider configuration used for shell command suggestion
85110
/// generation.
86-
#[serde(default)]
111+
#[serde(default, skip_serializing_if = "Option::is_none")]
87112
pub suggest: Option<ModelConfig>,
88113

89114
// --- Workflow fields ---
90115
/// Configuration for automatic Forge updates.
91-
#[serde(skip_serializing_if = "Option::is_none")]
116+
#[serde(default, skip_serializing_if = "Option::is_none")]
92117
pub updates: Option<Update>,
93118

94119
/// Output randomness for all agents; lower values are deterministic, higher
@@ -127,11 +152,13 @@ pub struct ForgeConfig {
127152

128153
/// Whether restricted mode is active; when enabled, tool execution requires
129154
/// explicit permission grants.
130-
pub restricted: bool,
155+
#[serde(default, skip_serializing_if = "Option::is_none")]
156+
pub restricted: Option<bool>,
131157

132158
/// Whether tool use is supported in the current environment; when false,
133159
/// all tool calls are disabled.
134-
pub tool_supported: bool,
160+
#[serde(default, skip_serializing_if = "Option::is_none")]
161+
pub tool_supported: Option<bool>,
135162
}
136163

137164
#[cfg(test)]

crates/forge_config/src/legacy.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ impl LegacyConfig {
3838
/// Reads the legacy `~/forge/.config.json` file at `path`, parses it, and
3939
/// returns the equivalent TOML representation as a [`String`].
4040
///
41+
/// Because every field in [`ForgeConfig`] is `Option`, fields not covered
42+
/// by the legacy format are `None` and omitted from the serialized TOML,
43+
/// so they cannot overwrite values from lower-priority config layers.
44+
///
4145
/// # Errors
4246
///
4347
/// Returns an error if the file cannot be read, the JSON is invalid, or the
@@ -51,7 +55,7 @@ impl LegacyConfig {
5155
}
5256

5357
/// Converts a [`LegacyConfig`] into the fields of [`ForgeConfig`] that it
54-
/// covers, leaving all other fields at their defaults.
58+
/// covers, leaving all other fields at their defaults (`None`).
5559
fn into_forge_config(self) -> ForgeConfig {
5660
let session = self.provider.as_deref().map(|provider_id| {
5761
let model_id = self.model.get(provider_id).cloned();

crates/forge_config/src/reader.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,43 @@ mod tests {
170170
assert!(actual.is_ok(), "read() failed: {:?}", actual.err());
171171
}
172172

173+
#[test]
174+
fn test_legacy_layer_does_not_overwrite_defaults() {
175+
// Simulate what `read_legacy` does: serialize a ForgeConfig that only
176+
// carries session/commit/suggest (all other fields are None) and layer
177+
// it on top of the embedded defaults. The default values must survive.
178+
let legacy = ForgeConfig {
179+
session: Some(ModelConfig {
180+
provider_id: Some("anthropic".to_string()),
181+
model_id: Some("claude-3".to_string()),
182+
}),
183+
..Default::default()
184+
};
185+
let legacy_toml = toml_edit::ser::to_string_pretty(&legacy).unwrap();
186+
187+
let actual = ConfigReader::default()
188+
.read_defaults()
189+
.read_toml(&legacy_toml)
190+
.build()
191+
.unwrap();
192+
193+
// Session should come from the legacy layer
194+
assert_eq!(
195+
actual.session,
196+
Some(ModelConfig {
197+
provider_id: Some("anthropic".to_string()),
198+
model_id: Some("claude-3".to_string()),
199+
})
200+
);
201+
202+
// Default values from .forge.toml must be retained, not reset to zero
203+
assert_eq!(actual.max_parallel_file_reads, Some(64));
204+
assert_eq!(actual.max_read_lines, Some(2000));
205+
assert_eq!(actual.tool_timeout_secs, Some(300));
206+
assert_eq!(actual.max_search_lines, Some(1000));
207+
assert_eq!(actual.tool_supported, Some(true));
208+
}
209+
173210
#[test]
174211
fn test_read_session_from_env_vars() {
175212
let _guard = EnvGuard::set(&[

crates/forge_infra/src/env.rs

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -135,36 +135,39 @@ fn to_environment(fc: ForgeConfig, cwd: PathBuf) -> Environment {
135135

136136
// --- ForgeConfig-mapped fields ---
137137
retry_config: fc.retry.map(to_retry_config).unwrap_or_default(),
138-
max_search_lines: fc.max_search_lines,
139-
max_search_result_bytes: fc.max_search_result_bytes,
140-
fetch_truncation_limit: fc.max_fetch_chars,
141-
stdout_max_prefix_length: fc.max_stdout_prefix_lines,
142-
stdout_max_suffix_length: fc.max_stdout_suffix_lines,
143-
stdout_max_line_length: fc.max_stdout_line_chars,
144-
max_line_length: fc.max_line_chars,
145-
max_read_size: fc.max_read_lines,
146-
max_file_read_batch_size: fc.max_file_read_batch_size,
138+
max_search_lines: fc.max_search_lines.unwrap_or_default(),
139+
max_search_result_bytes: fc.max_search_result_bytes.unwrap_or_default(),
140+
fetch_truncation_limit: fc.max_fetch_chars.unwrap_or_default(),
141+
stdout_max_prefix_length: fc.max_stdout_prefix_lines.unwrap_or_default(),
142+
stdout_max_suffix_length: fc.max_stdout_suffix_lines.unwrap_or_default(),
143+
stdout_max_line_length: fc.max_stdout_line_chars.unwrap_or_default(),
144+
max_line_length: fc.max_line_chars.unwrap_or_default(),
145+
max_read_size: fc.max_read_lines.unwrap_or_default(),
146+
max_file_read_batch_size: fc.max_file_read_batch_size.unwrap_or_default(),
147147
http: fc.http.map(to_http_config).unwrap_or_default(),
148-
max_file_size: fc.max_file_size_bytes,
149-
max_image_size: fc.max_image_size_bytes,
150-
tool_timeout: fc.tool_timeout_secs,
151-
auto_open_dump: fc.auto_open_dump,
148+
max_file_size: fc.max_file_size_bytes.unwrap_or_default(),
149+
max_image_size: fc.max_image_size_bytes.unwrap_or_default(),
150+
tool_timeout: fc.tool_timeout_secs.unwrap_or_default(),
151+
auto_open_dump: fc.auto_open_dump.unwrap_or_default(),
152152
debug_requests: fc.debug_requests,
153153
custom_history_path: fc.custom_history_path,
154-
max_conversations: fc.max_conversations,
155-
sem_search_limit: fc.max_sem_search_results,
156-
sem_search_top_k: fc.sem_search_top_k,
157-
service_url: Url::parse(fc.services_url.as_str())
158-
.unwrap_or_else(|_| Url::parse("https://api.forgecode.dev").unwrap()),
159-
max_extensions: fc.max_extensions,
154+
max_conversations: fc.max_conversations.unwrap_or_default(),
155+
sem_search_limit: fc.max_sem_search_results.unwrap_or_default(),
156+
sem_search_top_k: fc.sem_search_top_k.unwrap_or_default(),
157+
service_url: fc
158+
.services_url
159+
.as_deref()
160+
.and_then(|s| Url::parse(s).ok())
161+
.unwrap_or_else(|| Url::parse("https://api.forgecode.dev").unwrap()),
162+
max_extensions: fc.max_extensions.unwrap_or_default(),
160163
auto_dump: fc.auto_dump.map(to_auto_dump_format),
161-
parallel_file_reads: fc.max_parallel_file_reads,
162-
model_cache_ttl: fc.model_cache_ttl_secs,
164+
parallel_file_reads: fc.max_parallel_file_reads.unwrap_or_default(),
165+
model_cache_ttl: fc.model_cache_ttl_secs.unwrap_or_default(),
163166
session: fc.session.as_ref().map(to_session_config),
164167
commit: fc.commit.as_ref().map(to_session_config),
165168
suggest: fc.suggest.as_ref().map(to_session_config),
166-
is_restricted: fc.restricted,
167-
tool_supported: fc.tool_supported,
169+
is_restricted: fc.restricted.unwrap_or_default(),
170+
tool_supported: fc.tool_supported.unwrap_or_default(),
168171
temperature: fc
169172
.temperature
170173
.and_then(|v| Temperature::new(v.value() as f32).ok()),
@@ -293,37 +296,37 @@ fn to_forge_config(env: &Environment) -> ForgeConfig {
293296
} else {
294297
Some(from_retry_config(&env.retry_config))
295298
};
296-
fc.max_search_lines = env.max_search_lines;
297-
fc.max_search_result_bytes = env.max_search_result_bytes;
298-
fc.max_fetch_chars = env.fetch_truncation_limit;
299-
fc.max_stdout_prefix_lines = env.stdout_max_prefix_length;
300-
fc.max_stdout_suffix_lines = env.stdout_max_suffix_length;
301-
fc.max_stdout_line_chars = env.stdout_max_line_length;
302-
fc.max_line_chars = env.max_line_length;
303-
fc.max_read_lines = env.max_read_size;
304-
fc.max_file_read_batch_size = env.max_file_read_batch_size;
299+
fc.max_search_lines = Some(env.max_search_lines);
300+
fc.max_search_result_bytes = Some(env.max_search_result_bytes);
301+
fc.max_fetch_chars = Some(env.fetch_truncation_limit);
302+
fc.max_stdout_prefix_lines = Some(env.stdout_max_prefix_length);
303+
fc.max_stdout_suffix_lines = Some(env.stdout_max_suffix_length);
304+
fc.max_stdout_line_chars = Some(env.stdout_max_line_length);
305+
fc.max_line_chars = Some(env.max_line_length);
306+
fc.max_read_lines = Some(env.max_read_size);
307+
fc.max_file_read_batch_size = Some(env.max_file_read_batch_size);
305308
let default_http = HttpConfig::default();
306309
fc.http = if env.http == default_http {
307310
None
308311
} else {
309312
Some(from_http_config(&env.http))
310313
};
311-
fc.max_file_size_bytes = env.max_file_size;
312-
fc.max_image_size_bytes = env.max_image_size;
313-
fc.tool_timeout_secs = env.tool_timeout;
314-
fc.auto_open_dump = env.auto_open_dump;
314+
fc.max_file_size_bytes = Some(env.max_file_size);
315+
fc.max_image_size_bytes = Some(env.max_image_size);
316+
fc.tool_timeout_secs = Some(env.tool_timeout);
317+
fc.auto_open_dump = Some(env.auto_open_dump);
315318
fc.debug_requests = env.debug_requests.clone();
316319
fc.custom_history_path = env.custom_history_path.clone();
317-
fc.max_conversations = env.max_conversations;
318-
fc.max_sem_search_results = env.sem_search_limit;
319-
fc.sem_search_top_k = env.sem_search_top_k;
320-
fc.services_url = env.service_url.to_string();
321-
fc.max_extensions = env.max_extensions;
320+
fc.max_conversations = Some(env.max_conversations);
321+
fc.max_sem_search_results = Some(env.sem_search_limit);
322+
fc.sem_search_top_k = Some(env.sem_search_top_k);
323+
fc.services_url = Some(env.service_url.to_string());
324+
fc.max_extensions = Some(env.max_extensions);
322325
fc.auto_dump = env.auto_dump.as_ref().map(from_auto_dump_format);
323-
fc.max_parallel_file_reads = env.parallel_file_reads;
324-
fc.model_cache_ttl_secs = env.model_cache_ttl;
325-
fc.restricted = env.is_restricted;
326-
fc.tool_supported = env.tool_supported;
326+
fc.max_parallel_file_reads = Some(env.parallel_file_reads);
327+
fc.model_cache_ttl_secs = Some(env.model_cache_ttl);
328+
fc.restricted = Some(env.is_restricted);
329+
fc.tool_supported = Some(env.tool_supported);
327330

328331
// --- Workflow fields ---
329332
fc.temperature = env

0 commit comments

Comments
 (0)