1use std::collections::BTreeSet;
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45
46use crate::redaction::redact_for_disclosure;
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ConfiguredModel {
55 pub provider: String,
57 pub model: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub note: Option<String>,
63}
64
65impl ConfiguredModel {
66 #[must_use]
68 pub fn new(provider: impl Into<String>, model: impl Into<String>, note: Option<&str>) -> Self {
69 Self {
70 provider: provider.into(),
71 model: model.into(),
72 note: note
73 .map(|note| redact_for_disclosure(note).into_text())
74 .filter(|note| !note.trim().is_empty()),
75 }
76 }
77
78 #[must_use]
80 pub fn key(&self) -> String {
81 format!("{}/{}", self.provider, self.model)
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct CompositionRole {
88 pub role: String,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub intent: Option<String>,
93}
94
95impl CompositionRole {
96 #[must_use]
97 pub fn new(role: impl Into<String>, intent: Option<&str>) -> Self {
98 Self {
99 role: role.into(),
100 intent: intent
101 .map(|intent| redact_for_disclosure(intent).into_text())
102 .filter(|intent| !intent.trim().is_empty()),
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct FleetCompositionRequest {
114 pub pool: Vec<ConfiguredModel>,
116 pub roles: Vec<CompositionRole>,
118}
119
120impl FleetCompositionRequest {
121 pub fn new(
123 pool: Vec<ConfiguredModel>,
124 roles: Vec<CompositionRole>,
125 ) -> Result<Self, CompositionError> {
126 if pool.is_empty() {
127 return Err(CompositionError::EmptyPool);
128 }
129 if roles.is_empty() {
130 return Err(CompositionError::NoRoles);
131 }
132 let mut seen = BTreeSet::new();
133 for role in &roles {
134 let key = role.role.trim().to_ascii_lowercase();
135 if key.is_empty() {
136 return Err(CompositionError::InvalidRole {
137 role: role.role.clone(),
138 });
139 }
140 if !seen.insert(key.clone()) {
141 return Err(CompositionError::DuplicateRole { role: key });
142 }
143 }
144 Ok(Self { pool, roles })
145 }
146
147 #[must_use]
149 pub fn pool_contains(&self, provider: &str, model: &str) -> bool {
150 self.pool
151 .iter()
152 .any(|entry| entry.provider == provider && entry.model == model)
153 }
154
155 #[must_use]
158 pub fn pool_keys(&self) -> Vec<String> {
159 self.pool.iter().map(ConfiguredModel::key).collect()
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct RoleSuggestion {
166 pub role: String,
167 pub provider: String,
168 pub model: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub reason: Option<String>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum RatificationState {
181 Unratified,
183 Ratified,
185}
186
187impl RatificationState {
188 #[must_use]
189 pub const fn as_str(self) -> &'static str {
190 match self {
191 Self::Unratified => "unratified",
192 Self::Ratified => "ratified",
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct FleetCompositionProposal {
200 pub ratification: RatificationState,
202 pub suggestions: Vec<RoleSuggestion>,
204 pub advisory: String,
207}
208
209pub const COMPOSITION_ADVISORY: &str = "Suggestion only — not saved, not running. Review each \
211 role's provider and model, then save a Fleet yourself. Nothing here changes an existing \
212 Fleet or starts a Workflow.";
213
214impl FleetCompositionProposal {
215 pub fn validate(
222 request: &FleetCompositionRequest,
223 suggestions: Vec<RoleSuggestion>,
224 ) -> Result<Self, CompositionError> {
225 let mut seen = BTreeSet::new();
226 for suggestion in &suggestions {
227 let role = suggestion.role.trim().to_ascii_lowercase();
228 if !request
229 .roles
230 .iter()
231 .any(|wanted| wanted.role.trim().to_ascii_lowercase() == role)
232 {
233 return Err(CompositionError::UnknownRole {
234 role: suggestion.role.clone(),
235 });
236 }
237 if !seen.insert(role.clone()) {
238 return Err(CompositionError::DuplicateRole { role });
239 }
240 if !request.pool_contains(&suggestion.provider, &suggestion.model) {
241 return Err(CompositionError::ModelOutsidePool {
242 role: suggestion.role.clone(),
243 provider: suggestion.provider.clone(),
244 model: suggestion.model.clone(),
245 pool: request.pool_keys(),
246 });
247 }
248 }
249
250 Ok(Self {
251 ratification: RatificationState::Unratified,
252 suggestions: suggestions
253 .into_iter()
254 .map(|suggestion| RoleSuggestion {
255 reason: suggestion
256 .reason
257 .as_deref()
258 .map(|reason| redact_for_disclosure(reason).into_text())
259 .filter(|reason| !reason.trim().is_empty()),
260 ..suggestion
261 })
262 .collect(),
263 advisory: COMPOSITION_ADVISORY.to_string(),
264 })
265 }
266
267 #[must_use]
272 pub const fn is_actionable(&self) -> bool {
273 false
274 }
275
276 #[must_use]
278 pub fn unfilled_roles(&self, request: &FleetCompositionRequest) -> Vec<String> {
279 request
280 .roles
281 .iter()
282 .filter(|wanted| {
283 !self.suggestions.iter().any(|suggestion| {
284 suggestion
285 .role
286 .trim()
287 .eq_ignore_ascii_case(wanted.role.trim())
288 })
289 })
290 .map(|wanted| wanted.role.clone())
291 .collect()
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Error)]
296pub enum CompositionError {
297 #[error(
298 "fleet composition needs an explicit configured model pool; there is nothing to choose \
299 from and this schema will not go looking"
300 )]
301 EmptyPool,
302 #[error("fleet composition needs at least one role to fill")]
303 NoRoles,
304 #[error("`{role}` is not a valid role name")]
305 InvalidRole { role: String },
306 #[error("role `{role}` appears more than once")]
307 DuplicateRole { role: String },
308 #[error("suggestion names role `{role}`, which was not one of the requested roles")]
309 UnknownRole { role: String },
310 #[error(
311 "suggestion for role `{role}` names `{provider}/{model}`, which is not in the configured \
312 model pool ({}). A composition may only choose from what the operator already \
313 configured — it is rejected rather than substituted.",
314 pool.join(", ")
315 )]
316 ModelOutsidePool {
317 role: String,
318 provider: String,
319 model: String,
320 pool: Vec<String>,
321 },
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn request() -> FleetCompositionRequest {
329 FleetCompositionRequest::new(
330 vec![
331 ConfiguredModel::new("zai", "glm-5", Some("strong")),
332 ConfiguredModel::new("openai", "gpt-5.6-luna", Some("cheap")),
333 ],
334 vec![
335 CompositionRole::new("builder", Some("lands focused code changes")),
336 CompositionRole::new("scout", Some("fast read-only exploration")),
337 ],
338 )
339 .expect("valid request")
340 }
341
342 fn suggestion(role: &str, provider: &str, model: &str) -> RoleSuggestion {
343 RoleSuggestion {
344 role: role.to_string(),
345 provider: provider.to_string(),
346 model: model.to_string(),
347 reason: None,
348 }
349 }
350
351 #[test]
352 fn a_valid_proposal_is_born_unratified_and_not_actionable() {
353 let request = request();
354 let proposal = FleetCompositionProposal::validate(
355 &request,
356 vec![
357 suggestion("builder", "zai", "glm-5"),
358 suggestion("scout", "openai", "gpt-5.6-luna"),
359 ],
360 )
361 .expect("valid proposal");
362
363 assert_eq!(proposal.ratification, RatificationState::Unratified);
364 assert_eq!(proposal.ratification.as_str(), "unratified");
365 assert!(!proposal.is_actionable());
366 assert_eq!(proposal.advisory, COMPOSITION_ADVISORY);
367 assert!(proposal.advisory.contains("not saved"));
368 assert!(proposal.unfilled_roles(&request).is_empty());
369 }
370
371 #[test]
374 fn a_model_outside_the_pool_is_rejected_not_substituted() {
375 let request = request();
376 let err = FleetCompositionProposal::validate(
377 &request,
378 vec![suggestion("builder", "anthropic", "claude-opus-5")],
379 )
380 .expect_err("out-of-pool model");
381
382 assert!(
383 matches!(err, CompositionError::ModelOutsidePool { .. }),
384 "{err:?}"
385 );
386 let message = err.to_string();
387 assert!(message.contains("zai/glm-5"), "{message}");
388 assert!(
389 message.contains("rejected rather than substituted"),
390 "{message}"
391 );
392 }
393
394 #[test]
396 fn a_pool_entry_is_a_provider_and_model_pair_not_just_a_model() {
397 let request = request();
398 let err = FleetCompositionProposal::validate(
399 &request,
400 vec![suggestion("builder", "openai", "glm-5")],
401 )
402 .expect_err("provider must match too");
403 assert!(matches!(err, CompositionError::ModelOutsidePool { .. }));
404 }
405
406 #[test]
407 fn suggestions_must_answer_the_roles_that_were_asked_for() {
408 let request = request();
409 let err = FleetCompositionProposal::validate(
410 &request,
411 vec![suggestion("wizard", "zai", "glm-5")],
412 )
413 .expect_err("unknown role");
414 assert!(matches!(err, CompositionError::UnknownRole { .. }));
415
416 let duplicate = FleetCompositionProposal::validate(
417 &request,
418 vec![
419 suggestion("builder", "zai", "glm-5"),
420 suggestion("builder", "openai", "gpt-5.6-luna"),
421 ],
422 )
423 .expect_err("duplicate role");
424 assert!(matches!(duplicate, CompositionError::DuplicateRole { .. }));
425 }
426
427 #[test]
428 fn a_partial_proposal_reports_what_it_did_not_fill() {
429 let request = request();
430 let proposal = FleetCompositionProposal::validate(
431 &request,
432 vec![suggestion("builder", "zai", "glm-5")],
433 )
434 .expect("partial is allowed, and visible");
435
436 assert_eq!(proposal.unfilled_roles(&request), vec!["scout".to_string()]);
437 }
438
439 #[test]
440 fn an_empty_pool_or_role_list_is_refused() {
441 assert!(matches!(
442 FleetCompositionRequest::new(vec![], vec![CompositionRole::new("builder", None)])
443 .expect_err("empty pool"),
444 CompositionError::EmptyPool
445 ));
446 assert!(matches!(
447 FleetCompositionRequest::new(vec![ConfiguredModel::new("zai", "glm-5", None)], vec![])
448 .expect_err("no roles"),
449 CompositionError::NoRoles
450 ));
451 }
452
453 #[test]
457 fn free_text_is_redacted_on_the_way_in() {
458 let model = ConfiguredModel::new("zai", "glm-5", Some("configured in /Users/hunter/.env"));
459 assert!(!model.note.as_deref().unwrap().contains("/Users/"));
460
461 let role = CompositionRole::new("builder", Some("uses ZAI_API_KEY=zzz"));
462 assert!(!role.intent.as_deref().unwrap().contains("zzz"));
463
464 let request = FleetCompositionRequest::new(vec![model], vec![role]).expect("request");
465 let proposal = FleetCompositionProposal::validate(
466 &request,
467 vec![RoleSuggestion {
468 role: "builder".to_string(),
469 provider: "zai".to_string(),
470 model: "glm-5".to_string(),
471 reason: Some("matches /home/x/notes".to_string()),
472 }],
473 )
474 .expect("valid");
475
476 let json = serde_json::to_string(&proposal).expect("serialize");
477 assert!(!json.contains("/home/"), "{json}");
478 }
479
480 #[test]
483 fn a_proposal_carries_no_runtime_surface() {
484 let request = request();
485 let proposal = FleetCompositionProposal::validate(
486 &request,
487 vec![suggestion("builder", "zai", "glm-5")],
488 )
489 .expect("valid");
490 let json = serde_json::to_string(&proposal).expect("serialize");
491
492 assert!(json.contains("\"unratified\""), "{json}");
493 for forbidden in [
494 "snapshot",
495 "content_hash",
496 "permissions",
497 "reasoning_router",
498 "schema_revision",
499 ] {
500 assert!(
501 !json.contains(forbidden),
502 "a composition proposal must not look like a fleet: {forbidden} in {json}"
503 );
504 }
505 }
506}