Skip to main content

klieo_core/runtime/
review.rs

1//! Per-step human-review policy consulted by the run loop (ADR-045).
2
3use async_trait::async_trait;
4
5use crate::error::Error;
6use crate::llm::Message;
7
8/// Decides whether a run must pause for human approval after an LLM step,
9/// before its tool calls are dispatched. `Ok(None)` proceeds; `Ok(Some(reason))`
10/// suspends the run with `reason` recorded on the checkpoint.
11#[async_trait]
12pub trait ReviewPolicy: Send + Sync {
13    /// `step` is 1-indexed.
14    async fn should_pause_for_approval(
15        &self,
16        step: u32,
17        message: &Message,
18    ) -> Result<Option<String>, Error>;
19
20    /// `true` only for the no-op default. The streaming path consults this to
21    /// reject a gated streaming run rather than silently ignore the policy: a
22    /// checkpoint cannot capture a half-consumed provider stream (ADR-045).
23    fn is_never(&self) -> bool {
24        false
25    }
26}
27
28/// The additive default: existing `RunOptions` callers keep their prior
29/// non-pausing behaviour without opting into HITL.
30pub struct NeverReview;
31
32#[async_trait]
33impl ReviewPolicy for NeverReview {
34    async fn should_pause_for_approval(
35        &self,
36        _step: u32,
37        _message: &Message,
38    ) -> Result<Option<String>, Error> {
39        Ok(None)
40    }
41
42    fn is_never(&self) -> bool {
43        true
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use crate::llm::{Message, Role};
51
52    #[tokio::test]
53    async fn never_review_never_pauses() {
54        let policy = NeverReview;
55        let msg = Message {
56            role: Role::Assistant,
57            content: "hi".into(),
58            tool_calls: vec![],
59            tool_call_id: None,
60        };
61        assert_eq!(
62            policy.should_pause_for_approval(1, &msg).await.unwrap(),
63            None
64        );
65    }
66
67    #[test]
68    fn never_review_reports_is_never() {
69        assert!(NeverReview.is_never());
70    }
71
72    #[test]
73    fn custom_policy_is_not_never_by_default() {
74        struct Gating;
75        #[async_trait]
76        impl ReviewPolicy for Gating {
77            async fn should_pause_for_approval(
78                &self,
79                _step: u32,
80                _message: &Message,
81            ) -> Result<Option<String>, Error> {
82                Ok(Some("hold".into()))
83            }
84        }
85        assert!(!Gating.is_never());
86    }
87}