| 1 | //! Exact named-Fleet schema — fully resolved members, no late model choice. |
| 2 | //! |
| 3 | //! A named Fleet is a saved, reusable team. Two forms of `fleets/<name>.toml` |
| 4 | //! exist and both keep working: |
| 5 | //! |
| 6 | //! - **Legacy** (`[roles]` role → AgentProfile id). See [`crate::NamedFleet`]. |
| 7 | //! Legacy files declare no `schema` key, which is what makes the legacy form |
| 8 | //! *explicitly detectable* rather than inferred from a missing table. |
| 9 | //! - **Exact** (`schema = "exact"`). Every member owns a stable member id/role, |
| 10 | //! an exact configured provider id, an exact model id, a requested reasoning |
| 11 | //! policy, and a permission ceiling. |
| 12 | //! |
| 13 | //! The exact form deliberately has **no** late-binding selectors. `inherit`, |
| 14 | //! `faster`/fast siblings, model-strength classes, and `model = "auto"` are |
| 15 | //! rejected at parse time, not silently resolved later — a Fleet the operator |
| 16 | //! saved is the Fleet that runs. Users switch Fleets; models never switch |
| 17 | //! themselves. |
| 18 | //! |
| 19 | //! The **Adaptive Reasoning Router is not a Fleet member.** A Fleet says *who* |
| 20 | //! runs; a Router is a separate, optional, reusable service that decides only |
| 21 | //! *how hard an already frozen route thinks*. An exact Fleet points at one by |
| 22 | //! name — `reasoning_router = "luna-low"` — and the same saved profile may be |
| 23 | //! referenced by any number of Fleets. See [`crate::reasoning_router`]. |
| 24 | //! |
| 25 | //! The prototype form (`[[members]]` with `kind = "router"`) still parses, is |
| 26 | //! labelled **legacy inline**, and normalizes into the same captured service. |
| 27 | //! It is retained for compatibility only; it is not a second runtime concept. |
| 28 | |
| 29 | use std::collections::BTreeMap; |
| 30 | |
| 31 | use serde::{Deserialize, Serialize}; |
| 32 | use thiserror::Error; |
| 33 | |
| 34 | use crate::reasoning_router::{FleetRouterRef, ReasoningRouterError, RouterCallReasoning}; |
| 35 | |
| 36 | /// Wire value of the `schema` key that selects the exact form. |
| 37 | pub const EXACT_FLEET_SCHEMA_KIND: &str = "exact"; |
| 38 | /// Wire value recorded for files in the pre-exact role→profile form. |
| 39 | pub const LEGACY_FLEET_SCHEMA_KIND: &str = "legacy"; |
| 40 | /// Current revision of the exact schema. |
| 41 | pub const EXACT_FLEET_SCHEMA_REVISION: u32 = 1; |
| 42 | |
| 43 | /// Member kind that selects the Fleet Router. |
| 44 | pub const ROUTER_MEMBER_KIND: &str = "router"; |
| 45 | /// Member kind for an ordinary dispatchable worker. |
| 46 | pub const WORKER_MEMBER_KIND: &str = "worker"; |
| 47 | |
| 48 | /// The Router's public id. A Router is addressed by this literal everywhere a |
| 49 | /// receipt, decision, or error names it, whatever the file called the member. |
| 50 | /// No worker may claim it — see [`ExactFleetError::ReservedRouterIdentity`]. |
| 51 | pub const ROUTER_PUBLIC_ID: &str = "router"; |
| 52 | /// The Router's public role. Identical to [`ROUTER_PUBLIC_ID`]: a Router has |
| 53 | /// exactly one identity and it is not a dispatchable role. |
| 54 | pub const ROUTER_PUBLIC_ROLE: &str = "router"; |
| 55 | |
| 56 | /// Public role names that were renamed, and what they are now called. |
| 57 | /// |
| 58 | /// A saved Fleet, a gate, a handoff record, and a task option are four |
| 59 | /// different places the *same* role name is written down, and they are written |
| 60 | /// at different times: a Fleet file saved a year ago says `oracle`, a workflow |
| 61 | /// script written today says `consultant`. Canonicalizing in only one of those |
| 62 | /// places is what turns a rename into a lookup failure, so every boundary that |
| 63 | /// compares a role goes through [`canonical_role_key`]. |
| 64 | /// |
| 65 | /// New schemas and receipts always record the canonical name — the alias is an |
| 66 | /// input spelling, never an output one. |
| 67 | pub const ROLE_ALIASES: &[(&str, &str)] = &[("oracle", "consultant"), ("advisor", "consultant")]; |
| 68 | |
| 69 | /// The canonical, case-folded key a role compares under. |
| 70 | /// |
| 71 | /// Trims, lowercases, and resolves a renamed public role to its current name. |
| 72 | /// This is the *only* way roles are compared anywhere in the exact-Fleet path: |
| 73 | /// parse writes the canonical name into the roster, `validate` detects |
| 74 | /// duplicates under it, and every lookup resolves the caller's spelling through |
| 75 | /// it. A member saved as `oracle` and a task naming `consultant` therefore meet, |
| 76 | /// and so do the reverse. |
| 77 | #[must_use] |
| 78 | pub fn canonical_role_key(value: &str) -> String { |
| 79 | let key = value.trim().to_ascii_lowercase(); |
| 80 | ROLE_ALIASES |
| 81 | .iter() |
| 82 | .find(|(alias, _)| *alias == key) |
| 83 | .map_or(key, |(_, canonical)| (*canonical).to_string()) |
| 84 | } |
| 85 | |
| 86 | /// The case-folded key a member **id** compares under. |
| 87 | /// |
| 88 | /// Ids are identities, not names, so they get no alias table — but they do get |
| 89 | /// case folding, because `ExactFleet` is `Deserialize` and a roster can reach a |
| 90 | /// lookup without having passed the parser that lowercased it. |
| 91 | #[must_use] |
| 92 | pub fn canonical_member_key(value: &str) -> String { |
| 93 | value.trim().to_ascii_lowercase() |
| 94 | } |
| 95 | |
| 96 | /// Selector tokens that are legal elsewhere in CodeWhale but are exactly what |
| 97 | /// the exact schema exists to forbid. Rejecting them by value (in addition to |
| 98 | /// `deny_unknown_fields` rejecting `model_strength`/`loadout`/`model_class` as |
| 99 | /// keys) is what keeps "exact" honest. |
| 100 | const FORBIDDEN_ROUTE_SELECTORS: &[&str] = &[ |
| 101 | "auto", "inherit", "parent", "same", "faster", "fast", "cheap", "strong", "balanced", "default", |
| 102 | ]; |
| 103 | |
| 104 | /// A concrete reasoning tier. Unlike [`RequestedReasoning`] this has no `auto` |
| 105 | /// — it is what a request actually runs at. |
| 106 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] |
| 107 | #[serde(rename_all = "snake_case")] |
| 108 | pub enum ReasoningTier { |
| 109 | Off, |
| 110 | Low, |
| 111 | Medium, |
| 112 | High, |
| 113 | Max, |
| 114 | } |
| 115 | |
| 116 | impl ReasoningTier { |
| 117 | #[must_use] |
| 118 | pub const fn as_str(self) -> &'static str { |
| 119 | match self { |
| 120 | Self::Off => "off", |
| 121 | Self::Low => "low", |
| 122 | Self::Medium => "medium", |
| 123 | Self::High => "high", |
| 124 | Self::Max => "max", |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /// Parse a concrete tier. `auto` is intentionally NOT accepted here. |
| 129 | pub fn parse(value: &str) -> Option<Self> { |
| 130 | match value.trim().to_ascii_lowercase().as_str() { |
| 131 | "off" | "none" | "disabled" => Some(Self::Off), |
| 132 | "low" | "minimal" => Some(Self::Low), |
| 133 | "medium" | "mid" => Some(Self::Medium), |
| 134 | "high" => Some(Self::High), |
| 135 | "max" | "maximum" | "xhigh" => Some(Self::Max), |
| 136 | _ => None, |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | /// The reasoning policy a member *requests*. `Auto` is an explicit per-member |
| 142 | /// opt-in, never a global mode. |
| 143 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 144 | #[serde(rename_all = "snake_case")] |
| 145 | pub enum RequestedReasoning { |
| 146 | Off, |
| 147 | Low, |
| 148 | Medium, |
| 149 | High, |
| 150 | Max, |
| 151 | Auto, |
| 152 | } |
| 153 | |
| 154 | impl RequestedReasoning { |
| 155 | #[must_use] |
| 156 | pub const fn as_str(self) -> &'static str { |
| 157 | match self { |
| 158 | Self::Off => "off", |
| 159 | Self::Low => "low", |
| 160 | Self::Medium => "medium", |
| 161 | Self::High => "high", |
| 162 | Self::Max => "max", |
| 163 | Self::Auto => "auto", |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | #[must_use] |
| 168 | pub const fn is_auto(self) -> bool { |
| 169 | matches!(self, Self::Auto) |
| 170 | } |
| 171 | |
| 172 | /// The concrete tier this request names, or `None` for `auto`. |
| 173 | #[must_use] |
| 174 | pub const fn tier(self) -> Option<ReasoningTier> { |
| 175 | match self { |
| 176 | Self::Off => Some(ReasoningTier::Off), |
| 177 | Self::Low => Some(ReasoningTier::Low), |
| 178 | Self::Medium => Some(ReasoningTier::Medium), |
| 179 | Self::High => Some(ReasoningTier::High), |
| 180 | Self::Max => Some(ReasoningTier::Max), |
| 181 | Self::Auto => None, |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | pub fn parse(value: &str) -> Option<Self> { |
| 186 | if matches!(value.trim().to_ascii_lowercase().as_str(), "auto") { |
| 187 | return Some(Self::Auto); |
| 188 | } |
| 189 | ReasoningTier::parse(value).map(|tier| match tier { |
| 190 | ReasoningTier::Off => Self::Off, |
| 191 | ReasoningTier::Low => Self::Low, |
| 192 | ReasoningTier::Medium => Self::Medium, |
| 193 | ReasoningTier::High => Self::High, |
| 194 | ReasoningTier::Max => Self::Max, |
| 195 | }) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// Shell posture, ordered most → least restrictive so `min_with` is the safe |
| 200 | /// side of a clamp. |
| 201 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] |
| 202 | #[serde(rename_all = "snake_case")] |
| 203 | pub enum ShellCeiling { |
| 204 | None, |
| 205 | ReadOnly, |
| 206 | Full, |
| 207 | } |
| 208 | |
| 209 | impl ShellCeiling { |
| 210 | #[must_use] |
| 211 | pub const fn as_str(self) -> &'static str { |
| 212 | match self { |
| 213 | Self::None => "none", |
| 214 | Self::ReadOnly => "read_only", |
| 215 | Self::Full => "full", |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | #[must_use] |
| 220 | pub fn min_with(self, other: Self) -> Self { |
| 221 | if self <= other { self } else { other } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// The most a member is allowed to do. This is a *ceiling*, never a grant: |
| 226 | /// [`PermissionCeiling::clamp_to`] can only ever narrow against the active |
| 227 | /// session posture, so a saved Fleet can never raise live authority. |
| 228 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 229 | pub struct PermissionCeiling { |
| 230 | pub write: bool, |
| 231 | /// Whether the member may be handed a **model-visible network tool** |
| 232 | /// (fetch, browse, HTTP). |
| 233 | /// |
| 234 | /// This is deliberately *not* a statement about transport. Host-owned |
| 235 | /// provider inference — the ordinary API call CodeWhale makes on the |
| 236 | /// member's behalf — always happens over the network and is not governed |
| 237 | /// by this field. A member with `network_tool = false` still runs on a |
| 238 | /// remote model; it simply has no tool with which to reach the network |
| 239 | /// itself. Receipts disclose that distinction rather than implying an |
| 240 | /// air-gap. |
| 241 | #[serde(alias = "network")] |
| 242 | pub network_tool: bool, |
| 243 | pub shell: ShellCeiling, |
| 244 | /// Nested-delegation budget this member may consume. |
| 245 | pub delegation_depth: u32, |
| 246 | /// Whether the member may be handed tools at all. |
| 247 | pub tools: bool, |
| 248 | } |
| 249 | |
| 250 | impl PermissionCeiling { |
| 251 | /// The Router's fixed posture: no tools (so no network tool), no shell, no |
| 252 | /// writes, no delegation. Not configurable — see [`RouterMember`]. |
| 253 | /// |
| 254 | /// The Router itself is still *inferred* by its configured provider over |
| 255 | /// the network; that is host-owned transport, disclosed on the receipt. |
| 256 | pub const ROUTER: Self = Self { |
| 257 | write: false, |
| 258 | network_tool: false, |
| 259 | shell: ShellCeiling::None, |
| 260 | delegation_depth: 0, |
| 261 | tools: false, |
| 262 | }; |
| 263 | |
| 264 | /// Named presets accepted by `permissions = "<preset>"`. |
| 265 | pub fn preset(name: &str) -> Option<Self> { |
| 266 | let base = |write, network_tool, shell, delegation_depth| Self { |
| 267 | write, |
| 268 | network_tool, |
| 269 | shell, |
| 270 | delegation_depth, |
| 271 | tools: true, |
| 272 | }; |
| 273 | match name.trim().to_ascii_lowercase().as_str() { |
| 274 | "none" => Some(Self::ROUTER), |
| 275 | "analyst" => Some(base(false, false, ShellCeiling::None, 0)), |
| 276 | "read_only" | "readonly" => Some(base(false, false, ShellCeiling::ReadOnly, 0)), |
| 277 | "tester" | "verifier" => Some(base(false, false, ShellCeiling::Full, 0)), |
| 278 | "read_write" | "readwrite" => Some(base(true, false, ShellCeiling::Full, 0)), |
| 279 | "full" => Some(base(true, true, ShellCeiling::Full, 1)), |
| 280 | _ => None, |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /// Narrow this ceiling against the active session posture. Every field |
| 285 | /// takes the more restrictive side, so the result can never grant more |
| 286 | /// than either input. |
| 287 | #[must_use] |
| 288 | pub fn clamp_to(self, session: Self) -> Self { |
| 289 | Self { |
| 290 | write: self.write && session.write, |
| 291 | network_tool: self.network_tool && session.network_tool, |
| 292 | shell: self.shell.min_with(session.shell), |
| 293 | delegation_depth: self.delegation_depth.min(session.delegation_depth), |
| 294 | tools: self.tools && session.tools, |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | impl Default for PermissionCeiling { |
| 300 | fn default() -> Self { |
| 301 | Self::preset("read_only").expect("read_only is a known preset") |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | /// The exact provider/model a member is frozen to before any reasoning |
| 306 | /// resolution runs. Nothing downstream may change these two strings. |
| 307 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 308 | pub struct FrozenRoute { |
| 309 | pub provider: String, |
| 310 | pub model: String, |
| 311 | } |
| 312 | |
| 313 | /// A dispatchable exact Fleet member. |
| 314 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 315 | pub struct ExactMember { |
| 316 | /// Stable member id — the identity a run refers to. |
| 317 | pub id: String, |
| 318 | /// Role name; defaults to the member id. |
| 319 | pub role: String, |
| 320 | /// Exact configured provider id (a `[providers.<id>]` key or a built-in id). |
| 321 | pub provider: String, |
| 322 | /// Exact model id. |
| 323 | pub model: String, |
| 324 | /// Requested reasoning policy for this member. |
| 325 | pub reasoning: RequestedReasoning, |
| 326 | /// Permission ceiling/default for this member. |
| 327 | pub permissions: PermissionCeiling, |
| 328 | } |
| 329 | |
| 330 | impl ExactMember { |
| 331 | /// The provider/model pair, frozen. Callers resolve reasoning *after* this. |
| 332 | #[must_use] |
| 333 | pub fn frozen_route(&self) -> FrozenRoute { |
| 334 | FrozenRoute { |
| 335 | provider: self.provider.clone(), |
| 336 | model: self.model.clone(), |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | #[must_use] |
| 341 | pub const fn is_dispatchable(&self) -> bool { |
| 342 | true |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | /// The **legacy inline** Router form: a `[[members]]` entry with |
| 347 | /// `kind = "router"`. |
| 348 | /// |
| 349 | /// Retained for compatibility with Fleet files written against the prototype. |
| 350 | /// It is normalized into [`crate::reasoning_router::CapturedReasoningRouter`] |
| 351 | /// at capture, so nothing downstream sees two kinds of Router. New Fleets |
| 352 | /// should use `reasoning_router = "<name>"` and a saved profile, which is what |
| 353 | /// lets several Fleets share one Router configuration. |
| 354 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 355 | pub struct RouterMember { |
| 356 | pub id: String, |
| 357 | pub provider: String, |
| 358 | pub model: String, |
| 359 | /// What the Router's own call runs at — `off` or `low` only, exactly as for |
| 360 | /// a saved profile. Defaults to `off`; `auto` is rejected (a router cannot |
| 361 | /// ask a router what to think) and `medium`/`high`/`max` are rejected |
| 362 | /// rather than clamped. |
| 363 | #[serde(default, alias = "reasoning")] |
| 364 | pub call_reasoning: RouterCallReasoning, |
| 365 | } |
| 366 | |
| 367 | impl RouterMember { |
| 368 | /// The Router's public id — always the literal `router`, regardless of the |
| 369 | /// member id the file used. Receipts and errors name this, so a Fleet |
| 370 | /// cannot disguise its Router behind a friendly label. |
| 371 | #[must_use] |
| 372 | pub const fn public_id(&self) -> &'static str { |
| 373 | ROUTER_PUBLIC_ID |
| 374 | } |
| 375 | |
| 376 | /// The Router's public role — always the literal `router`. |
| 377 | #[must_use] |
| 378 | pub const fn public_role(&self) -> &'static str { |
| 379 | ROUTER_PUBLIC_ROLE |
| 380 | } |
| 381 | |
| 382 | /// A Router is never a worker. This is a constant, not a policy lookup. |
| 383 | #[must_use] |
| 384 | pub const fn is_dispatchable(&self) -> bool { |
| 385 | false |
| 386 | } |
| 387 | |
| 388 | /// The Router's tool surface is empty, always. |
| 389 | #[must_use] |
| 390 | pub const fn tool_surface(&self) -> &'static [&'static str] { |
| 391 | &[] |
| 392 | } |
| 393 | |
| 394 | /// The Router's fixed permission ceiling. |
| 395 | #[must_use] |
| 396 | pub const fn permissions(&self) -> PermissionCeiling { |
| 397 | PermissionCeiling::ROUTER |
| 398 | } |
| 399 | |
| 400 | #[must_use] |
| 401 | pub fn frozen_route(&self) -> FrozenRoute { |
| 402 | FrozenRoute { |
| 403 | provider: self.provider.clone(), |
| 404 | model: self.model.clone(), |
| 405 | } |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | /// A parsed exact Fleet. |
| 410 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 411 | pub struct ExactFleet { |
| 412 | pub name: String, |
| 413 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 414 | pub description: Option<String>, |
| 415 | pub schema_revision: u32, |
| 416 | pub members: Vec<ExactMember>, |
| 417 | /// Name of the saved Reasoning Router profile this Fleet references. The |
| 418 | /// profile is a separate, reusable service — several Fleets may name the |
| 419 | /// same one. Accepts a qualified `origin/name`. |
| 420 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 421 | pub reasoning_router: Option<String>, |
| 422 | /// The legacy inline Router, if the file used the prototype form. |
| 423 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 424 | pub router: Option<RouterMember>, |
| 425 | } |
| 426 | |
| 427 | impl ExactFleet { |
| 428 | /// Look up a dispatchable member by its **member id only**. |
| 429 | /// |
| 430 | /// Roles are semantic labels used by gates, handoffs, and records; ids are |
| 431 | /// what addresses a roster entry. Keeping the two lookups separate is what |
| 432 | /// lets a task carry a meaningful role (`builder`) while the runtime |
| 433 | /// resolves a distinct profile id (`implementer`) — see |
| 434 | /// [`Self::member_by_role`]. |
| 435 | #[must_use] |
| 436 | pub fn member(&self, id: &str) -> Option<&ExactMember> { |
| 437 | let key = canonical_member_key(id); |
| 438 | self.members |
| 439 | .iter() |
| 440 | .find(|member| canonical_member_key(&member.id) == key) |
| 441 | } |
| 442 | |
| 443 | /// Look up a dispatchable member by its semantic role. |
| 444 | /// |
| 445 | /// Both sides resolve through [`canonical_role_key`], so a member saved |
| 446 | /// under a renamed role (`oracle`) is found by a task, gate, or handoff that |
| 447 | /// names either spelling. |
| 448 | #[must_use] |
| 449 | pub fn member_by_role(&self, role: &str) -> Option<&ExactMember> { |
| 450 | let key = canonical_role_key(role); |
| 451 | self.members |
| 452 | .iter() |
| 453 | .find(|member| canonical_role_key(&member.role) == key) |
| 454 | } |
| 455 | |
| 456 | /// Look up a member by id first, then by role. Roster invariants forbid an |
| 457 | /// id/role collision, so this can never be order-dependent. |
| 458 | #[must_use] |
| 459 | pub fn member_by_id_or_role(&self, id_or_role: &str) -> Option<&ExactMember> { |
| 460 | self.member(id_or_role) |
| 461 | .or_else(|| self.member_by_role(id_or_role)) |
| 462 | } |
| 463 | |
| 464 | /// How this Fleet points at its Router, if it does at all. |
| 465 | #[must_use] |
| 466 | pub fn router_ref(&self) -> Option<FleetRouterRef> { |
| 467 | if let Some(name) = &self.reasoning_router { |
| 468 | return Some(FleetRouterRef::Profile { name: name.clone() }); |
| 469 | } |
| 470 | self.router |
| 471 | .as_ref() |
| 472 | .map(|member| FleetRouterRef::LegacyInline(Box::new(member.clone()))) |
| 473 | } |
| 474 | |
| 475 | /// The legacy inline Router member, if the file used the prototype form. |
| 476 | #[must_use] |
| 477 | pub fn legacy_inline_router(&self) -> Option<&RouterMember> { |
| 478 | self.router.as_ref() |
| 479 | } |
| 480 | |
| 481 | /// Whether any member explicitly requested `reasoning = "auto"`. |
| 482 | #[must_use] |
| 483 | pub fn has_auto_member(&self) -> bool { |
| 484 | self.members.iter().any(|member| member.reasoning.is_auto()) |
| 485 | } |
| 486 | |
| 487 | /// Re-check every roster invariant that [`Self::parse`] enforces. |
| 488 | /// |
| 489 | /// `ExactFleet` is `pub` and `Deserialize`, so a value can reach a snapshot |
| 490 | /// without ever passing through the TOML parser. Capture calls this so a |
| 491 | /// hand-built or round-tripped roster cannot smuggle in a duplicate role, an |
| 492 | /// id/role collision, or a worker claiming the Router's identity. |
| 493 | pub fn validate(&self) -> Result<(), ExactFleetError> { |
| 494 | if self.members.is_empty() { |
| 495 | return Err(ExactFleetError::NoMembers { |
| 496 | fleet: self.name.clone(), |
| 497 | }); |
| 498 | } |
| 499 | |
| 500 | let mut ids: BTreeMap<String, ()> = BTreeMap::new(); |
| 501 | let mut roles: BTreeMap<String, ()> = BTreeMap::new(); |
| 502 | |
| 503 | for member in &self.members { |
| 504 | let id = canonical_member_key(&member.id); |
| 505 | // Duplicate detection runs on the canonical role key, so a roster |
| 506 | // carrying both `oracle` and `consultant` is caught as the collision |
| 507 | // it is rather than resolving by list order at lookup time. |
| 508 | let role = canonical_role_key(&member.role); |
| 509 | if id.is_empty() { |
| 510 | return Err(ExactFleetError::InvalidToken { |
| 511 | field: "member id".to_string(), |
| 512 | value: member.id.clone(), |
| 513 | }); |
| 514 | } |
| 515 | if role.is_empty() { |
| 516 | return Err(ExactFleetError::InvalidToken { |
| 517 | field: "member role".to_string(), |
| 518 | value: member.role.clone(), |
| 519 | }); |
| 520 | } |
| 521 | // A worker may never be called `router`, by id or by role: the |
| 522 | // Router's public identity is that literal, and a worker wearing it |
| 523 | // would make a receipt ambiguous about who decided the reasoning. |
| 524 | for (field, value) in [("id", &id), ("role", &role)] { |
| 525 | if value.as_str() == ROUTER_PUBLIC_ID { |
| 526 | return Err(ExactFleetError::ReservedRouterIdentity { |
| 527 | id: member.id.clone(), |
| 528 | field: field.to_string(), |
| 529 | }); |
| 530 | } |
| 531 | } |
| 532 | if ids.insert(id.clone(), ()).is_some() { |
| 533 | return Err(ExactFleetError::DuplicateMember { id }); |
| 534 | } |
| 535 | if roles.insert(role.clone(), ()).is_some() { |
| 536 | return Err(ExactFleetError::DuplicateRole { role }); |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | // An id belonging to one member and a role belonging to a *different* |
| 541 | // member would make `member()` lookup order-dependent, so it is a |
| 542 | // collision even though neither set has an internal duplicate. |
| 543 | for member in &self.members { |
| 544 | let id = canonical_member_key(&member.id); |
| 545 | if let Some(other) = self |
| 546 | .members |
| 547 | .iter() |
| 548 | .find(|other| other.id != member.id && canonical_role_key(&other.role) == id) |
| 549 | { |
| 550 | return Err(ExactFleetError::IdRoleCollision { |
| 551 | id: member.id.clone(), |
| 552 | other: other.id.clone(), |
| 553 | }); |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | Ok(()) |
| 558 | } |
| 559 | |
| 560 | /// Parse an exact Fleet from TOML text. |
| 561 | pub fn parse(text: &str) -> Result<Self, ExactFleetError> { |
| 562 | let doc: ExactFleetToml = |
| 563 | toml::from_str(text).map_err(|error| ExactFleetError::Parse(error.to_string()))?; |
| 564 | Self::from_toml(doc) |
| 565 | } |
| 566 | |
| 567 | fn from_toml(doc: ExactFleetToml) -> Result<Self, ExactFleetError> { |
| 568 | if !doc |
| 569 | .schema |
| 570 | .trim() |
| 571 | .eq_ignore_ascii_case(EXACT_FLEET_SCHEMA_KIND) |
| 572 | { |
| 573 | return Err(ExactFleetError::UnknownSchema { |
| 574 | schema: doc.schema.trim().to_string(), |
| 575 | }); |
| 576 | } |
| 577 | if doc.schema_revision != EXACT_FLEET_SCHEMA_REVISION { |
| 578 | return Err(ExactFleetError::UnsupportedRevision { |
| 579 | revision: doc.schema_revision, |
| 580 | supported: EXACT_FLEET_SCHEMA_REVISION, |
| 581 | }); |
| 582 | } |
| 583 | let name = require_token(&doc.name, "name")?; |
| 584 | |
| 585 | let mut members = Vec::new(); |
| 586 | let mut router: Option<RouterMember> = None; |
| 587 | let mut seen: BTreeMap<String, ()> = BTreeMap::new(); |
| 588 | |
| 589 | for raw in doc.members { |
| 590 | let id = require_token(&raw.id, "member id")?; |
| 591 | if seen.insert(id.clone(), ()).is_some() { |
| 592 | return Err(ExactFleetError::DuplicateMember { id }); |
| 593 | } |
| 594 | let provider = require_exact_route_token(&raw.provider, &id, "provider")?; |
| 595 | let model = require_exact_route_token(&raw.model, &id, "model")?; |
| 596 | let kind = raw |
| 597 | .kind |
| 598 | .as_deref() |
| 599 | .map(str::trim) |
| 600 | .filter(|kind| !kind.is_empty()) |
| 601 | .unwrap_or(WORKER_MEMBER_KIND) |
| 602 | .to_ascii_lowercase(); |
| 603 | |
| 604 | match kind.as_str() { |
| 605 | ROUTER_MEMBER_KIND => { |
| 606 | if router.is_some() { |
| 607 | return Err(ExactFleetError::MultipleRouters); |
| 608 | } |
| 609 | if raw.permissions.is_some() { |
| 610 | return Err(ExactFleetError::RouterPermissionsDeclared { id }); |
| 611 | } |
| 612 | if raw.role.is_some() { |
| 613 | return Err(ExactFleetError::RouterRoleDeclared { id }); |
| 614 | } |
| 615 | let call_reasoning = match raw.reasoning.as_deref() { |
| 616 | None => RouterCallReasoning::default(), |
| 617 | Some(value) if value.trim().eq_ignore_ascii_case("auto") => { |
| 618 | return Err(ExactFleetError::RouterAutoReasoning { id }); |
| 619 | } |
| 620 | // The cheap ceiling is a property of the *service*, not |
| 621 | // of how it was written down, so the legacy inline form |
| 622 | // gets the identical rejection a saved profile gets. |
| 623 | Some(value) => RouterCallReasoning::parse(value, &id) |
| 624 | .map_err(|source| ExactFleetError::Router { source })?, |
| 625 | }; |
| 626 | router = Some(RouterMember { |
| 627 | id, |
| 628 | provider, |
| 629 | model, |
| 630 | call_reasoning, |
| 631 | }); |
| 632 | } |
| 633 | WORKER_MEMBER_KIND => { |
| 634 | let role = match raw.role.as_deref() { |
| 635 | Some(role) => require_member_role(role)?, |
| 636 | None => id.clone(), |
| 637 | }; |
| 638 | let reasoning = match raw.reasoning.as_deref() { |
| 639 | None => RequestedReasoning::Off, |
| 640 | Some(value) => RequestedReasoning::parse(value).ok_or_else(|| { |
| 641 | ExactFleetError::InvalidReasoning { |
| 642 | id: id.clone(), |
| 643 | value: value.trim().to_string(), |
| 644 | } |
| 645 | })?, |
| 646 | }; |
| 647 | let permissions = match raw.permissions.as_deref() { |
| 648 | None => PermissionCeiling::default(), |
| 649 | Some(preset) => PermissionCeiling::preset(preset).ok_or_else(|| { |
| 650 | ExactFleetError::UnknownPermissionPreset { |
| 651 | id: id.clone(), |
| 652 | preset: preset.trim().to_string(), |
| 653 | } |
| 654 | })?, |
| 655 | }; |
| 656 | members.push(ExactMember { |
| 657 | id, |
| 658 | role, |
| 659 | provider, |
| 660 | model, |
| 661 | reasoning, |
| 662 | permissions, |
| 663 | }); |
| 664 | } |
| 665 | other => { |
| 666 | return Err(ExactFleetError::UnknownMemberKind { |
| 667 | id, |
| 668 | kind: other.to_string(), |
| 669 | }); |
| 670 | } |
| 671 | } |
| 672 | } |
| 673 | |
| 674 | if members.is_empty() { |
| 675 | return Err(ExactFleetError::NoMembers { fleet: name }); |
| 676 | } |
| 677 | |
| 678 | // One Router per Fleet, named exactly one way. Declaring both forms is |
| 679 | // an error rather than a precedence rule: a silent winner here would |
| 680 | // decide which provider sees every routing summary. |
| 681 | let reasoning_router = match doc.reasoning_router.as_deref().map(str::trim) { |
| 682 | Some(value) if !value.is_empty() => { |
| 683 | if router.is_some() { |
| 684 | return Err(ExactFleetError::ConflictingRouterDeclarations { fleet: name }); |
| 685 | } |
| 686 | Some(value.to_string()) |
| 687 | } |
| 688 | _ => None, |
| 689 | }; |
| 690 | |
| 691 | let fleet = Self { |
| 692 | name, |
| 693 | description: doc.description, |
| 694 | schema_revision: doc.schema_revision, |
| 695 | members, |
| 696 | reasoning_router, |
| 697 | router, |
| 698 | }; |
| 699 | // One authority for the roster invariants, shared with capture-time |
| 700 | // revalidation so the two can never drift. |
| 701 | fleet.validate()?; |
| 702 | Ok(fleet) |
| 703 | } |
| 704 | } |
| 705 | |
| 706 | /// Peek at a fleet document's `schema` key without committing to a form. |
| 707 | /// |
| 708 | /// Returns `None` for legacy files, which declare no `schema` key at all. |
| 709 | /// A malformed document returns `None` too; the legacy parser then owns the |
| 710 | /// error, keeping old files on the old diagnostics. |
| 711 | #[must_use] |
| 712 | pub fn declared_schema_kind(text: &str) -> Option<String> { |
| 713 | #[derive(Deserialize)] |
| 714 | struct SchemaProbe { |
| 715 | #[serde(default)] |
| 716 | schema: Option<String>, |
| 717 | } |
| 718 | |
| 719 | let probe: SchemaProbe = toml::from_str(text).ok()?; |
| 720 | probe |
| 721 | .schema |
| 722 | .map(|schema| schema.trim().to_ascii_lowercase()) |
| 723 | .filter(|schema| !schema.is_empty()) |
| 724 | } |
| 725 | |
| 726 | #[derive(Debug, Deserialize)] |
| 727 | #[serde(deny_unknown_fields)] |
| 728 | struct ExactFleetToml { |
| 729 | name: String, |
| 730 | #[serde(default)] |
| 731 | description: Option<String>, |
| 732 | schema: String, |
| 733 | #[serde(default = "default_schema_revision")] |
| 734 | schema_revision: u32, |
| 735 | /// Reference to a saved Reasoning Router profile. Optional: a Fleet whose |
| 736 | /// members all pin explicit tiers needs no Router at all. |
| 737 | #[serde(default)] |
| 738 | reasoning_router: Option<String>, |
| 739 | #[serde(default)] |
| 740 | members: Vec<ExactMemberToml>, |
| 741 | } |
| 742 | |
| 743 | /// `deny_unknown_fields` is load-bearing here: it is what rejects |
| 744 | /// `model_strength`, `loadout`, `model_class`, and any other late-binding |
| 745 | /// selector someone tries to smuggle into an exact member. |
| 746 | #[derive(Debug, Deserialize)] |
| 747 | #[serde(deny_unknown_fields)] |
| 748 | struct ExactMemberToml { |
| 749 | id: String, |
| 750 | #[serde(default)] |
| 751 | kind: Option<String>, |
| 752 | #[serde(default)] |
| 753 | role: Option<String>, |
| 754 | provider: String, |
| 755 | model: String, |
| 756 | #[serde(default)] |
| 757 | reasoning: Option<String>, |
| 758 | #[serde(default)] |
| 759 | permissions: Option<String>, |
| 760 | } |
| 761 | |
| 762 | const fn default_schema_revision() -> u32 { |
| 763 | EXACT_FLEET_SCHEMA_REVISION |
| 764 | } |
| 765 | |
| 766 | fn require_token(value: &str, field: &str) -> Result<String, ExactFleetError> { |
| 767 | crate::role_resolve::normalize_token(value).ok_or_else(|| ExactFleetError::InvalidToken { |
| 768 | field: field.to_string(), |
| 769 | value: value.trim().to_string(), |
| 770 | }) |
| 771 | } |
| 772 | |
| 773 | /// Canonicalize the renamed public roles at the saved-Fleet boundary, so a new |
| 774 | /// schema and every receipt it produces record only the current name. |
| 775 | /// |
| 776 | /// Exact Fleets otherwise permit domain-specific semantic roles (for example |
| 777 | /// `auditor`), so this is intentionally not a closed-role parser. The alias |
| 778 | /// table is shared with [`canonical_role_key`], which is what makes an *old* |
| 779 | /// file — parsed before this canonicalization existed, or reaching the roster |
| 780 | /// through `Deserialize` — still resolvable by either spelling at lookup time. |
| 781 | fn require_member_role(value: &str) -> Result<String, ExactFleetError> { |
| 782 | let role = require_token(value, "member role")?; |
| 783 | Ok(canonical_role_key(&role)) |
| 784 | } |
| 785 | |
| 786 | /// Provider/model ids keep their configured casing (a model id is |
| 787 | /// case-sensitive on the wire) but must be non-empty, whitespace-free, and must |
| 788 | /// not be a late-binding selector. |
| 789 | fn require_exact_route_token( |
| 790 | value: &str, |
| 791 | member: &str, |
| 792 | field: &str, |
| 793 | ) -> Result<String, ExactFleetError> { |
| 794 | let trimmed = value.trim(); |
| 795 | if trimmed.is_empty() |
| 796 | || trimmed |
| 797 | .chars() |
| 798 | .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '=')) |
| 799 | { |
| 800 | return Err(ExactFleetError::InvalidToken { |
| 801 | field: format!("{member}.{field}"), |
| 802 | value: trimmed.to_string(), |
| 803 | }); |
| 804 | } |
| 805 | if FORBIDDEN_ROUTE_SELECTORS |
| 806 | .iter() |
| 807 | .any(|selector| trimmed.eq_ignore_ascii_case(selector)) |
| 808 | { |
| 809 | return Err(ExactFleetError::LateBindingSelector { |
| 810 | id: member.to_string(), |
| 811 | field: field.to_string(), |
| 812 | value: trimmed.to_string(), |
| 813 | }); |
| 814 | } |
| 815 | Ok(trimmed.to_string()) |
| 816 | } |
| 817 | |
| 818 | #[derive(Debug, Clone, PartialEq, Eq, Error)] |
| 819 | pub enum ExactFleetError { |
| 820 | #[error("failed to parse exact fleet file: {0}")] |
| 821 | Parse(String), |
| 822 | #[error("unknown fleet schema `{schema}`; expected `exact`")] |
| 823 | UnknownSchema { schema: String }, |
| 824 | #[error( |
| 825 | "exact fleet schema revision {revision} is not supported (this build reads {supported})" |
| 826 | )] |
| 827 | UnsupportedRevision { revision: u32, supported: u32 }, |
| 828 | #[error("{field} must be a non-empty token without whitespace, quotes, or `=` (got `{value}`)")] |
| 829 | InvalidToken { field: String, value: String }, |
| 830 | #[error("duplicate fleet member id `{id}`")] |
| 831 | DuplicateMember { id: String }, |
| 832 | #[error( |
| 833 | "duplicate fleet member role `{role}`; two members cannot answer to the same role or a \ |
| 834 | task naming it would resolve to whichever one happened to be listed first" |
| 835 | )] |
| 836 | DuplicateRole { role: String }, |
| 837 | #[error( |
| 838 | "member `{id}` collides with member `{other}`: one member's id is another member's role, \ |
| 839 | so a task naming it would resolve by list order rather than by identity" |
| 840 | )] |
| 841 | IdRoleCollision { id: String, other: String }, |
| 842 | #[error( |
| 843 | "member `{id}` claims the reserved {field} `router`; that identity belongs to the fleet \ |
| 844 | router, which is declared with `kind = \"router\"` and is never dispatchable" |
| 845 | )] |
| 846 | ReservedRouterIdentity { id: String, field: String }, |
| 847 | #[error( |
| 848 | "fleet `{fleet}` snapshot content hash does not describe its own contents (recorded \ |
| 849 | `{recorded}`, recomputed `{recomputed}`). The snapshot was edited or migrated after \ |
| 850 | capture, so its hash cannot be used as evidence that a run matched a saved definition. \ |
| 851 | Re-capture the fleet." |
| 852 | )] |
| 853 | ContentHashMismatch { |
| 854 | fleet: String, |
| 855 | recorded: String, |
| 856 | recomputed: String, |
| 857 | }, |
| 858 | #[error("fleet `{fleet}` declares no dispatchable members")] |
| 859 | NoMembers { fleet: String }, |
| 860 | #[error("member `{id}` has unknown kind `{kind}`; expected `worker` or `router`")] |
| 861 | UnknownMemberKind { id: String, kind: String }, |
| 862 | #[error("a fleet may declare at most one router member")] |
| 863 | MultipleRouters, |
| 864 | #[error( |
| 865 | "member `{id}`.{field} is `{value}`, but exact fleets forbid late-binding route selectors \ |
| 866 | (inherit, fast siblings, model strength, or model=auto). Name the exact provider/model." |
| 867 | )] |
| 868 | LateBindingSelector { |
| 869 | id: String, |
| 870 | field: String, |
| 871 | value: String, |
| 872 | }, |
| 873 | #[error( |
| 874 | "member `{id}` has invalid reasoning `{value}`; expected off, low, medium, high, max, or auto" |
| 875 | )] |
| 876 | InvalidReasoning { id: String, value: String }, |
| 877 | #[error("member `{id}` has unknown permission preset `{preset}`")] |
| 878 | UnknownPermissionPreset { id: String, preset: String }, |
| 879 | #[error("router member `{id}` may not declare permissions; a router gets none by construction")] |
| 880 | RouterPermissionsDeclared { id: String }, |
| 881 | #[error( |
| 882 | "router member `{id}` may not declare a role; a router is never dispatched as a worker" |
| 883 | )] |
| 884 | RouterRoleDeclared { id: String }, |
| 885 | #[error( |
| 886 | "router member `{id}` may not request reasoning `auto`; a router's own thinking is a fixed tier (default off)" |
| 887 | )] |
| 888 | RouterAutoReasoning { id: String }, |
| 889 | #[error( |
| 890 | "fleet `{fleet}` declares both `reasoning_router = \"...\"` and an inline \ |
| 891 | `kind = \"router\"` member. A fleet references exactly one reasoning router service; \ |
| 892 | pick the saved profile (preferred, and shareable across fleets) or the legacy inline \ |
| 893 | form, not both." |
| 894 | )] |
| 895 | ConflictingRouterDeclarations { fleet: String }, |
| 896 | #[error(transparent)] |
| 897 | Router { |
| 898 | #[from] |
| 899 | source: ReasoningRouterError, |
| 900 | }, |
| 901 | } |
| 902 | |
| 903 | #[cfg(test)] |
| 904 | mod role_alias_tests { |
| 905 | use super::*; |
| 906 | |
| 907 | /// A Fleet saved before the rename. The file spells the advisory role |
| 908 | /// `oracle`; everything downstream must call it `consultant`. |
| 909 | const RENAMED_ROLE_FLEET: &str = r#" |
| 910 | name = "counsel" |
| 911 | schema = "exact" |
| 912 | |
| 913 | [[members]] |
| 914 | id = "advisor-one" |
| 915 | role = "oracle" |
| 916 | provider = "zai" |
| 917 | model = "glm-5" |
| 918 | permissions = "analyst" |
| 919 | "#; |
| 920 | |
| 921 | /// Parse canonicalizes on the way in, so the roster — and therefore every |
| 922 | /// receipt built from it — records only the current name. |
| 923 | #[test] |
| 924 | fn parsing_a_renamed_role_stores_the_canonical_name() { |
| 925 | let fleet = ExactFleet::parse(RENAMED_ROLE_FLEET).expect("parse"); |
| 926 | assert_eq!(fleet.members[0].role, "consultant"); |
| 927 | } |
| 928 | |
| 929 | /// The compatibility half: a saved task, gate, or handoff that still spells |
| 930 | /// the role the old way resolves to the same member. This is the lookup that |
| 931 | /// used to fail, because parse canonicalized and the lookup did not. |
| 932 | #[test] |
| 933 | fn every_alias_spelling_resolves_to_the_same_member() { |
| 934 | let fleet = ExactFleet::parse(RENAMED_ROLE_FLEET).expect("parse"); |
| 935 | |
| 936 | for spelling in ["consultant", "oracle", "advisor", "Oracle", " ADVISOR "] { |
| 937 | let member = fleet |
| 938 | .member_by_role(spelling) |
| 939 | .unwrap_or_else(|| panic!("`{spelling}` must resolve")); |
| 940 | assert_eq!(member.id, "advisor-one"); |
| 941 | assert_eq!(member.role, "consultant", "receipts stay canonical"); |
| 942 | } |
| 943 | |
| 944 | // `member_by_id_or_role` is what the runtime actually calls. |
| 945 | assert_eq!( |
| 946 | fleet |
| 947 | .member_by_id_or_role("oracle") |
| 948 | .expect("alias resolves through the combined lookup") |
| 949 | .id, |
| 950 | "advisor-one" |
| 951 | ); |
| 952 | } |
| 953 | |
| 954 | /// A Fleet written against the *new* name keeps working, and is equally |
| 955 | /// reachable by the old one — the rename is bidirectional at the lookup. |
| 956 | #[test] |
| 957 | fn a_canonical_role_is_reachable_by_its_alias() { |
| 958 | let text = RENAMED_ROLE_FLEET.replace(r#"role = "oracle""#, r#"role = "consultant""#); |
| 959 | let fleet = ExactFleet::parse(&text).expect("parse"); |
| 960 | |
| 961 | assert_eq!(fleet.members[0].role, "consultant"); |
| 962 | assert!(fleet.member_by_role("oracle").is_some()); |
| 963 | assert!(fleet.member_by_role("advisor").is_some()); |
| 964 | } |
| 965 | |
| 966 | /// A roster that reaches `validate` through `Deserialize` — never having |
| 967 | /// passed the parser — is still judged on canonical keys. `oracle` and |
| 968 | /// `consultant` are one role, so declaring both is the collision it looks |
| 969 | /// like, not a pair that resolves by list order. |
| 970 | #[test] |
| 971 | fn an_alias_and_its_canonical_name_collide_on_reload() { |
| 972 | let member = |id: &str, role: &str| ExactMember { |
| 973 | id: id.to_string(), |
| 974 | role: role.to_string(), |
| 975 | provider: "zai".to_string(), |
| 976 | model: "glm-5".to_string(), |
| 977 | reasoning: RequestedReasoning::Off, |
| 978 | permissions: PermissionCeiling::default(), |
| 979 | }; |
| 980 | let fleet = ExactFleet { |
| 981 | name: "counsel".to_string(), |
| 982 | description: None, |
| 983 | schema_revision: EXACT_FLEET_SCHEMA_REVISION, |
| 984 | members: vec![member("a", "oracle"), member("b", "consultant")], |
| 985 | reasoning_router: None, |
| 986 | router: None, |
| 987 | }; |
| 988 | |
| 989 | assert!(matches!( |
| 990 | fleet.validate(), |
| 991 | Err(ExactFleetError::DuplicateRole { role }) if role == "consultant" |
| 992 | )); |
| 993 | } |
| 994 | |
| 995 | /// Ids are identities, not names: no alias table, but case folding, because |
| 996 | /// a deserialized roster never passed the parser that lowercased it. |
| 997 | #[test] |
| 998 | fn member_ids_resolve_case_insensitively_without_aliasing() { |
| 999 | let fleet = ExactFleet { |
| 1000 | name: "counsel".to_string(), |
| 1001 | description: None, |
| 1002 | schema_revision: EXACT_FLEET_SCHEMA_REVISION, |
| 1003 | members: vec![ExactMember { |
| 1004 | id: "Builder".to_string(), |
| 1005 | role: "auditor".to_string(), |
| 1006 | provider: "zai".to_string(), |
| 1007 | model: "glm-5".to_string(), |
| 1008 | reasoning: RequestedReasoning::Off, |
| 1009 | permissions: PermissionCeiling::default(), |
| 1010 | }], |
| 1011 | reasoning_router: None, |
| 1012 | router: None, |
| 1013 | }; |
| 1014 | |
| 1015 | assert!(fleet.member("builder").is_some()); |
| 1016 | assert!(fleet.member("Builder").is_some()); |
| 1017 | // `oracle` is a role alias, never an id alias. |
| 1018 | assert!(fleet.member("oracle").is_none()); |
| 1019 | } |
| 1020 | |
| 1021 | #[test] |
| 1022 | fn canonical_role_key_maps_only_the_declared_aliases() { |
| 1023 | assert_eq!(canonical_role_key(" Oracle "), "consultant"); |
| 1024 | assert_eq!(canonical_role_key("ADVISOR"), "consultant"); |
| 1025 | assert_eq!(canonical_role_key("consultant"), "consultant"); |
| 1026 | // Unrelated semantic roles pass through untouched, case-folded only. |
| 1027 | assert_eq!(canonical_role_key("Auditor"), "auditor"); |
| 1028 | assert_eq!(canonical_role_key("router"), "router"); |
| 1029 | } |
| 1030 | } |
| 1031 | |
| 1032 | #[cfg(test)] |
| 1033 | mod tests { |
| 1034 | use super::*; |
| 1035 | |
| 1036 | const GLM_FLEET: &str = r#" |
| 1037 | name = "glm-pair" |
| 1038 | description = "GLM worker with a GLM Turbo router" |
| 1039 | schema = "exact" |
| 1040 | schema_revision = 1 |
| 1041 | |
| 1042 | [[members]] |
| 1043 | id = "implementer" |
| 1044 | role = "builder" |
| 1045 | provider = "zai" |
| 1046 | model = "glm-5" |
| 1047 | reasoning = "auto" |
| 1048 | permissions = "read_write" |
| 1049 | |
| 1050 | [[members]] |
| 1051 | id = "router" |
| 1052 | kind = "router" |
| 1053 | provider = "zai" |
| 1054 | model = "glm-5-turbo" |
| 1055 | "#; |
| 1056 | |
| 1057 | /// A Fleet that references a saved, reusable Router service by name — the |
| 1058 | /// form new Fleets use. |
| 1059 | const NAMED_ROUTER_FLEET: &str = r#" |
| 1060 | name = "glm-pair" |
| 1061 | schema = "exact" |
| 1062 | reasoning_router = "luna-low" |
| 1063 | |
| 1064 | [[members]] |
| 1065 | id = "implementer" |
| 1066 | role = "builder" |
| 1067 | provider = "zai" |
| 1068 | model = "glm-5" |
| 1069 | reasoning = "auto" |
| 1070 | "#; |
| 1071 | |
| 1072 | #[test] |
| 1073 | fn exact_fleet_parses_members_and_a_legacy_inline_router() { |
| 1074 | let fleet = ExactFleet::parse(GLM_FLEET).expect("parse"); |
| 1075 | assert_eq!(fleet.name, "glm-pair"); |
| 1076 | assert_eq!(fleet.schema_revision, EXACT_FLEET_SCHEMA_REVISION); |
| 1077 | assert_eq!(fleet.members.len(), 1); |
| 1078 | |
| 1079 | // Id and role are separate lookups: a role is a semantic label, an id |
| 1080 | // addresses a roster entry. |
| 1081 | let member = fleet.member_by_role("builder").expect("role lookup"); |
| 1082 | assert_eq!(member.id, "implementer"); |
| 1083 | assert_eq!( |
| 1084 | fleet.member("implementer").expect("id lookup").id, |
| 1085 | "implementer" |
| 1086 | ); |
| 1087 | assert!( |
| 1088 | fleet.member("builder").is_none(), |
| 1089 | "an id lookup must not answer to a role" |
| 1090 | ); |
| 1091 | assert_eq!(member.provider, "zai"); |
| 1092 | assert_eq!(member.model, "glm-5"); |
| 1093 | assert_eq!(member.reasoning, RequestedReasoning::Auto); |
| 1094 | assert!(member.permissions.write); |
| 1095 | assert!(!member.permissions.network_tool); |
| 1096 | |
| 1097 | let router = fleet.legacy_inline_router().expect("inline router"); |
| 1098 | assert_eq!(router.provider, "zai"); |
| 1099 | assert_eq!(router.model, "glm-5-turbo"); |
| 1100 | // The call tier defaults to off when the file says nothing. |
| 1101 | assert_eq!(router.call_reasoning, RouterCallReasoning::Off); |
| 1102 | assert!(matches!( |
| 1103 | fleet.router_ref(), |
| 1104 | Some(FleetRouterRef::LegacyInline(_)) |
| 1105 | )); |
| 1106 | } |
| 1107 | |
| 1108 | #[test] |
| 1109 | fn legacy_advisory_role_names_canonicalize_to_consultant() { |
| 1110 | for legacy in ["oracle", "advisor"] { |
| 1111 | let text = GLM_FLEET.replace("role = \"builder\"", &format!("role = \"{legacy}\"")); |
| 1112 | let fleet = ExactFleet::parse(&text).expect("legacy role parses"); |
| 1113 | assert_eq!(fleet.members[0].role, "consultant"); |
| 1114 | assert!(fleet.member_by_role("consultant").is_some()); |
| 1115 | // The rename resolves in both directions: the roster stores the |
| 1116 | // canonical name, and a caller still spelling the legacy one lands |
| 1117 | // on the same member rather than on nothing. |
| 1118 | assert_eq!( |
| 1119 | fleet |
| 1120 | .member_by_role(legacy) |
| 1121 | .map(|member| member.id.as_str()), |
| 1122 | fleet |
| 1123 | .member_by_role("consultant") |
| 1124 | .map(|member| member.id.as_str()), |
| 1125 | ); |
| 1126 | } |
| 1127 | } |
| 1128 | |
| 1129 | /// The preferred form: the Router is a *reference* to a saved service, so |
| 1130 | /// several Fleets can point at one configuration. |
| 1131 | #[test] |
| 1132 | fn a_fleet_references_a_named_reasoning_router_service() { |
| 1133 | let fleet = ExactFleet::parse(NAMED_ROUTER_FLEET).expect("parse"); |
| 1134 | |
| 1135 | assert_eq!(fleet.reasoning_router.as_deref(), Some("luna-low")); |
| 1136 | assert!(fleet.legacy_inline_router().is_none()); |
| 1137 | assert!(matches!( |
| 1138 | fleet.router_ref(), |
| 1139 | Some(FleetRouterRef::Profile { ref name }) if name == "luna-low" |
| 1140 | )); |
| 1141 | assert!(fleet.has_auto_member()); |
| 1142 | |
| 1143 | // A qualified origin is accepted verbatim; resolution happens in the |
| 1144 | // host that owns the search roots. |
| 1145 | let qualified = NAMED_ROUTER_FLEET.replace("\"luna-low\"", "\"codewhale_home/luna-low\""); |
| 1146 | assert!(matches!( |
| 1147 | ExactFleet::parse(&qualified).expect("parse").router_ref(), |
| 1148 | Some(FleetRouterRef::Profile { ref name }) if name == "codewhale_home/luna-low" |
| 1149 | )); |
| 1150 | } |
| 1151 | |
| 1152 | /// Both forms at once would make a silent winner decide which provider sees |
| 1153 | /// every routing summary, so it is an error instead. |
| 1154 | #[test] |
| 1155 | fn declaring_both_router_forms_is_rejected() { |
| 1156 | let both = GLM_FLEET.replace( |
| 1157 | "schema_revision = 1", |
| 1158 | "schema_revision = 1\nreasoning_router = \"luna-low\"", |
| 1159 | ); |
| 1160 | let err = ExactFleet::parse(&both).expect_err("two router declarations"); |
| 1161 | assert!( |
| 1162 | matches!(err, ExactFleetError::ConflictingRouterDeclarations { .. }), |
| 1163 | "{err:?}" |
| 1164 | ); |
| 1165 | assert!(err.to_string().contains("exactly one"), "{err}"); |
| 1166 | } |
| 1167 | |
| 1168 | /// The cheap call ceiling belongs to the service, not to how it was written |
| 1169 | /// down: the inline form gets the identical rejection a saved profile does. |
| 1170 | #[test] |
| 1171 | fn a_legacy_inline_router_may_not_request_an_expensive_call_tier() { |
| 1172 | for value in ["medium", "high", "max"] { |
| 1173 | let text = format!("{GLM_FLEET}reasoning = \"{value}\"\n"); |
| 1174 | let err = ExactFleet::parse(&text).expect_err("expensive router tier"); |
| 1175 | assert!( |
| 1176 | matches!( |
| 1177 | err, |
| 1178 | ExactFleetError::Router { |
| 1179 | source: ReasoningRouterError::CallReasoningTooExpensive { .. } |
| 1180 | } |
| 1181 | ), |
| 1182 | "value={value} err={err:?}" |
| 1183 | ); |
| 1184 | } |
| 1185 | |
| 1186 | let low = format!("{GLM_FLEET}reasoning = \"low\"\n"); |
| 1187 | assert_eq!( |
| 1188 | ExactFleet::parse(&low) |
| 1189 | .expect("low is allowed") |
| 1190 | .legacy_inline_router() |
| 1191 | .expect("router") |
| 1192 | .call_reasoning, |
| 1193 | RouterCallReasoning::Low |
| 1194 | ); |
| 1195 | } |
| 1196 | |
| 1197 | #[test] |
| 1198 | fn a_router_is_not_dispatchable_and_holds_no_authority() { |
| 1199 | let fleet = ExactFleet::parse(GLM_FLEET).expect("parse"); |
| 1200 | let router = fleet.legacy_inline_router().expect("router"); |
| 1201 | |
| 1202 | assert!(!router.is_dispatchable()); |
| 1203 | assert!(router.tool_surface().is_empty()); |
| 1204 | let permissions = router.permissions(); |
| 1205 | assert!(!permissions.tools); |
| 1206 | assert!(!permissions.write); |
| 1207 | assert!(!permissions.network_tool); |
| 1208 | assert_eq!(permissions.shell, ShellCeiling::None); |
| 1209 | assert_eq!(permissions.delegation_depth, 0); |
| 1210 | |
| 1211 | // The router is not reachable through worker lookup either. |
| 1212 | assert!(fleet.member_by_id_or_role("router").is_none()); |
| 1213 | assert!(fleet.members.iter().all(ExactMember::is_dispatchable)); |
| 1214 | } |
| 1215 | |
| 1216 | #[test] |
| 1217 | fn late_binding_selectors_are_rejected() { |
| 1218 | for (field, value) in [ |
| 1219 | ("model", "auto"), |
| 1220 | ("model", "inherit"), |
| 1221 | ("model", "faster"), |
| 1222 | ("model", "strong"), |
| 1223 | ("provider", "inherit"), |
| 1224 | ] { |
| 1225 | let text = format!( |
| 1226 | r#" |
| 1227 | name = "f" |
| 1228 | schema = "exact" |
| 1229 | |
| 1230 | [[members]] |
| 1231 | id = "w" |
| 1232 | provider = "{provider}" |
| 1233 | model = "{model}" |
| 1234 | "#, |
| 1235 | provider = if field == "provider" { value } else { "zai" }, |
| 1236 | model = if field == "model" { value } else { "glm-5" }, |
| 1237 | ); |
| 1238 | let err = ExactFleet::parse(&text).expect_err("selector must be rejected"); |
| 1239 | assert!( |
| 1240 | matches!(err, ExactFleetError::LateBindingSelector { .. }), |
| 1241 | "field={field} value={value} err={err:?}" |
| 1242 | ); |
| 1243 | } |
| 1244 | } |
| 1245 | |
| 1246 | #[test] |
| 1247 | fn model_strength_and_loadout_keys_are_rejected() { |
| 1248 | for key in ["model_strength", "loadout", "model_class", "model_hint"] { |
| 1249 | let text = format!( |
| 1250 | r#" |
| 1251 | name = "f" |
| 1252 | schema = "exact" |
| 1253 | |
| 1254 | [[members]] |
| 1255 | id = "w" |
| 1256 | provider = "zai" |
| 1257 | model = "glm-5" |
| 1258 | {key} = "strong" |
| 1259 | "# |
| 1260 | ); |
| 1261 | let err = ExactFleet::parse(&text).expect_err("unknown key must be rejected"); |
| 1262 | assert!( |
| 1263 | matches!(err, ExactFleetError::Parse(_)), |
| 1264 | "key={key} err={err:?}" |
| 1265 | ); |
| 1266 | } |
| 1267 | } |
| 1268 | |
| 1269 | #[test] |
| 1270 | fn router_rejects_auth_permissions_role_and_auto_reasoning() { |
| 1271 | let base = r#" |
| 1272 | name = "f" |
| 1273 | schema = "exact" |
| 1274 | |
| 1275 | [[members]] |
| 1276 | id = "w" |
| 1277 | provider = "zai" |
| 1278 | model = "glm-5" |
| 1279 | |
| 1280 | [[members]] |
| 1281 | id = "router" |
| 1282 | kind = "router" |
| 1283 | provider = "zai" |
| 1284 | model = "glm-5-turbo" |
| 1285 | "#; |
| 1286 | let permissions = format!("{base}permissions = \"full\"\n"); |
| 1287 | assert!(matches!( |
| 1288 | ExactFleet::parse(&permissions).expect_err("permissions rejected"), |
| 1289 | ExactFleetError::RouterPermissionsDeclared { .. } |
| 1290 | )); |
| 1291 | |
| 1292 | let role = format!("{base}role = \"builder\"\n"); |
| 1293 | assert!(matches!( |
| 1294 | ExactFleet::parse(&role).expect_err("role rejected"), |
| 1295 | ExactFleetError::RouterRoleDeclared { .. } |
| 1296 | )); |
| 1297 | |
| 1298 | let auto = format!("{base}reasoning = \"auto\"\n"); |
| 1299 | assert!(matches!( |
| 1300 | ExactFleet::parse(&auto).expect_err("auto rejected"), |
| 1301 | ExactFleetError::RouterAutoReasoning { .. } |
| 1302 | )); |
| 1303 | } |
| 1304 | |
| 1305 | #[test] |
| 1306 | fn duplicate_members_and_multiple_routers_fail() { |
| 1307 | let duplicate = r#" |
| 1308 | name = "f" |
| 1309 | schema = "exact" |
| 1310 | |
| 1311 | [[members]] |
| 1312 | id = "w" |
| 1313 | provider = "zai" |
| 1314 | model = "glm-5" |
| 1315 | |
| 1316 | [[members]] |
| 1317 | id = "w" |
| 1318 | provider = "zai" |
| 1319 | model = "glm-5" |
| 1320 | "#; |
| 1321 | assert!(matches!( |
| 1322 | ExactFleet::parse(duplicate).expect_err("duplicate"), |
| 1323 | ExactFleetError::DuplicateMember { .. } |
| 1324 | )); |
| 1325 | |
| 1326 | let two_routers = r#" |
| 1327 | name = "f" |
| 1328 | schema = "exact" |
| 1329 | |
| 1330 | [[members]] |
| 1331 | id = "w" |
| 1332 | provider = "zai" |
| 1333 | model = "glm-5" |
| 1334 | |
| 1335 | [[members]] |
| 1336 | id = "r1" |
| 1337 | kind = "router" |
| 1338 | provider = "zai" |
| 1339 | model = "glm-5-turbo" |
| 1340 | |
| 1341 | [[members]] |
| 1342 | id = "r2" |
| 1343 | kind = "router" |
| 1344 | provider = "zai" |
| 1345 | model = "glm-5-turbo" |
| 1346 | "#; |
| 1347 | assert!(matches!( |
| 1348 | ExactFleet::parse(two_routers).expect_err("two routers"), |
| 1349 | ExactFleetError::MultipleRouters |
| 1350 | )); |
| 1351 | } |
| 1352 | |
| 1353 | /// Two members answering to one role, or one member's id being another's |
| 1354 | /// role, would make `member()` resolve by list order instead of identity. |
| 1355 | #[test] |
| 1356 | fn duplicate_roles_and_id_role_collisions_are_rejected() { |
| 1357 | let duplicate_role = r#" |
| 1358 | name = "f" |
| 1359 | schema = "exact" |
| 1360 | |
| 1361 | [[members]] |
| 1362 | id = "a" |
| 1363 | role = "builder" |
| 1364 | provider = "zai" |
| 1365 | model = "glm-5" |
| 1366 | |
| 1367 | [[members]] |
| 1368 | id = "b" |
| 1369 | role = "builder" |
| 1370 | provider = "zai" |
| 1371 | model = "glm-5" |
| 1372 | "#; |
| 1373 | assert!(matches!( |
| 1374 | ExactFleet::parse(duplicate_role).expect_err("duplicate role"), |
| 1375 | ExactFleetError::DuplicateRole { .. } |
| 1376 | )); |
| 1377 | |
| 1378 | // `b`'s role is `a`'s id: naming "a" would be ambiguous. |
| 1379 | let collision = r#" |
| 1380 | name = "f" |
| 1381 | schema = "exact" |
| 1382 | |
| 1383 | [[members]] |
| 1384 | id = "a" |
| 1385 | role = "builder" |
| 1386 | provider = "zai" |
| 1387 | model = "glm-5" |
| 1388 | |
| 1389 | [[members]] |
| 1390 | id = "b" |
| 1391 | role = "a" |
| 1392 | provider = "zai" |
| 1393 | model = "glm-5" |
| 1394 | "#; |
| 1395 | assert!(matches!( |
| 1396 | ExactFleet::parse(collision).expect_err("id/role collision"), |
| 1397 | ExactFleetError::IdRoleCollision { .. } |
| 1398 | )); |
| 1399 | } |
| 1400 | |
| 1401 | /// `router` is the Router's public identity. A worker may not wear it by |
| 1402 | /// either id or role. |
| 1403 | #[test] |
| 1404 | fn a_worker_may_not_claim_the_router_identity() { |
| 1405 | for (id, role) in [("router", None), ("helper", Some("router"))] { |
| 1406 | let role_line = role.map_or(String::new(), |role| format!("role = \"{role}\"\n")); |
| 1407 | let text = format!( |
| 1408 | r#" |
| 1409 | name = "f" |
| 1410 | schema = "exact" |
| 1411 | |
| 1412 | [[members]] |
| 1413 | id = "{id}" |
| 1414 | {role_line}provider = "zai" |
| 1415 | model = "glm-5" |
| 1416 | "# |
| 1417 | ); |
| 1418 | let err = ExactFleet::parse(&text).expect_err("reserved router identity"); |
| 1419 | assert!( |
| 1420 | matches!(err, ExactFleetError::ReservedRouterIdentity { .. }), |
| 1421 | "id={id} role={role:?} err={err:?}" |
| 1422 | ); |
| 1423 | } |
| 1424 | } |
| 1425 | |
| 1426 | /// `ExactFleet` is `pub` and `Deserialize`, so the invariants must be |
| 1427 | /// re-checkable on a value that never went through the TOML parser. |
| 1428 | #[test] |
| 1429 | fn capture_time_revalidation_catches_a_hand_built_roster() { |
| 1430 | let member = |id: &str, role: &str| ExactMember { |
| 1431 | id: id.to_string(), |
| 1432 | role: role.to_string(), |
| 1433 | provider: "zai".to_string(), |
| 1434 | model: "glm-5".to_string(), |
| 1435 | reasoning: RequestedReasoning::Off, |
| 1436 | permissions: PermissionCeiling::default(), |
| 1437 | }; |
| 1438 | |
| 1439 | let valid = ExactFleet { |
| 1440 | name: "f".to_string(), |
| 1441 | description: None, |
| 1442 | schema_revision: EXACT_FLEET_SCHEMA_REVISION, |
| 1443 | members: vec![member("a", "scout"), member("b", "builder")], |
| 1444 | reasoning_router: None, |
| 1445 | router: None, |
| 1446 | }; |
| 1447 | valid.validate().expect("a clean roster validates"); |
| 1448 | |
| 1449 | for (fleet, label) in [ |
| 1450 | ( |
| 1451 | ExactFleet { |
| 1452 | members: vec![member("a", "scout"), member("a", "builder")], |
| 1453 | ..valid.clone() |
| 1454 | }, |
| 1455 | "duplicate id", |
| 1456 | ), |
| 1457 | ( |
| 1458 | ExactFleet { |
| 1459 | members: vec![member("a", "scout"), member("b", "scout")], |
| 1460 | ..valid.clone() |
| 1461 | }, |
| 1462 | "duplicate role", |
| 1463 | ), |
| 1464 | ( |
| 1465 | ExactFleet { |
| 1466 | members: vec![member("router", "scout")], |
| 1467 | ..valid.clone() |
| 1468 | }, |
| 1469 | "reserved router id", |
| 1470 | ), |
| 1471 | ( |
| 1472 | ExactFleet { |
| 1473 | members: vec![member("a", "scout"), member("b", "a")], |
| 1474 | ..valid.clone() |
| 1475 | }, |
| 1476 | "id/role collision", |
| 1477 | ), |
| 1478 | ] { |
| 1479 | assert!( |
| 1480 | fleet.validate().is_err(), |
| 1481 | "{label} must not survive revalidation" |
| 1482 | ); |
| 1483 | } |
| 1484 | |
| 1485 | // A serde round-trip is exactly how such a value reaches a snapshot. |
| 1486 | let smuggled: ExactFleet = serde_json::from_str( |
| 1487 | &serde_json::to_string(&ExactFleet { |
| 1488 | members: vec![member("a", "scout"), member("b", "scout")], |
| 1489 | ..valid |
| 1490 | }) |
| 1491 | .expect("serialize"), |
| 1492 | ) |
| 1493 | .expect("deserialize"); |
| 1494 | assert!(matches!( |
| 1495 | smuggled |
| 1496 | .validate() |
| 1497 | .expect_err("round-trip must not launder it"), |
| 1498 | ExactFleetError::DuplicateRole { .. } |
| 1499 | )); |
| 1500 | } |
| 1501 | |
| 1502 | #[test] |
| 1503 | fn a_router_only_fleet_has_no_dispatchable_members() { |
| 1504 | let text = r#" |
| 1505 | name = "f" |
| 1506 | schema = "exact" |
| 1507 | |
| 1508 | [[members]] |
| 1509 | id = "router" |
| 1510 | kind = "router" |
| 1511 | provider = "zai" |
| 1512 | model = "glm-5-turbo" |
| 1513 | "#; |
| 1514 | assert!(matches!( |
| 1515 | ExactFleet::parse(text).expect_err("router alone is not a fleet"), |
| 1516 | ExactFleetError::NoMembers { .. } |
| 1517 | )); |
| 1518 | } |
| 1519 | |
| 1520 | #[test] |
| 1521 | fn permission_ceiling_can_only_narrow_the_session_posture() { |
| 1522 | let session = PermissionCeiling { |
| 1523 | write: false, |
| 1524 | network_tool: false, |
| 1525 | shell: ShellCeiling::ReadOnly, |
| 1526 | delegation_depth: 0, |
| 1527 | tools: true, |
| 1528 | }; |
| 1529 | let member = PermissionCeiling::preset("full").expect("preset"); |
| 1530 | |
| 1531 | let clamped = member.clamp_to(session); |
| 1532 | |
| 1533 | assert!( |
| 1534 | !clamped.write, |
| 1535 | "member must not gain write over a read-only session" |
| 1536 | ); |
| 1537 | assert!(!clamped.network_tool); |
| 1538 | assert_eq!(clamped.shell, ShellCeiling::ReadOnly); |
| 1539 | assert_eq!(clamped.delegation_depth, 0); |
| 1540 | } |
| 1541 | |
| 1542 | #[test] |
| 1543 | fn declared_schema_kind_distinguishes_the_two_forms() { |
| 1544 | assert_eq!(declared_schema_kind(GLM_FLEET).as_deref(), Some("exact")); |
| 1545 | assert_eq!( |
| 1546 | declared_schema_kind("name = \"stopship\"\n\n[roles]\nscout = \"scout\"\n"), |
| 1547 | None |
| 1548 | ); |
| 1549 | } |
| 1550 | |
| 1551 | #[test] |
| 1552 | fn unsupported_revision_fails_closed() { |
| 1553 | let text = r#" |
| 1554 | name = "f" |
| 1555 | schema = "exact" |
| 1556 | schema_revision = 99 |
| 1557 | |
| 1558 | [[members]] |
| 1559 | id = "w" |
| 1560 | provider = "zai" |
| 1561 | model = "glm-5" |
| 1562 | "#; |
| 1563 | assert!(matches!( |
| 1564 | ExactFleet::parse(text).expect_err("future revision"), |
| 1565 | ExactFleetError::UnsupportedRevision { revision: 99, .. } |
| 1566 | )); |
| 1567 | } |
| 1568 | } |
| 1569 |