Skip to main content

hotl_engine/
lib.rs

1//! L3 — the turn engine, M1: actor + turn tasks (commit-protocol.md).
2//!
3//! One **session actor** per session is the sole committer to the log and the
4//! owner of the projection ([`actor`]); **turn tasks** read actor-granted
5//! snapshots at sample boundaries and *propose* entries ([`turn`]). Steers
6//! admitted mid-turn are woven into the next sample (the conflict table's
7//! rebase row); interrupts travel out-of-band via a shared token; permission
8//! asks are events carrying a oneshot reply.
9
10mod actor;
11pub mod hooks;
12mod turn;
13
14use std::path::PathBuf;
15use std::sync::{Arc, Mutex};
16
17use hotl_platform::Clock;
18use hotl_provider::Provider;
19use hotl_store::SessionLog;
20use hotl_tools::{rules::Rules, Registry};
21use hotl_types::{EntryPayload, Item, TokenUsage};
22use tokio::sync::{mpsc, oneshot};
23use tokio_util::sync::CancellationToken;
24
25#[derive(Debug, Clone)]
26pub struct EngineConfig {
27    pub model: String,
28    pub max_tokens: u32,
29    pub max_turns: u32,
30    pub thinking: bool,
31    pub cache_static: bool,
32    /// Availability-only fallback models (≤3 total — RELIABILITY.md).
33    pub fallback_models: Vec<String>,
34    /// Consecutive failures of one tool before the turn stops.
35    pub tool_failure_budget: u32,
36    /// Model context window in tokens; compaction triggers at 80% (M2).
37    pub context_window: u64,
38    /// Housekeeping model (compaction summarize); defaults to `model`.
39    pub fast_model: Option<String>,
40    /// Reset-mode compaction (M4/#9): the continuation gets the preserved
41    /// prefix + digest only, no verbatim tail — a fresh slate rather than a
42    /// summarized-then-refilling window. Default false = M2 in-place behavior.
43    pub compaction_reset: bool,
44    /// Include `context_used%` in the MOIM turn-context block (M4/#9).
45    /// Default true = M2 behavior; false to avoid inducing context anxiety.
46    pub show_context_pct: bool,
47    /// Evict a successful tool result larger than this (estimated tokens) to a
48    /// masked blob, leaving a head preview + read pointer (T4). `0` disables.
49    pub evict_threshold_tokens: u64,
50}
51
52impl Default for EngineConfig {
53    fn default() -> Self {
54        Self {
55            model: "claude-opus-4-8".into(),
56            max_tokens: 32_000,
57            max_turns: 25,
58            thinking: true,
59            cache_static: true,
60            fallback_models: Vec::new(),
61            tool_failure_budget: 5,
62            context_window: 200_000,
63            fast_model: None,
64            compaction_reset: false,
65            show_context_pct: true,
66            evict_threshold_tokens: 20_000,
67        }
68    }
69}
70
71/// How a turn task ended: with a user-facing outcome, or asking the actor
72/// to compact and respawn a continuation (M2 mid-turn = terminate → compact
73/// → respawn, per commit-protocol).
74#[derive(Debug)]
75pub enum TurnEnd {
76    Outcome(Outcome),
77    /// Compact, folding with the speculative digest when the turn managed to
78    /// precompute one — `None` falls back to the inline summarize.
79    Compact {
80        spec: Option<SpecDigest>,
81    },
82}
83
84/// A compaction digest computed speculatively *during* the turn, overlapping
85/// the summarize call with the turn's own samples. Indices refer to the
86/// projection the digest was planned against; the projection only appends
87/// between folds, so they stay valid until the fold that consumes them.
88#[derive(Debug)]
89pub struct SpecDigest {
90    pub prefix_end: usize,
91    pub kept_from: usize,
92    pub text: String,
93}
94
95/// A human's answer to a permission ask. Widened from a
96/// bare `bool` so a denial can carry the reason to the model as tool-result
97/// feedback — a steer fused with a "no". §2b (M4) extends this with
98/// `AllowEdited`/`Respond`; callers should treat it as non-exhaustive.
99#[derive(Debug, Clone, PartialEq)]
100pub enum AskReply {
101    Allow,
102    Deny {
103        message: Option<String>,
104    },
105    /// The human approved but rewrote the tool input (§2b).
106    AllowEdited {
107        input: serde_json::Value,
108    },
109    /// The human answered *as* the tool — skip execution, use this as the
110    /// tool result (§2b).
111    Respond {
112        content: String,
113    },
114}
115
116#[derive(Debug, Clone, PartialEq)]
117pub enum Outcome {
118    Done { text: String },
119    Cancelled,
120    TurnLimit,
121    Refused,
122    DoomLoop { pattern: String },
123    ToolFailureBudget { tool: String },
124    Error { message: String },
125}
126
127/// Everything the surface renders. `Ask` carries the reply channel — the
128/// surface (or an allow-rule upstream) is the human on the loop.
129pub enum EngineEvent {
130    TextDelta(String),
131    ThinkingDelta(String),
132    ToolStart {
133        name: String,
134        summary: String,
135    },
136    ToolDone {
137        name: String,
138        ok: bool,
139    },
140    ToolDenied {
141        name: String,
142    },
143    ToolAutoAllowed {
144        name: String,
145        rule: String,
146    },
147    Retrying {
148        attempt: u32,
149        reason: String,
150    },
151    FallbackModel {
152        model: String,
153    },
154    PromptQueued,
155    /// Context was compacted (digest + verbatim tail); `degraded` means the
156    /// summarize call failed and the floor placeholder was used.
157    Compacted {
158        degraded: bool,
159    },
160    Ask {
161        summary: String,
162        protected_why: Option<String>,
163        reply: oneshot::Sender<AskReply>,
164    },
165    TurnDone {
166        outcome: Outcome,
167        usage: TokenUsage,
168    },
169}
170
171impl std::fmt::Debug for EngineEvent {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            Self::TextDelta(t) => write!(f, "TextDelta({t:?})"),
175            Self::ThinkingDelta(_) => write!(f, "ThinkingDelta"),
176            Self::ToolStart { name, .. } => write!(f, "ToolStart({name})"),
177            Self::ToolDone { name, ok } => write!(f, "ToolDone({name},{ok})"),
178            Self::ToolDenied { name } => write!(f, "ToolDenied({name})"),
179            Self::ToolAutoAllowed { name, rule } => write!(f, "ToolAutoAllowed({name},{rule})"),
180            Self::Retrying { attempt, .. } => write!(f, "Retrying({attempt})"),
181            Self::FallbackModel { model } => write!(f, "FallbackModel({model})"),
182            Self::PromptQueued => write!(f, "PromptQueued"),
183            Self::Compacted { degraded } => write!(f, "Compacted({degraded})"),
184            Self::Ask { summary, .. } => write!(f, "Ask({summary})"),
185            Self::TurnDone { outcome, .. } => write!(f, "TurnDone({outcome:?})"),
186        }
187    }
188}
189
190pub enum SessionCmd {
191    /// A user prompt. Starts a turn, or queues (one-at-a-time promotion).
192    Prompt(String),
193    /// A prompt whose committed item carries a provenance tag (T2: schema
194    /// contract + validation-retry feedback ride in as tagged user items).
195    PromptTagged {
196        text: String,
197        synthetic: hotl_types::SyntheticReason,
198    },
199    /// Continue an interrupted turn (M4/#8): sample against the current
200    /// projection with no new user item — used on resume when the last item
201    /// is a user/tool turn the model never answered. No-op if already running.
202    Continue,
203    /// Mid-turn guidance: admitted durably now, woven into the next sample.
204    Steer(String),
205    /// Turn task → actor: sample-boundary snapshot refresh.
206    Snapshot {
207        reply: oneshot::Sender<Arc<Vec<Item>>>,
208    },
209    /// Turn task → actor: commit these entries (durable-ack before reply).
210    Propose {
211        entries: Vec<EntryPayload>,
212        reply: oneshot::Sender<bool>,
213    },
214    /// Turn task → actor: write an oversized tool result to a masked blob
215    /// (T4 — the actor owns the log, the turn never touches it directly).
216    /// Replies `Ok(path)` on success; on write failure the content is handed
217    /// back in `Err` so eviction never loses data.
218    WriteBlob {
219        tool_use_id: String,
220        content: String,
221        reply: oneshot::Sender<Result<String, String>>,
222    },
223    /// Turn task → actor: the turn is over (or needs a compaction respawn).
224    TurnFinished { end: TurnEnd, usage: TokenUsage },
225}
226
227/// Workspace snapshots around mutating tool batches (M3b shadow-git).
228/// Implementations run the actual snapshot off-thread; a slow or absent
229/// snapshotter must never wedge the turn.
230pub trait Snapshotter: Send + Sync {
231    fn snapshot(&self, label: String) -> futures_util::future::BoxFuture<'static, ()>;
232}
233
234pub struct SessionDeps {
235    pub provider: Arc<dyn Provider>,
236    pub registry: Arc<Registry>,
237    pub rules: Arc<Rules>,
238    /// Gates bash allow-rules: true only while the kernel write floor is
239    /// enforced *and* any configured egress restriction is kernel-backed.
240    pub sandbox_enforced: bool,
241    pub clock: Arc<dyn Clock>,
242    pub log: SessionLog,
243    pub system: String,
244    /// Working directory for subdir instruction hints (M2).
245    pub cwd: PathBuf,
246    /// Shadow snapshots (M3b); None = run without undo support.
247    pub snapshots: Option<Arc<dyn Snapshotter>>,
248    /// Extension hooks (M5); None = no hooks.
249    pub hooks: Option<Arc<dyn hooks::Hooks>>,
250    pub initial_items: Vec<Item>,
251    pub config: EngineConfig,
252}
253
254pub struct SessionHandle {
255    cmd: mpsc::Sender<SessionCmd>,
256    pub events: mpsc::Receiver<EngineEvent>,
257    current_turn: Arc<Mutex<CancellationToken>>,
258}
259
260impl SessionHandle {
261    pub async fn prompt(&self, text: String) {
262        let _ = self.cmd.send(SessionCmd::Prompt(text)).await;
263    }
264    /// A prompt whose committed user item carries a provenance tag (T2).
265    pub async fn prompt_tagged(&self, text: String, synthetic: hotl_types::SyntheticReason) {
266        let _ = self
267            .cmd
268            .send(SessionCmd::PromptTagged { text, synthetic })
269            .await;
270    }
271    pub async fn steer(&self, text: String) {
272        let _ = self.cmd.send(SessionCmd::Steer(text)).await;
273    }
274    /// Continue an interrupted turn on resume (M4/#8).
275    pub async fn continue_turn(&self) {
276        let _ = self.cmd.send(SessionCmd::Continue).await;
277    }
278    /// Out-of-band interrupt of the in-flight turn (never queued behind data).
279    pub fn interrupt(&self) {
280        // A poisoned lock is fine: the token has no invariants to protect.
281        self.current_turn
282            .lock()
283            .unwrap_or_else(std::sync::PoisonError::into_inner)
284            .cancel();
285    }
286}
287
288/// Whether a projection ends on the model's turn to speak (M4/#8): the last
289/// item is a user prompt or a batch of tool results the model never answered
290/// — i.e. an interrupted turn worth continuing on resume. A projection ending
291/// in an assistant item (or holding only instructions) is complete.
292pub fn needs_continuation(items: &[Item]) -> bool {
293    matches!(
294        items.last(),
295        Some(Item::User { .. } | Item::ToolResults { .. })
296    )
297}
298
299pub fn spawn_session(deps: SessionDeps) -> SessionHandle {
300    let (cmd_tx, cmd_rx) = mpsc::channel(64);
301    let (event_tx, event_rx) = mpsc::channel(256);
302    let current_turn = Arc::new(Mutex::new(CancellationToken::new()));
303    tokio::spawn(actor::run(
304        deps,
305        cmd_rx,
306        cmd_tx.clone(),
307        event_tx,
308        current_turn.clone(),
309    ));
310    SessionHandle {
311        cmd: cmd_tx,
312        events: event_rx,
313        current_turn,
314    }
315}