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// - mcp_servers: overlay overrides base by logical server name (last wins)
24
25use crate::agent::Agent;
26use crate::capability_types::AgentCapabilityConfig;
27use crate::harness::Harness;
28use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
29use crate::network_access::{self, NetworkAccessList};
30use crate::session::Session;
31use crate::session_file::InitialFile;
32use crate::tool_types::ToolDefinition;
33use crate::typed_id::ModelId;
34
35/// A composable configuration layer.
36///
37/// Produced by Harness, Agent, or Session via `From<&T>`. Layers merge
38/// bottom-up into a single effective config for RuntimeAgent building.
39#[derive(Debug, Clone, Default)]
40pub struct AgentConfigOverlay {
41    /// System prompt fragment for this layer.
42    pub system_prompt: Option<String>,
43    /// Capabilities with per-layer configuration.
44    pub capabilities: Vec<AgentCapabilityConfig>,
45    /// Starter files for the session filesystem.
46    pub initial_files: Vec<InitialFile>,
47    /// Network access restrictions.
48    pub network_access: Option<NetworkAccessList>,
49    /// Default model ID for this layer.
50    pub default_model_id: Option<ModelId>,
51    /// Tool definitions (client-side or capability-provided).
52    pub tools: Vec<ToolDefinition>,
53    /// Max iterations per turn.
54    pub max_iterations: Option<usize>,
55    /// Remote MCP servers scoped to this layer.
56    pub mcp_servers: ScopedMcpServers,
57}
58
59impl AgentConfigOverlay {
60    /// Merge an overlay on top of this base layer, producing a new effective layer.
61    ///
62    /// This is the core composition operation. Each field follows its own merge
63    /// semantic (see module-level docs). The result represents the combined
64    /// configuration of both layers.
65    pub fn merge(self, overlay: AgentConfigOverlay) -> AgentConfigOverlay {
66        let system_prompt = merge_system_prompts(self.system_prompt, overlay.system_prompt);
67        let capabilities = merge_capabilities(&self.capabilities, &overlay.capabilities);
68        let initial_files = merge_initial_files(&self.initial_files, &overlay.initial_files);
69        let network_access = network_access::merge_network_access(
70            self.network_access.as_ref(),
71            overlay.network_access.as_ref(),
72        );
73        let default_model_id = overlay.default_model_id.or(self.default_model_id);
74        let max_iterations = overlay.max_iterations.or(self.max_iterations);
75        let mcp_servers = merge_scoped_mcp_servers(&self.mcp_servers, &overlay.mcp_servers);
76
77        let mut tools = self.tools;
78        tools.extend(overlay.tools);
79
80        AgentConfigOverlay {
81            system_prompt,
82            capabilities,
83            initial_files,
84            network_access,
85            default_model_id,
86            tools,
87            max_iterations,
88            mcp_servers,
89        }
90    }
91
92    /// Fold multiple layers bottom-up into a single effective layer.
93    ///
94    /// Layers are applied in order: the first layer is the base, each subsequent
95    /// layer is merged on top.
96    pub fn fold(layers: impl IntoIterator<Item = AgentConfigOverlay>) -> AgentConfigOverlay {
97        layers
98            .into_iter()
99            .fold(AgentConfigOverlay::default(), |acc, layer| acc.merge(layer))
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Merge helpers (shared by harness inheritance and config layer merging)
105// ---------------------------------------------------------------------------
106
107/// Merge two optional system prompt fragments. Base first, overlay appended.
108fn merge_system_prompts(base: Option<String>, overlay: Option<String>) -> Option<String> {
109    let base = base.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
110    let overlay = overlay
111        .map(|s| s.trim().to_string())
112        .filter(|s| !s.is_empty());
113
114    match (base, overlay) {
115        (None, None) => None,
116        (Some(b), None) => Some(b),
117        (None, Some(o)) => Some(o),
118        (Some(b), Some(o)) => Some(format!("{b}\n\n{o}")),
119    }
120}
121
122/// Merge capabilities: overlay overrides base by capability ID (last wins).
123pub fn merge_capabilities(
124    base: &[AgentCapabilityConfig],
125    overlay: &[AgentCapabilityConfig],
126) -> Vec<AgentCapabilityConfig> {
127    let mut merged = base.to_vec();
128
129    for overlay_cap in overlay {
130        if let Some(existing) = merged
131            .iter_mut()
132            .find(|existing| existing.capability_id() == overlay_cap.capability_id())
133        {
134            *existing = overlay_cap.clone();
135        } else {
136            merged.push(overlay_cap.clone());
137        }
138    }
139
140    merged
141}
142
143/// Merge initial files: overlay overrides base by normalized path (last wins).
144pub fn merge_initial_files(base: &[InitialFile], overlay: &[InitialFile]) -> Vec<InitialFile> {
145    let mut merged = base.to_vec();
146
147    for overlay_file in overlay {
148        let normalized_path = normalize_initial_file_path(&overlay_file.path);
149        if let Some(existing) = merged
150            .iter_mut()
151            .find(|existing| normalize_initial_file_path(&existing.path) == normalized_path)
152        {
153            *existing = overlay_file.clone();
154        } else {
155            merged.push(overlay_file.clone());
156        }
157    }
158
159    merged
160}
161
162/// Normalize an initial file path to a canonical form for comparison.
163///
164/// Strips `/workspace/` prefix, ensures leading `/`.
165pub fn normalize_initial_file_path(path: &str) -> String {
166    if path == "/workspace" {
167        "/".to_string()
168    } else if let Some(stripped) = path.strip_prefix("/workspace/") {
169        format!("/{}", stripped.trim_start_matches('/'))
170    } else if path.starts_with('/') {
171        path.to_string()
172    } else {
173        format!("/{}", path)
174    }
175}
176
177// ---------------------------------------------------------------------------
178// From conversions
179// ---------------------------------------------------------------------------
180
181impl From<&Harness> for AgentConfigOverlay {
182    fn from(h: &Harness) -> Self {
183        AgentConfigOverlay {
184            system_prompt: Some(h.system_prompt.clone()),
185            capabilities: h.capabilities.clone(),
186            initial_files: h.initial_files.clone(),
187            network_access: h.network_access.clone(),
188            default_model_id: h.default_model_id,
189            tools: vec![],
190            max_iterations: None,
191            mcp_servers: h.mcp_servers.clone(),
192        }
193    }
194}
195
196impl From<&Agent> for AgentConfigOverlay {
197    fn from(a: &Agent) -> Self {
198        AgentConfigOverlay {
199            system_prompt: Some(a.system_prompt.clone()),
200            capabilities: a.capabilities.clone(),
201            initial_files: a.initial_files.clone(),
202            network_access: a.network_access.clone(),
203            default_model_id: a.default_model_id,
204            tools: a.tools.clone(),
205            max_iterations: a.max_iterations,
206            mcp_servers: a.mcp_servers.clone(),
207        }
208    }
209}
210
211impl From<&Session> for AgentConfigOverlay {
212    fn from(s: &Session) -> Self {
213        AgentConfigOverlay {
214            system_prompt: s.system_prompt.clone(),
215            capabilities: s.capabilities.clone(),
216            initial_files: s.initial_files.clone(),
217            network_access: s.network_access.clone(),
218            default_model_id: s.model_id,
219            tools: s.tools.clone(),
220            max_iterations: s.max_iterations,
221            mcp_servers: s.mcp_servers.clone(),
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::capability_types::AgentCapabilityConfig;
230    use crate::mcp_server::ScopedMcpServer;
231    use crate::network_access::NetworkAccessList;
232    use crate::session_file::InitialFile;
233
234    fn make_file(path: &str, content: &str) -> InitialFile {
235        InitialFile {
236            path: path.to_string(),
237            content: content.to_string(),
238            encoding: "text".to_string(),
239            is_readonly: false,
240        }
241    }
242
243    #[test]
244    fn merge_system_prompts_concatenates() {
245        let base = AgentConfigOverlay {
246            system_prompt: Some("Base prompt.".into()),
247            ..Default::default()
248        };
249        let overlay = AgentConfigOverlay {
250            system_prompt: Some("Overlay prompt.".into()),
251            ..Default::default()
252        };
253        let merged = base.merge(overlay);
254        assert_eq!(
255            merged.system_prompt.as_deref(),
256            Some("Base prompt.\n\nOverlay prompt.")
257        );
258    }
259
260    #[test]
261    fn merge_system_prompts_base_only() {
262        let base = AgentConfigOverlay {
263            system_prompt: Some("Base.".into()),
264            ..Default::default()
265        };
266        let overlay = AgentConfigOverlay::default();
267        let merged = base.merge(overlay);
268        assert_eq!(merged.system_prompt.as_deref(), Some("Base."));
269    }
270
271    #[test]
272    fn merge_system_prompts_overlay_only() {
273        let base = AgentConfigOverlay::default();
274        let overlay = AgentConfigOverlay {
275            system_prompt: Some("Overlay.".into()),
276            ..Default::default()
277        };
278        let merged = base.merge(overlay);
279        assert_eq!(merged.system_prompt.as_deref(), Some("Overlay."));
280    }
281
282    #[test]
283    fn merge_system_prompts_both_empty() {
284        let merged = AgentConfigOverlay::default().merge(AgentConfigOverlay::default());
285        assert!(merged.system_prompt.is_none());
286    }
287
288    #[test]
289    fn merge_capabilities_override_by_id() {
290        let base = AgentConfigOverlay {
291            capabilities: vec![
292                AgentCapabilityConfig::new("session_file_system"),
293                AgentCapabilityConfig::with_config(
294                    "web_fetch",
295                    serde_json::json!({"enable_file_download": true}),
296                ),
297            ],
298            ..Default::default()
299        };
300        let overlay = AgentConfigOverlay {
301            capabilities: vec![
302                AgentCapabilityConfig::with_config(
303                    "web_fetch",
304                    serde_json::json!({"enable_file_download": false}),
305                ),
306                AgentCapabilityConfig::new("current_time"),
307            ],
308            ..Default::default()
309        };
310        let merged = base.merge(overlay);
311
312        assert_eq!(merged.capabilities.len(), 3);
313        assert_eq!(
314            merged.capabilities[0].capability_id(),
315            "session_file_system"
316        );
317        assert_eq!(merged.capabilities[1].capability_id(), "web_fetch");
318        assert_eq!(
319            merged.capabilities[1],
320            AgentCapabilityConfig::with_config(
321                "web_fetch",
322                serde_json::json!({"enable_file_download": false})
323            )
324        );
325        assert_eq!(merged.capabilities[2].capability_id(), "current_time");
326    }
327
328    #[test]
329    fn merge_initial_files_override_by_path() {
330        let base = AgentConfigOverlay {
331            initial_files: vec![
332                make_file("/workspace/README.md", "parent"),
333                make_file("/workspace/config.txt", "parent-config"),
334            ],
335            ..Default::default()
336        };
337        let overlay = AgentConfigOverlay {
338            initial_files: vec![
339                make_file("README.md", "child"),
340                make_file("/notes.txt", "notes"),
341            ],
342            ..Default::default()
343        };
344        let merged = base.merge(overlay);
345
346        assert_eq!(merged.initial_files.len(), 3);
347        assert_eq!(merged.initial_files[0].content, "child"); // overridden
348        assert_eq!(merged.initial_files[1].content, "parent-config"); // kept
349        assert_eq!(merged.initial_files[2].content, "notes"); // added
350    }
351
352    #[test]
353    fn merge_network_access_narrows() {
354        let base = AgentConfigOverlay {
355            network_access: Some(NetworkAccessList::allow_only([
356                "*.example.com",
357                "*.github.com",
358            ])),
359            ..Default::default()
360        };
361        let overlay = AgentConfigOverlay {
362            network_access: Some(NetworkAccessList::allow_only(["api.example.com"])),
363            ..Default::default()
364        };
365        let merged = base.merge(overlay);
366
367        let na = merged.network_access.unwrap();
368        assert_eq!(na.allowed, vec!["api.example.com".to_string()]);
369    }
370
371    #[test]
372    fn merge_model_overlay_wins() {
373        let base = AgentConfigOverlay {
374            default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
375            ..Default::default()
376        };
377        let overlay = AgentConfigOverlay {
378            default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(2))),
379            ..Default::default()
380        };
381        let merged = base.merge(overlay);
382        assert_eq!(
383            merged.default_model_id,
384            Some(ModelId::from_uuid(uuid::Uuid::from_u128(2)))
385        );
386    }
387
388    #[test]
389    fn merge_model_inherits_base() {
390        let base = AgentConfigOverlay {
391            default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
392            ..Default::default()
393        };
394        let overlay = AgentConfigOverlay::default();
395        let merged = base.merge(overlay);
396        assert_eq!(
397            merged.default_model_id,
398            Some(ModelId::from_uuid(uuid::Uuid::from_u128(1)))
399        );
400    }
401
402    #[test]
403    fn merge_max_iterations_overlay_wins() {
404        let base = AgentConfigOverlay {
405            max_iterations: Some(100),
406            ..Default::default()
407        };
408        let overlay = AgentConfigOverlay {
409            max_iterations: Some(50),
410            ..Default::default()
411        };
412        let merged = base.merge(overlay);
413        assert_eq!(merged.max_iterations, Some(50));
414    }
415
416    #[test]
417    fn merge_max_iterations_inherits_base() {
418        let base = AgentConfigOverlay {
419            max_iterations: Some(100),
420            ..Default::default()
421        };
422        let overlay = AgentConfigOverlay::default();
423        let merged = base.merge(overlay);
424        assert_eq!(merged.max_iterations, Some(100));
425    }
426
427    #[test]
428    fn merge_tools_additive() {
429        use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
430
431        let make_tool = |name: &str| {
432            ToolDefinition::Builtin(BuiltinTool {
433                name: name.to_string(),
434                display_name: None,
435                description: format!("{name} tool"),
436                parameters: serde_json::json!({}),
437                policy: ToolPolicy::Auto,
438                category: None,
439                deferrable: Default::default(),
440                hints: crate::tool_types::ToolHints::default(),
441                full_parameters: None,
442            })
443        };
444
445        let base = AgentConfigOverlay {
446            tools: vec![make_tool("tool_a")],
447            ..Default::default()
448        };
449        let overlay = AgentConfigOverlay {
450            tools: vec![make_tool("tool_b")],
451            ..Default::default()
452        };
453        let merged = base.merge(overlay);
454        assert_eq!(merged.tools.len(), 2);
455        assert_eq!(merged.tools[0].name(), "tool_a");
456        assert_eq!(merged.tools[1].name(), "tool_b");
457    }
458
459    #[test]
460    fn merge_mcp_servers_overlay_wins_by_name() {
461        let mut base_servers = ScopedMcpServers::default();
462        base_servers.insert(
463            "docs".to_string(),
464            ScopedMcpServer {
465                url: "https://base.example.com/mcp".to_string(),
466                ..Default::default()
467            },
468        );
469
470        let mut overlay_servers = ScopedMcpServers::default();
471        overlay_servers.insert(
472            "docs".to_string(),
473            ScopedMcpServer {
474                url: "https://overlay.example.com/mcp".to_string(),
475                ..Default::default()
476            },
477        );
478        overlay_servers.insert(
479            "search".to_string(),
480            ScopedMcpServer {
481                url: "https://search.example.com/mcp".to_string(),
482                ..Default::default()
483            },
484        );
485
486        let merged = AgentConfigOverlay {
487            mcp_servers: base_servers,
488            ..Default::default()
489        }
490        .merge(AgentConfigOverlay {
491            mcp_servers: overlay_servers,
492            ..Default::default()
493        });
494
495        assert_eq!(merged.mcp_servers.len(), 2);
496        assert_eq!(
497            merged
498                .mcp_servers
499                .get("docs")
500                .map(|server| server.url.as_str()),
501            Some("https://overlay.example.com/mcp")
502        );
503        assert_eq!(
504            merged
505                .mcp_servers
506                .get("search")
507                .map(|server| server.url.as_str()),
508            Some("https://search.example.com/mcp")
509        );
510    }
511
512    #[test]
513    fn fold_three_layers() {
514        let harness = AgentConfigOverlay {
515            system_prompt: Some("Harness prompt.".into()),
516            capabilities: vec![AgentCapabilityConfig::new("session_file_system")],
517            initial_files: vec![make_file("/config.txt", "harness")],
518            max_iterations: None,
519            ..Default::default()
520        };
521        let agent = AgentConfigOverlay {
522            system_prompt: Some("Agent prompt.".into()),
523            capabilities: vec![AgentCapabilityConfig::new("current_time")],
524            initial_files: vec![make_file("/config.txt", "agent")],
525            max_iterations: Some(200),
526            ..Default::default()
527        };
528        let session = AgentConfigOverlay {
529            system_prompt: Some("Session prompt.".into()),
530            max_iterations: Some(50),
531            ..Default::default()
532        };
533
534        let effective = AgentConfigOverlay::fold([harness, agent, session]);
535
536        assert_eq!(
537            effective.system_prompt.as_deref(),
538            Some("Harness prompt.\n\nAgent prompt.\n\nSession prompt.")
539        );
540        assert_eq!(effective.capabilities.len(), 2);
541        assert_eq!(effective.initial_files.len(), 1);
542        assert_eq!(effective.initial_files[0].content, "agent");
543        assert_eq!(effective.max_iterations, Some(50));
544    }
545
546    #[test]
547    fn normalize_workspace_prefix() {
548        assert_eq!(
549            normalize_initial_file_path("/workspace/README.md"),
550            "/README.md"
551        );
552        assert_eq!(normalize_initial_file_path("/workspace"), "/");
553        assert_eq!(normalize_initial_file_path("README.md"), "/README.md");
554        assert_eq!(normalize_initial_file_path("/README.md"), "/README.md");
555    }
556}