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