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