tkach 0.3.0

A provider-independent agent runtime for Rust with built-in tools
Documentation
//! User-approval gate for tool calls.
//!
//! Where [`ToolPolicy`](crate::ToolPolicy) is the *static* gate (which
//! tools are permitted at all), [`ApprovalHandler`] is the *dynamic*,
//! per-call gate. It runs after policy and before the tool actually
//! executes; the model never sees the call directly — only the
//! resulting tool_result, success or denial.
//!
//! ## Decision shape
//!
//! [`ApprovalDecision::Deny`] is a *recoverable* outcome: it surfaces
//! to the model as `is_error: true` `tool_result` so the LLM can pick
//! a different approach. It is **not** an `AgentError` — the loop
//! continues. This is consistent with how policy denials and missing
//! tools are handled.
//!
//! ## How consumers use it
//!
//! ```ignore
//! struct MyTuiApproval { tx: mpsc::Sender<PendingDecision> }
//!
//! #[async_trait]
//! impl ApprovalHandler for MyTuiApproval {
//!     async fn approve(&self, name: &str, input: &Value, class: ToolClass)
//!         -> ApprovalDecision
//!     {
//!         // Cheap path: blanket-allow read-only tools without a prompt.
//!         if class == ToolClass::ReadOnly { return ApprovalDecision::Allow; }
//!         // Otherwise hand off to the UI thread and await its answer.
//!         let (tx, rx) = oneshot::channel();
//!         self.tx.send(PendingDecision { name, input, reply: tx }).await.ok();
//!         rx.await.unwrap_or(ApprovalDecision::Deny("UI gone".into()))
//!     }
//! }
//! ```
//!
//! ## Cancellation
//!
//! The executor wraps `approve()` in `tokio::select!` against
//! `ctx.cancel.cancelled()`. If the caller cancels while the handler
//! is awaiting a user click, the executor short-circuits with a
//! cancelled tool_result rather than waiting forever.

use async_trait::async_trait;
use serde_json::Value;

use crate::tool::ToolClass;

/// What an [`ApprovalHandler`] returns for a given tool call.
///
/// `Deny(reason)` is recoverable — the runtime emits an
/// `is_error: true` tool_result containing the reason and lets the
/// model adapt. It is **not** an `AgentError`.
#[derive(Debug, Clone)]
pub enum ApprovalDecision {
    Allow,
    Deny(String),
}

/// Gate that decides per-call whether a tool may run.
///
/// Implementations are typically UI-bound (TUI modal, web confirmation
/// dialog, Slack interactive button, etc.) and may block until the
/// human responds. The runtime races the wait against the agent's
/// cancellation token — the handler does not need to plumb cancel
/// itself, but it *should* take care to avoid leaking resources if
/// dropped mid-await.
///
/// The `class` parameter is provided so handlers can fast-path
/// read-only tools without bothering the user.
#[async_trait]
pub trait ApprovalHandler: Send + Sync {
    async fn approve(&self, tool_name: &str, input: &Value, class: ToolClass) -> ApprovalDecision;
}

/// Default handler: allow every tool unconditionally.
///
/// Used when `AgentBuilder::approval(...)` was not called — preserves
/// pre-approval-flow behaviour exactly. Headless / batch / CI agents
/// typically want this; interactive consumers replace it with their
/// own implementation.
pub struct AutoApprove;

#[async_trait]
impl ApprovalHandler for AutoApprove {
    async fn approve(
        &self,
        _tool_name: &str,
        _input: &Value,
        _class: ToolClass,
    ) -> ApprovalDecision {
        ApprovalDecision::Allow
    }
}

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

    #[tokio::test]
    async fn auto_approve_allows_everything() {
        let h = AutoApprove;
        let d = h
            .approve("bash", &json!({"command": "rm -rf /"}), ToolClass::Mutating)
            .await;
        assert!(matches!(d, ApprovalDecision::Allow));
    }

    struct DenyAll(&'static str);
    #[async_trait]
    impl ApprovalHandler for DenyAll {
        async fn approve(&self, _: &str, _: &Value, _: ToolClass) -> ApprovalDecision {
            ApprovalDecision::Deny(self.0.to_string())
        }
    }

    #[tokio::test]
    async fn custom_handler_denies_with_reason() {
        let h = DenyAll("test policy");
        let d = h.approve("read", &json!({}), ToolClass::ReadOnly).await;
        let ApprovalDecision::Deny(reason) = d else {
            panic!("expected Deny");
        };
        assert_eq!(reason, "test policy");
    }
}