1use 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#[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 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 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}