klieo-core 3.5.0

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

use crate::guardrail::Guardrail;
use crate::summarize::SummarizeOptions;
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>>,
    /// Auto-compaction tunables (Item C). `Some(opts)` — the default —
    /// keeps long runs bounded: before building each request, the step
    /// loop calls [`crate::summarize::summarize_history`] with `opts`,
    /// and when it returns a summary, rewrites short-term memory to
    /// `[summary message, ...last opts.keep_recent_messages verbatim]`.
    /// `None` is the compliance carve-out — compaction never fires, and
    /// the full turn-by-turn transcript stays in short-term memory
    /// (subject only to the per-request `max_history_tokens` load cap).
    ///
    /// Compaction only ever shortens the *working* short-term context.
    /// It never touches `EpisodicMemory`: the per-step
    /// `Episode::LlmCall` / `Episode::ToolCall` audit trail is recorded
    /// (via `record_and_dispatch`) before this can fire,
    /// and the `EpisodicMemory` trait exposes no delete/mutate method —
    /// so the original run history stays fully accounted for in the
    /// audit trail no matter how many times short-term memory gets
    /// rewritten. The summarizer's own LLM call is itself recorded as
    /// `Episode::SummaryCheckpoint`, so it never bypasses the audit
    /// trail either. A `Some(opts)` with `opts.trigger_token_budget ==
    /// 0` is rejected with `Error::Config` the first time the step loop
    /// checks it, rather than silently re-summarising every step.
    pub compaction: Option<SummarizeOptions>,
}

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())
            .field("compaction_enabled", &self.compaction.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
    }

    /// Overrides [`Self::compaction`]'s tunables, keeping compaction enabled.
    pub fn with_compaction(mut self, opts: SummarizeOptions) -> Self {
        self.compaction = Some(opts);
        self
    }

    /// Disables auto-compaction entirely (the compliance carve-out) — the
    /// full turn-by-turn transcript stays in short-term memory for the
    /// life of the run.
    pub fn without_compaction(mut self) -> Self {
        self.compaction = None;
        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,
            compaction: Some(SummarizeOptions::default()),
        }
    }
}

#[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"
        );
    }

    /// Item C: compaction is default-ON so long runs stay bounded
    /// without any caller opt-in.
    #[test]
    fn default_compaction_is_enabled() {
        assert!(RunOptions::default().compaction.is_some());
    }

    #[test]
    fn without_compaction_disables_it() {
        let opts = RunOptions::default().without_compaction();
        assert!(opts.compaction.is_none());
    }

    #[test]
    fn with_compaction_overrides_tunables() {
        let custom = SummarizeOptions {
            trigger_token_budget: 42,
            ..SummarizeOptions::default()
        };
        let opts = RunOptions::default().with_compaction(custom);
        assert_eq!(
            opts.compaction
                .expect("compaction still enabled")
                .trigger_token_budget,
            42
        );
    }
}