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