use crate::HarnessId;
use serde_yaml::Value;
use crate::diagnostic::{RemovedField, RemovedValue, push_invalid, unrecognized_field};
use crate::frontmatter::{Frontmatter, SkillsSpec};
use crate::harness_overrides::{HarnessOverrides, parse_harness_overrides};
use crate::invocability::parse_invocability_axis;
use crate::model_policy::ModelPolicyRule;
use crate::network::{NetworkHost, NetworkPolicy};
use crate::permission_set::PermissionSet;
use crate::tool_policy::{self, EffectiveToolPolicy};
pub(crate) const REMOVED_AGENT_FIELDS: &[RemovedField] = &[
RemovedField::discarded("fanout", "use consumer `[settings.meridian.fanout]`"),
RemovedField::discarded("models", "use `model-policies`"),
RemovedField::discarded("mcp-tools", "put MCP capability references in `tools`"),
RemovedField::discarded("autocompact", "configure compaction on the model alias"),
RemovedField::discarded("autocompact_pct", "configure compaction on the model alias"),
RemovedField::replaced("disallowed-tools", "use `tools: {<capability>: deny}`"),
RemovedField::replaced("model_invocable", "use `model-invocable`"),
RemovedField::replaced("user_invocable", "use `user-invocable`"),
RemovedField::replaced("network_allow", "use `network-allow`"),
RemovedField::replaced("network_deny", "use `network-deny`"),
RemovedField::replaced(
"permission",
"move it under `harness-overrides.meridian-flow.permission`",
),
RemovedField::replaced(
"resident-rearm-budget",
"move it under `harness-overrides.meridian-cli.resident-rearm-budget`",
),
RemovedField::replaced(
"meridian-capabilities",
"move it under `harness-overrides.meridian-cli.capabilities`",
),
];
pub(crate) const REMOVED_AGENT_VALUES: &[RemovedValue] = &[
RemovedValue::new("approval", "yolo", "never"),
RemovedValue::new("effort", "adaptive", "medium"),
RemovedValue::new("effort", "disabled", "none"),
];
#[derive(Debug, Clone, Copy)]
pub enum LoweredField {
Identity,
Model,
Skills,
Tools,
Effort,
Mode,
}
impl LoweredField {
pub const ALL: [Self; 6] = [
Self::Identity,
Self::Model,
Self::Skills,
Self::Tools,
Self::Effort,
Self::Mode,
];
pub const fn keys(self) -> &'static [&'static str] {
match self {
Self::Identity => &["name", "description"],
Self::Model => &["model"],
Self::Skills => &["skills"],
Self::Tools => &["tools", "disallowed-tools"],
Self::Effort => &["effort"],
Self::Mode => &["mode"],
}
}
pub fn reserves(key: &str) -> bool {
Self::ALL.iter().any(|field| field.keys().contains(&key))
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentMode {
Primary,
Subagent,
}
impl AgentMode {
pub fn as_str(&self) -> &str {
match self {
AgentMode::Primary => "primary",
AgentMode::Subagent => "subagent",
}
}
}
impl std::fmt::Display for AgentMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalMode {
Default,
Auto,
Confirm,
Never,
}
impl ApprovalMode {
pub const ALLOWED: &'static str = "default, auto, confirm, never";
pub fn parse(s: &str) -> Option<Self> {
match s {
"default" => Some(Self::Default),
"auto" => Some(Self::Auto),
"confirm" => Some(Self::Confirm),
"never" => Some(Self::Never),
_ => None,
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Default => "default",
Self::Auto => "auto",
Self::Confirm => "confirm",
Self::Never => "never",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxMode {
Default,
ReadOnly,
WorkspaceWrite,
DangerFullAccess,
}
impl SandboxMode {
pub const ALLOWED: &'static str = "default, read-only, workspace-write, danger-full-access";
pub fn parse(s: &str) -> Option<Self> {
match s {
"default" => Some(Self::Default),
"read-only" => Some(Self::ReadOnly),
"workspace-write" => Some(Self::WorkspaceWrite),
"danger-full-access" => Some(Self::DangerFullAccess),
_ => None,
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Default => "default",
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum EffortLevel {
None,
Low,
Medium,
High,
#[serde(rename = "xhigh")]
XHigh,
Max,
}
impl EffortLevel {
pub const ALLOWED: &'static str = "none, low, medium, high, xhigh, max";
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
"max" => Some(Self::Max),
_ => None,
}
}
pub fn as_str(&self) -> &str {
match self {
Self::None => "none",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
}
impl<'de> serde::Deserialize<'de> for EffortLevel {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let text = String::deserialize(deserializer)?;
Self::parse(&text).ok_or_else(|| {
serde::de::Error::custom(format!(
"`effort` has invalid value `{text}`; allowed: {}",
Self::ALLOWED
))
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct AgentProfile {
pub name: Option<String>,
pub description: Option<String>,
pub harness: Option<HarnessId>,
pub model: Option<String>,
pub mode: Option<AgentMode>,
pub model_invocable: bool,
pub user_invocable: bool,
#[serde(skip)]
pub had_model_invocable_field: bool,
#[serde(skip)]
pub had_user_invocable_field: bool,
pub approval: Option<ApprovalMode>,
pub sandbox: Option<SandboxMode>,
pub network: Option<NetworkPolicy>,
pub network_allow: PermissionSet<NetworkHost>,
pub network_deny: PermissionSet<NetworkHost>,
pub effort: Option<EffortLevel>,
pub skills: SkillsSpec,
pub subagents: Vec<String>,
pub tools: EffectiveToolPolicy,
pub harness_overrides: HarnessOverrides,
pub model_policies: Vec<ModelPolicyRule>,
}
impl AgentProfile {
pub fn effective_native_config(
&self,
harness: &HarnessId,
) -> Option<serde_json::Map<String, serde_json::Value>> {
self.harness_overrides
.get(harness)
.filter(|map| !map.is_empty())
.map(|map| {
map.iter()
.map(|(key, value)| (key.clone(), value.to_json()))
.collect()
})
}
}
fn push_agent_invalid(
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
path: Vec<crate::diagnostic::PathSegment>,
value: impl Into<String>,
allowed: &'static str,
) {
push_invalid("agent", diags, path, value, allowed);
}
fn push_unknown_harness(diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>, value: &str) {
use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
diags.push(SchemaDiagnostic::new(
"harness.unknown",
Severity::Error,
key_path("harness"),
format!("unknown harness `{value}`; known: claude, codex, opencode, cursor, pi"),
));
}
fn push_model_policy_parse_error(
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
position: usize,
error: crate::model_policy::ModelPolicyRuleParseError,
) {
use crate::diagnostic::{PathSegment, key_path};
let mut rule_path = key_path("model-policies");
rule_path.push(PathSegment::Index(position));
let child = |key: &str| {
let mut path = rule_path.clone();
path.push(PathSegment::Key(key.to_owned()));
path
};
let grandchild = |first: &str, second: &str| {
let mut path = child(first);
path.push(PathSegment::Key(second.to_owned()));
path
};
match error {
crate::model_policy::ModelPolicyRuleParseError::RuleMustBeMapping { found } => {
push_agent_invalid(diags, rule_path, found, "mapping with match and override");
}
crate::model_policy::ModelPolicyRuleParseError::MatchMissing => {
push_agent_invalid(
diags,
child("match"),
"<missing>",
"mapping with exactly one of model, alias, model-glob",
);
}
crate::model_policy::ModelPolicyRuleParseError::MatchMustBeMapping { found } => {
push_agent_invalid(
diags,
child("match"),
found,
"mapping with exactly one of model, alias, model-glob",
);
}
crate::model_policy::ModelPolicyRuleParseError::MatchMustContainExactlyOne { found } => {
push_agent_invalid(
diags,
child("match"),
found,
"exactly one of model, alias, model-glob",
);
}
crate::model_policy::ModelPolicyRuleParseError::MatchKeyMustBeString { found } => {
push_agent_invalid(diags, child("match"), found, "model, alias, model-glob");
}
crate::model_policy::ModelPolicyRuleParseError::UnknownMatchKey { key } => {
push_agent_invalid(diags, child("match"), key, "model, alias, model-glob");
}
crate::model_policy::ModelPolicyRuleParseError::MatchValueMustBeString { key, found } => {
push_agent_invalid(diags, grandchild("match", &key), found, "non-empty string");
}
crate::model_policy::ModelPolicyRuleParseError::MatchValueEmpty { key } => {
push_agent_invalid(
diags,
grandchild("match", &key),
"<empty>",
"non-empty string",
);
}
crate::model_policy::ModelPolicyRuleParseError::OverrideMustBeMapping { found } => {
push_agent_invalid(diags, child("override"), found, "mapping");
}
crate::model_policy::ModelPolicyRuleParseError::UnknownOverrideKey { key } => {
push_agent_invalid(
diags,
grandchild("override", &key),
key,
"effort, sandbox, approval, network",
);
}
crate::model_policy::ModelPolicyRuleParseError::NetworkHostsNotAllowed { key } => {
push_agent_invalid(
diags,
grandchild("override", &key),
key,
"network host sets on settings, profile, overlay, or CLI",
);
}
crate::model_policy::ModelPolicyRuleParseError::OverrideValueInvalid {
key,
found,
allowed,
} => {
push_agent_invalid(diags, grandchild("override", &key), found, allowed);
}
crate::model_policy::ModelPolicyRuleParseError::NoFallbackMustBeBoolean { found } => {
push_agent_invalid(diags, child("no-fallback"), found, "boolean");
}
crate::model_policy::ModelPolicyRuleParseError::WhenInvalid { found } => {
push_agent_invalid(diags, child("when"), found, "non-empty one-line string");
}
}
}
fn parse_model_policies(
val: &Value,
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
) -> Vec<ModelPolicyRule> {
let Some(seq) = val.as_sequence() else {
push_agent_invalid(
diags,
crate::diagnostic::key_path("model-policies"),
crate::ordered::plain(val),
"sequence of rules",
);
return vec![];
};
let mut out = Vec::new();
for (index, entry) in seq.iter().enumerate() {
match crate::model_policy::parse_model_policy_rule_value(entry) {
Ok(rule) => out.push(rule),
Err(error) => push_model_policy_parse_error(diags, index, error),
}
}
out
}
fn parse_invocability_field(
field: &str,
raw: Option<&Value>,
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
) -> (bool, bool) {
let (value, had_field, invalid) = parse_invocability_axis(raw);
if let Some(invalid) = invalid {
push_agent_invalid(
diags,
crate::diagnostic::key_path(field),
invalid,
"boolean",
);
}
(value, had_field)
}
fn read_string(
value: &Value,
field: &'static str,
non_empty: bool,
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
) -> Option<String> {
let Some(value) = value.as_str() else {
diags.push(crate::diagnostic::SchemaDiagnostic::new(
"field.type",
crate::diagnostic::Severity::Error,
crate::diagnostic::key_path(field),
format!("`{field}` must be a string"),
));
return None;
};
if non_empty {
let value = value.trim();
if value.is_empty() {
diags.push(crate::diagnostic::SchemaDiagnostic::new(
"field.value",
crate::diagnostic::Severity::Error,
crate::diagnostic::key_path(field),
format!("`{field}` must be a non-empty string"),
));
return None;
}
return Some(value.to_owned());
}
Some(value.to_owned())
}
fn read_string_list(
value: Option<&Value>,
path: Vec<crate::diagnostic::PathSegment>,
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
) -> Vec<String> {
use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity, display_path};
let Some(value) = value else {
return Vec::new();
};
let Some(values) = value.as_sequence() else {
let display = display_path(&path);
diags.push(SchemaDiagnostic::new(
"field.type",
Severity::Error,
path,
format!("`{display}` must be a list of non-empty strings"),
));
return Vec::new();
};
let display = display_path(&path);
values
.iter()
.enumerate()
.filter_map(|(index, value)| {
if let Value::String(text) = value
&& !text.trim().is_empty()
{
return Some(text.clone());
}
let mut entry_path = path.clone();
entry_path.push(PathSegment::Index(index));
diags.push(SchemaDiagnostic::new(
if value.is_string() {
"field.value"
} else {
"field.type"
},
Severity::Error,
entry_path,
format!("`{display}` entry {} must be a non-empty string", index + 1),
));
None
})
.collect()
}
fn read_host_set(
field: &'static str,
value: &Value,
allow_replace: bool,
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
) -> PermissionSet<NetworkHost> {
use crate::diagnostic::{PathSegment, key_path};
const SHAPE: &str = "list of exact hosts, IPs, *.example.com, or **.example.com; or {replace: [...]} for an allow set";
let mut path = key_path(field);
let (replace, entries) = match value {
Value::Sequence(entries) => (false, entries),
Value::Mapping(mapping)
if mapping.len() == 1
&& mapping
.get("replace")
.is_some_and(|entries| entries.is_sequence()) =>
{
if !allow_replace {
push_agent_invalid(
diags,
path,
crate::ordered::plain(value),
"plain list; package profiles cannot replace a deny set",
);
return PermissionSet::default();
}
path.push(PathSegment::Key("replace".to_owned()));
let entries = mapping.get("replace").and_then(Value::as_sequence);
(true, entries.expect("checked to be a sequence"))
}
_ => {
push_agent_invalid(diags, path, crate::ordered::plain(value), SHAPE);
return PermissionSet::default();
}
};
let hosts = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let parsed = entry
.as_str()
.ok_or_else(|| crate::ordered::plain(entry))
.and_then(|text| text.parse::<NetworkHost>().map_err(|_| text.to_owned()));
parsed
.map_err(|found| {
let mut entry_path = path.clone();
entry_path.push(PathSegment::Index(index));
push_agent_invalid(diags, entry_path, found, NetworkHost::ALLOWED);
})
.ok()
})
.collect();
if replace {
PermissionSet::Replace { replace: hosts }
} else {
PermissionSet::Add(hosts)
}
}
fn read_skills(value: &Value, diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>) -> SkillsSpec {
use crate::diagnostic::{PathSegment, key_path};
let root = key_path("skills");
let Value::Mapping(mapping) = value else {
return SkillsSpec {
load: read_string_list(Some(value), root, diags),
available: Vec::new(),
};
};
let read_axis = |name: &str, diags: &mut Vec<_>| {
let mut path = root.clone();
path.push(PathSegment::Key(name.to_owned()));
read_string_list(mapping.get(Value::String(name.to_owned())), path, diags)
};
SkillsSpec {
load: read_axis("load", diags),
available: read_axis("available", diags),
}
}
pub(crate) fn read_fields(
fm: &Frontmatter,
diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
) -> AgentProfile {
use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
let mut profile = AgentProfile {
name: None,
description: None,
harness: None,
model: None,
mode: None,
model_invocable: true,
user_invocable: true,
had_model_invocable_field: false,
had_user_invocable_field: false,
approval: None,
sandbox: None,
network: None,
network_allow: PermissionSet::default(),
network_deny: PermissionSet::default(),
effort: None,
skills: SkillsSpec::default(),
subagents: Vec::new(),
tools: EffectiveToolPolicy::default(),
harness_overrides: HarnessOverrides::default(),
model_policies: Vec::new(),
};
for key in fm.keys() {
let value = fm.get(&key).expect("frontmatter key came from mapping");
match key.as_str() {
"name" => profile.name = read_string(value, "name", true, diags),
"description" => {
profile.description = read_string(value, "description", false, diags);
}
"harness" => {
profile.harness = read_string(value, "harness", false, diags).and_then(|name| {
crate::harness::parse(&name).or_else(|| {
push_unknown_harness(diags, &name);
None
})
});
}
"model" => profile.model = read_string(value, "model", true, diags),
"mode" => {
profile.mode =
read_string(value, "mode", false, diags).and_then(|mode| match mode.as_str() {
"primary" => Some(AgentMode::Primary),
"subagent" => Some(AgentMode::Subagent),
other => {
push_agent_invalid(
diags,
key_path("mode"),
other.to_string(),
"primary, subagent",
);
None
}
});
}
"model-invocable" => {
(profile.model_invocable, profile.had_model_invocable_field) =
parse_invocability_field("model-invocable", Some(value), diags);
}
"user-invocable" => {
(profile.user_invocable, profile.had_user_invocable_field) =
parse_invocability_field("user-invocable", Some(value), diags);
}
"approval" => {
profile.approval =
read_string(value, "approval", false, diags).and_then(|approval| {
if let Some(removed) =
RemovedValue::find(REMOVED_AGENT_VALUES, "approval", &approval)
{
diags.push(removed.diagnostic());
return None;
}
ApprovalMode::parse(&approval).or_else(|| {
push_agent_invalid(
diags,
key_path("approval"),
approval,
ApprovalMode::ALLOWED,
);
None
})
});
}
"permission" => diags.push(removed_permission(value)),
"sandbox" => {
profile.sandbox = read_string(value, "sandbox", false, diags).and_then(|sandbox| {
SandboxMode::parse(&sandbox).or_else(|| {
push_agent_invalid(
diags,
key_path("sandbox"),
sandbox,
SandboxMode::ALLOWED,
);
None
})
});
}
"network" => {
profile.network = read_string(value, "network", false, diags).and_then(|network| {
NetworkPolicy::parse(&network).or_else(|| {
push_agent_invalid(
diags,
key_path("network"),
network,
NetworkPolicy::ALLOWED,
);
None
})
});
}
"network-allow" => {
profile.network_allow = read_host_set("network-allow", value, true, diags);
}
"network-deny" => {
profile.network_deny = read_host_set("network-deny", value, false, diags);
}
"effort" => {
profile.effort = read_string(value, "effort", false, diags).and_then(|effort| {
if let Some(removed) =
RemovedValue::find(REMOVED_AGENT_VALUES, "effort", &effort)
{
diags.push(removed.diagnostic());
return None;
}
EffortLevel::parse(&effort).or_else(|| {
push_agent_invalid(diags, key_path("effort"), effort, EffortLevel::ALLOWED);
None
})
});
}
"skills" => profile.skills = read_skills(value, diags),
"subagents" => {
profile.subagents = read_string_list(Some(value), key_path("subagents"), diags);
}
"tools" => {
if value.is_sequence() {
diags.push(SchemaDiagnostic::new(
"tools.list-form",
Severity::Error,
key_path("tools"),
"`tools` must be an action map; list form was removed",
));
} else {
let mut invalid = |path, value: &str, allowed: &'static str| {
push_agent_invalid(diags, path, value, allowed);
};
profile.tools = tool_policy::parse_tool_policy(Some(value), &mut invalid);
}
}
"harness-overrides" => {
profile.harness_overrides = parse_harness_overrides("agent", value, diags);
}
"model-policies" => profile.model_policies = parse_model_policies(value, diags),
_ => diags.push(unrecognized_field(REMOVED_AGENT_FIELDS, &key)),
}
}
profile
}
fn removed_permission(value: &Value) -> crate::diagnostic::SchemaDiagnostic {
use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
match value {
Value::Mapping(_) => SchemaDiagnostic::new(
"field.removed",
Severity::Error,
key_path("permission"),
"`permission` as a map is OpenCode's format; put it under \
`harness-overrides.opencode.permission`",
),
Value::String(permission) if matches!(permission.as_str(), "read" | "edit") => {
unrecognized_field(REMOVED_AGENT_FIELDS, "permission")
}
_ => SchemaDiagnostic::new(
"field.removed",
Severity::Error,
key_path("permission"),
"`permission` was removed; Meridian Flow reads only `read` or `edit`, at \
`harness-overrides.meridian-flow.permission`. Grant what it allowed with \
`tools: {<capability>: allow}`",
),
}
}
pub fn read(content: &str) -> crate::read::DefinitionRead<AgentProfile> {
crate::read::read_with(content, read_fields)
}