Skip to main content

agentd/runtime/
artifacts.rs

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