Skip to main content

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))]
27// The serde impls below are hand-written, so utoipa cannot infer the wire
28// casing from a `serde(rename_all)` attribute and would otherwise publish the
29// Rust variant names. Keep this in lockstep with `as_provider_str`.
30#[cfg_attr(feature = "openapi", schema(rename_all = "snake_case"))]
31pub enum ExecutionPhase {
32    /// Intermediate update — preamble or commentary before/between tool calls.
33    /// The model is still working and may issue more tool calls.
34    Commentary,
35    /// Final completed response — no more tool calls expected.
36    FinalAnswer,
37}
38
39/// Where a message's [`ExecutionPhase`] came from.
40///
41/// Only some providers report a phase. For the rest the runtime infers one from
42/// tool-call presence, where "commentary" means nothing more than "this message
43/// called tools" — so a text-only preamble is indistinguishable from a final
44/// answer. Those are different claims, and a consumer cannot tell them apart
45/// from the phase value alone, so the source travels with it.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[cfg_attr(feature = "openapi", derive(ToSchema))]
48#[cfg_attr(feature = "openapi", schema(rename_all = "snake_case"))]
49pub enum PhaseSource {
50    /// The provider reported this phase on the wire.
51    Provider,
52    /// The runtime inferred it from tool-call presence. A weak signal.
53    Derived,
54}
55
56impl PhaseSource {
57    pub fn as_str(&self) -> &'static str {
58        match self {
59            Self::Provider => "provider",
60            Self::Derived => "derived",
61        }
62    }
63
64    pub fn from_str_opt(s: &str) -> Option<Self> {
65        match s {
66            "provider" => Some(Self::Provider),
67            "derived" => Some(Self::Derived),
68            _ => None,
69        }
70    }
71}
72
73impl Serialize for PhaseSource {
74    fn serialize<S: serde::Serializer>(
75        &self,
76        serializer: S,
77    ) -> std::result::Result<S::Ok, S::Error> {
78        serializer.serialize_str(self.as_str())
79    }
80}
81
82impl<'de> Deserialize<'de> for PhaseSource {
83    fn deserialize<D: serde::Deserializer<'de>>(
84        deserializer: D,
85    ) -> std::result::Result<Self, D::Error> {
86        let s = String::deserialize(deserializer)?;
87        Self::from_str_opt(&s)
88            .ok_or_else(|| serde::de::Error::unknown_variant(&s, &["provider", "derived"]))
89    }
90}
91
92impl ExecutionPhase {
93    /// Derive phase from whether the response contains tool calls.
94    pub fn from_has_tool_calls(has_tool_calls: bool) -> Self {
95        if has_tool_calls {
96            Self::Commentary
97        } else {
98            Self::FinalAnswer
99        }
100    }
101
102    /// Parse a provider wire value into an ExecutionPhase.
103    /// Returns `None` for unrecognized values.
104    pub fn from_provider_str(s: &str) -> Option<Self> {
105        match s {
106            "commentary" | "in_progress" => Some(Self::Commentary),
107            "final_answer" | "completed" => Some(Self::FinalAnswer),
108            _ => None,
109        }
110    }
111
112    /// Wire value used by providers that support native phases (OpenAI).
113    pub fn as_provider_str(&self) -> &'static str {
114        match self {
115            Self::Commentary => "commentary",
116            Self::FinalAnswer => "final_answer",
117        }
118    }
119
120    /// Monotonic refinement for the streamed phase *hint* on
121    /// `output.message.started` / `output.message.delta`.
122    ///
123    /// The hint may advance `None -> Commentary | FinalAnswer` exactly once and
124    /// then never changes: it never flip-flops between variants and never
125    /// reverts to `None`. Once a message has been classified mid-stream that
126    /// classification stays put; the authoritative value remains the completed
127    /// `Message.phase`. Returns the (possibly unchanged) refined hint.
128    pub fn refine_streamed_hint(current: Option<Self>, incoming: Self) -> Option<Self> {
129        // First classification wins; a later hint cannot overwrite it.
130        Some(current.unwrap_or(incoming))
131    }
132}
133
134impl std::fmt::Display for ExecutionPhase {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(self.as_provider_str())
137    }
138}
139
140impl Serialize for ExecutionPhase {
141    fn serialize<S: serde::Serializer>(
142        &self,
143        serializer: S,
144    ) -> std::result::Result<S::Ok, S::Error> {
145        serializer.serialize_str(self.as_provider_str())
146    }
147}
148
149impl<'de> Deserialize<'de> for ExecutionPhase {
150    fn deserialize<D: serde::Deserializer<'de>>(
151        deserializer: D,
152    ) -> std::result::Result<Self, D::Error> {
153        let s = String::deserialize(deserializer)?;
154        match s.as_str() {
155            "commentary" | "in_progress" => Ok(Self::Commentary),
156            "final_answer" | "completed" => Ok(Self::FinalAnswer),
157            other => Err(serde::de::Error::unknown_variant(
158                other,
159                &["commentary", "final_answer", "in_progress", "completed"],
160            )),
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_execution_phase_from_provider_str() {
171        assert_eq!(
172            ExecutionPhase::from_provider_str("commentary"),
173            Some(ExecutionPhase::Commentary)
174        );
175        assert_eq!(
176            ExecutionPhase::from_provider_str("final_answer"),
177            Some(ExecutionPhase::FinalAnswer)
178        );
179        assert_eq!(
180            ExecutionPhase::from_provider_str("in_progress"),
181            Some(ExecutionPhase::Commentary)
182        );
183        assert_eq!(
184            ExecutionPhase::from_provider_str("completed"),
185            Some(ExecutionPhase::FinalAnswer)
186        );
187        assert_eq!(ExecutionPhase::from_provider_str("unknown"), None);
188    }
189
190    #[test]
191    fn tool_call_presence_derives_execution_phase() {
192        assert_eq!(
193            ExecutionPhase::from_has_tool_calls(true),
194            ExecutionPhase::Commentary
195        );
196        assert_eq!(
197            ExecutionPhase::from_has_tool_calls(false),
198            ExecutionPhase::FinalAnswer
199        );
200    }
201
202    #[test]
203    fn streamed_hint_keeps_first_classification() {
204        use ExecutionPhase::{Commentary, FinalAnswer};
205        for (current, incoming, expected) in [
206            (None, Commentary, Commentary),
207            (None, FinalAnswer, FinalAnswer),
208            (Some(Commentary), Commentary, Commentary),
209            (Some(Commentary), FinalAnswer, Commentary),
210            (Some(FinalAnswer), Commentary, FinalAnswer),
211            (Some(FinalAnswer), FinalAnswer, FinalAnswer),
212        ] {
213            assert_eq!(
214                ExecutionPhase::refine_streamed_hint(current, incoming),
215                Some(expected)
216            );
217        }
218    }
219
220    #[test]
221    fn phase_wire_values_accept_legacy_spellings_and_reject_invalid_input() {
222        for (input, phase, canonical) in [
223            ("commentary", ExecutionPhase::Commentary, "commentary"),
224            ("in_progress", ExecutionPhase::Commentary, "commentary"),
225            ("final_answer", ExecutionPhase::FinalAnswer, "final_answer"),
226            ("completed", ExecutionPhase::FinalAnswer, "final_answer"),
227        ] {
228            let decoded: ExecutionPhase = serde_json::from_value(serde_json::json!(input)).unwrap();
229            assert_eq!(decoded, phase);
230            assert_eq!(
231                serde_json::to_value(decoded).unwrap(),
232                serde_json::json!(canonical)
233            );
234            assert_eq!(decoded.as_provider_str(), canonical);
235            assert_eq!(decoded.to_string(), canonical);
236        }
237        for input in [
238            serde_json::json!("bogus"),
239            serde_json::json!("Commentary"),
240            serde_json::Value::Null,
241            serde_json::json!(42),
242        ] {
243            assert!(serde_json::from_value::<ExecutionPhase>(input).is_err());
244        }
245    }
246}