klieo-core 0.6.0

Core traits + runtime for the klieo agent framework.
Documentation
//! Tool trait, invoker, and tool context.
//!
//! Tools are I/O leaves. They receive a narrowed [`ToolCtx`] holding only
//! bus + KV access — they cannot reach into LLM or memory directly.
//! Memory updates triggered by tools must flow through bus events that
//! agents subscribe to, keeping the dependency graph one-directional.

use crate::bus::{JobQueue, KvStore, Pubsub};
use crate::error::ToolError;
use async_trait::async_trait;
use std::sync::Arc;

/// Narrowed slice of `AgentContext` (see `agent.rs`) passed to tools.
#[derive(Clone)]
pub struct ToolCtx {
    /// Pub/sub bus.
    pub pubsub: Arc<dyn Pubsub>,
    /// KV store.
    pub kv: Arc<dyn KvStore>,
    /// Job queue.
    pub jobs: Arc<dyn JobQueue>,
}

/// One executable tool.
///
/// Implementations live wherever they make sense. The
/// `klieo_macros::tool` proc-macro generates one for you from a
/// plain async function.
///
/// ```
/// # tokio_test::block_on(async {
/// use async_trait::async_trait;
/// use klieo_core::test_utils::noop_bus;
/// use klieo_core::error::ToolError;
/// use klieo_core::tool::{Tool, ToolCtx};
/// use std::sync::OnceLock;
///
/// struct Echo;
///
/// #[async_trait]
/// impl Tool for Echo {
///     fn name(&self) -> &str { "echo" }
///     fn description(&self) -> &str { "echoes input" }
///     fn json_schema(&self) -> &serde_json::Value {
///         static S: OnceLock<serde_json::Value> = OnceLock::new();
///         S.get_or_init(|| serde_json::json!({"type": "object"}))
///     }
///     async fn invoke(&self, args: serde_json::Value, _ctx: ToolCtx)
///         -> Result<serde_json::Value, ToolError>
///     {
///         Ok(args)
///     }
/// }
///
/// let (pubsub, _, kv, jobs) = noop_bus();
/// let ctx = ToolCtx { pubsub, kv, jobs };
/// let out = Echo.invoke(serde_json::json!({"y": 2}), ctx).await.unwrap();
/// assert_eq!(out, serde_json::json!({"y": 2}));
/// # });
/// ```
#[async_trait]
pub trait Tool: Send + Sync {
    /// Tool name shown to the LLM. Must be unique within a catalogue.
    fn name(&self) -> &str;

    /// Human-readable description shown to the LLM.
    fn description(&self) -> &str;

    /// JSON-schema for the tool's arguments.
    fn json_schema(&self) -> &serde_json::Value;

    /// Invoke the tool. Args are pre-validated against `json_schema`.
    async fn invoke(
        &self,
        args: serde_json::Value,
        ctx: ToolCtx,
    ) -> Result<serde_json::Value, ToolError>;

    /// Whether this tool causes an irreversible side effect (payment,
    /// mutation of external state, sent message, etc.). Defaults to
    /// `false` (read-only). Tools that perform side effects MUST override
    /// to return `true` — `klieo-ops` Gates evaluate at the pre-effect
    /// boundary on every `is_effectful() == true` tool.
    fn is_effectful(&self) -> bool {
        false
    }
}

/// Dispatches tool calls by name.
///
/// ```
/// # tokio_test::block_on(async {
/// use klieo_core::test_utils::{noop_bus, FakeToolInvoker};
/// use klieo_core::{ToolCtx, ToolInvoker};
/// let (pubsub, _, kv, jobs) = noop_bus();
/// let inv = FakeToolInvoker::new()
///     .with_tool("echo", "echoes back", |args| Ok(args));
/// let ctx = ToolCtx { pubsub, kv, jobs };
/// let out = inv.invoke("echo", serde_json::json!({"x": 1}), ctx).await.unwrap();
/// assert_eq!(out, serde_json::json!({"x": 1}));
/// assert_eq!(inv.catalogue().len(), 1);
/// # });
/// ```
#[async_trait]
pub trait ToolInvoker: Send + Sync {
    /// Invoke `name` with `args` against the supplied context.
    async fn invoke(
        &self,
        name: &str,
        args: serde_json::Value,
        ctx: ToolCtx,
    ) -> Result<serde_json::Value, ToolError>;

    /// Return the catalogue this invoker exposes.
    fn catalogue(&self) -> Vec<crate::llm::ToolDef>;
}

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

    #[allow(dead_code)]
    fn _assert_dyn_invoker(_: &dyn ToolInvoker) {}

    #[allow(dead_code)]
    fn _assert_dyn_tool(_: &dyn Tool) {}
}