Skip to main content

ds_api/conversation/
core.rs

1//! The `Conversation` struct — manages history and context-window compression.
2
3use futures::stream::BoxStream;
4
5use crate::api::{ApiClient, ApiRequest};
6use crate::error::{ApiError, Result};
7use crate::raw::request::message::{Message, Role};
8
9use crate::conversation::{LlmSummarizer, Summarizer};
10
11/// Maintains a conversation history and handles context-window compression.
12///
13/// This is the primary building block used by [`DeepseekAgent`][crate::agent::DeepseekAgent].
14/// You can also use it directly for simple back-and-forth conversations that do not need tools.
15///
16/// # Context management
17///
18/// By default the conversation uses [`LlmSummarizer`], which calls DeepSeek to write
19/// a concise summary of older turns once the estimated token count exceeds a threshold.
20/// Swap it out via [`with_summarizer`][Conversation::with_summarizer]:
21///
22/// ```no_run
23/// use ds_api::{ApiClient, conversation::Conversation, conversation::SlidingWindowSummarizer};
24///
25/// let conv = Conversation::new(ApiClient::new("sk-..."))
26///     .with_summarizer(SlidingWindowSummarizer::new(20));
27/// ```
28pub struct Conversation {
29    pub(crate) client: ApiClient,
30    pub(crate) history: Vec<Message>,
31    summarizer: Box<dyn Summarizer + Send + Sync>,
32    auto_summary: bool,
33}
34
35impl Conversation {
36    /// Create a new conversation backed by `client`.
37    ///
38    /// The default summarizer is [`LlmSummarizer`] with sensible defaults
39    /// (~60 000 estimated tokens trigger, retain last 10 turns).
40    pub fn new(client: ApiClient) -> Self {
41        let summarizer = LlmSummarizer::new(client.clone());
42        Self {
43            client,
44            history: vec![],
45            summarizer: Box::new(summarizer),
46            auto_summary: true,
47        }
48    }
49
50    // ── Builder methods ───────────────────────────────────────────────────────
51
52    /// Replace the summarizer.
53    pub fn with_summarizer(mut self, s: impl Summarizer + 'static) -> Self {
54        self.summarizer = Box::new(s);
55        self
56    }
57
58    /// Enable or disable automatic summarization (enabled by default).
59    pub fn enable_auto_summary(mut self, v: bool) -> Self {
60        self.auto_summary = v;
61        self
62    }
63
64    /// Seed the conversation with an existing message history.
65    pub fn with_history(mut self, history: Vec<Message>) -> Self {
66        self.history = history;
67        self
68    }
69
70    // ── History access ────────────────────────────────────────────────────────
71
72    /// Read-only view of the current history.
73    pub fn history(&self) -> &[Message] {
74        &self.history
75    }
76
77    /// Mutable access to the raw history (advanced use).
78    pub fn history_mut(&mut self) -> &mut Vec<Message> {
79        &mut self.history
80    }
81
82    // ── Mutation helpers ──────────────────────────────────────────────────────
83
84    /// Append an arbitrary message (any role) to the history.
85    pub fn add_message(&mut self, message: Message) {
86        self.history.push(message);
87    }
88
89    /// Append a `Role::User` message to the history.
90    pub fn push_user_input(&mut self, text: impl Into<String>) {
91        self.history.push(Message::new(Role::User, &text.into()));
92    }
93
94    // ── Summarization ─────────────────────────────────────────────────────────
95
96    /// Run the summarizer if the current history warrants it.
97    ///
98    /// Errors from the summarizer are silently swallowed so that a transient API
99    /// failure during summarization does not abort an ongoing conversation turn.
100    pub async fn maybe_summarize(&mut self) {
101        if !self.auto_summary {
102            return;
103        }
104        if !self.summarizer.should_summarize(&self.history) {
105            return;
106        }
107        let _ = self.summarizer.summarize(&mut self.history).await;
108    }
109
110    // ── Single-turn send ──────────────────────────────────────────────────────
111
112    /// Send the current history to the API as a single (non-streaming) request
113    /// and return the assistant's text content (if any).
114    ///
115    /// The assistant reply is automatically appended to the history.
116    /// Summarization is run both before the request and after the reply is received.
117    pub async fn send_once(&mut self) -> Result<Option<String>> {
118        self.maybe_summarize().await;
119
120        let req = ApiRequest::builder().messages(self.history.clone());
121        let resp = self.client.send(req).await?;
122
123        let choice = resp
124            .choices
125            .into_iter()
126            .next()
127            .ok_or_else(|| ApiError::Other("empty choices from API".to_string()))?;
128
129        let assistant_msg = choice.message;
130        let content = assistant_msg.content.clone();
131        self.history.push(assistant_msg);
132
133        self.maybe_summarize().await;
134
135        Ok(content)
136    }
137
138    /// Stream text fragments (`delta.content`) from the API as a
139    /// `BoxStream<Result<String, ApiError>>`.
140    ///
141    /// # ⚠ Caller responsibilities
142    ///
143    /// Unlike [`send_once`][Conversation::send_once], this method is intentionally
144    /// minimal: it does **not** append the assistant reply to history, does **not**
145    /// run summarization, and does **not** set `stream: true` on the request for
146    /// you (the underlying [`ApiClient::stream_text`] handles that).
147    ///
148    /// If you want the conversation to remember this turn you must collect the
149    /// full text and push it yourself:
150    ///
151    /// ```no_run
152    /// use futures::StreamExt;
153    /// use ds_api::{ApiClient, conversation::Conversation};
154    /// use ds_api::raw::request::message::{Message, Role};
155    ///
156    /// # #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> {
157    /// let mut conv = Conversation::new(ApiClient::new("sk-..."));
158    /// conv.push_user_input("Tell me a joke.");
159    ///
160    /// let mut text = String::new();
161    /// let mut stream = conv.stream_text().await?;
162    /// while let Some(fragment) = stream.next().await {
163    ///     text.push_str(&fragment?);
164    /// }
165    /// drop(stream); // release the borrow on `conv`
166    ///
167    /// // Manually record the assistant turn so the next call sees it.
168    /// conv.add_message(Message::new(Role::Assistant, &text));
169    /// # Ok(())
170    /// # }
171    /// ```
172    ///
173    /// Skipping this step means the assistant's reply is silently absent from
174    /// history on the next turn.  [`send_once`][Conversation::send_once] does
175    /// all of this automatically and should be preferred unless you specifically
176    /// need incremental token delivery.
177    pub async fn stream_text(
178        &mut self,
179    ) -> Result<BoxStream<'_, std::result::Result<String, ApiError>>> {
180        let req = ApiRequest::builder()
181            .messages(self.history.clone())
182            .stream(true);
183        self.client.stream_text(req).await
184    }
185}
186
187// ── Tests ─────────────────────────────────────────────────────────────────────
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn fake() -> Conversation {
194        Conversation::new(ApiClient::new("fake-token"))
195    }
196
197    #[test]
198    fn new_has_empty_history() {
199        assert!(fake().history().is_empty());
200    }
201
202    #[test]
203    fn with_history_seeds_messages() {
204        let msgs = vec![Message::new(Role::User, "hi")];
205        let conv = fake().with_history(msgs);
206        assert_eq!(conv.history().len(), 1);
207    }
208
209    #[test]
210    fn push_user_input_appends_user_role() {
211        let mut conv = fake();
212        conv.push_user_input("hello");
213        assert_eq!(conv.history().len(), 1);
214        assert!(matches!(conv.history()[0].role, Role::User));
215    }
216
217    #[test]
218    fn add_message_appends() {
219        let mut conv = fake();
220        conv.add_message(Message::new(Role::Assistant, "hi"));
221        assert_eq!(conv.history().len(), 1);
222        assert!(matches!(conv.history()[0].role, Role::Assistant));
223    }
224
225    #[test]
226    fn enable_auto_summary_false() {
227        let conv = fake().enable_auto_summary(false);
228        assert!(!conv.auto_summary);
229    }
230}