meerkat-mob 0.6.21

Multi-agent orchestration runtime for Meerkat
Documentation
//! Parent tool scope snapshot for inheriting tool visibility into mob children.

use meerkat_core::WitnessedToolFilter;
use meerkat_core::tool_scope::ToolFilter;
use meerkat_core::types::{ToolDef, ToolProvenance, ToolSourceId, ToolSourceKind};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;

/// Snapshot of a parent agent's visible tools at child spawn time.
///
/// Captures the tool names and definitions that were visible to the parent,
/// allowing child sessions to inherit an appropriate base filter.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParentToolScopeSnapshot {
    /// Names of tools that were visible to the parent at capture time.
    pub visible_tool_names: HashSet<String>,
    /// Full definitions of visible tools (for child reference/filtering).
    pub visible_tool_defs: Vec<ToolDef>,
    /// When this snapshot was taken.
    pub captured_at: chrono::DateTime<chrono::Utc>,
}

impl ParentToolScopeSnapshot {
    /// Convert this snapshot into a `ToolFilter::Allow` containing the visible tool names.
    pub fn to_tool_filter(&self) -> ToolFilter {
        ToolFilter::Allow(self.visible_tool_names.iter().cloned().collect())
    }

    /// Convert this snapshot into a witnessed allow filter.
    pub fn to_witnessed_tool_filter(&self) -> WitnessedToolFilter {
        self.witnessed_filter_for(self.to_tool_filter())
    }

    /// Build a tool filter with additional allow/deny overlays applied on top
    /// of the snapshot's visible set.
    ///
    /// - `allow_overlay`: if `Some`, intersects with the snapshot's visible set
    /// - `deny_overlay`: names to remove from visibility
    pub fn with_overlays(
        &self,
        allow_overlay: Option<&HashSet<String>>,
        deny_overlay: Option<&HashSet<String>>,
    ) -> ToolFilter {
        let mut names = self.visible_tool_names.clone();

        if let Some(allow) = allow_overlay {
            names = names.intersection(allow).cloned().collect();
        }

        if let Some(deny) = deny_overlay {
            for name in deny {
                names.remove(name);
            }
        }

        ToolFilter::Allow(names.into_iter().collect())
    }

    /// Build a witnessed filter with additional allow/deny overlays applied.
    pub fn with_witnessed_overlays(
        &self,
        allow_overlay: Option<&HashSet<String>>,
        deny_overlay: Option<&HashSet<String>>,
    ) -> WitnessedToolFilter {
        self.witnessed_filter_for(self.with_overlays(allow_overlay, deny_overlay))
    }

    fn witnessed_filter_for(&self, filter: ToolFilter) -> WitnessedToolFilter {
        let witnesses = inherited_filter_witnesses_for_defs(&filter, &self.visible_tool_defs);
        WitnessedToolFilter::new(filter, witnesses)
    }

    /// Create a snapshot from a set of visible tool definitions.
    pub fn from_tools(tools: &[Arc<ToolDef>]) -> Self {
        Self {
            visible_tool_names: tools.iter().map(|t| t.name.to_string()).collect(),
            visible_tool_defs: tools.iter().map(|t| (**t).clone()).collect(),
            captured_at: chrono::Utc::now(),
        }
    }
}

fn inherited_filter_witnesses_for_defs(
    filter: &ToolFilter,
    tool_defs: &[ToolDef],
) -> BTreeMap<String, meerkat_core::ToolVisibilityWitness> {
    let filter_names = match filter {
        ToolFilter::All => return BTreeMap::new(),
        ToolFilter::Allow(names) | ToolFilter::Deny(names) => names,
    };

    filter_names
        .iter()
        .filter_map(|name| {
            let tool = tool_defs.iter().find(|tool| tool.name == name.as_str())?;
            let provenance = tool
                .provenance
                .clone()
                .unwrap_or_else(|| synthetic_parent_tool_provenance(name));
            Some((
                name.as_str().to_string(),
                meerkat_core::ToolVisibilityWitness {
                    stable_owner_key: Some(
                        meerkat_core::tool_catalog::stable_owner_key_from_provenance(&provenance),
                    ),
                    last_seen_provenance: Some(provenance),
                },
            ))
        })
        .collect()
}

fn synthetic_parent_tool_provenance(tool_name: &str) -> ToolProvenance {
    ToolProvenance {
        kind: ToolSourceKind::Callback,
        source_id: ToolSourceId::new(format!("parent_snapshot:{tool_name}")),
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;
    use serde_json;
    use std::collections::HashSet;

    fn make_tool(name: &str) -> Arc<ToolDef> {
        Arc::new(ToolDef {
            name: name.into(),
            description: format!("{name} tool"),
            input_schema: serde_json::json!({"type": "object"}),
            provenance: None,
        })
    }

    #[test]
    fn roundtrip_serialization() {
        let tools = vec![make_tool("read"), make_tool("write")];
        let snapshot = ParentToolScopeSnapshot::from_tools(&tools);

        let json = serde_json::to_string(&snapshot).unwrap();
        let parsed: ParentToolScopeSnapshot = serde_json::from_str(&json).unwrap();

        assert_eq!(parsed.visible_tool_names, snapshot.visible_tool_names);
        assert_eq!(parsed.visible_tool_defs.len(), 2);
    }

    #[test]
    fn to_tool_filter_produces_allow() {
        let tools = vec![make_tool("a"), make_tool("b")];
        let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
        let filter = snapshot.to_tool_filter();

        match filter {
            ToolFilter::Allow(names) => {
                assert!(names.contains("a"));
                assert!(names.contains("b"));
                assert_eq!(names.len(), 2);
            }
            other => panic!("expected Allow, got {other:?}"),
        }
    }

    #[test]
    fn with_overlays_intersects_allow() {
        let tools = vec![make_tool("a"), make_tool("b"), make_tool("c")];
        let snapshot = ParentToolScopeSnapshot::from_tools(&tools);

        let allow: HashSet<String> = ["b", "c", "d"].iter().copied().map(String::from).collect();
        let filter = snapshot.with_overlays(Some(&allow), None);

        match filter {
            ToolFilter::Allow(names) => {
                assert!(names.contains("b"));
                assert!(names.contains("c"));
                assert!(!names.contains("a")); // not in overlay
                assert!(!names.contains("d")); // not in snapshot
            }
            other => panic!("expected Allow, got {other:?}"),
        }
    }

    #[test]
    fn with_overlays_removes_deny() {
        let tools = vec![make_tool("a"), make_tool("b"), make_tool("c")];
        let snapshot = ParentToolScopeSnapshot::from_tools(&tools);

        let deny: HashSet<String> = ["b"].iter().copied().map(String::from).collect();
        let filter = snapshot.with_overlays(None, Some(&deny));

        match filter {
            ToolFilter::Allow(names) => {
                assert!(names.contains("a"));
                assert!(names.contains("c"));
                assert!(!names.contains("b"));
            }
            other => panic!("expected Allow, got {other:?}"),
        }
    }

    #[test]
    fn with_overlays_both() {
        let tools = vec![make_tool("a"), make_tool("b"), make_tool("c")];
        let snapshot = ParentToolScopeSnapshot::from_tools(&tools);

        let allow: HashSet<String> = ["a", "b"].iter().copied().map(String::from).collect();
        let deny: HashSet<String> = ["b"].iter().copied().map(String::from).collect();
        let filter = snapshot.with_overlays(Some(&allow), Some(&deny));

        match filter {
            ToolFilter::Allow(names) => {
                assert!(names.contains("a"));
                assert!(!names.contains("b"));
                assert!(!names.contains("c"));
                assert_eq!(names.len(), 1);
            }
            other => panic!("expected Allow, got {other:?}"),
        }
    }

    #[test]
    fn from_tools_captures_names_and_defs() {
        let tools = vec![make_tool("x"), make_tool("y")];
        let snapshot = ParentToolScopeSnapshot::from_tools(&tools);

        assert_eq!(snapshot.visible_tool_names.len(), 2);
        assert_eq!(snapshot.visible_tool_defs.len(), 2);
        assert!(snapshot.visible_tool_names.contains("x"));
        assert!(snapshot.visible_tool_names.contains("y"));
    }

    #[test]
    fn unprovenanced_parent_tools_get_inherited_filter_witnesses() {
        let tools = vec![make_tool("external_tool")];
        let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
        let authority = snapshot.to_witnessed_tool_filter();
        let witness = authority
            .witnesses
            .get("external_tool")
            .expect("external tool should have synthesized witness");

        assert!(
            witness.has_provenance_identity_witness(),
            "inherited filters need provenance-backed witnesses"
        );
        assert_eq!(
            witness
                .last_seen_provenance
                .as_ref()
                .map(|provenance| provenance.source_id.as_str()),
            Some("parent_snapshot:external_tool")
        );
        meerkat_core::tool_scope::validate_witnessed_filter_authority(
            &authority.filter,
            &authority.witnesses,
        )
        .expect("synthesized witness should satisfy inherited visibility validation");
    }
}