use crate::compiler::agents::{AgentProfile, HarnessKind, OverrideFields};
use crate::frontmatter::Frontmatter;
#[derive(Debug, Clone)]
pub struct LossyField {
pub field: String,
pub target: String,
pub classification: Lossiness,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Lossiness {
Approximate { note: &'static str },
Dropped,
MeridianOnly,
}
pub struct LoweredOutput {
pub bytes: Vec<u8>,
pub lossy_fields: Vec<LossyField>,
}
struct Effective<'a> {
profile: &'a AgentProfile,
over: Option<&'a OverrideFields>,
}
impl<'a> Effective<'a> {
fn new(profile: &'a AgentProfile, harness: &HarnessKind) -> Self {
let over = profile.harness_overrides.get(harness);
Self { profile, over }
}
fn effort(&self) -> Option<&crate::compiler::agents::EffortLevel> {
self.over
.and_then(|o| o.effort.as_ref())
.or(self.profile.effort.as_ref())
}
fn approval(&self) -> Option<&crate::compiler::agents::ApprovalMode> {
self.over
.and_then(|o| o.approval.as_ref())
.or(self.profile.approval.as_ref())
}
fn sandbox(&self) -> Option<&crate::compiler::agents::SandboxMode> {
self.over
.and_then(|o| o.sandbox.as_ref())
.or(self.profile.sandbox.as_ref())
}
fn skills(&self) -> &[String] {
if let Some(ov) = self.over.and_then(|o| o.skills.as_ref()) {
return ov;
}
&self.profile.skills
}
fn tools(&self) -> &[String] {
if let Some(ov) = self.over.and_then(|o| o.tools.as_ref()) {
return ov;
}
&self.profile.tools
}
fn disallowed_tools(&self) -> &[String] {
if let Some(ov) = self.over.and_then(|o| o.disallowed_tools.as_ref()) {
return ov;
}
&self.profile.disallowed_tools
}
}
pub fn lower_to_claude(profile: &AgentProfile, _fm: &Frontmatter, body: &str) -> LoweredOutput {
let eff = Effective::new(profile, &HarnessKind::Claude);
let mut lossy = Vec::new();
let mut yaml = serde_yaml::Mapping::new();
let yk = |s: &str| serde_yaml::Value::String(s.to_string());
let yv = |s: &str| serde_yaml::Value::String(s.to_string());
if let Some(name) = &profile.name {
yaml.insert(yk("name"), yv(name));
}
if let Some(desc) = &profile.description {
yaml.insert(yk("description"), yv(desc));
}
if let Some(model) = &profile.model {
yaml.insert(yk("model"), yv(model));
}
let skills = eff.skills();
if !skills.is_empty() {
let seq: serde_yaml::Value =
serde_yaml::Value::Sequence(skills.iter().map(|s| yv(s)).collect());
yaml.insert(yk("skills"), seq);
}
let tools = eff.tools();
if !tools.is_empty() {
let seq: serde_yaml::Value =
serde_yaml::Value::Sequence(tools.iter().map(|s| yv(s)).collect());
yaml.insert(yk("tools"), seq);
}
let dt = eff.disallowed_tools();
if !dt.is_empty() {
let seq: serde_yaml::Value =
serde_yaml::Value::Sequence(dt.iter().map(|s| yv(s)).collect());
yaml.insert(yk("disallowed-tools"), seq);
}
let mcp = &profile.mcp_tools;
if !mcp.is_empty() {
let seq: serde_yaml::Value =
serde_yaml::Value::Sequence(mcp.iter().map(|s| yv(s)).collect());
yaml.insert(yk("mcp-tools"), seq);
}
if let Some(effort) = eff.effort() {
yaml.insert(yk("effort"), yv(effort.claude_str()));
}
let target = "Claude";
if profile.approval.is_some() {
lossy.push(LossyField {
field: "approval".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if profile.sandbox.is_some() {
lossy.push(LossyField {
field: "sandbox".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if profile.mode.is_some() {
lossy.push(LossyField {
field: "mode".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if profile.autocompact.is_some() {
lossy.push(LossyField {
field: "autocompact".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.model_policies.is_empty() {
lossy.push(LossyField {
field: "model-policies".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.fanout.is_empty() {
lossy.push(LossyField {
field: "fanout".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
let yaml_str = if yaml.is_empty() {
String::new()
} else {
let mut s = serde_yaml::to_string(&yaml).unwrap_or_default();
if let Some(stripped) = s.strip_prefix("---\n") {
s = stripped.to_string();
}
s
};
let out = if yaml.is_empty() && body.is_empty() {
String::new()
} else if yaml.is_empty() {
body.to_string()
} else {
format!("---\n{}---\n{}", yaml_str, body)
};
LoweredOutput {
bytes: out.into_bytes(),
lossy_fields: lossy,
}
}
pub fn lower_to_codex(profile: &AgentProfile, body: &str) -> LoweredOutput {
let eff = Effective::new(profile, &HarnessKind::Codex);
let mut lossy = Vec::new();
let target = "Codex";
let name = profile.name.as_deref().unwrap_or("");
let description = profile.description.as_deref().unwrap_or("");
let model = profile.model.as_deref().unwrap_or("");
let effort_str = eff.effort().map(|e| e.as_str()).unwrap_or("");
let sandbox_str = eff.sandbox().map(|s| s.as_str()).unwrap_or("");
let approval_policy = eff
.approval()
.map(|a| {
use crate::compiler::agents::ApprovalMode;
match a {
ApprovalMode::Default => "",
ApprovalMode::Auto => "on-request",
ApprovalMode::Confirm => "untrusted",
ApprovalMode::Yolo => "bypass",
}
})
.unwrap_or("");
let skills = eff.skills();
if !skills.is_empty() {
lossy.push(LossyField {
field: "skills".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
let tools = eff.tools();
if !tools.is_empty() {
lossy.push(LossyField {
field: "tools".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
let dt = eff.disallowed_tools();
if !dt.is_empty() {
lossy.push(LossyField {
field: "disallowed-tools".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if !profile.mcp_tools.is_empty() {
lossy.push(LossyField {
field: "mcp-tools".into(),
target: target.into(),
classification: Lossiness::Approximate {
note: "Codex uses -c mcp.servers.<name>.command",
},
});
}
if profile.mode.is_some() {
lossy.push(LossyField {
field: "mode".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if profile.autocompact.is_some() {
lossy.push(LossyField {
field: "autocompact".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.model_policies.is_empty() {
lossy.push(LossyField {
field: "model-policies".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.fanout.is_empty() {
lossy.push(LossyField {
field: "fanout".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
let mut out = String::new();
out.push_str("[agent]\n");
out.push_str(&format!("name = {}\n", toml_str(name)));
if !description.is_empty() {
out.push_str(&format!("description = {}\n", toml_str(description)));
}
if !model.is_empty() {
out.push_str(&format!("model = {}\n", toml_str(model)));
}
let has_config =
!effort_str.is_empty() || !sandbox_str.is_empty() || !approval_policy.is_empty();
if has_config {
out.push_str("\n[agent.config]\n");
if !effort_str.is_empty() {
out.push_str(&format!(
"model_reasoning_effort = {}\n",
toml_str(effort_str)
));
}
if !sandbox_str.is_empty() {
out.push_str(&format!("sandbox_mode = {}\n", toml_str(sandbox_str)));
}
if !approval_policy.is_empty() {
out.push_str(&format!(
"approval_policy = {}\n",
toml_str(approval_policy)
));
}
}
if !body.is_empty() {
out.push_str("\n[agent.instructions]\n");
out.push_str(&format!("content = \"\"\"\n{}\n\"\"\"\n", body.trim_end()));
}
LoweredOutput {
bytes: out.into_bytes(),
lossy_fields: lossy,
}
}
fn toml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
pub fn lower_to_opencode(profile: &AgentProfile, body: &str) -> LoweredOutput {
let eff = Effective::new(profile, &HarnessKind::OpenCode);
let mut lossy = Vec::new();
let target = "OpenCode";
let mut yaml = serde_yaml::Mapping::new();
let yk = |s: &str| serde_yaml::Value::String(s.to_string());
let yv = |s: &str| serde_yaml::Value::String(s.to_string());
if let Some(name) = &profile.name {
yaml.insert(yk("name"), yv(name));
}
if let Some(desc) = &profile.description {
yaml.insert(yk("description"), yv(desc));
}
if let Some(model) = &profile.model {
yaml.insert(yk("model"), yv(model));
}
if let Some(mode) = &profile.mode {
yaml.insert(yk("mode"), yv(mode.as_str()));
lossy.push(LossyField {
field: "mode".into(),
target: target.into(),
classification: Lossiness::Approximate {
note: "OpenCode uses the same mode concept",
},
});
}
if eff.approval().is_some() {
lossy.push(LossyField {
field: "approval".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if eff.sandbox().is_some() {
lossy.push(LossyField {
field: "sandbox".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if !eff.tools().is_empty() {
lossy.push(LossyField {
field: "tools".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if !eff.disallowed_tools().is_empty() {
lossy.push(LossyField {
field: "disallowed-tools".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if eff.effort().is_some() {
lossy.push(LossyField {
field: "effort".into(),
target: target.into(),
classification: Lossiness::Approximate {
note: "effort maps to --variant on subprocess only",
},
});
}
if !profile.mcp_tools.is_empty() {
lossy.push(LossyField {
field: "mcp-tools".into(),
target: target.into(),
classification: Lossiness::Approximate {
note: "mcp-tools on subprocess errors; streaming uses session payload",
},
});
}
if profile.autocompact.is_some() {
lossy.push(LossyField {
field: "autocompact".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.model_policies.is_empty() {
lossy.push(LossyField {
field: "model-policies".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.fanout.is_empty() {
lossy.push(LossyField {
field: "fanout".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
let yaml_str = if yaml.is_empty() {
String::new()
} else {
let mut s = serde_yaml::to_string(&yaml).unwrap_or_default();
if let Some(stripped) = s.strip_prefix("---\n") {
s = stripped.to_string();
}
s
};
let out = if yaml.is_empty() {
body.to_string()
} else {
format!("---\n{}---\n{}", yaml_str, body)
};
LoweredOutput {
bytes: out.into_bytes(),
lossy_fields: lossy,
}
}
pub fn lower_to_pi(profile: &AgentProfile, body: &str) -> LoweredOutput {
let mut lossy = Vec::new();
let target = "Pi";
let mut yaml = serde_yaml::Mapping::new();
let yk = |s: &str| serde_yaml::Value::String(s.to_string());
let yv = |s: &str| serde_yaml::Value::String(s.to_string());
if let Some(name) = &profile.name {
yaml.insert(yk("name"), yv(name));
}
if let Some(desc) = &profile.description {
yaml.insert(yk("description"), yv(desc));
}
if let Some(model) = &profile.model {
yaml.insert(yk("model"), yv(model));
}
if let Some(mode) = &profile.mode {
yaml.insert(yk("mode"), yv(mode.as_str()));
lossy.push(LossyField {
field: "mode".into(),
target: target.into(),
classification: Lossiness::Approximate {
note: "Pi may use the same mode concept",
},
});
}
let eff = Effective::new(profile, &HarnessKind::Pi);
if eff.approval().is_some() {
lossy.push(LossyField {
field: "approval".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if eff.sandbox().is_some() {
lossy.push(LossyField {
field: "sandbox".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if !eff.tools().is_empty() {
lossy.push(LossyField {
field: "tools".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if !eff.disallowed_tools().is_empty() {
lossy.push(LossyField {
field: "disallowed-tools".into(),
target: target.into(),
classification: Lossiness::Dropped,
});
}
if eff.effort().is_some() {
lossy.push(LossyField {
field: "effort".into(),
target: target.into(),
classification: Lossiness::Approximate {
note: "Pi effort semantics unverified",
},
});
}
if profile.autocompact.is_some() {
lossy.push(LossyField {
field: "autocompact".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.model_policies.is_empty() {
lossy.push(LossyField {
field: "model-policies".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
if !profile.fanout.is_empty() {
lossy.push(LossyField {
field: "fanout".into(),
target: target.into(),
classification: Lossiness::MeridianOnly,
});
}
let yaml_str = if yaml.is_empty() {
String::new()
} else {
let mut s = serde_yaml::to_string(&yaml).unwrap_or_default();
if let Some(stripped) = s.strip_prefix("---\n") {
s = stripped.to_string();
}
s
};
let out = if yaml.is_empty() {
body.to_string()
} else {
format!("---\n{}---\n{}", yaml_str, body)
};
LoweredOutput {
bytes: out.into_bytes(),
lossy_fields: lossy,
}
}
pub fn lower_for_harness(
harness: &HarnessKind,
profile: &AgentProfile,
fm: &Frontmatter,
body: &str,
) -> LoweredOutput {
match harness {
HarnessKind::Claude => lower_to_claude(profile, fm, body),
HarnessKind::Codex => lower_to_codex(profile, body),
HarnessKind::OpenCode => lower_to_opencode(profile, body),
HarnessKind::Pi => lower_to_pi(profile, body),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compiler::agents::{AgentDiagnostic, parse_agent_content};
fn profile_from(content: &str) -> (AgentProfile, Frontmatter, Vec<AgentDiagnostic>) {
let mut diags = Vec::new();
let (profile, fm) = parse_agent_content(content, &mut diags).unwrap();
(profile, fm, diags)
}
#[test]
fn claude_lowering_preserves_name_description_model_skills_tools_body() {
let content = "---\nname: coder\ndescription: Code impl agent\nmodel: gpt55\nharness: claude\nskills: [dev-principles]\ntools: [Bash, Write]\n---\n# Coder\nYou write code.";
let (profile, fm, _) = profile_from(content);
let body = fm.body();
let out = lower_to_claude(&profile, &fm, body);
let text = String::from_utf8(out.bytes).unwrap();
assert!(text.contains("name: coder"), "name missing: {text}");
assert!(
text.contains("description: Code impl agent"),
"desc missing"
);
assert!(text.contains("model: gpt55"), "model missing");
assert!(text.contains("skills"), "skills missing");
assert!(text.contains("tools"), "tools missing");
assert!(text.contains("# Coder"), "body missing");
}
#[test]
fn claude_lowering_drops_approval_sandbox_mode_autocompact() {
let content = "---\nname: coder\nharness: claude\napproval: auto\nsandbox: read-only\nmode: subagent\nautocompact: 50\n---\n# Body";
let (profile, fm, _) = profile_from(content);
let out = lower_to_claude(&profile, &fm, fm.body());
let text = String::from_utf8(out.bytes).unwrap();
assert!(!text.contains("approval:"), "approval leaked: {text}");
assert!(!text.contains("sandbox:"), "sandbox leaked: {text}");
assert!(!text.contains("autocompact:"), "autocompact leaked: {text}");
let dropped: Vec<_> = out.lossy_fields.iter().map(|f| f.field.as_str()).collect();
assert!(
dropped.contains(&"approval"),
"approval not in lossy: {dropped:?}"
);
assert!(
dropped.contains(&"sandbox"),
"sandbox not in lossy: {dropped:?}"
);
assert!(
dropped.contains(&"autocompact"),
"autocompact not in lossy: {dropped:?}"
);
}
#[test]
fn claude_harness_override_applied_before_lowering() {
let content = "---\nname: r\nharness: claude\nskills: [base-skill]\nharness-overrides:\n claude:\n skills: [override-skill]\n---\n# body";
let (profile, fm, _) = profile_from(content);
let out = lower_to_claude(&profile, &fm, fm.body());
let text = String::from_utf8(out.bytes).unwrap();
assert!(
text.contains("override-skill"),
"override not applied: {text}"
);
assert!(
!text.contains("base-skill"),
"base skill not overridden: {text}"
);
}
#[test]
fn claude_meridian_only_fields_dropped() {
let content = "---\nname: r\nharness: claude\nmodel-policies:\n - match:\n model: gpt55\n override:\n harness: codex\nfanout:\n - alias: opus\n---\n# body";
let (profile, fm, _) = profile_from(content);
let out = lower_to_claude(&profile, &fm, fm.body());
let text = String::from_utf8(out.bytes).unwrap();
assert!(
!text.contains("model-policies:"),
"model-policies leaked: {text}"
);
assert!(!text.contains("fanout:"), "fanout leaked: {text}");
let meridian_only: Vec<_> = out
.lossy_fields
.iter()
.filter(|f| matches!(f.classification, Lossiness::MeridianOnly))
.map(|f| f.field.as_str())
.collect();
assert!(meridian_only.contains(&"model-policies"));
assert!(meridian_only.contains(&"fanout"));
}
#[test]
fn codex_lowering_produces_toml_with_agent_section() {
let content = "---\nname: coder\ndescription: Code agent\nmodel: gpt55\nharness: codex\neffort: high\nsandbox: workspace-write\napproval: auto\n---\n# Coder\nYou code.";
let (profile, fm, _) = profile_from(content);
let out = lower_to_codex(&profile, fm.body());
let text = String::from_utf8(out.bytes).unwrap();
assert!(text.contains("[agent]"), "no [agent] section: {text}");
assert!(text.contains("name = \"coder\""), "name missing");
assert!(text.contains("model = \"gpt55\""), "model missing");
assert!(text.contains("[agent.config]"), "no config section");
assert!(
text.contains("model_reasoning_effort = \"high\""),
"effort missing"
);
assert!(
text.contains("sandbox_mode = \"workspace-write\""),
"sandbox missing"
);
assert!(
text.contains("approval_policy = \"on-request\""),
"approval missing"
);
assert!(
text.contains("[agent.instructions]"),
"no instructions section"
);
}
#[test]
fn codex_lowering_drops_skills_and_tools() {
let content = "---\nname: r\nharness: codex\nskills: [review]\ntools: [Bash]\ndisallowed-tools: [Agent]\n---\n# body";
let (profile, fm, _) = profile_from(content);
let out = lower_to_codex(&profile, fm.body());
let dropped: Vec<_> = out
.lossy_fields
.iter()
.filter(|f| matches!(f.classification, Lossiness::Dropped))
.map(|f| f.field.as_str())
.collect();
assert!(dropped.contains(&"skills"));
assert!(dropped.contains(&"tools"));
assert!(dropped.contains(&"disallowed-tools"));
}
#[test]
fn codex_harness_override_applied() {
let content = "---\nname: r\nharness: codex\neffort: low\nharness-overrides:\n codex:\n effort: high\n sandbox: workspace-write\n---\n# body";
let (profile, fm, _) = profile_from(content);
let out = lower_to_codex(&profile, fm.body());
let text = String::from_utf8(out.bytes).unwrap();
assert!(
text.contains("model_reasoning_effort = \"high\""),
"override not applied: {text}"
);
assert!(
text.contains("sandbox_mode = \"workspace-write\""),
"sandbox override not applied: {text}"
);
}
#[test]
fn opencode_lowering_preserves_name_description_model_mode() {
let content = "---\nname: r\ndescription: Reviewer\nmodel: gpt55\nmode: primary\nharness: opencode\n---\n# Reviewer\nbody";
let (profile, fm, _) = profile_from(content);
let out = lower_to_opencode(&profile, fm.body());
let text = String::from_utf8(out.bytes).unwrap();
assert!(text.contains("name: r"), "name missing");
assert!(text.contains("description: Reviewer"), "desc missing");
assert!(text.contains("model: gpt55"), "model missing");
assert!(text.contains("mode: primary"), "mode missing");
}
#[test]
fn pi_lowering_preserves_name_description_model() {
let content = "---\nname: pi-agent\ndescription: Pi agent\nmodel: gpt55\nharness: pi\n---\n# Pi\nbody";
let (profile, fm, _) = profile_from(content);
let out = lower_to_pi(&profile, fm.body());
let text = String::from_utf8(out.bytes).unwrap();
assert!(text.contains("name: pi-agent"), "name missing");
assert!(text.contains("description: Pi agent"), "desc missing");
}
#[test]
fn lower_for_harness_dispatches_correctly() {
let content = "---\nname: coder\nmodel: gpt55\nharness: claude\n---\n# body";
let (profile, fm, _) = profile_from(content);
let body = fm.body().to_string();
let out = lower_for_harness(&HarnessKind::Claude, &profile, &fm, &body);
let text = String::from_utf8(out.bytes).unwrap();
assert!(text.contains("---"), "not markdown format");
let content2 = "---\nname: coder\nmodel: gpt55\nharness: codex\n---\n# body";
let (profile2, fm2, _) = profile_from(content2);
let body2 = fm2.body().to_string();
let out2 = lower_for_harness(&HarnessKind::Codex, &profile2, &fm2, &body2);
let text2 = String::from_utf8(out2.bytes).unwrap();
assert!(text2.contains("[agent]"), "not TOML format");
}
}