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