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}