1use crate::agent_definition::AgentDefinition;
27use crate::capability_types::AgentCapabilityConfig;
28use crate::harness_definition::HarnessDefinition;
29use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
30use crate::network_access::{self, NetworkAccessList};
31use crate::session::ExecutionSession;
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 selected_environment = overlay
139 .iter()
140 .filter(|capability| is_execution_environment_capability(capability.capability_id()))
141 .count()
142 == 1;
143 let mut merged = if selected_environment {
144 base.iter()
145 .filter(|capability| !is_execution_environment_capability(capability.capability_id()))
146 .cloned()
147 .collect()
148 } else {
149 base.to_vec()
150 };
151
152 for overlay_cap in overlay {
153 if let Some(existing) = merged
154 .iter_mut()
155 .find(|existing| existing.capability_id() == overlay_cap.capability_id())
156 {
157 *existing = overlay_cap.clone();
158 } else {
159 merged.push(overlay_cap.clone());
160 }
161 }
162
163 merged
164}
165
166fn is_execution_environment_capability(id: &str) -> bool {
170 matches!(
171 id,
172 "host_shell"
173 | "bashkit_shell"
174 | "virtual_bash"
175 | "container_sandbox"
176 | "docker_container"
177 | "session_sandbox"
178 | "daytona"
179 | "e2b"
180 )
181}
182
183pub fn merge_initial_files(base: &[InitialFile], overlay: &[InitialFile]) -> Vec<InitialFile> {
185 let mut merged = base.to_vec();
186
187 for overlay_file in overlay {
188 let normalized_path = normalize_initial_file_path(&overlay_file.path);
189 if let Some(existing) = merged
190 .iter_mut()
191 .find(|existing| normalize_initial_file_path(&existing.path) == normalized_path)
192 {
193 *existing = overlay_file.clone();
194 } else {
195 merged.push(overlay_file.clone());
196 }
197 }
198
199 merged
200}
201
202pub fn normalize_initial_file_path(path: &str) -> String {
206 if path == "/workspace" {
207 "/".to_string()
208 } else if let Some(stripped) = path.strip_prefix("/workspace/") {
209 format!("/{}", stripped.trim_start_matches('/'))
210 } else if path.starts_with('/') {
211 path.to_string()
212 } else {
213 format!("/{}", path)
214 }
215}
216
217impl From<&HarnessDefinition> for AgentConfigOverlay {
222 fn from(h: &HarnessDefinition) -> Self {
223 AgentConfigOverlay {
224 system_prompt: h.system_prompt.clone(),
225 capabilities: h.capabilities.clone(),
226 initial_files: h.initial_files.clone(),
227 network_access: h.network_access.clone(),
228 default_model_id: h.default_model_id,
229 tools: vec![],
230 max_iterations: None,
231 parallel_tool_calls: h.parallel_tool_calls,
232 mcp_servers: h.mcp_servers.clone(),
233 }
234 }
235}
236
237impl From<&AgentDefinition> for AgentConfigOverlay {
238 fn from(a: &AgentDefinition) -> Self {
239 AgentConfigOverlay {
240 system_prompt: Some(a.system_prompt.clone()),
241 capabilities: a.capabilities.clone(),
242 initial_files: a.initial_files.clone(),
243 network_access: a.network_access.clone(),
244 default_model_id: a.default_model_id,
245 tools: a.tools.clone(),
246 max_iterations: a.max_iterations,
247 parallel_tool_calls: a.parallel_tool_calls,
248 mcp_servers: a.mcp_servers.clone(),
249 }
250 }
251}
252
253impl From<&ExecutionSession> for AgentConfigOverlay {
254 fn from(s: &ExecutionSession) -> Self {
255 let goal_prompt = s
256 .goal
257 .as_ref()
258 .map(|goal| goal.trim())
259 .filter(|goal| !goal.is_empty())
260 .map(|goal| format!("<session-goal>\n{goal}\n</session-goal>"));
261 AgentConfigOverlay {
262 system_prompt: merge_system_prompts(s.system_prompt.clone(), goal_prompt),
263 capabilities: s.capabilities.clone(),
264 initial_files: s.initial_files.clone(),
265 network_access: s.network_access.clone(),
266 default_model_id: s.model_id,
267 tools: s.tools.clone(),
268 max_iterations: s.max_iterations,
269 parallel_tool_calls: s.parallel_tool_calls,
270 mcp_servers: s.mcp_servers.clone(),
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::mcp_server::{McpServerAuthMode, ScopedMcpServer};
279 use crate::tool_types::{BuiltinTool, ToolHints, ToolPolicy};
280 use serde_json::json;
281
282 fn file(path: &str, content: &str, readonly: bool) -> InitialFile {
283 InitialFile {
284 path: path.into(),
285 content: content.into(),
286 encoding: "text".into(),
287 is_readonly: readonly,
288 }
289 }
290
291 fn tool(name: &str, description: &str) -> ToolDefinition {
292 ToolDefinition::Builtin(BuiltinTool {
293 name: name.into(),
294 description: description.into(),
295 display_name: Some("Display".into()),
296 parameters: json!({"type":"object","properties":{"path":{"type":"string"}}}),
297 policy: ToolPolicy::Auto,
298 category: None,
299 deferrable: Default::default(),
300 hints: ToolHints::default(),
301 full_parameters: None,
302 })
303 }
304
305 fn server(url: &str, marker: &str) -> ScopedMcpServer {
306 ScopedMcpServer {
307 url: url.into(),
308 headers: [("X-Test".into(), marker.into())].into(),
309 auth_mode: McpServerAuthMode::OAuth,
310 oauth_provider_id: Some(marker.into()),
311 tool_discovery: false,
312 ..Default::default()
313 }
314 }
315
316 fn sample_overlay() -> AgentConfigOverlay {
317 AgentConfigOverlay {
318 system_prompt: Some("Base prompt.".into()),
319 capabilities: vec![AgentCapabilityConfig::with_config(
320 "web_fetch",
321 json!({"download":false}),
322 )],
323 initial_files: vec![file("/config.txt", "config", true)],
324 network_access: Some(NetworkAccessList::allow_only(["api.example.com"])),
325 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(7))),
326 tools: vec![tool("inspect", "Inspect the workspace")],
327 max_iterations: Some(17),
328 parallel_tool_calls: Some(false),
329 mcp_servers: [(
330 "docs".into(),
331 server("https://docs.example.com/mcp", "docs-provider"),
332 )]
333 .into(),
334 }
335 }
336
337 fn assert_overlay(actual: AgentConfigOverlay, expected: AgentConfigOverlay) {
338 assert_eq!(actual.system_prompt, expected.system_prompt);
339 assert_eq!(actual.capabilities, expected.capabilities);
340 assert_eq!(actual.initial_files, expected.initial_files);
341 assert_eq!(actual.network_access, expected.network_access);
342 assert_eq!(actual.default_model_id, expected.default_model_id);
343 assert_eq!(
344 serde_json::to_value(actual.tools).unwrap(),
345 serde_json::to_value(expected.tools).unwrap()
346 );
347 assert_eq!(actual.max_iterations, expected.max_iterations);
348 assert_eq!(actual.parallel_tool_calls, expected.parallel_tool_calls);
349 assert_eq!(actual.mcp_servers, expected.mcp_servers);
350 }
351
352 #[test]
353 fn merge_prompts_trims_omits_empty_and_preserves_layer_order() {
354 for (base, overlay, expected) in [
355 (None, None, None),
356 (Some(" \n"), Some("\t"), None),
357 (Some(" Base. "), None, Some("Base.")),
358 (None, Some(" Overlay.\n"), Some("Overlay.")),
359 (Some("Base."), Some(" \t"), Some("Base.")),
360 (Some(" \t"), Some("Overlay."), Some("Overlay.")),
361 (
362 Some(" Base. "),
363 Some(" Overlay. "),
364 Some("Base.\n\nOverlay."),
365 ),
366 ] {
367 let result = AgentConfigOverlay {
368 system_prompt: base.map(str::to_owned),
369 ..Default::default()
370 }
371 .merge(AgentConfigOverlay {
372 system_prompt: overlay.map(str::to_owned),
373 ..Default::default()
374 });
375 assert_eq!(
376 result.system_prompt.as_deref(),
377 expected,
378 "{base:?} + {overlay:?}"
379 );
380 }
381 }
382
383 #[test]
384 fn merge_capabilities_replaces_full_config_and_preserves_unrelated_order() {
385 let retained =
386 AgentCapabilityConfig::with_config("session_file_system", json!({"readonly":true}));
387 let old = AgentCapabilityConfig::with_config("web_fetch", json!({"old":true}));
388 let replacement =
389 AgentCapabilityConfig::with_config("web_fetch", json!({"download":false}));
390 let added = AgentCapabilityConfig::with_config("current_time", json!({"zone":"UTC"}));
391 let result = AgentConfigOverlay {
392 capabilities: vec![retained.clone(), old],
393 ..Default::default()
394 }
395 .merge(AgentConfigOverlay {
396 capabilities: vec![
397 AgentCapabilityConfig::new("web_fetch"),
398 added.clone(),
399 replacement.clone(),
400 ],
401 ..Default::default()
402 });
403 assert_eq!(result.capabilities, vec![retained, replacement, added]);
404 }
405
406 #[test]
407 fn one_selected_environment_displaces_inherited_compute() {
408 let result = merge_capabilities(
409 &[
410 AgentCapabilityConfig::new("current_time"),
411 AgentCapabilityConfig::new("bashkit_shell"),
412 AgentCapabilityConfig::new("daytona"),
413 ],
414 &[AgentCapabilityConfig::with_config(
415 "session_sandbox",
416 json!({"provider":"daytona"}),
417 )],
418 );
419 assert_eq!(
420 result
421 .iter()
422 .map(AgentCapabilityConfig::capability_id)
423 .collect::<Vec<_>>(),
424 vec!["current_time", "session_sandbox"]
425 );
426 }
427
428 #[test]
429 fn legacy_multi_compute_layer_is_not_silently_rewritten() {
430 let result = merge_capabilities(
431 &[AgentCapabilityConfig::new("current_time")],
432 &[
433 AgentCapabilityConfig::new("bashkit_shell"),
434 AgentCapabilityConfig::new("daytona"),
435 ],
436 );
437 assert_eq!(result.len(), 3);
438 }
439
440 #[test]
441 fn merge_initial_files_replaces_full_file_by_normalized_path() {
442 let retained = file("/workspace/config.txt", "parent-config", true);
443 let replacement = InitialFile {
444 path: "README.md".into(),
445 content: "Y2hpbGQ=".into(),
446 encoding: "base64".into(),
447 is_readonly: true,
448 };
449 let added = file("/notes.txt", "notes", false);
450 let result = AgentConfigOverlay {
451 initial_files: vec![
452 file("/workspace/README.md", "parent", false),
453 retained.clone(),
454 ],
455 ..Default::default()
456 }
457 .merge(AgentConfigOverlay {
458 initial_files: vec![replacement.clone(), added.clone()],
459 ..Default::default()
460 });
461 assert_eq!(result.initial_files, vec![replacement, retained, added]);
462 }
463
464 #[test]
465 fn merge_network_access_keeps_intersection_and_both_block_lists() {
466 let result = AgentConfigOverlay {
467 network_access: Some(NetworkAccessList {
468 allowed: vec!["*.example.com".into(), "*.github.com".into()],
469 blocked: vec!["private.example.com".into()],
470 }),
471 ..Default::default()
472 }
473 .merge(AgentConfigOverlay {
474 network_access: Some(NetworkAccessList {
475 allowed: vec![
476 "api.example.com".into(),
477 "private.example.com".into(),
478 "child.example.com".into(),
479 "outside.net".into(),
480 ],
481 blocked: vec!["child.example.com".into(), "private.example.com".into()],
482 }),
483 ..Default::default()
484 });
485 let policy = result.network_access.unwrap();
486 assert_eq!(
487 policy,
488 NetworkAccessList {
489 allowed: vec![
490 "api.example.com".into(),
491 "private.example.com".into(),
492 "child.example.com".into()
493 ],
494 blocked: vec!["private.example.com".into(), "child.example.com".into()],
495 }
496 );
497 assert!(policy.is_url_allowed("https://api.example.com/data"));
498 for url in [
499 "https://private.example.com",
500 "https://child.example.com",
501 "https://outside.net",
502 "https://github.com",
503 ] {
504 assert!(!policy.is_url_allowed(url), "{url}");
505 }
506 }
507
508 #[test]
509 fn merge_scalar_options_inherits_missing_and_keeps_explicit_zero_or_false() {
510 for (base, overlay, expected, base_parallel, overlay_parallel, expected_parallel) in [
512 (None, None, None, None, None, None),
513 (Some(7), None, Some(7), Some(true), None, Some(true)),
514 (None, Some(9), Some(9), None, Some(false), Some(false)),
515 (
516 Some(7),
517 Some(0),
518 Some(0),
519 Some(true),
520 Some(false),
521 Some(false),
522 ),
523 (Some(7), None, Some(7), Some(false), None, Some(false)),
524 ] {
525 let model = |n: u128| ModelId::from_uuid(uuid::Uuid::from_u128(n));
526 let result = AgentConfigOverlay {
527 default_model_id: base.map(model),
528 max_iterations: base.map(|n| n as usize),
529 parallel_tool_calls: base_parallel,
530 ..Default::default()
531 }
532 .merge(AgentConfigOverlay {
533 default_model_id: overlay.map(model),
534 max_iterations: overlay.map(|n| n as usize),
535 parallel_tool_calls: overlay_parallel,
536 ..Default::default()
537 });
538 assert_eq!(result.default_model_id, expected.map(model));
539 assert_eq!(result.max_iterations, expected.map(|n| n as usize));
540 assert_eq!(result.parallel_tool_calls, expected_parallel);
541 }
542 }
543
544 #[test]
545 fn merge_tools_preserves_full_definitions_and_defers_deduplication() {
546 let base = tool("inspect", "base schema owner");
547 let overlay = tool("inspect", "overlay schema owner");
548 let added = tool("search", "new tool");
549 let expected =
550 serde_json::to_value([base.clone(), overlay.clone(), added.clone()]).unwrap();
551 let result = AgentConfigOverlay {
552 tools: vec![base],
553 ..Default::default()
554 }
555 .merge(AgentConfigOverlay {
556 tools: vec![overlay, added],
557 ..Default::default()
558 });
559 assert_eq!(serde_json::to_value(result.tools).unwrap(), expected);
560 }
561
562 #[test]
563 fn merge_mcp_servers_replaces_credentials_and_keeps_unrelated_entries() {
564 let retained = server("https://retained.example.com/mcp", "retained");
565 let replacement = server("https://overlay.example.com/mcp", "new-provider");
566 let added = server("https://search.example.com/mcp", "search-provider");
567 let result = AgentConfigOverlay {
568 mcp_servers: [
569 (
570 "docs".into(),
571 server("https://base.example.com/mcp", "old-provider"),
572 ),
573 ("retained".into(), retained.clone()),
574 ]
575 .into(),
576 ..Default::default()
577 }
578 .merge(AgentConfigOverlay {
579 mcp_servers: [
580 ("docs".into(), replacement.clone()),
581 ("search".into(), added.clone()),
582 ]
583 .into(),
584 ..Default::default()
585 });
586 assert_eq!(
587 result.mcp_servers,
588 [
589 ("docs".into(), replacement),
590 ("search".into(), added),
591 ("retained".into(), retained)
592 ]
593 .into()
594 );
595 }
596
597 #[test]
598 fn fold_three_layers_preserves_every_overlay_field() {
599 let harness = sample_overlay();
600 let mut expected = sample_overlay();
601 expected.system_prompt = Some("Base prompt.\n\nAgent prompt.\n\nSession prompt.".into());
602 let extra_capability =
603 AgentCapabilityConfig::with_config("current_time", json!({"zone":"UTC"}));
604 expected.capabilities.push(extra_capability.clone());
605 expected.initial_files = vec![file("config.txt", "agent", false)];
606 expected.max_iterations = Some(50);
607 let agent = AgentConfigOverlay {
608 system_prompt: Some("Agent prompt.".into()),
609 capabilities: vec![extra_capability],
610 initial_files: vec![file("config.txt", "agent", false)],
611 max_iterations: Some(200),
612 ..Default::default()
613 };
614 let session = AgentConfigOverlay {
615 system_prompt: Some("Session prompt.".into()),
616 max_iterations: Some(50),
617 ..Default::default()
618 };
619 assert_overlay(
620 AgentConfigOverlay::fold([harness, agent, session]),
621 expected,
622 );
623 assert_overlay(AgentConfigOverlay::fold([]), AgentConfigOverlay::default());
624 }
625
626 #[test]
627 fn normalize_workspace_prefix_preserves_other_namespaces() {
628 for (input, expected) in [
629 ("/workspace/README.md", "/README.md"),
630 ("/workspace", "/"),
631 ("README.md", "/README.md"),
632 ("/README.md", "/README.md"),
633 ("/workspace//nested/file", "/nested/file"),
634 ("/workspace/", "/"),
635 ("/workspace-other/file", "/workspace-other/file"),
636 ("", "/"),
637 ] {
638 assert_eq!(normalize_initial_file_path(input), expected, "{input:?}");
639 }
640 }
641
642 #[test]
643 fn harness_projection_preserves_all_supported_overlay_fields() {
644 let mut expected = sample_overlay();
645 expected.tools.clear();
646 expected.max_iterations = None;
647 let harness = HarnessDefinition {
648 name: "harness".into(),
649 system_prompt: expected.system_prompt.clone(),
650 capabilities: expected.capabilities.clone(),
651 initial_files: expected.initial_files.clone(),
652 network_access: expected.network_access.clone(),
653 default_model_id: expected.default_model_id,
654 parallel_tool_calls: expected.parallel_tool_calls,
655 mcp_servers: expected.mcp_servers.clone(),
656 ..Default::default()
657 };
658 assert_overlay(AgentConfigOverlay::from(&harness), expected);
659 }
660
661 #[test]
662 fn agent_projection_preserves_all_overlay_fields() {
663 let expected = sample_overlay();
664 let mut agent =
665 AgentDefinition::new(crate::typed_id::AgentId::new(), "agent", "Base prompt.");
666 agent.capabilities = expected.capabilities.clone();
667 agent.initial_files = expected.initial_files.clone();
668 agent.network_access = expected.network_access.clone();
669 agent.default_model_id = expected.default_model_id;
670 agent.tools = expected.tools.clone();
671 agent.max_iterations = expected.max_iterations;
672 agent.parallel_tool_calls = expected.parallel_tool_calls;
673 agent.mcp_servers = expected.mcp_servers.clone();
674 assert_overlay(AgentConfigOverlay::from(&agent), expected);
675 }
676
677 #[test]
678 fn session_projection_preserves_fields_and_appends_only_nonempty_goal() {
679 for (prompt, goal, expected_prompt) in [
680 (
681 Some(" Session prompt. "),
682 Some(" goal text \n"),
683 Some("Session prompt.\n\n<session-goal>\ngoal text\n</session-goal>"),
684 ),
685 (
686 None,
687 Some("goal"),
688 Some("<session-goal>\ngoal\n</session-goal>"),
689 ),
690 (Some("prompt"), Some(" \t"), Some("prompt")),
691 (None, None, None),
692 ] {
693 let mut expected = sample_overlay();
694 expected.system_prompt = expected_prompt.map(str::to_owned);
695 let mut session = ExecutionSession::new(
696 crate::typed_id::SessionId::new(),
697 crate::typed_id::WorkspaceId::new(),
698 crate::typed_id::HarnessId::new(),
699 );
700 session.system_prompt = prompt.map(str::to_owned);
701 session.goal = goal.map(str::to_owned);
702 session.capabilities = expected.capabilities.clone();
703 session.initial_files = expected.initial_files.clone();
704 session.network_access = expected.network_access.clone();
705 session.model_id = expected.default_model_id;
706 session.tools = expected.tools.clone();
707 session.max_iterations = expected.max_iterations;
708 session.parallel_tool_calls = expected.parallel_tool_calls;
709 session.mcp_servers = expected.mcp_servers.clone();
710 assert_overlay(AgentConfigOverlay::from(&session), expected);
711 }
712 }
713}