Skip to main content

klieo_core/runtime/
options.rs

1//! [`RunOptions`] and its defaults / `Debug` impl.
2
3use crate::guardrail::Guardrail;
4use std::sync::Arc;
5
6/// Default cap on the total bytes a streaming run will forward to the
7/// caller before aborting. Sized at 4 MiB — comfortably above any sane
8/// chat response while small enough to bound a runaway provider.
9const DEFAULT_MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
10
11/// Tunables for [`super::run_steps`] and [`super::run_steps_streaming`].
12#[derive(Clone)]
13#[non_exhaustive]
14pub struct RunOptions {
15    /// Maximum number of (LLM call + tool dispatch) iterations.
16    pub max_steps: u32,
17    /// Maximum tokens of short-term memory to load into the prompt.
18    pub max_history_tokens: usize,
19    /// Cap on bytes accumulated across a single streaming cycle's
20    /// content + serialised tool-call payloads. Exceeding this aborts
21    /// the run with a terminal `Err`. Applies only to
22    /// [`super::run_steps_streaming`]; [`super::run_steps`] is
23    /// unaffected (the non-streaming path inherits the provider's own
24    /// limits).
25    pub max_response_bytes: usize,
26    /// Pre/post-LLM validators consulted around every LLM call.
27    ///
28    /// Empty by default — existing callers see no behavioural change.
29    /// Each guardrail is invoked in registration order; the first
30    /// non-[`GuardrailOutcome::Pass`](crate::guardrail::GuardrailOutcome::Pass)
31    /// outcome short-circuits the run with
32    /// [`Error::Refused`](crate::error::Error::Refused) or
33    /// [`Error::Handoff`](crate::error::Error::Handoff). In
34    /// [`super::run_steps_streaming`], post-LLM guardrails see the
35    /// buffered assistant message at the end of each cycle, and a
36    /// non-`Pass` outcome propagates as a terminal
37    /// `LlmError::Server("refused: …")` or `"handoff to <agent>: …"`
38    /// chunk.
39    pub guardrails: Vec<Arc<dyn Guardrail>>,
40    /// Per-step review policy consulted after each LLM step to decide
41    /// whether to pause for human approval (ADR-045). `Ok(None)` from
42    /// the policy proceeds; `Ok(Some(reason))` suspends the run and
43    /// returns [`crate::error::Error::Suspended`]. Defaults to
44    /// [`super::NeverReview`], which never pauses.
45    pub review_policy: Arc<dyn crate::runtime::ReviewPolicy>,
46    /// When `Some`, suspended-run checkpoints are persisted to this
47    /// `KvStore` bucket so a different process can resume the run via
48    /// `runtime::resume_from_checkpoint`. `None` keeps the checkpoint
49    /// inside the returned [`crate::error::Error::Suspended`] only
50    /// (within-process resume).
51    pub checkpoint_kv_bucket: Option<String>,
52    /// When `Some`, the run loop records each successful LLM call to this sink
53    /// (ADR-048) so a replayable capture can be built. `None` (default) records
54    /// no LLM I/O — today's behaviour. See
55    /// [`CaptureSink`](crate::runtime::CaptureSink).
56    pub capture_sink: Option<Arc<dyn crate::runtime::CaptureSink>>,
57}
58
59impl std::fmt::Debug for RunOptions {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("RunOptions")
62            .field("max_steps", &self.max_steps)
63            .field("max_history_tokens", &self.max_history_tokens)
64            .field("max_response_bytes", &self.max_response_bytes)
65            .field("guardrails", &self.guardrails.len())
66            .field("checkpoint_kv_bucket", &self.checkpoint_kv_bucket)
67            .field("capture_sink", &self.capture_sink.is_some())
68            .finish_non_exhaustive()
69    }
70}
71
72impl RunOptions {
73    /// Overrides [`Self::max_steps`].
74    pub fn with_max_steps(mut self, steps: u32) -> Self {
75        self.max_steps = steps;
76        self
77    }
78
79    /// Replaces the guardrail chain; see [`Self::guardrails`] for execution semantics.
80    pub fn with_guardrails(mut self, guardrails: Vec<Arc<dyn Guardrail>>) -> Self {
81        self.guardrails = guardrails;
82        self
83    }
84
85    /// Overrides [`Self::review_policy`].
86    pub fn with_review_policy(mut self, policy: Arc<dyn crate::runtime::ReviewPolicy>) -> Self {
87        self.review_policy = policy;
88        self
89    }
90
91    /// Persists suspend checkpoints to `bucket` in the agent's `KvStore`
92    /// (ADR-045), enabling cross-process resume. Without this, the checkpoint
93    /// travels only inside the returned [`crate::error::Error::Suspended`].
94    pub fn with_checkpoint_bucket(mut self, bucket: impl Into<String>) -> Self {
95        self.checkpoint_kv_bucket = Some(bucket.into());
96        self
97    }
98
99    /// Records each successful LLM call to `sink` (ADR-048) so a replayable
100    /// capture can be built from a live run.
101    pub fn with_capture_sink(mut self, sink: Arc<dyn crate::runtime::CaptureSink>) -> Self {
102        self.capture_sink = Some(sink);
103        self
104    }
105}
106
107impl Default for RunOptions {
108    fn default() -> Self {
109        Self {
110            max_steps: 16,
111            max_history_tokens: 8_000,
112            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
113            guardrails: Vec::new(),
114            review_policy: Arc::new(crate::runtime::NeverReview),
115            checkpoint_kv_bucket: None,
116            capture_sink: None,
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    /// Pin the streaming byte cap so an accidental retune of
126    /// [`DEFAULT_MAX_RESPONSE_BYTES`] is caught at test time rather
127    /// than silently changing the runtime contract for every caller
128    /// using `RunOptions::default()`.
129    #[test]
130    fn default_max_response_bytes_is_four_mebibytes() {
131        assert_eq!(RunOptions::default().max_response_bytes, 4 * 1024 * 1024);
132    }
133
134    #[tokio::test]
135    async fn default_review_policy_never_pauses_and_no_bucket() {
136        let opts = RunOptions::default();
137        assert!(opts.checkpoint_kv_bucket.is_none());
138        let msg = crate::llm::Message {
139            role: crate::llm::Role::Assistant,
140            content: "hi".into(),
141            tool_calls: vec![],
142            tool_call_id: None,
143        };
144        assert_eq!(
145            opts.review_policy
146                .should_pause_for_approval(1, &msg)
147                .await
148                .unwrap(),
149            None
150        );
151    }
152
153    #[tokio::test]
154    async fn builders_set_review_fields() {
155        struct PauseAlways;
156        #[async_trait::async_trait]
157        impl crate::runtime::ReviewPolicy for PauseAlways {
158            async fn should_pause_for_approval(
159                &self,
160                _step: u32,
161                _message: &crate::llm::Message,
162            ) -> Result<Option<String>, crate::error::Error> {
163                Ok(Some("always".into()))
164            }
165        }
166
167        let opts = RunOptions::default()
168            .with_checkpoint_bucket("klieo.run-checkpoints")
169            .with_review_policy(std::sync::Arc::new(PauseAlways));
170        assert_eq!(
171            opts.checkpoint_kv_bucket.as_deref(),
172            Some("klieo.run-checkpoints")
173        );
174        let msg = crate::llm::Message {
175            role: crate::llm::Role::Assistant,
176            content: "hi".into(),
177            tool_calls: vec![],
178            tool_call_id: None,
179        };
180        assert_eq!(
181            opts.review_policy
182                .should_pause_for_approval(1, &msg)
183                .await
184                .unwrap(),
185            Some("always".to_string()),
186            "with_review_policy must install the supplied policy, not the default"
187        );
188    }
189}