1use std::sync::Arc;
4
5use runifold_core::{Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, RunContext};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9
10use crate::stream::{AgentObserver, BufferedObserver, NoopObserver};
11use crate::{
12 Agent, AgentCheckpoint, AgentCheckpointPhase, AgentConversationError, AgentConversationOutcome,
13 AgentEventStream, AgentFuture, AgentStreamEvent, ConversationContextPolicy, ConversationId,
14 DurableConversationRequest, DurableConversationStore, MemoryNamespace, ResumePolicy,
15};
16
17mod summary;
18use summary::{SummaryConfig, SummaryProgress};
19
20const KIND: &str = "runifold.conversation.admission";
21
22#[derive(Clone)]
28pub struct AgentSession {
29 agent: Agent,
30 store: Arc<dyn DurableConversationStore>,
31 conversation_id: ConversationId,
32 namespace: MemoryNamespace,
33 policy: ConversationContextPolicy,
34 summary: Option<SummaryConfig>,
35}
36
37#[derive(Debug, Error)]
39pub enum AgentSessionError {
40 #[error("conversation is occupied by request {request_id} at revision {revision}")]
42 Busy {
43 request_id: CheckpointId,
45 revision: u64,
47 },
48 #[error("request identity does not match its original input or conversation")]
50 RequestMismatch,
51 #[error("invalid session admission record")]
53 InvalidAdmission,
54 #[error("session configuration does not match the admitted request")]
56 ConfigurationMismatch,
57 #[error("run usage does not match the session checkpoint")]
59 UsageMismatch,
60 #[error(transparent)]
62 Checkpoint(#[from] CheckpointError),
63 #[error(transparent)]
65 Conversation(Box<AgentConversationError>),
66}
67
68impl From<AgentConversationError> for AgentSessionError {
69 fn from(error: AgentConversationError) -> Self {
70 Self::Conversation(Box::new(error))
71 }
72}
73
74#[derive(Deserialize, Serialize)]
75struct Admission {
76 namespace: MemoryNamespace,
77 active: Option<AdmittedRequest>,
78}
79
80#[derive(Deserialize, Serialize)]
81struct AdmittedRequest {
82 id: CheckpointId,
83 input_digest: [u8; 32],
84 agent: summary::AgentDefinition,
85 context: (u16, u16, Option<u16>),
86 summary: Option<SummaryProgress>,
87 usage: runifold_core::Usage,
88}
89
90impl AgentSession {
91 pub fn new(
93 agent: Agent,
94 store: Arc<dyn DurableConversationStore>,
95 conversation_id: ConversationId,
96 namespace: MemoryNamespace,
97 policy: ConversationContextPolicy,
98 ) -> Self {
99 Self {
100 agent,
101 store,
102 conversation_id,
103 namespace,
104 policy,
105 summary: None,
106 }
107 }
108
109 #[must_use]
113 pub fn with_summary_agent(
114 mut self,
115 agent: Agent,
116 max_passes: crate::ConversationSummaryPassLimit,
117 ) -> Self {
118 self.summary = Some(SummaryConfig { agent, max_passes });
119 self
120 }
121
122 pub fn recovery_usage(
130 &self,
131 request_id: CheckpointId,
132 ) -> Result<runifold_core::Usage, AgentSessionError> {
133 let admission =
134 self.read_admission(&self.store.load(self.conversation_id.as_checkpoint_id())?)?;
135 let active = admission
136 .active
137 .ok_or(AgentSessionError::InvalidAdmission)?;
138 if active.id != request_id {
139 return Err(AgentSessionError::RequestMismatch);
140 }
141 for id in std::iter::once(request_id).chain(
142 active
143 .summary
144 .as_ref()
145 .and_then(|state| state.pending.as_ref())
146 .map(|pass| pass.checkpoint_id),
147 ) {
148 let store: Arc<dyn runifold_core::CheckpointStore> = self.store.clone();
149 match AgentCheckpoint::existing(id, store).load() {
150 Ok((_, state)) => return Ok(summary::usage_floor(active.usage, state.usage)),
151 Err(error) if error.kind == CheckpointErrorKind::NotFound => {}
152 Err(error) => return Err(error.into()),
153 }
154 }
155 Ok(active.usage)
156 }
157
158 pub fn run<'a>(
162 &'a self,
163 request_id: CheckpointId,
164 input: impl Into<String> + Send + 'a,
165 run: &'a RunContext,
166 ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentSessionError>> {
167 self.execute(request_id, input.into(), run, Arc::new(NoopObserver), None)
168 }
169
170 pub fn stream<'a>(
173 &'a self,
174 request_id: CheckpointId,
175 input: impl Into<String> + Send + 'a,
176 run: &'a RunContext,
177 ) -> AgentEventStream<'a, AgentSessionError> {
178 let observer = Arc::new(BufferedObserver::durable());
179 let events = observer.events();
180 let execution = self.execute(request_id, input.into(), run, observer.clone(), None);
181 AgentEventStream::new(
182 Box::pin(async move {
183 let result = execution.await?;
184 observer.emit(AgentStreamEvent::ConversationCommitted {
185 outcome: result.outcome.clone(),
186 conversation_version: result.conversation_version,
187 });
188 Ok(result.outcome)
189 }),
190 events,
191 )
192 }
193
194 pub fn recover_after_owner_exit<'a>(
201 &'a self,
202 request_id: CheckpointId,
203 input: impl Into<String> + Send + 'a,
204 run: &'a RunContext,
205 expected_revision: u64,
206 policy: ResumePolicy,
207 ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentSessionError>> {
208 self.execute(
209 request_id,
210 input.into(),
211 run,
212 Arc::new(NoopObserver),
213 Some((expected_revision, policy)),
214 )
215 }
216
217 fn execute<'a>(
218 &'a self,
219 request_id: CheckpointId,
220 input: String,
221 run: &'a RunContext,
222 observer: Arc<dyn AgentObserver>,
223 recovery: Option<(u64, ResumePolicy)>,
224 ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentSessionError>> {
225 Box::pin(async move {
226 if request_id == self.conversation_id.as_checkpoint_id() {
227 return Err(AgentSessionError::RequestMismatch);
228 }
229 let existing = self.existing_request(request_id, &input)?;
230 if existing == Some(true) && recovery.is_none() {
231 return Ok(self
232 .agent
233 .resume_durable_conversation(
234 self.store.clone(),
235 request_id,
236 run,
237 ResumePolicy::RejectAmbiguous,
238 )
239 .await?);
240 }
241 let mut admission = self.claim(
242 request_id,
243 &input,
244 run,
245 recovery.map(|(revision, _)| revision),
246 )?;
247 let existing = self.existing_request(request_id, &input)?;
250 let result = if existing.is_some() {
251 self.agent
252 .resume_durable_conversation_observed(
253 self.store.clone(),
254 request_id,
255 run,
256 recovery.map_or(ResumePolicy::RejectAmbiguous, |(_, policy)| policy),
257 observer,
258 )
259 .await?
260 } else {
261 self.compact_summary(
262 &mut admission,
263 run,
264 observer.as_ref(),
265 recovery.map_or(ResumePolicy::RejectAmbiguous, |(_, policy)| policy),
266 )
267 .await?;
268 self.agent
269 .run_durable_conversation_observed(
270 input,
271 run,
272 self.store.clone(),
273 DurableConversationRequest {
274 checkpoint_id: request_id,
275 conversation_id: self.conversation_id,
276 namespace: self.namespace.clone(),
277 policy: self.policy,
278 },
279 observer,
280 )
281 .await?
282 };
283 let idle = admission.next(self.payload(None)?)?;
284 self.store
285 .compare_and_swap(&idle, Some(admission.revision))?;
286 Ok(result)
287 })
288 }
289
290 fn existing_request(
291 &self,
292 id: CheckpointId,
293 input: &str,
294 ) -> Result<Option<bool>, AgentSessionError> {
295 let store: Arc<dyn runifold_core::CheckpointStore> = self.store.clone();
296 let checkpoint = AgentCheckpoint::existing(id, store);
297 let (_, state) = match checkpoint.load() {
298 Ok(value) => value,
299 Err(error) if error.kind == CheckpointErrorKind::NotFound => return Ok(None),
300 Err(error) => return Err(error.into()),
301 };
302 let durable = state
303 .durable_conversation
304 .as_ref()
305 .ok_or(AgentSessionError::RequestMismatch)?;
306 let index = usize::try_from(durable.persisted_prefix_len)
307 .map_err(|_| AgentSessionError::RequestMismatch)?;
308 let message = state
311 .transcript
312 .iter()
313 .skip(index)
314 .find(|message| !super::is_transient_context(message));
315 if durable.conversation_id != self.conversation_id
316 || durable.namespace != self.namespace
317 || message != Some(&runifold_model::Message::user(input))
318 {
319 return Err(AgentSessionError::RequestMismatch);
320 }
321 Ok(Some(matches!(
322 state.phase,
323 AgentCheckpointPhase::Completed { .. }
324 )))
325 }
326
327 fn claim(
328 &self,
329 id: CheckpointId,
330 input: &str,
331 run: &RunContext,
332 recovery: Option<u64>,
333 ) -> Result<Checkpoint, AgentSessionError> {
334 let gate_id = self.conversation_id.as_checkpoint_id();
335 let digest: [u8; 32] = Sha256::digest(input.as_bytes()).into();
336 let payload = self.payload(Some(AdmittedRequest {
337 id,
338 input_digest: digest,
339 agent: summary::AgentDefinition::new(&self.agent),
340 context: self.context_contract(),
341 summary: self.summary.as_ref().map(SummaryProgress::new),
342 usage: run.budget().usage(),
343 }))?;
344 let current = match self.store.load(gate_id) {
345 Ok(current) => current,
346 Err(error) if error.kind == CheckpointErrorKind::NotFound && recovery.is_none() => {
347 let first = Checkpoint::initial(gate_id, run.run_id(), KIND, 3, payload);
348 self.store.compare_and_swap(&first, None)?;
349 return Ok(first);
350 }
351 Err(error) => return Err(error.into()),
352 };
353 let state = self.read_admission(¤t)?;
354 let mut payload = payload;
355 if let Some(active) = state.active {
356 if active.id == id && active.input_digest != digest {
357 return Err(AgentSessionError::RequestMismatch);
358 }
359 if recovery != Some(current.revision) || active.id != id {
360 return Err(AgentSessionError::Busy {
361 request_id: active.id,
362 revision: current.revision,
363 });
364 }
365 if active.agent != summary::AgentDefinition::new(&self.agent)
366 || active.context != self.context_contract()
367 || active.summary.as_ref().map(|state| &state.contract)
368 != self.summary.as_ref().map(SummaryConfig::contract).as_ref()
369 {
370 return Err(AgentSessionError::ConfigurationMismatch);
371 }
372 payload = self.payload(Some(active))?;
374 } else if recovery.is_some() {
375 return Err(AgentSessionError::InvalidAdmission);
376 }
377 let next = current.next(payload)?;
378 self.store.compare_and_swap(&next, Some(current.revision))?;
379 Ok(next)
380 }
381
382 fn context_contract(&self) -> (u16, u16, Option<u16>) {
383 (
384 self.policy.window.get(),
385 self.policy.summary_batch.get(),
386 self.policy
387 .semantic_memory_limit
388 .map(std::num::NonZeroU16::get),
389 )
390 }
391
392 fn read_admission(&self, checkpoint: &Checkpoint) -> Result<Admission, AgentSessionError> {
393 if checkpoint.kind != KIND || checkpoint.schema_version != 3 {
394 return Err(AgentSessionError::InvalidAdmission);
395 }
396 let state: Admission = serde_json::from_value(checkpoint.payload.clone())
397 .map_err(|_| AgentSessionError::InvalidAdmission)?;
398 if state.namespace != self.namespace {
399 return Err(AgentSessionError::RequestMismatch);
400 }
401 Ok(state)
402 }
403
404 fn save_admission(
405 &self,
406 checkpoint: &mut Checkpoint,
407 active: AdmittedRequest,
408 ) -> Result<(), AgentSessionError> {
409 let next = checkpoint.next(self.payload(Some(active))?)?;
410 self.store
411 .compare_and_swap(&next, Some(checkpoint.revision))?;
412 *checkpoint = next;
413 Ok(())
414 }
415
416 fn payload(
417 &self,
418 active: Option<AdmittedRequest>,
419 ) -> Result<serde_json::Value, AgentSessionError> {
420 serde_json::to_value(Admission {
421 namespace: self.namespace.clone(),
422 active,
423 })
424 .map_err(|_| AgentSessionError::InvalidAdmission)
425 }
426}
427
428impl std::fmt::Debug for AgentSession {
429 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430 formatter
431 .debug_struct("AgentSession")
432 .field("conversation_id", &self.conversation_id)
433 .field("namespace", &self.namespace)
434 .finish_non_exhaustive()
435 }
436}