iron-core 0.1.37

Core AgentIron loop, session state, and tool registry
Documentation
//! Internal script-visible catalog of runtime tools and safe Python aliases.

use crate::tool::ToolDefinition;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};

const RESERVED_METHODS: &[&str] = &["call", "available", "describe", "python_exec"];
const PYTHON_KEYWORDS: &[&str] = &[
    "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue",
    "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import",
    "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while",
    "with", "yield",
];

#[derive(Debug, Clone)]
/// Catalog entry containing a tool definition and optional Python-safe alias.
pub(crate) struct ToolCatalogEntry {
    definition: ToolDefinition,
    alias: Option<String>,
}

impl ToolCatalogEntry {
    /// Returns the registered runtime tool name.
    pub(crate) fn name(&self) -> &str {
        &self.definition.name
    }

    /// Returns public catalog metadata without the input schema.
    pub(crate) fn summary_json(&self) -> Value {
        json!({
            "name": self.definition.name,
            "alias": self.alias,
            "description": self.definition.description,
            "requires_approval": self.definition.requires_approval,
        })
    }

    /// Returns complete script-visible metadata, including the input schema.
    pub(crate) fn describe_json(&self) -> Value {
        json!({
            "name": self.definition.name,
            "alias": self.alias,
            "description": self.definition.description,
            "requires_approval": self.definition.requires_approval,
            "input_schema": self.definition.input_schema,
        })
    }
}

#[derive(Debug, Clone, Default)]
/// Snapshot of tools available to one embedded-Python run.
pub(crate) struct ToolCatalog {
    entries: Vec<ToolCatalogEntry>,
    by_name: HashMap<String, usize>,
    by_alias: HashMap<String, usize>,
}

impl ToolCatalog {
    /// Builds a catalog and assigns non-conflicting Python-safe aliases.
    pub(crate) fn from_definitions(definitions: Vec<ToolDefinition>) -> Self {
        let mut entries = Vec::with_capacity(definitions.len());
        let mut by_name = HashMap::with_capacity(definitions.len());
        let mut by_alias = HashMap::with_capacity(definitions.len());
        let mut assigned_aliases = HashSet::with_capacity(definitions.len());

        for definition in definitions {
            let alias = alias_for_tool(&definition.name, &assigned_aliases);
            if let Some(alias_name) = alias.as_ref() {
                assigned_aliases.insert(alias_name.clone());
            }

            let index = entries.len();
            by_name.insert(definition.name.clone(), index);
            if let Some(alias_name) = alias.as_ref() {
                by_alias.insert(alias_name.clone(), index);
            }
            entries.push(ToolCatalogEntry { definition, alias });
        }

        Self {
            entries,
            by_name,
            by_alias,
        }
    }

    /// Creates the frozen `IronTools` object injected as the `tools` global.
    pub(crate) fn namespace_object(&self) -> monty::MontyObject {
        monty::MontyObject::Dataclass {
            name: "IronTools".to_string(),
            type_id: 0,
            field_names: Vec::new(),
            attrs: monty::DictPairs::from(Vec::<(monty::MontyObject, monty::MontyObject)>::new()),
            frozen: true,
        }
    }

    /// Returns summaries for all catalog entries in registry order.
    pub(crate) fn available_json(&self) -> Value {
        Value::Array(
            self.entries
                .iter()
                .map(ToolCatalogEntry::summary_json)
                .collect(),
        )
    }

    /// Returns full metadata for a registered tool name.
    pub(crate) fn describe_json(&self, name: &str) -> Option<Value> {
        self.entry_by_name(name)
            .map(ToolCatalogEntry::describe_json)
    }

    /// Looks up an entry by its registered runtime name.
    pub(crate) fn entry_by_name(&self, name: &str) -> Option<&ToolCatalogEntry> {
        self.by_name.get(name).map(|idx| &self.entries[*idx])
    }

    /// Looks up an entry by its generated Python method alias.
    pub(crate) fn entry_by_alias(&self, alias: &str) -> Option<&ToolCatalogEntry> {
        self.by_alias.get(alias).map(|idx| &self.entries[*idx])
    }
}

fn alias_for_tool(name: &str, assigned_aliases: &HashSet<String>) -> Option<String> {
    if RESERVED_METHODS.contains(&name) {
        return None;
    }

    let mut alias = String::with_capacity(name.len());
    for ch in name.chars() {
        if ch == '_' || ch.is_ascii_alphanumeric() {
            alias.push(ch);
        } else {
            alias.push('_');
        }
    }

    if alias.is_empty() {
        return None;
    }

    let first = alias.chars().next().unwrap();
    if !matches!(first, '_' | 'a'..='z' | 'A'..='Z') {
        alias.insert_str(0, "tool_");
    }

    if RESERVED_METHODS.contains(&alias.as_str()) || PYTHON_KEYWORDS.contains(&alias.as_str()) {
        alias.insert_str(0, "tool_");
    }

    if assigned_aliases.contains(&alias) {
        return None;
    }

    Some(alias)
}

/// Returns whether a Monty object is the injected `IronTools` namespace.
pub(crate) fn is_tools_namespace(obj: &monty::MontyObject) -> bool {
    matches!(obj, monty::MontyObject::Dataclass { name, .. } if name == "IronTools")
}

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

    #[test]
    fn alias_generation_sanitizes_names() {
        let catalog = ToolCatalog::from_definitions(vec![
            ToolDefinition::new("read-file", "desc", json!({})),
            ToolDefinition::new("123tool", "desc", json!({})),
        ]);

        assert_eq!(
            catalog.entry_by_name("read-file").unwrap().alias.as_deref(),
            Some("read_file")
        );
        assert_eq!(
            catalog.entry_by_name("123tool").unwrap().alias.as_deref(),
            Some("tool_123tool")
        );
    }

    #[test]
    fn alias_generation_omits_reserved_and_colliding_names() {
        let catalog = ToolCatalog::from_definitions(vec![
            ToolDefinition::new("call", "desc", json!({})),
            ToolDefinition::new("foo-bar", "desc", json!({})),
            ToolDefinition::new("foo_bar", "desc", json!({})),
            ToolDefinition::new("python_exec", "desc", json!({})),
        ]);

        assert_eq!(
            catalog.entry_by_name("call").unwrap().alias.as_deref(),
            None
        );
        assert_eq!(
            catalog.entry_by_name("foo-bar").unwrap().alias.as_deref(),
            Some("foo_bar")
        );
        assert_eq!(
            catalog.entry_by_name("foo_bar").unwrap().alias.as_deref(),
            None
        );
        assert_eq!(
            catalog
                .entry_by_name("python_exec")
                .unwrap()
                .alias
                .as_deref(),
            None
        );
    }
}