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}