1use std::collections::BTreeSet;
4use std::sync::Arc;
5
6use meerkat_core::handles::{DslTransitionError, TurnStateHandle, TurnStateSnapshot};
7use meerkat_core::lifecycle::RunId;
8use meerkat_core::ops::{AsyncOpRef, OperationId, WaitPolicy};
9use meerkat_core::retry::LlmRetrySchedule;
10#[cfg(test)]
11use meerkat_core::turn_execution_authority::TurnFailureSourceKind;
12use meerkat_core::turn_execution_authority::{
13 CallTimeoutSource as TurnCallTimeoutSource, CallTimeoutVerdict as TurnCallTimeoutVerdict,
14 ContentShape, LlmFailureRecoveryKind, TurnExecutionEffect, TurnExecutionInput,
15 TurnFailureReason, TurnFailureSource, TurnPhase, TurnPrimitiveKind, TurnTerminalCauseKind,
16 TurnTerminalOutcome, terminal_outcome_for_budget_exceeded,
17};
18
19use super::HandleDslAuthority;
20use crate::meerkat_machine::dsl as mm_dsl;
21
22#[derive(Debug)]
24pub struct RuntimeTurnStateHandle {
25 dsl: Arc<HandleDslAuthority>,
26}
27
28impl RuntimeTurnStateHandle {
29 pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
31 Self { dsl }
32 }
33
34 pub fn ephemeral() -> Self {
36 Self::new(Arc::new(HandleDslAuthority::ephemeral()))
37 }
38}
39
40fn parse_effect_run_id(
41 run_id: &mm_dsl::RunId,
42 context: &'static str,
43) -> Result<RunId, DslTransitionError> {
44 uuid::Uuid::parse_str(&run_id.0)
45 .map(RunId::from_uuid)
46 .map_err(|err| {
47 DslTransitionError::guard_rejected(
48 context,
49 format!(
50 "generated MeerkatMachine turn effect carried malformed run_id `{}`: {err}",
51 run_id.0
52 ),
53 )
54 })
55}
56
57fn map_generated_turn_effect(
58 effect: mm_dsl::MeerkatMachineEffect,
59 context: &'static str,
60) -> Result<Option<TurnExecutionEffect>, DslTransitionError> {
61 Ok(Some(match effect {
62 mm_dsl::MeerkatMachineEffect::TurnRunStarted { run_id } => {
63 TurnExecutionEffect::RunStarted {
64 run_id: parse_effect_run_id(&run_id, context)?,
65 }
66 }
67 mm_dsl::MeerkatMachineEffect::TurnBoundaryApplied {
68 run_id,
69 boundary_sequence,
70 } => TurnExecutionEffect::BoundaryApplied {
71 run_id: parse_effect_run_id(&run_id, context)?,
72 boundary_sequence,
73 },
74 mm_dsl::MeerkatMachineEffect::TurnRunCompleted { run_id, .. } => {
75 TurnExecutionEffect::RunCompleted {
76 run_id: parse_effect_run_id(&run_id, context)?,
77 }
78 }
79 mm_dsl::MeerkatMachineEffect::TurnRunFailed {
80 run_id,
81 terminal_cause_kind,
82 error,
83 } => {
84 let cause_kind: TurnTerminalCauseKind = terminal_cause_kind.into();
85 if !cause_kind.is_specific_failure_cause() {
86 return Err(DslTransitionError::guard_rejected(
87 context,
88 "generated MeerkatMachine TurnRunFailed effect carried unknown terminal_cause_kind",
89 ));
90 }
91 TurnExecutionEffect::RunFailed {
92 run_id: parse_effect_run_id(&run_id, context)?,
93 reason: TurnFailureReason::with_cause(
94 cause_kind,
95 cause_kind.agent_error_class(),
96 error,
97 ),
98 }
99 }
100 mm_dsl::MeerkatMachineEffect::TurnRunCancelled { run_id, .. } => {
101 TurnExecutionEffect::RunCancelled {
102 run_id: parse_effect_run_id(&run_id, context)?,
103 }
104 }
105 mm_dsl::MeerkatMachineEffect::TurnCheckCompaction => TurnExecutionEffect::CheckCompaction,
106 mm_dsl::MeerkatMachineEffect::LlmFailureRecoveryClassified { recovery } => {
107 TurnExecutionEffect::LlmFailureRecoveryClassified {
108 recovery: match recovery {
109 mm_dsl::LlmFailureRecoveryKind::Recover => LlmFailureRecoveryKind::Recover,
110 mm_dsl::LlmFailureRecoveryKind::Exhausted => LlmFailureRecoveryKind::Exhausted,
111 mm_dsl::LlmFailureRecoveryKind::Fatal => LlmFailureRecoveryKind::Fatal,
112 },
113 }
114 }
115 mm_dsl::MeerkatMachineEffect::AssistantOutputClassified {
116 empty_response_terminal,
117 } => TurnExecutionEffect::AssistantOutputClassified {
118 empty_response_terminal,
119 },
120 mm_dsl::MeerkatMachineEffect::CallTimeoutClassified {
121 verdict,
122 timeout_ms,
123 } => TurnExecutionEffect::CallTimeoutClassified {
124 verdict: match verdict {
125 mm_dsl::CallTimeoutVerdict::RetryableCallTimeout => {
126 TurnCallTimeoutVerdict::RetryableCallTimeout
127 }
128 mm_dsl::CallTimeoutVerdict::TerminalTurnBudget => {
129 TurnCallTimeoutVerdict::TerminalTurnBudget
130 }
131 },
132 timeout_ms,
133 },
134 _ => return Ok(None),
135 }))
136}
137
138impl TurnStateHandle for RuntimeTurnStateHandle {
139 fn apply_turn_input(
140 &self,
141 input: TurnExecutionInput,
142 ) -> Result<Vec<TurnExecutionEffect>, DslTransitionError> {
143 let context = "TurnStateHandle::apply_turn_input";
144 let dsl_input = match input {
145 TurnExecutionInput::StartConversationRun {
146 run_id,
147 primitive_kind,
148 admitted_content_shape,
149 vision_enabled,
150 image_tool_results_enabled,
151 max_extraction_retries,
152 } => mm_dsl::MeerkatMachineInput::StartConversationRun {
153 run_id: mm_dsl::RunId::from_domain(&run_id),
154 primitive_kind: mm_dsl::TurnPrimitiveKind::from(primitive_kind),
155 admitted_content_shape: mm_dsl::ContentShape::from(admitted_content_shape),
156 vision_enabled,
157 image_tool_results_enabled,
158 max_extraction_retries,
159 },
160 TurnExecutionInput::StartImmediateAppend { run_id } => {
161 mm_dsl::MeerkatMachineInput::StartImmediateAppend {
162 run_id: mm_dsl::RunId::from_domain(&run_id),
163 }
164 }
165 TurnExecutionInput::StartImmediateContext { run_id } => {
166 mm_dsl::MeerkatMachineInput::StartImmediateContext {
167 run_id: mm_dsl::RunId::from_domain(&run_id),
168 }
169 }
170 TurnExecutionInput::PrimitiveApplied { run_id } => {
171 mm_dsl::MeerkatMachineInput::PrimitiveApplied {
172 run_id: mm_dsl::RunId::from_domain(&run_id),
173 }
174 }
175 TurnExecutionInput::LlmReturnedToolCalls { run_id, tool_count } => {
176 mm_dsl::MeerkatMachineInput::LlmReturnedToolCalls {
177 run_id: mm_dsl::RunId::from_domain(&run_id),
178 tool_count: u64::from(tool_count),
179 }
180 }
181 TurnExecutionInput::LlmReturnedTerminal { run_id } => {
182 mm_dsl::MeerkatMachineInput::LlmReturnedTerminal {
183 run_id: mm_dsl::RunId::from_domain(&run_id),
184 }
185 }
186 TurnExecutionInput::RegisterPendingOps {
187 run_id,
188 op_refs,
189 barrier_operation_ids,
190 ..
191 } => mm_dsl::MeerkatMachineInput::RegisterPendingOps {
192 run_id: mm_dsl::RunId::from_domain(&run_id),
193 op_refs: op_refs
194 .iter()
195 .map(|op_ref| op_ref.operation_id.to_string())
196 .collect(),
197 barrier_operation_ids: barrier_operation_ids
203 .iter()
204 .map(|id| mm_dsl::OperationId::from(id.to_string()))
205 .collect(),
206 },
207 TurnExecutionInput::ToolCallsResolved { run_id } => {
208 mm_dsl::MeerkatMachineInput::ToolCallsResolved {
209 run_id: mm_dsl::RunId::from_domain(&run_id),
210 }
211 }
212 TurnExecutionInput::OpsBarrierSatisfied {
213 run_id,
214 operation_ids,
215 } => mm_dsl::MeerkatMachineInput::OpsBarrierSatisfied {
216 run_id: mm_dsl::RunId::from_domain(&run_id),
217 operation_ids: operation_ids
219 .iter()
220 .map(|id| mm_dsl::OperationId::from(id.to_string()))
221 .collect(),
222 },
223 TurnExecutionInput::BoundaryContinue { run_id } => {
224 mm_dsl::MeerkatMachineInput::BoundaryContinue {
225 run_id: mm_dsl::RunId::from_domain(&run_id),
226 }
227 }
228 TurnExecutionInput::BoundaryComplete { run_id } => {
229 mm_dsl::MeerkatMachineInput::BoundaryComplete {
230 run_id: mm_dsl::RunId::from_domain(&run_id),
231 }
232 }
233 TurnExecutionInput::RecoverableFailure { run_id, retry } => {
234 mm_dsl::MeerkatMachineInput::RecoverableFailure {
235 run_id: mm_dsl::RunId::from_domain(&run_id),
236 failure_kind: retry.failure.kind.into(),
237 retry_attempt: u64::from(retry.plan.attempt),
238 max_retries: u64::from(retry.plan.max_retries),
239 selected_delay_ms: retry.plan.selected_delay_ms,
240 error: retry.failure.message,
241 }
242 }
243 TurnExecutionInput::FatalFailure { run_id, failure } => {
244 mm_dsl::MeerkatMachineInput::FatalFailure {
245 run_id: mm_dsl::RunId::from_domain(&run_id),
246 terminal_failure_source: mm_dsl::RunFailureSourceKind::from(
247 failure.source_kind,
248 ),
249 error: failure.message,
250 }
251 }
252 TurnExecutionInput::RetryRequested {
253 run_id,
254 retry_attempt,
255 } => mm_dsl::MeerkatMachineInput::RetryRequested {
256 run_id: mm_dsl::RunId::from_domain(&run_id),
257 retry_attempt: u64::from(retry_attempt),
258 },
259 TurnExecutionInput::ClassifyLlmFailureRecovery {
260 failure_kind,
261 retry_attempt,
262 max_retries,
263 } => mm_dsl::MeerkatMachineInput::ClassifyLlmFailureRecovery {
264 failure_kind: failure_kind.map(Into::into),
265 retry_attempt: u64::from(retry_attempt),
266 max_retries: u64::from(max_retries),
267 },
268 TurnExecutionInput::ClassifyAssistantOutput {
269 has_visible_or_actionable,
270 } => mm_dsl::MeerkatMachineInput::ClassifyAssistantOutput {
271 has_visible_or_actionable,
272 },
273 TurnExecutionInput::ClassifyCallTimeout { source, timeout_ms } => {
274 mm_dsl::MeerkatMachineInput::ClassifyCallTimeout {
275 source: match source {
276 TurnCallTimeoutSource::CallBudget => mm_dsl::CallTimeoutSource::CallBudget,
277 TurnCallTimeoutSource::TurnBudget => mm_dsl::CallTimeoutSource::TurnBudget,
278 },
279 timeout_ms,
280 }
281 }
282 TurnExecutionInput::CancelNow { run_id } => mm_dsl::MeerkatMachineInput::CancelNow {
283 run_id: mm_dsl::RunId::from_domain(&run_id),
284 },
285 TurnExecutionInput::CancelAfterBoundary { run_id } => {
286 mm_dsl::MeerkatMachineInput::RequestCancelAfterBoundary {
287 run_id: mm_dsl::RunId::from_domain(&run_id),
288 }
289 }
290 TurnExecutionInput::CancellationObserved { run_id } => {
291 mm_dsl::MeerkatMachineInput::CancellationObserved {
292 run_id: mm_dsl::RunId::from_domain(&run_id),
293 }
294 }
295 TurnExecutionInput::AcknowledgeTerminal { run_id } => {
296 let outcome = self.snapshot().terminal_outcome.ok_or_else(|| {
297 DslTransitionError::guard_rejected(
298 context,
299 "generated MeerkatMachine terminal outcome missing for AcknowledgeTerminal",
300 )
301 })?;
302 mm_dsl::MeerkatMachineInput::AcknowledgeTerminal {
303 run_id: mm_dsl::RunId::from_domain(&run_id),
304 outcome: mm_dsl::TurnTerminalOutcome::from(outcome),
305 }
306 }
307 TurnExecutionInput::TurnLimitReached { run_id } => {
308 mm_dsl::MeerkatMachineInput::TurnLimitReached {
309 run_id: mm_dsl::RunId::from_domain(&run_id),
310 }
311 }
312 TurnExecutionInput::BudgetExhausted { run_id } => {
313 mm_dsl::MeerkatMachineInput::BudgetExhausted {
314 run_id: mm_dsl::RunId::from_domain(&run_id),
315 }
316 }
317 TurnExecutionInput::TimeBudgetExceeded { run_id } => {
318 mm_dsl::MeerkatMachineInput::TimeBudgetExceeded {
319 run_id: mm_dsl::RunId::from_domain(&run_id),
320 }
321 }
322 TurnExecutionInput::BudgetLimitExceeded { run_id, exceeded } => {
323 match terminal_outcome_for_budget_exceeded(exceeded) {
324 TurnTerminalOutcome::TimeBudgetExceeded => {
325 mm_dsl::MeerkatMachineInput::TimeBudgetExceeded {
326 run_id: mm_dsl::RunId::from_domain(&run_id),
327 }
328 }
329 TurnTerminalOutcome::BudgetExhausted => {
330 mm_dsl::MeerkatMachineInput::BudgetExhausted {
331 run_id: mm_dsl::RunId::from_domain(&run_id),
332 }
333 }
334 _ => unreachable!("budget exceeded maps only to budget terminal outcomes"),
335 }
336 }
337 TurnExecutionInput::EnterExtraction {
338 run_id,
339 max_retries,
340 } => mm_dsl::MeerkatMachineInput::EnterExtraction {
341 run_id: mm_dsl::RunId::from_domain(&run_id),
342 max_extraction_retries: u64::from(max_retries),
343 },
344 TurnExecutionInput::ExtractionValidationPassed { run_id } => {
345 mm_dsl::MeerkatMachineInput::ExtractionValidationPassed {
346 run_id: mm_dsl::RunId::from_domain(&run_id),
347 }
348 }
349 TurnExecutionInput::ExtractionValidationFailed { run_id, error } => {
350 mm_dsl::MeerkatMachineInput::ExtractionValidationFailed {
351 run_id: mm_dsl::RunId::from_domain(&run_id),
352 error,
353 }
354 }
355 TurnExecutionInput::ExtractionFailed { run_id, error } => {
356 mm_dsl::MeerkatMachineInput::ExtractionFailed {
357 run_id: mm_dsl::RunId::from_domain(&run_id),
358 error,
359 }
360 }
361 TurnExecutionInput::ExtractionStart { run_id } => {
362 mm_dsl::MeerkatMachineInput::ExtractionStart {
363 run_id: mm_dsl::RunId::from_domain(&run_id),
364 }
365 }
366 TurnExecutionInput::ForceCancelNoRun => mm_dsl::MeerkatMachineInput::ForceCancelNoRun,
367 };
368 self.dsl
369 .apply_input_with_effects(dsl_input, context)?
370 .into_iter()
371 .map(|effect| map_generated_turn_effect(effect, context))
372 .filter_map(Result::transpose)
373 .collect()
374 }
375
376 fn start_conversation_run(
377 &self,
378 run_id: RunId,
379 primitive_kind: TurnPrimitiveKind,
380 admitted_content_shape: ContentShape,
381 vision_enabled: bool,
382 image_tool_results_enabled: bool,
383 max_extraction_retries: u64,
384 ) -> Result<(), DslTransitionError> {
385 self.dsl.apply_input(
387 mm_dsl::MeerkatMachineInput::StartConversationRun {
388 run_id: mm_dsl::RunId::from_domain(&run_id),
389 primitive_kind: mm_dsl::TurnPrimitiveKind::from(primitive_kind),
390 admitted_content_shape: mm_dsl::ContentShape::from(admitted_content_shape),
391 vision_enabled,
392 image_tool_results_enabled,
393 max_extraction_retries,
394 },
395 "TurnStateHandle::start_conversation_run",
396 )
397 }
398
399 fn start_immediate_append(&self, run_id: RunId) -> Result<(), DslTransitionError> {
400 self.dsl.apply_input(
402 mm_dsl::MeerkatMachineInput::StartImmediateAppend {
403 run_id: mm_dsl::RunId::from_domain(&run_id),
404 },
405 "TurnStateHandle::start_immediate_append",
406 )
407 }
408
409 fn start_immediate_context(&self, run_id: RunId) -> Result<(), DslTransitionError> {
410 self.dsl.apply_input(
412 mm_dsl::MeerkatMachineInput::StartImmediateContext {
413 run_id: mm_dsl::RunId::from_domain(&run_id),
414 },
415 "TurnStateHandle::start_immediate_context",
416 )
417 }
418
419 fn primitive_applied(&self, run_id: RunId) -> Result<(), DslTransitionError> {
420 self.dsl.apply_input(
422 mm_dsl::MeerkatMachineInput::PrimitiveApplied {
423 run_id: mm_dsl::RunId::from_domain(&run_id),
424 },
425 "TurnStateHandle::primitive_applied",
426 )
427 }
428
429 fn llm_returned_tool_calls(
430 &self,
431 run_id: RunId,
432 tool_count: u64,
433 ) -> Result<(), DslTransitionError> {
434 self.apply_turn_input(TurnExecutionInput::LlmReturnedToolCalls {
435 run_id,
436 tool_count: u32::try_from(tool_count).map_err(|_| {
437 DslTransitionError::guard_rejected(
438 "TurnStateHandle::llm_returned_tool_calls",
439 "tool_count exceeds u32 turn input range",
440 )
441 })?,
442 })
443 .map(|_| ())
444 }
445
446 fn llm_returned_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError> {
447 self.apply_turn_input(TurnExecutionInput::LlmReturnedTerminal { run_id })
448 .map(|_| ())
449 }
450
451 fn register_pending_ops(
452 &self,
453 run_id: RunId,
454 op_refs: BTreeSet<AsyncOpRef>,
455 barrier_operation_ids: BTreeSet<OperationId>,
456 ) -> Result<(), DslTransitionError> {
457 let has_barrier_ops = !barrier_operation_ids.is_empty();
458 self.apply_turn_input(TurnExecutionInput::RegisterPendingOps {
459 run_id,
460 op_refs: op_refs.into_iter().collect(),
461 barrier_operation_ids: barrier_operation_ids.into_iter().collect(),
462 has_barrier_ops,
463 })
464 .map(|_| ())
465 }
466
467 fn tool_calls_resolved(&self, run_id: RunId) -> Result<(), DslTransitionError> {
468 self.apply_turn_input(TurnExecutionInput::ToolCallsResolved { run_id })
469 .map(|_| ())
470 }
471
472 fn ops_barrier_satisfied(
473 &self,
474 run_id: RunId,
475 operation_ids: BTreeSet<OperationId>,
476 ) -> Result<(), DslTransitionError> {
477 self.apply_turn_input(TurnExecutionInput::OpsBarrierSatisfied {
478 run_id,
479 operation_ids: operation_ids.into_iter().collect(),
480 })
481 .map(|_| ())
482 }
483
484 fn boundary_continue(&self, run_id: RunId) -> Result<(), DslTransitionError> {
485 self.apply_turn_input(TurnExecutionInput::BoundaryContinue { run_id })
486 .map(|_| ())
487 }
488
489 fn boundary_complete(&self, run_id: RunId) -> Result<(), DslTransitionError> {
490 self.apply_turn_input(TurnExecutionInput::BoundaryComplete { run_id })
491 .map(|_| ())
492 }
493
494 fn enter_extraction(&self, run_id: RunId, max_retries: u32) -> Result<(), DslTransitionError> {
495 self.apply_turn_input(TurnExecutionInput::EnterExtraction {
496 run_id,
497 max_retries,
498 })
499 .map(|_| ())
500 }
501
502 fn extraction_start(&self, run_id: RunId) -> Result<(), DslTransitionError> {
503 self.apply_turn_input(TurnExecutionInput::ExtractionStart { run_id })
504 .map(|_| ())
505 }
506
507 fn extraction_validation_passed(&self, run_id: RunId) -> Result<(), DslTransitionError> {
508 self.apply_turn_input(TurnExecutionInput::ExtractionValidationPassed { run_id })
509 .map(|_| ())
510 }
511
512 fn extraction_validation_failed(
513 &self,
514 run_id: RunId,
515 error: String,
516 ) -> Result<(), DslTransitionError> {
517 self.apply_turn_input(TurnExecutionInput::ExtractionValidationFailed { run_id, error })
518 .map(|_| ())
519 }
520
521 fn extraction_failed(&self, run_id: RunId, error: String) -> Result<(), DslTransitionError> {
522 self.apply_turn_input(TurnExecutionInput::ExtractionFailed { run_id, error })
523 .map(|_| ())
524 }
525
526 fn recoverable_failure(
527 &self,
528 run_id: RunId,
529 retry: LlmRetrySchedule,
530 ) -> Result<(), DslTransitionError> {
531 self.apply_turn_input(TurnExecutionInput::RecoverableFailure { run_id, retry })
532 .map(|_| ())
533 }
534
535 fn fatal_failure(
536 &self,
537 run_id: RunId,
538 failure: TurnFailureSource,
539 ) -> Result<(), DslTransitionError> {
540 self.apply_turn_input(TurnExecutionInput::FatalFailure { run_id, failure })
541 .map(|_| ())
542 }
543
544 fn retry_requested(&self, run_id: RunId, retry_attempt: u32) -> Result<(), DslTransitionError> {
545 self.apply_turn_input(TurnExecutionInput::RetryRequested {
546 run_id,
547 retry_attempt,
548 })
549 .map(|_| ())
550 }
551
552 fn cancel_now(&self, run_id: RunId) -> Result<(), DslTransitionError> {
553 self.apply_turn_input(TurnExecutionInput::CancelNow { run_id })
554 .map(|_| ())
555 }
556
557 fn request_cancel_after_boundary(&self, run_id: RunId) -> Result<(), DslTransitionError> {
558 self.apply_turn_input(TurnExecutionInput::CancelAfterBoundary { run_id })
559 .map(|_| ())
560 }
561
562 fn cancellation_observed(&self, run_id: RunId) -> Result<(), DslTransitionError> {
563 self.apply_turn_input(TurnExecutionInput::CancellationObserved { run_id })
564 .map(|_| ())
565 }
566
567 fn acknowledge_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError> {
568 self.apply_turn_input(TurnExecutionInput::AcknowledgeTerminal { run_id })
569 .map(|_| ())
570 }
571
572 fn turn_limit_reached(&self, run_id: RunId) -> Result<(), DslTransitionError> {
573 self.apply_turn_input(TurnExecutionInput::TurnLimitReached { run_id })
574 .map(|_| ())
575 }
576
577 fn budget_exhausted(&self, run_id: RunId) -> Result<(), DslTransitionError> {
578 self.apply_turn_input(TurnExecutionInput::BudgetExhausted { run_id })
579 .map(|_| ())
580 }
581
582 fn time_budget_exceeded(&self, run_id: RunId) -> Result<(), DslTransitionError> {
583 self.apply_turn_input(TurnExecutionInput::TimeBudgetExceeded { run_id })
584 .map(|_| ())
585 }
586
587 fn force_cancel_no_run(&self) -> Result<(), DslTransitionError> {
588 self.dsl.apply_input(
590 mm_dsl::MeerkatMachineInput::ForceCancelNoRun,
591 "TurnStateHandle::force_cancel_no_run",
592 )
593 }
594
595 fn run_completed(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
596 Ok(())
601 }
602
603 fn run_failed(
604 &self,
605 _run_id: RunId,
606 _reason: TurnFailureReason,
607 ) -> Result<(), DslTransitionError> {
608 Ok(())
611 }
612
613 fn run_cancelled(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
614 Ok(())
617 }
618
619 #[allow(clippy::expect_used)]
620 fn snapshot(&self) -> TurnStateSnapshot {
621 let state = self.dsl.snapshot_state();
622 let turn_phase = map_turn_phase(state.turn_phase);
623 let barrier_operation_ids: BTreeSet<_> = state
624 .barrier_operation_ids
625 .iter()
626 .map(|id| parse_operation_id(id.0.as_str()))
627 .collect();
628 let pending_op_refs = state
629 .pending_op_refs
630 .iter()
631 .map(|id| {
632 let operation_id = parse_operation_id(id);
633 AsyncOpRef {
634 wait_policy: if barrier_operation_ids.contains(&operation_id) {
635 WaitPolicy::Barrier
636 } else {
637 WaitPolicy::Detached
638 },
639 operation_id,
640 }
641 })
642 .collect();
643 let turn_terminal = classify_turn_terminal(&state);
644 let active_run_id = if turn_terminal {
645 None
646 } else {
647 state.current_run_id.as_ref().map(parse_snapshot_run_id)
648 };
649 TurnStateSnapshot {
650 active_run_id,
651 loop_state: map_loop_state(state.turn_phase),
652 turn_phase,
653 turn_terminal,
654 primitive_kind: state.primitive_kind.map(TurnPrimitiveKind::from),
655 admitted_content_shape: state.admitted_content_shape.map(Into::into),
656 vision_enabled: state.vision_enabled,
657 image_tool_results_enabled: state.image_tool_results_enabled,
658 tool_calls_pending: state.tool_calls_pending,
659 pending_op_refs,
660 barrier_operation_ids,
661 has_barrier_ops: state.has_barrier_ops,
662 barrier_satisfied: state.barrier_satisfied,
663 boundary_count: state.boundary_count,
664 cancel_after_boundary: state.cancel_after_boundary,
665 terminal_outcome: state.terminal_outcome.map(TurnTerminalOutcome::from),
666 terminal_cause_kind: state.terminal_cause_kind.map(Into::into),
667 extraction_attempts: state.extraction_attempts,
668 max_extraction_retries: state.max_extraction_retries,
669 extraction_active: state.extraction_active,
670 llm_retry_attempt: u32::try_from(state.llm_retry_attempt)
671 .expect("generated MeerkatMachine llm_retry_attempt must fit u32"),
672 llm_retry_max_retries: u32::try_from(state.llm_retry_max_retries)
673 .expect("generated MeerkatMachine llm_retry_max_retries must fit u32"),
674 llm_retry_selected_delay_ms: state.llm_retry_selected_delay_ms,
675 }
676 }
677}
678
679#[allow(clippy::expect_used)]
680fn parse_operation_id(value: &str) -> OperationId {
681 uuid::Uuid::parse_str(value)
682 .map(OperationId)
683 .expect("generated MeerkatMachine operation id projection must be well formed")
684}
685
686#[allow(clippy::expect_used)]
687fn parse_snapshot_run_id(run_id: &mm_dsl::RunId) -> RunId {
688 uuid::Uuid::parse_str(&run_id.0)
689 .map(RunId::from_uuid)
690 .expect("generated MeerkatMachine current_run_id projection must be well formed")
691}
692
693fn classify_turn_terminal(state: &mm_dsl::MeerkatMachineState) -> bool {
703 let Ok(mut authority) = mm_dsl::MeerkatMachineAuthority::recover_from_state(state.clone())
704 else {
705 return true;
706 };
707 let Ok(transition) = mm_dsl::MeerkatMachineMutator::apply(
708 &mut authority,
709 mm_dsl::MeerkatMachineInput::ClassifyTurnTerminality {},
710 ) else {
711 return true;
712 };
713 let mut classified = None;
714 for effect in transition.effects() {
715 if let mm_dsl::MeerkatMachineEffect::TurnTerminalityClassified { terminal } = effect
716 && classified.replace(*terminal).is_some()
717 {
718 return true;
719 }
720 }
721 classified.unwrap_or(true)
722}
723
724fn map_turn_phase(phase: mm_dsl::TurnPhase) -> TurnPhase {
729 match phase {
730 mm_dsl::TurnPhase::Ready => TurnPhase::Ready,
731 mm_dsl::TurnPhase::ApplyingPrimitive => TurnPhase::ApplyingPrimitive,
732 mm_dsl::TurnPhase::CallingLlm => TurnPhase::CallingLlm,
733 mm_dsl::TurnPhase::WaitingForOps => TurnPhase::WaitingForOps,
734 mm_dsl::TurnPhase::DrainingBoundary => TurnPhase::DrainingBoundary,
735 mm_dsl::TurnPhase::Extracting => TurnPhase::Extracting,
736 mm_dsl::TurnPhase::ErrorRecovery => TurnPhase::ErrorRecovery,
737 mm_dsl::TurnPhase::Cancelling => TurnPhase::Cancelling,
738 mm_dsl::TurnPhase::Completed => TurnPhase::Completed,
739 mm_dsl::TurnPhase::Failed => TurnPhase::Failed,
740 mm_dsl::TurnPhase::Cancelled => TurnPhase::Cancelled,
741 }
742}
743
744fn map_loop_state(phase: mm_dsl::TurnPhase) -> meerkat_core::LoopState {
748 match phase {
749 mm_dsl::TurnPhase::Ready
750 | mm_dsl::TurnPhase::ApplyingPrimitive
751 | mm_dsl::TurnPhase::CallingLlm => meerkat_core::LoopState::CallingLlm,
752 mm_dsl::TurnPhase::WaitingForOps => meerkat_core::LoopState::WaitingForOps,
753 mm_dsl::TurnPhase::DrainingBoundary | mm_dsl::TurnPhase::Extracting => {
754 meerkat_core::LoopState::DrainingEvents
755 }
756 mm_dsl::TurnPhase::ErrorRecovery => meerkat_core::LoopState::ErrorRecovery,
757 mm_dsl::TurnPhase::Cancelling => meerkat_core::LoopState::Cancelling,
758 mm_dsl::TurnPhase::Completed | mm_dsl::TurnPhase::Failed | mm_dsl::TurnPhase::Cancelled => {
759 meerkat_core::LoopState::Completed
760 }
761 }
762}
763
764#[cfg(test)]
765#[allow(clippy::unwrap_used)]
766mod tests {
767 use super::*;
768 use meerkat_core::retry::{
769 LlmRetryFailure, LlmRetryFailureKind, LlmRetryPlan, LlmRetrySchedule,
770 };
771 use uuid::Uuid;
772
773 fn retry_schedule(attempt: u32) -> LlmRetrySchedule {
774 retry_schedule_with_kind(attempt, 3, LlmRetryFailureKind::RateLimited)
775 }
776
777 fn retry_schedule_with_kind(
778 attempt: u32,
779 max_retries: u32,
780 kind: LlmRetryFailureKind,
781 ) -> LlmRetrySchedule {
782 LlmRetrySchedule {
783 failure: LlmRetryFailure {
784 provider: "test".to_string(),
785 kind,
786 retry_after_ms: Some(1_000),
787 duration_ms: None,
788 message: "rate limited".to_string(),
789 },
790 plan: LlmRetryPlan {
791 attempt,
792 max_retries,
793 computed_delay_ms: 500,
794 selected_delay_ms: 1_000,
795 retry_after_hint_ms: Some(1_000),
796 rate_limit_floor_applied: false,
797 budget_capped: false,
798 },
799 }
800 }
801
802 fn start_running_conversation_turn(handle: &RuntimeTurnStateHandle, run_id: &RunId) {
803 handle
804 .start_conversation_run(
805 run_id.clone(),
806 TurnPrimitiveKind::ConversationTurn,
807 meerkat_core::turn_execution_authority::ContentShape::Conversation,
808 false,
809 false,
810 0,
811 )
812 .unwrap();
813 handle.primitive_applied(run_id.clone()).unwrap();
814 }
815
816 fn unknown_failure_source(message: &'static str) -> TurnFailureSource {
817 TurnFailureSource::new(TurnFailureSourceKind::Unknown, message)
818 }
819
820 fn failure_source(
821 source_kind: TurnFailureSourceKind,
822 message: &'static str,
823 ) -> TurnFailureSource {
824 TurnFailureSource::new(source_kind, message)
825 }
826
827 #[test]
828 fn snapshot_carries_active_run_id_for_runtime_backed_turns() {
829 let handle = RuntimeTurnStateHandle::ephemeral();
830 let run_id = RunId(Uuid::from_u128(7));
831
832 handle
833 .start_conversation_run(
834 run_id.clone(),
835 TurnPrimitiveKind::ConversationTurn,
836 meerkat_core::turn_execution_authority::ContentShape::Conversation,
837 true,
838 false,
839 2,
840 )
841 .unwrap();
842
843 let snapshot = handle.snapshot();
844 assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
845 assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
846 assert_eq!(
847 snapshot.primitive_kind,
848 Some(TurnPrimitiveKind::ConversationTurn)
849 );
850 }
851
852 #[test]
853 fn primitive_applied_rejects_mismatched_run_id() {
854 let handle = RuntimeTurnStateHandle::ephemeral();
855 let run_id = RunId(Uuid::from_u128(21));
856 let stale_run_id = RunId(Uuid::from_u128(22));
857
858 handle
859 .start_conversation_run(
860 run_id.clone(),
861 TurnPrimitiveKind::ConversationTurn,
862 meerkat_core::turn_execution_authority::ContentShape::Conversation,
863 false,
864 false,
865 0,
866 )
867 .unwrap();
868
869 assert!(handle.primitive_applied(stale_run_id).is_err());
870 let snapshot = handle.snapshot();
871 assert_eq!(snapshot.active_run_id, Some(run_id));
872 assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
873 }
874
875 #[test]
876 fn post_primitive_observation_rejects_mismatched_run_id() {
877 let handle = RuntimeTurnStateHandle::ephemeral();
878 let run_id = RunId(Uuid::from_u128(23));
879 let stale_run_id = RunId(Uuid::from_u128(24));
880
881 handle
882 .start_conversation_run(
883 run_id.clone(),
884 TurnPrimitiveKind::ConversationTurn,
885 meerkat_core::turn_execution_authority::ContentShape::Conversation,
886 false,
887 false,
888 0,
889 )
890 .unwrap();
891 handle.primitive_applied(run_id.clone()).unwrap();
892
893 assert!(handle.llm_returned_terminal(stale_run_id).is_err());
894 let snapshot = handle.snapshot();
895 assert_eq!(snapshot.active_run_id, Some(run_id));
896 assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
897 }
898
899 #[test]
900 fn snapshot_clears_active_run_id_after_terminal_turn() {
901 let handle = RuntimeTurnStateHandle::ephemeral();
902 let run_id = RunId(Uuid::from_u128(8));
903
904 handle
905 .start_conversation_run(
906 run_id.clone(),
907 TurnPrimitiveKind::ConversationTurn,
908 meerkat_core::turn_execution_authority::ContentShape::Conversation,
909 false,
910 false,
911 0,
912 )
913 .unwrap();
914 handle.primitive_applied(run_id.clone()).unwrap();
915 handle.llm_returned_terminal(run_id.clone()).unwrap();
916 handle.boundary_complete(run_id).unwrap();
917
918 let snapshot = handle.snapshot();
919 assert_eq!(snapshot.turn_phase, TurnPhase::Completed);
920 assert_eq!(snapshot.active_run_id, None);
921 }
922
923 #[test]
924 fn cancel_after_boundary_cancels_continuation_boundary() {
925 let handle = RuntimeTurnStateHandle::ephemeral();
926 let run_id = RunId(Uuid::from_u128(18));
927
928 handle
929 .start_conversation_run(
930 run_id.clone(),
931 TurnPrimitiveKind::ConversationTurn,
932 meerkat_core::turn_execution_authority::ContentShape::Conversation,
933 false,
934 false,
935 0,
936 )
937 .unwrap();
938 handle.primitive_applied(run_id.clone()).unwrap();
939 handle.llm_returned_tool_calls(run_id.clone(), 1).unwrap();
940 handle
941 .register_pending_ops(run_id.clone(), BTreeSet::new(), BTreeSet::new())
942 .unwrap();
943 handle.tool_calls_resolved(run_id.clone()).unwrap();
944 handle
945 .request_cancel_after_boundary(run_id.clone())
946 .unwrap();
947 handle.boundary_continue(run_id).unwrap();
948
949 let snapshot = handle.snapshot();
950 assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
951 assert_eq!(
952 snapshot.terminal_outcome,
953 Some(TurnTerminalOutcome::Cancelled)
954 );
955 assert!(!snapshot.cancel_after_boundary);
956 assert_eq!(snapshot.active_run_id, None);
957 }
958
959 #[test]
960 fn cancel_after_boundary_cancels_terminal_boundary() {
961 let handle = RuntimeTurnStateHandle::ephemeral();
962 let run_id = RunId(Uuid::from_u128(19));
963
964 handle
965 .start_conversation_run(
966 run_id.clone(),
967 TurnPrimitiveKind::ConversationTurn,
968 meerkat_core::turn_execution_authority::ContentShape::Conversation,
969 false,
970 false,
971 0,
972 )
973 .unwrap();
974 handle.primitive_applied(run_id.clone()).unwrap();
975 handle.llm_returned_terminal(run_id.clone()).unwrap();
976 handle
977 .request_cancel_after_boundary(run_id.clone())
978 .unwrap();
979 handle.boundary_complete(run_id).unwrap();
980
981 let snapshot = handle.snapshot();
982 assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
983 assert_eq!(
984 snapshot.terminal_outcome,
985 Some(TurnTerminalOutcome::Cancelled)
986 );
987 assert!(!snapshot.cancel_after_boundary);
988 assert_eq!(snapshot.active_run_id, None);
989 }
990
991 #[test]
992 fn immediate_append_derives_content_shape() {
993 let handle = RuntimeTurnStateHandle::ephemeral();
994 let run_id = RunId(Uuid::from_u128(10));
995
996 handle.start_immediate_append(run_id).unwrap();
997
998 assert_eq!(
999 handle.snapshot().admitted_content_shape,
1000 Some(meerkat_core::turn_execution_authority::ContentShape::ImmediateAppend)
1001 );
1002 }
1003
1004 #[test]
1005 fn cancel_after_boundary_cancels_immediate_boundary() {
1006 let handle = RuntimeTurnStateHandle::ephemeral();
1007 let run_id = RunId(Uuid::from_u128(20));
1008
1009 handle.start_immediate_append(run_id.clone()).unwrap();
1010 handle
1011 .request_cancel_after_boundary(run_id.clone())
1012 .unwrap();
1013 handle.primitive_applied(run_id).unwrap();
1014
1015 let snapshot = handle.snapshot();
1016 assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
1017 assert_eq!(
1018 snapshot.terminal_outcome,
1019 Some(TurnTerminalOutcome::Cancelled)
1020 );
1021 assert!(!snapshot.cancel_after_boundary);
1022 assert_eq!(snapshot.active_run_id, None);
1023 }
1024
1025 #[test]
1026 fn retry_schedule_is_recorded_and_attempt_guarded() {
1027 let handle = RuntimeTurnStateHandle::ephemeral();
1028 let run_id = RunId(Uuid::from_u128(9));
1029
1030 handle
1031 .start_conversation_run(
1032 run_id.clone(),
1033 TurnPrimitiveKind::ConversationTurn,
1034 meerkat_core::turn_execution_authority::ContentShape::Conversation,
1035 false,
1036 false,
1037 0,
1038 )
1039 .unwrap();
1040 handle.primitive_applied(run_id.clone()).unwrap();
1041
1042 handle
1043 .recoverable_failure(run_id.clone(), retry_schedule(2))
1044 .unwrap();
1045
1046 let snapshot = handle.snapshot();
1047 assert_eq!(snapshot.turn_phase, TurnPhase::ErrorRecovery);
1048 assert_eq!(snapshot.llm_retry_attempt, 2);
1049 assert_eq!(snapshot.llm_retry_max_retries, 3);
1050 assert_eq!(snapshot.llm_retry_selected_delay_ms, 1_000);
1051
1052 assert!(handle.retry_requested(run_id.clone(), 1).is_err());
1053 handle.retry_requested(run_id, 2).unwrap();
1054 assert_eq!(handle.snapshot().turn_phase, TurnPhase::CallingLlm);
1055 }
1056
1057 #[test]
1062 fn recoverable_failure_past_exhaustion_is_machine_rejected() {
1063 let handle = RuntimeTurnStateHandle::ephemeral();
1064 let run_id = RunId(Uuid::from_u128(31));
1065 start_running_conversation_turn(&handle, &run_id);
1066
1067 let exhausted = retry_schedule_with_kind(4, 3, LlmRetryFailureKind::RateLimited);
1069 let err = handle
1070 .recoverable_failure(run_id.clone(), exhausted)
1071 .expect_err("exhausted retry must be rejected by the machine");
1072 assert!(err.is_guard_rejected(), "expected guard rejection: {err:?}");
1073
1074 let snapshot = handle.snapshot();
1076 assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
1077 assert_eq!(snapshot.llm_retry_attempt, 0);
1078
1079 let last = retry_schedule_with_kind(3, 3, LlmRetryFailureKind::NetworkTimeout);
1082 handle.recoverable_failure(run_id, last).unwrap();
1083 let snapshot = handle.snapshot();
1084 assert_eq!(snapshot.turn_phase, TurnPhase::ErrorRecovery);
1085 assert_eq!(snapshot.llm_retry_attempt, 3);
1086 assert_eq!(snapshot.llm_retry_max_retries, 3);
1087 }
1088
1089 #[test]
1090 fn fatal_failure_unknown_source_rejects_before_machine_apply() {
1091 let handle = RuntimeTurnStateHandle::ephemeral();
1092 let run_id = RunId(Uuid::from_u128(11));
1093
1094 handle
1095 .start_conversation_run(
1096 run_id.clone(),
1097 TurnPrimitiveKind::ConversationTurn,
1098 meerkat_core::turn_execution_authority::ContentShape::Conversation,
1099 false,
1100 false,
1101 0,
1102 )
1103 .unwrap();
1104
1105 let err = handle
1106 .fatal_failure(
1107 run_id.clone(),
1108 unknown_failure_source("display text must not classify fatal failure"),
1109 )
1110 .expect_err("unknown fatal source should reject before state mutation");
1111
1112 assert!(err.is_guard_rejected(), "expected guard rejection: {err:?}");
1113 let snapshot = handle.snapshot();
1114 assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1115 assert_eq!(snapshot.terminal_cause_kind, None);
1116
1117 handle
1118 .fatal_failure(
1119 run_id,
1120 failure_source(TurnFailureSourceKind::InternalError, "fatal failure"),
1121 )
1122 .expect("specific fatal source should remain accepted");
1123 assert_eq!(
1124 handle.snapshot().terminal_cause_kind,
1125 Some(meerkat_core::TurnTerminalCauseKind::FatalFailure)
1126 );
1127 }
1128
1129 #[test]
1130 fn run_failed_effect_does_not_terminalize_runtime_state() {
1131 let handle = RuntimeTurnStateHandle::ephemeral();
1132 let run_id = RunId(Uuid::from_u128(12));
1133
1134 handle
1135 .start_conversation_run(
1136 run_id.clone(),
1137 TurnPrimitiveKind::ConversationTurn,
1138 meerkat_core::turn_execution_authority::ContentShape::Conversation,
1139 false,
1140 false,
1141 0,
1142 )
1143 .unwrap();
1144
1145 handle
1146 .run_failed(
1147 run_id.clone(),
1148 TurnFailureReason::with_cause(
1149 meerkat_core::TurnTerminalCauseKind::Unknown,
1150 meerkat_core::event::AgentErrorClass::Internal,
1151 "display text must not classify run failure",
1152 ),
1153 )
1154 .expect("runtime-backed run_failed effect is observation-only");
1155
1156 let snapshot = handle.snapshot();
1157 assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
1158 assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1159 assert_eq!(snapshot.terminal_cause_kind, None);
1160
1161 handle
1162 .run_completed(run_id.clone())
1163 .expect("runtime-backed run_completed effect is observation-only");
1164 handle
1165 .run_cancelled(run_id)
1166 .expect("runtime-backed run_cancelled effect is observation-only");
1167 let snapshot = handle.snapshot();
1168 assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1169 assert_eq!(snapshot.terminal_cause_kind, None);
1170 }
1171}