meerkat-mob 0.5.0

Multi-agent orchestration runtime for Meerkat
Documentation
//! Profile and tool configuration for mob meerkats.
//!
//! A `Profile` defines the template for spawning a meerkat: which model to use,
//! which skills to load, tool configuration, and communication settings.

use crate::backend::MobBackendKind;
use crate::runtime_mode::MobRuntimeMode;
use serde::{Deserialize, Serialize};

/// Tool configuration for a meerkat profile.
///
/// Controls which tool categories are enabled for meerkats spawned
/// from this profile.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolConfig {
    /// Enable built-in tools (file read, etc.).
    #[serde(default)]
    pub builtins: bool,
    /// Enable shell execution tool.
    #[serde(default)]
    pub shell: bool,
    /// Enable comms tools (peer messaging).
    #[serde(default)]
    pub comms: bool,
    /// Enable memory/semantic search tools.
    #[serde(default)]
    pub memory: bool,
    /// Enable mob management tools (spawn, retire, wire, unwire, list).
    #[serde(default)]
    pub mob: bool,
    /// Enable shared task list tools (create, list, update, get).
    #[serde(default)]
    pub mob_tasks: bool,
    /// MCP server names this profile connects to.
    #[serde(default)]
    pub mcp: Vec<String>,
    /// Named Rust tool bundles wired by the mob runtime.
    ///
    /// String names referencing `Arc<dyn AgentToolDispatcher>` instances
    /// registered at mob construction time. Not serializable — must be
    /// re-registered on resume.
    #[serde(default)]
    pub rust_bundles: Vec<String>,
}

/// Profile template for spawning meerkats.
///
/// Each profile defines the model, skills, tool configuration, and
/// communication properties for a class of meerkat agents.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
    /// LLM model name (e.g. "claude-opus-4-6").
    pub model: String,
    /// Skill references to load for this profile.
    #[serde(default)]
    pub skills: Vec<String>,
    /// Tool configuration.
    #[serde(default)]
    pub tools: ToolConfig,
    /// Human-readable description of this meerkat's role, visible to peers.
    #[serde(default)]
    pub peer_description: String,
    /// Whether this meerkat can receive turns from external callers.
    #[serde(default)]
    pub external_addressable: bool,
    /// Optional backend override for this profile.
    ///
    /// If unset, runtime uses `definition.backend.default`.
    #[serde(default)]
    pub backend: Option<MobBackendKind>,
    /// Runtime mode for members spawned from this profile.
    ///
    /// Defaults to autonomous keep-alive behavior when omitted.
    #[serde(default)]
    pub runtime_mode: MobRuntimeMode,
    /// Maximum peer-count threshold for inline peer lifecycle context injection.
    ///
    /// - `None`: use runtime default
    /// - `0`: never inline peer lifecycle notifications
    /// - `-1`: always inline peer lifecycle notifications
    /// - `>0`: inline only when post-drain peer count is <= threshold
    /// - `<-1`: invalid
    #[serde(default)]
    pub max_inline_peer_notifications: Option<i32>,
    /// Optional JSON Schema for structured output extraction.
    ///
    /// When set, the agent session is configured with an [`OutputSchema`] that
    /// forces the LLM to respond with validated JSON conforming to this schema.
    /// The value should be a valid JSON Schema object (root must be an object).
    ///
    /// **Note:** Validation is deferred to spawn time (`build_session_config`)
    /// where `MeerkatSchema::new()` rejects invalid schemas. This is intentional:
    /// `Profile` is a serializable template that may be persisted or transmitted
    /// before any agent is spawned, and `MeerkatSchema` does not currently
    /// implement `Eq` or validate on deserialization.
    #[serde(default)]
    pub output_schema: Option<serde_json::Value>,
    /// Optional provider-specific parameters passed to the LLM adapter.
    ///
    /// This maps directly to `AgentBuildConfig.provider_params` and is useful
    /// for model/provider knobs such as Gemini `thinking_budget` or OpenAI
    /// `reasoning_effort`.
    #[serde(default)]
    pub provider_params: Option<serde_json::Value>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_tool_config_serde_roundtrip() {
        let config = ToolConfig {
            builtins: true,
            shell: false,
            comms: true,
            memory: false,
            mob: true,
            mob_tasks: true,
            mcp: vec!["server-a".to_string(), "server-b".to_string()],
            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,
            mob: false,
            mob_tasks: false,
            mcp: vec!["mcp-server".to_string()],
            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-6".to_string(),
            skills: vec!["orchestrator-skill".to_string()],
            tools: ToolConfig {
                builtins: true,
                shell: false,
                comms: true,
                memory: false,
                mob: true,
                mob_tasks: true,
                mcp: 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(),
            skills: vec!["worker-skill".to_string()],
            tools: ToolConfig {
                builtins: false,
                shell: true,
                comms: true,
                memory: false,
                mob: false,
                mob_tasks: true,
                mcp: vec!["code-server".to_string()],
                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.mob);
        assert!(!config.mob_tasks);
        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_toml_parses_provider_params() {
        let toml_str = r#"
model = "gemini-3-pro-preview"
provider_params = { thinking_budget = 8192, top_k = 20 }
"#;
        let profile: Profile = toml::from_str(toml_str).unwrap();
        assert_eq!(
            profile.provider_params,
            Some(serde_json::json!({"thinking_budget": 8192, "top_k": 20}))
        );
    }
}