Skip to main content

everruns_core/
config_layer.rs

1// Composable configuration overlay for Harness → Agent → Session merging.
2//
3// See knowledge/foundations/concepts.md#AgentConfigOverlay for design rationale and diagrams.
4//
5// Harness, Agent, and Session share additive fields (system_prompt, capabilities,
6// initial_files, network_access, tools, max_iterations, default_model_id).
7// AgentConfigOverlay extracts this shared shape so merge semantics are defined
8// once and tested in one place.
9//
10// Each entity produces an AgentConfigOverlay via From<&T>. Harness inheritance
11// chains produce one overlay per harness. All overlays fold bottom-up into a
12// single effective config that RuntimeAgentBuilder::from_overlay() resolves
13// into a RuntimeAgent.
14//
15// Merge semantics per field:
16// - system_prompt: base first, overlay appended (concatenate non-empty parts)
17// - capabilities: overlay overrides base by capability ID (last wins)
18// - initial_files: overlay overrides base by normalized path (last wins)
19// - network_access: allowed intersects, blocked unions (can only narrow)
20// - default_model_id: overlay wins if set, else inherit base
21// - tools: additive (overlay appended after base, deduplicated at build time)
22// - max_iterations: overlay wins if set, else inherit base
23// - parallel_tool_calls: overlay wins if set, else inherit base
24// - mcp_servers: overlay overrides base by logical server name (last wins)
25
26use 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/// A composable configuration layer.
37///
38/// Produced by Harness, Agent, or Session via `From<&T>`. Layers merge
39/// bottom-up into a single effective config for RuntimeAgent building.
40#[derive(Debug, Clone, Default)]
41pub struct AgentConfigOverlay {
42    /// System prompt fragment for this layer.
43    pub system_prompt: Option<String>,
44    /// Capabilities with per-layer configuration.
45    pub capabilities: Vec<AgentCapabilityConfig>,
46    /// Starter files for the session filesystem.
47    pub initial_files: Vec<InitialFile>,
48    /// Network access restrictions.
49    pub network_access: Option<NetworkAccessList>,
50    /// Default model ID for this layer.
51    pub default_model_id: Option<ModelId>,
52    /// Tool definitions (client-side or capability-provided).
53    pub tools: Vec<ToolDefinition>,
54    /// Max iterations per turn.
55    pub max_iterations: Option<usize>,
56    /// Request-level parallel tool calling preference (EVE-598).
57    pub parallel_tool_calls: Option<bool>,
58    /// Remote MCP servers scoped to this layer.
59    pub mcp_servers: ScopedMcpServers,
60}
61
62impl AgentConfigOverlay {
63    /// Merge an overlay on top of this base layer, producing a new effective layer.
64    ///
65    /// This is the core composition operation. Each field follows its own merge
66    /// semantic (see module-level docs). The result represents the combined
67    /// configuration of both layers.
68    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    /// Fold multiple layers bottom-up into a single effective layer.
98    ///
99    /// Layers are applied in order: the first layer is the base, each subsequent
100    /// layer is merged on top.
101    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
108// ---------------------------------------------------------------------------
109// Merge helpers (shared by harness inheritance and config layer merging)
110// ---------------------------------------------------------------------------
111
112/// Merge two optional system prompt fragments. Base first, overlay appended.
113fn 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
127/// Merge capabilities: overlay overrides base by capability ID (last wins).
128pub 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
148/// Merge initial files: overlay overrides base by normalized path (last wins).
149pub 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
167/// Normalize an initial file path to a canonical form for comparison.
168///
169/// Strips `/workspace/` prefix, ensures leading `/`.
170pub 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
182// ---------------------------------------------------------------------------
183// From conversions
184// ---------------------------------------------------------------------------
185
186impl 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        // Each tuple is base, overlay, expected. Distinct values expose reversed precedence.
442        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}