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