everruns_provider/execution_phase.rs
1//! Execution phase for assistant messages in multi-step tool-calling flows.
2//!
3//! This is a provider-wire concept: phases are parsed off the provider stream
4//! and, for providers with native support, sent back on the request. It lives in
5//! the provider abstraction so both the driver types (`LlmMessage`,
6//! `LlmStreamEvent`) and core's `Message` can share it.
7
8use serde::{Deserialize, Serialize};
9
10#[cfg(feature = "openapi")]
11use utoipa::ToSchema;
12
13/// Execution phase for assistant messages in multi-step tool-calling flows.
14///
15/// Providers that natively support phases (OpenAI GPT-5.x) send the phase value
16/// directly in the API request. For providers without native support (Anthropic,
17/// Gemini), the phase is still tracked internally and derived from state in the
18/// ReasonAtom, but is not sent to the provider API.
19///
20/// Serialized as lowercase strings for backward compatibility with existing
21/// persisted messages: `"commentary"` and `"final_answer"`.
22///
23/// Legacy values `"in_progress"` and `"completed"` are accepted during
24/// deserialization for backward compatibility.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[cfg_attr(feature = "openapi", derive(ToSchema))]
27pub enum ExecutionPhase {
28 /// Intermediate update — preamble or commentary before/between tool calls.
29 /// The model is still working and may issue more tool calls.
30 Commentary,
31 /// Final completed response — no more tool calls expected.
32 FinalAnswer,
33}
34
35impl ExecutionPhase {
36 /// Derive phase from whether the response contains tool calls.
37 pub fn from_has_tool_calls(has_tool_calls: bool) -> Self {
38 if has_tool_calls {
39 Self::Commentary
40 } else {
41 Self::FinalAnswer
42 }
43 }
44
45 /// Parse a provider wire value into an ExecutionPhase.
46 /// Returns `None` for unrecognized values.
47 pub fn from_provider_str(s: &str) -> Option<Self> {
48 match s {
49 "commentary" | "in_progress" => Some(Self::Commentary),
50 "final_answer" | "completed" => Some(Self::FinalAnswer),
51 _ => None,
52 }
53 }
54
55 /// Wire value used by providers that support native phases (OpenAI).
56 pub fn as_provider_str(&self) -> &'static str {
57 match self {
58 Self::Commentary => "commentary",
59 Self::FinalAnswer => "final_answer",
60 }
61 }
62
63 /// Monotonic refinement for the streamed phase *hint* on
64 /// `output.message.started` / `output.message.delta`.
65 ///
66 /// The hint may advance `None -> Commentary | FinalAnswer` exactly once and
67 /// then never changes: it never flip-flops between variants and never
68 /// reverts to `None`. Once a message has been classified mid-stream that
69 /// classification stays put; the authoritative value remains the completed
70 /// `Message.phase`. Returns the (possibly unchanged) refined hint.
71 pub fn refine_streamed_hint(current: Option<Self>, incoming: Self) -> Option<Self> {
72 // First classification wins; a later hint cannot overwrite it.
73 Some(current.unwrap_or(incoming))
74 }
75}
76
77impl std::fmt::Display for ExecutionPhase {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.write_str(self.as_provider_str())
80 }
81}
82
83impl Serialize for ExecutionPhase {
84 fn serialize<S: serde::Serializer>(
85 &self,
86 serializer: S,
87 ) -> std::result::Result<S::Ok, S::Error> {
88 serializer.serialize_str(self.as_provider_str())
89 }
90}
91
92impl<'de> Deserialize<'de> for ExecutionPhase {
93 fn deserialize<D: serde::Deserializer<'de>>(
94 deserializer: D,
95 ) -> std::result::Result<Self, D::Error> {
96 let s = String::deserialize(deserializer)?;
97 match s.as_str() {
98 "commentary" | "in_progress" => Ok(Self::Commentary),
99 "final_answer" | "completed" => Ok(Self::FinalAnswer),
100 other => Err(serde::de::Error::unknown_variant(
101 other,
102 &["commentary", "final_answer", "in_progress", "completed"],
103 )),
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn test_execution_phase_from_provider_str() {
114 assert_eq!(
115 ExecutionPhase::from_provider_str("commentary"),
116 Some(ExecutionPhase::Commentary)
117 );
118 assert_eq!(
119 ExecutionPhase::from_provider_str("final_answer"),
120 Some(ExecutionPhase::FinalAnswer)
121 );
122 assert_eq!(
123 ExecutionPhase::from_provider_str("in_progress"),
124 Some(ExecutionPhase::Commentary)
125 );
126 assert_eq!(
127 ExecutionPhase::from_provider_str("completed"),
128 Some(ExecutionPhase::FinalAnswer)
129 );
130 assert_eq!(ExecutionPhase::from_provider_str("unknown"), None);
131 }
132}