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