#![allow(dead_code)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use codewhale_config::{
FleetConfigToml, FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions,
FleetRole, FleetSlot,
};
use super::profile::{
AgentProfile, load_agent_profiles_from_dir_tolerant, load_workspace_agent_profiles_tolerant,
personal_agent_profile_dir,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileOrigin {
BuiltIn,
Config,
Personal,
Workspace,
}
impl std::fmt::Display for ProfileOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::BuiltIn => "built-in",
Self::Config => "config",
Self::Personal => "personal",
Self::Workspace => "project",
})
}
}
#[derive(Debug, Clone)]
pub struct FleetRoster {
members: Vec<AgentProfile>,
shadowed: Vec<ShadowedProfile>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShadowedProfile {
pub id: String,
pub shadowed_origin: ProfileOrigin,
pub shadowed_source: PathBuf,
pub winner_origin: ProfileOrigin,
pub winner_source: PathBuf,
}
static PROJECT_AGENT_PROFILES_ENABLED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
pub fn set_project_agent_profiles_enabled(enabled: bool) {
PROJECT_AGENT_PROFILES_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
#[must_use]
pub fn project_agent_profiles_enabled() -> bool {
PROJECT_AGENT_PROFILES_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
}
impl FleetRoster {
#[must_use]
pub fn built_ins_only() -> Self {
Self {
members: Self::built_in_members(),
shadowed: Vec::new(),
}
}
#[must_use]
pub fn from_members(members: Vec<AgentProfile>) -> Self {
Self {
members,
shadowed: Vec::new(),
}
}
#[must_use]
pub fn load(fleet_config: &FleetConfigToml, workspace: &Path) -> Self {
let personal_dir = personal_agent_profile_dir().ok();
Self::load_with_personal_dir(
fleet_config,
workspace,
personal_dir.as_deref(),
project_agent_profiles_enabled(),
)
}
fn load_with_personal_dir(
fleet_config: &FleetConfigToml,
workspace: &Path,
personal_dir: Option<&Path>,
include_workspace_profiles: bool,
) -> Self {
let mut built_ins = Self::built_in_members();
let mut extras: Vec<AgentProfile> = Vec::new();
let mut shadowed: Vec<ShadowedProfile> = Vec::new();
for (id, profile) in &fleet_config.profiles {
let mut profile = profile.clone();
profile.role.name = super::profile::canonical_public_role_name(&profile.role.name);
profile.slot = FleetSlot::from_name(&profile.role.name);
let member = AgentProfile {
id: id.clone(),
display_name: None,
description: profile.role.description.clone(),
profile,
source: PathBuf::from("config.toml"),
origin: ProfileOrigin::Config,
};
record_shadow(
merge_member(&mut built_ins, &mut extras, member),
&mut shadowed,
);
}
if let Some(personal_dir) = personal_dir {
match load_agent_profiles_from_dir_tolerant(personal_dir, ProfileOrigin::Personal) {
Ok((profiles, issues)) => {
for issue in issues {
tracing::warn!(
"fleet roster: skipping invalid personal agent profile: {issue}"
);
}
for member in profiles {
record_shadow(
merge_member(&mut built_ins, &mut extras, member),
&mut shadowed,
);
}
}
Err(err) => {
tracing::warn!("fleet roster: skipping personal agent profiles: {err:#}");
}
}
}
if include_workspace_profiles {
match load_workspace_agent_profiles_tolerant(workspace) {
Ok((profiles, issues)) => {
for issue in issues {
tracing::warn!(
workspace = %workspace.display(),
"fleet roster: skipping invalid workspace agent profile: {issue}"
);
}
for member in profiles {
record_shadow(
merge_member(&mut built_ins, &mut extras, member),
&mut shadowed,
);
}
}
Err(err) => {
tracing::warn!(
workspace = %workspace.display(),
"fleet roster: skipping workspace agent profiles: {err:#}"
);
}
}
}
for shadow in &shadowed {
if shadow.shadowed_origin == ProfileOrigin::BuiltIn {
tracing::debug!(
"fleet roster: '{}' {} copy at {} overrides the built-in default",
shadow.id,
shadow.winner_origin,
shadow.winner_source.display()
);
} else {
tracing::warn!(
"fleet roster: '{}' {} copy at {} shadows the {} copy at {} (ignored)",
shadow.id,
shadow.winner_origin,
shadow.winner_source.display(),
shadow.shadowed_origin,
shadow.shadowed_source.display()
);
}
}
extras.sort_by_key(|a| a.id.to_lowercase());
let mut members = built_ins;
members.extend(extras);
Self { members, shadowed }
}
#[must_use]
pub fn built_in_members() -> Vec<AgentProfile> {
[
(
"manager",
FleetSlot::Manager,
FleetLoadout::Inherit,
"Middle manager for one workflow: decomposes it into bounded tasks, dispatches workers, integrates results, and reports to the operator.",
Some(
"You lead exactly one workflow. Decompose it into bounded tasks, dispatch them to the right roles, keep work-in-progress small, integrate the results, and report a concise receipt (what was done, evidence, gaps) upward. Do not take on work outside your workflow.",
),
),
(
"operator",
FleetSlot::Operator,
FleetLoadout::Inherit,
"The helm of the session — the session's /model selection. Assigns managers to Workflows, routes work between them, arbitrates conflicts, and reviews what comes back.",
Some(
"You direct the overall work, not individual Workflow steps. Assign a manager per Workflow, route work and context between them, arbitrate conflicts and priorities, review the receipts that come back, and decide what runs next. Delegate execution; keep judgment.",
),
),
(
"scout",
FleetSlot::Scout,
FleetLoadout::Inherit,
"Read-only scouting: find files, map code, gather evidence.",
None,
),
(
"builder",
FleetSlot::Implementer,
FleetLoadout::Inherit,
"Writes code: implements bounded tasks with write and shell access.",
None,
),
(
"reviewer",
FleetSlot::Reviewer,
FleetLoadout::Inherit,
"Adversarial code review: assumes the change is broken and tries to prove it — regressions, missing tests, unhandled cases. Read-only.",
Some(
"Be adversarial: assume the change is wrong until the evidence proves otherwise. Actively try to refute the claims made about the work — hunt regressions, missing tests, unhandled edge cases, and quiet behavior changes. Report severity-scored findings with file:line evidence; if nothing survives your attack, say so plainly. Never patch.",
),
),
(
"verifier",
FleetSlot::Verifier,
FleetLoadout::Inherit,
"Runs builds and tests to verify claims; reports evidence, does not patch.",
None,
),
(
"consultant",
FleetSlot::Custom("consultant".to_string()),
FleetLoadout::Inherit,
"Short-lived, high-reasoning, read-only counsel for difficult decisions and overlooked risks.",
Some(
"Give the operator a direct second opinion grounded in what you can read. Surface the decisive tradeoff, overlooked failure mode, and your recommendation. Advise only: do not edit files or run commands.",
),
),
(
"synthesizer",
FleetSlot::Summarizer,
FleetLoadout::Inherit,
"Read-only synthesis: merge findings into one coherent report.",
None,
),
(
"general",
FleetSlot::General,
FleetLoadout::Inherit,
"Legacy alias of the 'worker' posture: general-purpose worker with full capabilities.",
None,
),
(
"worker",
FleetSlot::General,
FleetLoadout::Inherit,
"General-purpose worker: full tool access for multi-step tasks. The unnamed dispatch default.",
None,
),
(
"planner",
FleetSlot::Planner,
FleetLoadout::Inherit,
"Planning: analysis-only for architectural planning; read-only, no shell.",
None,
),
(
"custom",
FleetSlot::Custom("custom".to_string()),
FleetLoadout::Inherit,
"Custom tool access defined at spawn time via allowed_tools; locked down until then.",
None,
),
]
.into_iter()
.map(|(id, slot, loadout, description, instructions)| AgentProfile {
id: id.to_string(),
display_name: None,
description: Some(description.to_string()),
profile: FleetProfile {
slot,
role: FleetRole {
name: id.to_string(),
description: Some(description.to_string()),
instructions: instructions.map(str::to_string),
},
loadout,
model: None,
provider: None,
reasoning_effort: (id == "consultant").then(|| "high".to_string()),
permissions: FleetProfilePermissions::default(),
delegation: FleetDelegationHints::default(),
},
source: PathBuf::from("built-in"),
origin: ProfileOrigin::BuiltIn,
})
.collect()
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&AgentProfile> {
let id = id.trim();
self.members
.iter()
.find(|member| member.id.trim().eq_ignore_ascii_case(id))
}
#[must_use]
pub fn members(&self) -> &[AgentProfile] {
&self.members
}
#[must_use]
pub fn model_overrides(&self) -> HashMap<String, String> {
self.members
.iter()
.filter_map(|member| {
if member
.profile
.provider
.as_deref()
.is_some_and(|provider| !provider.trim().is_empty())
{
return None;
}
let model = member.profile.model.as_deref()?.trim();
(!model.is_empty()).then(|| (member.id.to_lowercase(), model.to_string()))
})
.collect()
}
#[must_use]
pub fn shadowed(&self) -> &[ShadowedProfile] {
&self.shadowed
}
pub fn shadowed_for<'a>(&'a self, id: &'a str) -> impl Iterator<Item = &'a ShadowedProfile> {
let id = id.trim().to_lowercase();
self.shadowed
.iter()
.filter(move |shadow| shadow.id.trim().eq_ignore_ascii_case(&id))
}
}
fn record_shadow(displaced: Option<ShadowedProfile>, shadowed: &mut Vec<ShadowedProfile>) {
if let Some(shadow) = displaced {
shadowed.push(shadow);
}
}
fn merge_member(
built_ins: &mut [AgentProfile],
extras: &mut Vec<AgentProfile>,
member: AgentProfile,
) -> Option<ShadowedProfile> {
let matches =
|existing: &AgentProfile| existing.id.trim().eq_ignore_ascii_case(member.id.trim());
let slot = built_ins
.iter_mut()
.find(|existing| matches(existing))
.or_else(|| extras.iter_mut().find(|existing| matches(existing)));
match slot {
Some(existing) => {
let shadow = ShadowedProfile {
id: existing.id.clone(),
shadowed_origin: existing.origin,
shadowed_source: existing.source.clone(),
winner_origin: member.origin,
winner_source: member.source.clone(),
};
*existing = member;
Some(shadow)
}
None => {
extras.push(member);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use tempfile::TempDir;
fn config_with_profiles(profiles: BTreeMap<String, FleetProfile>) -> FleetConfigToml {
FleetConfigToml {
profiles,
..FleetConfigToml::default()
}
}
fn config_profile(role: &str, model: Option<&str>) -> FleetProfile {
FleetProfile {
slot: FleetSlot::from_name(role),
role: FleetRole {
name: role.to_string(),
description: Some(format!("{role} from config")),
instructions: None,
},
loadout: FleetLoadout::Inherit,
model: model.map(str::to_string),
provider: None,
reasoning_effort: None,
permissions: FleetProfilePermissions::default(),
delegation: FleetDelegationHints::default(),
}
}
fn write_workspace_profile(workspace: &Path, filename: &str, contents: &str) {
let dir = workspace.join(super::super::profile::WORKSPACE_AGENT_PROFILE_DIR);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), contents).unwrap();
}
#[test]
fn built_in_party_is_complete_with_floor_permissions() {
let members = FleetRoster::built_in_members();
let ids: Vec<&str> = members.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
[
"manager",
"operator",
"scout",
"builder",
"reviewer",
"verifier",
"consultant",
"synthesizer",
"general",
"worker",
"planner",
"custom"
]
);
for member in &members {
assert_eq!(member.origin, ProfileOrigin::BuiltIn, "{}", member.id);
assert_eq!(
member.profile.permissions,
FleetProfilePermissions::default(),
"built-in {} must stay at the permission floor",
member.id
);
assert_eq!(
member.profile.delegation,
FleetDelegationHints::default(),
"{}",
member.id
);
assert!(member.profile.model.is_none(), "{}", member.id);
assert_eq!(
member.profile.reasoning_effort.as_deref(),
(member.id == "consultant").then_some("high"),
"built-in {} reasoning",
member.id
);
let carries_doctrine = matches!(
member.id.as_str(),
"manager" | "operator" | "reviewer" | "consultant"
);
assert_eq!(
member.profile.role.instructions.is_some(),
carries_doctrine,
"built-in {} instructions presence",
member.id
);
assert!(member.description.is_some(), "{}", member.id);
}
assert_eq!(members[0].profile.slot, FleetSlot::Manager);
assert_eq!(members[1].profile.slot, FleetSlot::Operator);
assert_eq!(members[2].profile.loadout, FleetLoadout::Inherit);
assert_eq!(members[6].profile.slot.as_str(), "consultant");
assert_eq!(members[7].profile.slot, FleetSlot::Summarizer);
assert_eq!(members[7].profile.loadout, FleetLoadout::Inherit);
}
#[test]
fn every_canonical_dispatch_posture_is_a_seeded_roster_member() {
let roster = FleetRoster::built_ins_only();
for (posture, expected_slot) in [
("worker", FleetSlot::General),
("scout", FleetSlot::Scout),
("planner", FleetSlot::Planner),
("reviewer", FleetSlot::Reviewer),
("builder", FleetSlot::Implementer),
("verifier", FleetSlot::Verifier),
("consultant", FleetSlot::Custom("consultant".to_string())),
("custom", FleetSlot::Custom("custom".to_string())),
] {
let member = roster.get(posture).unwrap_or_else(|| {
panic!("dispatch posture {posture:?} must be a seeded roster member")
});
assert_eq!(
member.profile.slot, expected_slot,
"seeded posture {posture:?} slot"
);
assert_eq!(member.origin, ProfileOrigin::BuiltIn, "{posture}");
assert!(member.profile.model.is_none(), "{posture}");
assert!(member.profile.provider.is_none(), "{posture}");
assert_eq!(member.profile.loadout, FleetLoadout::Inherit, "{posture}");
}
}
#[test]
fn config_member_overrides_built_in_and_extras_sort_alphabetically() {
let _env_lock = crate::test_support::lock_test_env();
let home = TempDir::new().unwrap();
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let tmp = TempDir::new().unwrap();
let config = config_with_profiles(BTreeMap::from([
(
"reviewer".to_string(),
config_profile("reviewer", Some("deepseek-v4-pro")),
),
("zeta".to_string(), config_profile("scout", None)),
("alpha".to_string(), config_profile("builder", None)),
]));
let roster = FleetRoster::load_with_personal_dir(&config, tmp.path(), None, true);
let ids: Vec<&str> = roster.members().iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
[
"manager",
"operator",
"scout",
"builder",
"reviewer",
"verifier",
"consultant",
"synthesizer",
"general",
"worker",
"planner",
"custom",
"alpha",
"zeta"
],
"overridden built-in keeps its slot; extras follow alphabetically"
);
let reviewer = roster.get("reviewer").unwrap();
assert_eq!(reviewer.origin, ProfileOrigin::Config);
assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-pro"));
assert_eq!(reviewer.source, PathBuf::from("config.toml"));
}
#[test]
fn workspace_member_wins_over_config_and_built_in() {
let tmp = TempDir::new().unwrap();
write_workspace_profile(
tmp.path(),
"reviewer.toml",
"id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"glm-5.2\"\n",
);
let config = config_with_profiles(BTreeMap::from([(
"reviewer".to_string(),
config_profile("reviewer", Some("deepseek-v4-pro")),
)]));
let roster = FleetRoster::load(&config, tmp.path());
let reviewer = roster.get("reviewer").unwrap();
assert_eq!(reviewer.origin, ProfileOrigin::Workspace);
assert_eq!(reviewer.profile.model.as_deref(), Some("glm-5.2"));
assert_eq!(
roster
.members()
.iter()
.filter(|m| m.id == "reviewer")
.count(),
1
);
}
#[test]
fn personal_member_applies_across_projects_but_project_still_wins() {
let tmp = TempDir::new().unwrap();
let personal_dir = tmp.path().join("personal-agents");
std::fs::create_dir_all(&personal_dir).unwrap();
std::fs::write(
personal_dir.join("reviewer.toml"),
"id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"deepseek-v4-flash\"\n",
)
.unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let personal = FleetRoster::load_with_personal_dir(
&FleetConfigToml::default(),
&workspace,
Some(&personal_dir),
true,
);
let reviewer = personal.get("reviewer").unwrap();
assert_eq!(reviewer.origin, ProfileOrigin::Personal);
assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-flash"));
write_workspace_profile(
&workspace,
"reviewer.toml",
"id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"glm-5.2\"\n",
);
let project = FleetRoster::load_with_personal_dir(
&FleetConfigToml::default(),
&workspace,
Some(&personal_dir),
true,
);
let reviewer = project.get("reviewer").unwrap();
assert_eq!(reviewer.origin, ProfileOrigin::Workspace);
assert_eq!(reviewer.profile.model.as_deref(), Some("glm-5.2"));
}
#[test]
fn personal_setup_target_round_trips_through_the_runtime_roster() {
let _env_lock = crate::test_support::lock_test_env();
let home = TempDir::new().unwrap();
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let workspace = TempDir::new().unwrap();
let personal_dir = super::super::profile::agent_profile_dir_for_scope(
super::super::profile::FleetProfileScope::Personal,
workspace.path(),
)
.expect("personal profile directory");
assert_eq!(personal_dir, home.path().join("agents"));
let target = personal_dir.join("reviewer.toml");
let mut transaction = codewhale_config::persistence::SetupTransaction::new();
transaction.stage(
target.clone(),
b"id = \"reviewer\"\nrole_hint = \"reviewer\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n"
.to_vec(),
);
transaction.commit().expect("atomic personal save");
assert!(target.is_file(), "save must land under CODEWHALE_HOME");
let roster = FleetRoster::load(&FleetConfigToml::default(), workspace.path());
let reviewer = roster
.get("reviewer")
.expect("saved personal profile must be loaded");
assert_eq!(reviewer.origin, ProfileOrigin::Personal);
assert_eq!(reviewer.source, target);
assert_eq!(reviewer.profile.provider.as_deref(), Some("deepseek"));
assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-flash"));
}
#[test]
fn broken_workspace_dir_degrades_to_built_ins_and_config() {
let tmp = TempDir::new().unwrap();
write_workspace_profile(
tmp.path(),
"broken.toml",
"provider = \"not a real provider\"\n",
);
let config = config_with_profiles(BTreeMap::from([(
"extra".to_string(),
config_profile("scout", None),
)]));
let roster = FleetRoster::load(&config, tmp.path());
assert!(roster.get("extra").is_some());
assert_eq!(
roster.members().len(),
FleetRoster::built_in_members().len() + 1
);
}
#[test]
fn invalid_legacy_profile_does_not_hide_valid_scout_neighbor() {
let _env_lock = crate::test_support::lock_test_env();
let home = TempDir::new().unwrap();
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let tmp = TempDir::new().unwrap();
write_workspace_profile(
tmp.path(),
"reviewer.toml",
"id = \"reviewer\"\nmodel_class_hint = \"heavy\"\n",
);
write_workspace_profile(
tmp.path(),
"scout.toml",
"id = \"scout\"\nrole_hint = \"scout\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n",
);
let roster = FleetRoster::load_with_personal_dir(
&FleetConfigToml::default(),
tmp.path(),
None,
true,
);
let scout = roster.get("scout").expect("valid scout remains visible");
assert_eq!(scout.origin, ProfileOrigin::Workspace);
assert_eq!(scout.profile.provider.as_deref(), Some("deepseek"));
assert_eq!(scout.profile.model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(
roster.get("reviewer").unwrap().origin,
ProfileOrigin::BuiltIn,
"invalid legacy override must fall back to the safe built-in"
);
}
#[test]
fn model_overrides_use_lowercased_ids_and_only_explicit_models() {
let _env_lock = crate::test_support::lock_test_env();
let home = TempDir::new().unwrap();
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let tmp = TempDir::new().unwrap();
let config = config_with_profiles(BTreeMap::from([
(
"Reviewer".to_string(),
config_profile("reviewer", Some("deepseek-v4-pro")),
),
("scout".to_string(), config_profile("scout", None)),
]));
let roster = FleetRoster::load(&config, tmp.path());
let overrides = roster.model_overrides();
assert_eq!(
overrides,
HashMap::from([("reviewer".to_string(), "deepseek-v4-pro".to_string())]),
"only members with explicit models are pinned, keyed lowercased"
);
}
#[test]
fn model_overrides_skip_provider_pinned_profiles() {
let _env_lock = crate::test_support::lock_test_env();
let home = TempDir::new().unwrap();
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let tmp = TempDir::new().unwrap();
let mut pinned = config_profile("scout", Some("deepseek-v4-flash"));
pinned.provider = Some("deepseek".to_string());
let mut blank_provider = config_profile("builder", Some("deepseek-v4-pro"));
blank_provider.provider = Some(" ".to_string());
let config = config_with_profiles(BTreeMap::from([
("scout".to_string(), pinned),
("builder".to_string(), blank_provider),
]));
let roster = FleetRoster::load(&config, tmp.path());
let overrides = roster.model_overrides();
assert!(
!overrides.contains_key("scout"),
"a provider-pinned profile must not leak its model into the \
provider-less role_models map: {overrides:?}"
);
assert_eq!(
overrides.get("builder").map(String::as_str),
Some("deepseek-v4-pro"),
"a blank provider pin is still provider-less: {overrides:?}"
);
let scout = roster.get("scout").expect("scout member");
assert_eq!(scout.profile.provider.as_deref(), Some("deepseek"));
assert_eq!(scout.profile.model.as_deref(), Some("deepseek-v4-flash"));
}
#[test]
fn get_is_trimmed_and_case_insensitive() {
let roster = FleetRoster::built_ins_only();
assert!(roster.get(" Reviewer ").is_some());
assert!(roster.get("SYNTHESIZER").is_some());
assert!(roster.get("nonexistent").is_none());
}
#[test]
fn origin_labels_are_stable() {
assert_eq!(ProfileOrigin::BuiltIn.to_string(), "built-in");
assert_eq!(ProfileOrigin::Config.to_string(), "config");
assert_eq!(ProfileOrigin::Personal.to_string(), "personal");
assert_eq!(ProfileOrigin::Workspace.to_string(), "project");
}
}
#[cfg(test)]
#[path = "tests/roster_shadow_and_trust.rs"]
mod shadow_and_trust_tests;