| 1 | //! **Setup-time** Fleet composition: a suggestion schema, and nothing else. |
| 2 | //! |
| 3 | //! The same Reasoning Router service an operator attaches to a Fleet may later |
| 4 | //! *assist* Fleet setup, by proposing which configured provider/model should |
| 5 | //! take which role. That is a fundamentally different act from what the runtime |
| 6 | //! router does, and this module exists to keep the two from ever being |
| 7 | //! confused: |
| 8 | //! |
| 9 | //! | | Runtime router | Setup-time composition | |
| 10 | //! |---|---|---| |
| 11 | //! | Input | one frozen worker route | an explicit, redacted model pool + role list | |
| 12 | //! | Output | a reasoning tier | a *suggested* provider/model per role | |
| 13 | //! | Authority | applies immediately | **none** — a human must review and save | |
| 14 | //! |
| 15 | //! Three properties make this safe to have in the tree at all: |
| 16 | //! |
| 17 | //! 1. **It is pure.** No clock, no filesystem, no network, no config. It maps |
| 18 | //! an input value to an output value. |
| 19 | //! 2. **It cannot save or launch.** There is no path from a |
| 20 | //! [`crate::fleet_composition::FleetCompositionProposal`] to an |
| 21 | //! [`crate::ExactFleet`], a snapshot, or a |
| 22 | //! spawn. Turning a proposal into a saved Fleet is a separate, explicit act |
| 23 | //! that belongs to the saved-Fleet UI lane. |
| 24 | //! 3. **It cannot invent a model.** Every suggestion must name a model that is |
| 25 | //! already in the operator's explicitly configured pool; anything else is |
| 26 | //! rejected, not silently substituted. |
| 27 | //! |
| 28 | //! Every proposal is born |
| 29 | //! [`crate::fleet_composition::RatificationState::Unratified`] and there is no |
| 30 | //! method here that ratifies one. Ratification is a human act recorded |
| 31 | //! elsewhere; this module can only ever describe a suggestion. |
| 32 | //! |
| 33 | //! ## The seam |
| 34 | //! |
| 35 | //! Runtime **must not** call anything in this module, and nothing here reads a |
| 36 | //! [`crate::FleetSnapshot`] or mutates a saved Fleet. If a future lane wires |
| 37 | //! composition into a UI, the wiring belongs on the UI side of this boundary: |
| 38 | //! parse → propose → *show the human* → human saves a Fleet file → the ordinary |
| 39 | //! exact-Fleet path takes over from there. |
| 40 | |
| 41 | use std::collections::BTreeSet; |
| 42 | |
| 43 | use serde::{Deserialize, Serialize}; |
| 44 | use thiserror::Error; |
| 45 | |
| 46 | use crate::redaction::redact_for_disclosure; |
| 47 | |
| 48 | /// One entry in the operator's explicitly configured model pool. |
| 49 | /// |
| 50 | /// "Explicitly configured" is load-bearing: this is not a catalogue of models |
| 51 | /// that exist in the world, it is the set the operator has already set up and |
| 52 | /// is willing to spend money on. |
| 53 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 54 | pub struct ConfiguredModel { |
| 55 | /// Exact configured provider id. |
| 56 | pub provider: String, |
| 57 | /// Exact model id. |
| 58 | pub model: String, |
| 59 | /// Optional non-secret operator note (e.g. "cheap", "long context"). |
| 60 | /// Redacted on construction — a note is free text and may hold a path. |
| 61 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 62 | pub note: Option<String>, |
| 63 | } |
| 64 | |
| 65 | impl ConfiguredModel { |
| 66 | /// Build a pool entry, redacting the note. |
| 67 | #[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 | /// `provider/model` — the key a suggestion is checked against. |
| 79 | #[must_use] |
| 80 | pub fn key(&self) -> String { |
| 81 | format!("{}/{}", self.provider, self.model) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// A role the operator wants filled. |
| 86 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 87 | pub struct CompositionRole { |
| 88 | /// Semantic role name, e.g. `builder`. |
| 89 | pub role: String, |
| 90 | /// What the operator wants this role to do. Redacted on construction. |
| 91 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 92 | pub intent: Option<String>, |
| 93 | } |
| 94 | |
| 95 | impl 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 | /// The complete, explicit input to a composition request. |
| 108 | /// |
| 109 | /// Nothing is discovered: the caller states the pool and the roles. A composer |
| 110 | /// that could go looking for models would be choosing on the operator's behalf, |
| 111 | /// which is exactly the authority this schema withholds. |
| 112 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 113 | pub struct FleetCompositionRequest { |
| 114 | /// The explicitly configured, already-redacted model pool. |
| 115 | pub pool: Vec<ConfiguredModel>, |
| 116 | /// The roles to fill. |
| 117 | pub roles: Vec<CompositionRole>, |
| 118 | } |
| 119 | |
| 120 | impl FleetCompositionRequest { |
| 121 | /// Build and validate a request. |
| 122 | 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 | /// Whether a provider/model pair is in the pool. |
| 148 | #[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 | /// The pool keys, for an error message that tells the operator what *is* |
| 156 | /// available without them having to go look. |
| 157 | #[must_use] |
| 158 | pub fn pool_keys(&self) -> Vec<String> { |
| 159 | self.pool.iter().map(ConfiguredModel::key).collect() |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// One suggested role → provider/model assignment. |
| 164 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 165 | pub struct RoleSuggestion { |
| 166 | pub role: String, |
| 167 | pub provider: String, |
| 168 | pub model: String, |
| 169 | /// Short, redacted reason. Advisory text for a human reader only. |
| 170 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 171 | pub reason: Option<String>, |
| 172 | } |
| 173 | |
| 174 | /// Whether a proposal has been reviewed and accepted by a human. |
| 175 | /// |
| 176 | /// There is deliberately no constructor for [`Self::Ratified`] in this module: |
| 177 | /// ratification happens where a human actually clicks, and that is not here. |
| 178 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 179 | #[serde(rename_all = "snake_case")] |
| 180 | pub enum RatificationState { |
| 181 | /// A suggestion. Not a Fleet, not saved, not runnable. |
| 182 | Unratified, |
| 183 | /// Reviewed and accepted by a human, elsewhere. |
| 184 | Ratified, |
| 185 | } |
| 186 | |
| 187 | impl 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 | /// A composition proposal: suggestions plus the fact that nobody has agreed. |
| 198 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 199 | pub struct FleetCompositionProposal { |
| 200 | /// Always [`RatificationState::Unratified`] as produced here. |
| 201 | pub ratification: RatificationState, |
| 202 | /// One suggestion per requested role. |
| 203 | pub suggestions: Vec<RoleSuggestion>, |
| 204 | /// The banner a UI must show. Present in the value itself so a surface |
| 205 | /// cannot render the suggestions without it being obvious what they are. |
| 206 | pub advisory: String, |
| 207 | } |
| 208 | |
| 209 | /// The advisory every proposal carries. |
| 210 | pub 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 | |
| 214 | impl FleetCompositionProposal { |
| 215 | /// Validate a set of suggestions against the request that produced them. |
| 216 | /// |
| 217 | /// Every suggestion must name a role that was asked for and a provider/model |
| 218 | /// that is in the pool. A model outside the pool is an error, never a |
| 219 | /// substitution: silently swapping in something the operator did not |
| 220 | /// configure is the exact failure this whole schema exists to prevent. |
| 221 | 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 | /// Whether this proposal may be acted on automatically. Always `false`. |
| 268 | /// |
| 269 | /// A constant rather than a field lookup, so no future edit can make a |
| 270 | /// proposal self-executing by flipping a boolean somewhere. |
| 271 | #[must_use] |
| 272 | pub const fn is_actionable(&self) -> bool { |
| 273 | false |
| 274 | } |
| 275 | |
| 276 | /// Roles the request asked for that no suggestion covers. |
| 277 | #[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)] |
| 296 | pub 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)] |
| 325 | mod 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 | /// The central guardrail: a model the operator never configured is an |
| 372 | /// error, and the error names what *is* available. |
| 373 | #[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 | /// A right model on the wrong provider is still outside the pool. |
| 395 | #[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 | /// Free-text fields are operator prose and may hold a path or a key. They |
| 454 | /// are redacted on the way in, because a proposal is displayed and may be |
| 455 | /// saved alongside logs. |
| 456 | #[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 | /// The schema is inert: a proposal serializes as a suggestion and carries |
| 481 | /// no route, snapshot, or launch surface a runtime could act on. |
| 482 | #[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 | } |
| 507 |