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::{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    Runtime {
111        command: String,
112        args: Vec<String>,
113        #[serde(
114            rename = "timeoutMs",
115            default,
116            skip_serializing_if = "Field::missing",
117            with = "milliseconds"
118        )]
119        timeout_ms: Field<Duration>,
120    },
121}
122impl StoredProfile {
123    pub fn kind(&self) -> ProfileKind {
124        match self {
125            Self::Agent { .. } => ProfileKind::Agent,
126            Self::Human {} => ProfileKind::Human,
127            Self::Runtime { .. } => ProfileKind::Runtime,
128        }
129    }
130}
131impl From<&Profile> for StoredProfile {
132    fn from(profile: &Profile) -> Self {
133        match profile {
134            Profile::Agent {
135                backend,
136                model,
137                reasoning,
138                timeout_ms,
139                max_tool_calls,
140                max_tokens,
141            } => Self::Agent {
142                backend: *backend,
143                model: model.clone(),
144                reasoning: (*reasoning).into(),
145                timeout_ms: (*timeout_ms).into(),
146                max_tool_calls: (*max_tool_calls).into(),
147                max_tokens: (*max_tokens).into(),
148            },
149            Profile::Human {} => Self::Human {},
150            Profile::Runtime {
151                command,
152                args,
153                timeout_ms,
154            } => Self::Runtime {
155                command: command.clone(),
156                args: args.iter().map(ToString::to_string).collect(),
157                timeout_ms: (*timeout_ms).into(),
158            },
159        }
160    }
161}
162mod milliseconds {
163    use super::*;
164    pub fn serialize<S: Serializer>(
165        value: &Field<Duration>,
166        serializer: S,
167    ) -> Result<S::Ok, S::Error> {
168        match value {
169            Field::Value(value) => serializer.serialize_u128(value.as_millis()),
170            _ => serializer.serialize_none(),
171        }
172    }
173    pub fn deserialize<'de, D: Deserializer<'de>>(
174        deserializer: D,
175    ) -> Result<Field<Duration>, D::Error> {
176        // Saved profiles can contain null; declarations use the strict timeout parser.
177        let value = Option::<serde_json::Number>::deserialize(deserializer)?;
178        value.map_or(Ok(Field::Null), |value| {
179            super::super::validation::timeout_number::<D::Error>(value).map(Field::Value)
180        })
181    }
182}
183
184impl From<Option<&super::EvalPayload>> for StoredPayload {
185    fn from(payload: Option<&super::EvalPayload>) -> Self {
186        payload.map_or_else(
187            || Self {
188                instruction: Field::Missing,
189                extra: Default::default(),
190            },
191            Self::from,
192        )
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use serde_json::json;
200    #[test]
201    fn stored_payloads_preserve_schema_5_missing_null_and_owner_context() {
202        for (value, instruction) in [
203            (json!({}), ""),
204            (json!({"instruction":null,"owner":[true,null,42]}), ""),
205            (
206                json!({"instruction":"Inspect {input}.","owner":{"nested":[]}}),
207                "Inspect {input}.",
208            ),
209        ] {
210            let stored: StoredPayload = serde_json::from_value(value.clone()).unwrap();
211            assert_eq!(serde_json::to_value(&stored).unwrap(), value);
212            assert_eq!(stored.instruction(), instruction);
213            if value["instruction"].is_null() {
214                assert!(serde_json::from_value::<super::super::EvalPayload>(value).is_err());
215            }
216        }
217    }
218
219    #[test]
220    fn stored_profiles_preserve_missing_and_null_without_weakening_config() {
221        for value in [
222            json!({"kind":"agent","backend":"openai","model":"fixture"}),
223            json!({
224                "kind":"agent",
225                "backend":"openai",
226                "model":"fixture",
227                "reasoning":null,
228                "timeoutMs":null,
229                "maxToolCalls":null,
230                "maxTokens":null,
231            }),
232            json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null}),
233            json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":1000}),
234            json!({"kind":"human"}),
235        ] {
236            let stored: StoredProfile = serde_json::from_value(value.clone()).unwrap();
237            assert_eq!(serde_json::to_value(&stored).unwrap(), value);
238            if value
239                .get("timeoutMs")
240                .is_some_and(serde_json::Value::is_null)
241            {
242                let mut declaration = value;
243                let object = declaration.as_object_mut().unwrap();
244                for (camel, snake) in [
245                    ("timeoutMs", "timeout_ms"),
246                    ("maxToolCalls", "max_tool_calls"),
247                    ("maxTokens", "max_tokens"),
248                ] {
249                    if let Some(value) = object.remove(camel) {
250                        object.insert(snake.into(), value);
251                    }
252                }
253                // Only the timeout is invalid, so another optional field cannot mask it.
254                for field in ["reasoning", "max_tool_calls", "max_tokens"] {
255                    object.remove(field);
256                }
257                let error = serde_json::from_value::<Profile>(declaration)
258                    .unwrap_err()
259                    .to_string();
260                assert!(
261                    error.contains("null") && !error.contains("unknown field"),
262                    "{error}"
263                );
264            }
265        }
266        let missing: StoredProfile =
267            serde_json::from_value(json!({"kind":"runtime","command":"fixture","args":[]}))
268                .unwrap();
269        let null: StoredProfile = serde_json::from_value(
270            json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null}),
271        )
272        .unwrap();
273        assert_ne!(missing, null);
274        let config: Profile =
275            serde_json::from_value(json!({"kind":"agent","backend":"openai","model":"fixture"}))
276                .unwrap();
277        assert_eq!(
278            serde_json::to_value(StoredProfile::from(&config)).unwrap(),
279            json!({
280                "kind":"agent",
281                "backend":"openai",
282                "model":"fixture",
283                "reasoning":null,
284                "timeoutMs":null,
285                "maxToolCalls":null,
286                "maxTokens":null,
287            })
288        );
289    }
290}