Skip to main content

agentd/runtime/
artifacts.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Artifacts**: named pieces of content produced by turns and steps,
3//! store-backed, delivered on A2A tasks and referenced by large step outputs
4//! as `{"$artifact": id}` so a big payload travels by reference rather than
5//! being copied into every message that mentions it. Content is stored inline
6//! (JSON or text) up to [`MAX_INLINE_BYTES`]; the record carries
7//! `{name, mime, size, sha256, content, created_by, sensitive}`.
8
9use crate::state::{Durable, Kind, now_ms, ulid};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::collections::BTreeMap;
13
14/// The inline content cap (bytes of the serialized content).
15pub const MAX_INLINE_BYTES: usize = 4 * 1024 * 1024;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Artifact {
19    pub id: String,
20    pub name: String,
21    pub mime: String,
22    pub size: u64,
23    pub sha256: String,
24    pub content: Value,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub created_by: Option<String>,
27    #[serde(default)]
28    pub sensitive: bool,
29    #[serde(default)]
30    pub created: u64,
31    /// The A2A task / run this artifact belongs to (delivery target).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub owner: Option<String>,
34}
35
36impl Artifact {
37    pub fn meta(&self) -> Value {
38        json!({"id": self.id, "name": self.name, "mime": self.mime, "size": self.size, "sha256": self.sha256, "created_by": self.created_by, "sensitive": self.sensitive, "created": self.created, "owner": self.owner})
39    }
40}
41
42/// The inputs of `artifact.create`.
43pub struct NewArtifact<'a> {
44    pub name: &'a str,
45    pub mime: Option<&'a str>,
46    pub content: Value,
47    pub created_by: Option<&'a str>,
48    pub sensitive: bool,
49    pub owner: Option<&'a str>,
50}
51
52/// The artifact registry (in-memory index of the durable records).
53#[derive(Default)]
54pub struct Artifacts {
55    map: BTreeMap<String, Artifact>,
56}
57
58impl Artifacts {
59    pub fn new() -> Artifacts {
60        Artifacts::default()
61    }
62
63    /// Adopt restored records.
64    pub fn restore(&mut self, envelopes: &[crate::store::Envelope]) -> usize {
65        let mut n = 0;
66        for env in envelopes {
67            if let Ok(a) = serde_json::from_value::<Artifact>(env.state.clone()) {
68                self.map.insert(a.id.clone(), a);
69                n += 1;
70            }
71        }
72        n
73    }
74
75    /// `artifact.create`.
76    pub fn create(&mut self, d: &Durable, spec: NewArtifact<'_>) -> Result<Value, String> {
77        let NewArtifact {
78            name,
79            mime,
80            content,
81            created_by,
82            sensitive,
83            owner,
84        } = spec;
85        if name.trim().is_empty() {
86            return Err("artifact.create: name must be non-empty".into());
87        }
88        let serialized = match &content {
89            Value::String(s) => s.clone(),
90            other => other.to_string(),
91        };
92        if serialized.len() > MAX_INLINE_BYTES {
93            return Err(format!(
94                "artifact.create: content is {} bytes; the inline cap is {MAX_INLINE_BYTES}",
95                serialized.len()
96            ));
97        }
98        let mime = mime.map(str::to_string).unwrap_or_else(|| {
99            if content.is_string() {
100                "text/plain".into()
101            } else {
102                "application/json".into()
103            }
104        });
105        let a = Artifact {
106            id: ulid::new(),
107            name: name.trim().to_string(),
108            mime,
109            size: serialized.len() as u64,
110            sha256: crate::sha::sha256_hex(serialized.as_bytes()),
111            content,
112            created_by: created_by.map(str::to_string),
113            sensitive,
114            created: now_ms(),
115            owner: owner.map(str::to_string),
116        };
117        d.put(
118            Kind::Artifact,
119            &a.id,
120            serde_json::to_value(&a).unwrap_or(Value::Null),
121            Some(a.sha256.clone()),
122        )
123        .map_err(|e| e.to_string())?;
124        let meta = a.meta();
125        self.map.insert(a.id.clone(), a);
126        Ok(meta)
127    }
128
129    /// `artifact.get`.
130    pub fn get(&self, id: &str) -> Option<&Artifact> {
131        self.map.get(id)
132    }
133
134    /// `artifact.get` as a tool result: the metadata plus the inline content.
135    /// The `sensitive` flag rides along on the metadata so the caller's own
136    /// redaction rules can act on it.
137    pub fn get_value(&self, id: &str) -> Result<Value, String> {
138        let a = self
139            .map
140            .get(id)
141            .ok_or_else(|| format!("no such artifact {id:?}"))?;
142        let mut v = a.meta();
143        v["content"] = a.content.clone();
144        Ok(v)
145    }
146
147    /// `artifact.delete`.
148    pub fn delete(&mut self, d: &Durable, id: &str) -> Result<Value, String> {
149        if self.map.remove(id).is_none() {
150            return Err(format!("no such artifact {id:?}"));
151        }
152        d.delete(Kind::Artifact, id).map_err(|e| e.to_string())?;
153        Ok(json!({"ok": true, "id": id}))
154    }
155
156    /// `artifact.list`.
157    pub fn list(&self, prefix: Option<&str>, limit: Option<usize>, owner: Option<&str>) -> Value {
158        let limit = limit.unwrap_or(100).max(1);
159        let mut items: Vec<Value> = self
160            .map
161            .values()
162            .filter(|a| prefix.is_none_or(|p| a.name.starts_with(p)))
163            .filter(|a| owner.is_none_or(|o| a.owner.as_deref() == Some(o)))
164            .map(Artifact::meta)
165            .collect();
166        let truncated = items.len() > limit;
167        items.truncate(limit);
168        json!({"artifacts": items, "truncated": truncated})
169    }
170
171    pub fn len(&self) -> usize {
172        self.map.len()
173    }
174    pub fn is_empty(&self) -> bool {
175        self.map.is_empty()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::state::Policy;
183    use crate::store::memory::MemoryStore;
184    use std::sync::Arc;
185
186    #[test]
187    fn create_get_list_delete_and_restore() {
188        let d = Durable::new(
189            Arc::new(MemoryStore::new()),
190            "agentd",
191            "i",
192            Policy::default(),
193            None,
194        );
195        let mut a = Artifacts::new();
196        let m = a
197            .create(
198                &d,
199                NewArtifact {
200                    name: "report.md",
201                    mime: None,
202                    content: json!("# hi"),
203                    created_by: Some("root"),
204                    sensitive: false,
205                    owner: Some("task-1"),
206                },
207            )
208            .unwrap();
209        let id = m["id"].as_str().unwrap().to_string();
210        assert_eq!(m["mime"], json!("text/plain"));
211        assert_eq!(m["size"], json!(4));
212        let m2 = a
213            .create(
214                &d,
215                NewArtifact {
216                    name: "data.json",
217                    mime: None,
218                    content: json!({"a": 1}),
219                    created_by: None,
220                    sensitive: true,
221                    owner: None,
222                },
223            )
224            .unwrap();
225        assert_eq!(m2["mime"], json!("application/json"));
226        assert_eq!(a.get_value(&id).unwrap()["content"], json!("# hi"));
227        assert_eq!(
228            a.list(Some("rep"), None, None)["artifacts"]
229                .as_array()
230                .unwrap()
231                .len(),
232            1
233        );
234        assert_eq!(
235            a.list(None, None, Some("task-1"))["artifacts"]
236                .as_array()
237                .unwrap()
238                .len(),
239            1
240        );
241        assert_eq!(a.list(None, Some(1), None)["truncated"], json!(true));
242        assert!(
243            a.create(
244                &d,
245                NewArtifact {
246                    name: "",
247                    mime: None,
248                    content: json!(1),
249                    created_by: None,
250                    sensitive: false,
251                    owner: None,
252                },
253            )
254            .is_err()
255        );
256        // Restore.
257        let mut b = Artifacts::new();
258        assert_eq!(b.restore(d.restore().unwrap().of(Kind::Artifact)), 2);
259        assert!(b.get(&id).is_some());
260        b.delete(&d, &id).unwrap();
261        assert!(b.delete(&d, &id).is_err());
262        assert_eq!(d.restore().unwrap().of(Kind::Artifact).len(), 1);
263    }
264}