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 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<&HarnessDefinition> for AgentConfigOverlay {
187 fn from(h: &HarnessDefinition) -> 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<&AgentDefinition> for AgentConfigOverlay {
203 fn from(a: &AgentDefinition) -> 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<&ExecutionSession> for AgentConfigOverlay {
219 fn from(s: &ExecutionSession) -> 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::mcp_server::{McpServerAuthMode, ScopedMcpServer};
244 use crate::tool_types::{BuiltinTool, ToolHints, ToolPolicy};
245 use serde_json::json;
246
247 fn file(path: &str, content: &str, readonly: bool) -> InitialFile {
248 InitialFile {
249 path: path.into(),
250 content: content.into(),
251 encoding: "text".into(),
252 is_readonly: readonly,
253 }
254 }
255
256 fn tool(name: &str, description: &str) -> ToolDefinition {
257 ToolDefinition::Builtin(BuiltinTool {
258 name: name.into(),
259 description: description.into(),
260 display_name: Some("Display".into()),
261 parameters: json!({"type":"object","properties":{"path":{"type":"string"}}}),
262 policy: ToolPolicy::Auto,
263 category: None,
264 deferrable: Default::default(),
265 hints: ToolHints::default(),
266 full_parameters: None,
267 })
268 }
269
270 fn server(url: &str, marker: &str) -> ScopedMcpServer {
271 ScopedMcpServer {
272 url: url.into(),
273 headers: [("X-Test".into(), marker.into())].into(),
274 auth_mode: McpServerAuthMode::OAuth,
275 oauth_provider_id: Some(marker.into()),
276 tool_discovery: false,
277 ..Default::default()
278 }
279 }
280
281 fn sample_overlay() -> AgentConfigOverlay {
282 AgentConfigOverlay {
283 system_prompt: Some("Base prompt.".into()),
284 capabilities: vec![AgentCapabilityConfig::with_config(
285 "web_fetch",
286 json!({"download":false}),
287 )],
288 initial_files: vec![file("/config.txt", "config", true)],
289 network_access: Some(NetworkAccessList::allow_only(["api.example.com"])),
290 default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(7))),
291 tools: vec![tool("inspect", "Inspect the workspace")],
292 max_iterations: Some(17),
293 parallel_tool_calls: Some(false),
294 mcp_servers: [(
295 "docs".into(),
296 server("https://docs.example.com/mcp", "docs-provider"),
297 )]
298 .into(),
299 }
300 }
301
302 fn assert_overlay(actual: AgentConfigOverlay, expected: AgentConfigOverlay) {
303 assert_eq!(actual.system_prompt, expected.system_prompt);
304 assert_eq!(actual.capabilities, expected.capabilities);
305 assert_eq!(actual.initial_files, expected.initial_files);
306 assert_eq!(actual.network_access, expected.network_access);
307 assert_eq!(actual.default_model_id, expected.default_model_id);
308 assert_eq!(
309 serde_json::to_value(actual.tools).unwrap(),
310 serde_json::to_value(expected.tools).unwrap()
311 );
312 assert_eq!(actual.max_iterations, expected.max_iterations);
313 assert_eq!(actual.parallel_tool_calls, expected.parallel_tool_calls);
314 assert_eq!(actual.mcp_servers, expected.mcp_servers);
315 }
316
317 #[test]
318 fn merge_prompts_trims_omits_empty_and_preserves_layer_order() {
319 for (base, overlay, expected) in [
320 (None, None, None),
321 (Some(" \n"), Some("\t"), None),
322 (Some(" Base. "), None, Some("Base.")),
323 (None, Some(" Overlay.\n"), Some("Overlay.")),
324 (Some("Base."), Some(" \t"), Some("Base.")),
325 (Some(" \t"), Some("Overlay."), Some("Overlay.")),
326 (
327 Some(" Base. "),
328 Some(" Overlay. "),
329 Some("Base.\n\nOverlay."),
330 ),
331 ] {
332 let result = AgentConfigOverlay {
333 system_prompt: base.map(str::to_owned),
334 ..Default::default()
335 }
336 .merge(AgentConfigOverlay {
337 system_prompt: overlay.map(str::to_owned),
338 ..Default::default()
339 });
340 assert_eq!(
341 result.system_prompt.as_deref(),
342 expected,
343 "{base:?} + {overlay:?}"
344 );
345 }
346 }
347
348 #[test]
349 fn merge_capabilities_replaces_full_config_and_preserves_unrelated_order() {
350 let retained =
351 AgentCapabilityConfig::with_config("session_file_system", json!({"readonly":true}));
352 let old = AgentCapabilityConfig::with_config("web_fetch", json!({"old":true}));
353 let replacement =
354 AgentCapabilityConfig::with_config("web_fetch", json!({"download":false}));
355 let added = AgentCapabilityConfig::with_config("current_time", json!({"zone":"UTC"}));
356 let result = AgentConfigOverlay {
357 capabilities: vec![retained.clone(), old],
358 ..Default::default()
359 }
360 .merge(AgentConfigOverlay {
361 capabilities: vec![
362 AgentCapabilityConfig::new("web_fetch"),
363 added.clone(),
364 replacement.clone(),
365 ],
366 ..Default::default()
367 });
368 assert_eq!(result.capabilities, vec![retained, replacement, added]);
369 }
370
371 #[test]
372 fn merge_initial_files_replaces_full_file_by_normalized_path() {
373 let retained = file("/workspace/config.txt", "parent-config", true);
374 let replacement = InitialFile {
375 path: "README.md".into(),
376 content: "Y2hpbGQ=".into(),
377 encoding: "base64".into(),
378 is_readonly: true,
379 };
380 let added = file("/notes.txt", "notes", false);
381 let result = AgentConfigOverlay {
382 initial_files: vec![
383 file("/workspace/README.md", "parent", false),
384 retained.clone(),
385 ],
386 ..Default::default()
387 }
388 .merge(AgentConfigOverlay {
389 initial_files: vec![replacement.clone(), added.clone()],
390 ..Default::default()
391 });
392 assert_eq!(result.initial_files, vec![replacement, retained, added]);
393 }
394
395 #[test]
396 fn merge_network_access_keeps_intersection_and_both_block_lists() {
397 let result = AgentConfigOverlay {
398 network_access: Some(NetworkAccessList {
399 allowed: vec!["*.example.com".into(), "*.github.com".into()],
400 blocked: vec!["private.example.com".into()],
401 }),
402 ..Default::default()
403 }
404 .merge(AgentConfigOverlay {
405 network_access: Some(NetworkAccessList {
406 allowed: vec![
407 "api.example.com".into(),
408 "private.example.com".into(),
409 "child.example.com".into(),
410 "outside.net".into(),
411 ],
412 blocked: vec!["child.example.com".into(), "private.example.com".into()],
413 }),
414 ..Default::default()
415 });
416 let policy = result.network_access.unwrap();
417 assert_eq!(
418 policy,
419 NetworkAccessList {
420 allowed: vec![
421 "api.example.com".into(),
422 "private.example.com".into(),
423 "child.example.com".into()
424 ],
425 blocked: vec!["private.example.com".into(), "child.example.com".into()],
426 }
427 );
428 assert!(policy.is_url_allowed("https://api.example.com/data"));
429 for url in [
430 "https://private.example.com",
431 "https://child.example.com",
432 "https://outside.net",
433 "https://github.com",
434 ] {
435 assert!(!policy.is_url_allowed(url), "{url}");
436 }
437 }
438
439 #[test]
440 fn merge_scalar_options_inherits_missing_and_keeps_explicit_zero_or_false() {
441 for (base, overlay, expected, base_parallel, overlay_parallel, expected_parallel) in [
443 (None, None, None, None, None, None),
444 (Some(7), None, Some(7), Some(true), None, Some(true)),
445 (None, Some(9), Some(9), None, Some(false), Some(false)),
446 (
447 Some(7),
448 Some(0),
449 Some(0),
450 Some(true),
451 Some(false),
452 Some(false),
453 ),
454 (Some(7), None, Some(7), Some(false), None, Some(false)),
455 ] {
456 let model = |n: u128| ModelId::from_uuid(uuid::Uuid::from_u128(n));
457 let result = AgentConfigOverlay {
458 default_model_id: base.map(model),
459 max_iterations: base.map(|n| n as usize),
460 parallel_tool_calls: base_parallel,
461 ..Default::default()
462 }
463 .merge(AgentConfigOverlay {
464 default_model_id: overlay.map(model),
465 max_iterations: overlay.map(|n| n as usize),
466 parallel_tool_calls: overlay_parallel,
467 ..Default::default()
468 });
469 assert_eq!(result.default_model_id, expected.map(model));
470 assert_eq!(result.max_iterations, expected.map(|n| n as usize));
471 assert_eq!(result.parallel_tool_calls, expected_parallel);
472 }
473 }
474
475 #[test]
476 fn merge_tools_preserves_full_definitions_and_defers_deduplication() {
477 let base = tool("inspect", "base schema owner");
478 let overlay = tool("inspect", "overlay schema owner");
479 let added = tool("search", "new tool");
480 let expected =
481 serde_json::to_value([base.clone(), overlay.clone(), added.clone()]).unwrap();
482 let result = AgentConfigOverlay {
483 tools: vec![base],
484 ..Default::default()
485 }
486 .merge(AgentConfigOverlay {
487 tools: vec![overlay, added],
488 ..Default::default()
489 });
490 assert_eq!(serde_json::to_value(result.tools).unwrap(), expected);
491 }
492
493 #[test]
494 fn merge_mcp_servers_replaces_credentials_and_keeps_unrelated_entries() {
495 let retained = server("https://retained.example.com/mcp", "retained");
496 let replacement = server("https://overlay.example.com/mcp", "new-provider");
497 let added = server("https://search.example.com/mcp", "search-provider");
498 let result = AgentConfigOverlay {
499 mcp_servers: [
500 (
501 "docs".into(),
502 server("https://base.example.com/mcp", "old-provider"),
503 ),
504 ("retained".into(), retained.clone()),
505 ]
506 .into(),
507 ..Default::default()
508 }
509 .merge(AgentConfigOverlay {
510 mcp_servers: [
511 ("docs".into(), replacement.clone()),
512 ("search".into(), added.clone()),
513 ]
514 .into(),
515 ..Default::default()
516 });
517 assert_eq!(
518 result.mcp_servers,
519 [
520 ("docs".into(), replacement),
521 ("search".into(), added),
522 ("retained".into(), retained)
523 ]
524 .into()
525 );
526 }
527
528 #[test]
529 fn fold_three_layers_preserves_every_overlay_field() {
530 let harness = sample_overlay();
531 let mut expected = sample_overlay();
532 expected.system_prompt = Some("Base prompt.\n\nAgent prompt.\n\nSession prompt.".into());
533 let extra_capability =
534 AgentCapabilityConfig::with_config("current_time", json!({"zone":"UTC"}));
535 expected.capabilities.push(extra_capability.clone());
536 expected.initial_files = vec![file("config.txt", "agent", false)];
537 expected.max_iterations = Some(50);
538 let agent = AgentConfigOverlay {
539 system_prompt: Some("Agent prompt.".into()),
540 capabilities: vec![extra_capability],
541 initial_files: vec![file("config.txt", "agent", false)],
542 max_iterations: Some(200),
543 ..Default::default()
544 };
545 let session = AgentConfigOverlay {
546 system_prompt: Some("Session prompt.".into()),
547 max_iterations: Some(50),
548 ..Default::default()
549 };
550 assert_overlay(
551 AgentConfigOverlay::fold([harness, agent, session]),
552 expected,
553 );
554 assert_overlay(AgentConfigOverlay::fold([]), AgentConfigOverlay::default());
555 }
556
557 #[test]
558 fn normalize_workspace_prefix_preserves_other_namespaces() {
559 for (input, expected) in [
560 ("/workspace/README.md", "/README.md"),
561 ("/workspace", "/"),
562 ("README.md", "/README.md"),
563 ("/README.md", "/README.md"),
564 ("/workspace//nested/file", "/nested/file"),
565 ("/workspace/", "/"),
566 ("/workspace-other/file", "/workspace-other/file"),
567 ("", "/"),
568 ] {
569 assert_eq!(normalize_initial_file_path(input), expected, "{input:?}");
570 }
571 }
572
573 #[test]
574 fn harness_projection_preserves_all_supported_overlay_fields() {
575 let mut expected = sample_overlay();
576 expected.tools.clear();
577 expected.max_iterations = None;
578 let harness = HarnessDefinition {
579 name: "harness".into(),
580 system_prompt: expected.system_prompt.clone(),
581 capabilities: expected.capabilities.clone(),
582 initial_files: expected.initial_files.clone(),
583 network_access: expected.network_access.clone(),
584 default_model_id: expected.default_model_id,
585 parallel_tool_calls: expected.parallel_tool_calls,
586 mcp_servers: expected.mcp_servers.clone(),
587 ..Default::default()
588 };
589 assert_overlay(AgentConfigOverlay::from(&harness), expected);
590 }
591
592 #[test]
593 fn agent_projection_preserves_all_overlay_fields() {
594 let expected = sample_overlay();
595 let mut agent =
596 AgentDefinition::new(crate::typed_id::AgentId::new(), "agent", "Base prompt.");
597 agent.capabilities = expected.capabilities.clone();
598 agent.initial_files = expected.initial_files.clone();
599 agent.network_access = expected.network_access.clone();
600 agent.default_model_id = expected.default_model_id;
601 agent.tools = expected.tools.clone();
602 agent.max_iterations = expected.max_iterations;
603 agent.parallel_tool_calls = expected.parallel_tool_calls;
604 agent.mcp_servers = expected.mcp_servers.clone();
605 assert_overlay(AgentConfigOverlay::from(&agent), expected);
606 }
607
608 #[test]
609 fn session_projection_preserves_fields_and_appends_only_nonempty_goal() {
610 for (prompt, goal, expected_prompt) in [
611 (
612 Some(" Session prompt. "),
613 Some(" goal text \n"),
614 Some("Session prompt.\n\n<session-goal>\ngoal text\n</session-goal>"),
615 ),
616 (
617 None,
618 Some("goal"),
619 Some("<session-goal>\ngoal\n</session-goal>"),
620 ),
621 (Some("prompt"), Some(" \t"), Some("prompt")),
622 (None, None, None),
623 ] {
624 let mut expected = sample_overlay();
625 expected.system_prompt = expected_prompt.map(str::to_owned);
626 let mut session = ExecutionSession::new(
627 crate::typed_id::SessionId::new(),
628 crate::typed_id::WorkspaceId::new(),
629 crate::typed_id::HarnessId::new(),
630 );
631 session.system_prompt = prompt.map(str::to_owned);
632 session.goal = goal.map(str::to_owned);
633 session.capabilities = expected.capabilities.clone();
634 session.initial_files = expected.initial_files.clone();
635 session.network_access = expected.network_access.clone();
636 session.model_id = expected.default_model_id;
637 session.tools = expected.tools.clone();
638 session.max_iterations = expected.max_iterations;
639 session.parallel_tool_calls = expected.parallel_tool_calls;
640 session.mcp_servers = expected.mcp_servers.clone();
641 assert_overlay(AgentConfigOverlay::from(&session), expected);
642 }
643 }
644}