use crate::MobBackendKind;
use crate::ids::{BranchId, FlowId, MobId, ProfileName, StepId};
use crate::profile::Profile;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrchestratorConfig {
pub profile: ProfileName,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpServerConfig {
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum SkillSource {
Inline {
content: String,
},
Path {
path: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleWiringRule {
pub a: ProfileName,
pub b: ProfileName,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WiringRules {
#[serde(default)]
pub auto_wire_orchestrator: bool,
#[serde(default)]
pub role_wiring: Vec<RoleWiringRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalBackendConfig {
pub address_base: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BackendConfig {
#[serde(default)]
pub default: MobBackendKind,
#[serde(default)]
pub external: Option<ExternalBackendConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DispatchMode {
#[default]
FanOut,
OneToOne,
FanIn,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CollectionPolicy {
All,
Any,
Quorum { n: u8 },
}
impl Default for CollectionPolicy {
fn default() -> Self {
Self::All
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DependencyMode {
#[default]
All,
Any,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ConditionExpr {
Eq {
path: String,
value: serde_json::Value,
},
In {
path: String,
values: Vec<serde_json::Value>,
},
Gt {
path: String,
value: serde_json::Value,
},
Lt {
path: String,
value: serde_json::Value,
},
And {
exprs: Vec<ConditionExpr>,
},
Or {
exprs: Vec<ConditionExpr>,
},
Not {
expr: Box<ConditionExpr>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlowStepSpec {
pub role: ProfileName,
pub message: String,
#[serde(default)]
pub depends_on: Vec<StepId>,
#[serde(default)]
pub dispatch_mode: DispatchMode,
#[serde(default)]
pub collection_policy: CollectionPolicy,
#[serde(default)]
pub condition: Option<ConditionExpr>,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub expected_schema_ref: Option<String>,
#[serde(default)]
pub branch: Option<BranchId>,
#[serde(default)]
pub depends_on_mode: DependencyMode,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlowSpec {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub steps: IndexMap<StepId, FlowStepSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyMode {
#[default]
Advisory,
Strict,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopologyRule {
pub from_role: ProfileName,
pub to_role: ProfileName,
pub allowed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopologySpec {
pub mode: PolicyMode,
pub rules: Vec<TopologyRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupervisorSpec {
pub role: ProfileName,
pub escalation_threshold: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LimitsSpec {
pub max_flow_duration_ms: Option<u64>,
pub max_step_retries: Option<u32>,
pub max_orphaned_turns: Option<u32>,
#[serde(default)]
pub cancel_grace_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobDefinition {
pub id: MobId,
#[serde(default)]
pub orchestrator: Option<OrchestratorConfig>,
#[serde(default)]
pub profiles: BTreeMap<ProfileName, Profile>,
#[serde(default)]
pub mcp_servers: BTreeMap<String, McpServerConfig>,
#[serde(default)]
pub wiring: WiringRules,
#[serde(default)]
pub skills: BTreeMap<String, SkillSource>,
#[serde(default)]
pub backend: BackendConfig,
#[serde(default)]
pub flows: BTreeMap<FlowId, FlowSpec>,
#[serde(default)]
pub topology: Option<TopologySpec>,
#[serde(default)]
pub supervisor: Option<SupervisorSpec>,
#[serde(default)]
pub limits: Option<LimitsSpec>,
}
#[derive(Deserialize)]
struct TomlMob {
id: MobId,
orchestrator: Option<TomlOrchestrator>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum TomlOrchestrator {
Profile(String),
Config(OrchestratorConfig),
}
#[derive(Deserialize)]
struct TomlDefinition {
mob: TomlMob,
#[serde(default)]
profiles: BTreeMap<ProfileName, Profile>,
#[serde(default)]
mcp: BTreeMap<String, McpServerConfig>,
#[serde(default)]
wiring: WiringRules,
#[serde(default)]
skills: BTreeMap<String, SkillSource>,
#[serde(default)]
backend: BackendConfig,
#[serde(default)]
flows: BTreeMap<FlowId, FlowSpec>,
#[serde(default)]
topology: Option<TopologySpec>,
#[serde(default)]
supervisor: Option<SupervisorSpec>,
#[serde(default)]
limits: Option<LimitsSpec>,
}
impl MobDefinition {
pub fn from_toml(content: &str) -> Result<Self, toml::de::Error> {
let raw: TomlDefinition = toml::from_str(content)?;
let orchestrator = raw.mob.orchestrator.map(|orchestrator| match orchestrator {
TomlOrchestrator::Profile(profile) => OrchestratorConfig {
profile: ProfileName::from(profile),
},
TomlOrchestrator::Config(config) => config,
});
Ok(Self {
id: raw.mob.id,
orchestrator,
profiles: raw.profiles,
mcp_servers: raw.mcp,
wiring: raw.wiring,
skills: raw.skills,
backend: raw.backend,
flows: raw.flows,
topology: raw.topology,
supervisor: raw.supervisor,
limits: raw.limits,
})
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::redundant_clone
)]
mod tests {
use super::*;
use crate::profile::ToolConfig;
fn example_toml() -> &'static str {
r#"
[mob]
id = "code-review"
orchestrator = "lead"
[profiles.lead]
model = "claude-opus-4-6"
skills = ["orchestrator-skill"]
peer_description = "Coordinates code review"
external_addressable = true
[profiles.lead.tools]
builtins = true
comms = true
mob = true
mob_tasks = true
[profiles.reviewer]
model = "claude-sonnet-4-5"
skills = ["reviewer-skill"]
peer_description = "Reviews code for quality"
[profiles.reviewer.tools]
builtins = true
shell = true
comms = true
mob_tasks = true
mcp = ["code-server"]
[mcp.code-server]
command = ["npx", "-y", "@mcp/code-server"]
[mcp.docs-server]
url = "https://docs.example.com/mcp"
[wiring]
auto_wire_orchestrator = true
[[wiring.role_wiring]]
a = "reviewer"
b = "reviewer"
[skills.orchestrator-skill]
source = "inline"
content = "You are the lead code reviewer."
[skills.reviewer-skill]
source = "path"
path = "skills/reviewer.md"
"#
}
#[test]
fn test_mob_definition_from_toml() {
let def = MobDefinition::from_toml(example_toml()).unwrap();
assert_eq!(def.id.as_str(), "code-review");
assert_eq!(def.orchestrator.as_ref().unwrap().profile.as_str(), "lead");
assert_eq!(def.profiles.len(), 2);
assert!(def.profiles.contains_key(&ProfileName::from("lead")));
assert!(def.profiles.contains_key(&ProfileName::from("reviewer")));
let lead = &def.profiles[&ProfileName::from("lead")];
assert_eq!(lead.model, "claude-opus-4-6");
assert!(lead.tools.mob);
assert!(lead.tools.mob_tasks);
assert!(lead.tools.comms);
assert!(lead.external_addressable);
let reviewer = &def.profiles[&ProfileName::from("reviewer")];
assert_eq!(reviewer.model, "claude-sonnet-4-5");
assert!(reviewer.tools.shell);
assert_eq!(reviewer.tools.mcp, vec!["code-server"]);
assert_eq!(def.mcp_servers.len(), 2);
assert!(def.mcp_servers.contains_key("code-server"));
let code_server = &def.mcp_servers["code-server"];
assert_eq!(
code_server.command,
vec![
"npx".to_string(),
"-y".to_string(),
"@mcp/code-server".to_string()
]
);
let docs_server = &def.mcp_servers["docs-server"];
assert_eq!(
docs_server.url.as_ref().unwrap(),
"https://docs.example.com/mcp"
);
assert!(def.wiring.auto_wire_orchestrator);
assert_eq!(def.wiring.role_wiring.len(), 1);
assert_eq!(def.wiring.role_wiring[0].a.as_str(), "reviewer");
assert_eq!(def.wiring.role_wiring[0].b.as_str(), "reviewer");
assert_eq!(def.skills.len(), 2);
match &def.skills["orchestrator-skill"] {
SkillSource::Inline { content } => {
assert_eq!(content, "You are the lead code reviewer.");
}
_ => panic!("expected inline skill"),
}
match &def.skills["reviewer-skill"] {
SkillSource::Path { path } => {
assert_eq!(path, "skills/reviewer.md");
}
_ => panic!("expected path skill"),
}
}
#[test]
fn test_mob_definition_toml_roundtrip() {
let def = MobDefinition::from_toml(example_toml()).unwrap();
let json = serde_json::to_string(&def).unwrap();
let parsed: MobDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, def);
}
#[test]
fn test_mob_definition_json_roundtrip() {
let def = MobDefinition {
id: MobId::from("test-mob"),
orchestrator: Some(OrchestratorConfig {
profile: ProfileName::from("lead"),
}),
profiles: {
let mut m = BTreeMap::new();
m.insert(
ProfileName::from("lead"),
Profile {
model: "claude-opus-4-6".to_string(),
skills: vec!["skill-a".to_string()],
tools: ToolConfig::default(),
peer_description: "The leader".to_string(),
external_addressable: true,
backend: None,
runtime_mode: crate::MobRuntimeMode::AutonomousHost,
max_inline_peer_notifications: None,
},
);
m
},
mcp_servers: BTreeMap::new(),
wiring: WiringRules::default(),
skills: BTreeMap::new(),
backend: BackendConfig::default(),
flows: BTreeMap::new(),
topology: None,
supervisor: None,
limits: None,
};
let json = serde_json::to_string_pretty(&def).unwrap();
let parsed: MobDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, def);
}
#[test]
fn test_minimal_toml() {
let toml_str = r#"
[mob]
id = "minimal"
"#;
let def = MobDefinition::from_toml(toml_str).unwrap();
assert_eq!(def.id.as_str(), "minimal");
assert!(def.orchestrator.is_none());
assert!(def.profiles.is_empty());
assert!(def.mcp_servers.is_empty());
assert!(!def.wiring.auto_wire_orchestrator);
assert!(def.wiring.role_wiring.is_empty());
assert!(def.skills.is_empty());
assert_eq!(def.backend.default, MobBackendKind::Subagent);
assert!(def.backend.external.is_none());
assert!(def.flows.is_empty());
assert!(def.topology.is_none());
assert!(def.supervisor.is_none());
assert!(def.limits.is_none());
}
#[test]
fn test_wiring_rules_serde_roundtrip() {
let rules = WiringRules {
auto_wire_orchestrator: true,
role_wiring: vec![RoleWiringRule {
a: ProfileName::from("worker"),
b: ProfileName::from("reviewer"),
}],
};
let json = serde_json::to_string(&rules).unwrap();
let parsed: WiringRules = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, rules);
}
#[test]
fn test_mcp_server_config_serde() {
let stdio = McpServerConfig {
command: vec!["node".to_string(), "server.js".to_string()],
url: None,
env: {
let mut m = BTreeMap::new();
m.insert("API_KEY".to_string(), "secret".to_string());
m
},
};
let json = serde_json::to_string(&stdio).unwrap();
let parsed: McpServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, stdio);
}
#[test]
fn test_mob_definition_from_toml_supports_orchestrator_table() {
let toml = r#"
[mob]
id = "table-orchestrator"
orchestrator = { profile = "lead" }
"#;
let def = MobDefinition::from_toml(toml).unwrap();
assert_eq!(
def.orchestrator.as_ref().map(|o| o.profile.as_str()),
Some("lead")
);
}
#[test]
fn test_skill_source_serde() {
let inline = SkillSource::Inline {
content: "You are a helper.".to_string(),
};
let json = serde_json::to_string(&inline).unwrap();
let parsed: SkillSource = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, inline);
let path = SkillSource::Path {
path: "skills/helper.md".to_string(),
};
let json = serde_json::to_string(&path).unwrap();
let parsed: SkillSource = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, path);
}
#[test]
fn test_flow_spec_toml_parse_preserves_author_order() {
let toml = r#"
[mob]
id = "flow-mob"
[profiles.lead]
model = "claude-sonnet-4-5"
[flows.demo]
description = "demo"
[flows.demo.steps.first]
role = "lead"
message = "first"
[flows.demo.steps.second]
role = "lead"
message = "second"
"#;
let definition = MobDefinition::from_toml(toml).unwrap();
let flow = definition
.flows
.get(&FlowId::from("demo"))
.expect("flow exists");
let step_order = flow.steps.keys().cloned().collect::<Vec<_>>();
let step_order = step_order
.into_iter()
.map(|step_id| step_id.to_string())
.collect::<Vec<_>>();
assert_eq!(step_order, vec!["first".to_string(), "second".to_string()]);
}
#[test]
fn test_flow_and_topology_roundtrip() {
let toml = r#"
[mob]
id = "flow-mob"
[profiles.lead]
model = "claude-sonnet-4-5"
[profiles.worker]
model = "claude-sonnet-4-5"
[flows.pipeline]
description = "pipeline flow"
[flows.pipeline.steps.start]
role = "lead"
message = "go"
dispatch_mode = "one_to_one"
depends_on_mode = "all"
[flows.pipeline.steps.branch_a]
role = "worker"
message = "a"
depends_on = ["start"]
branch = "choose"
condition = { op = "eq", path = "params.choice", value = "a" }
[flows.pipeline.steps.branch_b]
role = "worker"
message = "b"
depends_on = ["start"]
branch = "choose"
condition = { op = "eq", path = "params.choice", value = "b" }
[flows.pipeline.steps.join]
role = "lead"
message = "join"
depends_on = ["branch_a", "branch_b"]
depends_on_mode = "any"
collection_policy = { type = "quorum", n = 1 }
timeout_ms = 1000
expected_schema_ref = "schemas/join.json"
[topology]
mode = "strict"
rules = [{ from_role = "lead", to_role = "worker", allowed = true }]
[supervisor]
role = "lead"
escalation_threshold = 2
[limits]
max_flow_duration_ms = 30000
max_step_retries = 1
max_orphaned_turns = 8
"#;
let definition = MobDefinition::from_toml(toml).unwrap();
assert!(definition.flows.contains_key(&FlowId::from("pipeline")));
assert_eq!(
definition.topology.as_ref().map(|t| t.mode.clone()),
Some(PolicyMode::Strict)
);
assert_eq!(
definition
.supervisor
.as_ref()
.map(|s| s.escalation_threshold),
Some(2)
);
assert_eq!(
definition
.limits
.as_ref()
.and_then(|l| l.max_orphaned_turns),
Some(8)
);
let encoded = serde_json::to_string(&definition).unwrap();
let decoded: MobDefinition = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded, definition);
}
}