Skip to main content

kaynine_runtime/
service.rs

1//! AgentRuntime: the host-facing facade over per-session actors (SPEC §3.2,
2//! §12). Each session has at most one actor task holding the writer lease,
3//! which serializes all authoritative writes for that session.
4
5use crate::actor::{self, ActorCommand, ActorHandle};
6use crate::run;
7use kaynine_core::budget::BudgetPolicy;
8use kaynine_core::compaction::{CompactionConfig, CompactionModelSelector};
9use kaynine_core::error::KaynineError;
10use kaynine_core::event::{EventEnvelope, RealtimeEvent};
11use kaynine_core::ids::{BranchId, ModelId, RunId, SessionId};
12use kaynine_core::message::ContentBlock;
13use kaynine_core::policy::Policy;
14use kaynine_core::provider::{
15    CredentialProvider, ModelCapabilities, ModelProvider, ReasoningLevel, TokenCounter,
16};
17use kaynine_core::store::{
18    BranchRecord, CreateSessionRequest, EntryRecord, RunState, SessionRecord, SessionStore,
19    SteerRecord,
20};
21use kaynine_core::tool::Tool;
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
25use std::sync::{Arc, Mutex};
26use std::time::Duration;
27use tokio::sync::{broadcast, mpsc, oneshot};
28
29pub struct AgentRuntime {
30    store: Arc<dyn SessionStore>,
31    actors: Arc<Mutex<HashMap<SessionId, ActorHandle>>>,
32    actor_seq: AtomicU64,
33    shutting_down: AtomicBool,
34}
35
36#[derive(Clone)]
37pub struct StartRunRequest {
38    pub command_id: String,
39    pub session_id: SessionId,
40    pub branch_id: BranchId,
41    pub content: Vec<ContentBlock>,
42    pub model_override: Option<ModelId>,
43    pub reasoning_override: Option<ReasoningLevel>,
44    pub capabilities: ModelCapabilities,
45    pub system_prompt: String,
46    pub provider: Arc<dyn ModelProvider>,
47    pub token_counter: Arc<dyn TokenCounter>,
48    pub credentials: Arc<dyn CredentialProvider>,
49    pub tools: Vec<Arc<dyn Tool>>,
50    pub budget: BudgetPolicy,
51    pub max_turns: Option<u32>,
52    /// Policy gate evaluated before every tool execution (SPEC §8). Defaults
53    /// to `AllowAllPolicy` for trusted hosts.
54    pub policy: Arc<dyn Policy>,
55    /// When set, the run gets an InteractiveApprovalHandler with this
56    /// per-decision timeout; Ask decisions wait for `resolve_approval`.
57    /// None → approval mode DisabledFailClosed: Ask fails closed.
58    pub approval_timeout: Option<Duration>,
59    /// When set, the loop composes the system prompt through this composer
60    /// at every turn (SPEC §9); None falls back to `system_prompt`.
61    pub prompt: Option<Arc<kaynine_core::prompt::PromptComposer>>,
62    /// Compaction thresholds (SPEC §10); None disables compaction.
63    pub compaction: Option<CompactionConfig>,
64    /// Model selector for summary generation; None → current model.
65    pub compaction_selector: Option<Arc<dyn CompactionModelSelector>>,
66}
67
68#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
69pub struct RunAccepted {
70    pub run_id: RunId,
71    pub user_entry_id: kaynine_core::ids::EntryId,
72    pub revision: u64,
73}
74
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76pub struct CancelRequest {
77    pub command_id: String,
78    pub session_id: SessionId,
79    pub run_id: RunId,
80}
81
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
83pub enum CancelOutcome {
84    Cancelled { revision: u64 },
85    AlreadyTerminal { state: RunState },
86}
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub struct SessionSnapshot {
90    pub session: SessionRecord,
91    pub branches: Vec<BranchRecord>,
92    pub chains: HashMap<BranchId, Vec<EntryRecord>>,
93    /// Only reflects a live actor's run. A `Running` row left behind by a
94    /// crashed process is surfaced after recovery-on-next-actor, not here.
95    pub active_run: Option<ActiveRunInfo>,
96    pub current_revision: u64,
97    pub last_run_seq: Option<u64>,
98    pub unapplied_steers: Vec<SteerRecord>,
99}
100
101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102pub struct ActiveRunInfo {
103    pub run_id: RunId,
104    pub branch_id: BranchId,
105    pub model: ModelId,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub enum ReleaseOutcome {
110    Released,
111    RunAlreadyActive,
112    NotFound,
113}
114
115#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
116pub struct ShutdownReport {
117    pub cancelled_runs: Vec<RunId>,
118    pub interrupted_runs: Vec<RunId>,
119    pub released_sessions: usize,
120}
121
122/// Items a subscription yields: the authoritative `Snapshot` first, then
123/// live `Event`s. `ResyncRequired` appears when the consumer lagged past the
124/// broadcast capacity; the following `next()` returns a fresh `Snapshot`.
125/// Events between the initial registration and the snapshot (or spanning a
126/// resync) can be delivered both in a snapshot and as events; consumers
127/// dedupe by (revision, run_seq) (SPEC §5.3 allows this interpretation).
128pub enum SubscriptionItem {
129    Snapshot(Box<SessionSnapshot>),
130    Event(EventEnvelope<RealtimeEvent>),
131    ResyncRequired,
132}
133
134pub struct SessionSubscription {
135    store: Arc<dyn SessionStore>,
136    session_id: SessionId,
137    receiver: broadcast::Receiver<EventEnvelope<RealtimeEvent>>,
138    pending_snapshot: Option<Box<SessionSnapshot>>,
139    resynced: bool,
140}
141
142impl SessionSubscription {
143    pub async fn next(&mut self) -> Option<SubscriptionItem> {
144        if let Some(snapshot) = self.pending_snapshot.take() {
145            return Some(SubscriptionItem::Snapshot(snapshot));
146        }
147        if self.resynced {
148            self.resynced = false;
149            let snapshot =
150                run::build_snapshot(self.store.as_ref(), &self.session_id, None, None, None)
151                    .await
152                    .ok()?;
153            return Some(SubscriptionItem::Snapshot(Box::new(snapshot)));
154        }
155        match self.receiver.recv().await {
156            Ok(envelope) => Some(SubscriptionItem::Event(envelope)),
157            Err(broadcast::error::RecvError::Lagged(_)) => {
158                self.resynced = true;
159                Some(SubscriptionItem::ResyncRequired)
160            }
161            Err(broadcast::error::RecvError::Closed) => None,
162        }
163    }
164}
165
166#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
167pub struct UpdateSessionRequest {
168    pub command_id: Option<String>,
169    pub session_id: SessionId,
170    pub default_model: Option<ModelId>,
171    pub reasoning: Option<ReasoningLevel>,
172    pub metadata: Option<serde_json::Value>,
173}
174
175#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
176pub struct SteerRequest {
177    pub command_id: String,
178    pub session_id: SessionId,
179    pub run_id: RunId,
180    pub content: String,
181}
182
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184pub struct SteerAccepted {
185    pub steer_id: String,
186    pub revision: u64,
187}
188
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
190pub struct ApprovalResolution {
191    pub command_id: String,
192    pub session_id: SessionId,
193    pub run_id: RunId,
194    pub call_id: kaynine_core::ids::ToolCallId,
195    pub approved: bool,
196}
197
198/// Result of submitting an approval decision (SPEC §8.3): Delivered reached a
199/// live waiter; Expired arrived after the deadline lapsed; NotFound is an
200/// unknown or already-resolved call.
201#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
202pub enum ApprovalOutcome {
203    Delivered,
204    Expired,
205    NotFound,
206}
207
208impl AgentRuntime {
209    pub fn new(store: Arc<dyn SessionStore>) -> Self {
210        Self {
211            store,
212            actors: Arc::new(Mutex::new(HashMap::new())),
213            actor_seq: AtomicU64::new(0),
214            shutting_down: AtomicBool::new(false),
215        }
216    }
217
218    pub async fn create_session(
219        &self,
220        request: CreateSessionRequest,
221    ) -> Result<SessionRecord, KaynineError> {
222        self.store.create_session(request).await
223    }
224
225    pub async fn list_sessions(&self) -> Result<Vec<SessionRecord>, KaynineError> {
226        self.store.list_sessions().await
227    }
228
229    pub async fn list_branches(
230        &self,
231        session_id: &SessionId,
232    ) -> Result<Vec<BranchRecord>, KaynineError> {
233        self.store.list_branches(session_id).await
234    }
235
236    pub async fn get_snapshot(
237        &self,
238        session_id: &SessionId,
239    ) -> Result<SessionSnapshot, KaynineError> {
240        let handle = {
241            let actors = self.actors.lock().expect("actor registry mutex poisoned");
242            actors.get(session_id).cloned()
243        };
244        if let Some(handle) = handle {
245            let (tx, rx) = oneshot::channel();
246            if handle
247                .tx
248                .send(ActorCommand::Snapshot { reply: tx })
249                .await
250                .is_ok()
251            {
252                if let Ok(result) = rx.await {
253                    return result;
254                }
255            }
256            // The actor died mid-command; drop the stale registration and
257            // fall through to reading store truth directly.
258            self.remove_actor(session_id, &handle);
259        }
260        run::build_snapshot(self.store.as_ref(), session_id, None, None, None).await
261    }
262
263    pub async fn start_run(&self, request: StartRunRequest) -> Result<RunAccepted, KaynineError> {
264        self.check_shutting_down()?;
265        let session_id = request.session_id.clone();
266        let request = Box::new(request);
267        self.with_actor(&session_id, move |reply| ActorCommand::StartRun {
268            request: request.clone(),
269            reply,
270        })
271        .await
272    }
273
274    pub async fn cancel(&self, request: CancelRequest) -> Result<CancelOutcome, KaynineError> {
275        self.check_shutting_down()?;
276        let session_id = request.session_id.clone();
277        self.with_actor(&session_id, move |reply| ActorCommand::Cancel {
278            request: request.clone(),
279            reply,
280        })
281        .await
282    }
283
284    /// Watches a session. Spawns (or reuses) the session actor, so if another
285    /// process holds the writer lease this returns `SessionBusy` — v0.1
286    /// targets single-process desktop use, where multi-process watching can
287    /// read the store directly instead of via a live feed.
288    pub async fn watch_session(
289        &self,
290        session_id: &SessionId,
291    ) -> Result<SessionSubscription, KaynineError> {
292        // Register the receiver BEFORE requesting the snapshot so no event
293        // between the two is missed (duplicates in the other direction are
294        // possible; consumers dedupe by revision/run_seq).
295        let receiver = self
296            .with_actor(session_id, |reply| ActorCommand::Subscribe { reply })
297            .await?;
298        let snapshot = self
299            .with_actor(session_id, |reply| ActorCommand::Snapshot { reply })
300            .await?;
301        Ok(SessionSubscription {
302            store: self.store.clone(),
303            session_id: session_id.clone(),
304            receiver,
305            pending_snapshot: Some(Box::new(snapshot)),
306            resynced: false,
307        })
308    }
309
310    pub async fn steer(&self, request: SteerRequest) -> Result<SteerAccepted, KaynineError> {
311        self.check_shutting_down()?;
312        let session_id = request.session_id.clone();
313        self.with_actor(&session_id, move |reply| ActorCommand::Steer {
314            request: request.clone(),
315            reply,
316        })
317        .await
318    }
319
320    pub async fn resolve_approval(
321        &self,
322        request: ApprovalResolution,
323    ) -> Result<ApprovalOutcome, KaynineError> {
324        self.check_shutting_down()?;
325        let session_id = request.session_id.clone();
326        self.with_actor(&session_id, move |reply| ActorCommand::ResolveApproval {
327            request: request.clone(),
328            reply,
329        })
330        .await
331    }
332
333    pub async fn update_session(
334        &self,
335        request: UpdateSessionRequest,
336    ) -> Result<SessionRecord, KaynineError> {
337        self.check_shutting_down()?;
338        let session_id = request.session_id.clone();
339        self.with_actor(&session_id, move |reply| ActorCommand::UpdateSession {
340            request: request.clone(),
341            reply,
342        })
343        .await
344    }
345
346    pub async fn release_session(
347        &self,
348        session_id: &SessionId,
349    ) -> Result<ReleaseOutcome, KaynineError> {
350        let handle = {
351            let actors = self.actors.lock().expect("actor registry mutex poisoned");
352            actors.get(session_id).cloned()
353        };
354        let Some(handle) = handle else {
355            return Ok(ReleaseOutcome::NotFound);
356        };
357        let (tx, rx) = oneshot::channel();
358        if handle
359            .tx
360            .send(ActorCommand::Release { reply: tx })
361            .await
362            .is_err()
363        {
364            self.remove_actor(session_id, &handle);
365            return Ok(ReleaseOutcome::NotFound);
366        }
367        match rx.await {
368            Ok(result) => result,
369            Err(_) => {
370                self.remove_actor(session_id, &handle);
371                Ok(ReleaseOutcome::NotFound)
372            }
373        }
374    }
375
376    pub async fn shutdown(&self, grace: Duration) -> Result<ShutdownReport, KaynineError> {
377        self.shutting_down.store(true, Ordering::SeqCst);
378        let handles: Vec<(SessionId, ActorHandle)> = {
379            let actors = self.actors.lock().expect("actor registry mutex poisoned");
380            actors
381                .iter()
382                .map(|(id, h)| (id.clone(), h.clone()))
383                .collect()
384        };
385        let mut report = ShutdownReport::default();
386        for (session_id, handle) in handles {
387            let (tx, rx) = oneshot::channel();
388            if handle
389                .tx
390                .send(ActorCommand::Shutdown { grace, reply: tx })
391                .await
392                .is_err()
393            {
394                self.remove_actor(&session_id, &handle);
395                continue;
396            }
397            if let Ok(Ok((cancelled, interrupted))) = rx.await {
398                report.cancelled_runs.extend(cancelled);
399                report.interrupted_runs.extend(interrupted);
400                report.released_sessions += 1;
401            }
402            self.remove_actor(&session_id, &handle);
403        }
404        Ok(report)
405    }
406
407    fn check_shutting_down(&self) -> Result<(), KaynineError> {
408        if self.shutting_down.load(Ordering::SeqCst) {
409            return Err(KaynineError::InvalidRequest);
410        }
411        Ok(())
412    }
413
414    /// Sends a command to the session's actor, spawning one if needed. If the
415    /// actor task already exited (e.g. its lease acquisition failed and it
416    /// replied SessionBusy to queued commands before this send), the stale
417    /// registration is dropped and a fresh actor is tried once.
418    async fn with_actor<T>(
419        &self,
420        session_id: &SessionId,
421        build: impl Fn(oneshot::Sender<Result<T, KaynineError>>) -> ActorCommand,
422    ) -> Result<T, KaynineError> {
423        let mut handle = self.get_or_spawn_actor(session_id)?;
424        for attempt in 0..2 {
425            let (tx, rx) = oneshot::channel();
426            if handle.tx.send(build(tx)).await.is_ok() {
427                return match rx.await {
428                    Ok(result) => result,
429                    Err(_) => {
430                        if attempt == 0 {
431                            self.remove_actor(session_id, &handle);
432                            handle = self.get_or_spawn_actor(session_id)?;
433                            continue;
434                        }
435                        Err(KaynineError::Internal)
436                    }
437                };
438            }
439            if attempt == 0 {
440                self.remove_actor(session_id, &handle);
441                handle = self.get_or_spawn_actor(session_id)?;
442                continue;
443            }
444            return Err(KaynineError::Internal);
445        }
446        Err(KaynineError::Internal)
447    }
448
449    fn get_or_spawn_actor(&self, session_id: &SessionId) -> Result<ActorHandle, KaynineError> {
450        self.check_shutting_down()?;
451        let mut actors = self.actors.lock().expect("actor registry mutex poisoned");
452        if let Some(handle) = actors.get(session_id) {
453            return Ok(handle.clone());
454        }
455        let (tx, rx) = mpsc::channel(64);
456        let handle = ActorHandle {
457            actor_id: self.actor_seq.fetch_add(1, Ordering::SeqCst),
458            tx,
459        };
460        actors.insert(session_id.clone(), handle.clone());
461        drop(actors);
462        actor::spawn(
463            self.store.clone(),
464            self.actors.clone(),
465            session_id.clone(),
466            handle.clone(),
467            rx,
468        );
469        Ok(handle)
470    }
471
472    fn remove_actor(&self, session_id: &SessionId, handle: &ActorHandle) {
473        let mut actors = self.actors.lock().expect("actor registry mutex poisoned");
474        if actors
475            .get(session_id)
476            .is_some_and(|current| current.actor_id == handle.actor_id)
477        {
478            actors.remove(session_id);
479        }
480    }
481}