1mod 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 pub fallback_models: Vec<String>,
34 pub tool_failure_budget: u32,
36 pub context_window: u64,
38 pub fast_model: Option<String>,
40 pub compaction_reset: bool,
44 pub show_context_pct: bool,
47 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#[derive(Debug)]
75pub enum TurnEnd {
76 Outcome(Outcome),
77 Compact {
80 spec: Option<SpecDigest>,
81 },
82}
83
84#[derive(Debug)]
89pub struct SpecDigest {
90 pub prefix_end: usize,
91 pub kept_from: usize,
92 pub text: String,
93}
94
95#[derive(Debug, Clone, PartialEq)]
100pub enum AskReply {
101 Allow,
102 Deny {
103 message: Option<String>,
104 },
105 AllowEdited {
107 input: serde_json::Value,
108 },
109 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
127pub 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 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 Prompt(String),
193 PromptTagged {
196 text: String,
197 synthetic: hotl_types::SyntheticReason,
198 },
199 Continue,
203 Steer(String),
205 Snapshot {
207 reply: oneshot::Sender<Arc<Vec<Item>>>,
208 },
209 Propose {
211 entries: Vec<EntryPayload>,
212 reply: oneshot::Sender<bool>,
213 },
214 WriteBlob {
219 tool_use_id: String,
220 content: String,
221 reply: oneshot::Sender<Result<String, String>>,
222 },
223 TurnFinished { end: TurnEnd, usage: TokenUsage },
225}
226
227pub 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 pub sandbox_enforced: bool,
241 pub clock: Arc<dyn Clock>,
242 pub log: SessionLog,
243 pub system: String,
244 pub cwd: PathBuf,
246 pub snapshots: Option<Arc<dyn Snapshotter>>,
248 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 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 pub async fn continue_turn(&self) {
276 let _ = self.cmd.send(SessionCmd::Continue).await;
277 }
278 pub fn interrupt(&self) {
280 self.current_turn
282 .lock()
283 .unwrap_or_else(std::sync::PoisonError::into_inner)
284 .cancel();
285 }
286}
287
288pub 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}