mod actor;
pub mod hooks;
mod turn;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use hotl_platform::Clock;
use hotl_provider::Provider;
use hotl_store::SessionLog;
use hotl_tools::{rules::Rules, Registry};
use hotl_types::{EntryPayload, Item, TokenUsage};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub model: String,
pub max_tokens: u32,
pub max_turns: u32,
pub thinking: bool,
pub cache_static: bool,
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,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
model: "claude-opus-4-8".into(),
max_tokens: 32_000,
max_turns: 25,
thinking: true,
cache_static: true,
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,
}
}
}
#[derive(Debug)]
pub enum TurnEnd {
Outcome(Outcome),
Compact {
spec: Option<SpecDigest>,
},
}
#[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>,
},
TurnDone {
outcome: Outcome,
usage: TokenUsage,
},
}
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::TurnDone { outcome, .. } => write!(f, "TurnDone({outcome:?})"),
}
}
}
pub enum SessionCmd {
Prompt(String),
PromptTagged {
text: String,
synthetic: hotl_types::SyntheticReason,
},
Continue,
Steer(String),
Rename(String),
Snapshot {
reply: oneshot::Sender<Arc<Vec<Item>>>,
},
Propose {
entries: Vec<EntryPayload>,
reply: oneshot::Sender<bool>,
},
WriteBlob {
tool_use_id: String,
content: String,
reply: oneshot::Sender<Result<String, String>>,
},
TurnFinished { end: TurnEnd, usage: TokenUsage },
}
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 config: EngineConfig,
}
pub struct SessionHandle {
cmd: mpsc::Sender<SessionCmd>,
pub events: mpsc::Receiver<EngineEvent>,
current_turn: Arc<Mutex<CancellationToken>>,
}
impl SessionHandle {
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 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 fn needs_continuation(items: &[Item]) -> bool {
matches!(
items.last(),
Some(Item::User { .. } | Item::ToolResults { .. })
)
}
pub fn spawn_session(deps: SessionDeps) -> SessionHandle {
let (cmd_tx, cmd_rx) = mpsc::channel(64);
let (event_tx, event_rx) = mpsc::channel(256);
let current_turn = Arc::new(Mutex::new(CancellationToken::new()));
tokio::spawn(actor::run(
deps,
cmd_rx,
cmd_tx.downgrade(),
event_tx,
current_turn.clone(),
));
SessionHandle {
cmd: cmd_tx,
events: event_rx,
current_turn,
}
}