1use serde::{Deserialize, Serialize};
2
3use super::{ExecutionProfileFallbackPolicy, ExecutionProfileInheritance};
4use crate::error::DomainError;
5
6const MAX_PROFILE_TEXT: usize = 256;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ExecutionProfile {
15 requested_model: String,
16 requested_reasoning_effort: String,
17 required_capabilities: Vec<String>,
18 fallback_policy: ExecutionProfileFallbackPolicy,
19 fallback_model: Option<String>,
20 fallback_reasoning_effort: Option<String>,
21 actual_model: String,
22 actual_reasoning_effort: String,
23 actual_capabilities: Vec<String>,
24 host_agent_id: String,
25 host_agent_incarnation: String,
26 inherited_from: Option<ExecutionProfileInheritance>,
27 checkpoint_id: Option<String>,
28 handoff_from: Option<String>,
29}
30
31impl ExecutionProfile {
32 pub fn from_json(value: serde_json::Value) -> Result<Self, DomainError> {
33 let profile: Self =
34 serde_json::from_value(value).map_err(|_| DomainError::InvariantViolated {
35 reason: "invalid execution profile payload",
36 })?;
37 Self::new(
38 profile.requested_model,
39 profile.requested_reasoning_effort,
40 profile.required_capabilities,
41 profile.fallback_policy,
42 profile.fallback_model,
43 profile.fallback_reasoning_effort,
44 profile.actual_model,
45 profile.actual_reasoning_effort,
46 profile.actual_capabilities,
47 profile.host_agent_id,
48 profile.host_agent_incarnation,
49 profile.inherited_from,
50 profile.checkpoint_id,
51 profile.handoff_from,
52 )
53 }
54
55 #[allow(clippy::too_many_arguments)]
56 pub fn new(
57 requested_model: impl Into<String>,
58 requested_reasoning_effort: impl Into<String>,
59 required_capabilities: Vec<String>,
60 fallback_policy: ExecutionProfileFallbackPolicy,
61 fallback_model: Option<String>,
62 fallback_reasoning_effort: Option<String>,
63 actual_model: impl Into<String>,
64 actual_reasoning_effort: impl Into<String>,
65 actual_capabilities: Vec<String>,
66 host_agent_id: impl Into<String>,
67 host_agent_incarnation: impl Into<String>,
68 inherited_from: Option<ExecutionProfileInheritance>,
69 checkpoint_id: Option<String>,
70 handoff_from: Option<String>,
71 ) -> Result<Self, DomainError> {
72 let profile = Self {
73 requested_model: clean(requested_model.into(), "execution_profile.requested_model")?,
74 requested_reasoning_effort: clean(
75 requested_reasoning_effort.into(),
76 "execution_profile.requested_reasoning_effort",
77 )?,
78 required_capabilities: clean_list(
79 required_capabilities,
80 "execution_profile.required_capabilities",
81 )?,
82 fallback_policy,
83 fallback_model: fallback_model
84 .map(|value| clean(value, "execution_profile.fallback_model"))
85 .transpose()?,
86 fallback_reasoning_effort: fallback_reasoning_effort
87 .map(|value| clean(value, "execution_profile.fallback_reasoning_effort"))
88 .transpose()?,
89 actual_model: clean(actual_model.into(), "execution_profile.actual_model")?,
90 actual_reasoning_effort: clean(
91 actual_reasoning_effort.into(),
92 "execution_profile.actual_reasoning_effort",
93 )?,
94 actual_capabilities: clean_list(
95 actual_capabilities,
96 "execution_profile.actual_capabilities",
97 )?,
98 host_agent_id: clean(host_agent_id.into(), "execution_profile.host_agent_id")?,
99 host_agent_incarnation: clean(
100 host_agent_incarnation.into(),
101 "execution_profile.host_agent_incarnation",
102 )?,
103 inherited_from,
104 checkpoint_id: checkpoint_id
105 .map(|value| clean(value, "execution_profile.checkpoint_id"))
106 .transpose()?,
107 handoff_from: handoff_from
108 .map(|value| clean(value, "execution_profile.handoff_from"))
109 .transpose()?,
110 };
111 profile.validate_resolution()?;
112 Ok(profile)
113 }
114
115 pub fn validate_resolution(&self) -> Result<(), DomainError> {
117 if self
118 .required_capabilities
119 .iter()
120 .any(|required| !self.actual_capabilities.contains(required))
121 {
122 return Err(DomainError::InvariantViolated {
123 reason:
124 "actual execution profile capabilities do not satisfy required capabilities",
125 });
126 }
127 match self.fallback_policy {
128 ExecutionProfileFallbackPolicy::Reject
129 if self.actual_model != self.requested_model
130 || self.actual_reasoning_effort != self.requested_reasoning_effort =>
131 {
132 Err(DomainError::InvariantViolated {
133 reason:
134 "requested execution profile is unsupported and fallback_policy is reject",
135 })
136 }
137 ExecutionProfileFallbackPolicy::Fallback => {
138 if self.actual_model != self.requested_model
139 && self
140 .fallback_model
141 .as_deref()
142 .is_some_and(|fallback| fallback != self.actual_model)
143 {
144 return Err(DomainError::InvariantViolated {
145 reason: "declared fallback model does not match actual model",
146 });
147 }
148 if self.actual_reasoning_effort != self.requested_reasoning_effort
149 && self
150 .fallback_reasoning_effort
151 .as_deref()
152 .is_some_and(|fallback| fallback != self.actual_reasoning_effort)
153 {
154 return Err(DomainError::InvariantViolated {
155 reason: "declared fallback reasoning effort does not match actual effort",
156 });
157 }
158 if self.actual_model != self.requested_model
159 && self.fallback_model.as_deref() != Some(self.actual_model.as_str())
160 {
161 return Err(DomainError::InvariantViolated {
162 reason: "actual model is not the declared execution profile fallback",
163 });
164 }
165 if self.actual_reasoning_effort != self.requested_reasoning_effort
166 && self.fallback_reasoning_effort.as_deref()
167 != Some(self.actual_reasoning_effort.as_str())
168 {
169 return Err(DomainError::InvariantViolated {
170 reason:
171 "actual reasoning effort is not the declared execution profile fallback",
172 });
173 }
174 Ok(())
175 }
176 ExecutionProfileFallbackPolicy::Reject => Ok(()),
177 }
178 }
179
180 #[must_use]
181 pub fn requested_model(&self) -> &str {
182 &self.requested_model
183 }
184 #[must_use]
185 pub fn requested_reasoning_effort(&self) -> &str {
186 &self.requested_reasoning_effort
187 }
188 #[must_use]
189 pub fn required_capabilities(&self) -> &[String] {
190 &self.required_capabilities
191 }
192 #[must_use]
193 pub const fn fallback_policy(&self) -> ExecutionProfileFallbackPolicy {
194 self.fallback_policy
195 }
196 #[must_use]
197 pub fn actual_model(&self) -> &str {
198 &self.actual_model
199 }
200 #[must_use]
201 pub fn actual_reasoning_effort(&self) -> &str {
202 &self.actual_reasoning_effort
203 }
204 #[must_use]
205 pub fn actual_capabilities(&self) -> &[String] {
206 &self.actual_capabilities
207 }
208 #[must_use]
209 pub fn host_agent_id(&self) -> &str {
210 &self.host_agent_id
211 }
212 #[must_use]
213 pub fn host_agent_incarnation(&self) -> &str {
214 &self.host_agent_incarnation
215 }
216 #[must_use]
217 pub fn inherited_from(&self) -> Option<ExecutionProfileInheritance> {
218 self.inherited_from
219 }
220 #[must_use]
221 pub fn checkpoint_id(&self) -> Option<&str> {
222 self.checkpoint_id.as_deref()
223 }
224 #[must_use]
225 pub fn handoff_from(&self) -> Option<&str> {
226 self.handoff_from.as_deref()
227 }
228}
229
230fn clean(value: impl Into<String>, field: &'static str) -> Result<String, DomainError> {
231 let value = value.into().trim().to_owned();
232 if value.is_empty() {
233 return Err(DomainError::EmptyField { field });
234 }
235 if value.len() > MAX_PROFILE_TEXT {
236 return Err(DomainError::FieldTooLong {
237 field,
238 actual: value.len(),
239 max: MAX_PROFILE_TEXT,
240 });
241 }
242 if value.chars().any(char::is_control) {
243 return Err(DomainError::InvalidCharacters { field });
244 }
245 Ok(value)
246}
247
248fn clean_list(values: Vec<String>, field: &'static str) -> Result<Vec<String>, DomainError> {
249 values
250 .into_iter()
251 .map(|value| clean(value, field))
252 .collect()
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn profile(
260 policy: ExecutionProfileFallbackPolicy,
261 actual_model: &str,
262 ) -> Result<ExecutionProfile, DomainError> {
263 ExecutionProfile::new(
264 "strong-model",
265 "high",
266 vec!["reasoning".into()],
267 policy,
268 Some("balanced-model".into()),
269 Some("medium".into()),
270 actual_model,
271 if actual_model == "strong-model" {
272 "high"
273 } else {
274 "medium"
275 },
276 vec!["reasoning".into()],
277 "codex-agent-1",
278 "inc-2",
279 Some(ExecutionProfileInheritance::RoleDefault),
280 Some("checkpoint-7".into()),
281 Some("codex-agent-0/inc-1".into()),
282 )
283 }
284
285 #[test]
286 fn fallback_is_explicit_and_actionable() {
287 assert!(profile(ExecutionProfileFallbackPolicy::Fallback, "balanced-model").is_ok());
288 assert!(profile(ExecutionProfileFallbackPolicy::Reject, "balanced-model").is_err());
289 }
290
291 #[test]
292 fn requested_and_actual_identity_are_retained() {
293 let selected = profile(ExecutionProfileFallbackPolicy::Fallback, "balanced-model").unwrap();
294 assert_eq!(selected.requested_model(), "strong-model");
295 assert_eq!(selected.actual_model(), "balanced-model");
296 assert_eq!(
297 selected.inherited_from(),
298 Some(ExecutionProfileInheritance::RoleDefault)
299 );
300 assert_eq!(selected.handoff_from(), Some("codex-agent-0/inc-1"));
301 }
302
303 #[test]
304 fn required_capabilities_must_be_realised_by_actual_selection() {
305 assert!(ExecutionProfile::new(
306 "strong-model",
307 "high",
308 vec!["reasoning".into(), "vision".into()],
309 ExecutionProfileFallbackPolicy::Reject,
310 None,
311 None,
312 "strong-model",
313 "high",
314 vec!["reasoning".into()],
315 "codex-agent-1",
316 "inc-2",
317 None,
318 None,
319 None,
320 )
321 .is_err());
322 }
323
324 #[test]
325 fn fallback_model_and_effort_are_one_coherent_selection() {
326 assert!(ExecutionProfile::new(
327 "strong-model",
328 "high",
329 vec!["reasoning".into()],
330 ExecutionProfileFallbackPolicy::Fallback,
331 Some("balanced-model".into()),
332 Some("high".into()),
333 "balanced-model",
334 "medium",
335 vec!["reasoning".into()],
336 "codex-agent-1",
337 "inc-2",
338 None,
339 None,
340 None,
341 )
342 .is_err());
343 }
344
345 #[test]
346 fn supported_requested_selection_does_not_use_declared_fallback() {
347 assert!(ExecutionProfile::new(
348 "strong-model",
349 "high",
350 vec!["reasoning".into()],
351 ExecutionProfileFallbackPolicy::Fallback,
352 Some("balanced-model".into()),
353 Some("medium".into()),
354 "strong-model",
355 "high",
356 vec!["reasoning".into()],
357 "codex-agent-1",
358 "inc-2",
359 None,
360 None,
361 None,
362 )
363 .is_ok());
364 }
365}