Skip to main content

aither_core/llm/
reasoning.rs

1//! Provider-opaque reasoning state.
2
3use alloc::string::String;
4
5/// Reasoning state that a provider requires to be handed back unmodified.
6///
7/// Current models do not treat their own reasoning as throwaway. Anthropic
8/// requires thinking blocks to be replayed "complete and unmodified" across a
9/// tool-use turn, `OpenAI`'s stateless flow needs the encrypted reasoning item
10/// echoed back into the next request, and Gemini attaches a thought signature
11/// to each function call. Dropping any of them degrades multi-turn tool use
12/// quietly: the model loses the reasoning that produced the very call it is
13/// being handed a result for.
14///
15/// The payload is provider-encoded and deliberately opaque. Core never parses
16/// it, and neither should anything else — the encoding belongs to the provider,
17/// the meaning belongs to the provider, and the only correct operation is to
18/// give it back. Encoding the whole block rather than its parts is what lets
19/// one type carry Anthropic's `thinking` *and* `redacted_thinking` blocks,
20/// whose shapes differ, without core knowing either.
21///
22/// # Provider tagging
23///
24/// State is tied to the model that produced it. Anthropic documents that
25/// thinking blocks must be stripped when switching models, and cross-provider
26/// replay is at best ignored and at worst rejected. Every value therefore
27/// records its origin, and providers read it back with
28/// [`payload_for`](Self::payload_for) rather than trusting whatever the
29/// conversation happens to carry.
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct ReasoningState {
33    provider: String,
34    payload: String,
35}
36
37impl ReasoningState {
38    /// Creates reasoning state tagged with the provider that produced it.
39    ///
40    /// `provider` should be the stable short name the provider crate uses for
41    /// itself (`"claude"`, `"openai"`, `"gemini"`), and `payload` its own
42    /// encoding of the block, which it alone will decode.
43    #[must_use]
44    pub fn new(provider: impl Into<String>, payload: impl Into<String>) -> Self {
45        Self {
46            provider: provider.into(),
47            payload: payload.into(),
48        }
49    }
50
51    /// The provider that produced this state.
52    #[must_use]
53    pub fn provider(&self) -> &str {
54        &self.provider
55    }
56
57    /// The opaque payload, without checking its origin.
58    ///
59    /// Prefer [`payload_for`](Self::payload_for) when about to put the value on
60    /// the wire; this accessor is for logging and inspection.
61    #[must_use]
62    pub fn payload(&self) -> &str {
63        &self.payload
64    }
65
66    /// The payload, but only if this state came from `provider`.
67    ///
68    /// Returns `None` for foreign state so callers drop it instead of replaying
69    /// it into an API that cannot verify it.
70    #[must_use]
71    pub fn payload_for(&self, provider: &str) -> Option<&str> {
72        (self.provider == provider).then_some(self.payload.as_str())
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use alloc::vec::Vec;
80
81    #[test]
82    fn payload_is_returned_only_to_its_own_provider() {
83        let state = ReasoningState::new("claude", "opaque-signature");
84        assert_eq!(state.payload_for("claude"), Some("opaque-signature"));
85        assert_eq!(state.payload_for("openai"), None);
86        assert_eq!(state.payload_for("gemini"), None);
87    }
88
89    /// The documented behaviour when switching models mid-conversation is to
90    /// strip foreign reasoning, so filtering must leave a usable remainder
91    /// rather than failing the whole turn.
92    #[test]
93    fn filtering_a_mixed_conversation_keeps_only_native_state() {
94        let states = [
95            ReasoningState::new("claude", "a"),
96            ReasoningState::new("openai", "b"),
97            ReasoningState::new("claude", "c"),
98        ];
99        let kept: Vec<&str> = states
100            .iter()
101            .filter_map(|state| state.payload_for("claude"))
102            .collect();
103        assert_eq!(kept, ["a", "c"]);
104    }
105}