1use 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#[non_exhaustive]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum RunApplyBoundary {
23 Immediate,
25 RunStart,
27 RunCheckpoint,
29}
30
31#[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 Text { text: String },
39 Blocks {
41 blocks: Vec<crate::types::ContentBlock>,
42 },
43 Json { value: serde_json::Value },
47 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 { uri: String, label: Option<String> },
57}
58
59impl CoreRenderable {
60 #[must_use]
66 pub fn text(text: impl Into<String>) -> Self {
67 Self::Text { text: text.into() }
68 }
69
70 #[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#[non_exhaustive]
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ConversationAppendRole {
103 User,
105 Assistant,
107 SystemNotice,
109 Tool,
111 InjectedContext,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ConversationAppend {
123 pub role: ConversationAppendRole,
125 pub content: CoreRenderable,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ConversationContextAppend {
132 pub key: String,
134 pub content: CoreRenderable,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum RuntimeExecutionKind {
146 ContentTurn,
149 ResumePending,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum PeerResponseTerminalApplyIntent {
161 AppendContextAndRun,
164}
165
166#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum KeepAliveMode {
202 Pinned,
203 PolicyDriven,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(tag = "directive", rename_all = "snake_case")]
217pub enum KeepAliveDirective {
218 Enable(KeepAlivePolicy),
220 Disable,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct TurnInstruction {
227 pub kind: TurnInstructionKind,
228 pub body: String,
229}
230
231#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
254#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
255pub struct StructuredProviderExtension {
256 pub namespace: String,
258 pub key: String,
260 #[cfg_attr(feature = "schema", schemars(with = "String"))]
262 #[serde(default)]
263 pub body: String,
264}
265
266#[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 Unknown {
289 bag: StructuredProviderExtension,
290 },
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
301#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
302#[serde(transparent)]
303pub struct OpaqueProviderBody {
304 #[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#[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,
331 Enabled { budget_tokens: u32 },
334}
335
336#[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#[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 Other {
371 region: String,
372 },
373}
374
375#[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 OneMegabyte,
382}
383
384#[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,
391 Custom { edit: OpaqueProviderBody },
394}
395
396#[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 Disabled,
403 SystemPrefix,
405}
406
407#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub thinking: Option<AnthropicThinkingConfig>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub thinking_budget_tokens: Option<u32>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub web_search: Option<OpaqueProviderBody>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub top_k: Option<u32>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub effort: Option<AnthropicEffort>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub structured_output: Option<crate::OutputSchema>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub inference_geo: Option<AnthropicInferenceGeo>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub compaction: Option<AnthropicCompactionConfig>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub context: Option<AnthropicContextWindow>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub cache_control: Option<AnthropicCacheControlPolicy>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub supports_temperature_override: Option<bool>,
450}
451
452#[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 InMemory,
459 #[serde(rename = "24h")]
461 TwentyFourHours,
462}
463
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
466#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
467#[serde(deny_unknown_fields)]
468pub struct OpenAiProviderTag {
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub reasoning_effort: Option<ReasoningEffort>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub seed: Option<i64>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub frequency_penalty: Option<f32>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub presence_penalty: Option<f32>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub web_search: Option<OpaqueProviderBody>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub structured_output: Option<crate::OutputSchema>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub reasoning: Option<OpaqueProviderBody>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub chat_template_kwargs: Option<OpaqueProviderBody>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub thinking: Option<OpaqueProviderBody>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub store: Option<bool>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub prompt_cache_key: Option<String>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub prompt_cache_retention: Option<OpenAiPromptCacheRetention>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub supports_temperature_override: Option<bool>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub supports_reasoning_override: Option<bool>,
517}
518
519#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub include_thoughts: Option<bool>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub thinking_level: Option<GeminiThinkingLevel>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub thinking_budget: Option<u32>,
555}
556
557#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
559#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
560#[serde(deny_unknown_fields)]
561pub struct GeminiProviderTag {
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub thinking: Option<GeminiThinkingConfig>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub thinking_budget: Option<u32>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub thinking_level: Option<GeminiThinkingLevel>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub top_k: Option<u32>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub top_p: Option<f32>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub structured_output: Option<crate::OutputSchema>,
581 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub google_search: Option<OpaqueProviderBody>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub candidate_count: Option<u32>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub cached_content_name: Option<String>,
591}
592
593#[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#[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 Emit,
626 Silent,
628 Off,
630}
631
632#[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 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 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 (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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739pub enum StructuredOutputInjection {
740 Injected,
743 NoProviderSlot,
746}
747
748#[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 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 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 (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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
879#[serde(transparent)]
880pub struct ProviderParamsCarrier {
881 pub params: ProviderParamsOverride,
883 #[serde(skip)]
886 pub tool_defaults: Option<ProviderTag>,
887}
888
889impl ProviderParamsCarrier {
890 pub fn from_params(params: ProviderParamsOverride) -> Self {
892 Self {
893 params,
894 tool_defaults: None,
895 }
896 }
897
898 pub fn is_empty(&self) -> bool {
900 self.params.is_empty() && self.tool_defaults.is_none()
901 }
902
903 pub fn serializes_empty(&self) -> bool {
907 self.params.is_empty()
908 }
909
910 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#[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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1049#[serde(deny_unknown_fields)]
1050pub struct RuntimeTurnMetadata {
1051 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
1060 pub additional_instructions: Option<Vec<TurnInstruction>>,
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1063 pub model: Option<ModelId>,
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1066 pub provider: Option<Provider>,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub provider_params: Option<TurnMetadataOverride<ProviderParamsOverride>>,
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub auth_binding: Option<TurnMetadataOverride<AuthBindingRef>>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1080 pub keep_alive: Option<KeepAliveDirective>,
1081 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub render_metadata: Option<RenderMetadata>,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1092 pub execution_kind: Option<RuntimeExecutionKind>,
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub peer_response_terminal_apply_intent: Option<PeerResponseTerminalApplyIntent>,
1097 #[serde(default, skip_serializing_if = "TranscriptMessageIdentity::is_empty")]
1101 pub transcript_identity: TranscriptMessageIdentity,
1102}
1103
1104impl RuntimeTurnMetadata {
1105 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 pub fn merge(&mut self, other: Self) -> Result<(), TurnMetadataMergeConflict> {
1132 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1262pub struct StagedRunInput {
1263 pub boundary: RunApplyBoundary,
1265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1267 pub appends: Vec<ConversationAppend>,
1268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1270 pub context_appends: Vec<ConversationContextAppend>,
1271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1273 pub contributing_input_ids: Vec<InputId>,
1274 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub turn_metadata: Option<RuntimeTurnMetadata>,
1277}
1278
1279#[non_exhaustive]
1285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1286#[serde(tag = "primitive_type", rename_all = "snake_case")]
1287#[allow(clippy::large_enum_variant)]
1292pub enum RunPrimitive {
1293 StagedInput(StagedRunInput),
1295 ImmediateAppend(ConversationAppend),
1297 ImmediateContextAppend(ConversationContextAppend),
1299}
1300
1301impl RunPrimitive {
1302 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 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 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 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 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 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 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 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 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 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 #[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 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 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 #[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 #[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 assert_eq!(tag.effort, Some(AnthropicEffort::High));
2176 assert_eq!(
2177 tag.cache_control,
2178 Some(AnthropicCacheControlPolicy::Disabled)
2179 );
2180 assert_eq!(
2182 tag.web_search,
2183 Some(OpaqueProviderBody::from_value(
2184 &serde_json::json!({"type": "web_search_20250305"})
2185 ))
2186 );
2187 }
2188
2189 #[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 #[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 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 #[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 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}