Skip to main content

everruns_core/
config_layer.rs

1// Composable configuration overlay for Harness → Agent → Session merging.
2//
3// See specs/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::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/// 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<&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"); // overridden
356        assert_eq!(merged.initial_files[1].content, "parent-config"); // kept
357        assert_eq!(merged.initial_files[2].content, "notes"); // added
358    }
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        // EVE-598: overlay wins when set, even when it flips the base value.
438        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        // EVE-598: an unset overlay inherits the base preference; an all-unset
452        // fold leaves it None (provider default preserved).
453        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}