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}