Skip to main content

meerkat_core/lifecycle/
run_primitive.rs

1//! §18 Run primitives — the ONLY input core receives from the runtime layer.
2//!
3//! Core's entire world is: conversation mutations, run boundaries, and staged inputs.
4//! It knows nothing about input acceptance, policy, queueing, or topology.
5
6use serde::{Deserialize, Serialize};
7
8use super::identifiers::InputId;
9use crate::service::TurnToolOverlay;
10use crate::skills::SkillKey;
11use crate::types::{HandlingMode, RenderMetadata};
12
13/// When to apply a conversation mutation relative to the run lifecycle.
14#[non_exhaustive]
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RunApplyBoundary {
18    /// Apply immediately (no run boundary required).
19    Immediate,
20    /// Apply at the start of the next run.
21    RunStart,
22    /// Apply at the next checkpoint within a run.
23    RunCheckpoint,
24}
25
26/// Renderable content that can be appended to a conversation.
27#[non_exhaustive]
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum CoreRenderable {
31    /// Plain text content.
32    Text { text: String },
33    /// Multimodal content blocks (text + images).
34    Blocks {
35        blocks: Vec<crate::types::ContentBlock>,
36    },
37    /// JSON-structured content. Uses `Value` because the runtime layer constructs
38    /// these from various typed sources (peer messages, external events) and core
39    /// needs to render them into conversation messages — not a pass-through boundary.
40    Json { value: serde_json::Value },
41    /// Reference to an external artifact.
42    Reference { uri: String, label: Option<String> },
43}
44
45/// Which role to append to in the conversation.
46#[non_exhaustive]
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ConversationAppendRole {
50    /// User message.
51    User,
52    /// Assistant message.
53    Assistant,
54    /// System notice (injected context).
55    SystemNotice,
56    /// Tool result.
57    Tool,
58}
59
60/// A single conversation append operation.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ConversationAppend {
63    /// The role for this message.
64    pub role: ConversationAppendRole,
65    /// The content to append.
66    pub content: CoreRenderable,
67}
68
69/// A context-only append (system context, not user-facing).
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ConversationContextAppend {
72    /// Key for deduplication/replacement.
73    pub key: String,
74    /// The context content.
75    pub content: CoreRenderable,
76}
77
78/// Typed execution intent classified by the runtime layer.
79///
80/// The runtime stamps this on `RuntimeTurnMetadata` so the session layer can
81/// dispatch `run_turn` vs `run_pending` from typed intent rather than inferring
82/// from prompt emptiness.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum RuntimeExecutionKind {
86    /// Ordinary content turn: prompts, peer messages/requests/terminal-responses,
87    /// external events, flow steps.
88    ContentTurn,
89    /// Explicit continuation that resumes pending work at a boundary.
90    ResumePending,
91}
92
93/// An input staged for application at a run boundary.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
95pub struct RuntimeTurnMetadata {
96    /// Handling mode for staged ordinary work when admitted through runtime.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub handling_mode: Option<HandlingMode>,
99    /// `None` = use session default; `Some(true)` = force keep-alive; `Some(false)` = force non-keep-alive.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub keep_alive: Option<bool>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub skill_references: Option<Vec<SkillKey>>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub flow_tool_overlay: Option<TurnToolOverlay>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub additional_instructions: Option<Vec<String>>,
108    /// Override model for this turn (hot-swap on materialized sessions).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub model: Option<String>,
111    /// Override provider for this turn (hot-swap on materialized sessions).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub provider: Option<String>,
114    /// Override provider-specific parameters for this turn.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub provider_params: Option<serde_json::Value>,
117    /// Optional normalized rendering metadata for this turn.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub render_metadata: Option<RenderMetadata>,
120    /// Typed execution intent classified by the runtime layer.
121    ///
122    /// `None` means the session layer should use its existing heuristic
123    /// (backward compat for non-runtime substrate-direct paths).
124    /// `Some(ContentTurn)` forces `run_turn`.
125    /// `Some(ResumePending)` forces `run_pending`.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub execution_kind: Option<RuntimeExecutionKind>,
128}
129
130/// An input staged for application at a run boundary.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct StagedRunInput {
133    /// When to apply this input.
134    pub boundary: RunApplyBoundary,
135    /// Conversation mutations to apply.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub appends: Vec<ConversationAppend>,
138    /// Context-only appends.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub context_appends: Vec<ConversationContextAppend>,
141    /// Input IDs contributing to this staged input (opaque to core).
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub contributing_input_ids: Vec<InputId>,
144    /// Optional turn semantics that must survive crash recovery.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub turn_metadata: Option<RuntimeTurnMetadata>,
147}
148
149/// The ONLY type core receives from the runtime layer for run execution.
150///
151/// This is the complete interface between the runtime control-plane and core.
152/// Core does not know about Input, InputState, PolicyDecision, or any
153/// runtime-layer types. It only sees this.
154#[non_exhaustive]
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "primitive_type", rename_all = "snake_case")]
157pub enum RunPrimitive {
158    /// Apply conversation mutations at a boundary.
159    StagedInput(StagedRunInput),
160    /// Inject content immediately (no boundary required).
161    ImmediateAppend(ConversationAppend),
162    /// Inject context immediately.
163    ImmediateContextAppend(ConversationContextAppend),
164}
165
166impl RunPrimitive {
167    /// Get all contributing input IDs (if any).
168    pub fn contributing_input_ids(&self) -> &[InputId] {
169        match self {
170            RunPrimitive::StagedInput(staged) => &staged.contributing_input_ids,
171            RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => &[],
172        }
173    }
174
175    pub fn turn_metadata(&self) -> Option<&RuntimeTurnMetadata> {
176        match self {
177            RunPrimitive::StagedInput(staged) => staged.turn_metadata.as_ref(),
178            RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => None,
179        }
180    }
181
182    /// Extract content input from this primitive's conversation appends.
183    ///
184    /// Consolidates the 5 near-identical `extract_prompt` / `extract_runtime_prompt`
185    /// functions that were duplicated across RPC, REST, MCP, mob, and CLI surfaces.
186    pub fn extract_content_input(&self) -> crate::types::ContentInput {
187        use crate::types::{ContentBlock, ContentInput};
188        match self {
189            RunPrimitive::StagedInput(staged) => {
190                let mut all_blocks = Vec::new();
191                for append in &staged.appends {
192                    match &append.content {
193                        CoreRenderable::Text { text } => {
194                            all_blocks.push(ContentBlock::Text { text: text.clone() });
195                        }
196                        CoreRenderable::Blocks { blocks } => {
197                            all_blocks.extend(blocks.iter().cloned());
198                        }
199                        _ => {}
200                    }
201                }
202                if all_blocks.is_empty() {
203                    ContentInput::Text(String::new())
204                } else if all_blocks.len() == 1 {
205                    if let ContentBlock::Text { text } = &all_blocks[0] {
206                        ContentInput::Text(text.clone())
207                    } else {
208                        ContentInput::Blocks(all_blocks)
209                    }
210                } else {
211                    ContentInput::Blocks(all_blocks)
212                }
213            }
214            RunPrimitive::ImmediateAppend(append) => match &append.content {
215                CoreRenderable::Text { text } => ContentInput::Text(text.clone()),
216                CoreRenderable::Blocks { blocks } => ContentInput::Blocks(blocks.clone()),
217                _ => ContentInput::Text(String::new()),
218            },
219            RunPrimitive::ImmediateContextAppend(ctx) => match &ctx.content {
220                CoreRenderable::Text { text } => ContentInput::Text(text.clone()),
221                CoreRenderable::Blocks { blocks } => ContentInput::Blocks(blocks.clone()),
222                _ => ContentInput::Text(String::new()),
223            },
224        }
225    }
226
227    /// Return the canonical runtime apply boundary for this primitive.
228    pub fn apply_boundary(&self) -> RunApplyBoundary {
229        match self {
230            RunPrimitive::StagedInput(staged) => staged.boundary,
231            RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => {
232                RunApplyBoundary::Immediate
233            }
234        }
235    }
236
237    /// Whether this primitive is a context-only staged input that should be
238    /// routed to `apply_runtime_context_appends` rather than a full turn.
239    pub fn is_context_only_immediate(&self) -> bool {
240        matches!(
241            self,
242            RunPrimitive::StagedInput(staged)
243            if staged.appends.is_empty()
244                && !staged.context_appends.is_empty()
245                && staged.boundary == RunApplyBoundary::Immediate
246        )
247    }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn run_apply_boundary_serde_roundtrip() {
257        for boundary in [
258            RunApplyBoundary::Immediate,
259            RunApplyBoundary::RunStart,
260            RunApplyBoundary::RunCheckpoint,
261        ] {
262            let json = serde_json::to_value(boundary).unwrap();
263            let parsed: RunApplyBoundary = serde_json::from_value(json).unwrap();
264            assert_eq!(boundary, parsed);
265        }
266    }
267
268    #[test]
269    fn core_renderable_text_serde() {
270        let r = CoreRenderable::Text {
271            text: "hello".into(),
272        };
273        let json = serde_json::to_value(&r).unwrap();
274        assert_eq!(json["type"], "text");
275        assert_eq!(json["text"], "hello");
276        let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
277        assert_eq!(r, parsed);
278    }
279
280    #[test]
281    fn core_renderable_json_serde() {
282        let r = CoreRenderable::Json {
283            value: serde_json::json!({"key": "val"}),
284        };
285        let json = serde_json::to_value(&r).unwrap();
286        assert_eq!(json["type"], "json");
287        let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
288        assert_eq!(r, parsed);
289    }
290
291    // --- extract_content_input tests ---
292
293    fn make_staged(appends: Vec<ConversationAppend>) -> RunPrimitive {
294        RunPrimitive::StagedInput(StagedRunInput {
295            boundary: RunApplyBoundary::RunStart,
296            appends,
297            context_appends: vec![],
298            contributing_input_ids: vec![],
299            turn_metadata: None,
300        })
301    }
302
303    #[test]
304    fn extract_content_from_staged_text() {
305        let p = make_staged(vec![ConversationAppend {
306            role: ConversationAppendRole::User,
307            content: CoreRenderable::Text {
308                text: "hello".into(),
309            },
310        }]);
311        assert_eq!(
312            p.extract_content_input(),
313            crate::types::ContentInput::Text("hello".into())
314        );
315    }
316
317    #[test]
318    fn extract_content_from_staged_blocks() {
319        let p = make_staged(vec![ConversationAppend {
320            role: ConversationAppendRole::User,
321            content: CoreRenderable::Blocks {
322                blocks: vec![
323                    crate::types::ContentBlock::Text { text: "a".into() },
324                    crate::types::ContentBlock::Text { text: "b".into() },
325                ],
326            },
327        }]);
328        let result = p.extract_content_input();
329        assert!(
330            matches!(&result, crate::types::ContentInput::Blocks(blocks) if blocks.len() == 2),
331            "expected Blocks with 2 elements, got {result:?}"
332        );
333    }
334
335    #[test]
336    fn extract_content_from_staged_empty() {
337        let p = make_staged(vec![]);
338        assert_eq!(
339            p.extract_content_input(),
340            crate::types::ContentInput::Text(String::new())
341        );
342    }
343
344    #[test]
345    fn extract_content_single_text_block_collapses() {
346        let p = make_staged(vec![ConversationAppend {
347            role: ConversationAppendRole::User,
348            content: CoreRenderable::Blocks {
349                blocks: vec![crate::types::ContentBlock::Text {
350                    text: "single".into(),
351                }],
352            },
353        }]);
354        assert_eq!(
355            p.extract_content_input(),
356            crate::types::ContentInput::Text("single".into())
357        );
358    }
359
360    // --- is_context_only_immediate tests ---
361
362    #[test]
363    fn context_only_immediate_true() {
364        let p = RunPrimitive::StagedInput(StagedRunInput {
365            boundary: RunApplyBoundary::Immediate,
366            appends: vec![],
367            context_appends: vec![ConversationContextAppend {
368                key: "k".into(),
369                content: CoreRenderable::Text { text: "ctx".into() },
370            }],
371            contributing_input_ids: vec![],
372            turn_metadata: None,
373        });
374        assert!(p.is_context_only_immediate());
375    }
376
377    #[test]
378    fn context_only_immediate_false_with_appends() {
379        let p = RunPrimitive::StagedInput(StagedRunInput {
380            boundary: RunApplyBoundary::Immediate,
381            appends: vec![ConversationAppend {
382                role: ConversationAppendRole::User,
383                content: CoreRenderable::Text { text: "hi".into() },
384            }],
385            context_appends: vec![ConversationContextAppend {
386                key: "k".into(),
387                content: CoreRenderable::Text { text: "ctx".into() },
388            }],
389            contributing_input_ids: vec![],
390            turn_metadata: None,
391        });
392        assert!(!p.is_context_only_immediate());
393    }
394
395    #[test]
396    fn context_only_immediate_false_wrong_boundary() {
397        let p = RunPrimitive::StagedInput(StagedRunInput {
398            boundary: RunApplyBoundary::RunCheckpoint,
399            appends: vec![],
400            context_appends: vec![ConversationContextAppend {
401                key: "k".into(),
402                content: CoreRenderable::Text { text: "ctx".into() },
403            }],
404            contributing_input_ids: vec![],
405            turn_metadata: None,
406        });
407        assert!(!p.is_context_only_immediate());
408    }
409
410    #[test]
411    fn non_staged_is_not_context_only() {
412        let p = RunPrimitive::ImmediateAppend(ConversationAppend {
413            role: ConversationAppendRole::User,
414            content: CoreRenderable::Text { text: "hi".into() },
415        });
416        assert!(!p.is_context_only_immediate());
417    }
418
419    #[test]
420    fn core_renderable_reference_serde() {
421        let r = CoreRenderable::Reference {
422            uri: "file:///tmp/a.txt".into(),
423            label: Some("a file".into()),
424        };
425        let json = serde_json::to_value(&r).unwrap();
426        assert_eq!(json["type"], "reference");
427        let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
428        assert_eq!(r, parsed);
429    }
430
431    #[test]
432    fn execution_kind_serde_round_trip() {
433        for kind in [
434            RuntimeExecutionKind::ContentTurn,
435            RuntimeExecutionKind::ResumePending,
436        ] {
437            let json = serde_json::to_value(kind).unwrap();
438            let parsed: RuntimeExecutionKind = serde_json::from_value(json.clone()).unwrap();
439            assert_eq!(kind, parsed);
440        }
441        // Verify snake_case naming
442        assert_eq!(
443            serde_json::to_value(RuntimeExecutionKind::ContentTurn).unwrap(),
444            serde_json::Value::String("content_turn".into())
445        );
446        assert_eq!(
447            serde_json::to_value(RuntimeExecutionKind::ResumePending).unwrap(),
448            serde_json::Value::String("resume_pending".into())
449        );
450    }
451
452    #[test]
453    fn turn_metadata_execution_kind_defaults_to_none() {
454        let meta = RuntimeTurnMetadata::default();
455        assert_eq!(meta.execution_kind, None);
456    }
457
458    #[test]
459    fn turn_metadata_execution_kind_round_trips() {
460        let meta = RuntimeTurnMetadata {
461            execution_kind: Some(RuntimeExecutionKind::ContentTurn),
462            ..Default::default()
463        };
464        let json = serde_json::to_value(&meta).unwrap();
465        assert_eq!(json["execution_kind"], "content_turn");
466        let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
467        assert_eq!(
468            parsed.execution_kind,
469            Some(RuntimeExecutionKind::ContentTurn)
470        );
471    }
472
473    #[test]
474    fn turn_metadata_without_execution_kind_deserializes() {
475        // Backward compat: old payloads without execution_kind deserialize to None
476        let json = serde_json::json!({});
477        let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
478        assert_eq!(parsed.execution_kind, None);
479    }
480
481    #[test]
482    fn conversation_append_role_serde() {
483        for role in [
484            ConversationAppendRole::User,
485            ConversationAppendRole::Assistant,
486            ConversationAppendRole::SystemNotice,
487            ConversationAppendRole::Tool,
488        ] {
489            let json = serde_json::to_value(role).unwrap();
490            let parsed: ConversationAppendRole = serde_json::from_value(json).unwrap();
491            assert_eq!(role, parsed);
492        }
493    }
494
495    #[test]
496    fn conversation_append_serde() {
497        let append = ConversationAppend {
498            role: ConversationAppendRole::User,
499            content: CoreRenderable::Text {
500                text: "hello".into(),
501            },
502        };
503        let json = serde_json::to_value(&append).unwrap();
504        let parsed: ConversationAppend = serde_json::from_value(json).unwrap();
505        assert_eq!(append, parsed);
506    }
507
508    #[test]
509    fn staged_run_input_serde() {
510        let staged = StagedRunInput {
511            boundary: RunApplyBoundary::RunStart,
512            appends: vec![ConversationAppend {
513                role: ConversationAppendRole::User,
514                content: CoreRenderable::Text {
515                    text: "prompt".into(),
516                },
517            }],
518            context_appends: vec![],
519            contributing_input_ids: vec![InputId::new()],
520            turn_metadata: Some(RuntimeTurnMetadata {
521                keep_alive: Some(true),
522                ..Default::default()
523            }),
524        };
525        let json = serde_json::to_value(&staged).unwrap();
526        let parsed: StagedRunInput = serde_json::from_value(json).unwrap();
527        assert_eq!(staged, parsed);
528    }
529
530    #[test]
531    fn run_primitive_staged_input_serde() {
532        let primitive = RunPrimitive::StagedInput(StagedRunInput {
533            boundary: RunApplyBoundary::RunStart,
534            appends: vec![],
535            context_appends: vec![],
536            contributing_input_ids: vec![InputId::new(), InputId::new()],
537            turn_metadata: None,
538        });
539        let json = serde_json::to_value(&primitive).unwrap();
540        assert_eq!(json["primitive_type"], "staged_input");
541        let parsed: RunPrimitive = serde_json::from_value(json).unwrap();
542        assert_eq!(primitive, parsed);
543    }
544
545    #[test]
546    fn run_primitive_immediate_append_serde() {
547        let primitive = RunPrimitive::ImmediateAppend(ConversationAppend {
548            role: ConversationAppendRole::SystemNotice,
549            content: CoreRenderable::Text {
550                text: "notice".into(),
551            },
552        });
553        let json = serde_json::to_value(&primitive).unwrap();
554        assert_eq!(json["primitive_type"], "immediate_append");
555        let parsed: RunPrimitive = serde_json::from_value(json).unwrap();
556        assert_eq!(primitive, parsed);
557    }
558
559    #[test]
560    fn run_primitive_contributing_input_ids() {
561        let ids = vec![InputId::new(), InputId::new()];
562        let primitive = RunPrimitive::StagedInput(StagedRunInput {
563            boundary: RunApplyBoundary::RunStart,
564            appends: vec![],
565            context_appends: vec![],
566            contributing_input_ids: ids.clone(),
567            turn_metadata: None,
568        });
569        assert_eq!(primitive.contributing_input_ids(), &ids);
570
571        let immediate = RunPrimitive::ImmediateAppend(ConversationAppend {
572            role: ConversationAppendRole::User,
573            content: CoreRenderable::Text { text: "hi".into() },
574        });
575        assert!(immediate.contributing_input_ids().is_empty());
576    }
577
578    #[test]
579    fn conversation_context_append_serde() {
580        let ctx = ConversationContextAppend {
581            key: "peers".into(),
582            content: CoreRenderable::Json {
583                value: serde_json::json!(["peer1", "peer2"]),
584            },
585        };
586        let json = serde_json::to_value(&ctx).unwrap();
587        let parsed: ConversationContextAppend = serde_json::from_value(json).unwrap();
588        assert_eq!(ctx, parsed);
589    }
590}