Files
clawmates/crates/cm-api/src/agent_names.rs
T
Omar SobhandClaude Opus 5 d3a398716b fix(workforce): a crew should not read as an alphabetical run
Seeding the name pick with the role index (0..n) started every crew at the top
of the pool and took the next free names, so the first mission after the switch
to per-mission crews hired Aarav, Abebe, Adaora, Adrian, Agnieszka. Unique and
correct, and transparently generated.

Seed from the claw's own uuid instead. UUIDv7 puts its random bytes LAST — the
leading bytes are a timestamp, which would cluster the same way — so the tail
is what spreads five picks across the whole pool.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-10 15:57:54 -07:00

232 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Human given names for minted agents.
//!
//! A team used to come back as `planner`, `coder`, `tester`, `reviewer`,
//! `committer` — the roster read as a list of job tickets, and the UI showed
//! the same word twice (name on top, role underneath). A crew you keep should
//! read like people: Meredith, Vijay, Tomasz, Amara.
//!
//! The role is not lost — it stays in `job_title`, which is what the mission
//! machinery binds on. Only the display identity changes.
//!
//! Names are drawn from many naming traditions on purpose: this workforce is
//! not from one place. They are given names only — no surnames — so nobody
//! reads a claw as a specific real person.
/// Given names, deliberately wide. Kept as one flat list rather than grouped by
/// origin: grouping invites picking "one from each", which is a worse kind of
/// tokenism than simply having a broad pool and drawing from it evenly.
///
/// Size is a product decision, not an aesthetic one. Every mission now mints
/// its own crew and nothing retires them, so the roster grows by the team size
/// per mission — at ~5 a mission a 70-name pool starts emitting "Amara 2"
/// inside twenty missions. This pool carries a few hundred so a workspace runs
/// for a long time before any name repeats at all.
pub const NAMES: &[&str] = &[
// A
"Aarav", "Abebe", "Adaora", "Adrian", "Agnieszka", "Ahmad", "Aiko", "Ainhoa", "Alejandro",
"Alina", "Amara", "Amina", "Anders", "Andrea", "Anjali", "Annika", "Antoine", "Arjun", "Astrid",
"Ayo", "Ayesha", "Aziz",
// BC
"Beatriz", "Bilal", "Bjorn", "Blessing", "Bogdan", "Camila", "Carlos", "Catalina", "Chidi",
"Chiara", "Chioma", "Cyrus",
// DE
"Dagny", "Damir", "Daniela", "Dilnoza", "Dmitri", "Ebele", "Eduardo", "Eero", "Ekaterina",
"Elena", "Elias", "Emeka", "Enrique", "Esi", "Esther", "Eun-ji", "Ewa",
// FG
"Fabio", "Farida", "Fatou", "Felipe", "Fernanda", "Freya", "Gabriel", "Georgi", "Giulia",
"Grace", "Gunnar", "Gulnara",
// HI
"Hana", "Hasan", "Heidi", "Hina", "Hiroshi", "Ibrahim", "Idris", "Ilya", "Imani", "Ingrid",
"Iris", "Isabela", "Ivan", "Iwona",
// JK
"Jaromir", "Javier", "Jing", "Joana", "Johan", "Josefina", "Junko", "Kaito", "Kalinda", "Karim",
"Katarzyna", "Kenji", "Khalid", "Kiran", "Klara", "Kwame", "Kyoko",
// LM
"Lakshmi", "Lars", "Laila", "Leilani", "Lena", "Liam", "Linnea", "Lucia", "Lukas", "Madhavi",
"Maja", "Malik", "Marisol", "Mateo", "Matteo", "Mei", "Meredith", "Milena", "Mira", "Mohan",
"Mira-Lynn", "Mateusz",
// NO
"Nadia", "Nasrin", "Neelam", "Niamh", "Nikolai", "Nilufar", "Nkechi", "Noor", "Nuria", "Oksana",
"Oleksii", "Olamide", "Omar", "Oskar", "Osei",
// PR
"Paloma", "Panagiotis", "Pedro", "Petra", "Priya", "Rafael", "Rania", "Ravi", "Reza", "Renata",
"Rin", "Robert", "Rosalind", "Rustam",
// S
"Sadia", "Salome", "Samir", "Sanjay", "Sara", "Seong-min", "Sipho", "Sofia", "Solveig", "Soren",
"Suvi", "Svetlana",
// TU
"Tadeusz", "Takeshi", "Tamar", "Tariq", "Thandiwe", "Thi", "Tim", "Tomasz", "Tove", "Tuva",
"Ulrika", "Uma", "Usman",
// VZ
"Valentina", "Vera", "Vijay", "Vikram", "Wanjiru", "Wei", "Wiktor", "Yara", "Yasmin", "Yohannes",
"Yuki", "Yusuf", "Zainab", "Zara", "Zoltan", "Zuzanna",
];
/// Pick a name not already in `taken`.
///
/// `seed` spreads the starting point so a workspace does not always begin at
/// "Amara" — it is an offset into the list, not randomness, so the choice is
/// reproducible for a given (seed, taken) pair and therefore testable.
///
/// When every name is taken it appends a numeric suffix — `Amara 2` — rather
/// than returning `None` and forcing the caller to invent something. Running
/// out is a nice problem (70+ concurrent agents in one workspace) and a
/// duplicate display name is far less harmful than a failed mission launch.
pub fn pick(taken: &[String], seed: u64) -> String {
let start = (seed % NAMES.len() as u64) as usize;
for i in 0..NAMES.len() {
let candidate = NAMES[(start + i) % NAMES.len()];
if !taken.iter().any(|t| t.eq_ignore_ascii_case(candidate)) {
return candidate.to_string();
}
}
// Second pass with a suffix. `round` starts at 2 so the first repeat reads
// "Amara 2", which is how a person would disambiguate two colleagues.
for round in 2..1000 {
for i in 0..NAMES.len() {
let candidate = format!("{} {}", NAMES[(start + i) % NAMES.len()], round);
if !taken.iter().any(|t| t.eq_ignore_ascii_case(&candidate)) {
return candidate;
}
}
}
// Unreachable in practice; still not a panic.
format!("Agent {seed}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_unique_and_non_empty() {
let mut seen = std::collections::HashSet::new();
for n in NAMES {
assert!(!n.trim().is_empty(), "empty name in the pool");
assert!(seen.insert(n.to_ascii_lowercase()), "duplicate in pool: {n}");
}
// Every mission mints its own crew and nothing retires them, so the
// pool is consumed for the life of the workspace, not recycled. At ~5
// per mission this is ~35 missions before the first numeric suffix.
assert!(NAMES.len() >= 150, "pool too small for one crew per mission");
}
/// A crew should not read as an alphabetical run.
///
/// With the role index as the seed, every crew started at the top of the
/// pool and took the next free names — the first real mission hired Aarav,
/// Abebe, Adaora, Adrian, Agnieszka. Unique and correct, and obviously
/// generated. Callers now seed from the claw's uuid tail, so this checks
/// that well-spread seeds actually land in different regions of the pool
/// rather than clustering at one end.
#[test]
fn spread_seeds_do_not_produce_an_alphabetical_run() {
let index_of = |n: &str| NAMES.iter().position(|c| *c == n).expect("name in pool");
let seeds = [
0x9e37_79b9_7f4a_7c15u64,
0x1234_5678_9abc_def0,
0xfeed_face_dead_beef,
0x0f0f_0f0f_f0f0_f0f0,
0xa5a5_5a5a_c3c3_3c3c,
];
let mut taken: Vec<String> = Vec::new();
let mut positions = Vec::new();
for s in seeds {
let n = pick(&taken, s);
positions.push(index_of(&n) as i64);
taken.push(n);
}
// Adjacent picks landing within a couple of slots of each other is the
// clustering signature; require the crew to span a real distance.
let (min, max) = (
*positions.iter().min().unwrap(),
*positions.iter().max().unwrap(),
);
assert!(
max - min > (NAMES.len() as i64) / 3,
"crew clustered in one region of the pool: {positions:?}"
);
}
/// The scenario the operator actually asked for: consecutive missions must
/// not hand back the same names. Reuse is off, so mission two staffs from
/// what mission one left.
#[test]
fn consecutive_missions_get_different_crews() {
let mut roster: Vec<String> = Vec::new();
let mut crews: Vec<Vec<String>> = Vec::new();
for mission in 0..6u64 {
let mut crew = Vec::new();
for role in 0..5u64 {
let n = pick(&roster, mission * 5 + role);
roster.push(n.clone());
crew.push(n);
}
crews.push(crew);
}
for (i, a) in crews.iter().enumerate() {
for (j, b) in crews.iter().enumerate().skip(i + 1) {
let shared: Vec<_> = a.iter().filter(|n| b.contains(n)).collect();
assert!(
shared.is_empty(),
"missions {i} and {j} share {shared:?} — crews must be distinct"
);
}
}
// And no duplicates anywhere on the roster.
let uniq: std::collections::HashSet<_> = roster.iter().collect();
assert_eq!(uniq.len(), roster.len(), "a name was issued twice");
}
#[test]
fn pick_avoids_taken_names() {
let taken: Vec<String> = NAMES.iter().take(10).map(|s| s.to_string()).collect();
let got = pick(&taken, 0);
assert!(
!taken.iter().any(|t| t.eq_ignore_ascii_case(&got)),
"picked a name already taken: {got}"
);
}
#[test]
fn pick_is_case_insensitive_about_taken() {
// A name already on the roster in a different case is still taken —
// "meredith" and "Meredith" are the same colleague.
let taken = vec![NAMES[0].to_ascii_lowercase()];
assert_ne!(pick(&taken, 0).to_ascii_lowercase(), taken[0]);
}
#[test]
fn seed_spreads_the_starting_point() {
// Different seeds should not all hand back the same first name, or a
// fresh workspace always opens with the same roster.
let a = pick(&[], 0);
let b = pick(&[], 7);
assert_ne!(a, b, "seed had no effect on the choice");
}
#[test]
fn exhausting_the_pool_suffixes_rather_than_failing() {
let taken: Vec<String> = NAMES.iter().map(|s| s.to_string()).collect();
let got = pick(&taken, 0);
assert!(
!taken.iter().any(|t| t.eq_ignore_ascii_case(&got)),
"must not reuse a taken name"
);
assert!(got.ends_with(" 2"), "expected a suffixed name, got {got}");
}
#[test]
fn a_full_team_gets_distinct_names() {
// The actual scenario: mint five roles into an empty workspace and get
// five different people, not five "planner"s.
let mut taken: Vec<String> = Vec::new();
for i in 0..5 {
let n = pick(&taken, i);
assert!(!taken.contains(&n), "repeated {n} within one team");
taken.push(n);
}
assert_eq!(taken.len(), 5);
}
}