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