Skip to main content

agentic_core/executor/modes/
conversation.rs

1//! Conversation storage handler — owns all conversation store operations.
2
3use crate::storage::{ConversationData, ConversationStore, InOutItem, ResponseMetadata};
4use crate::types::io::OutputItem;
5
6use crate::executor::error::{ExecutorError, ExecutorResult};
7use crate::executor::request::RequestContext;
8
9/// Handles all conversation store operations: creation, rehydration, and persistence.
10#[derive(Clone, Debug)]
11pub struct ConversationHandler {
12    store: ConversationStore,
13}
14
15impl ConversationHandler {
16    #[must_use]
17    pub fn new(store: ConversationStore) -> Self {
18        Self { store }
19    }
20
21    /// Gets an existing conversation or creates one.
22    ///
23    /// Reads `conversation_id` from `ctx.original_request`.
24    ///
25    /// # Errors
26    /// Returns `ExecutorError` if `conversation_id` is absent, the store is
27    /// disabled, or the database query fails.
28    pub async fn get_or_create(&self, ctx: &RequestContext) -> ExecutorResult<ConversationData> {
29        let conv_id = ctx
30            .original_request
31            .conversation_id
32            .as_deref()
33            .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for get_or_create".into()))?;
34        self.store.get_or_create(conv_id).await.map_err(ExecutorError::Storage)
35    }
36
37    /// Gets an existing conversation.
38    ///
39    /// Reads `conversation_id` from `ctx.original_request`.
40    ///
41    /// # Errors
42    /// Returns `ExecutorError` if `conversation_id` is absent, the store is
43    /// disabled, the conversation does not exist, or the database query fails.
44    pub async fn get(&self, ctx: &RequestContext) -> ExecutorResult<ConversationData> {
45        let conv_id = ctx
46            .original_request
47            .conversation_id
48            .as_deref()
49            .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for get".into()))?;
50        self.store.get(conv_id).await.map_err(ExecutorError::Storage)
51    }
52
53    /// Creates a brand-new conversation with a freshly generated ID.
54    ///
55    /// # Errors
56    /// Returns `ExecutorError` if the store is disabled or the database query fails.
57    pub async fn create(&self) -> ExecutorResult<ConversationData> {
58        self.store.create().await.map_err(ExecutorError::Storage)
59    }
60
61    /// Loads all history items for the conversation referenced by the request.
62    ///
63    /// Reads `conversation_id` from `ctx.original_request`. Returns an empty vec
64    /// if the conversation exists but has no items yet.
65    ///
66    /// # Errors
67    /// Returns `ExecutorError` if `conversation_id` is absent, the store is
68    /// disabled, or the database query fails.
69    pub async fn rehydrate(&self, ctx: &RequestContext) -> ExecutorResult<Vec<InOutItem>> {
70        let conv_id = ctx
71            .original_request
72            .conversation_id
73            .as_deref()
74            .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for rehydrate".into()))?;
75        self.store.rehydrate(conv_id).await.map_err(ExecutorError::Storage)
76    }
77
78    /// Persists one conversation turn — only the new items from this turn.
79    ///
80    /// Takes `ctx` and `output_items` by value so fields can be moved directly
81    /// into [`ResponseMetadata`] without cloning. The store tracks sequence
82    /// numbers and appends, so prior history must not be re-inserted.
83    ///
84    /// # Errors
85    /// Returns `ExecutorError` if `conversation_id` is absent on the context,
86    /// the store is disabled, or the database operation fails.
87    pub async fn execute_turn(&self, ctx: RequestContext, output_items: Vec<OutputItem>) -> ExecutorResult<()> {
88        let conversation_id = ctx
89            .conversation_id
90            .ok_or_else(|| ExecutorError::InvalidRequest("conversation_id is required for execute_turn".into()))?;
91
92        let metadata = ResponseMetadata {
93            model: ctx.enriched_request.model,
94            previous_response_id: ctx.original_request.previous_response_id,
95            effective_tools: ctx.enriched_request.tools,
96            effective_tool_choice: ctx.enriched_request.tool_choice.unwrap_or_default(),
97            effective_instructions: ctx.enriched_request.instructions,
98        };
99
100        let mut new_items = Vec::with_capacity(ctx.new_input_items.len() + output_items.len());
101        new_items.extend(ctx.new_input_items.into_iter().map(InOutItem::Input));
102        new_items.extend(output_items.into_iter().map(InOutItem::Output));
103
104        self.store
105            .persist(
106                &conversation_id,
107                &ctx.response_id,
108                metadata.previous_response_id.as_deref(),
109                new_items,
110                &metadata,
111            )
112            .await
113            .map_err(ExecutorError::Storage)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::types::io::ResponsesInput;
121    use crate::types::request_response::RequestPayload;
122
123    fn disabled_handler() -> ConversationHandler {
124        ConversationHandler::new(ConversationStore::disabled())
125    }
126
127    fn make_ctx(conversation_id: Option<&str>) -> RequestContext {
128        let req = RequestPayload {
129            model: "test".into(),
130            input: ResponsesInput::Text("hi".into()),
131            instructions: None,
132            previous_response_id: None,
133            conversation_id: conversation_id.map(str::to_string),
134            tools: None,
135            tool_choice: None,
136            stream: false,
137            store: true,
138            include: None,
139            temperature: None,
140            top_p: None,
141            max_output_tokens: None,
142            truncation: None,
143            metadata: None,
144        };
145        RequestContext {
146            enriched_request: req.clone(),
147            original_request: req,
148            new_input_items: vec![],
149            response_id: "resp_test".into(),
150            conversation_id: conversation_id.map(str::to_string),
151        }
152    }
153
154    #[tokio::test]
155    async fn test_get_or_create_missing_id_returns_error() {
156        let result = disabled_handler().get_or_create(&make_ctx(None)).await;
157        assert!(result.is_err());
158    }
159
160    #[tokio::test]
161    async fn test_rehydrate_missing_id_returns_error() {
162        let result = disabled_handler().rehydrate(&make_ctx(None)).await;
163        assert!(result.is_err());
164    }
165
166    #[tokio::test]
167    async fn test_get_or_create_disabled_store_returns_error() {
168        let result = disabled_handler().get_or_create(&make_ctx(Some("conv_1"))).await;
169        assert!(result.is_err());
170    }
171
172    #[tokio::test]
173    async fn test_get_disabled_store_returns_error() {
174        let result = disabled_handler().get(&make_ctx(Some("conv_1"))).await;
175        assert!(result.is_err());
176    }
177
178    #[tokio::test]
179    async fn test_rehydrate_disabled_store_returns_error() {
180        let result = disabled_handler().rehydrate(&make_ctx(Some("conv_1"))).await;
181        assert!(result.is_err());
182    }
183
184    #[tokio::test]
185    async fn test_execute_turn_missing_conv_id_returns_error() {
186        let result = disabled_handler().execute_turn(make_ctx(None), vec![]).await;
187        assert!(result.is_err());
188    }
189}