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: crate::platform::path_text(std::path::Path::new(command)),
157 args: args.iter().map(ToString::to_string).collect(),
158 timeout_ms: (*timeout_ms).into(),
159 },
160 }
161 }
162}
163mod milliseconds {
164 use super::*;
165 pub fn serialize<S: Serializer>(
166 value: &Field<Duration>,
167 serializer: S,
168 ) -> Result<S::Ok, S::Error> {
169 match value {
170 Field::Value(value) => serializer.serialize_u128(value.as_millis()),
171 _ => serializer.serialize_none(),
172 }
173 }
174 pub fn deserialize<'de, D: Deserializer<'de>>(
175 deserializer: D,
176 ) -> Result<Field<Duration>, D::Error> {
177 let value = Option::<serde_json::Number>::deserialize(deserializer)?;
179 value.map_or(Ok(Field::Null), |value| {
180 super::super::validation::timeout_number::<D::Error>(value).map(Field::Value)
181 })
182 }
183}
184
185impl From<Option<&super::EvalPayload>> for StoredPayload {
186 fn from(payload: Option<&super::EvalPayload>) -> Self {
187 payload.map_or_else(
188 || Self {
189 instruction: Field::Missing,
190 extra: Default::default(),
191 },
192 Self::from,
193 )
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use serde_json::json;
201 #[test]
202 fn stored_payloads_preserve_schema_5_missing_null_and_owner_context() {
203 for (value, instruction) in [
204 (json!({}), ""),
205 (json!({"instruction":null,"owner":[true,null,42]}), ""),
206 (
207 json!({"instruction":"Inspect {input}.","owner":{"nested":[]}}),
208 "Inspect {input}.",
209 ),
210 ] {
211 let stored: StoredPayload = serde_json::from_value(value.clone()).unwrap();
212 assert_eq!(serde_json::to_value(&stored).unwrap(), value);
213 assert_eq!(stored.instruction(), instruction);
214 if value["instruction"].is_null() {
215 assert!(serde_json::from_value::<super::super::EvalPayload>(value).is_err());
216 }
217 }
218 }
219
220 #[test]
221 fn stored_profiles_preserve_missing_and_null_without_weakening_config() {
222 for value in [
223 json!({"kind":"agent","backend":"openai","model":"fixture"}),
224 json!({
225 "kind":"agent",
226 "backend":"openai",
227 "model":"fixture",
228 "reasoning":null,
229 "timeoutMs":null,
230 "maxToolCalls":null,
231 "maxTokens":null,
232 }),
233 json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null}),
234 json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":1000}),
235 json!({"kind":"human"}),
236 ] {
237 let stored: StoredProfile = serde_json::from_value(value.clone()).unwrap();
238 assert_eq!(serde_json::to_value(&stored).unwrap(), value);
239 if value
240 .get("timeoutMs")
241 .is_some_and(serde_json::Value::is_null)
242 {
243 let mut declaration = value;
244 let object = declaration.as_object_mut().unwrap();
245 for (camel, snake) in [
246 ("timeoutMs", "timeout_ms"),
247 ("maxToolCalls", "max_tool_calls"),
248 ("maxTokens", "max_tokens"),
249 ] {
250 if let Some(value) = object.remove(camel) {
251 object.insert(snake.into(), value);
252 }
253 }
254 for field in ["reasoning", "max_tool_calls", "max_tokens"] {
256 object.remove(field);
257 }
258 let error = serde_json::from_value::<Profile>(declaration)
259 .unwrap_err()
260 .to_string();
261 assert!(
262 error.contains("null") && !error.contains("unknown field"),
263 "{error}"
264 );
265 }
266 }
267 let missing: StoredProfile =
268 serde_json::from_value(json!({"kind":"runtime","command":"fixture","args":[]}))
269 .unwrap();
270 let null: StoredProfile = serde_json::from_value(
271 json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null}),
272 )
273 .unwrap();
274 assert_ne!(missing, null);
275 let config: Profile =
276 serde_json::from_value(json!({"kind":"agent","backend":"openai","model":"fixture"}))
277 .unwrap();
278 assert_eq!(
279 serde_json::to_value(StoredProfile::from(&config)).unwrap(),
280 json!({
281 "kind":"agent",
282 "backend":"openai",
283 "model":"fixture",
284 "reasoning":null,
285 "timeoutMs":null,
286 "maxToolCalls":null,
287 "maxTokens":null,
288 })
289 );
290 }
291}