Skip to main content

roder_api/
artifacts.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5
6use crate::events::{ThreadId, TurnId};
7
8pub type ContextArtifactId = String;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum ContextArtifactKind {
13    ToolOutput,
14    CommandStdout,
15    CommandStderr,
16    TerminalTranscript,
17    ChatHistory,
18    CompactionSource,
19    ContextProviderDump,
20}
21
22impl ContextArtifactKind {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Self::ToolOutput => "tool_output",
26            Self::CommandStdout => "command_stdout",
27            Self::CommandStderr => "command_stderr",
28            Self::TerminalTranscript => "terminal_transcript",
29            Self::ChatHistory => "chat_history",
30            Self::CompactionSource => "compaction_source",
31            Self::ContextProviderDump => "context_provider_dump",
32        }
33    }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub struct ContextArtifact {
39    pub id: ContextArtifactId,
40    pub kind: ContextArtifactKind,
41    pub thread_id: ThreadId,
42    pub turn_id: TurnId,
43    pub byte_count: u64,
44    pub line_count: u64,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub source_tool_id: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub label: Option<String>,
49    pub store_path: String,
50    #[serde(
51        default,
52        with = "time::serde::rfc3339::option",
53        skip_serializing_if = "Option::is_none"
54    )]
55    pub retention_expires_at: Option<OffsetDateTime>,
56    #[serde(with = "time::serde::rfc3339")]
57    pub created_at: OffsetDateTime,
58    #[serde(default = "default_roder_owned")]
59    pub roder_owned: bool,
60}
61
62impl ContextArtifact {
63    pub fn descriptor(&self) -> ContextArtifactDescriptor {
64        ContextArtifactDescriptor {
65            id: self.id.clone(),
66            kind: self.kind.clone(),
67            thread_id: self.thread_id.clone(),
68            turn_id: self.turn_id.clone(),
69            byte_count: self.byte_count,
70            line_count: self.line_count,
71            source_tool_id: self.source_tool_id.clone(),
72            label: self.label.clone(),
73            retention_expires_at: self.retention_expires_at,
74            created_at: self.created_at,
75        }
76    }
77}
78
79fn default_roder_owned() -> bool {
80    true
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "camelCase")]
85pub struct ContextArtifactDescriptor {
86    pub id: ContextArtifactId,
87    pub kind: ContextArtifactKind,
88    pub thread_id: ThreadId,
89    pub turn_id: TurnId,
90    pub byte_count: u64,
91    pub line_count: u64,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub source_tool_id: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub label: Option<String>,
96    #[serde(
97        default,
98        with = "time::serde::rfc3339::option",
99        skip_serializing_if = "Option::is_none"
100    )]
101    pub retention_expires_at: Option<OffsetDateTime>,
102    #[serde(with = "time::serde::rfc3339")]
103    pub created_at: OffsetDateTime,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(rename_all = "camelCase")]
108pub struct ArtifactReadPage {
109    pub artifact: ContextArtifactDescriptor,
110    pub text: String,
111    pub start_line: usize,
112    pub limit: usize,
113    pub shown: usize,
114    pub total_lines: usize,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub next_start_line: Option<usize>,
117    pub truncated: bool,
118}
119
120#[derive(Debug, Clone)]
121pub struct CreateArtifactRequest<'a> {
122    pub kind: ContextArtifactKind,
123    pub thread_id: &'a ThreadId,
124    pub turn_id: &'a TurnId,
125    pub source_tool_id: Option<&'a str>,
126    pub label: Option<&'a str>,
127    pub bytes: &'a [u8],
128}
129
130#[derive(Clone)]
131pub struct ContextArtifactStore {
132    backend: Arc<dyn ContextArtifactAccess>,
133}
134
135impl ContextArtifactStore {
136    pub fn new(backend: Arc<dyn ContextArtifactAccess>) -> Self {
137        Self { backend }
138    }
139
140    pub fn backend(&self) -> Arc<dyn ContextArtifactAccess> {
141        Arc::clone(&self.backend)
142    }
143
144    pub fn create(&self, request: CreateArtifactRequest<'_>) -> anyhow::Result<ContextArtifact> {
145        self.backend.create_artifact(request)
146    }
147
148    pub fn append(
149        &self,
150        thread_id: &ThreadId,
151        artifact_id: &ContextArtifactId,
152        bytes: &[u8],
153    ) -> anyhow::Result<ContextArtifact> {
154        self.backend.append_artifact(thread_id, artifact_id, bytes)
155    }
156
157    pub fn list_artifacts(&self, thread_id: &ThreadId) -> anyhow::Result<Vec<ContextArtifact>> {
158        self.backend.list_artifacts(thread_id)
159    }
160
161    pub fn read_artifact(
162        &self,
163        thread_id: &ThreadId,
164        artifact_id: &ContextArtifactId,
165        start_line: usize,
166        limit: usize,
167    ) -> anyhow::Result<ArtifactReadPage> {
168        self.backend
169            .read_artifact(thread_id, artifact_id, start_line, limit)
170    }
171
172    pub fn grep_artifact(
173        &self,
174        thread_id: &ThreadId,
175        artifact_id: &ContextArtifactId,
176        query: &str,
177        offset: usize,
178        limit: usize,
179    ) -> anyhow::Result<ArtifactGrepPage> {
180        self.backend
181            .grep_artifact(thread_id, artifact_id, query, offset, limit)
182    }
183
184    pub fn tail_artifact(
185        &self,
186        thread_id: &ThreadId,
187        artifact_id: &ContextArtifactId,
188        lines: usize,
189    ) -> anyhow::Result<ArtifactTailPage> {
190        self.backend.tail_artifact(thread_id, artifact_id, lines)
191    }
192
193    pub fn delete_artifact(
194        &self,
195        thread_id: &ThreadId,
196        artifact_id: &ContextArtifactId,
197    ) -> anyhow::Result<bool> {
198        self.backend.delete_artifact(thread_id, artifact_id)
199    }
200}
201
202impl std::fmt::Debug for ContextArtifactStore {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.debug_struct("ContextArtifactStore")
205            .finish_non_exhaustive()
206    }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
210#[serde(rename_all = "camelCase")]
211pub struct ArtifactGrepPage {
212    pub artifact: ContextArtifactDescriptor,
213    pub query: String,
214    pub text: String,
215    pub offset: usize,
216    pub limit: usize,
217    pub shown: usize,
218    pub total_matches: usize,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub next_offset: Option<usize>,
221    pub truncated: bool,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(rename_all = "camelCase")]
226pub struct ArtifactTailPage {
227    pub artifact: ContextArtifactDescriptor,
228    pub text: String,
229    pub start_line: usize,
230    pub lines: usize,
231    pub shown: usize,
232    pub total_lines: usize,
233    pub truncated: bool,
234}
235
236pub trait ContextArtifactAccess: Send + Sync + 'static {
237    fn create_artifact(
238        &self,
239        request: CreateArtifactRequest<'_>,
240    ) -> anyhow::Result<ContextArtifact>;
241    fn append_artifact(
242        &self,
243        thread_id: &ThreadId,
244        artifact_id: &ContextArtifactId,
245        bytes: &[u8],
246    ) -> anyhow::Result<ContextArtifact>;
247    fn list_artifacts(&self, thread_id: &ThreadId) -> anyhow::Result<Vec<ContextArtifact>>;
248    fn read_artifact(
249        &self,
250        thread_id: &ThreadId,
251        artifact_id: &ContextArtifactId,
252        start_line: usize,
253        limit: usize,
254    ) -> anyhow::Result<ArtifactReadPage>;
255    fn grep_artifact(
256        &self,
257        thread_id: &ThreadId,
258        artifact_id: &ContextArtifactId,
259        query: &str,
260        offset: usize,
261        limit: usize,
262    ) -> anyhow::Result<ArtifactGrepPage>;
263    fn tail_artifact(
264        &self,
265        thread_id: &ThreadId,
266        artifact_id: &ContextArtifactId,
267        lines: usize,
268    ) -> anyhow::Result<ArtifactTailPage>;
269    fn delete_artifact(
270        &self,
271        thread_id: &ThreadId,
272        artifact_id: &ContextArtifactId,
273    ) -> anyhow::Result<bool>;
274}
275
276pub fn format_artifact_reference(artifact: &ContextArtifact, label: impl AsRef<str>) -> String {
277    let label = label.as_ref();
278    let label = if label.is_empty() { "content" } else { label };
279    format!(
280        "[artifact: {} {label} lines={} bytes={} id={}]\nUse read_artifact, grep_artifact, or tail_artifact with artifact_id \"{}\" to inspect more.",
281        artifact.kind.as_str(),
282        artifact.line_count,
283        artifact.byte_count,
284        artifact.id,
285        artifact.id
286    )
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn artifact_descriptor_hides_store_path() {
295        let artifact = ContextArtifact {
296            id: "artifact-1".to_string(),
297            kind: ContextArtifactKind::ToolOutput,
298            thread_id: "thread-a".to_string(),
299            turn_id: "turn-a".to_string(),
300            byte_count: 10,
301            line_count: 2,
302            source_tool_id: Some("call-1".to_string()),
303            label: Some("stdout".to_string()),
304            store_path: "/tmp/private/artifact-1.txt".to_string(),
305            retention_expires_at: None,
306            created_at: OffsetDateTime::UNIX_EPOCH,
307            roder_owned: true,
308        };
309
310        let value = serde_json::to_value(artifact.descriptor()).unwrap();
311
312        assert_eq!(value["kind"], "tool_output");
313        assert_eq!(value["sourceToolId"], "call-1");
314        assert!(value.get("storePath").is_none());
315    }
316
317    #[test]
318    fn artifact_reference_names_follow_up_tools() {
319        let artifact = ContextArtifact {
320            id: "artifact-1".to_string(),
321            kind: ContextArtifactKind::CommandStdout,
322            thread_id: "app-server".to_string(),
323            turn_id: "process-1".to_string(),
324            byte_count: 12,
325            line_count: 1,
326            source_tool_id: Some("process-1".to_string()),
327            label: Some("stdout".to_string()),
328            store_path: "/tmp/private/artifact-1.txt".to_string(),
329            retention_expires_at: None,
330            created_at: OffsetDateTime::UNIX_EPOCH,
331            roder_owned: true,
332        };
333
334        let reference = format_artifact_reference(&artifact, "stdout");
335
336        assert!(
337            reference.contains("[artifact: command_stdout stdout lines=1 bytes=12 id=artifact-1]")
338        );
339        assert!(reference.contains("read_artifact"));
340        assert!(reference.contains("grep_artifact"));
341    }
342}