use crate::agent::Agent;
use crate::capability_types::AgentCapabilityConfig;
use crate::harness::Harness;
use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
use crate::network_access::{self, NetworkAccessList};
use crate::session::Session;
use crate::session_file::InitialFile;
use crate::tool_types::ToolDefinition;
use crate::typed_id::ModelId;
#[derive(Debug, Clone, Default)]
pub struct AgentConfigOverlay {
pub system_prompt: Option<String>,
pub capabilities: Vec<AgentCapabilityConfig>,
pub initial_files: Vec<InitialFile>,
pub network_access: Option<NetworkAccessList>,
pub default_model_id: Option<ModelId>,
pub tools: Vec<ToolDefinition>,
pub max_iterations: Option<usize>,
pub mcp_servers: ScopedMcpServers,
}
impl AgentConfigOverlay {
pub fn merge(self, overlay: AgentConfigOverlay) -> AgentConfigOverlay {
let system_prompt = merge_system_prompts(self.system_prompt, overlay.system_prompt);
let capabilities = merge_capabilities(&self.capabilities, &overlay.capabilities);
let initial_files = merge_initial_files(&self.initial_files, &overlay.initial_files);
let network_access = network_access::merge_network_access(
self.network_access.as_ref(),
overlay.network_access.as_ref(),
);
let default_model_id = overlay.default_model_id.or(self.default_model_id);
let max_iterations = overlay.max_iterations.or(self.max_iterations);
let mcp_servers = merge_scoped_mcp_servers(&self.mcp_servers, &overlay.mcp_servers);
let mut tools = self.tools;
tools.extend(overlay.tools);
AgentConfigOverlay {
system_prompt,
capabilities,
initial_files,
network_access,
default_model_id,
tools,
max_iterations,
mcp_servers,
}
}
pub fn fold(layers: impl IntoIterator<Item = AgentConfigOverlay>) -> AgentConfigOverlay {
layers
.into_iter()
.fold(AgentConfigOverlay::default(), |acc, layer| acc.merge(layer))
}
}
fn merge_system_prompts(base: Option<String>, overlay: Option<String>) -> Option<String> {
let base = base.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let overlay = overlay
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
match (base, overlay) {
(None, None) => None,
(Some(b), None) => Some(b),
(None, Some(o)) => Some(o),
(Some(b), Some(o)) => Some(format!("{b}\n\n{o}")),
}
}
pub fn merge_capabilities(
base: &[AgentCapabilityConfig],
overlay: &[AgentCapabilityConfig],
) -> Vec<AgentCapabilityConfig> {
let mut merged = base.to_vec();
for overlay_cap in overlay {
if let Some(existing) = merged
.iter_mut()
.find(|existing| existing.capability_id() == overlay_cap.capability_id())
{
*existing = overlay_cap.clone();
} else {
merged.push(overlay_cap.clone());
}
}
merged
}
pub fn merge_initial_files(base: &[InitialFile], overlay: &[InitialFile]) -> Vec<InitialFile> {
let mut merged = base.to_vec();
for overlay_file in overlay {
let normalized_path = normalize_initial_file_path(&overlay_file.path);
if let Some(existing) = merged
.iter_mut()
.find(|existing| normalize_initial_file_path(&existing.path) == normalized_path)
{
*existing = overlay_file.clone();
} else {
merged.push(overlay_file.clone());
}
}
merged
}
pub fn normalize_initial_file_path(path: &str) -> String {
if path == "/workspace" {
"/".to_string()
} else if let Some(stripped) = path.strip_prefix("/workspace/") {
format!("/{}", stripped.trim_start_matches('/'))
} else if path.starts_with('/') {
path.to_string()
} else {
format!("/{}", path)
}
}
impl From<&Harness> for AgentConfigOverlay {
fn from(h: &Harness) -> Self {
AgentConfigOverlay {
system_prompt: Some(h.system_prompt.clone()),
capabilities: h.capabilities.clone(),
initial_files: h.initial_files.clone(),
network_access: h.network_access.clone(),
default_model_id: h.default_model_id,
tools: vec![],
max_iterations: None,
mcp_servers: h.mcp_servers.clone(),
}
}
}
impl From<&Agent> for AgentConfigOverlay {
fn from(a: &Agent) -> Self {
AgentConfigOverlay {
system_prompt: Some(a.system_prompt.clone()),
capabilities: a.capabilities.clone(),
initial_files: a.initial_files.clone(),
network_access: a.network_access.clone(),
default_model_id: a.default_model_id,
tools: a.tools.clone(),
max_iterations: a.max_iterations,
mcp_servers: a.mcp_servers.clone(),
}
}
}
impl From<&Session> for AgentConfigOverlay {
fn from(s: &Session) -> Self {
AgentConfigOverlay {
system_prompt: s.system_prompt.clone(),
capabilities: s.capabilities.clone(),
initial_files: s.initial_files.clone(),
network_access: s.network_access.clone(),
default_model_id: s.model_id,
tools: s.tools.clone(),
max_iterations: s.max_iterations,
mcp_servers: s.mcp_servers.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability_types::AgentCapabilityConfig;
use crate::mcp_server::ScopedMcpServer;
use crate::network_access::NetworkAccessList;
use crate::session_file::InitialFile;
fn make_file(path: &str, content: &str) -> InitialFile {
InitialFile {
path: path.to_string(),
content: content.to_string(),
encoding: "text".to_string(),
is_readonly: false,
}
}
#[test]
fn merge_system_prompts_concatenates() {
let base = AgentConfigOverlay {
system_prompt: Some("Base prompt.".into()),
..Default::default()
};
let overlay = AgentConfigOverlay {
system_prompt: Some("Overlay prompt.".into()),
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(
merged.system_prompt.as_deref(),
Some("Base prompt.\n\nOverlay prompt.")
);
}
#[test]
fn merge_system_prompts_base_only() {
let base = AgentConfigOverlay {
system_prompt: Some("Base.".into()),
..Default::default()
};
let overlay = AgentConfigOverlay::default();
let merged = base.merge(overlay);
assert_eq!(merged.system_prompt.as_deref(), Some("Base."));
}
#[test]
fn merge_system_prompts_overlay_only() {
let base = AgentConfigOverlay::default();
let overlay = AgentConfigOverlay {
system_prompt: Some("Overlay.".into()),
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(merged.system_prompt.as_deref(), Some("Overlay."));
}
#[test]
fn merge_system_prompts_both_empty() {
let merged = AgentConfigOverlay::default().merge(AgentConfigOverlay::default());
assert!(merged.system_prompt.is_none());
}
#[test]
fn merge_capabilities_override_by_id() {
let base = AgentConfigOverlay {
capabilities: vec![
AgentCapabilityConfig::new("session_file_system"),
AgentCapabilityConfig::with_config(
"web_fetch",
serde_json::json!({"enable_file_download": true}),
),
],
..Default::default()
};
let overlay = AgentConfigOverlay {
capabilities: vec![
AgentCapabilityConfig::with_config(
"web_fetch",
serde_json::json!({"enable_file_download": false}),
),
AgentCapabilityConfig::new("current_time"),
],
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(merged.capabilities.len(), 3);
assert_eq!(
merged.capabilities[0].capability_id(),
"session_file_system"
);
assert_eq!(merged.capabilities[1].capability_id(), "web_fetch");
assert_eq!(
merged.capabilities[1],
AgentCapabilityConfig::with_config(
"web_fetch",
serde_json::json!({"enable_file_download": false})
)
);
assert_eq!(merged.capabilities[2].capability_id(), "current_time");
}
#[test]
fn merge_initial_files_override_by_path() {
let base = AgentConfigOverlay {
initial_files: vec![
make_file("/workspace/README.md", "parent"),
make_file("/workspace/config.txt", "parent-config"),
],
..Default::default()
};
let overlay = AgentConfigOverlay {
initial_files: vec![
make_file("README.md", "child"),
make_file("/notes.txt", "notes"),
],
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(merged.initial_files.len(), 3);
assert_eq!(merged.initial_files[0].content, "child"); assert_eq!(merged.initial_files[1].content, "parent-config"); assert_eq!(merged.initial_files[2].content, "notes"); }
#[test]
fn merge_network_access_narrows() {
let base = AgentConfigOverlay {
network_access: Some(NetworkAccessList::allow_only([
"*.example.com",
"*.github.com",
])),
..Default::default()
};
let overlay = AgentConfigOverlay {
network_access: Some(NetworkAccessList::allow_only(["api.example.com"])),
..Default::default()
};
let merged = base.merge(overlay);
let na = merged.network_access.unwrap();
assert_eq!(na.allowed, vec!["api.example.com".to_string()]);
}
#[test]
fn merge_model_overlay_wins() {
let base = AgentConfigOverlay {
default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
..Default::default()
};
let overlay = AgentConfigOverlay {
default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(2))),
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(
merged.default_model_id,
Some(ModelId::from_uuid(uuid::Uuid::from_u128(2)))
);
}
#[test]
fn merge_model_inherits_base() {
let base = AgentConfigOverlay {
default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(1))),
..Default::default()
};
let overlay = AgentConfigOverlay::default();
let merged = base.merge(overlay);
assert_eq!(
merged.default_model_id,
Some(ModelId::from_uuid(uuid::Uuid::from_u128(1)))
);
}
#[test]
fn merge_max_iterations_overlay_wins() {
let base = AgentConfigOverlay {
max_iterations: Some(100),
..Default::default()
};
let overlay = AgentConfigOverlay {
max_iterations: Some(50),
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(merged.max_iterations, Some(50));
}
#[test]
fn merge_max_iterations_inherits_base() {
let base = AgentConfigOverlay {
max_iterations: Some(100),
..Default::default()
};
let overlay = AgentConfigOverlay::default();
let merged = base.merge(overlay);
assert_eq!(merged.max_iterations, Some(100));
}
#[test]
fn merge_tools_additive() {
use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
let make_tool = |name: &str| {
ToolDefinition::Builtin(BuiltinTool {
name: name.to_string(),
display_name: None,
description: format!("{name} tool"),
parameters: serde_json::json!({}),
policy: ToolPolicy::Auto,
category: None,
deferrable: Default::default(),
hints: crate::tool_types::ToolHints::default(),
full_parameters: None,
})
};
let base = AgentConfigOverlay {
tools: vec![make_tool("tool_a")],
..Default::default()
};
let overlay = AgentConfigOverlay {
tools: vec![make_tool("tool_b")],
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(merged.tools.len(), 2);
assert_eq!(merged.tools[0].name(), "tool_a");
assert_eq!(merged.tools[1].name(), "tool_b");
}
#[test]
fn merge_mcp_servers_overlay_wins_by_name() {
let mut base_servers = ScopedMcpServers::default();
base_servers.insert(
"docs".to_string(),
ScopedMcpServer {
url: "https://base.example.com/mcp".to_string(),
..Default::default()
},
);
let mut overlay_servers = ScopedMcpServers::default();
overlay_servers.insert(
"docs".to_string(),
ScopedMcpServer {
url: "https://overlay.example.com/mcp".to_string(),
..Default::default()
},
);
overlay_servers.insert(
"search".to_string(),
ScopedMcpServer {
url: "https://search.example.com/mcp".to_string(),
..Default::default()
},
);
let merged = AgentConfigOverlay {
mcp_servers: base_servers,
..Default::default()
}
.merge(AgentConfigOverlay {
mcp_servers: overlay_servers,
..Default::default()
});
assert_eq!(merged.mcp_servers.len(), 2);
assert_eq!(
merged
.mcp_servers
.get("docs")
.map(|server| server.url.as_str()),
Some("https://overlay.example.com/mcp")
);
assert_eq!(
merged
.mcp_servers
.get("search")
.map(|server| server.url.as_str()),
Some("https://search.example.com/mcp")
);
}
#[test]
fn fold_three_layers() {
let harness = AgentConfigOverlay {
system_prompt: Some("Harness prompt.".into()),
capabilities: vec![AgentCapabilityConfig::new("session_file_system")],
initial_files: vec![make_file("/config.txt", "harness")],
max_iterations: None,
..Default::default()
};
let agent = AgentConfigOverlay {
system_prompt: Some("Agent prompt.".into()),
capabilities: vec![AgentCapabilityConfig::new("current_time")],
initial_files: vec![make_file("/config.txt", "agent")],
max_iterations: Some(200),
..Default::default()
};
let session = AgentConfigOverlay {
system_prompt: Some("Session prompt.".into()),
max_iterations: Some(50),
..Default::default()
};
let effective = AgentConfigOverlay::fold([harness, agent, session]);
assert_eq!(
effective.system_prompt.as_deref(),
Some("Harness prompt.\n\nAgent prompt.\n\nSession prompt.")
);
assert_eq!(effective.capabilities.len(), 2);
assert_eq!(effective.initial_files.len(), 1);
assert_eq!(effective.initial_files[0].content, "agent");
assert_eq!(effective.max_iterations, Some(50));
}
#[test]
fn normalize_workspace_prefix() {
assert_eq!(
normalize_initial_file_path("/workspace/README.md"),
"/README.md"
);
assert_eq!(normalize_initial_file_path("/workspace"), "/");
assert_eq!(normalize_initial_file_path("README.md"), "/README.md");
assert_eq!(normalize_initial_file_path("/README.md"), "/README.md");
}
}