返回 CodeWhale
fleet_composition.rs
根目录 / crates / workflow / src / fleet_composition.rs
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
507 lines RUST