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        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"); // overridden
362        assert_eq!(merged.initial_files[1].content, "parent-config"); // kept
363        assert_eq!(merged.initial_files[2].content, "notes"); // added
364    }
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        // EVE-598: overlay wins when set, even when it flips the base value.
444        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        // EVE-598: an unset overlay inherits the base preference; an all-unset
458        // fold leaves it None (provider default preserved).
459        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}