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