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 /// Unlike [`send_once`][Conversation::send_once], this method does **not**
142 /// automatically append the assistant reply or run summarization — the caller
143 /// is responsible for collecting the stream and updating history if needed.
144 pub async fn stream_text(
145 &mut self,
146 ) -> Result<BoxStream<'_, std::result::Result<String, ApiError>>> {
147 let req = ApiRequest::builder()
148 .messages(self.history.clone())
149 .stream(true);
150 self.client.stream_text(req).await
151 }
152}
153
154// ── Tests ─────────────────────────────────────────────────────────────────────
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn fake() -> Conversation {
161 Conversation::new(ApiClient::new("fake-token"))
162 }
163
164 #[test]
165 fn new_has_empty_history() {
166 assert!(fake().history().is_empty());
167 }
168
169 #[test]
170 fn with_history_seeds_messages() {
171 let msgs = vec![Message::new(Role::User, "hi")];
172 let conv = fake().with_history(msgs);
173 assert_eq!(conv.history().len(), 1);
174 }
175
176 #[test]
177 fn push_user_input_appends_user_role() {
178 let mut conv = fake();
179 conv.push_user_input("hello");
180 assert_eq!(conv.history().len(), 1);
181 assert!(matches!(conv.history()[0].role, Role::User));
182 }
183
184 #[test]
185 fn add_message_appends() {
186 let mut conv = fake();
187 conv.add_message(Message::new(Role::Assistant, "hi"));
188 assert_eq!(conv.history().len(), 1);
189 assert!(matches!(conv.history()[0].role, Role::Assistant));
190 }
191
192 #[test]
193 fn enable_auto_summary_false() {
194 let conv = fake().enable_auto_summary(false);
195 assert!(!conv.auto_summary);
196 }
197}