Skip to main content

recursive/
kernel.rs

1//! Turn-level types for the Agent Run Kernel architecture.
2//!
3//! This module defines the input/output contract for a single turn of
4//! agent execution:
5//!
6//! * [`TurnContext`] — everything the kernel needs to execute one turn
7//!   (messages, tools, config, event sink).
8//! * [`TurnOutcome`] — the result of executing one turn (new messages,
9//!   usage, finish reason, side effects).
10//! * [`AgentKernel`] — the stateless single-turn executor. Its `run()`
11//!   method delegates to [`crate::run_core::RunCore`] which owns the
12//!   ReAct step loop.
13//!
14//! # Design
15//!
16//! The Kernel is stateless and knows nothing about transcripts, sessions,
17//! or cross-turn state. The Wrapper (`AgentRuntime`) prepares a
18//! `TurnContext` from its transcript, calls the kernel, and then
19//! incorporates the `TurnOutcome` back into its state.
20//!
21//! The kernel passes the caller's `AgentEvent` channel directly to
22//! `RunCore` — no internal bridge (introduced in Goal 219).
23
24use crate::agent::FinishReason;
25use crate::compact::Compactor;
26use crate::event::AgentEvent;
27use crate::hooks::HookRegistry;
28use crate::llm::{ChatProvider, TokenUsage, ToolSpec};
29use crate::message::Message;
30use crate::permissions::PermissionMode;
31use crate::storage::{NoopSessionStore, SessionStore, StorageBackend};
32use crate::tool_set_provider::ToolSetProvider;
33use crate::tools::PermissionHook;
34use crate::tools::ToolRegistry;
35use std::sync::atomic::AtomicBool;
36use std::sync::Arc;
37
38// ---------------------------------------------------------------------------
39// TurnContext
40// ---------------------------------------------------------------------------
41
42/// Everything the Kernel needs to execute one turn.
43///
44/// Prepared by the Wrapper (AgentRuntime). The Kernel does not know
45/// where these messages came from — could be fresh, compacted, or resumed.
46///
47/// # Ownership
48///
49/// `messages` is an **owned copy** of the wrapper's transcript. The kernel
50/// takes ownership and may mutate it freely during the ReAct loop (trimming
51/// old tool results, intra-turn compaction). The wrapper retains the canonical
52/// transcript and incorporates only the new messages from
53/// [`TurnOutcome::new_messages`] after the turn completes.
54///
55/// A full clone per turn is intentional: `RunCore` mutates the list in-place,
56/// so sharing via `Arc` would not eliminate the allocation. The clone is
57/// bounded by the `max_transcript_chars` trim that runs before each turn.
58pub struct TurnContext {
59    /// Shared reference to the wrapper's transcript for this turn.
60    ///
61    /// The kernel may mutate this list in-place via `Arc::make_mut`;
62    /// the wrapper's canonical transcript is unaffected until the
63    /// kernel drops its reference.
64    pub messages: Arc<Vec<Message>>,
65
66    /// Channel to send agent events to the caller (runtime or test harness).
67    ///
68    /// The kernel passes this channel directly to `RunCore` (Goal 219 Commit
69    /// 1), so callers receive the same `AgentEvent` stream the kernel sees —
70    /// no internal conversion.
71    pub step_events_tx: Option<tokio::sync::mpsc::UnboundedSender<AgentEvent>>,
72
73    /// Tool specifications to advertise to the LLM.
74    pub tool_specs: Vec<ToolSpec>,
75
76    /// Whether to stream LLM responses token-by-token.
77    pub streaming: bool,
78
79    /// Optional permission hook for gating tool calls.
80    pub permission_hook: Option<Arc<dyn PermissionHook>>,
81
82    /// Goal-165: shared flag that enables agent-driven read-only plan mode.
83    /// When `true`, write tools are blocked until `exit_plan_mode` is called.
84    pub exploring_plan_mode: Arc<AtomicBool>,
85
86    /// Goal-190: default permission mode for tools not covered by explicit
87    /// config lists. Mirrors `PermissionsConfig.mode` for quick access.
88    pub permission_mode: PermissionMode,
89
90    /// Optional mailbox for mid-run message injection from a coordinator.
91    ///
92    /// When set, the kernel drains this mailbox at the start of every step
93    /// and appends any pending messages as user turns.  This powers the
94    /// `send_message` tool's bidirectional coordinator ↔ worker flow.
95    pub mailbox: Option<crate::tools::send_message::WorkerMailbox>,
96
97    /// Turn index (0-based), used to scope [`crate::tools::AuditMeta`]
98    /// keys so that tool-call-id collisions across turns cannot cause
99    /// audit metadata to be overwritten or lost.
100    pub turn: u32,
101
102    /// Goal-328: structured prompt segments from `assemble_system_prompt`,
103    /// forwarded to `RunCore` so it can size the static breakdown
104    /// buckets. `None` when the caller did not provide one (legacy
105    /// channels, tests).
106    pub prompt_segments: Option<crate::system_prompt::PromptSegments>,
107
108    /// Goal 345: wall-clock timeout in seconds. When > 0, the step
109    /// loop checks elapsed time and terminates cleanly with
110    /// [`FinishReason::WallClockExceeded`]. Default 0 = unset.
111    pub wall_timeout_secs: u64,
112}
113
114// ---------------------------------------------------------------------------
115// TurnOutcome
116// ---------------------------------------------------------------------------
117
118/// The result of executing one turn.
119///
120/// Returned to the Wrapper, which appends new_messages to its transcript,
121/// persists them, handles side effects, and tracks costs.
122#[derive(Debug)]
123pub struct TurnOutcome {
124    /// All messages produced during this turn (assistant responses + tool results).
125    /// Does NOT include the input messages — only what the kernel generated.
126    pub new_messages: Vec<Message>,
127
128    /// The final assistant text (convenience — also the last assistant msg in new_messages).
129    pub final_text: Option<String>,
130
131    /// Why the turn ended.
132    pub finish_reason: FinishReason,
133
134    /// Cumulative token usage across all LLM calls in this turn.
135    pub usage: TokenUsage,
136
137    /// `prompt_tokens` from the last LLM call only (not accumulated).
138    /// Use for cross-turn compaction; `usage.prompt_tokens` is the turn sum.
139    pub last_prompt_tokens: u32,
140
141    /// Total LLM call latency in milliseconds (excluding tool execution time).
142    pub llm_latency_ms: u64,
143
144    /// Number of steps (LLM invocations) executed in this turn.
145    pub steps: usize,
146
147    /// Goal-153: audit records for tool results, keyed by `(turn, tool_call_id)`.
148    /// Passed through from `RunInnerOutcome` so the persistence layer
149    /// can emit `MessageAppendedWithAudit` for tool messages.
150    pub tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
151
152    /// Turn index (0-based) this outcome belongs to. Redundant with the key
153    /// prefix in `tool_audits`, but needed for `emit_turn_messages` lookup.
154    pub turn: u32,
155}
156
157// ---------------------------------------------------------------------------
158// AgentKernel
159// ---------------------------------------------------------------------------
160
161/// The stateless Agent Kernel — a single-turn ReAct executor.
162///
163/// Cheap to create, safe to clone, safe to share across threads.
164/// Does not own transcript, session, or any cross-turn state.
165///
166#[derive(Clone)]
167pub struct AgentKernel {
168    /// The LLM provider to use for completions.
169    pub(crate) llm: Arc<dyn ChatProvider>,
170    /// The tool registry (tools available to the agent).
171    pub(crate) tools: ToolRegistry,
172    /// Maximum number of LLM calls per turn.
173    pub(crate) max_steps: usize,
174    /// Maximum transcript characters before trimming (None = no limit).
175    pub(crate) max_transcript_chars: Option<usize>,
176    /// Optional compactor for summarising old messages.
177    pub(crate) compactor: Option<Compactor>,
178    /// Optional microcompactor for no-LLM proactive pruning of old tool results.
179    pub(crate) microcompactor: Option<crate::compact::Microcompactor>,
180    /// Hook registry for lifecycle hooks.
181    pub(crate) hooks: HookRegistry,
182    /// Optional cancellation token for graceful shutdown. When the token
183    /// is cancelled, the kernel's step loop terminates with
184    /// [`FinishReason::Cancelled`](crate::agent::FinishReason::Cancelled)
185    /// at the next step boundary.
186    pub(crate) shutdown_token: Option<tokio_util::sync::CancellationToken>,
187    /// Pluggable storage backend (transcript + memory). Defaults to a
188    /// `LocalStorageBackend` when not set; cloud deployments inject S3.
189    pub(crate) storage: Arc<dyn StorageBackend>,
190    /// Pluggable session hot-state store (checkpoint step/transcript_len).
191    /// Defaults to `NoopSessionStore`; cloud deployments inject Redis.
192    pub(crate) session_store: Arc<dyn SessionStore>,
193    /// Sliding window size for stuck detection (from Config). Default 10.
194    pub(crate) stuck_window: usize,
195    /// Error rate threshold within the window to declare stuck. Default 0.8.
196    pub(crate) stuck_error_rate: f64,
197    /// Goal-318: Globs-mode skills passed to `SkillInjector` each run.
198    pub(crate) globs_skills: Vec<crate::skills::Skill>,
199}
200
201impl std::fmt::Debug for AgentKernel {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        let tools_count = self.tools.names().len();
204        let hooks_count = self.hooks.len();
205        f.debug_struct("AgentKernel")
206            .field("llm", &"<ChatProvider>")
207            .field("tools_count", &tools_count)
208            .field("max_steps", &self.max_steps)
209            .field("max_transcript_chars", &self.max_transcript_chars)
210            .field("compactor", &self.compactor)
211            .field("hooks_count", &hooks_count)
212            .field("storage", &"<StorageBackend>")
213            .field("session_store", &"<SessionStore>")
214            .finish()
215    }
216}
217
218impl AgentKernel {
219    /// Create a new builder for `AgentKernel`.
220    #[cfg_attr(test, mutants::skip)]
221    pub fn builder() -> AgentKernelBuilder {
222        AgentKernelBuilder::default()
223    }
224
225    /// Access the LLM provider.
226    pub fn llm(&self) -> &Arc<dyn ChatProvider> {
227        &self.llm
228    }
229
230    /// Hot-swap the LLM provider.
231    ///
232    /// Replaces the provider used for subsequent completions. Used by the
233    /// TUI `/model` picker to switch models without restarting the process.
234    /// Safe to call between turns; callers must not invoke it while a turn
235    /// is in flight on the same kernel (the runtime's turn task owns the
236    /// kernel via `&mut self` for the duration of the turn).
237    pub fn set_llm(&mut self, llm: Arc<dyn ChatProvider>) {
238        self.llm = llm;
239    }
240
241    /// Access the tool registry.
242    pub fn tools(&self) -> &ToolRegistry {
243        &self.tools
244    }
245
246    /// Mutable access to the tool registry.
247    ///
248    /// Used by [`AgentRuntime::enable_checkpoints`] to register
249    /// session-scoped read-only tools (`checkpoint_list`,
250    /// `checkpoint_diff`) once the session id is known.
251    pub fn tools_mut(&mut self) -> &mut ToolRegistry {
252        &mut self.tools
253    }
254
255    /// Access the cancellation token, if one was configured.
256    ///
257    /// Useful for tests verifying that token propagation through
258    /// `with_tools` (and other clones) preserves the handle.
259    pub fn shutdown_token(&self) -> Option<&tokio_util::sync::CancellationToken> {
260        self.shutdown_token.as_ref()
261    }
262
263    /// Access the storage backend.
264    pub fn storage(&self) -> &Arc<dyn StorageBackend> {
265        &self.storage
266    }
267
268    /// Access the session store.
269    pub fn session_store(&self) -> &Arc<dyn SessionStore> {
270        &self.session_store
271    }
272
273    /// Public access to the hook registry. Used by `AgentRuntime` to
274    /// dispatch cross-turn `PreCompact` / `PostCompact` events that are
275    /// not handled by `RunCore`.
276    pub fn hooks(&self) -> &HookRegistry {
277        &self.hooks
278    }
279
280    /// Create a new kernel with a different tool registry (same LLM, same config).
281    /// Useful for Multi-Agent scenarios where sub-agents get restricted tool subsets.
282    pub fn with_tools(&self, tools: ToolRegistry) -> Self {
283        let mut clone = self.clone();
284        clone.tools = tools;
285        clone
286    }
287
288    /// Execute one turn of the ReAct loop.
289    ///
290    /// Takes a [`TurnContext`] prepared by the Wrapper and returns a
291    /// [`TurnOutcome`] containing only the new messages produced during
292    /// this turn, plus usage stats and finish reason.
293    ///
294    /// The Kernel is stateless: it does not retain any state between calls.
295    /// All cross-turn concerns (transcript accumulation, compaction, persistence)
296    /// are the Wrapper's responsibility.
297    pub async fn run(&self, ctx: TurnContext) -> crate::error::Result<TurnOutcome> {
298        let input_len = ctx.messages.len();
299
300        let core = {
301            use crate::run_core::{RunCore, StaticBreakdownCache};
302            // Goal-328: size the static breakdown cache from the provided
303            // prompt segments + the tool registry's specs. The cache is
304            // read-only for the rest of the run (no field on RunCore
305            // mutates it after construction); only `conversation` and
306            // `overhead` are recomputed per step.
307            let static_breakdown = match ctx.prompt_segments.as_ref() {
308                Some(segments) => {
309                    StaticBreakdownCache::build(segments, &self.tools.specs(), &self.tools)
310                }
311                None => StaticBreakdownCache::default(),
312            };
313            RunCore {
314                messages: ctx.messages,
315                llm: self.llm.clone(),
316                tools: Arc::new(self.tools.clone()),
317                max_steps: self.max_steps,
318                max_transcript_chars: self.max_transcript_chars,
319                events: ctx.step_events_tx,
320                streaming: ctx.streaming,
321                compactor: self.compactor.clone(),
322                microcompactor: self.microcompactor.clone(),
323                permission_hook: ctx.permission_hook,
324                hooks: &self.hooks,
325                total_llm_latency_ms: 0,
326                exploring_plan_mode: ctx.exploring_plan_mode,
327                shutdown_token: self.shutdown_token.clone(),
328                mailbox: ctx.mailbox,
329                stuck_window: self.stuck_window,
330                stuck_error_rate: self.stuck_error_rate,
331                turn: ctx.turn,
332                globs_skills: self.globs_skills.clone(),
333                prompt_segments: ctx.prompt_segments,
334                static_breakdown,
335                last_prompt_tokens: 0,
336                consecutive_compact_failures: 0,
337                wall_timeout_secs: ctx.wall_timeout_secs,
338                wall_start: if ctx.wall_timeout_secs > 0 {
339                    Some(std::time::Instant::now())
340                } else {
341                    None
342                },
343            }
344        };
345
346        let inner = core.run_inner().await?;
347
348        // Extract only the messages produced during this turn.
349        //
350        // If `RunCore` performed intra-turn compaction, a summary message
351        // (marked with `is_compaction_summary`) is inserted at position 0.
352        // `inner.messages[input_len..]` would miss that summary, so detect
353        // it and prepend.
354        let mut new_messages = turn_delta_messages(&inner.messages, input_len);
355        if !inner.messages.is_empty() && inner.messages[0].is_compaction_summary {
356            new_messages.insert(0, inner.messages[0].clone());
357        }
358
359        Ok(TurnOutcome {
360            new_messages,
361            final_text: inner.final_message,
362            finish_reason: inner.finish_reason,
363            usage: inner.total_usage,
364            last_prompt_tokens: inner.last_prompt_tokens,
365            llm_latency_ms: inner.total_llm_latency_ms,
366            steps: inner.steps,
367            tool_audits: inner.tool_audits,
368            turn: ctx.turn,
369        })
370    }
371}
372
373/// Messages produced after `input_len` in this turn. Soft-skipped: `>` vs `>=`
374/// is equivalent when lengths are equal (empty suffix slice).
375#[cfg_attr(test, mutants::skip)]
376fn turn_delta_messages(
377    inner: &[crate::message::Message],
378    input_len: usize,
379) -> Vec<crate::message::Message> {
380    if inner.len() > input_len {
381        inner[input_len..].to_vec()
382    } else {
383        Vec::new()
384    }
385}
386
387// ---------------------------------------------------------------------------
388// AgentKernelBuilder
389// ---------------------------------------------------------------------------
390
391/// Builder for [`AgentKernel`].
392#[derive(Default)]
393pub struct AgentKernelBuilder {
394    llm: Option<Arc<dyn ChatProvider>>,
395    tools: Option<ToolRegistry>,
396    max_steps: Option<usize>,
397    max_transcript_chars: Option<usize>,
398    compactor: Option<Compactor>,
399    microcompactor: Option<crate::compact::Microcompactor>,
400    hooks: Option<HookRegistry>,
401    shutdown_token: Option<tokio_util::sync::CancellationToken>,
402    /// Pluggable storage backend. When `None`, `build()` falls back to
403    /// `LocalStorageBackend` rooted at the current directory.
404    storage: Option<Arc<dyn StorageBackend>>,
405    /// Pluggable session hot-state store. When `None`, `build()` uses
406    /// `NoopSessionStore` (no-op, zero overhead).
407    session_store: Option<Arc<dyn SessionStore>>,
408    /// Pluggable tool set provider. When `Some`, `build()` calls
409    /// `provider.build_registry()` unless `tools` was set explicitly.
410    tool_set_provider: Option<Arc<dyn ToolSetProvider>>,
411    /// Stuck detection window (default 10).
412    stuck_window: Option<usize>,
413    /// Stuck detection error rate threshold (default 0.8).
414    stuck_error_rate: Option<f64>,
415    /// Goal-318: Globs-mode skills.
416    globs_skills: Vec<crate::skills::Skill>,
417}
418
419impl std::fmt::Debug for AgentKernelBuilder {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        let tools_desc = self.tools.as_ref().map(|t| t.names().len());
422        let hooks_desc = self.hooks.as_ref().map(|h| h.len());
423        f.debug_struct("AgentKernelBuilder")
424            .field("llm", &self.llm.as_ref().map(|_| "<ChatProvider>"))
425            .field("tools", &tools_desc)
426            .field("max_steps", &self.max_steps)
427            .field("max_transcript_chars", &self.max_transcript_chars)
428            .field("compactor", &self.compactor)
429            .field("hooks", &hooks_desc)
430            .field(
431                "storage",
432                &self.storage.as_ref().map(|_| "<StorageBackend>"),
433            )
434            .field(
435                "session_store",
436                &self.session_store.as_ref().map(|_| "<SessionStore>"),
437            )
438            .field(
439                "tool_set_provider",
440                &self.tool_set_provider.as_ref().map(|_| "<ToolSetProvider>"),
441            )
442            .finish()
443    }
444}
445
446impl AgentKernelBuilder {
447    /// Set the LLM provider.
448    pub fn llm(mut self, llm: Arc<dyn ChatProvider>) -> Self {
449        self.llm = Some(llm);
450        self
451    }
452
453    /// Set the tool registry.
454    pub fn tools(mut self, tools: ToolRegistry) -> Self {
455        self.tools = Some(tools);
456        self
457    }
458
459    /// Set the maximum number of LLM calls per turn.
460    pub fn max_steps(mut self, n: usize) -> Self {
461        self.max_steps = Some(n);
462        self
463    }
464
465    /// Set the maximum transcript characters before trimming.
466    pub fn max_transcript_chars(mut self, n: usize) -> Self {
467        self.max_transcript_chars = Some(n);
468        self
469    }
470
471    /// Set the compactor for summarising old messages.
472    pub fn compactor(mut self, compactor: Compactor) -> Self {
473        self.compactor = Some(compactor);
474        self
475    }
476
477    /// Set the microcompactor for no-LLM proactive pruning of old tool results.
478    pub fn microcompactor(mut self, microcompactor: crate::compact::Microcompactor) -> Self {
479        self.microcompactor = Some(microcompactor);
480        self
481    }
482
483    /// Set the hook registry.
484    pub fn hooks(mut self, hooks: HookRegistry) -> Self {
485        self.hooks = Some(hooks);
486        self
487    }
488
489    /// Set the cancellation token for graceful shutdown. When the token
490    /// is cancelled, the kernel's step loop terminates with
491    /// [`FinishReason::Cancelled`](crate::agent::FinishReason::Cancelled)
492    /// at the next step boundary.
493    pub fn shutdown_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
494        self.shutdown_token = Some(token);
495        self
496    }
497
498    /// Inject a storage backend. If not set, `build()` defaults to
499    /// `LocalStorageBackend` rooted at the current working directory.
500    pub fn with_storage(mut self, backend: Arc<dyn StorageBackend>) -> Self {
501        self.storage = Some(backend);
502        self
503    }
504
505    /// Inject a session hot-state store. If not set, `build()` uses
506    /// `NoopSessionStore` (zero cost, no I/O).
507    pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
508        self.session_store = Some(store);
509        self
510    }
511
512    /// Inject a tool set provider. When set and `tools()` is NOT also called,
513    /// `build()` delegates `tools` construction to `provider.build_registry()`.
514    /// If `tools()` was set explicitly, that registry takes precedence.
515    pub fn with_tool_set_provider(mut self, provider: Arc<dyn ToolSetProvider>) -> Self {
516        self.tool_set_provider = Some(provider);
517        self
518    }
519
520    /// Set the stuck-detection sliding window size.
521    pub fn stuck_window(mut self, n: usize) -> Self {
522        self.stuck_window = Some(n);
523        self
524    }
525
526    /// Set the stuck-detection error rate threshold.
527    pub fn stuck_error_rate(mut self, rate: f64) -> Self {
528        self.stuck_error_rate = Some(rate);
529        self
530    }
531
532    /// Build the `AgentKernel`, or return an error if required fields are missing.
533    pub fn build(self) -> crate::error::Result<AgentKernel> {
534        let llm = self.llm.ok_or_else(|| crate::error::Error::Config {
535            message: "llm provider is required".into(),
536        })?;
537        // Tools: explicit registry > tool_set_provider > local default.
538        let tools = if let Some(registry) = self.tools {
539            registry
540        } else if let Some(ref provider) = self.tool_set_provider {
541            provider.build_registry()
542        } else {
543            ToolRegistry::local()
544        };
545        let max_steps = self.max_steps.unwrap_or(0);
546        let hooks = self.hooks.unwrap_or_default();
547        // Storage defaults: local filesystem, no-op session store.
548        let storage: Arc<dyn StorageBackend> = self.storage.unwrap_or_else(|| {
549            Arc::new(crate::storage::local::LocalStorageBackend::new(
550                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
551            ))
552        });
553        let session_store: Arc<dyn SessionStore> = self
554            .session_store
555            .unwrap_or_else(|| Arc::new(NoopSessionStore));
556        Ok(AgentKernel {
557            llm,
558            tools,
559            max_steps,
560            max_transcript_chars: self.max_transcript_chars,
561            compactor: self.compactor,
562            microcompactor: self.microcompactor,
563            hooks,
564            shutdown_token: self.shutdown_token,
565            storage,
566            session_store,
567            stuck_window: self.stuck_window.unwrap_or(10),
568            stuck_error_rate: self.stuck_error_rate.unwrap_or(0.8),
569            globs_skills: self.globs_skills,
570        })
571    }
572
573    /// Goal-318: set the skills list (for Globs-mode injection).
574    pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
575        self.globs_skills = skills;
576        self
577    }
578}
579
580// ---------------------------------------------------------------------------
581// Tests
582// ---------------------------------------------------------------------------
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::llm::MockProvider;
588
589    // -- Builder tests ------------------------------------------------------
590
591    #[test]
592    fn kernel_builder_requires_llm() {
593        let result = AgentKernel::builder().build();
594        assert!(result.is_err());
595        match result {
596            Err(e) => assert!(e.to_string().contains("llm provider is required")),
597            Ok(_) => panic!("expected Err"),
598        }
599    }
600
601    #[test]
602    fn kernel_builder_happy_path() {
603        let mock = MockProvider::default();
604        let tools = ToolRegistry::local();
605        let kernel = AgentKernel::builder()
606            .llm(Arc::new(mock))
607            .tools(tools)
608            .max_steps(16)
609            .build()
610            .expect("build should succeed");
611        assert_eq!(kernel.max_steps, 16);
612    }
613
614    #[test]
615    fn kernel_builder_default_max_steps() {
616        let mock = MockProvider::default();
617        let tools = ToolRegistry::local();
618        let kernel = AgentKernel::builder()
619            .llm(Arc::new(mock))
620            .tools(tools)
621            .build()
622            .expect("build should succeed");
623        assert_eq!(kernel.max_steps, 0);
624    }
625
626    // -- Clone / with_tools tests ------------------------------------------
627
628    #[test]
629    fn kernel_clone_is_independent() {
630        let mock = MockProvider::default();
631        let tools1 = ToolRegistry::local();
632        let kernel = AgentKernel::builder()
633            .llm(Arc::new(mock))
634            .tools(tools1)
635            .build()
636            .expect("build should succeed");
637
638        let mut cloned = kernel.clone();
639        // Modify the clone's tools by creating a new registry
640        let new_tools = ToolRegistry::local();
641        cloned.tools = new_tools;
642
643        // The original should still have its original tools
644        // (we can't compare ToolRegistry directly, but we can check
645        // that the clone's tools are different by checking the transport)
646        assert!(!Arc::ptr_eq(
647            kernel.tools().transport(),
648            cloned.tools().transport()
649        ));
650    }
651
652    #[test]
653    fn kernel_with_tools_preserves_llm() {
654        let mock = MockProvider::default();
655        let mock_arc = Arc::new(mock);
656        let tools1 = ToolRegistry::local();
657        let kernel = AgentKernel::builder()
658            .llm(mock_arc.clone())
659            .tools(tools1)
660            .build()
661            .expect("build should succeed");
662
663        let tools2 = ToolRegistry::local();
664        let new_kernel = kernel.with_tools(tools2);
665
666        // LLM provider should be the same Arc
667        assert!(Arc::ptr_eq(&kernel.llm, &new_kernel.llm));
668        // max_steps should be preserved
669        assert_eq!(new_kernel.max_steps, kernel.max_steps);
670    }
671
672    // -- AgentKernel::run() tests -------------------------------------------
673
674    fn make_minimal_ctx(messages: Vec<Message>) -> TurnContext {
675        use std::sync::atomic::AtomicBool;
676        TurnContext {
677            messages: Arc::new(messages),
678            step_events_tx: None,
679            tool_specs: vec![],
680            streaming: false,
681            permission_hook: None,
682            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
683            permission_mode: crate::permissions::PermissionMode::Default,
684            mailbox: None,
685            turn: 0,
686            prompt_segments: None,
687            wall_timeout_secs: 0,
688        }
689    }
690
691    /// Kills: `replace > with ==` (and `replace > with <`) at line 313.
692    ///
693    /// After a simple one-reply turn, `new_messages` must contain the reply.
694    /// With `== input_len`, the condition `inner.messages.len() == input_len`
695    /// is false when a reply was added (len > input_len), so `new_messages`
696    /// would be empty.
697    #[tokio::test]
698    async fn kernel_run_new_messages_contains_reply() {
699        use crate::llm::Completion;
700        let provider = Arc::new(MockProvider::new(vec![Completion {
701            content: "done".to_string(),
702            tool_calls: vec![],
703            finish_reason: Some("stop".to_string()),
704            usage: None,
705            reasoning_content: None,
706        }]));
707        let kernel = AgentKernel::builder()
708            .llm(provider)
709            .max_steps(1)
710            .build()
711            .expect("build");
712
713        let ctx = make_minimal_ctx(vec![Message::user("hello".to_string())]);
714        let outcome = kernel.run(ctx).await.expect("run");
715
716        assert_eq!(
717            outcome.new_messages.len(),
718            1,
719            "new_messages must contain exactly the assistant reply; got {:?}",
720            outcome.new_messages
721        );
722        assert_eq!(outcome.new_messages[0].content, "done");
723    }
724
725    /// Kills: `replace && with ||` at line 318.
726    ///
727    /// When there is NO compaction summary, the first input message must NOT
728    /// be prepended to `new_messages`.  With `||`, the condition becomes
729    /// `!inner.messages.is_empty() || ...` which is true for any non-empty
730    /// messages list, causing the first message to ALWAYS be prepended.
731    #[tokio::test]
732    async fn kernel_run_does_not_prepend_input_to_new_messages() {
733        use crate::llm::Completion;
734        let provider = Arc::new(MockProvider::new(vec![Completion {
735            content: "answer".to_string(),
736            tool_calls: vec![],
737            finish_reason: Some("stop".to_string()),
738            usage: None,
739            reasoning_content: None,
740        }]));
741        let kernel = AgentKernel::builder()
742            .llm(provider)
743            .max_steps(1)
744            .build()
745            .expect("build");
746
747        let input = Message::user("question".to_string());
748        let ctx = make_minimal_ctx(vec![input.clone()]);
749        let outcome = kernel.run(ctx).await.expect("run");
750
751        // new_messages must contain ONLY the assistant reply, not the input.
752        assert_eq!(outcome.new_messages.len(), 1, "only reply expected");
753        assert_eq!(
754            outcome.new_messages[0].content, "answer",
755            "first new message must be the reply, not the input"
756        );
757        assert!(
758            outcome.new_messages[0].content != "question",
759            "input must not appear in new_messages"
760        );
761    }
762
763    /// Kills: `delete !` on the compaction-summary prepend guard — when the
764    /// first message is NOT a compaction summary, it must not be prepended.
765    #[tokio::test]
766    async fn kernel_run_does_not_prepend_non_summary_first_message() {
767        use crate::llm::Completion;
768        let provider = Arc::new(MockProvider::new(vec![Completion {
769            content: "reply".to_string(),
770            tool_calls: vec![],
771            finish_reason: Some("stop".to_string()),
772            usage: None,
773            reasoning_content: None,
774        }]));
775        let kernel = AgentKernel::builder()
776            .llm(provider)
777            .max_steps(1)
778            .build()
779            .expect("build");
780
781        let system = Message::system("sys".to_string());
782        assert!(
783            !system.is_compaction_summary,
784            "fixture must not be a compaction summary"
785        );
786        let ctx = make_minimal_ctx(vec![system, Message::user("q".to_string())]);
787        let outcome = kernel.run(ctx).await.expect("run");
788        assert_eq!(outcome.new_messages.len(), 1);
789        assert_eq!(outcome.new_messages[0].content, "reply");
790        assert!(
791            outcome.new_messages.iter().all(|m| m.content != "sys"),
792            "non-summary first message must not be prepended; got {:?}",
793            outcome.new_messages
794        );
795    }
796
797    #[tokio::test]
798    async fn kernel_run_prepends_compaction_summary_to_new_messages() {
799        use crate::compact::Compactor;
800        use crate::llm::Completion;
801        let messages = vec![
802            Message::system("sys".to_string()),
803            Message::user("u1".to_string()),
804            Message::assistant("a1".to_string()),
805            Message::user("u2".to_string()),
806            Message::assistant("a2".to_string()),
807            Message::user("u3".to_string()),
808        ];
809        let provider = Arc::new(MockProvider::new(vec![
810            Completion {
811                content: "compact summary".to_string(),
812                tool_calls: vec![],
813                finish_reason: Some("stop".to_string()),
814                usage: None,
815                reasoning_content: None,
816            },
817            Completion {
818                content: "final".to_string(),
819                tool_calls: vec![],
820                finish_reason: Some("stop".to_string()),
821                usage: None,
822                reasoning_content: None,
823            },
824        ]));
825        let kernel = AgentKernel::builder()
826            .llm(provider)
827            .compactor(Compactor::new(0).keep_recent_n(2))
828            .max_steps(1)
829            .build()
830            .expect("build");
831
832        let ctx = make_minimal_ctx(messages);
833        let outcome = kernel.run(ctx).await.expect("run");
834
835        assert!(
836            !outcome.new_messages.is_empty(),
837            "expected new messages after compaction + reply"
838        );
839        assert!(
840            outcome.new_messages[0].is_compaction_summary,
841            "compaction summary must be prepended to new_messages; got {:?}",
842            outcome.new_messages
843        );
844    }
845
846    #[test]
847    fn kernel_builder_max_transcript_chars_stored_and_chains() {
848        let mock = Arc::new(MockProvider::default());
849        let kernel = AgentKernel::builder()
850            .llm(mock)
851            .max_transcript_chars(12_345)
852            .max_steps(3)
853            .build()
854            .expect("build");
855        assert_eq!(
856            kernel.max_transcript_chars,
857            Some(12_345),
858            "max_transcript_chars must survive builder chaining"
859        );
860        assert_eq!(
861            kernel.max_steps, 3,
862            "builder chain must not reset max_steps"
863        );
864    }
865
866    // -- AgentKernelBuilder Debug fmt test ----------------------------------
867
868    #[test]
869    fn agent_kernel_builder_debug_contains_field_names() {
870        // kills `replace <impl std::fmt::Debug for AgentKernelBuilder>::fmt
871        //         -> std::fmt::Result with Ok(Default::default())`
872        // With the mutant the formatter produces no output; the assertion fails.
873        let builder = AgentKernel::builder().max_steps(42);
874        let dbg = format!("{:?}", builder);
875        assert!(
876            dbg.contains("AgentKernelBuilder"),
877            "Debug output must contain struct name; got: {dbg}"
878        );
879        assert!(
880            dbg.contains("max_steps"),
881            "Debug output must contain max_steps field; got: {dbg}"
882        );
883    }
884
885    #[test]
886    fn kernel_builder_stuck_window_and_error_rate() {
887        // kills mutations to `unwrap_or(10)` and `unwrap_or(0.8)` defaults
888        let mock = MockProvider::default();
889        let kernel_defaults = AgentKernel::builder().llm(Arc::new(mock)).build().unwrap();
890        assert_eq!(
891            kernel_defaults.stuck_window, 10,
892            "default stuck_window must be 10"
893        );
894        assert!(
895            (kernel_defaults.stuck_error_rate - 0.8).abs() < 1e-10,
896            "default stuck_error_rate must be 0.8"
897        );
898
899        let mock2 = MockProvider::default();
900        let kernel_custom = AgentKernel::builder()
901            .llm(Arc::new(mock2))
902            .stuck_window(5)
903            .stuck_error_rate(0.5)
904            .build()
905            .unwrap();
906        assert_eq!(kernel_custom.stuck_window, 5);
907        assert!((kernel_custom.stuck_error_rate - 0.5).abs() < 1e-10);
908    }
909
910    #[test]
911    fn kernel_accessor_methods() {
912        // kills accessor method-replacement mutations
913        let mock = Arc::new(MockProvider::default());
914        let kernel = AgentKernel::builder().llm(mock.clone()).build().unwrap();
915        // llm() returns the same Arc
916        assert!(
917            Arc::ptr_eq(&kernel.llm, kernel.llm()),
918            "llm() must return &self.llm"
919        );
920        // tools() returns the registry
921        let _ = kernel.tools();
922        // hooks() returns hook registry
923        let _ = kernel.hooks();
924        // storage() returns storage backend
925        let _ = kernel.storage();
926        // session_store() returns session store
927        let _ = kernel.session_store();
928        // shutdown_token is None when not set
929        assert!(
930            kernel.shutdown_token().is_none(),
931            "no token must be set by default"
932        );
933    }
934
935    #[test]
936    fn kernel_max_steps_zero_by_default() {
937        // kills `self.max_steps.unwrap_or(0)` → `unwrap_or(1)` mutation
938        let mock = Arc::new(MockProvider::default());
939        let kernel = AgentKernel::builder().llm(mock).build().unwrap();
940        assert_eq!(
941            kernel.max_steps, 0,
942            "default max_steps must be 0 (unlimited)"
943        );
944    }
945
946    #[test]
947    fn kernel_max_steps_custom_value() {
948        // kills `max_steps.unwrap_or(...)` mutation
949        let mock = Arc::new(MockProvider::default());
950        let kernel = AgentKernel::builder()
951            .llm(mock)
952            .max_steps(25)
953            .build()
954            .unwrap();
955        assert_eq!(kernel.max_steps, 25, "custom max_steps must be stored");
956    }
957
958    #[test]
959    fn with_tools_replaces_registry() {
960        // kills `fn with_tools` function-replacement mutation
961        use crate::tools::transport::LocalTransport;
962        let mock = Arc::new(MockProvider::default());
963        let kernel = AgentKernel::builder().llm(mock.clone()).build().unwrap();
964        // The local registry has tools; create an empty one to swap in.
965        let empty_reg = ToolRegistry::new(Arc::new(LocalTransport));
966        let replaced = kernel.with_tools(empty_reg);
967        // The replaced kernel must use the new (empty) registry.
968        // ToolRegistry::local() registers many tools; our empty one has none.
969        assert_eq!(
970            replaced.tools().names().len(),
971            0,
972            "with_tools must swap in the empty registry"
973        );
974    }
975
976    // -- TurnOutcome tests --------------------------------------------------
977
978    #[test]
979    fn turn_outcome_default_values() {
980        let outcome = TurnOutcome {
981            new_messages: vec![],
982            final_text: None,
983            finish_reason: FinishReason::NoMoreToolCalls,
984            usage: TokenUsage::default(),
985            last_prompt_tokens: 0,
986            llm_latency_ms: 0,
987            steps: 0,
988            tool_audits: std::collections::HashMap::new(),
989            turn: 0,
990        };
991        assert!(outcome.new_messages.is_empty());
992        assert!(outcome.final_text.is_none());
993        assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);
994        assert_eq!(outcome.usage, TokenUsage::default());
995        assert_eq!(outcome.llm_latency_ms, 0);
996        assert_eq!(outcome.steps, 0);
997    }
998}