Skip to main content

artifactize_core/config/
stored.rs

1//! Profiles saved by schema 5. Unlike declarations, their optional fields may be null.
2//! Missing and null remain distinct: profile comparison drives the otherProfile tally.
3
4use super::{ArtifactName, Backend, ModelId, Profile, ProfileKind, Reasoning};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use std::time::Duration;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub enum Field<T> {
10    #[default]
11    Missing,
12    Null,
13    Value(T),
14}
15impl<T> Field<T> {
16    pub fn value(&self) -> Option<&T> {
17        match self {
18            Self::Value(value) => Some(value),
19            _ => None,
20        }
21    }
22    pub(crate) fn missing(&self) -> bool {
23        matches!(self, Self::Missing)
24    }
25}
26impl<T: Serialize> Serialize for Field<T> {
27    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
28        match self {
29            Self::Value(value) => value.serialize(serializer),
30            _ => serializer.serialize_none(),
31        }
32    }
33}
34impl<'de, T: Deserialize<'de>> Deserialize<'de> for Field<T> {
35    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
36        Ok(Option::<T>::deserialize(deserializer)?.map_or(Self::Null, Self::Value))
37    }
38}
39impl<T> From<Option<T>> for Field<T> {
40    fn from(value: Option<T>) -> Self {
41        value.map_or(Self::Null, Self::Value)
42    }
43}
44
45/// Read schema-5 payloads without inventing an instruction field for older audits.
46/// Declarations still require a String; omission/null tolerance belongs only here.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct StoredPayload {
49    #[serde(default, skip_serializing_if = "Field::missing")]
50    instruction: Field<String>,
51    #[serde(flatten)]
52    pub extra: serde_json::Map<String, serde_json::Value>,
53}
54
55impl StoredPayload {
56    pub fn instruction(&self) -> &str {
57        match &self.instruction {
58            Field::Value(value) => value,
59            Field::Missing | Field::Null => "",
60        }
61    }
62}
63
64impl From<&Option<super::EvalPayload>> for StoredPayload {
65    fn from(payload: &Option<super::EvalPayload>) -> Self {
66        payload.as_ref().map_or_else(
67            || Self {
68                instruction: Field::Missing,
69                extra: Default::default(),
70            },
71            Self::from,
72        )
73    }
74}
75
76impl From<&super::EvalPayload> for StoredPayload {
77    fn from(payload: &super::EvalPayload) -> Self {
78        Self {
79            instruction: Field::Value(payload.instruction.clone()),
80            extra: payload.extra.clone(),
81        }
82    }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "lowercase")]
87pub enum StoredProfile {
88    Agent {
89        backend: Backend,
90        model: ModelId,
91        #[serde(default, skip_serializing_if = "Field::missing")]
92        reasoning: Field<Reasoning>,
93        #[serde(
94            rename = "timeoutMs",
95            default,
96            skip_serializing_if = "Field::missing",
97            with = "milliseconds"
98        )]
99        timeout_ms: Field<Duration>,
100        #[serde(
101            rename = "maxToolCalls",
102            default,
103            skip_serializing_if = "Field::missing"
104        )]
105        max_tool_calls: Field<u64>,
106        #[serde(rename = "maxTokens", default, skip_serializing_if = "Field::missing")]
107        max_tokens: Field<u64>,
108    },
109    Human {},
110    Dependency {
111        #[serde(rename = "dependsOn")]
112        depends_on: Vec<ArtifactName>,
113    },
114    Runtime {
115        command: String,
116        args: Vec<String>,
117        #[serde(
118            rename = "timeoutMs",
119            default,
120            skip_serializing_if = "Field::missing",
121            with = "milliseconds"
122        )]
123        timeout_ms: Field<Duration>,
124    },
125}
126impl StoredProfile {
127    pub fn kind(&self) -> ProfileKind {
128        match self {
129            Self::Agent { .. } => ProfileKind::Agent,
130            Self::Human {} => ProfileKind::Human,
131            Self::Dependency { .. } => ProfileKind::Dependency,
132            Self::Runtime { .. } => ProfileKind::Runtime,
133        }
134    }
135}
136impl From<&Profile> for StoredProfile {
137    fn from(profile: &Profile) -> Self {
138        match profile {
139            Profile::Agent {
140                backend,
141                model,
142                reasoning,
143                timeout_ms,
144                max_tool_calls,
145                max_tokens,
146            } => Self::Agent {
147                backend: *backend,
148                model: model.clone(),
149                reasoning: (*reasoning).into(),
150                timeout_ms: (*timeout_ms).into(),
151                max_tool_calls: (*max_tool_calls).into(),
152                max_tokens: (*max_tokens).into(),
153            },
154            Profile::Human {} => Self::Human {},
155            Profile::Dependency { depends_on } => Self::Dependency {
156                depends_on: depends_on.clone(),
157            },
158            Profile::Runtime {
159                command,
160                args,
161                timeout_ms,
162            } => Self::Runtime {
163                command: command.clone(),
164                args: args.iter().map(ToString::to_string).collect(),
165                timeout_ms: (*timeout_ms).into(),
166            },
167        }
168    }
169}
170mod milliseconds {
171    use super::*;
172    pub fn serialize<S: Serializer>(
173        value: &Field<Duration>,
174        serializer: S,
175    ) -> Result<S::Ok, S::Error> {
176        match value {
177            Field::Value(value) => serializer.serialize_u128(value.as_millis()),
178            _ => serializer.serialize_none(),
179        }
180    }
181    pub fn deserialize<'de, D: Deserializer<'de>>(
182        deserializer: D,
183    ) -> Result<Field<Duration>, D::Error> {
184        // Saved profiles can contain null; declarations use the strict timeout parser.
185        let value = Option::<serde_json::Number>::deserialize(deserializer)?;
186        value.map_or(Ok(Field::Null), |value| {
187            super::super::validation::timeout_number::<D::Error>(value).map(Field::Value)
188        })
189    }
190}
191
192impl From<Option<&super::EvalPayload>> for StoredPayload {
193    fn from(payload: Option<&super::EvalPayload>) -> Self {
194        payload.map_or_else(
195            || Self {
196                instruction: Field::Missing,
197                extra: Default::default(),
198            },
199            Self::from,
200        )
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use serde_json::json;
208    #[test]
209    fn stored_payloads_preserve_schema_5_missing_null_and_owner_context() {
210        for (value, instruction) in [
211            (json!({}), ""),
212            (json!({"instruction":null,"owner":[true,null,42]}), ""),
213            (
214                json!({"instruction":"Inspect {input}.","owner":{"nested":[]}}),
215                "Inspect {input}.",
216            ),
217        ] {
218            let stored: StoredPayload = serde_json::from_value(value.clone()).unwrap();
219            assert_eq!(serde_json::to_value(&stored).unwrap(), value);
220            assert_eq!(stored.instruction(), instruction);
221            if value["instruction"].is_null() {
222                assert!(serde_json::from_value::<super::super::EvalPayload>(value).is_err());
223            }
224        }
225    }
226
227    #[test]
228    fn stored_profiles_preserve_missing_and_null_without_weakening_config() {
229        for value in [
230            json!({"kind":"agent","backend":"openai","model":"fixture"}),
231            json!({
232                "kind":"agent",
233                "backend":"openai",
234                "model":"fixture",
235                "reasoning":null,
236                "timeoutMs":null,
237                "maxToolCalls":null,
238                "maxTokens":null,
239            }),
240            json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null}),
241            json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":1000}),
242            json!({"kind":"human"}),
243        ] {
244            let stored: StoredProfile = serde_json::from_value(value.clone()).unwrap();
245            assert_eq!(serde_json::to_value(&stored).unwrap(), value);
246            if value
247                .get("timeoutMs")
248                .is_some_and(serde_json::Value::is_null)
249            {
250                let mut declaration = value;
251                let object = declaration.as_object_mut().unwrap();
252                for (camel, snake) in [
253                    ("timeoutMs", "timeout_ms"),
254                    ("maxToolCalls", "max_tool_calls"),
255                    ("maxTokens", "max_tokens"),
256                ] {
257                    if let Some(value) = object.remove(camel) {
258                        object.insert(snake.into(), value);
259                    }
260                }
261                // Only the timeout is invalid, so another optional field cannot mask it.
262                for field in ["reasoning", "max_tool_calls", "max_tokens"] {
263                    object.remove(field);
264                }
265                let error = serde_json::from_value::<Profile>(declaration)
266                    .unwrap_err()
267                    .to_string();
268                assert!(
269                    error.contains("null") && !error.contains("unknown field"),
270                    "{error}"
271                );
272            }
273        }
274        let missing: StoredProfile =
275            serde_json::from_value(json!({"kind":"runtime","command":"fixture","args":[]}))
276                .unwrap();
277        let null: StoredProfile = serde_json::from_value(
278            json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null}),
279        )
280        .unwrap();
281        assert_ne!(missing, null);
282        let config: Profile =
283            serde_json::from_value(json!({"kind":"agent","backend":"openai","model":"fixture"}))
284                .unwrap();
285        assert_eq!(
286            serde_json::to_value(StoredProfile::from(&config)).unwrap(),
287            json!({
288                "kind":"agent",
289                "backend":"openai",
290                "model":"fixture",
291                "reasoning":null,
292                "timeoutMs":null,
293                "maxToolCalls":null,
294                "maxTokens":null,
295            })
296        );
297    }
298}