klieo-core 2.2.0

Core traits + runtime for the klieo agent framework.
Documentation
//! [`RunOptions`] and its defaults / `Debug` impl.

use crate::guardrail::Guardrail;
use std::sync::Arc;

/// Default cap on the total bytes a streaming run will forward to the
/// caller before aborting. Sized at 4 MiB — comfortably above any sane
/// chat response while small enough to bound a runaway provider.
const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;

/// Tunables for [`super::run_steps`] and [`super::run_steps_streaming`].
#[derive(Clone)]
#[non_exhaustive]
pub struct RunOptions {
    /// Maximum number of (LLM call + tool dispatch) iterations.
    pub max_steps: u32,
    /// Maximum tokens of short-term memory to load into the prompt.
    pub max_history_tokens: usize,
    /// Cap on bytes accumulated across a single streaming cycle's
    /// content + serialised tool-call payloads. Exceeding this aborts
    /// the run with a terminal `Err`. Applies only to
    /// [`super::run_steps_streaming`]; [`super::run_steps`] is
    /// unaffected (the non-streaming path inherits the provider's own
    /// limits).
    pub max_response_bytes: usize,
    /// Pre/post-LLM validators consulted around every LLM call.
    ///
    /// Empty by default — existing callers see no behavioural change.
    /// Each guardrail is invoked in registration order; the first
    /// non-[`GuardrailOutcome::Pass`](crate::guardrail::GuardrailOutcome::Pass)
    /// outcome short-circuits the run with
    /// [`Error::Refused`](crate::error::Error::Refused) or
    /// [`Error::Handoff`](crate::error::Error::Handoff). In
    /// [`super::run_steps_streaming`], post-LLM guardrails see the
    /// buffered assistant message at the end of each cycle, and a
    /// non-`Pass` outcome propagates as a terminal
    /// `LlmError::Server("refused: …")` or `"handoff to <agent>: …"`
    /// chunk.
    pub guardrails: Vec<Arc<dyn Guardrail>>,
    /// Per-step review policy consulted after each LLM step to decide
    /// whether to pause for human approval (ADR-045). `Ok(None)` from
    /// the policy proceeds; `Ok(Some(reason))` suspends the run and
    /// returns [`crate::error::Error::Suspended`]. Defaults to
    /// [`super::NeverReview`], which never pauses.
    pub review_policy: Arc<dyn crate::runtime::ReviewPolicy>,
    /// When `Some`, suspended-run checkpoints are persisted to this
    /// `KvStore` bucket so a different process can resume the run via
    /// `runtime::resume_from_checkpoint`. `None` keeps the checkpoint
    /// inside the returned [`crate::error::Error::Suspended`] only
    /// (within-process resume).
    pub checkpoint_kv_bucket: Option<String>,
    /// When `Some`, the run loop records each successful LLM call to this sink
    /// (ADR-048) so a replayable capture can be built. `None` (default) records
    /// no LLM I/O — today's behaviour. See
    /// [`CaptureSink`](crate::runtime::CaptureSink).
    pub capture_sink: Option<Arc<dyn crate::runtime::CaptureSink>>,
}

impl std::fmt::Debug for RunOptions {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RunOptions")
            .field("max_steps", &self.max_steps)
            .field("max_history_tokens", &self.max_history_tokens)
            .field("max_response_bytes", &self.max_response_bytes)
            .field("guardrails", &self.guardrails.len())
            .field("checkpoint_kv_bucket", &self.checkpoint_kv_bucket)
            .field("capture_sink", &self.capture_sink.is_some())
            .finish_non_exhaustive()
    }
}

impl RunOptions {
    /// Overrides [`Self::max_steps`].
    pub fn with_max_steps(mut self, steps: u32) -> Self {
        self.max_steps = steps;
        self
    }

    /// Replaces the guardrail chain; see [`Self::guardrails`] for execution semantics.
    pub fn with_guardrails(mut self, guardrails: Vec<Arc<dyn Guardrail>>) -> Self {
        self.guardrails = guardrails;
        self
    }

    /// Overrides [`Self::review_policy`].
    pub fn with_review_policy(mut self, policy: Arc<dyn crate::runtime::ReviewPolicy>) -> Self {
        self.review_policy = policy;
        self
    }

    /// Persists suspend checkpoints to `bucket` in the agent's `KvStore`
    /// (ADR-045), enabling cross-process resume. Without this, the checkpoint
    /// travels only inside the returned [`crate::error::Error::Suspended`].
    pub fn with_checkpoint_bucket(mut self, bucket: impl Into<String>) -> Self {
        self.checkpoint_kv_bucket = Some(bucket.into());
        self
    }

    /// Records each successful LLM call to `sink` (ADR-048) so a replayable
    /// capture can be built from a live run.
    pub fn with_capture_sink(mut self, sink: Arc<dyn crate::runtime::CaptureSink>) -> Self {
        self.capture_sink = Some(sink);
        self
    }
}

impl Default for RunOptions {
    fn default() -> Self {
        Self {
            max_steps: 16,
            max_history_tokens: 8_000,
            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
            guardrails: Vec::new(),
            review_policy: Arc::new(crate::runtime::NeverReview),
            checkpoint_kv_bucket: None,
            capture_sink: None,
        }
    }
}

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

    /// Pin the streaming byte cap so an accidental retune of
    /// [`DEFAULT_MAX_RESPONSE_BYTES`] is caught at test time rather
    /// than silently changing the runtime contract for every caller
    /// using `RunOptions::default()`.
    #[test]
    fn default_max_response_bytes_is_four_mebibytes() {
        assert_eq!(RunOptions::default().max_response_bytes, 4 * 1024 * 1024);
    }

    #[tokio::test]
    async fn default_review_policy_never_pauses_and_no_bucket() {
        let opts = RunOptions::default();
        assert!(opts.checkpoint_kv_bucket.is_none());
        let msg = crate::llm::Message {
            role: crate::llm::Role::Assistant,
            content: "hi".into(),
            tool_calls: vec![],
            tool_call_id: None,
        };
        assert_eq!(
            opts.review_policy
                .should_pause_for_approval(1, &msg)
                .await
                .unwrap(),
            None
        );
    }

    #[tokio::test]
    async fn builders_set_review_fields() {
        struct PauseAlways;
        #[async_trait::async_trait]
        impl crate::runtime::ReviewPolicy for PauseAlways {
            async fn should_pause_for_approval(
                &self,
                _step: u32,
                _message: &crate::llm::Message,
            ) -> Result<Option<String>, crate::error::Error> {
                Ok(Some("always".into()))
            }
        }

        let opts = RunOptions::default()
            .with_checkpoint_bucket("klieo.run-checkpoints")
            .with_review_policy(std::sync::Arc::new(PauseAlways));
        assert_eq!(
            opts.checkpoint_kv_bucket.as_deref(),
            Some("klieo.run-checkpoints")
        );
        let msg = crate::llm::Message {
            role: crate::llm::Role::Assistant,
            content: "hi".into(),
            tool_calls: vec![],
            tool_call_id: None,
        };
        assert_eq!(
            opts.review_policy
                .should_pause_for_approval(1, &msg)
                .await
                .unwrap(),
            Some("always".to_string()),
            "with_review_policy must install the supplied policy, not the default"
        );
    }
}