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}