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 let goal_prompt = s
221 .goal
222 .as_ref()
223 .map(|goal| goal.trim())
224 .filter(|goal| !goal.is_empty())
225 .map(|goal| format!("<session-goal>\n{goal}\n</session-goal>"));
226 AgentConfigOverlay {
227 system_prompt: merge_system_prompts(s.system_prompt.clone(), goal_prompt),
228 capabilities: s.capabilities.clone(),
229 initial_files: s.initial_files.clone(),
230 network_access: s.network_access.clone(),
231 default_model_id: s.model_id,
232 tools: s.tools.clone(),
233 max_iterations: s.max_iterations,
234 parallel_tool_calls: s.parallel_tool_calls,
235 mcp_servers: s.mcp_servers.clone(),
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::capability_types::AgentCapabilityConfig;
244 use crate::mcp_server::ScopedMcpServer;
245 use crate::network_access::NetworkAccessList;
246 use crate::session_file::InitialFile;
247
248 fn make_file(path: &str, content: &str) -> InitialFile {
249 InitialFile {
250 path: path.to_string(),
251 content: content.to_string(),
252 encoding: "text".to_string(),
253 is_readonly: false,
254 }
255 }
256
257 #[test]
258 fn merge_system_prompts_concatenates() {
259 let base = AgentConfigOverlay {
260 system_prompt: Some("Base prompt.".into()),
261 ..Default::default()
262 };
263 let overlay = AgentConfigOverlay {
264 system_prompt: Some("Overlay prompt.".into()),
265 ..Default::default()
266 };
267 let merged = base.merge(overlay);
268 assert_eq!(
269 merged.system_prompt.as_deref(),
270 Some("Base prompt.\n\nOverlay prompt.")
271 );
272 }
273
274 #[test]
275 fn merge_system_prompts_base_only() {
276 let base = AgentConfigOverlay {
277 system_prompt: Some("Base.".into()),
278 ..Default::default()
279 };
280 let overlay = AgentConfigOverlay::default();
281 let merged = base.merge(overlay);
282 assert_eq!(merged.system_prompt.as_deref(), Some("Base."));
283 }
284
285 #[test]
286 fn merge_system_prompts_overlay_only() {
287 let base = AgentConfigOverlay::default();
288 let overlay = AgentConfigOverlay {
289 system_prompt: Some("Overlay.".into()),
290 ..Default::default()
291 };
292 let merged = base.merge(overlay);
293 assert_eq!(merged.system_prompt.as_deref(), Some("Overlay."));
294 }
295
296 #[test]
297 fn merge_system_prompts_both_empty() {
298 let merged = AgentConfigOverlay::default().merge(AgentConfigOverlay::default());
299 assert!(merged.system_prompt.is_none());
300 }
301
302 #[test]
303 fn merge_capabilities_override_by_id() {
304 let base = AgentConfigOverlay {
305 capabilities: vec![
306 AgentCapabilityConfig::new("session_file_system"),
307 AgentCapabilityConfig::with_config(
308 "web_fetch",
309 serde_json::json!({"enable_file_download": true}),
310 ),
311 ],
312 ..Default::default()
313 };
314 let overlay = AgentConfigOverlay {
315 capabilities: vec![
316 AgentCapabilityConfig::with_config(
317 "web_fetch",
318 serde_json::json!({"enable_file_download": false}),
319 ),
320 AgentCapabilityConfig::new("current_time"),
321 ],
322 ..Default::default()
323 };
324 let merged = base.merge(overlay);
325
326 assert_eq!(merged.capabilities.len(), 3);
327 assert_eq!(
328 merged.capabilities[0].capability_id(),
329 "session_file_system"
330 );
331 assert_eq!(merged.capabilities[1].capability_id(), "web_fetch");
332 assert_eq!(
333 merged.capabilities[1],
334 AgentCapabilityConfig::with_config(
335 "web_fetch",
336 serde_json::json!({"enable_file_download": false})
337 )
338 );
339 assert_eq!(merged.capabilities[2].capability_id(), "current_time");
340 }
341
342 #[test]
343 fn merge_initial_files_override_by_path() {
344 let base = AgentConfigOverlay {
345 initial_files: vec![
346 make_file("/workspace/README.md", "parent"),
347 make_file("/workspace/config.txt", "parent-config"),
348 ],
349 ..Default::default()
350 };
351 let overlay = AgentConfigOverlay {
352 initial_files: vec![
353 make_file("README.md", "child"),
354 make_file("/notes.txt", "notes"),
355 ],
356 ..Default::default()
357 };
358 let merged = base.merge(overlay);
359
360 assert_eq!(merged.initial_files.len(), 3);
361 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"); }
365
366 #[test]
367 fn merge_network_access_narrows() {
368 let base = AgentConfigOverlay {
369 network_access: Some(NetworkAccessList::allow_only([
370 "*.example.com",
371 "*.github.com",
372 ])),
373 ..Default::default()
374 };
375 let overlay = AgentConfigOverlay {
376 network_access: Some(NetworkAccessList::allow_only(["api.example.com"])),
377 ..Default::default()
378 };
379 let merged = base.merge(overlay);
380
381 let na = merged.network_access.unwrap();
382 assert_eq!(na.allowed, vec!["api.example.com".to_string()]);
383 }
384
385 #[test]
386 fn merge_model_overlay_wins() {
387 let base = AgentConfigOverlay {
388 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
389 ..Default::default()
390 };
391 let overlay = AgentConfigOverlay {
392 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(2))),
393 ..Default::default()
394 };
395 let merged = base.merge(overlay);
396 assert_eq!(
397 merged.default_model_id,
398 Some(ModelId::from_uuid(uuid::Uuid::from_u128(2)))
399 );
400 }
401
402 #[test]
403 fn merge_model_inherits_base() {
404 let base = AgentConfigOverlay {
405 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
406 ..Default::default()
407 };
408 let overlay = AgentConfigOverlay::default();
409 let merged = base.merge(overlay);
410 assert_eq!(
411 merged.default_model_id,
412 Some(ModelId::from_uuid(uuid::Uuid::from_u128(1)))
413 );
414 }
415
416 #[test]
417 fn merge_max_iterations_overlay_wins() {
418 let base = AgentConfigOverlay {
419 max_iterations: Some(100),
420 ..Default::default()
421 };
422 let overlay = AgentConfigOverlay {
423 max_iterations: Some(50),
424 ..Default::default()
425 };
426 let merged = base.merge(overlay);
427 assert_eq!(merged.max_iterations, Some(50));
428 }
429
430 #[test]
431 fn merge_max_iterations_inherits_base() {
432 let base = AgentConfigOverlay {
433 max_iterations: Some(100),
434 ..Default::default()
435 };
436 let overlay = AgentConfigOverlay::default();
437 let merged = base.merge(overlay);
438 assert_eq!(merged.max_iterations, Some(100));
439 }
440
441 #[test]
442 fn merge_parallel_tool_calls_overlay_wins() {
443 let base = AgentConfigOverlay {
445 parallel_tool_calls: Some(true),
446 ..Default::default()
447 };
448 let overlay = AgentConfigOverlay {
449 parallel_tool_calls: Some(false),
450 ..Default::default()
451 };
452 assert_eq!(base.merge(overlay).parallel_tool_calls, Some(false));
453 }
454
455 #[test]
456 fn merge_parallel_tool_calls_inherits_base() {
457 let base = AgentConfigOverlay {
460 parallel_tool_calls: Some(true),
461 ..Default::default()
462 };
463 let merged = base.merge(AgentConfigOverlay::default());
464 assert_eq!(merged.parallel_tool_calls, Some(true));
465
466 let none_fold = AgentConfigOverlay::default().merge(AgentConfigOverlay::default());
467 assert_eq!(none_fold.parallel_tool_calls, None);
468 }
469
470 #[test]
471 fn merge_tools_additive() {
472 use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
473
474 let make_tool = |name: &str| {
475 ToolDefinition::Builtin(BuiltinTool {
476 name: name.to_string(),
477 display_name: None,
478 description: format!("{name} tool"),
479 parameters: serde_json::json!({}),
480 policy: ToolPolicy::Auto,
481 category: None,
482 deferrable: Default::default(),
483 hints: crate::tool_types::ToolHints::default(),
484 full_parameters: None,
485 })
486 };
487
488 let base = AgentConfigOverlay {
489 tools: vec![make_tool("tool_a")],
490 ..Default::default()
491 };
492 let overlay = AgentConfigOverlay {
493 tools: vec![make_tool("tool_b")],
494 ..Default::default()
495 };
496 let merged = base.merge(overlay);
497 assert_eq!(merged.tools.len(), 2);
498 assert_eq!(merged.tools[0].name(), "tool_a");
499 assert_eq!(merged.tools[1].name(), "tool_b");
500 }
501
502 #[test]
503 fn merge_mcp_servers_overlay_wins_by_name() {
504 let mut base_servers = ScopedMcpServers::default();
505 base_servers.insert(
506 "docs".to_string(),
507 ScopedMcpServer {
508 url: "https://base.example.com/mcp".to_string(),
509 ..Default::default()
510 },
511 );
512
513 let mut overlay_servers = ScopedMcpServers::default();
514 overlay_servers.insert(
515 "docs".to_string(),
516 ScopedMcpServer {
517 url: "https://overlay.example.com/mcp".to_string(),
518 ..Default::default()
519 },
520 );
521 overlay_servers.insert(
522 "search".to_string(),
523 ScopedMcpServer {
524 url: "https://search.example.com/mcp".to_string(),
525 ..Default::default()
526 },
527 );
528
529 let merged = AgentConfigOverlay {
530 mcp_servers: base_servers,
531 ..Default::default()
532 }
533 .merge(AgentConfigOverlay {
534 mcp_servers: overlay_servers,
535 ..Default::default()
536 });
537
538 assert_eq!(merged.mcp_servers.len(), 2);
539 assert_eq!(
540 merged
541 .mcp_servers
542 .get("docs")
543 .map(|server| server.url.as_str()),
544 Some("https://overlay.example.com/mcp")
545 );
546 assert_eq!(
547 merged
548 .mcp_servers
549 .get("search")
550 .map(|server| server.url.as_str()),
551 Some("https://search.example.com/mcp")
552 );
553 }
554
555 #[test]
556 fn fold_three_layers() {
557 let harness = AgentConfigOverlay {
558 system_prompt: Some("Harness prompt.".into()),
559 capabilities: vec![AgentCapabilityConfig::new("session_file_system")],
560 initial_files: vec![make_file("/config.txt", "harness")],
561 max_iterations: None,
562 ..Default::default()
563 };
564 let agent = AgentConfigOverlay {
565 system_prompt: Some("Agent prompt.".into()),
566 capabilities: vec![AgentCapabilityConfig::new("current_time")],
567 initial_files: vec![make_file("/config.txt", "agent")],
568 max_iterations: Some(200),
569 ..Default::default()
570 };
571 let session = AgentConfigOverlay {
572 system_prompt: Some("Session prompt.".into()),
573 max_iterations: Some(50),
574 ..Default::default()
575 };
576
577 let effective = AgentConfigOverlay::fold([harness, agent, session]);
578
579 assert_eq!(
580 effective.system_prompt.as_deref(),
581 Some("Harness prompt.\n\nAgent prompt.\n\nSession prompt.")
582 );
583 assert_eq!(effective.capabilities.len(), 2);
584 assert_eq!(effective.initial_files.len(), 1);
585 assert_eq!(effective.initial_files[0].content, "agent");
586 assert_eq!(effective.max_iterations, Some(50));
587 }
588
589 #[test]
590 fn normalize_workspace_prefix() {
591 assert_eq!(
592 normalize_initial_file_path("/workspace/README.md"),
593 "/README.md"
594 );
595 assert_eq!(normalize_initial_file_path("/workspace"), "/");
596 assert_eq!(normalize_initial_file_path("README.md"), "/README.md");
597 assert_eq!(normalize_initial_file_path("/README.md"), "/README.md");
598 }
599}