Skip to main content

systemprompt_agent/services/
context.rs

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