collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use serde::Deserialize;
use serde_json::{Value, json};

use crate::mcp::manager::McpManager;
use crate::tools::tool_index::ToolIndex;

#[derive(Debug, Deserialize)]
pub struct ToolSearchInput {
    /// Search query — keywords to match against tool names and descriptions.
    pub query: String,
}

/// Tool definition for the LLM `tools` array.
pub fn definition() -> Value {
    json!({
        "type": "function",
        "function": {
            "name": "tool_search",
            "description": "Search available tools (MCP servers, skills, agents) by keyword using BM25 relevance ranking. Returns full tool schemas for matching MCP tools and metadata for skills/agents. Use when tools are deferred (not listed in the tools array) or when you need to discover the right tool for a task.",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Natural language query or keywords to find relevant tools"
                    }
                },
                "required": ["query"]
            }
        }
    })
}

/// Execute tool_search enriched with a live MCP manager query.
///
/// When `mcp_manager` is `Some` and deferred mode is active, the manager's
/// `search_tools` method is called to supplement BM25 index results with
/// any tools that may have been registered after the index was last built.
///
/// Use [`execute`] for the plain BM25-only path (no MCP enrichment).
pub fn execute(input: ToolSearchInput, tool_index: &ToolIndex) -> crate::common::Result<String> {
    execute_with_mcp(input, tool_index, None)
}

pub fn execute_with_mcp(
    input: ToolSearchInput,
    tool_index: &ToolIndex,
    mcp_manager: Option<&McpManager>,
) -> crate::common::Result<String> {
    let results = tool_index.search(&input.query, 10);

    // Collect BM25 result IDs to avoid duplicates when merging MCP results.
    let mut seen_ids: std::collections::HashSet<String> =
        results.iter().map(|r| r.id.clone()).collect();

    let mut entries: Vec<Value> = results
        .iter()
        .map(|r| {
            let mut entry = json!({
                "id": r.id,
                "source": format!("{:?}", r.source),
                "score": format!("{:.2}", r.score),
                "description": r.description,
            });
            if let Some(ref schema) = r.schema {
                entry["schema"] = schema.clone();
            }
            entry
        })
        .collect();

    // In deferred mode, supplement with live MCP search results for tools
    // that may not yet be in the BM25 index.
    if let Some(mgr) = mcp_manager
        && mgr.is_deferred_mode()
    {
        for schema in mgr.search_tools(&input.query) {
            let name = schema["function"]["name"]
                .as_str()
                .unwrap_or("")
                .to_string();
            if !name.is_empty() && seen_ids.insert(name.clone()) {
                let desc = schema["function"]["description"]
                    .as_str()
                    .unwrap_or("")
                    .to_string();
                entries.push(json!({
                    "id": name,
                    "source": "Mcp",
                    "score": "live",
                    "description": desc,
                    "schema": schema,
                }));
            }
        }
    }

    if entries.is_empty() {
        return Ok(format!(
            "No tools found matching '{}'. Try broader or different keywords.",
            input.query
        ));
    }

    let output = json!({
        "match_count": entries.len(),
        "tools": entries,
    });

    serde_json::to_string_pretty(&output)
        .map_err(|e| crate::common::AgentError::Internal(format!("JSON serialization failed: {e}")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::tool_index::{ToolIndex, ToolSource};
    use serde_json::json;

    fn make_index() -> ToolIndex {
        let mut idx = ToolIndex::new();
        idx.upsert_entry(
            "mcp__ctx__docs".into(),
            ToolSource::Mcp,
            "mcp__ctx__docs",
            "Query documentation for an API",
            None,
            Some(json!({"type": "function", "function": {"name": "mcp__ctx__docs"}})),
        );
        idx.upsert_entry(
            "git-commit".into(),
            ToolSource::Skill,
            "git-commit",
            "Create a conventional commit message",
            None,
            None,
        );
        idx
    }

    #[test]
    fn test_execute_returns_results() {
        let idx = make_index();
        let input = ToolSearchInput {
            query: "documentation API".into(),
        };
        let result = execute(input, &idx).unwrap();
        assert!(result.contains("mcp__ctx__docs"));
    }

    #[test]
    fn test_execute_no_results() {
        let idx = make_index();
        let input = ToolSearchInput {
            query: "quantum physics xyz".into(),
        };
        let result = execute(input, &idx).unwrap();
        assert!(result.contains("No tools found"));
    }

    #[test]
    fn test_execute_with_mcp_no_manager() {
        let idx = make_index();
        let input = ToolSearchInput {
            query: "commit".into(),
        };
        let result = execute_with_mcp(input, &idx, None).unwrap();
        assert!(result.contains("git-commit"));
    }
}