1use crate::agent::Agent;
26use crate::capability_types::AgentCapabilityConfig;
27use crate::harness::Harness;
28use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
29use crate::network_access::{self, NetworkAccessList};
30use crate::session::Session;
31use crate::session_file::InitialFile;
32use crate::tool_types::ToolDefinition;
33use crate::typed_id::ModelId;
34
35#[derive(Debug, Clone, Default)]
40pub struct AgentConfigOverlay {
41 pub system_prompt: Option<String>,
43 pub capabilities: Vec<AgentCapabilityConfig>,
45 pub initial_files: Vec<InitialFile>,
47 pub network_access: Option<NetworkAccessList>,
49 pub default_model_id: Option<ModelId>,
51 pub tools: Vec<ToolDefinition>,
53 pub max_iterations: Option<usize>,
55 pub mcp_servers: ScopedMcpServers,
57}
58
59impl AgentConfigOverlay {
60 pub fn merge(self, overlay: AgentConfigOverlay) -> AgentConfigOverlay {
66 let system_prompt = merge_system_prompts(self.system_prompt, overlay.system_prompt);
67 let capabilities = merge_capabilities(&self.capabilities, &overlay.capabilities);
68 let initial_files = merge_initial_files(&self.initial_files, &overlay.initial_files);
69 let network_access = network_access::merge_network_access(
70 self.network_access.as_ref(),
71 overlay.network_access.as_ref(),
72 );
73 let default_model_id = overlay.default_model_id.or(self.default_model_id);
74 let max_iterations = overlay.max_iterations.or(self.max_iterations);
75 let mcp_servers = merge_scoped_mcp_servers(&self.mcp_servers, &overlay.mcp_servers);
76
77 let mut tools = self.tools;
78 tools.extend(overlay.tools);
79
80 AgentConfigOverlay {
81 system_prompt,
82 capabilities,
83 initial_files,
84 network_access,
85 default_model_id,
86 tools,
87 max_iterations,
88 mcp_servers,
89 }
90 }
91
92 pub fn fold(layers: impl IntoIterator<Item = AgentConfigOverlay>) -> AgentConfigOverlay {
97 layers
98 .into_iter()
99 .fold(AgentConfigOverlay::default(), |acc, layer| acc.merge(layer))
100 }
101}
102
103fn merge_system_prompts(base: Option<String>, overlay: Option<String>) -> Option<String> {
109 let base = base.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
110 let overlay = overlay
111 .map(|s| s.trim().to_string())
112 .filter(|s| !s.is_empty());
113
114 match (base, overlay) {
115 (None, None) => None,
116 (Some(b), None) => Some(b),
117 (None, Some(o)) => Some(o),
118 (Some(b), Some(o)) => Some(format!("{b}\n\n{o}")),
119 }
120}
121
122pub fn merge_capabilities(
124 base: &[AgentCapabilityConfig],
125 overlay: &[AgentCapabilityConfig],
126) -> Vec<AgentCapabilityConfig> {
127 let mut merged = base.to_vec();
128
129 for overlay_cap in overlay {
130 if let Some(existing) = merged
131 .iter_mut()
132 .find(|existing| existing.capability_id() == overlay_cap.capability_id())
133 {
134 *existing = overlay_cap.clone();
135 } else {
136 merged.push(overlay_cap.clone());
137 }
138 }
139
140 merged
141}
142
143pub fn merge_initial_files(base: &[InitialFile], overlay: &[InitialFile]) -> Vec<InitialFile> {
145 let mut merged = base.to_vec();
146
147 for overlay_file in overlay {
148 let normalized_path = normalize_initial_file_path(&overlay_file.path);
149 if let Some(existing) = merged
150 .iter_mut()
151 .find(|existing| normalize_initial_file_path(&existing.path) == normalized_path)
152 {
153 *existing = overlay_file.clone();
154 } else {
155 merged.push(overlay_file.clone());
156 }
157 }
158
159 merged
160}
161
162pub fn normalize_initial_file_path(path: &str) -> String {
166 if path == "/workspace" {
167 "/".to_string()
168 } else if let Some(stripped) = path.strip_prefix("/workspace/") {
169 format!("/{}", stripped.trim_start_matches('/'))
170 } else if path.starts_with('/') {
171 path.to_string()
172 } else {
173 format!("/{}", path)
174 }
175}
176
177impl From<&Harness> for AgentConfigOverlay {
182 fn from(h: &Harness) -> Self {
183 AgentConfigOverlay {
184 system_prompt: Some(h.system_prompt.clone()),
185 capabilities: h.capabilities.clone(),
186 initial_files: h.initial_files.clone(),
187 network_access: h.network_access.clone(),
188 default_model_id: h.default_model_id,
189 tools: vec![],
190 max_iterations: None,
191 mcp_servers: h.mcp_servers.clone(),
192 }
193 }
194}
195
196impl From<&Agent> for AgentConfigOverlay {
197 fn from(a: &Agent) -> Self {
198 AgentConfigOverlay {
199 system_prompt: Some(a.system_prompt.clone()),
200 capabilities: a.capabilities.clone(),
201 initial_files: a.initial_files.clone(),
202 network_access: a.network_access.clone(),
203 default_model_id: a.default_model_id,
204 tools: a.tools.clone(),
205 max_iterations: a.max_iterations,
206 mcp_servers: a.mcp_servers.clone(),
207 }
208 }
209}
210
211impl From<&Session> for AgentConfigOverlay {
212 fn from(s: &Session) -> Self {
213 AgentConfigOverlay {
214 system_prompt: s.system_prompt.clone(),
215 capabilities: s.capabilities.clone(),
216 initial_files: s.initial_files.clone(),
217 network_access: s.network_access.clone(),
218 default_model_id: s.model_id,
219 tools: s.tools.clone(),
220 max_iterations: s.max_iterations,
221 mcp_servers: s.mcp_servers.clone(),
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::capability_types::AgentCapabilityConfig;
230 use crate::mcp_server::ScopedMcpServer;
231 use crate::network_access::NetworkAccessList;
232 use crate::session_file::InitialFile;
233
234 fn make_file(path: &str, content: &str) -> InitialFile {
235 InitialFile {
236 path: path.to_string(),
237 content: content.to_string(),
238 encoding: "text".to_string(),
239 is_readonly: false,
240 }
241 }
242
243 #[test]
244 fn merge_system_prompts_concatenates() {
245 let base = AgentConfigOverlay {
246 system_prompt: Some("Base prompt.".into()),
247 ..Default::default()
248 };
249 let overlay = AgentConfigOverlay {
250 system_prompt: Some("Overlay prompt.".into()),
251 ..Default::default()
252 };
253 let merged = base.merge(overlay);
254 assert_eq!(
255 merged.system_prompt.as_deref(),
256 Some("Base prompt.\n\nOverlay prompt.")
257 );
258 }
259
260 #[test]
261 fn merge_system_prompts_base_only() {
262 let base = AgentConfigOverlay {
263 system_prompt: Some("Base.".into()),
264 ..Default::default()
265 };
266 let overlay = AgentConfigOverlay::default();
267 let merged = base.merge(overlay);
268 assert_eq!(merged.system_prompt.as_deref(), Some("Base."));
269 }
270
271 #[test]
272 fn merge_system_prompts_overlay_only() {
273 let base = AgentConfigOverlay::default();
274 let overlay = AgentConfigOverlay {
275 system_prompt: Some("Overlay.".into()),
276 ..Default::default()
277 };
278 let merged = base.merge(overlay);
279 assert_eq!(merged.system_prompt.as_deref(), Some("Overlay."));
280 }
281
282 #[test]
283 fn merge_system_prompts_both_empty() {
284 let merged = AgentConfigOverlay::default().merge(AgentConfigOverlay::default());
285 assert!(merged.system_prompt.is_none());
286 }
287
288 #[test]
289 fn merge_capabilities_override_by_id() {
290 let base = AgentConfigOverlay {
291 capabilities: vec![
292 AgentCapabilityConfig::new("session_file_system"),
293 AgentCapabilityConfig::with_config(
294 "web_fetch",
295 serde_json::json!({"enable_file_download": true}),
296 ),
297 ],
298 ..Default::default()
299 };
300 let overlay = AgentConfigOverlay {
301 capabilities: vec![
302 AgentCapabilityConfig::with_config(
303 "web_fetch",
304 serde_json::json!({"enable_file_download": false}),
305 ),
306 AgentCapabilityConfig::new("current_time"),
307 ],
308 ..Default::default()
309 };
310 let merged = base.merge(overlay);
311
312 assert_eq!(merged.capabilities.len(), 3);
313 assert_eq!(
314 merged.capabilities[0].capability_id(),
315 "session_file_system"
316 );
317 assert_eq!(merged.capabilities[1].capability_id(), "web_fetch");
318 assert_eq!(
319 merged.capabilities[1],
320 AgentCapabilityConfig::with_config(
321 "web_fetch",
322 serde_json::json!({"enable_file_download": false})
323 )
324 );
325 assert_eq!(merged.capabilities[2].capability_id(), "current_time");
326 }
327
328 #[test]
329 fn merge_initial_files_override_by_path() {
330 let base = AgentConfigOverlay {
331 initial_files: vec![
332 make_file("/workspace/README.md", "parent"),
333 make_file("/workspace/config.txt", "parent-config"),
334 ],
335 ..Default::default()
336 };
337 let overlay = AgentConfigOverlay {
338 initial_files: vec![
339 make_file("README.md", "child"),
340 make_file("/notes.txt", "notes"),
341 ],
342 ..Default::default()
343 };
344 let merged = base.merge(overlay);
345
346 assert_eq!(merged.initial_files.len(), 3);
347 assert_eq!(merged.initial_files[0].content, "child"); assert_eq!(merged.initial_files[1].content, "parent-config"); assert_eq!(merged.initial_files[2].content, "notes"); }
351
352 #[test]
353 fn merge_network_access_narrows() {
354 let base = AgentConfigOverlay {
355 network_access: Some(NetworkAccessList::allow_only([
356 "*.example.com",
357 "*.github.com",
358 ])),
359 ..Default::default()
360 };
361 let overlay = AgentConfigOverlay {
362 network_access: Some(NetworkAccessList::allow_only(["api.example.com"])),
363 ..Default::default()
364 };
365 let merged = base.merge(overlay);
366
367 let na = merged.network_access.unwrap();
368 assert_eq!(na.allowed, vec!["api.example.com".to_string()]);
369 }
370
371 #[test]
372 fn merge_model_overlay_wins() {
373 let base = AgentConfigOverlay {
374 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
375 ..Default::default()
376 };
377 let overlay = AgentConfigOverlay {
378 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(2))),
379 ..Default::default()
380 };
381 let merged = base.merge(overlay);
382 assert_eq!(
383 merged.default_model_id,
384 Some(ModelId::from_uuid(uuid::Uuid::from_u128(2)))
385 );
386 }
387
388 #[test]
389 fn merge_model_inherits_base() {
390 let base = AgentConfigOverlay {
391 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
392 ..Default::default()
393 };
394 let overlay = AgentConfigOverlay::default();
395 let merged = base.merge(overlay);
396 assert_eq!(
397 merged.default_model_id,
398 Some(ModelId::from_uuid(uuid::Uuid::from_u128(1)))
399 );
400 }
401
402 #[test]
403 fn merge_max_iterations_overlay_wins() {
404 let base = AgentConfigOverlay {
405 max_iterations: Some(100),
406 ..Default::default()
407 };
408 let overlay = AgentConfigOverlay {
409 max_iterations: Some(50),
410 ..Default::default()
411 };
412 let merged = base.merge(overlay);
413 assert_eq!(merged.max_iterations, Some(50));
414 }
415
416 #[test]
417 fn merge_max_iterations_inherits_base() {
418 let base = AgentConfigOverlay {
419 max_iterations: Some(100),
420 ..Default::default()
421 };
422 let overlay = AgentConfigOverlay::default();
423 let merged = base.merge(overlay);
424 assert_eq!(merged.max_iterations, Some(100));
425 }
426
427 #[test]
428 fn merge_tools_additive() {
429 use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
430
431 let make_tool = |name: &str| {
432 ToolDefinition::Builtin(BuiltinTool {
433 name: name.to_string(),
434 display_name: None,
435 description: format!("{name} tool"),
436 parameters: serde_json::json!({}),
437 policy: ToolPolicy::Auto,
438 category: None,
439 deferrable: Default::default(),
440 hints: crate::tool_types::ToolHints::default(),
441 full_parameters: None,
442 })
443 };
444
445 let base = AgentConfigOverlay {
446 tools: vec![make_tool("tool_a")],
447 ..Default::default()
448 };
449 let overlay = AgentConfigOverlay {
450 tools: vec![make_tool("tool_b")],
451 ..Default::default()
452 };
453 let merged = base.merge(overlay);
454 assert_eq!(merged.tools.len(), 2);
455 assert_eq!(merged.tools[0].name(), "tool_a");
456 assert_eq!(merged.tools[1].name(), "tool_b");
457 }
458
459 #[test]
460 fn merge_mcp_servers_overlay_wins_by_name() {
461 let mut base_servers = ScopedMcpServers::default();
462 base_servers.insert(
463 "docs".to_string(),
464 ScopedMcpServer {
465 url: "https://base.example.com/mcp".to_string(),
466 ..Default::default()
467 },
468 );
469
470 let mut overlay_servers = ScopedMcpServers::default();
471 overlay_servers.insert(
472 "docs".to_string(),
473 ScopedMcpServer {
474 url: "https://overlay.example.com/mcp".to_string(),
475 ..Default::default()
476 },
477 );
478 overlay_servers.insert(
479 "search".to_string(),
480 ScopedMcpServer {
481 url: "https://search.example.com/mcp".to_string(),
482 ..Default::default()
483 },
484 );
485
486 let merged = AgentConfigOverlay {
487 mcp_servers: base_servers,
488 ..Default::default()
489 }
490 .merge(AgentConfigOverlay {
491 mcp_servers: overlay_servers,
492 ..Default::default()
493 });
494
495 assert_eq!(merged.mcp_servers.len(), 2);
496 assert_eq!(
497 merged
498 .mcp_servers
499 .get("docs")
500 .map(|server| server.url.as_str()),
501 Some("https://overlay.example.com/mcp")
502 );
503 assert_eq!(
504 merged
505 .mcp_servers
506 .get("search")
507 .map(|server| server.url.as_str()),
508 Some("https://search.example.com/mcp")
509 );
510 }
511
512 #[test]
513 fn fold_three_layers() {
514 let harness = AgentConfigOverlay {
515 system_prompt: Some("Harness prompt.".into()),
516 capabilities: vec![AgentCapabilityConfig::new("session_file_system")],
517 initial_files: vec![make_file("/config.txt", "harness")],
518 max_iterations: None,
519 ..Default::default()
520 };
521 let agent = AgentConfigOverlay {
522 system_prompt: Some("Agent prompt.".into()),
523 capabilities: vec![AgentCapabilityConfig::new("current_time")],
524 initial_files: vec![make_file("/config.txt", "agent")],
525 max_iterations: Some(200),
526 ..Default::default()
527 };
528 let session = AgentConfigOverlay {
529 system_prompt: Some("Session prompt.".into()),
530 max_iterations: Some(50),
531 ..Default::default()
532 };
533
534 let effective = AgentConfigOverlay::fold([harness, agent, session]);
535
536 assert_eq!(
537 effective.system_prompt.as_deref(),
538 Some("Harness prompt.\n\nAgent prompt.\n\nSession prompt.")
539 );
540 assert_eq!(effective.capabilities.len(), 2);
541 assert_eq!(effective.initial_files.len(), 1);
542 assert_eq!(effective.initial_files[0].content, "agent");
543 assert_eq!(effective.max_iterations, Some(50));
544 }
545
546 #[test]
547 fn normalize_workspace_prefix() {
548 assert_eq!(
549 normalize_initial_file_path("/workspace/README.md"),
550 "/README.md"
551 );
552 assert_eq!(normalize_initial_file_path("/workspace"), "/");
553 assert_eq!(normalize_initial_file_path("README.md"), "/README.md");
554 assert_eq!(normalize_initial_file_path("/README.md"), "/README.md");
555 }
556}