klieo-core 3.0.0

Core traits + runtime for the klieo agent framework.
Documentation
//! Per-step human-review policy consulted by the run loop (ADR-045).

use async_trait::async_trait;

use crate::error::Error;
use crate::llm::Message;

/// Decides whether a run must pause for human approval after an LLM step,
/// before its tool calls are dispatched. `Ok(None)` proceeds; `Ok(Some(reason))`
/// suspends the run with `reason` recorded on the checkpoint.
#[async_trait]
pub trait ReviewPolicy: Send + Sync {
    /// `step` is 1-indexed.
    async fn should_pause_for_approval(
        &self,
        step: u32,
        message: &Message,
    ) -> Result<Option<String>, Error>;

    /// `true` only for the no-op default. The streaming path consults this to
    /// reject a gated streaming run rather than silently ignore the policy: a
    /// checkpoint cannot capture a half-consumed provider stream (ADR-045).
    fn is_never(&self) -> bool {
        false
    }
}

/// The additive default: existing `RunOptions` callers keep their prior
/// non-pausing behaviour without opting into HITL.
pub struct NeverReview;

#[async_trait]
impl ReviewPolicy for NeverReview {
    async fn should_pause_for_approval(
        &self,
        _step: u32,
        _message: &Message,
    ) -> Result<Option<String>, Error> {
        Ok(None)
    }

    fn is_never(&self) -> bool {
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::llm::{Message, Role};

    #[tokio::test]
    async fn never_review_never_pauses() {
        let policy = NeverReview;
        let msg = Message {
            role: Role::Assistant,
            content: "hi".into(),
            tool_calls: vec![],
            tool_call_id: None,
        };
        assert_eq!(
            policy.should_pause_for_approval(1, &msg).await.unwrap(),
            None
        );
    }

    #[test]
    fn never_review_reports_is_never() {
        assert!(NeverReview.is_never());
    }

    #[test]
    fn custom_policy_is_not_never_by_default() {
        struct Gating;
        #[async_trait]
        impl ReviewPolicy for Gating {
            async fn should_pause_for_approval(
                &self,
                _step: u32,
                _message: &Message,
            ) -> Result<Option<String>, Error> {
                Ok(Some("hold".into()))
            }
        }
        assert!(!Gating.is_never());
    }
}