mod actor;
pub mod hooks;
mod ledger;
mod turn;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use hotl_platform::Clock;
use hotl_provider::{CacheTtl, Provider};
use hotl_store::SessionLog;
use hotl_tools::{
rules::{PermissionMode, Rules},
Registry,
};
use hotl_types::{EntryPayload, Item, Todo, TokenUsage};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
pub use hotl_types::QuestionAnswer;
pub use hooks::NotificationKind;
pub use ledger::{LedgerSummary, Phase, PhaseDeltaSummary};
pub use actor::ProjectionHead;
pub use actor::Snapshot;
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub model: String,
pub max_tokens: u32,
pub max_turns: i64,
pub thinking: bool,
pub cache_static: bool,
pub cache_ttl: CacheTtl,
pub fallback_models: Vec<String>,
pub tool_failure_budget: u32,
pub context_window: u64,
pub fast_model: Option<String>,
pub compaction_reset: bool,
pub show_context_pct: bool,
pub evict_threshold_tokens: u64,
#[doc(hidden)]
pub ack_mode: AckMode,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
model: "claude-opus-4-8".into(),
max_tokens: 32_000,
max_turns: 100,
thinking: true,
cache_static: true,
cache_ttl: CacheTtl::FiveMinutes,
fallback_models: Vec::new(),
tool_failure_budget: 5,
context_window: 200_000,
fast_model: None,
compaction_reset: false,
show_context_pct: true,
evict_threshold_tokens: 20_000,
ack_mode: AckMode::Pipelined,
}
}
}
#[derive(Debug)]
pub enum TurnEnd {
Outcome(Outcome),
Compact {
spec: Option<SpecDigest>,
cont: Box<TurnContinuation>,
},
}
#[derive(Debug, Default)]
pub struct TurnContinuation {
pub(crate) spent: i64,
pub(crate) model_idx: usize,
pub(crate) call_sigs: std::collections::VecDeque<crate::turn::CallSig>,
pub(crate) consecutive_failures: std::collections::HashMap<String, u32>,
pub(crate) turn_extensions: u32,
pub(crate) samples_since_compact: u32,
}
#[derive(Debug)]
pub struct SpecDigest {
pub prefix_end: usize,
pub kept_from: usize,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AskReply {
Allow,
Deny {
message: Option<String>,
},
AllowEdited {
input: serde_json::Value,
},
Respond {
content: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
Done { text: String },
Cancelled,
TurnLimit,
Refused,
DoomLoop { pattern: String },
ToolFailureBudget { tool: String },
Error { message: String },
}
pub enum EngineEvent {
TextDelta(String),
ThinkingDelta(String),
ToolStart {
name: String,
summary: String,
},
ToolDone {
name: String,
ok: bool,
},
ToolDenied {
name: String,
},
ToolAutoAllowed {
name: String,
rule: String,
},
Retrying {
attempt: u32,
reason: String,
},
FallbackModel {
model: String,
},
PromptQueued,
Compacted {
degraded: bool,
},
Ask {
summary: String,
protected_why: Option<String>,
reply: oneshot::Sender<AskReply>,
},
Question {
id: String,
question: hotl_types::Question,
reply: oneshot::Sender<hotl_types::QuestionAnswer>,
},
TurnDone {
outcome: Outcome,
usage: TokenUsage,
},
TodosChanged {
items: Vec<Todo>,
},
LedgerReport(LedgerSummary),
}
impl std::fmt::Debug for EngineEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TextDelta(t) => write!(f, "TextDelta({t:?})"),
Self::ThinkingDelta(_) => write!(f, "ThinkingDelta"),
Self::ToolStart { name, .. } => write!(f, "ToolStart({name})"),
Self::ToolDone { name, ok } => write!(f, "ToolDone({name},{ok})"),
Self::ToolDenied { name } => write!(f, "ToolDenied({name})"),
Self::ToolAutoAllowed { name, rule } => write!(f, "ToolAutoAllowed({name},{rule})"),
Self::Retrying { attempt, .. } => write!(f, "Retrying({attempt})"),
Self::FallbackModel { model } => write!(f, "FallbackModel({model})"),
Self::PromptQueued => write!(f, "PromptQueued"),
Self::Compacted { degraded } => write!(f, "Compacted({degraded})"),
Self::Ask { summary, .. } => write!(f, "Ask({summary})"),
Self::Question { question, .. } => write!(f, "Question({})", question.header),
Self::TurnDone { outcome, .. } => write!(f, "TurnDone({outcome:?})"),
Self::TodosChanged { items } => write!(f, "TodosChanged(n={})", items.len()),
Self::LedgerReport(s) => write!(f, "LedgerReport(samples={})", s.sample_count),
}
}
}
pub struct PreparedEntry {
payload: hotl_store::PreparedPayload,
item: Option<Item>,
}
impl PreparedEntry {
pub fn new(payload: hotl_store::PreparedPayload, item: Option<Item>) -> Self {
debug_assert_eq!(
item.is_some(),
matches!(payload.kind(), hotl_store::EntryKind::Item),
"PreparedEntry::new: item's presence must match payload.kind() == EntryKind::Item"
);
Self { payload, item }
}
pub fn payload(&self) -> &hotl_store::PreparedPayload {
&self.payload
}
pub fn item(&self) -> Option<&Item> {
self.item.as_ref()
}
pub fn into_parts(self) -> (hotl_store::PreparedPayload, Option<Item>) {
(self.payload, self.item)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleStage {
AtBoundary,
InSample,
}
pub enum EntryProposal {
Single(PreparedEntry),
Group(Vec<PreparedEntry>),
}
impl EntryProposal {
pub fn of(mut entries: Vec<PreparedEntry>) -> Self {
if entries.len() == 1 {
Self::Single(entries.pop().expect("just checked len == 1"))
} else {
Self::Group(entries)
}
}
pub(crate) fn entries(&self) -> &[PreparedEntry] {
match self {
Self::Single(entry) => std::slice::from_ref(entry),
Self::Group(entries) => entries,
}
}
pub(crate) fn is_empty(&self) -> bool {
self.entries().is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AckMode {
Sync,
#[default]
Pipelined,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommitAck {
pub offset: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitFailed {
LogSealed,
Aborted,
}
#[derive(Debug)]
pub struct CommitTicket {
pub id: String,
pub seq: u64,
pub ack: oneshot::Receiver<Result<CommitAck, CommitFailed>>,
}
#[derive(Debug)]
pub enum ProposeReply {
Committed,
Sealed,
StaleEpoch,
Ticket(CommitTicket),
}
#[allow(clippy::manual_non_exhaustive)]
pub enum SessionCmd {
Prompt(String),
PromptTagged {
text: String,
synthetic: hotl_types::SyntheticReason,
},
Continue,
Steer(String),
Rename(String),
SetMode(PermissionMode),
SetTodos(Vec<Todo>),
Propose {
entries: Vec<EntryPayload>,
reply: oneshot::Sender<bool>,
},
ProposePrepared {
proposal: EntryProposal,
stage: SampleStage,
mode: AckMode,
reply: oneshot::Sender<ProposeReply>,
},
WriteBlob {
tool_use_id: String,
content: String,
reply: oneshot::Sender<Result<String, String>>,
},
TurnFinished { end: TurnEnd, usage: TokenUsage },
#[doc(hidden)]
BumpRulesEpoch,
}
pub trait Snapshotter: Send + Sync {
fn snapshot(&self, label: String) -> futures_util::future::BoxFuture<'static, ()>;
}
pub struct SessionDeps {
pub provider: Arc<dyn Provider>,
pub registry: Arc<Registry>,
pub rules: Arc<Rules>,
pub sandbox_enforced: bool,
pub clock: Arc<dyn Clock>,
pub log: SessionLog,
pub system: String,
pub cwd: PathBuf,
pub snapshots: Option<Arc<dyn Snapshotter>>,
pub hooks: Option<Arc<dyn hooks::Hooks>>,
pub initial_items: Vec<Item>,
pub initial_todos: Vec<Todo>,
pub config: EngineConfig,
}
pub struct SessionHandle {
cmd: mpsc::Sender<SessionCmd>,
pub events: mpsc::Receiver<EngineEvent>,
current_turn: Arc<Mutex<CancellationToken>>,
notifications: hooks::NotificationDrain,
head: tokio::sync::watch::Receiver<Arc<ProjectionHead>>,
actor: tokio::task::JoinHandle<()>,
}
impl SessionHandle {
pub fn head(&self) -> tokio::sync::watch::Receiver<Arc<ProjectionHead>> {
self.head.clone()
}
pub async fn prompt(&self, text: String) {
let _ = self.cmd.send(SessionCmd::Prompt(text)).await;
}
pub async fn prompt_tagged(&self, text: String, synthetic: hotl_types::SyntheticReason) {
let _ = self
.cmd
.send(SessionCmd::PromptTagged { text, synthetic })
.await;
}
pub async fn steer(&self, text: String) {
let _ = self.cmd.send(SessionCmd::Steer(text)).await;
}
pub async fn rename(&self, name: String) {
let _ = self.cmd.send(SessionCmd::Rename(name)).await;
}
pub async fn set_mode(&self, mode: PermissionMode) {
let _ = self.cmd.send(SessionCmd::SetMode(mode)).await;
}
pub async fn set_todos(&self, items: Vec<Todo>) {
let _ = self.cmd.send(SessionCmd::SetTodos(items)).await;
}
pub async fn continue_turn(&self) {
let _ = self.cmd.send(SessionCmd::Continue).await;
}
pub fn interrupt(&self) {
self.current_turn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.cancel();
}
pub async fn drain_notifications(&self, grace: Duration) {
self.notifications.drain(grace).await;
}
pub async fn finish(self, grace: Duration) {
self.notifications.drain(grace).await;
let SessionHandle { cmd, actor, .. } = self;
drop(cmd);
let _ = tokio::time::timeout(grace, actor).await;
}
}
pub fn needs_continuation(items: &[Item]) -> bool {
matches!(
items.last(),
Some(Item::User { .. } | Item::ToolResults { .. })
)
}
pub fn session_channel() -> (mpsc::Sender<SessionCmd>, mpsc::Receiver<SessionCmd>) {
mpsc::channel(64)
}
pub fn event_channel() -> (mpsc::Sender<EngineEvent>, mpsc::Receiver<EngineEvent>) {
mpsc::channel(256)
}
pub fn spawn_session(deps: SessionDeps) -> SessionHandle {
let (cmd_tx, cmd_rx) = session_channel();
spawn_session_with(deps, cmd_tx, cmd_rx)
}
pub fn spawn_session_with(
deps: SessionDeps,
cmd_tx: mpsc::Sender<SessionCmd>,
cmd_rx: mpsc::Receiver<SessionCmd>,
) -> SessionHandle {
let (event_tx, event_rx) = event_channel();
spawn_session_with_channels(
deps,
cmd_tx,
cmd_rx,
event_tx,
event_rx,
hooks::NotificationDrain::new(),
)
}
pub fn spawn_session_with_channels(
deps: SessionDeps,
cmd_tx: mpsc::Sender<SessionCmd>,
cmd_rx: mpsc::Receiver<SessionCmd>,
event_tx: mpsc::Sender<EngineEvent>,
event_rx: mpsc::Receiver<EngineEvent>,
notifications: hooks::NotificationDrain,
) -> SessionHandle {
let current_turn = Arc::new(Mutex::new(CancellationToken::new()));
let (head_tx, head_rx) = actor::head_channel();
let actor = tokio::spawn(actor::run(
deps,
cmd_rx,
cmd_tx.downgrade(),
event_tx,
current_turn.clone(),
notifications.clone(),
head_tx,
));
SessionHandle {
cmd: cmd_tx,
events: event_rx,
current_turn,
notifications,
head: head_rx,
actor,
}
}
pub fn question_sink(
cmd_tx: mpsc::WeakSender<SessionCmd>,
events_tx: mpsc::WeakSender<EngineEvent>,
hooks_handle: Option<Arc<dyn hooks::Hooks>>,
notifications: hooks::NotificationDrain,
) -> hotl_tools::ask::QuestionSink {
let hook_mask: Arc<std::sync::atomic::AtomicU8> = hooks_handle
.as_ref()
.and_then(|h| h.mask_handle())
.unwrap_or_else(|| {
Arc::new(std::sync::atomic::AtomicU8::new(
hooks_handle
.as_ref()
.map_or(hooks::EventMask::NONE, |h| h.event_mask())
.bits(),
))
});
std::sync::Arc::new(move |question, cancel| {
let hook_mask = Arc::clone(&hook_mask);
let cmd_tx = cmd_tx.clone();
let events_tx = events_tx.clone();
let hooks_handle = hooks_handle.clone();
let notifications = notifications.clone();
Box::pin(async move {
let id = hotl_types::new_ulid();
propose_via(
&cmd_tx,
vec![EntryPayload::PendingQuestion {
id: id.clone(),
question: question.clone(),
}],
)
.await;
crate::hooks::hook_gate!(
hooks_handle,
crate::hooks::mask_of(&hook_mask),
crate::hooks::EventMask::NOTIFICATION,
|h| {
crate::hooks::notify(
h,
¬ifications,
crate::hooks::NotificationKind::Blocked,
question.header.clone(),
);
},
else {}
);
let answer = match events_tx.upgrade() {
None => hotl_types::QuestionAnswer::NoHuman,
Some(events) => {
let (reply_tx, reply_rx) = oneshot::channel();
if events
.send(EngineEvent::Question {
id: id.clone(),
question,
reply: reply_tx,
})
.await
.is_err()
{
hotl_types::QuestionAnswer::NoHuman
} else {
tokio::select! {
biased;
_ = cancel.cancelled() => hotl_types::QuestionAnswer::NoHuman,
reply = reply_rx => reply.unwrap_or(hotl_types::QuestionAnswer::NoHuman),
}
}
}
};
propose_via(
&cmd_tx,
vec![EntryPayload::QuestionResolved {
id,
answer: hotl_tools::ask::format_answer(&answer),
}],
)
.await;
answer
})
})
}
async fn propose_via(cmd_tx: &mpsc::WeakSender<SessionCmd>, entries: Vec<EntryPayload>) {
let Some(tx) = cmd_tx.upgrade() else { return };
let (reply_tx, reply_rx) = oneshot::channel();
if tx
.send(SessionCmd::Propose {
entries,
reply: reply_tx,
})
.await
.is_ok()
{
let _ = reply_rx.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "item's presence must match payload.kind()")]
fn prepared_entry_new_rejects_a_mismatched_item_and_kind() {
let masker = hotl_store::Masker::empty();
let payload = hotl_store::prepare_payload(
&EntryPayload::Usage {
usage: TokenUsage::default(),
},
&masker,
0,
)
.unwrap();
let _ = PreparedEntry::new(
payload,
Some(Item::User {
text: "x".into(),
synthetic: None,
}),
);
}
#[test]
fn prepared_entry_new_accepts_a_matching_item_and_kind() {
let masker = hotl_store::Masker::empty();
let item = Item::User {
text: "x".into(),
synthetic: None,
};
let payload =
hotl_store::prepare_payload(&EntryPayload::Item { item: item.clone() }, &masker, 0)
.unwrap();
let entry = PreparedEntry::new(payload, Some(item));
assert!(entry.item().is_some());
}
}