Skip to main content

mars_schema/
agent.rs

1//! Agent definition reader.
2
3use crate::HarnessId;
4use serde_yaml::Value;
5
6use crate::diagnostic::{RemovedField, RemovedValue, push_invalid, unrecognized_field};
7use crate::frontmatter::{Frontmatter, SkillsSpec};
8use crate::harness_overrides::{HarnessOverrides, parse_harness_overrides};
9use crate::invocability::parse_invocability_axis;
10use crate::model_policy::ModelPolicyRule;
11use crate::network::{NetworkHost, NetworkPolicy};
12use crate::permission_set::PermissionSet;
13use crate::tool_policy::{self, EffectiveToolPolicy};
14
15/// `lift_mars_only_keys` drops the `discarded` keys; the legacy upgrade rewrites the rest.
16pub(crate) const REMOVED_AGENT_FIELDS: &[RemovedField] = &[
17    RemovedField::discarded("fanout", "use consumer `[settings.meridian.fanout]`"),
18    RemovedField::discarded("models", "use `model-policies`"),
19    RemovedField::discarded("mcp-tools", "put MCP capability references in `tools`"),
20    RemovedField::discarded("autocompact", "configure compaction on the model alias"),
21    RemovedField::discarded("autocompact_pct", "configure compaction on the model alias"),
22    RemovedField::replaced("disallowed-tools", "use `tools: {<capability>: deny}`"),
23    RemovedField::replaced("model_invocable", "use `model-invocable`"),
24    RemovedField::replaced("user_invocable", "use `user-invocable`"),
25    RemovedField::replaced("network_allow", "use `network-allow`"),
26    RemovedField::replaced("network_deny", "use `network-deny`"),
27    RemovedField::replaced(
28        "permission",
29        "move it under `harness-overrides.meridian-flow.permission`",
30    ),
31    RemovedField::replaced(
32        "resident-rearm-budget",
33        "move it under `harness-overrides.meridian-cli.resident-rearm-budget`",
34    ),
35    RemovedField::replaced(
36        "meridian-capabilities",
37        "move it under `harness-overrides.meridian-cli.capabilities`",
38    ),
39];
40
41/// Values `lift_mars_only_keys` rewrites; the reader refuses them.
42pub(crate) const REMOVED_AGENT_VALUES: &[RemovedValue] = &[
43    RemovedValue::new("approval", "yolo", "never"),
44    RemovedValue::new("effort", "adaptive", "medium"),
45    RemovedValue::new("effort", "disabled", "none"),
46];
47
48/// The Agent keys Mars lowers into a harness's native file itself, by the field that owns them.
49/// A `harness-overrides` entry never replaces one (native lowering keeps Mars's value and reports
50/// the override as dropped), so the translation's set-aside never moves one there either.
51#[derive(Debug, Clone, Copy)]
52pub enum LoweredField {
53    Identity,
54    Model,
55    Skills,
56    Tools,
57    Effort,
58    Mode,
59}
60
61impl LoweredField {
62    pub const ALL: [Self; 6] = [
63        Self::Identity,
64        Self::Model,
65        Self::Skills,
66        Self::Tools,
67        Self::Effort,
68        Self::Mode,
69    ];
70
71    /// Frontmatter keys this field lowers, whether or not the profile has a value for them.
72    pub const fn keys(self) -> &'static [&'static str] {
73        match self {
74            Self::Identity => &["name", "description"],
75            Self::Model => &["model"],
76            Self::Skills => &["skills"],
77            Self::Tools => &["tools", "disallowed-tools"],
78            Self::Effort => &["effort"],
79            Self::Mode => &["mode"],
80        }
81    }
82
83    /// Whether Mars lowers `key` itself on some harness.
84    pub fn reserves(key: &str) -> bool {
85        Self::ALL.iter().any(|field| field.keys().contains(&key))
86    }
87}
88
89// ---------------------------------------------------------------------------
90// Field enums
91// ---------------------------------------------------------------------------
92
93/// Agent execution mode — how the agent is launched.
94#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum AgentMode {
97    Primary,
98    Subagent,
99}
100
101impl AgentMode {
102    pub fn as_str(&self) -> &str {
103        match self {
104            AgentMode::Primary => "primary",
105            AgentMode::Subagent => "subagent",
106        }
107    }
108}
109
110impl std::fmt::Display for AgentMode {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116/// Approval policy field.
117#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum ApprovalMode {
120    Default,
121    Auto,
122    Confirm,
123    Never,
124}
125
126impl ApprovalMode {
127    /// The values `parse` accepts, as the invalid-value errors list them.
128    pub const ALLOWED: &'static str = "default, auto, confirm, never";
129
130    pub fn parse(s: &str) -> Option<Self> {
131        match s {
132            "default" => Some(Self::Default),
133            "auto" => Some(Self::Auto),
134            "confirm" => Some(Self::Confirm),
135            "never" => Some(Self::Never),
136            _ => None,
137        }
138    }
139
140    pub fn as_str(&self) -> &str {
141        match self {
142            Self::Default => "default",
143            Self::Auto => "auto",
144            Self::Confirm => "confirm",
145            Self::Never => "never",
146        }
147    }
148}
149
150/// Sandbox mode field.
151#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
152#[serde(rename_all = "kebab-case")]
153pub enum SandboxMode {
154    Default,
155    ReadOnly,
156    WorkspaceWrite,
157    DangerFullAccess,
158}
159
160impl SandboxMode {
161    /// The values `parse` accepts, as the invalid-value errors list them.
162    pub const ALLOWED: &'static str = "default, read-only, workspace-write, danger-full-access";
163
164    pub fn parse(s: &str) -> Option<Self> {
165        match s {
166            "default" => Some(Self::Default),
167            "read-only" => Some(Self::ReadOnly),
168            "workspace-write" => Some(Self::WorkspaceWrite),
169            "danger-full-access" => Some(Self::DangerFullAccess),
170            _ => None,
171        }
172    }
173
174    pub fn as_str(&self) -> &str {
175        match self {
176            Self::Default => "default",
177            Self::ReadOnly => "read-only",
178            Self::WorkspaceWrite => "workspace-write",
179            Self::DangerFullAccess => "danger-full-access",
180        }
181    }
182}
183
184/// Effort level field.
185#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
186#[serde(rename_all = "kebab-case")]
187pub enum EffortLevel {
188    None,
189    Low,
190    Medium,
191    High,
192    #[serde(rename = "xhigh")]
193    XHigh,
194    Max,
195}
196
197impl EffortLevel {
198    /// The values `parse` accepts, as the invalid-value errors list them.
199    pub const ALLOWED: &'static str = "none, low, medium, high, xhigh, max";
200
201    pub fn parse(s: &str) -> Option<Self> {
202        match s.to_ascii_lowercase().as_str() {
203            "none" => Some(Self::None),
204            "low" => Some(Self::Low),
205            "medium" => Some(Self::Medium),
206            "high" => Some(Self::High),
207            "xhigh" => Some(Self::XHigh),
208            "max" => Some(Self::Max),
209            _ => None,
210        }
211    }
212
213    pub fn as_str(&self) -> &str {
214        match self {
215            Self::None => "none",
216            Self::Low => "low",
217            Self::Medium => "medium",
218            Self::High => "high",
219            Self::XHigh => "xhigh",
220            Self::Max => "max",
221        }
222    }
223}
224
225/// Reads the same spellings `parse` does, so a config field typed `EffortLevel` holds the
226/// canonical level and no later reader re-parses a string.
227impl<'de> serde::Deserialize<'de> for EffortLevel {
228    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
229        let text = String::deserialize(deserializer)?;
230        Self::parse(&text).ok_or_else(|| {
231            serde::de::Error::custom(format!(
232                "`effort` has invalid value `{text}`; allowed: {}",
233                Self::ALLOWED
234            ))
235        })
236    }
237}
238
239// ---------------------------------------------------------------------------
240// AgentProfile — the fully parsed frontmatter
241// ---------------------------------------------------------------------------
242
243/// Strongly-typed representation of an agent profile's frontmatter.
244///
245/// Parsed from YAML frontmatter by [`read`].
246/// Used for:
247/// - Compile-time validation (mode values, passthrough shape)
248/// - Dual-surface routing (harness → output target)
249/// - Per-target lowering (field lowering per agent-compilation-mapping.md)
250#[derive(Debug, Clone, serde::Serialize)]
251#[serde(rename_all = "kebab-case")]
252pub struct AgentProfile {
253    // --- Identity fields ---
254    pub name: Option<String>,
255    pub description: Option<String>,
256
257    // --- Routing fields ---
258    pub harness: Option<HarnessId>,
259
260    // --- Model fields ---
261    pub model: Option<String>,
262
263    // --- Runtime policy fields ---
264    pub mode: Option<AgentMode>,
265    pub model_invocable: bool,
266    /// Whether the user can invoke this agent directly (slash command, picker, etc.).
267    pub user_invocable: bool,
268    /// `true` when source frontmatter explicitly set `model-invocable`.
269    #[serde(skip)]
270    pub had_model_invocable_field: bool,
271    /// `true` when source frontmatter explicitly set `user-invocable`.
272    #[serde(skip)]
273    pub had_user_invocable_field: bool,
274    pub approval: Option<ApprovalMode>,
275    pub sandbox: Option<SandboxMode>,
276    pub network: Option<NetworkPolicy>,
277    pub network_allow: PermissionSet<NetworkHost>,
278    pub network_deny: PermissionSet<NetworkHost>,
279    pub effort: Option<EffortLevel>,
280
281    // --- Tool fields ---
282    pub skills: SkillsSpec,
283    pub subagents: Vec<String>,
284    /// `tools:` and `disallowed-tools:` as one rule list. An allowlist has an explicit
285    /// `'*': deny`; deprecated list form only grants its entries.
286    pub tools: EffectiveToolPolicy,
287
288    // --- Override tables ---
289    pub harness_overrides: HarnessOverrides,
290    pub model_policies: Vec<ModelPolicyRule>,
291}
292
293impl AgentProfile {
294    /// The harness's passthrough block as a JSON map (keys then sort), if it has entries.
295    pub fn effective_native_config(
296        &self,
297        harness: &HarnessId,
298    ) -> Option<serde_json::Map<String, serde_json::Value>> {
299        self.harness_overrides
300            .get(harness)
301            .filter(|map| !map.is_empty())
302            .map(|map| {
303                map.iter()
304                    .map(|(key, value)| (key.clone(), value.to_json()))
305                    .collect()
306            })
307    }
308}
309// ---------------------------------------------------------------------------
310// Validation warnings/errors
311// ---------------------------------------------------------------------------
312
313fn push_agent_invalid(
314    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
315    path: Vec<crate::diagnostic::PathSegment>,
316    value: impl Into<String>,
317    allowed: &'static str,
318) {
319    push_invalid("agent", diags, path, value, allowed);
320}
321
322fn push_unknown_harness(diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>, value: &str) {
323    use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
324    diags.push(SchemaDiagnostic::new(
325        "harness.unknown",
326        Severity::Error,
327        key_path("harness"),
328        format!("unknown harness `{value}`; known: claude, codex, opencode, cursor, pi"),
329    ));
330}
331
332fn push_model_policy_parse_error(
333    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
334    position: usize,
335    error: crate::model_policy::ModelPolicyRuleParseError,
336) {
337    use crate::diagnostic::{PathSegment, key_path};
338    let mut rule_path = key_path("model-policies");
339    rule_path.push(PathSegment::Index(position));
340    let child = |key: &str| {
341        let mut path = rule_path.clone();
342        path.push(PathSegment::Key(key.to_owned()));
343        path
344    };
345    let grandchild = |first: &str, second: &str| {
346        let mut path = child(first);
347        path.push(PathSegment::Key(second.to_owned()));
348        path
349    };
350    match error {
351        crate::model_policy::ModelPolicyRuleParseError::RuleMustBeMapping { found } => {
352            push_agent_invalid(diags, rule_path, found, "mapping with match and override");
353        }
354        crate::model_policy::ModelPolicyRuleParseError::MatchMissing => {
355            push_agent_invalid(
356                diags,
357                child("match"),
358                "<missing>",
359                "mapping with exactly one of model, alias, model-glob",
360            );
361        }
362        crate::model_policy::ModelPolicyRuleParseError::MatchMustBeMapping { found } => {
363            push_agent_invalid(
364                diags,
365                child("match"),
366                found,
367                "mapping with exactly one of model, alias, model-glob",
368            );
369        }
370        crate::model_policy::ModelPolicyRuleParseError::MatchMustContainExactlyOne { found } => {
371            push_agent_invalid(
372                diags,
373                child("match"),
374                found,
375                "exactly one of model, alias, model-glob",
376            );
377        }
378        crate::model_policy::ModelPolicyRuleParseError::MatchKeyMustBeString { found } => {
379            push_agent_invalid(diags, child("match"), found, "model, alias, model-glob");
380        }
381        crate::model_policy::ModelPolicyRuleParseError::UnknownMatchKey { key } => {
382            push_agent_invalid(diags, child("match"), key, "model, alias, model-glob");
383        }
384        crate::model_policy::ModelPolicyRuleParseError::MatchValueMustBeString { key, found } => {
385            push_agent_invalid(diags, grandchild("match", &key), found, "non-empty string");
386        }
387        crate::model_policy::ModelPolicyRuleParseError::MatchValueEmpty { key } => {
388            push_agent_invalid(
389                diags,
390                grandchild("match", &key),
391                "<empty>",
392                "non-empty string",
393            );
394        }
395        crate::model_policy::ModelPolicyRuleParseError::OverrideMustBeMapping { found } => {
396            push_agent_invalid(diags, child("override"), found, "mapping");
397        }
398        crate::model_policy::ModelPolicyRuleParseError::UnknownOverrideKey { key } => {
399            push_agent_invalid(
400                diags,
401                grandchild("override", &key),
402                key,
403                "effort, sandbox, approval, network",
404            );
405        }
406        crate::model_policy::ModelPolicyRuleParseError::NetworkHostsNotAllowed { key } => {
407            push_agent_invalid(
408                diags,
409                grandchild("override", &key),
410                key,
411                "network host sets on settings, profile, overlay, or CLI",
412            );
413        }
414        crate::model_policy::ModelPolicyRuleParseError::OverrideValueInvalid {
415            key,
416            found,
417            allowed,
418        } => {
419            push_agent_invalid(diags, grandchild("override", &key), found, allowed);
420        }
421        crate::model_policy::ModelPolicyRuleParseError::NoFallbackMustBeBoolean { found } => {
422            push_agent_invalid(diags, child("no-fallback"), found, "boolean");
423        }
424        crate::model_policy::ModelPolicyRuleParseError::WhenInvalid { found } => {
425            push_agent_invalid(diags, child("when"), found, "non-empty one-line string");
426        }
427    }
428}
429
430fn parse_model_policies(
431    val: &Value,
432    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
433) -> Vec<ModelPolicyRule> {
434    let Some(seq) = val.as_sequence() else {
435        push_agent_invalid(
436            diags,
437            crate::diagnostic::key_path("model-policies"),
438            crate::ordered::plain(val),
439            "sequence of rules",
440        );
441        return vec![];
442    };
443
444    let mut out = Vec::new();
445    for (index, entry) in seq.iter().enumerate() {
446        match crate::model_policy::parse_model_policy_rule_value(entry) {
447            Ok(rule) => out.push(rule),
448            Err(error) => push_model_policy_parse_error(diags, index, error),
449        }
450    }
451    out
452}
453
454fn parse_invocability_field(
455    field: &str,
456    raw: Option<&Value>,
457    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
458) -> (bool, bool) {
459    let (value, had_field, invalid) = parse_invocability_axis(raw);
460    if let Some(invalid) = invalid {
461        push_agent_invalid(
462            diags,
463            crate::diagnostic::key_path(field),
464            invalid,
465            "boolean",
466        );
467    }
468    (value, had_field)
469}
470
471fn read_string(
472    value: &Value,
473    field: &'static str,
474    non_empty: bool,
475    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
476) -> Option<String> {
477    let Some(value) = value.as_str() else {
478        diags.push(crate::diagnostic::SchemaDiagnostic::new(
479            "field.type",
480            crate::diagnostic::Severity::Error,
481            crate::diagnostic::key_path(field),
482            format!("`{field}` must be a string"),
483        ));
484        return None;
485    };
486    if non_empty {
487        let value = value.trim();
488        if value.is_empty() {
489            diags.push(crate::diagnostic::SchemaDiagnostic::new(
490                "field.value",
491                crate::diagnostic::Severity::Error,
492                crate::diagnostic::key_path(field),
493                format!("`{field}` must be a non-empty string"),
494            ));
495            return None;
496        }
497        return Some(value.to_owned());
498    }
499    Some(value.to_owned())
500}
501
502fn read_string_list(
503    value: Option<&Value>,
504    path: Vec<crate::diagnostic::PathSegment>,
505    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
506) -> Vec<String> {
507    use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity, display_path};
508    let Some(value) = value else {
509        return Vec::new();
510    };
511    let Some(values) = value.as_sequence() else {
512        let display = display_path(&path);
513        diags.push(SchemaDiagnostic::new(
514            "field.type",
515            Severity::Error,
516            path,
517            format!("`{display}` must be a list of non-empty strings"),
518        ));
519        return Vec::new();
520    };
521    let display = display_path(&path);
522    values
523        .iter()
524        .enumerate()
525        .filter_map(|(index, value)| {
526            if let Value::String(text) = value
527                && !text.trim().is_empty()
528            {
529                return Some(text.clone());
530            }
531            let mut entry_path = path.clone();
532            entry_path.push(PathSegment::Index(index));
533            diags.push(SchemaDiagnostic::new(
534                if value.is_string() {
535                    "field.value"
536                } else {
537                    "field.type"
538                },
539                Severity::Error,
540                entry_path,
541                format!("`{display}` entry {} must be a non-empty string", index + 1),
542            ));
543            None
544        })
545        .collect()
546}
547
548/// A host set: a list of hosts, or for an allow set `{replace: [...]}`, which clears the lower
549/// layers' hosts. A package profile cannot replace a deny set, so a project's deny always applies.
550fn read_host_set(
551    field: &'static str,
552    value: &Value,
553    allow_replace: bool,
554    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
555) -> PermissionSet<NetworkHost> {
556    use crate::diagnostic::{PathSegment, key_path};
557    const SHAPE: &str = "list of exact hosts, IPs, *.example.com, or **.example.com; or {replace: [...]} for an allow set";
558    let mut path = key_path(field);
559    let (replace, entries) = match value {
560        Value::Sequence(entries) => (false, entries),
561        Value::Mapping(mapping)
562            if mapping.len() == 1
563                && mapping
564                    .get("replace")
565                    .is_some_and(|entries| entries.is_sequence()) =>
566        {
567            if !allow_replace {
568                push_agent_invalid(
569                    diags,
570                    path,
571                    crate::ordered::plain(value),
572                    "plain list; package profiles cannot replace a deny set",
573                );
574                return PermissionSet::default();
575            }
576            path.push(PathSegment::Key("replace".to_owned()));
577            let entries = mapping.get("replace").and_then(Value::as_sequence);
578            (true, entries.expect("checked to be a sequence"))
579        }
580        _ => {
581            push_agent_invalid(diags, path, crate::ordered::plain(value), SHAPE);
582            return PermissionSet::default();
583        }
584    };
585    let hosts = entries
586        .iter()
587        .enumerate()
588        .filter_map(|(index, entry)| {
589            let parsed = entry
590                .as_str()
591                .ok_or_else(|| crate::ordered::plain(entry))
592                .and_then(|text| text.parse::<NetworkHost>().map_err(|_| text.to_owned()));
593            parsed
594                .map_err(|found| {
595                    let mut entry_path = path.clone();
596                    entry_path.push(PathSegment::Index(index));
597                    push_agent_invalid(diags, entry_path, found, NetworkHost::ALLOWED);
598                })
599                .ok()
600        })
601        .collect();
602    if replace {
603        PermissionSet::Replace { replace: hosts }
604    } else {
605        PermissionSet::Add(hosts)
606    }
607}
608
609fn read_skills(value: &Value, diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>) -> SkillsSpec {
610    use crate::diagnostic::{PathSegment, key_path};
611    let root = key_path("skills");
612    let Value::Mapping(mapping) = value else {
613        return SkillsSpec {
614            load: read_string_list(Some(value), root, diags),
615            available: Vec::new(),
616        };
617    };
618    let read_axis = |name: &str, diags: &mut Vec<_>| {
619        let mut path = root.clone();
620        path.push(PathSegment::Key(name.to_owned()));
621        read_string_list(mapping.get(Value::String(name.to_owned())), path, diags)
622    };
623    SkillsSpec {
624        load: read_axis("load", diags),
625        available: read_axis("available", diags),
626    }
627}
628
629pub(crate) fn read_fields(
630    fm: &Frontmatter,
631    diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
632) -> AgentProfile {
633    use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
634
635    let mut profile = AgentProfile {
636        name: None,
637        description: None,
638        harness: None,
639        model: None,
640        mode: None,
641        model_invocable: true,
642        user_invocable: true,
643        had_model_invocable_field: false,
644        had_user_invocable_field: false,
645        approval: None,
646        sandbox: None,
647        network: None,
648        network_allow: PermissionSet::default(),
649        network_deny: PermissionSet::default(),
650        effort: None,
651        skills: SkillsSpec::default(),
652        subagents: Vec::new(),
653        tools: EffectiveToolPolicy::default(),
654        harness_overrides: HarnessOverrides::default(),
655        model_policies: Vec::new(),
656    };
657
658    for key in fm.keys() {
659        let value = fm.get(&key).expect("frontmatter key came from mapping");
660        match key.as_str() {
661            "name" => profile.name = read_string(value, "name", true, diags),
662            "description" => {
663                profile.description = read_string(value, "description", false, diags);
664            }
665            "harness" => {
666                profile.harness = read_string(value, "harness", false, diags).and_then(|name| {
667                    crate::harness::parse(&name).or_else(|| {
668                        push_unknown_harness(diags, &name);
669                        None
670                    })
671                });
672            }
673            "model" => profile.model = read_string(value, "model", true, diags),
674            "mode" => {
675                profile.mode =
676                    read_string(value, "mode", false, diags).and_then(|mode| match mode.as_str() {
677                        "primary" => Some(AgentMode::Primary),
678                        "subagent" => Some(AgentMode::Subagent),
679                        other => {
680                            push_agent_invalid(
681                                diags,
682                                key_path("mode"),
683                                other.to_string(),
684                                "primary, subagent",
685                            );
686                            None
687                        }
688                    });
689            }
690            "model-invocable" => {
691                (profile.model_invocable, profile.had_model_invocable_field) =
692                    parse_invocability_field("model-invocable", Some(value), diags);
693            }
694            "user-invocable" => {
695                (profile.user_invocable, profile.had_user_invocable_field) =
696                    parse_invocability_field("user-invocable", Some(value), diags);
697            }
698            "approval" => {
699                profile.approval =
700                    read_string(value, "approval", false, diags).and_then(|approval| {
701                        if let Some(removed) =
702                            RemovedValue::find(REMOVED_AGENT_VALUES, "approval", &approval)
703                        {
704                            diags.push(removed.diagnostic());
705                            return None;
706                        }
707                        ApprovalMode::parse(&approval).or_else(|| {
708                            push_agent_invalid(
709                                diags,
710                                key_path("approval"),
711                                approval,
712                                ApprovalMode::ALLOWED,
713                            );
714                            None
715                        })
716                    });
717            }
718            "permission" => diags.push(removed_permission(value)),
719            "sandbox" => {
720                profile.sandbox = read_string(value, "sandbox", false, diags).and_then(|sandbox| {
721                    SandboxMode::parse(&sandbox).or_else(|| {
722                        push_agent_invalid(
723                            diags,
724                            key_path("sandbox"),
725                            sandbox,
726                            SandboxMode::ALLOWED,
727                        );
728                        None
729                    })
730                });
731            }
732            "network" => {
733                profile.network = read_string(value, "network", false, diags).and_then(|network| {
734                    NetworkPolicy::parse(&network).or_else(|| {
735                        push_agent_invalid(
736                            diags,
737                            key_path("network"),
738                            network,
739                            NetworkPolicy::ALLOWED,
740                        );
741                        None
742                    })
743                });
744            }
745            "network-allow" => {
746                profile.network_allow = read_host_set("network-allow", value, true, diags);
747            }
748            "network-deny" => {
749                profile.network_deny = read_host_set("network-deny", value, false, diags);
750            }
751            "effort" => {
752                profile.effort = read_string(value, "effort", false, diags).and_then(|effort| {
753                    if let Some(removed) =
754                        RemovedValue::find(REMOVED_AGENT_VALUES, "effort", &effort)
755                    {
756                        diags.push(removed.diagnostic());
757                        return None;
758                    }
759                    EffortLevel::parse(&effort).or_else(|| {
760                        push_agent_invalid(diags, key_path("effort"), effort, EffortLevel::ALLOWED);
761                        None
762                    })
763                });
764            }
765            "skills" => profile.skills = read_skills(value, diags),
766            "subagents" => {
767                profile.subagents = read_string_list(Some(value), key_path("subagents"), diags);
768            }
769            "tools" => {
770                if value.is_sequence() {
771                    diags.push(SchemaDiagnostic::new(
772                        "tools.list-form",
773                        Severity::Error,
774                        key_path("tools"),
775                        "`tools` must be an action map; list form was removed",
776                    ));
777                } else {
778                    let mut invalid = |path, value: &str, allowed: &'static str| {
779                        push_agent_invalid(diags, path, value, allowed);
780                    };
781                    profile.tools = tool_policy::parse_tool_policy(Some(value), &mut invalid);
782                }
783            }
784            "harness-overrides" => {
785                profile.harness_overrides = parse_harness_overrides("agent", value, diags);
786            }
787            "model-policies" => profile.model_policies = parse_model_policies(value, diags),
788            _ => diags.push(unrecognized_field(REMOVED_AGENT_FIELDS, &key)),
789        }
790    }
791
792    profile
793}
794
795/// What the reader tells an author whose top-level `permission` schema 1 removed.
796///
797/// Meridian Flow defines `read` and `edit`, and OpenCode's format is a map; any other value names where it
798/// can live instead of sending the author to a block that would reject it again.
799fn removed_permission(value: &Value) -> crate::diagnostic::SchemaDiagnostic {
800    use crate::diagnostic::{SchemaDiagnostic, Severity, key_path};
801    match value {
802        Value::Mapping(_) => SchemaDiagnostic::new(
803            "field.removed",
804            Severity::Error,
805            key_path("permission"),
806            "`permission` as a map is OpenCode's format; put it under \
807             `harness-overrides.opencode.permission`",
808        ),
809        Value::String(permission) if matches!(permission.as_str(), "read" | "edit") => {
810            unrecognized_field(REMOVED_AGENT_FIELDS, "permission")
811        }
812        _ => SchemaDiagnostic::new(
813            "field.removed",
814            Severity::Error,
815            key_path("permission"),
816            "`permission` was removed; Meridian Flow reads only `read` or `edit`, at \
817             `harness-overrides.meridian-flow.permission`. Grant what it allowed with \
818             `tools: {<capability>: allow}`",
819        ),
820    }
821}
822
823/// Read an Agent definition through the versioned schema contract.
824pub fn read(content: &str) -> crate::read::DefinitionRead<AgentProfile> {
825    crate::read::read_with(content, read_fields)
826}