1use 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
15pub(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
33pub(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#[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 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 pub fn reserves(key: &str) -> bool {
77 Self::ALL.iter().any(|field| field.keys().contains(&key))
78 }
79}
80
81#[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#[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 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#[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 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#[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 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
217impl<'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#[derive(Debug, Clone, serde::Serialize)]
243#[serde(rename_all = "kebab-case")]
244pub struct AgentProfile {
245 pub name: Option<String>,
247 pub description: Option<String>,
248
249 pub harness: Option<HarnessId>,
251
252 pub model: Option<String>,
254
255 pub mode: Option<AgentMode>,
257 pub model_invocable: bool,
258 pub user_invocable: bool,
260 #[serde(skip)]
262 pub had_model_invocable_field: bool,
263 #[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 pub skills: SkillsSpec,
275 pub subagents: Vec<String>,
276 pub tools: EffectiveToolPolicy,
279
280 pub harness_overrides: HarnessOverrides,
282 pub model_policies: Vec<ModelPolicyRule>,
283}
284
285impl AgentProfile {
286 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}
301fn 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
540fn 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
786pub fn read(content: &str) -> crate::read::DefinitionRead<AgentProfile> {
788 crate::read::read_with(content, read_fields)
789}