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::de::{self, DeserializeOwned};
7use serde::{Deserialize, Serialize};
8
9use super::identifiers::InputId;
10use crate::connection::AuthBindingRef;
11use crate::provider::Provider;
12use crate::service::TurnToolOverlay;
13use crate::skills::SkillKey;
14use crate::types::{
15    HandlingMode, RenderMetadata, SystemNoticeBlock, SystemNoticeKind, TranscriptMessageIdentity,
16};
17
18/// When to apply a conversation mutation relative to the run lifecycle.
19#[non_exhaustive]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum RunApplyBoundary {
23    /// Apply immediately (no run boundary required).
24    Immediate,
25    /// Apply at the start of the next run.
26    RunStart,
27    /// Apply at the next checkpoint within a run.
28    RunCheckpoint,
29}
30
31/// Renderable content that can be appended to a conversation.
32#[non_exhaustive]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum CoreRenderable {
37    /// Plain text content.
38    Text { text: String },
39    /// Multimodal content blocks (text + images).
40    Blocks {
41        blocks: Vec<crate::types::ContentBlock>,
42    },
43    /// JSON-structured content. Uses `Value` because the runtime layer constructs
44    /// these from various typed sources (peer messages, external events) and core
45    /// needs to render them into conversation messages — not a pass-through boundary.
46    Json { value: serde_json::Value },
47    /// Typed runtime-authored system notice content.
48    SystemNotice {
49        kind: SystemNoticeKind,
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        body: Option<String>,
52        #[serde(default, skip_serializing_if = "Vec::is_empty")]
53        blocks: Vec<SystemNoticeBlock>,
54    },
55    /// Reference to an external artifact.
56    Reference { uri: String, label: Option<String> },
57}
58
59impl CoreRenderable {
60    /// Construct a plain-text renderable.
61    ///
62    /// Convenience constructor for callers that only carry a `String` body
63    /// (the common runtime/system-context append case). Richer producers build
64    /// the `Blocks` / `SystemNotice` / `Json` / `Reference` variants directly.
65    #[must_use]
66    pub fn text(text: impl Into<String>) -> Self {
67        Self::Text { text: text.into() }
68    }
69
70    /// Render this content to its canonical plain-text projection.
71    ///
72    /// This is the single owner of the renderable -> text lowering used by
73    /// system-context surfaces; callers must not re-implement per-variant
74    /// flattening. Non-text variants project to their model-facing text form
75    /// (multimodal blocks collapse to their text, JSON pretty-prints, a
76    /// reference renders a `[Reference] ...` line, a system notice renders its
77    /// model-projection text).
78    #[must_use]
79    pub fn render_text(&self) -> String {
80        match self {
81            Self::Text { text } => text.clone(),
82            Self::Blocks { blocks } => crate::types::text_content(blocks),
83            Self::Json { value } => {
84                serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
85            }
86            Self::Reference { uri, label } => match label {
87                Some(label) if !label.trim().is_empty() => format!("[Reference] {label} ({uri})"),
88                _ => format!("[Reference] {uri}"),
89            },
90            Self::SystemNotice { kind, body, blocks } => {
91                crate::types::SystemNoticeMessage::with_blocks(*kind, body.clone(), blocks.clone())
92                    .model_projection_text()
93            }
94        }
95    }
96}
97
98/// Which role to append to in the conversation.
99#[non_exhaustive]
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ConversationAppendRole {
103    /// User message.
104    User,
105    /// Assistant message.
106    Assistant,
107    /// System notice (injected context).
108    SystemNotice,
109    /// Tool result.
110    Tool,
111    /// Host-attached injected context on the user channel.
112    ///
113    /// Lowers into a `Message::User` carrying
114    /// [`crate::types::TranscriptUserRole::InjectedContext`], so the typed
115    /// slot the content arrived in — not free-form role strings — mints the
116    /// transcript role.
117    InjectedContext,
118}
119
120/// A single conversation append operation.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ConversationAppend {
123    /// The role for this message.
124    pub role: ConversationAppendRole,
125    /// The content to append.
126    pub content: CoreRenderable,
127}
128
129/// A context-only append (system context, not user-facing).
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ConversationContextAppend {
132    /// Key for deduplication/replacement.
133    pub key: String,
134    /// The context content.
135    pub content: CoreRenderable,
136}
137
138/// Typed execution intent classified by the runtime layer.
139///
140/// The runtime stamps this on `RuntimeTurnMetadata` so the session layer can
141/// dispatch `run_turn` vs `run_pending` from typed intent rather than inferring
142/// from prompt emptiness.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum RuntimeExecutionKind {
146    /// Ordinary content turn: prompts, peer messages/requests/terminal-responses,
147    /// external events, flow steps.
148    ContentTurn,
149    /// Explicit continuation that resumes pending work at a boundary.
150    ResumePending,
151}
152
153/// Machine-owned apply intent for terminal peer responses.
154///
155/// Terminal peer responses are context facts and requester wake/reaction work.
156/// This closed intent prevents context-only executor shortcuts from inferring a
157/// different meaning from the primitive's append shape.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum PeerResponseTerminalApplyIntent {
161    /// Append the durable system-context fact, then run the requester reaction
162    /// turn using the appended context.
163    AppendContextAndRun,
164}
165
166/// Opaque model identifier carried by a per-turn override.
167///
168/// A bare string here is a failure of the typed-metadata invariant: validation
169/// against the catalog happens at the runtime boundary before `ModelId` is
170/// constructed. Construct via [`ModelId::new`].
171#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
172pub struct ModelId(String);
173
174impl ModelId {
175    pub fn new(s: impl Into<String>) -> Self {
176        Self(s.into())
177    }
178
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182}
183
184impl std::fmt::Display for ModelId {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(&self.0)
187    }
188}
189
190/// Keep-alive policy for a materialized session during a turn.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192pub struct KeepAlivePolicy {
193    #[serde(with = "duration_seconds")]
194    pub ttl: std::time::Duration,
195    pub policy: KeepAliveMode,
196}
197
198/// Keep-alive mode: pinned (caller-owned) or policy-driven (runtime sweeps).
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum KeepAliveMode {
202    Pinned,
203    PolicyDriven,
204}
205
206/// Per-turn keep-alive directive carried on [`RuntimeTurnMetadata`].
207///
208/// Together with the carrier's `Option`, this is the typed tri-state the
209/// generated `RuntimeKeepAliveRequest` admission input models:
210/// `Some(Enable(policy))` -> `Enable`, `Some(Disable)` -> `Disable` (explicit
211/// operator intent to turn keep-alive off), and `None` -> `Preserve` (the
212/// session's existing keep-alive stands unchanged). The former
213/// `Option<KeepAlivePolicy>` carrier had no `Disable` representation, so a
214/// caller's `keep_alive: false` was silently collapsed into `Preserve`.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(tag = "directive", rename_all = "snake_case")]
217pub enum KeepAliveDirective {
218    /// Enable keep-alive for the session with the given policy.
219    Enable(KeepAlivePolicy),
220    /// Explicitly disable keep-alive for the session.
221    Disable,
222}
223
224/// Single additional instruction attached to a turn.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct TurnInstruction {
227    pub kind: TurnInstructionKind,
228    pub body: String,
229}
230
231/// Typed category of [`TurnInstruction`].
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum TurnInstructionKind {
235    User,
236    System,
237    Host,
238}
239
240/// Typed non-semantic opaque bag for per-turn provider knobs that cannot be
241/// fully typed without blocking a wave boundary. Explicitly marked
242/// non-semantic and RMAT-exempt.
243///
244/// Use of this type is a deliberate boundary marker: content is passed
245/// through without interpretation. Any consumer that needs to interpret the
246/// content must promote the relevant structure into a proper typed variant
247/// in its own wave.
248///
249/// Relocated from `meerkat_contracts::wire::runtime` into core so
250/// `ProviderTag::Unknown { bag }` can name the bag without a cross-crate
251/// cycle (adversarial review flaw 5). `meerkat-contracts` re-exports this
252/// type so the wire path is preserved.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
254#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
255pub struct StructuredProviderExtension {
256    /// Free-form provider namespace discriminator (e.g. `"anthropic"`).
257    pub namespace: String,
258    /// Opaque key identifying the extension within the namespace.
259    pub key: String,
260    /// Opaque body. Non-semantic — never pattern matched across the wire.
261    #[cfg_attr(feature = "schema", schemars(with = "String"))]
262    #[serde(default)]
263    pub body: String,
264}
265
266/// Provider-specific typed override payload carried on a single turn.
267///
268/// Each provider family gets its own typed variant. Anything that does not
269/// fit a typed field belongs on the per-binding auth/backend profile, not
270/// on the per-turn override — the per-turn seam carries only scalars the
271/// runtime can route authoritatively.
272///
273/// `Unknown { bag }` is the typed escape hatch for V3 legacy-row
274/// deserialize (see C-TM-V3): the untyped `serde_json::Value` thinking
275/// carrier from pre-wave rows projects into `StructuredProviderExtension`
276/// rather than being silently dropped (persistence-migration.md §3.1,
277/// adversarial review flaw 5).
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[serde(tag = "provider", rename_all = "snake_case")]
281pub enum ProviderTag {
282    Anthropic(AnthropicProviderTag),
283    OpenAi(OpenAiProviderTag),
284    Gemini(GeminiProviderTag),
285    /// Opaque pass-through for legacy-row knobs that don't (yet) map to a
286    /// typed variant. Carries the namespaced bag so a later wave can
287    /// promote the structure to a typed variant without losing data.
288    Unknown {
289        bag: StructuredProviderExtension,
290    },
291}
292
293/// Opaque provider-native JSON body carried verbatim from caller to
294/// provider. Used for pass-through sub-shapes (web search config,
295/// provider-native custom compaction edits, OpenAI-compatible
296/// `chat_template_kwargs`/`thinking`/`reasoning` forwards) where the
297/// exact wire shape varies across downstream providers (Anthropic /
298/// DeepSeek / OpenRouter / custom proxies) and the runtime deliberately
299/// does not parse the body — it simply forwards it.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
301#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
302#[serde(transparent)]
303pub struct OpaqueProviderBody {
304    /// Serialized JSON body. Callers that have a `serde_json::Value`
305    /// should use [`OpaqueProviderBody::from_value`]. Providers reading
306    /// the body call [`OpaqueProviderBody::as_value`] to recover a
307    /// `Value` for wire emission.
308    #[cfg_attr(feature = "schema", schemars(with = "String"))]
309    pub body: String,
310}
311
312impl OpaqueProviderBody {
313    pub fn from_value(v: &serde_json::Value) -> Self {
314        Self {
315            body: v.to_string(),
316        }
317    }
318
319    pub fn as_value(&self) -> serde_json::Value {
320        serde_json::from_str(&self.body).unwrap_or(serde_json::Value::Null)
321    }
322}
323
324/// Typed shape of Anthropic's extended-thinking knob.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
327#[serde(tag = "type", rename_all = "snake_case")]
328pub enum AnthropicThinkingConfig {
329    /// Adaptive thinking — provider picks the budget.
330    Adaptive,
331    /// Explicit budget — model emits at most `budget_tokens` tokens of
332    /// reasoning before the assistant text.
333    Enabled { budget_tokens: u32 },
334}
335
336/// Typed shape of Anthropic's response-effort knob.
337/// `XHigh` is the Opus 4.8 / 4.7 extended-high effort level.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
340#[serde(rename_all = "snake_case")]
341pub enum AnthropicEffort {
342    Low,
343    Medium,
344    High,
345    Max,
346    XHigh,
347}
348
349impl AnthropicEffort {
350    pub fn as_legacy_str(self) -> &'static str {
351        match self {
352            Self::Low => "low",
353            Self::Medium => "medium",
354            Self::High => "high",
355            Self::Max => "max",
356            Self::XHigh => "xhigh",
357        }
358    }
359}
360
361/// Typed shape of Anthropic's data-residency knob.
362#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364#[serde(tag = "kind", rename_all = "snake_case")]
365pub enum AnthropicInferenceGeo {
366    Us,
367    Global,
368    /// Caller-provided region string — providers may accept region codes
369    /// this typed variant does not yet enumerate.
370    Other {
371        region: String,
372    },
373}
374
375/// Typed shape of Anthropic's context-window opt-in.
376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(rename_all = "snake_case")]
379pub enum AnthropicContextWindow {
380    /// 1M-token beta context window (2025-08-07 beta header).
381    OneMegabyte,
382}
383
384/// Typed shape of Anthropic's automatic-compaction knob.
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(tag = "kind", rename_all = "snake_case")]
388pub enum AnthropicCompactionConfig {
389    /// `"auto"` — provider picks trigger and instructions.
390    Auto,
391    /// Caller-provided edit body merged into the compact edit shape.
392    /// Fields like `trigger` / `instructions` are preserved verbatim.
393    Custom { edit: OpaqueProviderBody },
394}
395
396/// Typed shape of Anthropic's prompt-cache breakpoint policy.
397#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub enum AnthropicCacheControlPolicy {
401    /// Do not request Anthropic prompt-cache breakpoints.
402    Disabled,
403    /// Mark the stable top-level system prompt as an ephemeral cache prefix.
404    SystemPrefix,
405}
406
407/// Per-turn Anthropic-specific knobs carried in `ProviderTag::Anthropic`.
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
409#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
410#[serde(deny_unknown_fields)]
411pub struct AnthropicProviderTag {
412    /// Extended-thinking configuration.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub thinking: Option<AnthropicThinkingConfig>,
415    /// Legacy flat `thinking_budget_tokens` — preserved for V3
416    /// persistence round-trip (single-key legacy projector) and for
417    /// callers that cannot express the full `thinking` shape.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub thinking_budget_tokens: Option<u32>,
420    /// Provider-native web-search tool body, injected alongside
421    /// `tools` at the provider-runtime boundary.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub web_search: Option<OpaqueProviderBody>,
424    /// Override top-k sampling.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub top_k: Option<u32>,
427    /// Response-effort knob.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub effort: Option<AnthropicEffort>,
430    /// Structured-output schema (forces JSON-schema output envelope).
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub structured_output: Option<crate::OutputSchema>,
433    /// Data-residency override for inference routing.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub inference_geo: Option<AnthropicInferenceGeo>,
436    /// Automatic compaction configuration.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub compaction: Option<AnthropicCompactionConfig>,
439    /// Context-window opt-in (1M beta).
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub context: Option<AnthropicContextWindow>,
442    /// Prompt-cache breakpoint policy.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub cache_control: Option<AnthropicCacheControlPolicy>,
445    /// Internal override: force-enable temperature for this request even
446    /// when the model profile says unsupported. Used by proxied /
447    /// custom deployments.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub supports_temperature_override: Option<bool>,
450}
451
452/// Typed shape of OpenAI's prompt-cache retention hint.
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
454#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
455#[serde(rename_all = "snake_case")]
456pub enum OpenAiPromptCacheRetention {
457    /// Retain only in memory.
458    InMemory,
459    /// Retain for 24 hours.
460    #[serde(rename = "24h")]
461    TwentyFourHours,
462}
463
464/// Per-turn OpenAI-specific knobs carried in `ProviderTag::OpenAi`.
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
466#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
467#[serde(deny_unknown_fields)]
468pub struct OpenAiProviderTag {
469    /// Reasoning-effort level for o-series and GPT-5 models.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub reasoning_effort: Option<ReasoningEffort>,
472    /// Deterministic-sampling seed.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub seed: Option<i64>,
475    /// Frequency penalty (-2.0 .. 2.0).
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub frequency_penalty: Option<f32>,
478    /// Presence penalty (-2.0 .. 2.0).
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub presence_penalty: Option<f32>,
481    /// Provider-native web-search tool body, injected alongside `tools`.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub web_search: Option<OpaqueProviderBody>,
484    /// Structured-output schema (forces `text.format.json_schema`).
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub structured_output: Option<crate::OutputSchema>,
487    /// OpenAI-compatible endpoints (DeepSeek / OpenRouter / vLLM):
488    /// full `reasoning` body forwarded verbatim alongside
489    /// `reasoning_effort`.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub reasoning: Option<OpaqueProviderBody>,
492    /// OpenAI-compatible endpoints: `chat_template_kwargs` passthrough.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub chat_template_kwargs: Option<OpaqueProviderBody>,
495    /// OpenAI-compatible endpoints: vendor-specific `thinking` body
496    /// forwarded verbatim.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub thinking: Option<OpaqueProviderBody>,
499    /// Responses API persistence override. Absent means the client sends
500    /// `store: false` by default; callers may explicitly opt in with
501    /// `Some(true)`.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub store: Option<bool>,
504    /// OpenAI prompt-cache affinity routing key.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub prompt_cache_key: Option<String>,
507    /// OpenAI prompt-cache retention hint.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub prompt_cache_retention: Option<OpenAiPromptCacheRetention>,
510    /// Internal override: force-enable temperature for this request.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub supports_temperature_override: Option<bool>,
513    /// Internal override: force-enable reasoning payload for this
514    /// request (used by client_compatible endpoints).
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub supports_reasoning_override: Option<bool>,
517}
518
519/// Gemini 3 reasoning levels accepted by the API.
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
522#[serde(rename_all = "snake_case")]
523pub enum GeminiThinkingLevel {
524    Minimal,
525    Low,
526    Medium,
527    High,
528}
529
530impl GeminiThinkingLevel {
531    pub fn as_str(self) -> &'static str {
532        match self {
533            Self::Minimal => "minimal",
534            Self::Low => "low",
535            Self::Medium => "medium",
536            Self::High => "high",
537        }
538    }
539}
540
541/// Typed shape of Gemini's thinking knob.
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544#[serde(deny_unknown_fields)]
545pub struct GeminiThinkingConfig {
546    /// Whether reasoning output is included in the response.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub include_thoughts: Option<bool>,
549    /// Gemini 3 reasoning level.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub thinking_level: Option<GeminiThinkingLevel>,
552    /// Reasoning token budget.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub thinking_budget: Option<u32>,
555}
556
557/// Per-turn Gemini-specific knobs carried in `ProviderTag::Gemini`.
558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
559#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
560#[serde(deny_unknown_fields)]
561pub struct GeminiProviderTag {
562    /// Thinking configuration (Gemini 3+ models).
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub thinking: Option<GeminiThinkingConfig>,
565    /// Legacy flat `thinking_budget` — preserved for V3 persistence
566    /// round-trip.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub thinking_budget: Option<u32>,
569    /// Gemini 3 flat thinking level override.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub thinking_level: Option<GeminiThinkingLevel>,
572    /// Top-K sampling override.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub top_k: Option<u32>,
575    /// Top-P (nucleus) sampling override.
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub top_p: Option<f32>,
578    /// Structured-output schema.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub structured_output: Option<crate::OutputSchema>,
581    /// Provider-native google_search grounding tool body.
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub google_search: Option<OpaqueProviderBody>,
584    /// Number of candidate completions (runtime/persistence knob, not
585    /// read by the Gemini client today — preserved for V3 round-trip).
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub candidate_count: Option<u32>,
588    /// Gemini explicit context-cache resource name.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub cached_content_name: Option<String>,
591}
592
593/// Typed projection of OpenAI's reasoning-effort knob.
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
595#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
596#[serde(rename_all = "snake_case")]
597pub enum ReasoningEffort {
598    None,
599    Low,
600    #[default]
601    Medium,
602    High,
603    #[serde(rename = "xhigh")]
604    XHigh,
605}
606
607impl ReasoningEffort {
608    pub fn as_legacy_str(self) -> &'static str {
609        match self {
610            Self::None => "none",
611            Self::Low => "low",
612            Self::Medium => "medium",
613            Self::High => "high",
614            Self::XHigh => "xhigh",
615        }
616    }
617}
618
619/// Typed mode for generalized reasoning emission.
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
621#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
622#[serde(rename_all = "snake_case")]
623pub enum ReasoningMode {
624    /// Reasoning output is emitted inline to the caller.
625    Emit,
626    /// Reasoning is performed but not emitted.
627    Silent,
628    /// Reasoning is disabled entirely for this turn.
629    Off,
630}
631
632/// Typed per-turn provider parameter overrides.
633///
634/// Replaces the legacy untyped `serde_json::Value` bag. Every knob exposed
635/// by the runtime on a per-turn seam must have a typed field here. Anything
636/// provider-specific enough to not fit goes on [`ProviderTag`]; anything
637/// that is fundamentally per-binding (not per-turn) lives on the auth /
638/// backend profile and never traverses this seam.
639#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
641#[serde(deny_unknown_fields)]
642pub struct ProviderParamsOverride {
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub temperature: Option<f32>,
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub top_p: Option<f32>,
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub max_output_tokens: Option<u32>,
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub reasoning: Option<ReasoningMode>,
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub thinking_budget_tokens: Option<u32>,
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub provider_tag: Option<ProviderTag>,
655}
656
657impl ProviderParamsOverride {
658    pub fn is_empty(&self) -> bool {
659        self.temperature.is_none()
660            && self.top_p.is_none()
661            && self.max_output_tokens.is_none()
662            && self.reasoning.is_none()
663            && self.thinking_budget_tokens.is_none()
664            && self.provider_tag.is_none()
665    }
666
667    /// Clear any provider-native web-search / grounding tool body from this
668    /// override. Used when an extraction (deterministic, tool-free) turn must
669    /// suppress web search without re-deriving provider-native key names.
670    pub fn clear_web_search(&mut self) {
671        match self.provider_tag.as_mut() {
672            Some(ProviderTag::Anthropic(t)) => t.web_search = None,
673            Some(ProviderTag::OpenAi(t)) => t.web_search = None,
674            Some(ProviderTag::Gemini(t)) => t.google_search = None,
675            _ => {}
676        }
677    }
678
679    /// Inject the structured-output schema for an extraction turn into the
680    /// provider tag slot owned by `provider`.
681    ///
682    /// Fails closed when the override already carries a tag for a different
683    /// provider family (an identity conflict is a typed fault, never a silent
684    /// overwrite) and when the provider has no typed structured-output slot.
685    pub fn set_structured_output(
686        &mut self,
687        provider: Provider,
688        schema: crate::OutputSchema,
689    ) -> Result<StructuredOutputInjection, ProviderParamsMergeError> {
690        match (provider, self.provider_tag.as_mut()) {
691            (Provider::Anthropic, Some(ProviderTag::Anthropic(tag))) => {
692                tag.structured_output = Some(schema);
693            }
694            (Provider::Anthropic, None) => {
695                self.provider_tag = Some(ProviderTag::Anthropic(AnthropicProviderTag {
696                    structured_output: Some(schema),
697                    ..Default::default()
698                }));
699            }
700            // Self-hosted endpoints speak the OpenAI-compatible surface.
701            (Provider::OpenAI | Provider::SelfHosted, Some(ProviderTag::OpenAi(tag))) => {
702                tag.structured_output = Some(schema);
703            }
704            (Provider::OpenAI | Provider::SelfHosted, None) => {
705                self.provider_tag = Some(ProviderTag::OpenAi(OpenAiProviderTag {
706                    structured_output: Some(schema),
707                    ..Default::default()
708                }));
709            }
710            (Provider::Gemini, Some(ProviderTag::Gemini(tag))) => {
711                tag.structured_output = Some(schema);
712            }
713            (Provider::Gemini, None) => {
714                self.provider_tag = Some(ProviderTag::Gemini(GeminiProviderTag {
715                    structured_output: Some(schema),
716                    ..Default::default()
717                }));
718            }
719            (Provider::Other, _) => {
720                // `Other` has no provider-native structured-output slot —
721                // a typed capability fact, not a fault. Extraction proceeds
722                // prompt-based; the schema is still enforced at the
723                // validation seam after the call.
724                return Ok(StructuredOutputInjection::NoProviderSlot);
725            }
726            (_, Some(tag)) => {
727                return Err(ProviderParamsMergeError::ProviderTagMismatch {
728                    explicit: tag.provider_label(),
729                    defaults: provider.as_str(),
730                });
731            }
732        }
733        Ok(StructuredOutputInjection::Injected)
734    }
735}
736
737/// Typed outcome of [`ProviderParamsOverride::set_structured_output`].
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739pub enum StructuredOutputInjection {
740    /// The schema was injected into the provider's typed structured-output
741    /// slot — the provider enforces the output envelope natively.
742    Injected,
743    /// The provider has no typed structured-output slot; extraction runs
744    /// prompt-based and the schema is enforced at the validation seam.
745    NoProviderSlot,
746}
747
748/// Typed fault from the field-wise provider-params merge.
749///
750/// A merge conflict between the explicit per-session override and the
751/// build-derived defaults (or an extraction injection) is a configuration
752/// identity fault — it propagates typed instead of fabricating a mixed bag.
753#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
754pub enum ProviderParamsMergeError {
755    #[error(
756        "provider params carry a `{explicit}` provider tag but the merge target is `{defaults}`"
757    )]
758    ProviderTagMismatch {
759        explicit: &'static str,
760        defaults: &'static str,
761    },
762}
763
764impl ProviderTag {
765    /// Stable label for the provider family this tag belongs to.
766    pub fn provider_label(&self) -> &'static str {
767        match self {
768            Self::Anthropic(_) => "anthropic",
769            Self::OpenAi(_) => "openai",
770            Self::Gemini(_) => "gemini",
771            Self::Unknown { .. } => "unknown",
772        }
773    }
774
775    /// Field-wise merge: fill every `None` knob on `self` from `defaults`.
776    /// Explicitly-set knobs always win; a provider-family mismatch is a typed
777    /// fault rather than a silent union of unrelated provider bags.
778    pub fn merge_missing_from(
779        &mut self,
780        defaults: &ProviderTag,
781    ) -> Result<(), ProviderParamsMergeError> {
782        fn fill<T: Clone>(target: &mut Option<T>, default: &Option<T>) {
783            if target.is_none()
784                && let Some(value) = default
785            {
786                *target = Some(value.clone());
787            }
788        }
789        match (self, defaults) {
790            (Self::Anthropic(target), Self::Anthropic(default)) => {
791                fill(&mut target.thinking, &default.thinking);
792                fill(
793                    &mut target.thinking_budget_tokens,
794                    &default.thinking_budget_tokens,
795                );
796                fill(&mut target.web_search, &default.web_search);
797                fill(&mut target.top_k, &default.top_k);
798                fill(&mut target.effort, &default.effort);
799                fill(&mut target.structured_output, &default.structured_output);
800                fill(&mut target.inference_geo, &default.inference_geo);
801                fill(&mut target.compaction, &default.compaction);
802                fill(&mut target.context, &default.context);
803                fill(&mut target.cache_control, &default.cache_control);
804                fill(
805                    &mut target.supports_temperature_override,
806                    &default.supports_temperature_override,
807                );
808                Ok(())
809            }
810            (Self::OpenAi(target), Self::OpenAi(default)) => {
811                fill(&mut target.reasoning_effort, &default.reasoning_effort);
812                fill(&mut target.seed, &default.seed);
813                fill(&mut target.frequency_penalty, &default.frequency_penalty);
814                fill(&mut target.presence_penalty, &default.presence_penalty);
815                fill(&mut target.web_search, &default.web_search);
816                fill(&mut target.structured_output, &default.structured_output);
817                fill(&mut target.reasoning, &default.reasoning);
818                fill(
819                    &mut target.chat_template_kwargs,
820                    &default.chat_template_kwargs,
821                );
822                fill(&mut target.thinking, &default.thinking);
823                fill(&mut target.store, &default.store);
824                fill(&mut target.prompt_cache_key, &default.prompt_cache_key);
825                fill(
826                    &mut target.prompt_cache_retention,
827                    &default.prompt_cache_retention,
828                );
829                fill(
830                    &mut target.supports_temperature_override,
831                    &default.supports_temperature_override,
832                );
833                fill(
834                    &mut target.supports_reasoning_override,
835                    &default.supports_reasoning_override,
836                );
837                Ok(())
838            }
839            (Self::Gemini(target), Self::Gemini(default)) => {
840                fill(&mut target.thinking, &default.thinking);
841                fill(&mut target.thinking_budget, &default.thinking_budget);
842                fill(&mut target.thinking_level, &default.thinking_level);
843                fill(&mut target.top_k, &default.top_k);
844                fill(&mut target.top_p, &default.top_p);
845                fill(&mut target.structured_output, &default.structured_output);
846                fill(&mut target.google_search, &default.google_search);
847                fill(&mut target.candidate_count, &default.candidate_count);
848                fill(
849                    &mut target.cached_content_name,
850                    &default.cached_content_name,
851                );
852                Ok(())
853            }
854            // An opaque pass-through bag cannot be field-merged; the explicit
855            // bag wins wholesale only when both sides are the same opaque tag.
856            (Self::Unknown { .. }, Self::Unknown { .. }) => Ok(()),
857            (explicit, defaults) => Err(ProviderParamsMergeError::ProviderTagMismatch {
858                explicit: explicit.provider_label(),
859                defaults: defaults.provider_label(),
860            }),
861        }
862    }
863}
864
865/// Config-resident typed owner of provider parameter facts.
866///
867/// The carrier pairs the explicit, persisted [`ProviderParamsOverride`] (the
868/// LLM-edge value domain) with the build-derived provider-native tool
869/// defaults. It is parsed fail-closed at config ingress — malformed provider
870/// params are rejected when the config is read, never deferred to the first
871/// LLM call — and the per-turn effective params are produced by a typed
872/// field-wise merge ([`ProviderParamsCarrier::effective_params`]), replacing
873/// the retired RFC-7396 raw-JSON merge-patch.
874///
875/// Serde shape is transparent over `params`: the durable/wire face of the
876/// carrier is exactly the typed override shape. `tool_defaults` is never
877/// persisted; it is re-derived on every build from config + model profile.
878#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
879#[serde(transparent)]
880pub struct ProviderParamsCarrier {
881    /// Explicit provider parameter overrides (persisted).
882    pub params: ProviderParamsOverride,
883    /// Build-derived provider-native tool defaults for the session's
884    /// provider (e.g. web-search / grounding tool bodies). Never persisted.
885    #[serde(skip)]
886    pub tool_defaults: Option<ProviderTag>,
887}
888
889impl ProviderParamsCarrier {
890    /// Carrier with explicit params and no build-derived defaults.
891    pub fn from_params(params: ProviderParamsOverride) -> Self {
892        Self {
893            params,
894            tool_defaults: None,
895        }
896    }
897
898    /// Whether the carrier holds no facts at all.
899    pub fn is_empty(&self) -> bool {
900        self.params.is_empty() && self.tool_defaults.is_none()
901    }
902
903    /// Whether the carrier's durable face serializes nothing: serde is
904    /// transparent over `params` and `tool_defaults` is `#[serde(skip)]`
905    /// (build-derived, never persisted), so only the params half decides.
906    pub fn serializes_empty(&self) -> bool {
907        self.params.is_empty()
908    }
909
910    /// Produce the effective per-turn params: explicit overrides win, tool
911    /// defaults fill the unset provider-native slots. A provider-family
912    /// conflict propagates typed.
913    pub fn effective_params(&self) -> Result<ProviderParamsOverride, ProviderParamsMergeError> {
914        let mut effective = self.params.clone();
915        if let Some(defaults) = self.tool_defaults.as_ref() {
916            match effective.provider_tag.as_mut() {
917                None => effective.provider_tag = Some(defaults.clone()),
918                Some(tag) => tag.merge_missing_from(defaults)?,
919            }
920        }
921        Ok(effective)
922    }
923}
924
925/// Error returned when [`merge_batch_turn_metadata`] sees two distinct scalar
926/// overrides for the same field in a single batch.
927#[derive(Debug, Clone, PartialEq, Eq)]
928pub struct TurnMetadataMergeConflict {
929    pub field: &'static str,
930    pub reason: &'static str,
931}
932
933impl std::fmt::Display for TurnMetadataMergeConflict {
934    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
935        write!(
936            f,
937            "batch turn-metadata scalar conflict on field `{}`: {}",
938            self.field, self.reason
939        )
940    }
941}
942
943impl std::error::Error for TurnMetadataMergeConflict {}
944
945/// Tri-state per-turn metadata override.
946///
947/// `None` on the containing field means preserve the durable session value.
948/// `Some(Set(value))` overrides it for this turn, and `Some(Clear)` removes it.
949#[derive(Debug, Clone, PartialEq, Serialize)]
950#[serde(tag = "action", content = "value", rename_all = "snake_case")]
951pub enum TurnMetadataOverride<T> {
952    Set(T),
953    Clear,
954}
955
956impl<T> TurnMetadataOverride<T> {
957    pub fn set(value: T) -> Self {
958        Self::Set(value)
959    }
960
961    pub const fn clear() -> Self {
962        Self::Clear
963    }
964
965    pub fn as_set(&self) -> Option<&T> {
966        match self {
967            Self::Set(value) => Some(value),
968            Self::Clear => None,
969        }
970    }
971
972    pub fn into_set(self) -> Option<T> {
973        match self {
974            Self::Set(value) => Some(value),
975            Self::Clear => None,
976        }
977    }
978
979    pub const fn is_clear(&self) -> bool {
980        matches!(self, Self::Clear)
981    }
982
983    /// Borrow the inner value, mapping `&TurnMetadataOverride<T>` to
984    /// `TurnMetadataOverride<&T>` (mirrors [`Option::as_ref`]). Useful when a
985    /// borrowed override seam (e.g. `SessionLlmIdentityOverride<'a>`) needs the
986    /// tri-state without taking ownership.
987    pub fn as_ref(&self) -> TurnMetadataOverride<&T> {
988        match self {
989            Self::Set(value) => TurnMetadataOverride::Set(value),
990            Self::Clear => TurnMetadataOverride::Clear,
991        }
992    }
993}
994
995impl<'de, T> Deserialize<'de> for TurnMetadataOverride<T>
996where
997    T: DeserializeOwned,
998{
999    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1000    where
1001        D: serde::Deserializer<'de>,
1002    {
1003        let raw = serde_json::Value::deserialize(deserializer)?;
1004        if let Some(object) = raw.as_object() {
1005            let Some(action_value) = object.get("action") else {
1006                return serde_json::from_value(raw)
1007                    .map(Self::Set)
1008                    .map_err(de::Error::custom);
1009            };
1010            let action = action_value.as_str().ok_or_else(|| {
1011                de::Error::custom("turn metadata override action must be a string")
1012            })?;
1013            return match action {
1014                "clear" => {
1015                    if object.contains_key("value") {
1016                        return Err(de::Error::custom("clear override cannot include value"));
1017                    }
1018                    Ok(Self::Clear)
1019                }
1020                "set" => {
1021                    let value = object
1022                        .get("value")
1023                        .ok_or_else(|| de::Error::custom("set override is missing value"))?;
1024                    serde_json::from_value(value.clone())
1025                        .map(Self::Set)
1026                        .map_err(de::Error::custom)
1027                }
1028                other => Err(de::Error::custom(format!(
1029                    "unknown turn metadata override action `{other}`"
1030                ))),
1031            };
1032        }
1033
1034        serde_json::from_value(raw)
1035            .map(Self::Set)
1036            .map_err(de::Error::custom)
1037    }
1038}
1039
1040/// Canonical per-turn runtime metadata carried alongside a
1041/// [`StagedRunInput`]. This is the typed seam consumed by the core layer —
1042/// `serde_json::Value` does not appear anywhere in this shape.
1043///
1044/// Construction in the runtime crate MUST go through the single canonical
1045/// `for_input(&Input)` constructor. Other code paths that previously built
1046/// a `RuntimeTurnMetadata` literal are updated to call `for_input` or be
1047/// deleted.
1048#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1049#[serde(deny_unknown_fields)]
1050pub struct RuntimeTurnMetadata {
1051    /// Handling mode for staged ordinary work when admitted through runtime.
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub handling_mode: Option<HandlingMode>,
1054    #[serde(default, skip_serializing_if = "Option::is_none")]
1055    pub skill_references: Option<Vec<SkillKey>>,
1056    #[serde(default, skip_serializing_if = "Option::is_none")]
1057    pub flow_tool_overlay: Option<TurnToolOverlay>,
1058    /// Additional instructions for this turn, typed by role.
1059    #[serde(default, skip_serializing_if = "Option::is_none")]
1060    pub additional_instructions: Option<Vec<TurnInstruction>>,
1061    /// Override model for this turn (hot-swap on materialized sessions).
1062    #[serde(default, skip_serializing_if = "Option::is_none")]
1063    pub model: Option<ModelId>,
1064    /// Override provider for this turn (hot-swap on materialized sessions).
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub provider: Option<Provider>,
1067    /// Override, clear, or preserve provider-specific parameters for this turn
1068    /// (typed; no Value).
1069    #[serde(default, skip_serializing_if = "Option::is_none")]
1070    pub provider_params: Option<TurnMetadataOverride<ProviderParamsOverride>>,
1071    /// Override, clear, or preserve the auth binding reference this turn must
1072    /// resolve against.
1073    #[serde(default, skip_serializing_if = "Option::is_none")]
1074    pub auth_binding: Option<TurnMetadataOverride<AuthBindingRef>>,
1075    /// Keep-alive directive for materialized resources for this turn.
1076    ///
1077    /// `None` preserves the session's existing keep-alive setting; see
1078    /// [`KeepAliveDirective`] for the explicit enable/disable tri-state.
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub keep_alive: Option<KeepAliveDirective>,
1081    /// Optional normalized rendering metadata for this turn.
1082    #[serde(default, skip_serializing_if = "Option::is_none")]
1083    pub render_metadata: Option<RenderMetadata>,
1084    /// Typed execution intent classified by the runtime layer.
1085    ///
1086    /// `None` is retained only for legacy/caller-owned metadata before runtime
1087    /// admission. Runtime boundaries must stamp this field before applying a
1088    /// turn; the session layer must not invent a runtime execution kind.
1089    /// `Some(ContentTurn)` forces `run_turn`.
1090    /// `Some(ResumePending)` forces `run_pending`.
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub execution_kind: Option<RuntimeExecutionKind>,
1093    /// Typed terminal peer-response apply intent classified by the runtime
1094    /// machine at admission.
1095    #[serde(default, skip_serializing_if = "Option::is_none")]
1096    pub peer_response_terminal_apply_intent: Option<PeerResponseTerminalApplyIntent>,
1097    /// Stable transcript identity derived at runtime admission. Persisting this
1098    /// on transcript messages lets history readers join persisted frames with
1099    /// live frames without falling back to message text.
1100    #[serde(default, skip_serializing_if = "TranscriptMessageIdentity::is_empty")]
1101    pub transcript_identity: TranscriptMessageIdentity,
1102}
1103
1104impl RuntimeTurnMetadata {
1105    /// True when every field is `None` — used to skip serializing empty
1106    /// metadata carriers on the wire.
1107    pub fn is_empty(&self) -> bool {
1108        self.handling_mode.is_none()
1109            && self.skill_references.is_none()
1110            && self.flow_tool_overlay.is_none()
1111            && self.additional_instructions.is_none()
1112            && self.model.is_none()
1113            && self.provider.is_none()
1114            && self.provider_params.is_none()
1115            && self.auth_binding.is_none()
1116            && self.keep_alive.is_none()
1117            && self.render_metadata.is_none()
1118            && self.execution_kind.is_none()
1119            && self.peer_response_terminal_apply_intent.is_none()
1120            && self.transcript_identity.is_empty()
1121    }
1122
1123    pub fn transcript_message_identity(&self) -> Option<TranscriptMessageIdentity> {
1124        (!self.transcript_identity.is_empty()).then(|| self.transcript_identity.clone())
1125    }
1126
1127    /// Merge another metadata carrier into this one. Scalar conflicts (two
1128    /// inputs in a batch disagreeing on `model`, `provider`, `auth_binding`,
1129    /// etc.) return a typed [`TurnMetadataMergeConflict`] rather than
1130    /// last-wins. Collection fields accumulate.
1131    pub fn merge(&mut self, other: Self) -> Result<(), TurnMetadataMergeConflict> {
1132        // Scalar: conflict-refusing merge.
1133        merge_scalar(
1134            &mut self.handling_mode,
1135            other.handling_mode,
1136            "handling_mode",
1137        )?;
1138        merge_scalar(
1139            &mut self.flow_tool_overlay,
1140            other.flow_tool_overlay,
1141            "flow_tool_overlay",
1142        )?;
1143        merge_scalar(&mut self.model, other.model, "model")?;
1144        merge_scalar(&mut self.provider, other.provider, "provider")?;
1145        merge_override(
1146            &mut self.provider_params,
1147            other.provider_params,
1148            "provider_params",
1149        )?;
1150        merge_override(&mut self.auth_binding, other.auth_binding, "auth_binding")?;
1151        merge_scalar(&mut self.keep_alive, other.keep_alive, "keep_alive")?;
1152        merge_scalar(
1153            &mut self.render_metadata,
1154            other.render_metadata,
1155            "render_metadata",
1156        )?;
1157        merge_scalar(
1158            &mut self.execution_kind,
1159            other.execution_kind,
1160            "execution_kind",
1161        )?;
1162        merge_scalar(
1163            &mut self.peer_response_terminal_apply_intent,
1164            other.peer_response_terminal_apply_intent,
1165            "peer_response_terminal_apply_intent",
1166        )?;
1167        merge_transcript_identity(&mut self.transcript_identity, other.transcript_identity);
1168
1169        // Collections: accumulate.
1170        if let Some(extra) = other.skill_references {
1171            self.skill_references
1172                .get_or_insert_with(Vec::new)
1173                .extend(extra);
1174        }
1175        if let Some(extra) = other.additional_instructions {
1176            self.additional_instructions
1177                .get_or_insert_with(Vec::new)
1178                .extend(extra);
1179        }
1180        Ok(())
1181    }
1182}
1183
1184fn merge_transcript_identity(lhs: &mut TranscriptMessageIdentity, rhs: TranscriptMessageIdentity) {
1185    if rhs.is_empty() {
1186        return;
1187    }
1188    if lhs.is_empty() {
1189        *lhs = rhs;
1190        return;
1191    }
1192    if *lhs != rhs {
1193        *lhs = TranscriptMessageIdentity::default();
1194    }
1195}
1196
1197fn merge_scalar<T: PartialEq>(
1198    lhs: &mut Option<T>,
1199    rhs: Option<T>,
1200    field: &'static str,
1201) -> Result<(), TurnMetadataMergeConflict> {
1202    match (lhs.as_ref(), rhs) {
1203        (_, None) => Ok(()),
1204        (None, Some(v)) => {
1205            *lhs = Some(v);
1206            Ok(())
1207        }
1208        (Some(existing), Some(new)) => {
1209            if *existing == new {
1210                Ok(())
1211            } else {
1212                Err(TurnMetadataMergeConflict {
1213                    field,
1214                    reason: "two inputs in one batch set distinct scalar overrides",
1215                })
1216            }
1217        }
1218    }
1219}
1220
1221fn merge_override<T: PartialEq>(
1222    lhs: &mut Option<TurnMetadataOverride<T>>,
1223    rhs: Option<TurnMetadataOverride<T>>,
1224    field: &'static str,
1225) -> Result<(), TurnMetadataMergeConflict> {
1226    match (lhs.as_ref(), rhs) {
1227        (_, None) => Ok(()),
1228        (None, Some(override_fact)) => {
1229            *lhs = Some(override_fact);
1230            Ok(())
1231        }
1232        (Some(existing), Some(new)) if *existing == new => Ok(()),
1233        (Some(TurnMetadataOverride::Set(_)), Some(TurnMetadataOverride::Set(_))) => {
1234            Err(TurnMetadataMergeConflict {
1235                field,
1236                reason: "two inputs in one batch set distinct scalar overrides",
1237            })
1238        }
1239        (Some(_), Some(_)) => Err(TurnMetadataMergeConflict {
1240            field,
1241            reason: "one input sets the field while another clears it",
1242        }),
1243    }
1244}
1245
1246mod duration_seconds {
1247    use serde::{Deserialize, Deserializer, Serializer};
1248    use std::time::Duration;
1249
1250    pub fn serialize<S: Serializer>(value: &Duration, ser: S) -> Result<S::Ok, S::Error> {
1251        ser.serialize_u64(value.as_secs())
1252    }
1253
1254    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
1255        let secs = u64::deserialize(de)?;
1256        Ok(Duration::from_secs(secs))
1257    }
1258}
1259
1260/// An input staged for application at a run boundary.
1261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1262pub struct StagedRunInput {
1263    /// When to apply this input.
1264    pub boundary: RunApplyBoundary,
1265    /// Conversation mutations to apply.
1266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1267    pub appends: Vec<ConversationAppend>,
1268    /// Context-only appends.
1269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1270    pub context_appends: Vec<ConversationContextAppend>,
1271    /// Input IDs contributing to this staged input (opaque to core).
1272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1273    pub contributing_input_ids: Vec<InputId>,
1274    /// Optional turn semantics that must survive crash recovery.
1275    #[serde(default, skip_serializing_if = "Option::is_none")]
1276    pub turn_metadata: Option<RuntimeTurnMetadata>,
1277}
1278
1279/// The ONLY type core receives from the runtime layer for run execution.
1280///
1281/// This is the complete interface between the runtime control-plane and core.
1282/// Core does not know about Input, InputState, PolicyDecision, or any
1283/// runtime-layer types. It only sees this.
1284#[non_exhaustive]
1285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1286#[serde(tag = "primitive_type", rename_all = "snake_case")]
1287// StagedInput is intentionally large — it carries the full
1288// RuntimeTurnMetadata (model/provider/auth_binding overrides,
1289// rendering metadata, skill refs, etc.). Boxing would force an
1290// allocation on every input construction, which is in the hot path.
1291#[allow(clippy::large_enum_variant)]
1292pub enum RunPrimitive {
1293    /// Apply conversation mutations at a boundary.
1294    StagedInput(StagedRunInput),
1295    /// Inject content immediately (no boundary required).
1296    ImmediateAppend(ConversationAppend),
1297    /// Inject context immediately.
1298    ImmediateContextAppend(ConversationContextAppend),
1299}
1300
1301impl RunPrimitive {
1302    /// Get all contributing input IDs (if any).
1303    pub fn contributing_input_ids(&self) -> &[InputId] {
1304        match self {
1305            RunPrimitive::StagedInput(staged) => &staged.contributing_input_ids,
1306            RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => &[],
1307        }
1308    }
1309
1310    pub fn turn_metadata(&self) -> Option<&RuntimeTurnMetadata> {
1311        match self {
1312            RunPrimitive::StagedInput(staged) => staged.turn_metadata.as_ref(),
1313            RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => None,
1314        }
1315    }
1316
1317    /// Extract content input from this primitive's conversation appends.
1318    ///
1319    /// Consolidates the 5 near-identical `extract_prompt` / `extract_runtime_prompt`
1320    /// functions that were duplicated across RPC, REST, MCP, mob, and CLI surfaces.
1321    pub fn extract_content_input(&self) -> crate::types::ContentInput {
1322        match self {
1323            RunPrimitive::StagedInput(staged) => {
1324                content_input_from_conversation_appends(&staged.appends)
1325            }
1326            RunPrimitive::ImmediateAppend(append) => {
1327                content_input_from_core_renderable(&append.content)
1328            }
1329            RunPrimitive::ImmediateContextAppend(ctx) => {
1330                content_input_from_core_renderable(&ctx.content)
1331            }
1332        }
1333    }
1334
1335    /// Project this primitive into provider-visible input.
1336    ///
1337    /// Unlike [`RunPrimitive::extract_content_input`], this is allowed to
1338    /// render typed runtime-authored notices into model text. The rendered text
1339    /// is an internal provider projection only; transcript persistence remains
1340    /// typed `SystemNotice` content.
1341    pub fn model_projection_content_input(&self) -> crate::types::ContentInput {
1342        match self {
1343            RunPrimitive::StagedInput(staged) => {
1344                model_projection_content_input_from_conversation_appends(&staged.appends)
1345            }
1346            RunPrimitive::ImmediateAppend(append) => {
1347                model_projection_content_input_from_core_renderable(&append.content)
1348            }
1349            RunPrimitive::ImmediateContextAppend(ctx) => {
1350                model_projection_content_input_from_core_renderable(&ctx.content)
1351            }
1352        }
1353    }
1354
1355    /// Runtime-authored transcript appends carried by this primitive.
1356    ///
1357    /// Provider prompt text is derived separately; these appends remain the
1358    /// durable authorship source for transcript persistence.
1359    pub fn typed_turn_appends(&self) -> Vec<ConversationAppend> {
1360        match self {
1361            RunPrimitive::StagedInput(staged) => staged.appends.clone(),
1362            RunPrimitive::ImmediateAppend(append) => vec![append.clone()],
1363            RunPrimitive::ImmediateContextAppend(_) => Vec::new(),
1364        }
1365    }
1366
1367    /// Host-attached injected-context entries carried by this primitive's
1368    /// appends, projected back to content inputs in delivery order.
1369    ///
1370    /// Used when a surface re-lowers the primitive through a direct
1371    /// `StartTurnRequest` (deferred-session promotion) and must move the
1372    /// injected context onto the request's typed carrier instead of the
1373    /// cleared appends.
1374    pub fn injected_context_content_inputs(&self) -> Vec<crate::types::ContentInput> {
1375        match self {
1376            RunPrimitive::StagedInput(staged) => staged
1377                .appends
1378                .iter()
1379                .filter(|append| append.role == ConversationAppendRole::InjectedContext)
1380                .map(|append| content_input_from_core_renderable(&append.content))
1381                .collect(),
1382            RunPrimitive::ImmediateAppend(append)
1383                if append.role == ConversationAppendRole::InjectedContext =>
1384            {
1385                vec![content_input_from_core_renderable(&append.content)]
1386            }
1387            RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => {
1388                Vec::new()
1389            }
1390        }
1391    }
1392
1393    /// Return the canonical runtime apply boundary for this primitive.
1394    pub fn apply_boundary(&self) -> RunApplyBoundary {
1395        match self {
1396            RunPrimitive::StagedInput(staged) => staged.boundary,
1397            RunPrimitive::ImmediateAppend(_) | RunPrimitive::ImmediateContextAppend(_) => {
1398                RunApplyBoundary::Immediate
1399            }
1400        }
1401    }
1402
1403    pub fn peer_response_terminal_apply_intent(&self) -> Option<PeerResponseTerminalApplyIntent> {
1404        self.turn_metadata()
1405            .and_then(|metadata| metadata.peer_response_terminal_apply_intent)
1406    }
1407
1408    pub fn is_peer_response_terminal_context_and_run(&self) -> bool {
1409        matches!(
1410            self.peer_response_terminal_apply_intent(),
1411            Some(PeerResponseTerminalApplyIntent::AppendContextAndRun)
1412        )
1413    }
1414
1415    pub fn peer_response_terminal_apply_intent_violation(&self) -> Option<&'static str> {
1416        if !self.is_peer_response_terminal_context_and_run() {
1417            return None;
1418        }
1419
1420        let RunPrimitive::StagedInput(staged) = self else {
1421            return Some("terminal peer-response apply intent requires a staged primitive");
1422        };
1423        if staged.boundary != RunApplyBoundary::RunStart {
1424            return Some("terminal peer-response apply intent requires RunStart boundary");
1425        }
1426        if staged.context_appends.is_empty() {
1427            return Some("terminal peer-response apply intent requires a staged context append");
1428        }
1429        if staged
1430            .turn_metadata
1431            .as_ref()
1432            .and_then(|metadata| metadata.execution_kind)
1433            != Some(RuntimeExecutionKind::ContentTurn)
1434        {
1435            return Some("terminal peer-response apply intent requires ContentTurn execution kind");
1436        }
1437        None
1438    }
1439
1440    /// Whether this primitive's context appends should be applied without
1441    /// running a requester reaction turn.
1442    pub fn is_context_only_apply_without_turn(&self) -> bool {
1443        matches!(
1444            self,
1445            RunPrimitive::StagedInput(staged)
1446            if staged.appends.is_empty()
1447                && !staged.context_appends.is_empty()
1448                && !self.is_peer_response_terminal_context_and_run()
1449        )
1450    }
1451
1452    /// Whether this primitive is a context-only staged input that should be
1453    /// routed to `apply_runtime_context_appends` rather than a full turn.
1454    pub fn is_context_only_immediate(&self) -> bool {
1455        matches!(
1456            self,
1457            RunPrimitive::StagedInput(staged)
1458            if staged.appends.is_empty()
1459                && !staged.context_appends.is_empty()
1460                && staged.boundary == RunApplyBoundary::Immediate
1461        )
1462    }
1463}
1464
1465pub fn content_input_from_conversation_appends(
1466    appends: &[ConversationAppend],
1467) -> crate::types::ContentInput {
1468    let mut all_blocks = Vec::new();
1469    for append in appends {
1470        // Injected context is delivered ALONGSIDE the turn's boundary
1471        // content, never inside it: those appends materialize as separate
1472        // typed transcript messages. Folding them into the extracted prompt
1473        // would bake the ambient context into the user message (and
1474        // double-deliver it wherever the extraction is re-lowered).
1475        if append.role == ConversationAppendRole::InjectedContext {
1476            continue;
1477        }
1478        append_content_blocks(&append.content, &mut all_blocks);
1479    }
1480    content_input_from_blocks(all_blocks)
1481}
1482
1483pub fn model_projection_content_input_from_conversation_appends(
1484    appends: &[ConversationAppend],
1485) -> crate::types::ContentInput {
1486    let mut all_blocks = Vec::new();
1487    for append in appends {
1488        append_model_projection_blocks(&append.content, &mut all_blocks);
1489    }
1490    content_input_from_blocks(all_blocks)
1491}
1492
1493fn content_input_from_core_renderable(content: &CoreRenderable) -> crate::types::ContentInput {
1494    let mut all_blocks = Vec::new();
1495    append_content_blocks(content, &mut all_blocks);
1496    content_input_from_blocks(all_blocks)
1497}
1498
1499fn model_projection_content_input_from_core_renderable(
1500    content: &CoreRenderable,
1501) -> crate::types::ContentInput {
1502    let mut all_blocks = Vec::new();
1503    append_model_projection_blocks(content, &mut all_blocks);
1504    content_input_from_blocks(all_blocks)
1505}
1506
1507fn append_content_blocks(
1508    content: &CoreRenderable,
1509    all_blocks: &mut Vec<crate::types::ContentBlock>,
1510) {
1511    use crate::types::ContentBlock;
1512    match content {
1513        CoreRenderable::Text { text } => {
1514            all_blocks.push(ContentBlock::Text { text: text.clone() });
1515        }
1516        CoreRenderable::Blocks { blocks } => {
1517            all_blocks.extend(blocks.iter().cloned());
1518        }
1519        CoreRenderable::SystemNotice { .. } => {}
1520        _ => {}
1521    }
1522}
1523
1524fn append_model_projection_blocks(
1525    content: &CoreRenderable,
1526    all_blocks: &mut Vec<crate::types::ContentBlock>,
1527) {
1528    use crate::types::{ContentBlock, SystemNoticeMessage};
1529    match content {
1530        CoreRenderable::SystemNotice { kind, body, blocks } => {
1531            let projection = SystemNoticeMessage::with_blocks(*kind, body.clone(), blocks.clone())
1532                .model_projection_text();
1533            if !projection.trim().is_empty() {
1534                all_blocks.push(ContentBlock::Text { text: projection });
1535            }
1536            append_system_notice_media_blocks(blocks, all_blocks);
1537        }
1538        _ => append_content_blocks(content, all_blocks),
1539    }
1540}
1541
1542fn append_system_notice_media_blocks(
1543    blocks: &[SystemNoticeBlock],
1544    all_blocks: &mut Vec<crate::types::ContentBlock>,
1545) {
1546    for block in blocks {
1547        let content = match block {
1548            SystemNoticeBlock::Comms { content, .. }
1549            | SystemNoticeBlock::ExternalEvent { content, .. } => content,
1550            _ => continue,
1551        };
1552        all_blocks.extend(
1553            content
1554                .iter()
1555                .filter(|block| !matches!(block, crate::types::ContentBlock::Text { .. }))
1556                .cloned(),
1557        );
1558    }
1559}
1560
1561fn content_input_from_blocks(
1562    all_blocks: Vec<crate::types::ContentBlock>,
1563) -> crate::types::ContentInput {
1564    use crate::types::{ContentBlock, ContentInput};
1565    if all_blocks.is_empty() {
1566        ContentInput::Text(String::new())
1567    } else if all_blocks.len() == 1 {
1568        if let ContentBlock::Text { text } = &all_blocks[0] {
1569            ContentInput::Text(text.clone())
1570        } else {
1571            ContentInput::Blocks(all_blocks)
1572        }
1573    } else {
1574        ContentInput::Blocks(all_blocks)
1575    }
1576}
1577
1578#[cfg(test)]
1579#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1580mod tests {
1581    use super::*;
1582
1583    #[test]
1584    fn run_apply_boundary_serde_roundtrip() {
1585        for boundary in [
1586            RunApplyBoundary::Immediate,
1587            RunApplyBoundary::RunStart,
1588            RunApplyBoundary::RunCheckpoint,
1589        ] {
1590            let json = serde_json::to_value(boundary).unwrap();
1591            let parsed: RunApplyBoundary = serde_json::from_value(json).unwrap();
1592            assert_eq!(boundary, parsed);
1593        }
1594    }
1595
1596    #[test]
1597    fn core_renderable_text_serde() {
1598        let r = CoreRenderable::Text {
1599            text: "hello".into(),
1600        };
1601        let json = serde_json::to_value(&r).unwrap();
1602        assert_eq!(json["type"], "text");
1603        assert_eq!(json["text"], "hello");
1604        let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
1605        assert_eq!(r, parsed);
1606    }
1607
1608    #[test]
1609    fn core_renderable_json_serde() {
1610        let r = CoreRenderable::Json {
1611            value: serde_json::json!({"key": "val"}),
1612        };
1613        let json = serde_json::to_value(&r).unwrap();
1614        assert_eq!(json["type"], "json");
1615        let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
1616        assert_eq!(r, parsed);
1617    }
1618
1619    // --- extract_content_input tests ---
1620
1621    fn make_staged(appends: Vec<ConversationAppend>) -> RunPrimitive {
1622        RunPrimitive::StagedInput(StagedRunInput {
1623            boundary: RunApplyBoundary::RunStart,
1624            appends,
1625            context_appends: vec![],
1626            contributing_input_ids: vec![],
1627            turn_metadata: None,
1628        })
1629    }
1630
1631    #[test]
1632    fn extract_content_from_staged_text() {
1633        let p = make_staged(vec![ConversationAppend {
1634            role: ConversationAppendRole::User,
1635            content: CoreRenderable::Text {
1636                text: "hello".into(),
1637            },
1638        }]);
1639        assert_eq!(
1640            p.extract_content_input(),
1641            crate::types::ContentInput::Text("hello".into())
1642        );
1643    }
1644
1645    #[test]
1646    fn extract_content_from_staged_blocks() {
1647        let p = make_staged(vec![ConversationAppend {
1648            role: ConversationAppendRole::User,
1649            content: CoreRenderable::Blocks {
1650                blocks: vec![
1651                    crate::types::ContentBlock::Text { text: "a".into() },
1652                    crate::types::ContentBlock::Text { text: "b".into() },
1653                ],
1654            },
1655        }]);
1656        let result = p.extract_content_input();
1657        assert!(
1658            matches!(&result, crate::types::ContentInput::Blocks(blocks) if blocks.len() == 2),
1659            "expected Blocks with 2 elements, got {result:?}"
1660        );
1661    }
1662
1663    #[test]
1664    fn extract_content_from_staged_empty() {
1665        let p = make_staged(vec![]);
1666        assert_eq!(
1667            p.extract_content_input(),
1668            crate::types::ContentInput::Text(String::new())
1669        );
1670    }
1671
1672    #[test]
1673    fn extract_content_single_text_block_collapses() {
1674        let p = make_staged(vec![ConversationAppend {
1675            role: ConversationAppendRole::User,
1676            content: CoreRenderable::Blocks {
1677                blocks: vec![crate::types::ContentBlock::Text {
1678                    text: "single".into(),
1679                }],
1680            },
1681        }]);
1682        assert_eq!(
1683            p.extract_content_input(),
1684            crate::types::ContentInput::Text("single".into())
1685        );
1686    }
1687
1688    #[test]
1689    fn system_notice_append_does_not_leak_projection_into_operator_prompt() {
1690        let append = ConversationAppend {
1691            role: ConversationAppendRole::SystemNotice,
1692            content: CoreRenderable::SystemNotice {
1693                kind: SystemNoticeKind::Comms,
1694                body: Some("Peer request: checksum_token".to_string()),
1695                blocks: vec![SystemNoticeBlock::Comms {
1696                    kind: crate::types::CommsNoticeKind::Request,
1697                    direction: crate::types::SystemNoticeDirection::Incoming,
1698                    peer: Some(crate::types::SystemNoticePeer {
1699                        id: crate::comms::PeerId::new(),
1700                        display_name: Some("worker-1".to_string()),
1701                    }),
1702                    sender_taint: None,
1703                    request_id: Some(crate::time_compat::new_uuid_v7().to_string()),
1704                    intent: Some("checksum_token".to_string()),
1705                    status: None,
1706                    summary: Some("Peer request: checksum_token".to_string()),
1707                    payload: None,
1708                    content: vec![crate::types::ContentBlock::Text {
1709                        text: "What is the token?".to_string(),
1710                    }],
1711                }],
1712            },
1713        };
1714        let p = make_staged(vec![append.clone()]);
1715
1716        assert_eq!(p.typed_turn_appends(), vec![append]);
1717        assert_eq!(
1718            p.extract_content_input(),
1719            crate::types::ContentInput::Text(String::new())
1720        );
1721        let projection = p.model_projection_content_input().text_content();
1722        assert!(projection.contains("Peer request"));
1723        assert!(projection.contains("checksum_token"));
1724        assert!(projection.contains("What is the token?"));
1725    }
1726
1727    #[test]
1728    fn system_notice_media_remains_typed_notice_not_operator_prompt() {
1729        let image = crate::types::ContentBlock::Image {
1730            media_type: "image/png".to_string(),
1731            data: crate::types::ImageData::Inline {
1732                data: "aW1hZ2U=".to_string(),
1733            },
1734        };
1735        let p = make_staged(vec![ConversationAppend {
1736            role: ConversationAppendRole::SystemNotice,
1737            content: CoreRenderable::SystemNotice {
1738                kind: SystemNoticeKind::ExternalEvent,
1739                body: Some("External event".to_string()),
1740                blocks: vec![SystemNoticeBlock::ExternalEvent {
1741                    source: "webhook".to_string(),
1742                    event_type: "image".to_string(),
1743                    summary: Some("Webhook image".to_string()),
1744                    body: Some("Do not inject this prose".to_string()),
1745                    payload: None,
1746                    content: vec![
1747                        crate::types::ContentBlock::Text {
1748                            text: "Do not inject this text".to_string(),
1749                        },
1750                        image,
1751                    ],
1752                }],
1753            },
1754        }]);
1755
1756        assert_eq!(
1757            p.extract_content_input(),
1758            crate::types::ContentInput::Text(String::new())
1759        );
1760        match p.model_projection_content_input() {
1761            crate::types::ContentInput::Blocks(blocks) => {
1762                assert!(blocks.iter().any(|block| matches!(
1763                    block,
1764                    crate::types::ContentBlock::Text { text }
1765                        if text.contains("Do not inject this prose")
1766                            && text.contains("Do not inject this text")
1767                )));
1768                assert!(
1769                    blocks
1770                        .iter()
1771                        .any(|block| matches!(block, crate::types::ContentBlock::Image { .. }))
1772                );
1773            }
1774            other => panic!("expected typed notice projection with media blocks, got {other:?}"),
1775        }
1776    }
1777
1778    #[test]
1779    fn typed_turn_appends_excludes_context_only_appends() {
1780        let p = RunPrimitive::ImmediateContextAppend(ConversationContextAppend {
1781            key: "ctx".to_string(),
1782            content: CoreRenderable::SystemNotice {
1783                kind: SystemNoticeKind::Comms,
1784                body: Some("context".to_string()),
1785                blocks: Vec::new(),
1786            },
1787        });
1788
1789        assert!(p.typed_turn_appends().is_empty());
1790        assert_eq!(
1791            p.extract_content_input(),
1792            crate::types::ContentInput::Text(String::new())
1793        );
1794    }
1795
1796    // --- is_context_only_immediate tests ---
1797
1798    #[test]
1799    fn context_only_immediate_true() {
1800        let p = RunPrimitive::StagedInput(StagedRunInput {
1801            boundary: RunApplyBoundary::Immediate,
1802            appends: vec![],
1803            context_appends: vec![ConversationContextAppend {
1804                key: "k".into(),
1805                content: CoreRenderable::Text { text: "ctx".into() },
1806            }],
1807            contributing_input_ids: vec![],
1808            turn_metadata: None,
1809        });
1810        assert!(p.is_context_only_immediate());
1811    }
1812
1813    #[test]
1814    fn context_only_immediate_false_with_appends() {
1815        let p = RunPrimitive::StagedInput(StagedRunInput {
1816            boundary: RunApplyBoundary::Immediate,
1817            appends: vec![ConversationAppend {
1818                role: ConversationAppendRole::User,
1819                content: CoreRenderable::Text { text: "hi".into() },
1820            }],
1821            context_appends: vec![ConversationContextAppend {
1822                key: "k".into(),
1823                content: CoreRenderable::Text { text: "ctx".into() },
1824            }],
1825            contributing_input_ids: vec![],
1826            turn_metadata: None,
1827        });
1828        assert!(!p.is_context_only_immediate());
1829    }
1830
1831    #[test]
1832    fn context_only_immediate_false_wrong_boundary() {
1833        let p = RunPrimitive::StagedInput(StagedRunInput {
1834            boundary: RunApplyBoundary::RunCheckpoint,
1835            appends: vec![],
1836            context_appends: vec![ConversationContextAppend {
1837                key: "k".into(),
1838                content: CoreRenderable::Text { text: "ctx".into() },
1839            }],
1840            contributing_input_ids: vec![],
1841            turn_metadata: None,
1842        });
1843        assert!(!p.is_context_only_immediate());
1844    }
1845
1846    #[test]
1847    fn context_only_apply_without_turn_true_for_plain_context() {
1848        let p = RunPrimitive::StagedInput(StagedRunInput {
1849            boundary: RunApplyBoundary::RunCheckpoint,
1850            appends: vec![],
1851            context_appends: vec![ConversationContextAppend {
1852                key: "k".into(),
1853                content: CoreRenderable::Text { text: "ctx".into() },
1854            }],
1855            contributing_input_ids: vec![],
1856            turn_metadata: Some(RuntimeTurnMetadata {
1857                execution_kind: Some(RuntimeExecutionKind::ContentTurn),
1858                ..Default::default()
1859            }),
1860        });
1861
1862        assert!(p.is_context_only_apply_without_turn());
1863    }
1864
1865    #[test]
1866    fn terminal_peer_response_context_and_run_bypasses_context_only_shortcut() {
1867        let p = RunPrimitive::StagedInput(StagedRunInput {
1868            boundary: RunApplyBoundary::RunStart,
1869            appends: vec![],
1870            context_appends: vec![ConversationContextAppend {
1871                key: "peer_response_terminal:550e8400-e29b-41d4-a716-446655440000:req-123".into(),
1872                content: CoreRenderable::Text {
1873                    text: "Peer terminal response: done".into(),
1874                },
1875            }],
1876            contributing_input_ids: vec![InputId::new()],
1877            turn_metadata: Some(RuntimeTurnMetadata {
1878                execution_kind: Some(RuntimeExecutionKind::ContentTurn),
1879                peer_response_terminal_apply_intent: Some(
1880                    PeerResponseTerminalApplyIntent::AppendContextAndRun,
1881                ),
1882                ..Default::default()
1883            }),
1884        });
1885
1886        assert!(p.is_peer_response_terminal_context_and_run());
1887        assert_eq!(p.peer_response_terminal_apply_intent_violation(), None);
1888        assert!(!p.is_context_only_apply_without_turn());
1889    }
1890
1891    #[test]
1892    fn terminal_peer_response_with_conversation_append_keeps_context_and_run_intent() {
1893        let p = RunPrimitive::StagedInput(StagedRunInput {
1894            boundary: RunApplyBoundary::RunStart,
1895            appends: vec![ConversationAppend {
1896                role: ConversationAppendRole::User,
1897                content: CoreRenderable::Blocks {
1898                    blocks: vec![crate::types::ContentBlock::Text {
1899                        text: "Peer terminal response: done".into(),
1900                    }],
1901                },
1902            }],
1903            context_appends: vec![ConversationContextAppend {
1904                key: "peer_response_terminal:550e8400-e29b-41d4-a716-446655440000:req-123".into(),
1905                content: CoreRenderable::Text {
1906                    text: "Peer terminal response: done".into(),
1907                },
1908            }],
1909            contributing_input_ids: vec![InputId::new()],
1910            turn_metadata: Some(RuntimeTurnMetadata {
1911                execution_kind: Some(RuntimeExecutionKind::ContentTurn),
1912                peer_response_terminal_apply_intent: Some(
1913                    PeerResponseTerminalApplyIntent::AppendContextAndRun,
1914                ),
1915                ..Default::default()
1916            }),
1917        });
1918
1919        assert!(p.is_peer_response_terminal_context_and_run());
1920        assert_eq!(p.peer_response_terminal_apply_intent_violation(), None);
1921        assert!(!p.is_context_only_apply_without_turn());
1922    }
1923
1924    #[test]
1925    fn non_staged_is_not_context_only() {
1926        let p = RunPrimitive::ImmediateAppend(ConversationAppend {
1927            role: ConversationAppendRole::User,
1928            content: CoreRenderable::Text { text: "hi".into() },
1929        });
1930        assert!(!p.is_context_only_immediate());
1931    }
1932
1933    #[test]
1934    fn core_renderable_reference_serde() {
1935        let r = CoreRenderable::Reference {
1936            uri: "file:///tmp/a.txt".into(),
1937            label: Some("a file".into()),
1938        };
1939        let json = serde_json::to_value(&r).unwrap();
1940        assert_eq!(json["type"], "reference");
1941        let parsed: CoreRenderable = serde_json::from_value(json).unwrap();
1942        assert_eq!(r, parsed);
1943    }
1944
1945    #[test]
1946    fn execution_kind_serde_round_trip() {
1947        for kind in [
1948            RuntimeExecutionKind::ContentTurn,
1949            RuntimeExecutionKind::ResumePending,
1950        ] {
1951            let json = serde_json::to_value(kind).unwrap();
1952            let parsed: RuntimeExecutionKind = serde_json::from_value(json.clone()).unwrap();
1953            assert_eq!(kind, parsed);
1954        }
1955        // Verify snake_case naming
1956        assert_eq!(
1957            serde_json::to_value(RuntimeExecutionKind::ContentTurn).unwrap(),
1958            serde_json::Value::String("content_turn".into())
1959        );
1960        assert_eq!(
1961            serde_json::to_value(RuntimeExecutionKind::ResumePending).unwrap(),
1962            serde_json::Value::String("resume_pending".into())
1963        );
1964    }
1965
1966    #[test]
1967    fn turn_metadata_execution_kind_defaults_to_none() {
1968        let meta = RuntimeTurnMetadata::default();
1969        assert_eq!(meta.execution_kind, None);
1970    }
1971
1972    #[test]
1973    fn turn_metadata_execution_kind_round_trips() {
1974        let meta = RuntimeTurnMetadata {
1975            execution_kind: Some(RuntimeExecutionKind::ContentTurn),
1976            ..Default::default()
1977        };
1978        let json = serde_json::to_value(&meta).unwrap();
1979        assert_eq!(json["execution_kind"], "content_turn");
1980        let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
1981        assert_eq!(
1982            parsed.execution_kind,
1983            Some(RuntimeExecutionKind::ContentTurn)
1984        );
1985    }
1986
1987    #[test]
1988    fn turn_metadata_without_execution_kind_deserializes() {
1989        // Backward compat: old payloads without execution_kind deserialize to None
1990        let json = serde_json::json!({});
1991        let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
1992        assert_eq!(parsed.execution_kind, None);
1993    }
1994
1995    #[test]
1996    fn conversation_append_role_serde() {
1997        for role in [
1998            ConversationAppendRole::User,
1999            ConversationAppendRole::Assistant,
2000            ConversationAppendRole::SystemNotice,
2001            ConversationAppendRole::Tool,
2002            ConversationAppendRole::InjectedContext,
2003        ] {
2004            let json = serde_json::to_value(role).unwrap();
2005            let parsed: ConversationAppendRole = serde_json::from_value(json).unwrap();
2006            assert_eq!(role, parsed);
2007        }
2008    }
2009
2010    #[test]
2011    fn conversation_append_role_injected_context_is_snake_case() {
2012        assert_eq!(
2013            serde_json::to_value(ConversationAppendRole::InjectedContext).unwrap(),
2014            serde_json::json!("injected_context"),
2015        );
2016    }
2017
2018    #[test]
2019    fn conversation_append_serde() {
2020        let append = ConversationAppend {
2021            role: ConversationAppendRole::User,
2022            content: CoreRenderable::Text {
2023                text: "hello".into(),
2024            },
2025        };
2026        let json = serde_json::to_value(&append).unwrap();
2027        let parsed: ConversationAppend = serde_json::from_value(json).unwrap();
2028        assert_eq!(append, parsed);
2029    }
2030
2031    #[test]
2032    fn staged_run_input_serde() {
2033        let staged = StagedRunInput {
2034            boundary: RunApplyBoundary::RunStart,
2035            appends: vec![ConversationAppend {
2036                role: ConversationAppendRole::User,
2037                content: CoreRenderable::Text {
2038                    text: "prompt".into(),
2039                },
2040            }],
2041            context_appends: vec![],
2042            contributing_input_ids: vec![InputId::new()],
2043            turn_metadata: Some(RuntimeTurnMetadata {
2044                keep_alive: Some(KeepAliveDirective::Enable(KeepAlivePolicy {
2045                    ttl: std::time::Duration::from_secs(30),
2046                    policy: KeepAliveMode::Pinned,
2047                })),
2048                ..Default::default()
2049            }),
2050        };
2051        let json = serde_json::to_value(&staged).unwrap();
2052        let parsed: StagedRunInput = serde_json::from_value(json).unwrap();
2053        assert_eq!(staged, parsed);
2054    }
2055
2056    /// Dogma K13: the per-turn keep-alive carrier must represent the full
2057    /// tri-state. `Disable` must survive serde round-trips distinctly from
2058    /// both `Enable` and absence (`Preserve`).
2059    #[test]
2060    fn keep_alive_directive_tri_state_round_trip() {
2061        let disable = RuntimeTurnMetadata {
2062            keep_alive: Some(KeepAliveDirective::Disable),
2063            ..Default::default()
2064        };
2065        let json = serde_json::to_value(&disable).unwrap();
2066        let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
2067        assert_eq!(parsed.keep_alive, Some(KeepAliveDirective::Disable));
2068        assert!(!parsed.is_empty(), "explicit Disable is not empty metadata");
2069
2070        let enable = RuntimeTurnMetadata {
2071            keep_alive: Some(KeepAliveDirective::Enable(KeepAlivePolicy {
2072                ttl: std::time::Duration::from_secs(30),
2073                policy: KeepAliveMode::Pinned,
2074            })),
2075            ..Default::default()
2076        };
2077        let json = serde_json::to_value(&enable).unwrap();
2078        let parsed: RuntimeTurnMetadata = serde_json::from_value(json).unwrap();
2079        assert_eq!(parsed.keep_alive, enable.keep_alive);
2080        assert_ne!(parsed.keep_alive, Some(KeepAliveDirective::Disable));
2081    }
2082
2083    #[test]
2084    fn run_primitive_staged_input_serde() {
2085        let primitive = RunPrimitive::StagedInput(StagedRunInput {
2086            boundary: RunApplyBoundary::RunStart,
2087            appends: vec![],
2088            context_appends: vec![],
2089            contributing_input_ids: vec![InputId::new(), InputId::new()],
2090            turn_metadata: None,
2091        });
2092        let json = serde_json::to_value(&primitive).unwrap();
2093        assert_eq!(json["primitive_type"], "staged_input");
2094        let parsed: RunPrimitive = serde_json::from_value(json).unwrap();
2095        assert_eq!(primitive, parsed);
2096    }
2097
2098    #[test]
2099    fn run_primitive_immediate_append_serde() {
2100        let primitive = RunPrimitive::ImmediateAppend(ConversationAppend {
2101            role: ConversationAppendRole::SystemNotice,
2102            content: CoreRenderable::Text {
2103                text: "notice".into(),
2104            },
2105        });
2106        let json = serde_json::to_value(&primitive).unwrap();
2107        assert_eq!(json["primitive_type"], "immediate_append");
2108        let parsed: RunPrimitive = serde_json::from_value(json).unwrap();
2109        assert_eq!(primitive, parsed);
2110    }
2111
2112    #[test]
2113    fn run_primitive_contributing_input_ids() {
2114        let ids = vec![InputId::new(), InputId::new()];
2115        let primitive = RunPrimitive::StagedInput(StagedRunInput {
2116            boundary: RunApplyBoundary::RunStart,
2117            appends: vec![],
2118            context_appends: vec![],
2119            contributing_input_ids: ids.clone(),
2120            turn_metadata: None,
2121        });
2122        assert_eq!(primitive.contributing_input_ids(), &ids);
2123
2124        let immediate = RunPrimitive::ImmediateAppend(ConversationAppend {
2125            role: ConversationAppendRole::User,
2126            content: CoreRenderable::Text { text: "hi".into() },
2127        });
2128        assert!(immediate.contributing_input_ids().is_empty());
2129    }
2130
2131    #[test]
2132    fn conversation_context_append_serde() {
2133        let ctx = ConversationContextAppend {
2134            key: "peers".into(),
2135            content: CoreRenderable::Json {
2136                value: serde_json::json!(["peer1", "peer2"]),
2137            },
2138        };
2139        let json = serde_json::to_value(&ctx).unwrap();
2140        let parsed: ConversationContextAppend = serde_json::from_value(json).unwrap();
2141        assert_eq!(ctx, parsed);
2142    }
2143
2144    /// K2 invariant: the per-turn effective params come from a typed
2145    /// field-wise merge on the carrier — explicit overrides win, build-derived
2146    /// tool defaults fill unset slots, and nothing round-trips through JSON.
2147    #[test]
2148    fn carrier_effective_params_typed_fieldwise_merge() {
2149        let carrier = ProviderParamsCarrier {
2150            params: ProviderParamsOverride {
2151                temperature: Some(0.3),
2152                provider_tag: Some(ProviderTag::Anthropic(AnthropicProviderTag {
2153                    effort: Some(AnthropicEffort::High),
2154                    cache_control: Some(AnthropicCacheControlPolicy::Disabled),
2155                    ..Default::default()
2156                })),
2157                ..Default::default()
2158            },
2159            tool_defaults: Some(ProviderTag::Anthropic(AnthropicProviderTag {
2160                effort: Some(AnthropicEffort::Low),
2161                cache_control: Some(AnthropicCacheControlPolicy::SystemPrefix),
2162                web_search: Some(OpaqueProviderBody::from_value(
2163                    &serde_json::json!({"type": "web_search_20250305"}),
2164                )),
2165                ..Default::default()
2166            })),
2167        };
2168
2169        let effective = carrier.effective_params().expect("merge succeeds");
2170        assert_eq!(effective.temperature, Some(0.3));
2171        let Some(ProviderTag::Anthropic(tag)) = effective.provider_tag else {
2172            panic!("anthropic tag expected");
2173        };
2174        // Explicit knob wins over the default.
2175        assert_eq!(tag.effort, Some(AnthropicEffort::High));
2176        assert_eq!(
2177            tag.cache_control,
2178            Some(AnthropicCacheControlPolicy::Disabled)
2179        );
2180        // Unset slot is filled from the build-derived default.
2181        assert_eq!(
2182            tag.web_search,
2183            Some(OpaqueProviderBody::from_value(
2184                &serde_json::json!({"type": "web_search_20250305"})
2185            ))
2186        );
2187    }
2188
2189    /// K2 invariant: a provider-family conflict between explicit params and
2190    /// tool defaults is a typed fault, never a silently-fabricated mixed bag.
2191    #[test]
2192    fn carrier_effective_params_provider_mismatch_fails_typed() {
2193        let carrier = ProviderParamsCarrier {
2194            params: ProviderParamsOverride {
2195                provider_tag: Some(ProviderTag::Gemini(GeminiProviderTag::default())),
2196                ..Default::default()
2197            },
2198            tool_defaults: Some(ProviderTag::OpenAi(OpenAiProviderTag::default())),
2199        };
2200        let err = carrier.effective_params().expect_err("mismatch is a fault");
2201        assert_eq!(
2202            err,
2203            ProviderParamsMergeError::ProviderTagMismatch {
2204                explicit: "gemini",
2205                defaults: "openai",
2206            }
2207        );
2208    }
2209
2210    #[test]
2211    fn carrier_effective_params_preserves_explicit_openai_store_false() {
2212        let carrier = ProviderParamsCarrier {
2213            params: ProviderParamsOverride {
2214                provider_tag: Some(ProviderTag::OpenAi(OpenAiProviderTag {
2215                    store: Some(false),
2216                    ..Default::default()
2217                })),
2218                ..Default::default()
2219            },
2220            tool_defaults: Some(ProviderTag::OpenAi(OpenAiProviderTag {
2221                store: Some(true),
2222                prompt_cache_key: Some("default-key".to_string()),
2223                ..Default::default()
2224            })),
2225        };
2226
2227        let effective = carrier.effective_params().expect("merge succeeds");
2228        let Some(ProviderTag::OpenAi(tag)) = effective.provider_tag else {
2229            panic!("openai tag expected");
2230        };
2231        assert_eq!(tag.store, Some(false));
2232        assert_eq!(tag.prompt_cache_key.as_deref(), Some("default-key"));
2233    }
2234
2235    /// K2 invariant: the carrier's durable face is exactly the typed override
2236    /// shape (transparent serde), and unknown keys fail closed at ingress.
2237    #[test]
2238    fn carrier_serde_is_transparent_and_fail_closed() {
2239        let parsed: ProviderParamsCarrier =
2240            serde_json::from_value(serde_json::json!({ "temperature": 0.7 }))
2241                .expect("typed shape parses");
2242        assert_eq!(parsed.params.temperature, Some(0.7));
2243        assert!(parsed.tool_defaults.is_none());
2244
2245        let unknown =
2246            serde_json::from_value::<ProviderParamsCarrier>(serde_json::json!({ "thinking": {} }));
2247        assert!(
2248            unknown.is_err(),
2249            "legacy/unknown provider-params keys must be rejected at ingress"
2250        );
2251
2252        // The durable face is exactly the typed override shape: one
2253        // `temperature` key (f32-backed, so the JSON number is the f32
2254        // value), and the re-parsed carrier is identical.
2255        let round = serde_json::to_value(&parsed).expect("serialize");
2256        let keys: Vec<&str> = round
2257            .as_object()
2258            .expect("carrier serializes as an object")
2259            .keys()
2260            .map(String::as_str)
2261            .collect();
2262        assert_eq!(keys, ["temperature"]);
2263        let reparsed: ProviderParamsCarrier =
2264            serde_json::from_value(round).expect("round-trip parses");
2265        assert_eq!(reparsed, parsed);
2266    }
2267
2268    /// K2 invariant: extraction structured-output injection goes through the
2269    /// typed ProviderTag owner and fails typed on identity conflicts.
2270    #[test]
2271    fn set_structured_output_typed_injection() {
2272        let schema = crate::OutputSchema::from_json_value(
2273            serde_json::json!({"type": "object", "properties": {}}),
2274        )
2275        .expect("schema");
2276
2277        let mut params = ProviderParamsOverride::default();
2278        params
2279            .set_structured_output(Provider::Anthropic, schema.clone())
2280            .expect("inject into empty override");
2281        assert!(matches!(
2282            params.provider_tag,
2283            Some(ProviderTag::Anthropic(AnthropicProviderTag {
2284                structured_output: Some(_),
2285                ..
2286            }))
2287        ));
2288
2289        let mut mismatched = ProviderParamsOverride {
2290            provider_tag: Some(ProviderTag::Gemini(GeminiProviderTag::default())),
2291            ..Default::default()
2292        };
2293        let err = mismatched
2294            .set_structured_output(Provider::Anthropic, schema.clone())
2295            .expect_err("identity conflict is typed");
2296        assert!(matches!(
2297            err,
2298            ProviderParamsMergeError::ProviderTagMismatch { .. }
2299        ));
2300
2301        // `Other` has no provider-native slot: a typed capability outcome,
2302        // never a fault and never a silent injection.
2303        let mut other = ProviderParamsOverride::default();
2304        let outcome = other
2305            .set_structured_output(Provider::Other, schema)
2306            .expect("no-slot providers proceed prompt-based");
2307        assert_eq!(outcome, StructuredOutputInjection::NoProviderSlot);
2308        assert!(other.provider_tag.is_none());
2309    }
2310
2311    #[test]
2312    fn opaque_provider_body_round_trip() {
2313        let v = serde_json::json!({"max_uses": 5, "allowed_domains": ["example.com"]});
2314        let body = OpaqueProviderBody::from_value(&v);
2315        assert_eq!(body.as_value(), v);
2316    }
2317}