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 /// Set the session's display name (durable: appended to the log).
206 Rename(String),
207 /// Turn task → actor: sample-boundary snapshot refresh.
208 Snapshot {
209 reply: oneshot::Sender<Arc<Vec<Item>>>,
210 },
211 /// Turn task → actor: commit these entries (durable-ack before reply).
212 Propose {
213 entries: Vec<EntryPayload>,
214 reply: oneshot::Sender<bool>,
215 },
216 /// Turn task → actor: write an oversized tool result to a masked blob
217 /// (T4 — the actor owns the log, the turn never touches it directly).
218 /// Replies `Ok(path)` on success; on write failure the content is handed
219 /// back in `Err` so eviction never loses data.
220 WriteBlob {
221 tool_use_id: String,
222 content: String,
223 reply: oneshot::Sender<Result<String, String>>,
224 },
225 /// Turn task → actor: the turn is over (or needs a compaction respawn).
226 TurnFinished { end: TurnEnd, usage: TokenUsage },
227}
228
229/// Workspace snapshots around mutating tool batches (M3b shadow-git).
230/// Implementations run the actual snapshot off-thread; a slow or absent
231/// snapshotter must never wedge the turn.
232pub trait Snapshotter: Send + Sync {
233 fn snapshot(&self, label: String) -> futures_util::future::BoxFuture<'static, ()>;
234}
235
236pub struct SessionDeps {
237 pub provider: Arc<dyn Provider>,
238 pub registry: Arc<Registry>,
239 pub rules: Arc<Rules>,
240 /// Gates bash allow-rules: true only while the kernel write floor is
241 /// enforced *and* any configured egress restriction is kernel-backed.
242 pub sandbox_enforced: bool,
243 pub clock: Arc<dyn Clock>,
244 pub log: SessionLog,
245 pub system: String,
246 /// Working directory for subdir instruction hints (M2).
247 pub cwd: PathBuf,
248 /// Shadow snapshots (M3b); None = run without undo support.
249 pub snapshots: Option<Arc<dyn Snapshotter>>,
250 /// Extension hooks (M5); None = no hooks.
251 pub hooks: Option<Arc<dyn hooks::Hooks>>,
252 pub initial_items: Vec<Item>,
253 pub config: EngineConfig,
254}
255
256pub struct SessionHandle {
257 cmd: mpsc::Sender<SessionCmd>,
258 pub events: mpsc::Receiver<EngineEvent>,
259 current_turn: Arc<Mutex<CancellationToken>>,
260}
261
262impl SessionHandle {
263 pub async fn prompt(&self, text: String) {
264 let _ = self.cmd.send(SessionCmd::Prompt(text)).await;
265 }
266 /// A prompt whose committed user item carries a provenance tag (T2).
267 pub async fn prompt_tagged(&self, text: String, synthetic: hotl_types::SyntheticReason) {
268 let _ = self
269 .cmd
270 .send(SessionCmd::PromptTagged { text, synthetic })
271 .await;
272 }
273 pub async fn steer(&self, text: String) {
274 let _ = self.cmd.send(SessionCmd::Steer(text)).await;
275 }
276 /// Name the session durably (a `rename` log entry; last one wins).
277 pub async fn rename(&self, name: String) {
278 let _ = self.cmd.send(SessionCmd::Rename(name)).await;
279 }
280 /// Continue an interrupted turn on resume (M4/#8).
281 pub async fn continue_turn(&self) {
282 let _ = self.cmd.send(SessionCmd::Continue).await;
283 }
284 /// Out-of-band interrupt of the in-flight turn (never queued behind data).
285 pub fn interrupt(&self) {
286 // A poisoned lock is fine: the token has no invariants to protect.
287 self.current_turn
288 .lock()
289 .unwrap_or_else(std::sync::PoisonError::into_inner)
290 .cancel();
291 }
292}
293
294/// Whether a projection ends on the model's turn to speak (M4/#8): the last
295/// item is a user prompt or a batch of tool results the model never answered
296/// — i.e. an interrupted turn worth continuing on resume. A projection ending
297/// in an assistant item (or holding only instructions) is complete.
298pub fn needs_continuation(items: &[Item]) -> bool {
299 matches!(
300 items.last(),
301 Some(Item::User { .. } | Item::ToolResults { .. })
302 )
303}
304
305pub fn spawn_session(deps: SessionDeps) -> SessionHandle {
306 let (cmd_tx, cmd_rx) = mpsc::channel(64);
307 let (event_tx, event_rx) = mpsc::channel(256);
308 let current_turn = Arc::new(Mutex::new(CancellationToken::new()));
309 // The actor gets only a weak sender: strong senders are the handle and
310 // any in-flight turn task, so dropping the handle lets the command
311 // channel close and the actor task exit instead of leaking.
312 tokio::spawn(actor::run(
313 deps,
314 cmd_rx,
315 cmd_tx.downgrade(),
316 event_tx,
317 current_turn.clone(),
318 ));
319 SessionHandle {
320 cmd: cmd_tx,
321 events: event_rx,
322 current_turn,
323 }
324}