use crate::agent::FinishReason;
use crate::compact::Compactor;
use crate::event::AgentEvent;
use crate::hooks::HookRegistry;
use crate::llm::{ChatProvider, TokenUsage, ToolSpec};
use crate::message::Message;
use crate::permissions::PermissionMode;
use crate::storage::{NoopSessionStore, SessionStore, StorageBackend};
use crate::tool_set_provider::ToolSetProvider;
use crate::tools::PermissionHook;
use crate::tools::ToolRegistry;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
pub struct TurnContext {
pub messages: Arc<Vec<Message>>,
pub step_events_tx: Option<tokio::sync::mpsc::UnboundedSender<AgentEvent>>,
pub tool_specs: Vec<ToolSpec>,
pub streaming: bool,
pub permission_hook: Option<Arc<dyn PermissionHook>>,
pub exploring_plan_mode: Arc<AtomicBool>,
pub permission_mode: PermissionMode,
pub mailbox: Option<crate::tools::send_message::WorkerMailbox>,
pub turn: u32,
pub prompt_segments: Option<crate::system_prompt::PromptSegments>,
pub wall_timeout_secs: u64,
}
#[derive(Debug)]
pub struct TurnOutcome {
pub new_messages: Vec<Message>,
pub final_text: Option<String>,
pub finish_reason: FinishReason,
pub usage: TokenUsage,
pub last_prompt_tokens: u32,
pub llm_latency_ms: u64,
pub steps: usize,
pub tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
pub turn: u32,
}
#[derive(Clone)]
pub struct AgentKernel {
pub(crate) llm: Arc<dyn ChatProvider>,
pub(crate) tools: ToolRegistry,
pub(crate) max_steps: usize,
pub(crate) max_transcript_chars: Option<usize>,
pub(crate) compactor: Option<Compactor>,
pub(crate) microcompactor: Option<crate::compact::Microcompactor>,
pub(crate) hooks: HookRegistry,
pub(crate) shutdown_token: Option<tokio_util::sync::CancellationToken>,
pub(crate) storage: Arc<dyn StorageBackend>,
pub(crate) session_store: Arc<dyn SessionStore>,
pub(crate) stuck_window: usize,
pub(crate) stuck_error_rate: f64,
pub(crate) globs_skills: Vec<crate::skills::Skill>,
}
impl std::fmt::Debug for AgentKernel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tools_count = self.tools.names().len();
let hooks_count = self.hooks.len();
f.debug_struct("AgentKernel")
.field("llm", &"<ChatProvider>")
.field("tools_count", &tools_count)
.field("max_steps", &self.max_steps)
.field("max_transcript_chars", &self.max_transcript_chars)
.field("compactor", &self.compactor)
.field("hooks_count", &hooks_count)
.field("storage", &"<StorageBackend>")
.field("session_store", &"<SessionStore>")
.finish()
}
}
impl AgentKernel {
#[cfg_attr(test, mutants::skip)]
pub fn builder() -> AgentKernelBuilder {
AgentKernelBuilder::default()
}
pub fn llm(&self) -> &Arc<dyn ChatProvider> {
&self.llm
}
pub fn set_llm(&mut self, llm: Arc<dyn ChatProvider>) {
self.llm = llm;
}
pub fn tools(&self) -> &ToolRegistry {
&self.tools
}
pub fn tools_mut(&mut self) -> &mut ToolRegistry {
&mut self.tools
}
pub fn shutdown_token(&self) -> Option<&tokio_util::sync::CancellationToken> {
self.shutdown_token.as_ref()
}
pub fn storage(&self) -> &Arc<dyn StorageBackend> {
&self.storage
}
pub fn session_store(&self) -> &Arc<dyn SessionStore> {
&self.session_store
}
pub fn hooks(&self) -> &HookRegistry {
&self.hooks
}
pub fn with_tools(&self, tools: ToolRegistry) -> Self {
let mut clone = self.clone();
clone.tools = tools;
clone
}
pub async fn run(&self, ctx: TurnContext) -> crate::error::Result<TurnOutcome> {
let input_len = ctx.messages.len();
let core = {
use crate::run_core::{RunCore, StaticBreakdownCache};
let static_breakdown = match ctx.prompt_segments.as_ref() {
Some(segments) => {
StaticBreakdownCache::build(segments, &self.tools.specs(), &self.tools)
}
None => StaticBreakdownCache::default(),
};
RunCore {
messages: ctx.messages,
llm: self.llm.clone(),
tools: Arc::new(self.tools.clone()),
max_steps: self.max_steps,
max_transcript_chars: self.max_transcript_chars,
events: ctx.step_events_tx,
streaming: ctx.streaming,
compactor: self.compactor.clone(),
microcompactor: self.microcompactor.clone(),
permission_hook: ctx.permission_hook,
hooks: &self.hooks,
total_llm_latency_ms: 0,
exploring_plan_mode: ctx.exploring_plan_mode,
shutdown_token: self.shutdown_token.clone(),
mailbox: ctx.mailbox,
stuck_window: self.stuck_window,
stuck_error_rate: self.stuck_error_rate,
turn: ctx.turn,
globs_skills: self.globs_skills.clone(),
prompt_segments: ctx.prompt_segments,
static_breakdown,
last_prompt_tokens: 0,
consecutive_compact_failures: 0,
wall_timeout_secs: ctx.wall_timeout_secs,
wall_start: if ctx.wall_timeout_secs > 0 {
Some(std::time::Instant::now())
} else {
None
},
}
};
let inner = core.run_inner().await?;
let mut new_messages = turn_delta_messages(&inner.messages, input_len);
if !inner.messages.is_empty() && inner.messages[0].is_compaction_summary {
new_messages.insert(0, inner.messages[0].clone());
}
Ok(TurnOutcome {
new_messages,
final_text: inner.final_message,
finish_reason: inner.finish_reason,
usage: inner.total_usage,
last_prompt_tokens: inner.last_prompt_tokens,
llm_latency_ms: inner.total_llm_latency_ms,
steps: inner.steps,
tool_audits: inner.tool_audits,
turn: ctx.turn,
})
}
}
#[cfg_attr(test, mutants::skip)]
fn turn_delta_messages(
inner: &[crate::message::Message],
input_len: usize,
) -> Vec<crate::message::Message> {
if inner.len() > input_len {
inner[input_len..].to_vec()
} else {
Vec::new()
}
}
#[derive(Default)]
pub struct AgentKernelBuilder {
llm: Option<Arc<dyn ChatProvider>>,
tools: Option<ToolRegistry>,
max_steps: Option<usize>,
max_transcript_chars: Option<usize>,
compactor: Option<Compactor>,
microcompactor: Option<crate::compact::Microcompactor>,
hooks: Option<HookRegistry>,
shutdown_token: Option<tokio_util::sync::CancellationToken>,
storage: Option<Arc<dyn StorageBackend>>,
session_store: Option<Arc<dyn SessionStore>>,
tool_set_provider: Option<Arc<dyn ToolSetProvider>>,
stuck_window: Option<usize>,
stuck_error_rate: Option<f64>,
globs_skills: Vec<crate::skills::Skill>,
}
impl std::fmt::Debug for AgentKernelBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tools_desc = self.tools.as_ref().map(|t| t.names().len());
let hooks_desc = self.hooks.as_ref().map(|h| h.len());
f.debug_struct("AgentKernelBuilder")
.field("llm", &self.llm.as_ref().map(|_| "<ChatProvider>"))
.field("tools", &tools_desc)
.field("max_steps", &self.max_steps)
.field("max_transcript_chars", &self.max_transcript_chars)
.field("compactor", &self.compactor)
.field("hooks", &hooks_desc)
.field(
"storage",
&self.storage.as_ref().map(|_| "<StorageBackend>"),
)
.field(
"session_store",
&self.session_store.as_ref().map(|_| "<SessionStore>"),
)
.field(
"tool_set_provider",
&self.tool_set_provider.as_ref().map(|_| "<ToolSetProvider>"),
)
.finish()
}
}
impl AgentKernelBuilder {
pub fn llm(mut self, llm: Arc<dyn ChatProvider>) -> Self {
self.llm = Some(llm);
self
}
pub fn tools(mut self, tools: ToolRegistry) -> Self {
self.tools = Some(tools);
self
}
pub fn max_steps(mut self, n: usize) -> Self {
self.max_steps = Some(n);
self
}
pub fn max_transcript_chars(mut self, n: usize) -> Self {
self.max_transcript_chars = Some(n);
self
}
pub fn compactor(mut self, compactor: Compactor) -> Self {
self.compactor = Some(compactor);
self
}
pub fn microcompactor(mut self, microcompactor: crate::compact::Microcompactor) -> Self {
self.microcompactor = Some(microcompactor);
self
}
pub fn hooks(mut self, hooks: HookRegistry) -> Self {
self.hooks = Some(hooks);
self
}
pub fn shutdown_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
self.shutdown_token = Some(token);
self
}
pub fn with_storage(mut self, backend: Arc<dyn StorageBackend>) -> Self {
self.storage = Some(backend);
self
}
pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
self.session_store = Some(store);
self
}
pub fn with_tool_set_provider(mut self, provider: Arc<dyn ToolSetProvider>) -> Self {
self.tool_set_provider = Some(provider);
self
}
pub fn stuck_window(mut self, n: usize) -> Self {
self.stuck_window = Some(n);
self
}
pub fn stuck_error_rate(mut self, rate: f64) -> Self {
self.stuck_error_rate = Some(rate);
self
}
pub fn build(self) -> crate::error::Result<AgentKernel> {
let llm = self.llm.ok_or_else(|| crate::error::Error::Config {
message: "llm provider is required".into(),
})?;
let tools = if let Some(registry) = self.tools {
registry
} else if let Some(ref provider) = self.tool_set_provider {
provider.build_registry()
} else {
ToolRegistry::local()
};
let max_steps = self.max_steps.unwrap_or(0);
let hooks = self.hooks.unwrap_or_default();
let storage: Arc<dyn StorageBackend> = self.storage.unwrap_or_else(|| {
Arc::new(crate::storage::local::LocalStorageBackend::new(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
))
});
let session_store: Arc<dyn SessionStore> = self
.session_store
.unwrap_or_else(|| Arc::new(NoopSessionStore));
Ok(AgentKernel {
llm,
tools,
max_steps,
max_transcript_chars: self.max_transcript_chars,
compactor: self.compactor,
microcompactor: self.microcompactor,
hooks,
shutdown_token: self.shutdown_token,
storage,
session_store,
stuck_window: self.stuck_window.unwrap_or(10),
stuck_error_rate: self.stuck_error_rate.unwrap_or(0.8),
globs_skills: self.globs_skills,
})
}
pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
self.globs_skills = skills;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::MockProvider;
#[test]
fn kernel_builder_requires_llm() {
let result = AgentKernel::builder().build();
assert!(result.is_err());
match result {
Err(e) => assert!(e.to_string().contains("llm provider is required")),
Ok(_) => panic!("expected Err"),
}
}
#[test]
fn kernel_builder_happy_path() {
let mock = MockProvider::default();
let tools = ToolRegistry::local();
let kernel = AgentKernel::builder()
.llm(Arc::new(mock))
.tools(tools)
.max_steps(16)
.build()
.expect("build should succeed");
assert_eq!(kernel.max_steps, 16);
}
#[test]
fn kernel_builder_default_max_steps() {
let mock = MockProvider::default();
let tools = ToolRegistry::local();
let kernel = AgentKernel::builder()
.llm(Arc::new(mock))
.tools(tools)
.build()
.expect("build should succeed");
assert_eq!(kernel.max_steps, 0);
}
#[test]
fn kernel_clone_is_independent() {
let mock = MockProvider::default();
let tools1 = ToolRegistry::local();
let kernel = AgentKernel::builder()
.llm(Arc::new(mock))
.tools(tools1)
.build()
.expect("build should succeed");
let mut cloned = kernel.clone();
let new_tools = ToolRegistry::local();
cloned.tools = new_tools;
assert!(!Arc::ptr_eq(
kernel.tools().transport(),
cloned.tools().transport()
));
}
#[test]
fn kernel_with_tools_preserves_llm() {
let mock = MockProvider::default();
let mock_arc = Arc::new(mock);
let tools1 = ToolRegistry::local();
let kernel = AgentKernel::builder()
.llm(mock_arc.clone())
.tools(tools1)
.build()
.expect("build should succeed");
let tools2 = ToolRegistry::local();
let new_kernel = kernel.with_tools(tools2);
assert!(Arc::ptr_eq(&kernel.llm, &new_kernel.llm));
assert_eq!(new_kernel.max_steps, kernel.max_steps);
}
fn make_minimal_ctx(messages: Vec<Message>) -> TurnContext {
use std::sync::atomic::AtomicBool;
TurnContext {
messages: Arc::new(messages),
step_events_tx: None,
tool_specs: vec![],
streaming: false,
permission_hook: None,
exploring_plan_mode: Arc::new(AtomicBool::new(false)),
permission_mode: crate::permissions::PermissionMode::Default,
mailbox: None,
turn: 0,
prompt_segments: None,
wall_timeout_secs: 0,
}
}
#[tokio::test]
async fn kernel_run_new_messages_contains_reply() {
use crate::llm::Completion;
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "done".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
}]));
let kernel = AgentKernel::builder()
.llm(provider)
.max_steps(1)
.build()
.expect("build");
let ctx = make_minimal_ctx(vec![Message::user("hello".to_string())]);
let outcome = kernel.run(ctx).await.expect("run");
assert_eq!(
outcome.new_messages.len(),
1,
"new_messages must contain exactly the assistant reply; got {:?}",
outcome.new_messages
);
assert_eq!(outcome.new_messages[0].content, "done");
}
#[tokio::test]
async fn kernel_run_does_not_prepend_input_to_new_messages() {
use crate::llm::Completion;
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "answer".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
}]));
let kernel = AgentKernel::builder()
.llm(provider)
.max_steps(1)
.build()
.expect("build");
let input = Message::user("question".to_string());
let ctx = make_minimal_ctx(vec![input.clone()]);
let outcome = kernel.run(ctx).await.expect("run");
assert_eq!(outcome.new_messages.len(), 1, "only reply expected");
assert_eq!(
outcome.new_messages[0].content, "answer",
"first new message must be the reply, not the input"
);
assert!(
outcome.new_messages[0].content != "question",
"input must not appear in new_messages"
);
}
#[tokio::test]
async fn kernel_run_does_not_prepend_non_summary_first_message() {
use crate::llm::Completion;
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "reply".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
}]));
let kernel = AgentKernel::builder()
.llm(provider)
.max_steps(1)
.build()
.expect("build");
let system = Message::system("sys".to_string());
assert!(
!system.is_compaction_summary,
"fixture must not be a compaction summary"
);
let ctx = make_minimal_ctx(vec![system, Message::user("q".to_string())]);
let outcome = kernel.run(ctx).await.expect("run");
assert_eq!(outcome.new_messages.len(), 1);
assert_eq!(outcome.new_messages[0].content, "reply");
assert!(
outcome.new_messages.iter().all(|m| m.content != "sys"),
"non-summary first message must not be prepended; got {:?}",
outcome.new_messages
);
}
#[tokio::test]
async fn kernel_run_prepends_compaction_summary_to_new_messages() {
use crate::compact::Compactor;
use crate::llm::Completion;
let messages = vec![
Message::system("sys".to_string()),
Message::user("u1".to_string()),
Message::assistant("a1".to_string()),
Message::user("u2".to_string()),
Message::assistant("a2".to_string()),
Message::user("u3".to_string()),
];
let provider = Arc::new(MockProvider::new(vec![
Completion {
content: "compact summary".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
Completion {
content: "final".to_string(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
]));
let kernel = AgentKernel::builder()
.llm(provider)
.compactor(Compactor::new(0).keep_recent_n(2))
.max_steps(1)
.build()
.expect("build");
let ctx = make_minimal_ctx(messages);
let outcome = kernel.run(ctx).await.expect("run");
assert!(
!outcome.new_messages.is_empty(),
"expected new messages after compaction + reply"
);
assert!(
outcome.new_messages[0].is_compaction_summary,
"compaction summary must be prepended to new_messages; got {:?}",
outcome.new_messages
);
}
#[test]
fn kernel_builder_max_transcript_chars_stored_and_chains() {
let mock = Arc::new(MockProvider::default());
let kernel = AgentKernel::builder()
.llm(mock)
.max_transcript_chars(12_345)
.max_steps(3)
.build()
.expect("build");
assert_eq!(
kernel.max_transcript_chars,
Some(12_345),
"max_transcript_chars must survive builder chaining"
);
assert_eq!(
kernel.max_steps, 3,
"builder chain must not reset max_steps"
);
}
#[test]
fn agent_kernel_builder_debug_contains_field_names() {
let builder = AgentKernel::builder().max_steps(42);
let dbg = format!("{:?}", builder);
assert!(
dbg.contains("AgentKernelBuilder"),
"Debug output must contain struct name; got: {dbg}"
);
assert!(
dbg.contains("max_steps"),
"Debug output must contain max_steps field; got: {dbg}"
);
}
#[test]
fn kernel_builder_stuck_window_and_error_rate() {
let mock = MockProvider::default();
let kernel_defaults = AgentKernel::builder().llm(Arc::new(mock)).build().unwrap();
assert_eq!(
kernel_defaults.stuck_window, 10,
"default stuck_window must be 10"
);
assert!(
(kernel_defaults.stuck_error_rate - 0.8).abs() < 1e-10,
"default stuck_error_rate must be 0.8"
);
let mock2 = MockProvider::default();
let kernel_custom = AgentKernel::builder()
.llm(Arc::new(mock2))
.stuck_window(5)
.stuck_error_rate(0.5)
.build()
.unwrap();
assert_eq!(kernel_custom.stuck_window, 5);
assert!((kernel_custom.stuck_error_rate - 0.5).abs() < 1e-10);
}
#[test]
fn kernel_accessor_methods() {
let mock = Arc::new(MockProvider::default());
let kernel = AgentKernel::builder().llm(mock.clone()).build().unwrap();
assert!(
Arc::ptr_eq(&kernel.llm, kernel.llm()),
"llm() must return &self.llm"
);
let _ = kernel.tools();
let _ = kernel.hooks();
let _ = kernel.storage();
let _ = kernel.session_store();
assert!(
kernel.shutdown_token().is_none(),
"no token must be set by default"
);
}
#[test]
fn kernel_max_steps_zero_by_default() {
let mock = Arc::new(MockProvider::default());
let kernel = AgentKernel::builder().llm(mock).build().unwrap();
assert_eq!(
kernel.max_steps, 0,
"default max_steps must be 0 (unlimited)"
);
}
#[test]
fn kernel_max_steps_custom_value() {
let mock = Arc::new(MockProvider::default());
let kernel = AgentKernel::builder()
.llm(mock)
.max_steps(25)
.build()
.unwrap();
assert_eq!(kernel.max_steps, 25, "custom max_steps must be stored");
}
#[test]
fn with_tools_replaces_registry() {
use crate::tools::transport::LocalTransport;
let mock = Arc::new(MockProvider::default());
let kernel = AgentKernel::builder().llm(mock.clone()).build().unwrap();
let empty_reg = ToolRegistry::new(Arc::new(LocalTransport));
let replaced = kernel.with_tools(empty_reg);
assert_eq!(
replaced.tools().names().len(),
0,
"with_tools must swap in the empty registry"
);
}
#[test]
fn turn_outcome_default_values() {
let outcome = TurnOutcome {
new_messages: vec![],
final_text: None,
finish_reason: FinishReason::NoMoreToolCalls,
usage: TokenUsage::default(),
last_prompt_tokens: 0,
llm_latency_ms: 0,
steps: 0,
tool_audits: std::collections::HashMap::new(),
turn: 0,
};
assert!(outcome.new_messages.is_empty());
assert!(outcome.final_text.is_none());
assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);
assert_eq!(outcome.usage, TokenUsage::default());
assert_eq!(outcome.llm_latency_ms, 0);
assert_eq!(outcome.steps, 0);
}
}