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