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