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//! * [`SideEffect`] — side effects that outlive the turn (background jobs,
11//!   scheduled wakeups).
12//! * [`AgentKernel`] — the stateless single-turn executor (struct + builder
13//!   only; `run()` is not yet implemented).
14//!
15//! # Design
16//!
17//! The Kernel is stateless and knows nothing about transcripts, sessions,
18//! or cross-turn state. The Wrapper (`AgentRuntime`) prepares a
19//! `TurnContext` from its transcript, calls the kernel, and then
20//! incorporates the `TurnOutcome` back into its state.
21//!
22//! As of Goal 219 Commit 1, the kernel passes the caller's
23//! `AgentEvent` channel directly to `RunCore` — no internal bridge.
24
25use std::sync::atomic::AtomicBool;
26use std::sync::Arc;
27use std::time::Duration;
28
29use crate::agent::{FinishReason, PlanningMode};
30use crate::compact::Compactor;
31use crate::event::AgentEvent;
32use crate::hooks::HookRegistry;
33use crate::llm::{LlmProvider, TokenUsage, ToolSpec};
34use crate::message::Message;
35use crate::permissions::PermissionMode;
36use crate::storage::{NoopSessionStore, SessionStore, StorageBackend};
37use crate::tool_set_provider::ToolSetProvider;
38use crate::tools::PermissionHook;
39use crate::tools::ToolRegistry;
40
41// ---------------------------------------------------------------------------
42// TurnContext
43// ---------------------------------------------------------------------------
44
45/// Everything the Kernel needs to execute one turn.
46///
47/// Prepared by the Wrapper (AgentRuntime). The Kernel does not know
48/// where these messages came from — could be fresh, compacted, or resumed.
49pub struct TurnContext {
50    /// The full message list to send to the LLM (system + history + new user msg).
51    pub messages: Vec<Message>,
52
53    /// Channel to send agent events to the caller (runtime or test harness).
54    ///
55    /// The kernel passes this channel directly to `RunCore` (Goal 219 Commit
56    /// 1), so callers receive the same `AgentEvent` stream the kernel sees —
57    /// no internal conversion.
58    pub step_events_tx: Option<tokio::sync::mpsc::UnboundedSender<AgentEvent>>,
59
60    /// Whether the user confirmed a pending plan.
61    pub plan_confirmed: bool,
62
63    /// Buffered tool calls from a proposed plan (when user confirms).
64    pub plan_buffer: Option<Vec<crate::llm::ToolCall>>,
65
66    /// Tool specifications to advertise to the LLM.
67    pub tool_specs: Vec<ToolSpec>,
68
69    /// Whether to stream LLM responses token-by-token.
70    pub streaming: bool,
71
72    /// Optional permission hook for gating tool calls.
73    pub permission_hook: Option<Arc<dyn PermissionHook>>,
74
75    /// Planning mode (execute immediately vs buffer for confirmation).
76    pub planning_mode: PlanningMode,
77
78    /// Goal-165: shared flag that enables agent-driven read-only plan mode.
79    /// When `true`, write tools are blocked until `exit_plan_mode` is called.
80    pub exploring_plan_mode: Arc<AtomicBool>,
81
82    /// Goal-190: default permission mode for tools not covered by explicit
83    /// config lists. Mirrors `PermissionsConfig.mode` for quick access.
84    pub permission_mode: PermissionMode,
85
86    /// Optional mailbox for mid-run message injection from a coordinator.
87    ///
88    /// When set, the kernel drains this mailbox at the start of every step
89    /// and appends any pending messages as user turns.  This powers the
90    /// `send_message` tool's bidirectional coordinator ↔ worker flow.
91    pub mailbox: Option<crate::tools::send_message::WorkerMailbox>,
92}
93
94// ---------------------------------------------------------------------------
95// TurnOutcome
96// ---------------------------------------------------------------------------
97
98/// The result of executing one turn.
99///
100/// Returned to the Wrapper, which appends new_messages to its transcript,
101/// persists them, handles side effects, and tracks costs.
102#[derive(Debug)]
103pub struct TurnOutcome {
104    /// All messages produced during this turn (assistant responses + tool results).
105    /// Does NOT include the input messages — only what the kernel generated.
106    pub new_messages: Vec<Message>,
107
108    /// The final assistant text (convenience — also the last assistant msg in new_messages).
109    pub final_text: Option<String>,
110
111    /// Why the turn ended.
112    pub finish_reason: FinishReason,
113
114    /// Cumulative token usage across all LLM calls in this turn.
115    pub usage: TokenUsage,
116
117    /// Total LLM call latency in milliseconds (excluding tool execution time).
118    pub llm_latency_ms: u64,
119
120    /// Number of steps (LLM invocations) executed in this turn.
121    pub steps: usize,
122
123    /// Side effects the Wrapper should adopt (background jobs, scheduled tasks).
124    pub side_effects: Vec<SideEffect>,
125
126    /// Buffered tool calls from a proposed plan (when plan is pending).
127    pub plan_buffer: Option<Vec<crate::llm::ToolCall>>,
128
129    /// Goal-153: audit records for tool results, keyed by `tool_call_id`.
130    /// Passed through from `RunInnerOutcome` so the persistence layer
131    /// can emit `MessageAppendedWithAudit` for tool messages.
132    pub tool_audits: std::collections::HashMap<String, crate::tools::AuditMeta>,
133
134    /// Whether the plan was confirmed by the user.
135    pub plan_confirmed: bool,
136}
137
138// ---------------------------------------------------------------------------
139// SideEffect
140// ---------------------------------------------------------------------------
141
142/// A side effect produced during a turn that outlives the turn itself.
143/// The Wrapper is responsible for managing these.
144#[derive(Debug, Clone)]
145pub enum SideEffect {
146    /// A background process was spawned (e.g. via run_background tool).
147    BackgroundJob {
148        id: String,
149        pid: u32,
150        command: String,
151    },
152    /// The agent requested a future wakeup (e.g. via schedule_wakeup tool).
153    ScheduleWakeup { delay: Duration, prompt: String },
154}
155
156// ---------------------------------------------------------------------------
157// AgentKernel
158// ---------------------------------------------------------------------------
159
160/// The stateless Agent Kernel — a single-turn ReAct executor.
161///
162/// Cheap to create, safe to clone, safe to share across threads.
163/// Does not own transcript, session, or any cross-turn state.
164///
165/// NOTE: The `run()` method is NOT implemented in this goal.
166/// This goal only defines the struct and its builder. The actual
167/// execution logic will be wired in Goal C (Phase 2).
168#[derive(Clone)]
169pub struct AgentKernel {
170    /// The LLM provider to use for completions.
171    pub(crate) llm: Arc<dyn LlmProvider>,
172    /// The tool registry (tools available to the agent).
173    pub(crate) tools: ToolRegistry,
174    /// Maximum number of LLM calls per turn.
175    pub(crate) max_steps: usize,
176    /// Maximum transcript characters before trimming (None = no limit).
177    pub(crate) max_transcript_chars: Option<usize>,
178    /// Optional compactor for summarising old messages.
179    pub(crate) compactor: Option<Compactor>,
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}
194
195impl std::fmt::Debug for AgentKernel {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        let tools_count = self.tools.names().len();
198        let hooks_count = self.hooks.len();
199        f.debug_struct("AgentKernel")
200            .field("llm", &"<LlmProvider>")
201            .field("tools_count", &tools_count)
202            .field("max_steps", &self.max_steps)
203            .field("max_transcript_chars", &self.max_transcript_chars)
204            .field("compactor", &self.compactor)
205            .field("hooks_count", &hooks_count)
206            .field("storage", &"<StorageBackend>")
207            .field("session_store", &"<SessionStore>")
208            .finish()
209    }
210}
211
212impl AgentKernel {
213    /// Create a new builder for `AgentKernel`.
214    pub fn builder() -> AgentKernelBuilder {
215        AgentKernelBuilder::default()
216    }
217
218    /// Access the LLM provider.
219    pub fn llm(&self) -> &Arc<dyn LlmProvider> {
220        &self.llm
221    }
222
223    /// Access the tool registry.
224    pub fn tools(&self) -> &ToolRegistry {
225        &self.tools
226    }
227
228    /// Mutable access to the tool registry.
229    ///
230    /// Used by [`AgentRuntime::enable_checkpoints`] to register
231    /// session-scoped read-only tools (`checkpoint_list`,
232    /// `checkpoint_diff`) once the session id is known.
233    pub fn tools_mut(&mut self) -> &mut ToolRegistry {
234        &mut self.tools
235    }
236
237    /// Access the cancellation token, if one was configured.
238    ///
239    /// Useful for tests verifying that token propagation through
240    /// `with_tools` (and other clones) preserves the handle.
241    pub fn shutdown_token(&self) -> Option<&tokio_util::sync::CancellationToken> {
242        self.shutdown_token.as_ref()
243    }
244
245    /// Access the storage backend.
246    pub fn storage(&self) -> &Arc<dyn StorageBackend> {
247        &self.storage
248    }
249
250    /// Access the session store.
251    pub fn session_store(&self) -> &Arc<dyn SessionStore> {
252        &self.session_store
253    }
254
255    /// Public access to the hook registry. Used by `AgentRuntime` to
256    /// dispatch cross-turn `PreCompact` / `PostCompact` events that are
257    /// not handled by `RunCore`.
258    pub fn hooks(&self) -> &HookRegistry {
259        &self.hooks
260    }
261
262    /// Create a new kernel with a different tool registry (same LLM, same config).
263    /// Useful for Multi-Agent scenarios where sub-agents get restricted tool subsets.
264    pub fn with_tools(&self, tools: ToolRegistry) -> Self {
265        Self {
266            llm: self.llm.clone(),
267            tools,
268            max_steps: self.max_steps,
269            max_transcript_chars: self.max_transcript_chars,
270            compactor: self.compactor.clone(),
271            hooks: self.hooks.clone(),
272            shutdown_token: self.shutdown_token.clone(),
273            storage: self.storage.clone(),
274            session_store: self.session_store.clone(),
275        }
276    }
277
278    /// Execute one turn of the ReAct loop.
279    ///
280    /// Takes a [`TurnContext`] prepared by the Wrapper and returns a
281    /// [`TurnOutcome`] containing only the new messages produced during
282    /// this turn, plus usage stats and finish reason.
283    ///
284    /// The Kernel is stateless: it does not retain any state between calls.
285    /// All cross-turn concerns (transcript accumulation, compaction, persistence)
286    /// are the Wrapper's responsibility.
287    pub async fn run(&self, ctx: TurnContext) -> crate::error::Result<TurnOutcome> {
288        use crate::run_core::RunCore;
289
290        let input_len = ctx.messages.len();
291
292        let core = RunCore {
293            messages: ctx.messages,
294            llm: self.llm.clone(),
295            tools: Arc::new(self.tools.clone()),
296            max_steps: self.max_steps,
297            max_transcript_chars: self.max_transcript_chars,
298            events: ctx.step_events_tx,
299            streaming: ctx.streaming,
300            compactor: self.compactor.clone(),
301            permission_hook: ctx.permission_hook,
302            hooks: &self.hooks,
303            planning_mode: ctx.planning_mode,
304            total_llm_latency_ms: 0,
305            plan_buffer: ctx.plan_buffer,
306            plan_confirmed: ctx.plan_confirmed,
307            exploring_plan_mode: ctx.exploring_plan_mode,
308            permission_mode: ctx.permission_mode,
309            shutdown_token: self.shutdown_token.clone(),
310            mailbox: ctx.mailbox,
311        };
312
313        let inner = core.run_inner().await?;
314
315        // Extract only the messages produced during this turn.
316        //
317        // If `RunCore` performed intra-turn compaction, a `[compacted: ...]`
318        // summary message is inserted at position 0.  `inner.messages[input_len..]`
319        // would miss that summary, so detect it and prepend.
320        let mut new_messages = if inner.messages.len() > input_len {
321            inner.messages[input_len..].to_vec()
322        } else {
323            Vec::new()
324        };
325        if !inner.messages.is_empty()
326            && inner.messages[0].role == crate::message::Role::System
327            && inner.messages[0].content.contains("[compacted:")
328        {
329            new_messages.insert(0, inner.messages[0].clone());
330        }
331
332        Ok(TurnOutcome {
333            new_messages,
334            final_text: inner.final_message,
335            finish_reason: inner.finish_reason,
336            usage: inner.total_usage,
337            llm_latency_ms: inner.total_llm_latency_ms,
338            steps: inner.steps,
339            side_effects: Vec::new(),
340            plan_buffer: inner.plan_buffer,
341            plan_confirmed: inner.plan_confirmed,
342            tool_audits: inner.tool_audits,
343        })
344    }
345}
346
347// ---------------------------------------------------------------------------
348// AgentKernelBuilder
349// ---------------------------------------------------------------------------
350
351/// Builder for [`AgentKernel`].
352#[derive(Default)]
353pub struct AgentKernelBuilder {
354    llm: Option<Arc<dyn LlmProvider>>,
355    tools: Option<ToolRegistry>,
356    max_steps: Option<usize>,
357    max_transcript_chars: Option<usize>,
358    compactor: Option<Compactor>,
359    hooks: Option<HookRegistry>,
360    shutdown_token: Option<tokio_util::sync::CancellationToken>,
361    /// Pluggable storage backend. When `None`, `build()` falls back to
362    /// `LocalStorageBackend` rooted at the current directory.
363    storage: Option<Arc<dyn StorageBackend>>,
364    /// Pluggable session hot-state store. When `None`, `build()` uses
365    /// `NoopSessionStore` (no-op, zero overhead).
366    session_store: Option<Arc<dyn SessionStore>>,
367    /// Pluggable tool set provider. When `Some`, `build()` calls
368    /// `provider.build_registry()` unless `tools` was set explicitly.
369    tool_set_provider: Option<Arc<dyn ToolSetProvider>>,
370}
371
372impl std::fmt::Debug for AgentKernelBuilder {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        let tools_desc = self.tools.as_ref().map(|t| t.names().len());
375        let hooks_desc = self.hooks.as_ref().map(|h| h.len());
376        f.debug_struct("AgentKernelBuilder")
377            .field("llm", &self.llm.as_ref().map(|_| "<LlmProvider>"))
378            .field("tools", &tools_desc)
379            .field("max_steps", &self.max_steps)
380            .field("max_transcript_chars", &self.max_transcript_chars)
381            .field("compactor", &self.compactor)
382            .field("hooks", &hooks_desc)
383            .field(
384                "storage",
385                &self.storage.as_ref().map(|_| "<StorageBackend>"),
386            )
387            .field(
388                "session_store",
389                &self.session_store.as_ref().map(|_| "<SessionStore>"),
390            )
391            .field(
392                "tool_set_provider",
393                &self.tool_set_provider.as_ref().map(|_| "<ToolSetProvider>"),
394            )
395            .finish()
396    }
397}
398
399impl AgentKernelBuilder {
400    /// Set the LLM provider.
401    pub fn llm(mut self, llm: Arc<dyn LlmProvider>) -> Self {
402        self.llm = Some(llm);
403        self
404    }
405
406    /// Set the tool registry.
407    pub fn tools(mut self, tools: ToolRegistry) -> Self {
408        self.tools = Some(tools);
409        self
410    }
411
412    /// Set the maximum number of LLM calls per turn.
413    pub fn max_steps(mut self, n: usize) -> Self {
414        self.max_steps = Some(n);
415        self
416    }
417
418    /// Set the maximum transcript characters before trimming.
419    pub fn max_transcript_chars(mut self, n: usize) -> Self {
420        self.max_transcript_chars = Some(n);
421        self
422    }
423
424    /// Set the compactor for summarising old messages.
425    pub fn compactor(mut self, compactor: Compactor) -> Self {
426        self.compactor = Some(compactor);
427        self
428    }
429
430    /// Set the hook registry.
431    pub fn hooks(mut self, hooks: HookRegistry) -> Self {
432        self.hooks = Some(hooks);
433        self
434    }
435
436    /// Set the cancellation token for graceful shutdown. When the token
437    /// is cancelled, the kernel's step loop terminates with
438    /// [`FinishReason::Cancelled`](crate::agent::FinishReason::Cancelled)
439    /// at the next step boundary.
440    pub fn shutdown_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
441        self.shutdown_token = Some(token);
442        self
443    }
444
445    /// Inject a storage backend. If not set, `build()` defaults to
446    /// `LocalStorageBackend` rooted at the current working directory.
447    pub fn with_storage(mut self, backend: Arc<dyn StorageBackend>) -> Self {
448        self.storage = Some(backend);
449        self
450    }
451
452    /// Inject a session hot-state store. If not set, `build()` uses
453    /// `NoopSessionStore` (zero cost, no I/O).
454    pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
455        self.session_store = Some(store);
456        self
457    }
458
459    /// Inject a tool set provider. When set and `tools()` is NOT also called,
460    /// `build()` delegates `tools` construction to `provider.build_registry()`.
461    /// If `tools()` was set explicitly, that registry takes precedence.
462    pub fn with_tool_set_provider(mut self, provider: Arc<dyn ToolSetProvider>) -> Self {
463        self.tool_set_provider = Some(provider);
464        self
465    }
466
467    /// Build the `AgentKernel`, or return an error if required fields are missing.
468    pub fn build(self) -> crate::error::Result<AgentKernel> {
469        let llm = self.llm.ok_or_else(|| crate::error::Error::Config {
470            message: "llm provider is required".into(),
471        })?;
472        // Tools: explicit registry > tool_set_provider > local default.
473        let tools = if let Some(registry) = self.tools {
474            registry
475        } else if let Some(ref provider) = self.tool_set_provider {
476            provider.build_registry()
477        } else {
478            ToolRegistry::local()
479        };
480        let max_steps = self.max_steps.unwrap_or(32);
481        let hooks = self.hooks.unwrap_or_default();
482        // Storage defaults: local filesystem, no-op session store.
483        let storage: Arc<dyn StorageBackend> = self.storage.unwrap_or_else(|| {
484            Arc::new(crate::storage::local::LocalStorageBackend::new(
485                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
486            ))
487        });
488        let session_store: Arc<dyn SessionStore> = self
489            .session_store
490            .unwrap_or_else(|| Arc::new(NoopSessionStore));
491        Ok(AgentKernel {
492            llm,
493            tools,
494            max_steps,
495            max_transcript_chars: self.max_transcript_chars,
496            compactor: self.compactor,
497            hooks,
498            shutdown_token: self.shutdown_token,
499            storage,
500            session_store,
501        })
502    }
503}
504
505// ---------------------------------------------------------------------------
506// Tests
507// ---------------------------------------------------------------------------
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::llm::MockProvider;
513
514    // -- Builder tests ------------------------------------------------------
515
516    #[test]
517    fn kernel_builder_requires_llm() {
518        let result = AgentKernel::builder().build();
519        assert!(result.is_err());
520        match result {
521            Err(e) => assert!(e.to_string().contains("llm provider is required")),
522            Ok(_) => panic!("expected Err"),
523        }
524    }
525
526    #[test]
527    fn kernel_builder_happy_path() {
528        let mock = MockProvider::default();
529        let tools = ToolRegistry::local();
530        let kernel = AgentKernel::builder()
531            .llm(Arc::new(mock))
532            .tools(tools)
533            .max_steps(16)
534            .build()
535            .expect("build should succeed");
536        assert_eq!(kernel.max_steps, 16);
537    }
538
539    #[test]
540    fn kernel_builder_default_max_steps() {
541        let mock = MockProvider::default();
542        let tools = ToolRegistry::local();
543        let kernel = AgentKernel::builder()
544            .llm(Arc::new(mock))
545            .tools(tools)
546            .build()
547            .expect("build should succeed");
548        assert_eq!(kernel.max_steps, 32);
549    }
550
551    // -- Clone / with_tools tests ------------------------------------------
552
553    #[test]
554    fn kernel_clone_is_independent() {
555        let mock = MockProvider::default();
556        let tools1 = ToolRegistry::local();
557        let kernel = AgentKernel::builder()
558            .llm(Arc::new(mock))
559            .tools(tools1)
560            .build()
561            .expect("build should succeed");
562
563        let mut cloned = kernel.clone();
564        // Modify the clone's tools by creating a new registry
565        let new_tools = ToolRegistry::local();
566        cloned.tools = new_tools;
567
568        // The original should still have its original tools
569        // (we can't compare ToolRegistry directly, but we can check
570        // that the clone's tools are different by checking the transport)
571        assert!(!Arc::ptr_eq(
572            kernel.tools().transport(),
573            cloned.tools().transport()
574        ));
575    }
576
577    #[test]
578    fn kernel_with_tools_preserves_llm() {
579        let mock = MockProvider::default();
580        let mock_arc = Arc::new(mock);
581        let tools1 = ToolRegistry::local();
582        let kernel = AgentKernel::builder()
583            .llm(mock_arc.clone())
584            .tools(tools1)
585            .build()
586            .expect("build should succeed");
587
588        let tools2 = ToolRegistry::local();
589        let new_kernel = kernel.with_tools(tools2);
590
591        // LLM provider should be the same Arc
592        assert!(Arc::ptr_eq(&kernel.llm, &new_kernel.llm));
593        // max_steps should be preserved
594        assert_eq!(new_kernel.max_steps, kernel.max_steps);
595    }
596
597    // -- TurnOutcome tests --------------------------------------------------
598
599    #[test]
600    fn turn_outcome_default_values() {
601        let outcome = TurnOutcome {
602            new_messages: vec![],
603            final_text: None,
604            finish_reason: FinishReason::NoMoreToolCalls,
605            usage: TokenUsage::default(),
606            llm_latency_ms: 0,
607            steps: 0,
608            side_effects: vec![],
609            plan_buffer: None,
610            plan_confirmed: false,
611            tool_audits: std::collections::HashMap::new(),
612        };
613        assert!(outcome.new_messages.is_empty());
614        assert!(outcome.final_text.is_none());
615        assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);
616        assert_eq!(outcome.usage, TokenUsage::default());
617        assert_eq!(outcome.llm_latency_ms, 0);
618        assert_eq!(outcome.steps, 0);
619        assert!(outcome.side_effects.is_empty());
620    }
621
622    // -- SideEffect tests ---------------------------------------------------
623
624    #[test]
625    fn side_effect_variants() {
626        let bg = SideEffect::BackgroundJob {
627            id: "job-1".into(),
628            pid: 12345,
629            command: "echo hello".into(),
630        };
631        match &bg {
632            SideEffect::BackgroundJob { id, pid, command } => {
633                assert_eq!(id, "job-1");
634                assert_eq!(*pid, 12345);
635                assert_eq!(command, "echo hello");
636            }
637            _ => panic!("expected BackgroundJob"),
638        }
639
640        let wake = SideEffect::ScheduleWakeup {
641            delay: Duration::from_secs(60),
642            prompt: "check status".into(),
643        };
644        match &wake {
645            SideEffect::ScheduleWakeup { delay, prompt } => {
646                assert_eq!(delay.as_secs(), 60);
647                assert_eq!(prompt, "check status");
648            }
649            _ => panic!("expected ScheduleWakeup"),
650        }
651    }
652}