use crate::backend::MobBackendKind;
use crate::runtime_mode::MobRuntimeMode;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ToolConfig {
#[serde(default)]
pub builtins: bool,
#[serde(default)]
pub shell: bool,
#[serde(default)]
pub comms: bool,
#[serde(default)]
pub memory: bool,
#[serde(default)]
pub workgraph: bool,
#[serde(default)]
pub mob: bool,
#[serde(default)]
pub schedule: bool,
#[serde(default)]
pub image_generation: bool,
#[serde(default)]
pub mcp: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<meerkat_core::mcp_config::McpServerConfig>,
#[serde(default)]
pub rust_bundles: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum ProfileBinding {
RealmRef {
realm_profile: String,
},
Inline(Box<Profile>),
}
impl ProfileBinding {
pub fn as_inline(&self) -> Option<&Profile> {
match self {
Self::Inline(p) => Some(p),
Self::RealmRef { .. } => None,
}
}
pub fn as_inline_mut(&mut self) -> Option<&mut Profile> {
match self {
Self::Inline(p) => Some(p),
Self::RealmRef { .. } => None,
}
}
pub fn realm_ref_name(&self) -> Option<&str> {
match self {
Self::RealmRef { realm_profile } => Some(realm_profile),
Self::Inline(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum SpawnTooling {
InheritParent {
#[serde(default, skip_serializing_if = "Option::is_none")]
allow_overlay: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
deny_overlay: Option<Vec<String>>,
},
Minimal,
Profile {
source: Box<ProfileSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
allow_overlay: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
deny_overlay: Option<Vec<String>>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProfileSource {
RealmProfile {
name: String,
},
Inline(Box<Profile>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Profile {
pub model: String,
#[serde(default)]
pub provider: Option<meerkat_core::Provider>,
#[serde(default)]
pub self_hosted_server_id: Option<String>,
#[serde(default)]
pub image_generation_provider: Option<meerkat_core::Provider>,
#[serde(default)]
pub auto_compact_threshold: Option<std::num::NonZeroU64>,
#[serde(default)]
pub resume_overrides: Vec<ResumeOverrideField>,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default)]
pub tools: ToolConfig,
#[serde(default)]
pub peer_description: String,
#[serde(default)]
pub external_addressable: bool,
#[serde(default)]
pub backend: Option<MobBackendKind>,
#[serde(default)]
pub runtime_mode: MobRuntimeMode,
#[serde(default)]
pub max_inline_peer_notifications: Option<i32>,
#[serde(default, deserialize_with = "deserialize_output_schema")]
pub output_schema: Option<meerkat_core::MeerkatSchema>,
#[serde(default)]
pub provider_params: Option<meerkat_core::lifecycle::run_primitive::ProviderParamsOverride>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ResumeOverrideField {
Model,
Provider,
ProviderParams,
}
impl Profile {
pub fn resume_override_mask(&self) -> meerkat_core::service::ResumeOverrideMask {
let mut mask = meerkat_core::service::ResumeOverrideMask::default();
for field in &self.resume_overrides {
match field {
ResumeOverrideField::Model => mask.model = true,
ResumeOverrideField::Provider => mask.provider = true,
ResumeOverrideField::ProviderParams => mask.provider_params = true,
}
}
mask
}
}
fn deserialize_output_schema<'de, D>(
deserializer: D,
) -> Result<Option<meerkat_core::MeerkatSchema>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<serde_json::Value>::deserialize(deserializer)?
.map(|value| meerkat_core::MeerkatSchema::new(value).map_err(serde::de::Error::custom))
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_config_serde_roundtrip() {
let config = ToolConfig {
builtins: true,
shell: false,
comms: true,
memory: false,
workgraph: true,
mob: true,
schedule: true,
image_generation: true,
mcp: vec!["server-a".to_string(), "server-b".to_string()],
mcp_servers: vec![],
rust_bundles: vec!["custom-tools".to_string()],
};
let json = serde_json::to_string(&config).unwrap();
let parsed: ToolConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, config);
}
#[test]
fn test_tool_config_toml_roundtrip() {
let config = ToolConfig {
builtins: true,
shell: true,
comms: false,
memory: false,
workgraph: false,
mob: false,
schedule: false,
image_generation: false,
mcp: vec!["mcp-server".to_string()],
mcp_servers: vec![],
rust_bundles: Vec::new(),
};
let toml_str = toml::to_string(&config).unwrap();
let parsed: ToolConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed, config);
}
#[test]
fn test_profile_serde_roundtrip() {
let profile = Profile {
model: "claude-opus-4-8".to_string(),
provider: None,
self_hosted_server_id: None,
image_generation_provider: None,
auto_compact_threshold: None,
resume_overrides: Vec::new(),
skills: vec!["orchestrator-skill".to_string()],
tools: ToolConfig {
builtins: true,
shell: false,
comms: true,
memory: false,
workgraph: true,
mob: true,
schedule: false,
image_generation: false,
mcp: vec![],
mcp_servers: vec![],
rust_bundles: vec![],
},
peer_description: "Orchestrates worker agents".to_string(),
external_addressable: true,
backend: None,
runtime_mode: MobRuntimeMode::AutonomousHost,
max_inline_peer_notifications: None,
output_schema: None,
provider_params: None,
};
let json = serde_json::to_string(&profile).unwrap();
let parsed: Profile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, profile);
}
#[test]
fn test_profile_toml_roundtrip() {
let profile = Profile {
model: "gpt-5.2".to_string(),
provider: None,
self_hosted_server_id: None,
image_generation_provider: None,
auto_compact_threshold: None,
resume_overrides: Vec::new(),
skills: vec!["worker-skill".to_string()],
tools: ToolConfig {
builtins: false,
shell: true,
comms: true,
memory: false,
workgraph: false,
mob: false,
schedule: false,
image_generation: false,
mcp: vec!["code-server".to_string()],
mcp_servers: vec![],
rust_bundles: vec!["custom".to_string()],
},
peer_description: "Writes code".to_string(),
external_addressable: false,
backend: Some(MobBackendKind::External),
runtime_mode: MobRuntimeMode::TurnDriven,
max_inline_peer_notifications: Some(20),
output_schema: None,
provider_params: None,
};
let toml_str = toml::to_string(&profile).unwrap();
let parsed: Profile = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed, profile);
}
#[test]
fn test_tool_config_defaults() {
let config = ToolConfig::default();
assert!(!config.builtins);
assert!(!config.shell);
assert!(!config.comms);
assert!(!config.memory);
assert!(!config.workgraph);
assert!(!config.mob);
assert!(!config.schedule);
assert!(config.mcp.is_empty());
assert!(config.rust_bundles.is_empty());
}
#[test]
fn test_profile_default_fields_from_toml() {
let toml_str = r#"
model = "claude-sonnet-4-5"
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(profile.model, "claude-sonnet-4-5");
assert!(profile.skills.is_empty());
assert_eq!(profile.tools, ToolConfig::default());
assert_eq!(profile.peer_description, "");
assert!(!profile.external_addressable);
assert_eq!(profile.backend, None);
assert_eq!(profile.runtime_mode, MobRuntimeMode::AutonomousHost);
assert_eq!(profile.max_inline_peer_notifications, None);
assert_eq!(profile.provider_params, None);
}
#[test]
fn test_profile_toml_parses_zero_inline_threshold() {
let toml_str = r#"
model = "claude-sonnet-4-5"
max_inline_peer_notifications = 0
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(profile.max_inline_peer_notifications, Some(0));
}
#[test]
fn test_profile_toml_parses_always_inline_threshold() {
let toml_str = r#"
model = "claude-sonnet-4-5"
max_inline_peer_notifications = -1
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(profile.max_inline_peer_notifications, Some(-1));
}
#[test]
fn test_profile_output_schema_validates_at_ingress() {
let profile: Profile = serde_json::from_str(
r#"{"model":"claude-sonnet-4-5","output_schema":{"type":"object"}}"#,
)
.unwrap();
assert!(profile.output_schema.is_some());
assert!(
serde_json::from_str::<Profile>(
r#"{"model":"claude-sonnet-4-5","output_schema":"not an object"}"#,
)
.is_err(),
"an invalid output_schema must be rejected at profile deserialization"
);
}
#[test]
fn test_profile_toml_parses_typed_provider_and_self_hosted_binding() {
let toml_str = r#"
model = "claude-internal-preview"
provider = "anthropic"
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(profile.provider, Some(meerkat_core::Provider::Anthropic));
assert_eq!(profile.self_hosted_server_id, None);
let toml_str = r#"
model = "gemma-4-31b"
provider = "self_hosted"
self_hosted_server_id = "local"
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(profile.provider, Some(meerkat_core::Provider::SelfHosted));
assert_eq!(profile.self_hosted_server_id.as_deref(), Some("local"));
}
#[test]
fn test_profile_provider_parse_is_fail_closed() {
let toml_str = r#"
model = "claude-internal-preview"
provider = "antropic"
"#;
assert!(
toml::from_str::<Profile>(toml_str).is_err(),
"unknown provider names must reject the profile at ingress"
);
}
#[test]
fn test_profile_toml_parses_image_generation_provider() {
let toml_str = r#"
model = "claude-opus-4-8"
image_generation_provider = "gemini"
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(
profile.image_generation_provider,
Some(meerkat_core::Provider::Gemini)
);
}
#[test]
fn test_profile_auto_compact_threshold_rejects_zero() {
let toml_str = r#"
model = "claude-opus-4-8"
auto_compact_threshold = 0
"#;
assert!(
toml::from_str::<Profile>(toml_str).is_err(),
"zero compaction threshold must fail closed at profile ingress"
);
let profile: Profile = toml::from_str(
r#"
model = "claude-opus-4-8"
auto_compact_threshold = 60000
"#,
)
.unwrap();
assert_eq!(
profile.auto_compact_threshold,
std::num::NonZeroU64::new(60_000)
);
}
#[test]
fn test_profile_resume_overrides_parse_and_project_to_mask() {
let toml_str = r#"
model = "claude-opus-4-8"
resume_overrides = ["model", "provider"]
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(
profile.resume_overrides,
vec![ResumeOverrideField::Model, ResumeOverrideField::Provider]
);
let mask = profile.resume_override_mask();
assert!(mask.model);
assert!(mask.provider);
assert!(!mask.provider_params);
assert!(!mask.max_tokens);
}
#[test]
fn test_profile_resume_overrides_reject_unknown_fields() {
let toml_str = r#"
model = "claude-opus-4-8"
resume_overrides = ["model", "everything"]
"#;
assert!(
toml::from_str::<Profile>(toml_str).is_err(),
"resume_overrides vocabulary is closed; unknown entries must fail"
);
}
#[test]
fn test_profile_toml_parses_provider_params() {
let toml_str = r#"
model = "gemini-3-pro-preview"
provider_params = { provider_tag = { provider = "gemini", thinking_budget = 8192, top_k = 20 } }
"#;
let profile: Profile = toml::from_str(toml_str).unwrap();
assert_eq!(
profile.provider_params,
Some(
meerkat_core::lifecycle::run_primitive::ProviderParamsOverride {
provider_tag: Some(
meerkat_core::lifecycle::run_primitive::ProviderTag::Gemini(
meerkat_core::lifecycle::run_primitive::GeminiProviderTag {
thinking_budget: Some(8192),
top_k: Some(20),
..Default::default()
},
)
),
..Default::default()
}
)
);
}
#[test]
fn test_profile_toml_rejects_legacy_flat_provider_params() {
let toml_str = r#"
model = "gemini-3-pro-preview"
provider_params = { thinking_budget = 8192, top_k = 20 }
"#;
toml::from_str::<Profile>(toml_str)
.expect_err("legacy flat provider_params must fail profile parse");
}
#[test]
fn profile_binding_inline_roundtrip() {
let profile = Profile {
model: "claude-opus-4-8".to_string(),
provider: None,
self_hosted_server_id: None,
image_generation_provider: None,
auto_compact_threshold: None,
resume_overrides: Vec::new(),
..Profile {
model: String::new(),
provider: None,
self_hosted_server_id: None,
image_generation_provider: None,
auto_compact_threshold: None,
resume_overrides: Vec::new(),
skills: vec![],
tools: ToolConfig::default(),
peer_description: String::new(),
external_addressable: false,
backend: None,
runtime_mode: MobRuntimeMode::AutonomousHost,
max_inline_peer_notifications: None,
output_schema: None,
provider_params: None,
}
};
let binding = ProfileBinding::Inline(Box::new(profile.clone()));
let json = serde_json::to_string(&binding).unwrap();
let parsed: ProfileBinding = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.as_inline().unwrap().model, "claude-opus-4-8");
}
#[test]
fn profile_binding_realm_ref_roundtrip() {
let binding = ProfileBinding::RealmRef {
realm_profile: "worker-v2".to_string(),
};
let json = serde_json::to_string(&binding).unwrap();
assert!(json.contains("realm_profile"));
let parsed: ProfileBinding = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.realm_ref_name(), Some("worker-v2"));
assert!(parsed.as_inline().is_none());
}
#[test]
fn profile_binding_backward_compat_raw_profile_deserializes_as_inline() {
let profile_json = r#"{"model":"claude-sonnet-4-5"}"#;
let binding: ProfileBinding = serde_json::from_str(profile_json).unwrap();
assert!(binding.as_inline().is_some());
assert_eq!(binding.as_inline().unwrap().model, "claude-sonnet-4-5");
}
#[test]
fn profile_binding_realm_ref_not_confused_with_inline() {
let ref_json = r#"{"realm_profile":"my-profile"}"#;
let binding: ProfileBinding = serde_json::from_str(ref_json).unwrap();
assert!(binding.realm_ref_name().is_some());
assert!(binding.as_inline().is_none());
}
#[test]
fn spawn_tooling_inherit_parent_roundtrip() {
let tooling = SpawnTooling::InheritParent {
allow_overlay: Some(vec!["shell".into()]),
deny_overlay: Some(vec!["memory_search".into()]),
};
let json = serde_json::to_string(&tooling).unwrap();
let parsed: SpawnTooling = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, tooling);
}
#[test]
fn spawn_tooling_minimal_roundtrip() {
let tooling = SpawnTooling::Minimal;
let json = serde_json::to_string(&tooling).unwrap();
let parsed: SpawnTooling = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, tooling);
}
#[test]
fn spawn_tooling_profile_realm_roundtrip() {
let tooling = SpawnTooling::Profile {
source: Box::new(ProfileSource::RealmProfile {
name: "worker-v2".into(),
}),
allow_overlay: None,
deny_overlay: Some(vec!["dangerous_tool".into()]),
};
let json = serde_json::to_string(&tooling).unwrap();
let parsed: SpawnTooling = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, tooling);
}
#[test]
fn spawn_tooling_profile_inline_roundtrip() {
let profile = Profile {
model: "claude-sonnet-4-5".into(),
provider: None,
self_hosted_server_id: None,
image_generation_provider: None,
auto_compact_threshold: None,
resume_overrides: Vec::new(),
skills: vec![],
tools: ToolConfig::default(),
peer_description: String::new(),
external_addressable: false,
backend: None,
runtime_mode: MobRuntimeMode::AutonomousHost,
max_inline_peer_notifications: None,
output_schema: None,
provider_params: None,
};
let tooling = SpawnTooling::Profile {
source: Box::new(ProfileSource::Inline(Box::new(profile))),
allow_overlay: None,
deny_overlay: None,
};
let json = serde_json::to_string(&tooling).unwrap();
let parsed: SpawnTooling = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, tooling);
}
}