Skip to main content

systemprompt_agent/services/a2a_server/processing/
conversation_service.rs

1//! Conversation-history assembly for AI requests.
2//!
3//! [`ConversationService`] reads prior tasks for a context and flattens their
4//! messages and artifacts into a `Vec<AiMessage>`, decoding supported file
5//! parts into [`AiContentPart`]s and serializing artifacts back into textual
6//! context.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use crate::services::shared::{AgentServiceError, Result};
12use base64::Engine;
13use systemprompt_identifiers::ContextId;
14use systemprompt_models::{
15    AiContentPart, AiMessage, MessageRole, is_supported_audio, is_supported_image,
16    is_supported_text, is_supported_video,
17};
18
19use crate::models::a2a::{FilePart, MessageRole as A2aMessageRole, Part};
20use crate::models::{Artifact, Message};
21use crate::repository::task::TaskRepository;
22
23#[derive(Debug)]
24pub struct ConversationService {
25    task_repo: TaskRepository,
26}
27
28impl ConversationService {
29    #[must_use]
30    pub const fn new(task_repo: TaskRepository) -> Self {
31        Self { task_repo }
32    }
33
34    pub async fn load_conversation_history(
35        &self,
36        context_id: &ContextId,
37    ) -> Result<Vec<AiMessage>> {
38        let tasks = self
39            .task_repo
40            .list_tasks_by_context(context_id)
41            .await
42            .map_err(|e| {
43                AgentServiceError::Internal(format!("Failed to load conversation history: {e}"))
44            })?;
45
46        let mut history_messages = Vec::new();
47
48        for task in tasks {
49            if let Some(task_history) = task.history {
50                for msg in task_history {
51                    let (text, parts) = Self::extract_message_content(&msg);
52                    if text.is_empty() && parts.is_empty() {
53                        continue;
54                    }
55
56                    let role = match msg.role {
57                        A2aMessageRole::User => MessageRole::User,
58                        A2aMessageRole::Agent => MessageRole::Assistant,
59                    };
60
61                    history_messages.push(AiMessage {
62                        role,
63                        content: text,
64                        parts,
65                    });
66                }
67            }
68
69            if let Some(artifacts) = task.artifacts {
70                for artifact in artifacts {
71                    let artifact_content = Self::serialize_artifact_for_context(&artifact);
72                    history_messages.push(AiMessage {
73                        role: MessageRole::Assistant,
74                        content: artifact_content,
75                        parts: Vec::new(),
76                    });
77                }
78            }
79        }
80
81        Ok(history_messages)
82    }
83
84    #[doc(hidden)]
85    pub fn extract_message_content(message: &Message) -> (String, Vec<AiContentPart>) {
86        let mut text_content = String::new();
87        let mut content_parts = Vec::new();
88
89        for part in &message.parts {
90            match part {
91                Part::Text(text_part) => {
92                    if text_content.is_empty() {
93                        text_content.clone_from(&text_part.text);
94                    }
95                    content_parts.push(AiContentPart::text(&text_part.text));
96                },
97                Part::File(file_part) => {
98                    if let Some(content_part) = Self::file_to_content_part(file_part) {
99                        content_parts.push(content_part);
100                    }
101                },
102                Part::Data(_) => {},
103            }
104        }
105
106        (text_content, content_parts)
107    }
108
109    #[doc(hidden)]
110    pub fn file_to_content_part(file_part: &FilePart) -> Option<AiContentPart> {
111        let mime_type = file_part.file.mime_type.as_deref()?;
112        let file_name = file_part.file.name.as_deref().unwrap_or("unnamed");
113
114        let bytes = file_part.file.bytes.as_deref()?;
115
116        if is_supported_image(mime_type) {
117            return Some(AiContentPart::image(mime_type, bytes));
118        }
119
120        if is_supported_audio(mime_type) {
121            return Some(AiContentPart::audio(mime_type, bytes));
122        }
123
124        if is_supported_video(mime_type) {
125            return Some(AiContentPart::video(mime_type, bytes));
126        }
127
128        if is_supported_text(mime_type) {
129            return Self::decode_text_file(bytes, file_name, mime_type);
130        }
131
132        tracing::warn!(
133            file_name = %file_name,
134            mime_type = %mime_type,
135            "Unsupported file type - file will not be sent to AI"
136        );
137        None
138    }
139
140    #[doc(hidden)]
141    pub fn decode_text_file(
142        bytes: &str,
143        file_name: &str,
144        mime_type: &str,
145    ) -> Option<AiContentPart> {
146        let decoded = base64::engine::general_purpose::STANDARD
147            .decode(bytes)
148            .map_err(|e| {
149                tracing::warn!(
150                    file_name = %file_name,
151                    mime_type = %mime_type,
152                    error = %e,
153                    "Failed to decode base64 text file"
154                );
155                e
156            })
157            .ok()?;
158
159        let text_content = String::from_utf8(decoded)
160            .map_err(|e| {
161                tracing::warn!(
162                    file_name = %file_name,
163                    mime_type = %mime_type,
164                    error = %e,
165                    "Failed to decode text file as UTF-8"
166                );
167                e
168            })
169            .ok()?;
170
171        let formatted = format!("[File: {file_name} ({mime_type})]\n{text_content}");
172        Some(AiContentPart::text(formatted))
173    }
174
175    #[doc(hidden)]
176    pub fn serialize_artifact_for_context(artifact: &Artifact) -> String {
177        let artifact_name = artifact
178            .title
179            .clone()
180            .unwrap_or_else(|| "unnamed".to_owned());
181
182        let mut content = format!(
183            "[Artifact: {} (type: {})]\n",
184            artifact_name, artifact.metadata.artifact_type
185        );
186
187        for part in &artifact.parts {
188            match part {
189                Part::Text(text_part) => {
190                    content.push_str(&text_part.text);
191                    content.push('\n');
192                },
193                Part::Data(data_part) => {
194                    let json_str = serde_json::to_string_pretty(&data_part.data)
195                        .unwrap_or_else(|_| "{}".to_owned());
196                    content.push_str(&json_str);
197                    content.push('\n');
198                },
199                Part::File(file_part) => {
200                    if let Some(name) = &file_part.file.name {
201                        content.push_str(&format!("[File: {}]\n", name));
202                    }
203                },
204            }
205        }
206
207        content
208    }
209}