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    /// 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}