Skip to main content

recursive/runtime/
builder.rs

1//! Builder for [`crate::runtime::AgentRuntime`].
2//!
3//! Kept in a child module so `runtime.rs` stays under the invariant #1
4//! line budget.
5
6use std::sync::{Arc, RwLock};
7
8use crate::compact::Compactor;
9use crate::error::Result;
10use crate::event::{EventSink, NullSink};
11use crate::hooks::HookRegistry;
12use crate::kernel::AgentKernelBuilder;
13use crate::llm::ChatProvider;
14use crate::message::Message;
15use crate::tools::plan_mode::{
16    EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanModeRequestGate, RequestPlanModeTool,
17};
18use crate::tools::{TodoItem, TodoWriteTool, ToolRegistry};
19
20use super::{AgentRuntime, CheckpointState, SessionLifecycle};
21
22/// Builder for [`AgentRuntime`].
23///
24/// # Required
25/// - `llm(...)` — The LLM provider.
26///
27/// All other methods are optional with sensible defaults.
28pub struct AgentRuntimeBuilder {
29    kernel_builder: AgentKernelBuilder,
30    system_prompt: Option<String>,
31    seed: Vec<Message>,
32    streaming: bool,
33    saved_event_sink: Option<Arc<dyn EventSink>>,
34    compactor: Option<Compactor>,
35    microcompactor: Option<crate::compact::Microcompactor>,
36    /// When `true`, register `enter_plan_mode`, `exit_plan_mode`, and
37    /// `request_plan_mode` tools. These tools block waiting for human
38    /// approval via the plan approval gate, so they must only be registered
39    /// when a live interactive channel (TUI or interactive CLI) is present
40    /// to call `confirm_plan()` / `reject_plan()`. Headless and non-interactive
41    /// callers must leave this `false` (the default) — the tools simply do not
42    /// exist in the registry, so the model cannot invoke them.
43    with_plan_mode_tools: bool,
44    /// Goal-291: goal-evaluator judge tail-window size. Default 12.
45    goal_eval_transcript_tail: usize,
46    /// Goal-318: skills passed through to AgentKernel for Globs-mode injection.
47    skills: Vec<crate::skills::Skill>,
48    /// Goal-328: structured prompt segments from `assemble_system_prompt`,
49    /// forwarded to the kernel for the local `ContextBreakdown` estimator.
50    prompt_segments: Option<crate::system_prompt::PromptSegments>,
51    /// Goal-334: optional file re-injector for post-compaction restoration
52    /// of recently-read file contents as System attachments.
53    file_reinjector: Option<crate::compact::FileReinjector>,
54    /// Goal-335: optional skill re-injector for post-compaction restoration
55    /// of invoked skill bodies as System attachments.
56    skill_reinjector: Option<crate::compact::SkillReinjector>,
57}
58
59impl std::fmt::Debug for AgentRuntimeBuilder {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("AgentRuntimeBuilder")
62            .field("kernel_builder", &self.kernel_builder)
63            .field("system_prompt", &self.system_prompt)
64            .field("seed", &self.seed)
65            .field("streaming", &self.streaming)
66            .field(
67                "event_sink",
68                &self.saved_event_sink.as_ref().map(|_| "<EventSink>"),
69            )
70            .field("goal_eval_transcript_tail", &self.goal_eval_transcript_tail)
71            .field("file_reinjector", &self.file_reinjector.is_some())
72            .field("skill_reinjector", &self.skill_reinjector.is_some())
73            .finish()
74    }
75}
76
77impl Default for AgentRuntimeBuilder {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl AgentRuntimeBuilder {
84    /// Create a new builder with default values.
85    pub fn new() -> Self {
86        Self {
87            kernel_builder: AgentKernelBuilder::default(),
88            system_prompt: None,
89            seed: Vec::new(),
90            streaming: false,
91            saved_event_sink: None,
92            compactor: None,
93            microcompactor: None,
94            with_plan_mode_tools: false,
95            goal_eval_transcript_tail: 12,
96            skills: Vec::new(),
97            prompt_segments: None,
98            file_reinjector: None,
99            skill_reinjector: None,
100        }
101    }
102
103    /// Goal-328: forward structured prompt segments to the kernel so the
104    /// local `ContextBreakdown` estimator can size the static buckets
105    /// (`system_prompt`, `rules`, `skills`, `subagents`, `tools`,
106    /// `mcp_dynamic`). Callers that built the prompt via
107    /// [`crate::assemble_system_prompt`] should chain the returned
108    /// `segments` through this method:
109    ///
110    /// ```ignore
111    /// let assembled = assemble_system_prompt(base, ws, &skills, sub);
112    /// let mut builder = AgentRuntimeBuilder::new()
113    ///     .system_prompt(assembled.full())
114    ///     .prompt_segments(assembled.segments);
115    /// ```
116    pub fn prompt_segments(mut self, segments: crate::system_prompt::PromptSegments) -> Self {
117        self.prompt_segments = Some(segments);
118        self
119    }
120
121    /// Register `enter_plan_mode`, `exit_plan_mode`, and `request_plan_mode`
122    /// tools. Call this only from channels that have a live human reviewer
123    /// (TUI, interactive CLI). Headless and batch callers must NOT set this —
124    /// the tools block indefinitely waiting for `confirm_plan()`.
125    pub fn with_plan_mode_tools(mut self, enabled: bool) -> Self {
126        self.with_plan_mode_tools = enabled;
127        self
128    }
129
130    /// Set the LLM provider (required).
131    pub fn llm(mut self, llm: Arc<dyn ChatProvider>) -> Self {
132        self.kernel_builder = self.kernel_builder.llm(llm);
133        self
134    }
135
136    /// Set the tool registry (optional, defaults to a local empty registry).
137    pub fn tools(mut self, tools: ToolRegistry) -> Self {
138        self.kernel_builder = self.kernel_builder.tools(tools);
139        self
140    }
141
142    /// Goal-318: set the skills list for Globs-mode automatic injection.
143    pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
144        self.skills = skills;
145        self
146    }
147
148    /// Set an initial system prompt (optional).
149    ///
150    /// This is prepended to the transcript as the first message.
151    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
152        self.system_prompt = Some(prompt.into());
153        self
154    }
155
156    /// Set the maximum number of LLM calls per turn (optional, default 0 (unlimited)).
157    pub fn max_steps(mut self, n: usize) -> Self {
158        self.kernel_builder = self.kernel_builder.max_steps(n);
159        self
160    }
161
162    /// Set a transcript character limit (optional, default unlimited).
163    pub fn max_transcript_chars(mut self, n: usize) -> Self {
164        self.kernel_builder = self.kernel_builder.max_transcript_chars(n);
165        self
166    }
167
168    /// Set an optional compactor for summarising old messages.
169    pub fn compactor(mut self, compactor: Compactor) -> Self {
170        // Also pass the compactor to the kernel so `RunCore` can perform
171        // intra-turn compaction (which dispatches `PreCompact` / `PostCompact`
172        // hooks). Cross-turn compaction is performed by the runtime itself.
173        self.kernel_builder = self.kernel_builder.compactor(compactor.clone());
174        self.compactor = Some(compactor);
175        self
176    }
177
178    /// Set an optional microcompactor for no-LLM proactive pruning of old
179    /// tool results by count.
180    pub fn microcompactor(mut self, microcompactor: crate::compact::Microcompactor) -> Self {
181        self.kernel_builder = self.kernel_builder.microcompactor(microcompactor.clone());
182        self.microcompactor = Some(microcompactor);
183        self
184    }
185
186    /// Enable or disable streaming of partial tokens (optional, default false).
187    pub fn streaming(mut self, enabled: bool) -> Self {
188        self.streaming = enabled;
189        self
190    }
191
192    /// Set the hook registry (optional).
193    pub fn hooks(mut self, hooks: HookRegistry) -> Self {
194        self.kernel_builder = self.kernel_builder.hooks(hooks);
195        self
196    }
197
198    /// Seed the transcript with messages from a previous session.
199    ///
200    /// These messages are placed after any system prompt, before the
201    /// first user turn. Use this to resume an existing conversation.
202    pub fn seed_transcript(mut self, messages: Vec<Message>) -> Self {
203        self.seed = messages;
204        self
205    }
206
207    /// Set the event sink for streaming events (optional, defaults to [`NullSink`]).
208    pub fn event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
209        self.saved_event_sink = Some(sink);
210        self
211    }
212
213    /// Set the cancellation token for graceful shutdown. When the token
214    /// is cancelled, the runtime's underlying kernel terminates the
215    /// step loop with
216    /// [`FinishReason::Cancelled`](crate::agent::FinishReason::Cancelled)
217    /// at the next step boundary.
218    pub fn shutdown_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
219        self.kernel_builder = self.kernel_builder.shutdown_token(token);
220        self
221    }
222
223    /// Set the stuck-detection sliding window size.
224    pub fn stuck_window(mut self, n: usize) -> Self {
225        self.kernel_builder = self.kernel_builder.stuck_window(n);
226        self
227    }
228
229    /// Set the stuck-detection error rate threshold.
230    pub fn stuck_error_rate(mut self, rate: f64) -> Self {
231        self.kernel_builder = self.kernel_builder.stuck_error_rate(rate);
232        self
233    }
234
235    /// Set the tail-window size for the goal-evaluator judge.
236    ///
237    /// Each turn, the goal loop calls
238    /// [`GoalEvaluator::evaluate`](crate::runtime_goal::GoalEvaluator::evaluate)
239    /// with the most-recent `n` transcript messages. Smaller values reduce
240    /// judge cost; larger values give the judge more context for long
241    /// sessions. Defaults to 12 (matching the previous hard-coded
242    /// `GOAL_EVAL_TRANSCRIPT_TAIL` constant). Goal-291.
243    pub fn goal_eval_transcript_tail(mut self, n: usize) -> Self {
244        self.goal_eval_transcript_tail = n;
245        self
246    }
247
248    /// Set an optional file re-injector for post-compaction restoration
249    /// of recently-read file contents as System attachments.
250    pub fn file_reinjector(mut self, r: crate::compact::FileReinjector) -> Self {
251        self.file_reinjector = Some(r);
252        self
253    }
254
255    /// Set an optional skill re-injector for post-compaction restoration.
256    pub fn skill_reinjector(mut self, r: crate::compact::SkillReinjector) -> Self {
257        self.skill_reinjector = Some(r);
258        self
259    }
260
261    /// Build the [`AgentRuntime`].
262    ///
263    /// Returns an error if the LLM provider is missing.
264    pub fn build(self) -> Result<AgentRuntime> {
265        let kernel_builder = self.kernel_builder.skills(self.skills);
266        let mut kernel = kernel_builder.build()?;
267
268        let mut transcript = Vec::new();
269        if let Some(sys) = self.system_prompt {
270            transcript.push(Message::system(sys));
271        }
272        transcript.extend(self.seed);
273
274        let event_sink: Arc<dyn EventSink> =
275            self.saved_event_sink.unwrap_or_else(|| Arc::new(NullSink));
276
277        // Goal-167: create the shared todo list and register a properly-sinked
278        // TodoWriteTool, overriding the NullSink version from build_standard_tools.
279        let todo_list = Arc::new(RwLock::new(Vec::<TodoItem>::new()));
280        kernel.tools_mut().register_mut(Arc::new(TodoWriteTool::new(
281            todo_list.clone(),
282            event_sink.clone(),
283        )));
284
285        // Goal-165 / Goal-202: plan mode tools block waiting for human approval
286        // via the gate. They must only be registered when a live interactive
287        // channel (TUI or interactive CLI) is present to call confirm_plan().
288        // Headless / batch callers set with_plan_mode_tools = false (the default)
289        // so the model never sees these tools and cannot trigger a deadlock.
290        let plan_approval_gate = Arc::new(PlanApprovalGate::new());
291        let plan_mode_request_gate = Arc::new(PlanModeRequestGate::new());
292        if self.with_plan_mode_tools {
293            let permissions_arc = kernel.tools().permissions_config().map(Arc::new);
294            kernel.tools_mut().register_mut({
295                let mut tool = EnterPlanModeTool::new(plan_approval_gate.clone());
296                if let Some(ref perms) = permissions_arc {
297                    tool = tool.with_permissions(perms.clone());
298                }
299                Arc::new(tool)
300            });
301            kernel.tools_mut().register_mut({
302                let mut tool =
303                    ExitPlanModeTool::new(plan_approval_gate.clone(), event_sink.clone());
304                if let Some(ref perms) = permissions_arc {
305                    tool = tool.with_permissions(perms.clone());
306                }
307                Arc::new(tool)
308            });
309            kernel
310                .tools_mut()
311                .register_mut(Arc::new(RequestPlanModeTool::new(
312                    plan_mode_request_gate.clone(),
313                    event_sink.clone(),
314                )));
315        }
316
317        // Goal-340: plan/todo re-injector shares the same todo_list and
318        // plan_approval_gate arcs already constructed above.
319        let plan_todo_reinjector = Some(crate::compact::PlanTodoReinjector::new(
320            todo_list.clone(),
321            plan_approval_gate.clone(),
322        ));
323
324        // Register ToolSearchTool only when the provider supports deferred
325        // tool loading via tool_reference (Anthropic API feature).
326        // OpenAI and compatible providers get all tools eagerly.
327        if kernel.llm().supports_deferred_tools() {
328            kernel.tools_mut().freeze_deferred_specs();
329        }
330
331        Ok(AgentRuntime {
332            kernel,
333            transcript: Arc::new(transcript),
334            event_sink,
335            streaming: self.streaming,
336            compactor: self.compactor,
337            microcompactor: self.microcompactor,
338            consecutive_compact_failures: 0,
339            checkpoints: CheckpointState::disabled(),
340            todo_list,
341            plan_approval_gate,
342            plan_mode_request_gate,
343            goal_state: Arc::new(RwLock::new(None)),
344            message_queue: std::collections::VecDeque::new(),
345            deferred_turn_finished: None,
346            session: SessionLifecycle::open(),
347            goal_eval_transcript_tail: self.goal_eval_transcript_tail,
348            prompt_segments: self.prompt_segments,
349            file_reinjector: self.file_reinjector,
350            skill_reinjector: self.skill_reinjector,
351            plan_todo_reinjector,
352            last_compact_turn: None,
353            permission_hook: None,
354        })
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::llm::MockProvider;
362    use crate::message::Role;
363
364    fn mock_llm() -> Arc<dyn ChatProvider> {
365        Arc::new(MockProvider::new(vec![]))
366    }
367
368    #[test]
369    fn build_with_minimum_config_succeeds() {
370        let rt = AgentRuntimeBuilder::new()
371            .llm(mock_llm())
372            .build()
373            .expect("build() with just an LLM must succeed");
374
375        // Sane defaults pinned: max_steps 0 (unlimited), no compactor,
376        // streaming off, empty transcript, checkpoints disabled.
377        assert_eq!(rt.kernel.max_steps, 0);
378        assert_eq!(rt.kernel.max_transcript_chars, None);
379        assert!(rt.compactor.is_none());
380        assert!(rt.microcompactor.is_none());
381        assert!(!rt.streaming);
382        assert!(rt.transcript.is_empty());
383        assert!(!rt.checkpoints.enabled());
384        assert_eq!(rt.goal_eval_transcript_tail, 12);
385    }
386
387    #[test]
388    fn build_without_llm_errors() {
389        let result = AgentRuntimeBuilder::new().build();
390        let err = result.expect_err("build() without an LLM must fail");
391        assert!(
392            matches!(err, crate::error::Error::Config { .. }),
393            "expected Error::Config, got {err:?}"
394        );
395    }
396
397    #[test]
398    fn builder_setters_round_trip() {
399        let compactor = Compactor::new(1000);
400        let rt = AgentRuntimeBuilder::new()
401            .llm(mock_llm())
402            .max_steps(7)
403            .max_transcript_chars(5000)
404            .compactor(compactor)
405            .streaming(true)
406            .stuck_window(4)
407            .seed_transcript(vec![
408                Message::user("seed user"),
409                Message::assistant("seed assistant"),
410            ])
411            .build()
412            .expect("build() with setters must succeed");
413
414        assert_eq!(rt.kernel.max_steps, 7);
415        assert_eq!(rt.kernel.max_transcript_chars, Some(5000));
416        assert!(rt.compactor.is_some());
417        assert!(rt.streaming);
418        assert_eq!(rt.kernel.stuck_window, 4);
419        // Seeded transcript appears verbatim (no system prompt set here).
420        assert_eq!(rt.transcript.len(), 2);
421        assert_eq!(rt.transcript[0].role, Role::User);
422        assert_eq!(rt.transcript[1].role, Role::Assistant);
423        assert_eq!(rt.transcript[0].content, "seed user");
424        assert_eq!(rt.transcript[1].content, "seed assistant");
425    }
426
427    #[test]
428    fn file_reinjector_and_skill_reinjector_wire_through() {
429        let read_state = Arc::new(std::sync::Mutex::new(crate::tools::fs::ReadFileState::new()));
430        let rt = AgentRuntimeBuilder::new()
431            .llm(mock_llm())
432            .file_reinjector(crate::compact::FileReinjector::new(read_state))
433            .skill_reinjector(crate::compact::SkillReinjector::new(vec![]))
434            .build()
435            .expect("build() with reinjectors must succeed");
436
437        assert!(rt.file_reinjector.is_some());
438        assert!(rt.skill_reinjector.is_some());
439        // Goal-340: build() always wires the plan/todo reinjector.
440        assert!(rt.plan_todo_reinjector.is_some());
441    }
442}