klieo-tools 0.2.0

Tool dispatch + JSON-schema arg validation + timeout enforcement for klieo-core.
Documentation
//! End-to-end integration test for `ChainedInvoker`.

use async_trait::async_trait;
use klieo_bus_memory::MemoryBus;
use klieo_core::error::ToolError;
use klieo_core::tool::{Tool, ToolCtx, ToolInvoker};
use klieo_tools::ChainedInvoker;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;

#[derive(Deserialize)]
struct GreetArgs {
    name: String,
    excited: Option<bool>,
}

#[derive(Serialize, Debug, PartialEq)]
struct GreetResult {
    message: String,
}

struct GreetTool;

#[async_trait]
impl Tool for GreetTool {
    fn name(&self) -> &str {
        "greet"
    }
    fn description(&self) -> &str {
        "greet a person"
    }
    fn json_schema(&self) -> &serde_json::Value {
        static SCHEMA: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
        SCHEMA.get_or_init(|| {
            serde_json::json!({
                "type": "object",
                "properties": {
                    "name": { "type": "string", "minLength": 1 },
                    "excited": { "type": "boolean" }
                },
                "required": ["name"],
                "additionalProperties": false,
            })
        })
    }
    async fn invoke(
        &self,
        args: serde_json::Value,
        _ctx: ToolCtx,
    ) -> Result<serde_json::Value, ToolError> {
        let parsed: GreetArgs =
            serde_json::from_value(args).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
        let suffix = if parsed.excited.unwrap_or(false) {
            "!"
        } else {
            "."
        };
        let result = GreetResult {
            message: format!("Hello, {}{}", parsed.name, suffix),
        };
        serde_json::to_value(result).map_err(|e| ToolError::Permanent(e.to_string()))
    }
}

fn ctx() -> ToolCtx {
    let bus = MemoryBus::new();
    ToolCtx {
        pubsub: bus.pubsub,
        kv: bus.kv,
        jobs: bus.jobs,
    }
}

#[tokio::test]
async fn end_to_end_greet_tool_succeeds() {
    let inv = ChainedInvoker::new()
        .with_default_timeout(Duration::from_secs(1))
        .with_tool(Arc::new(GreetTool));
    let out = inv
        .invoke(
            "greet",
            serde_json::json!({ "name": "world", "excited": true }),
            ctx(),
        )
        .await
        .unwrap();
    assert_eq!(out, serde_json::json!({ "message": "Hello, world!" }));
}

#[tokio::test]
async fn end_to_end_missing_required_field_rejected_at_schema() {
    let inv = ChainedInvoker::new().with_tool(Arc::new(GreetTool));
    let err = inv
        .invoke("greet", serde_json::json!({"excited": true}), ctx())
        .await
        .unwrap_err();
    assert!(matches!(err, ToolError::InvalidArgs(_)));
}

#[tokio::test]
async fn end_to_end_min_length_violation_rejected_at_schema() {
    let inv = ChainedInvoker::new().with_tool(Arc::new(GreetTool));
    let err = inv
        .invoke("greet", serde_json::json!({"name": ""}), ctx())
        .await
        .unwrap_err();
    assert!(matches!(err, ToolError::InvalidArgs(_)));
}

#[tokio::test]
async fn catalogue_includes_greet_tool() {
    let inv = ChainedInvoker::new().with_tool(Arc::new(GreetTool));
    let cat = inv.catalogue();
    assert_eq!(cat.len(), 1);
    assert_eq!(cat[0].name, "greet");
    assert!(cat[0]
        .json_schema
        .get("required")
        .and_then(|r| r.as_array())
        .map(|a| a.iter().any(|s| s == "name"))
        .unwrap_or(false));
}