use std::sync::LazyLock;
use strum::IntoEnumIterator;
use crate::Role;
pub(crate) const DIAGNOSTICS_ROLE: &str = "diagnostics";
pub(crate) const SYSTEM_ROLE: &str = "system";
pub struct RoleInfo {
pub has_discovery: bool,
pub badge_fg: (f32, f32, f32),
pub default_reasoning_effort: &'static str,
pub display_label: &'static str,
}
const BASE_ROLE_INFO: RoleInfo = RoleInfo {
has_discovery: true,
badge_fg: (0.0, 0.0, 0.0),
default_reasoning_effort: "high",
display_label: "",
};
#[must_use]
#[allow(clippy::trivially_copy_pass_by_ref)]
pub const fn role_info(role: &Role) -> &'static RoleInfo {
match role {
Role::Manager => &RoleInfo {
badge_fg: (0.816, 0.635, 0.082),
default_reasoning_effort: "xhigh",
display_label: "Manager",
..BASE_ROLE_INFO
},
Role::Engineer => &RoleInfo {
badge_fg: (0.855, 0.439, 0.173),
default_reasoning_effort: "xhigh",
display_label: "Engineer",
..BASE_ROLE_INFO
},
Role::Analyst => &RoleInfo {
badge_fg: (0.263, 0.522, 0.745),
display_label: "Analyst",
..BASE_ROLE_INFO
},
Role::Coder => &RoleInfo {
badge_fg: (0.353, 0.604, 0.416),
display_label: "Coder",
..BASE_ROLE_INFO
},
Role::Qa => &RoleInfo {
badge_fg: (0.545, 0.494, 0.784),
display_label: "QA",
..BASE_ROLE_INFO
},
Role::Reviewer => &RoleInfo {
badge_fg: (0.431, 0.494, 0.784),
display_label: "Reviewer",
..BASE_ROLE_INFO
},
Role::Discovery => &RoleInfo {
has_discovery: false,
default_reasoning_effort: "xhigh",
badge_fg: (0.227, 0.663, 0.624),
display_label: "Discovery",
},
Role::Maintainer => &RoleInfo {
badge_fg: (0.753, 0.376, 0.502),
default_reasoning_effort: "xhigh",
display_label: "Maintainer",
..BASE_ROLE_INFO
},
Role::Sanitation => &RoleInfo {
badge_fg: (0.482, 0.482, 0.482),
display_label: "Sanitation",
..BASE_ROLE_INFO
},
Role::Assistant => &RoleInfo {
has_discovery: false,
badge_fg: (0.153, 0.820, 0.757),
default_reasoning_effort: "xhigh",
display_label: "Assistant",
},
}
}
static ALL_ROLE_NAMES: LazyLock<String> = LazyLock::new(|| {
Role::iter()
.map(|r| r.as_str())
.collect::<Vec<_>>()
.join(", ")
});
impl std::str::FromStr for Role {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let lower = s.to_ascii_lowercase();
Role::iter().find(|r| r.as_str() == lower).ok_or_else(|| {
anyhow::anyhow!("Unknown role '{s}', expected one of: {}", *ALL_ROLE_NAMES)
})
}
}
impl Role {
#[must_use]
pub fn as_str(&self) -> &'static str {
self.into()
}
#[must_use]
pub const fn display_label(&self) -> &'static str {
role_info(self).display_label
}
#[must_use]
pub fn role_description(&self) -> String {
crate::prompt::load_prompt(&format!("role/{}.md", self.as_str()))
}
#[must_use]
pub fn role_description_for(&self, is_admin: bool) -> String {
if *self == Role::Assistant && is_admin {
crate::prompt::load_prompt("role/assistant_admin.md")
} else {
self.role_description()
}
}
#[must_use]
pub fn discovery_prompt(&self) -> String {
let info = role_info(self);
if info.has_discovery {
crate::prompt::load_prompt(&format!("discovery/{}.md", self.as_str()))
} else {
panic!("Discovery prompt for role '{self}' does not exist")
}
}
#[must_use]
pub fn summary_prompt(&self) -> String {
crate::prompt::load_prompt(&format!("summarize/{}.md", self.as_str()))
}
}
use crate::Tool;
use crate::Workspace;
use crate::config::CONFIG;
#[cfg(not(target_os = "macos"))]
use crate::tools::ComputerTool;
use crate::tools::{
AddAlarmTool, AddCommentTool, AnalyzeTool, ChromeTool, CreateTicketTool, CustomTool,
DispatchMode, EditTool, GetTicketTool, ImageGenTool, ImplementTool, ListAlarmsTool,
ListTicketsTool, MahbotConfigTool, MahbotDebugTool, ReadTool, RemoveAlarmTool, ResearchTool,
SearchArchivedTicketsTool, SearchTool, SendMessageToManagerTool, ShellMode, ShellTool,
SleepTool, UpdateTicketTool, VideoEditTool, VideoGenTool, WebSearchBackend, WebSearchTool,
WorkspaceControlTool,
};
impl Role {
fn inspector_core_tools(shell: Option<ShellMode>) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> =
vec![Box::new(ReadTool::general()), Box::new(SearchTool)];
if let Some(mode) = shell {
tools.push(Box::new(ShellTool::new(mode)));
}
tools
}
fn analyst_shell_allowed(ws: &Workspace) -> bool {
match crate::users::personal_user_name(&ws.name) {
Some(user) => crate::users::is_admin_name(user),
None => true,
}
}
fn full_core_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(ShellTool::new(ShellMode::Full)),
Box::new(ReadTool::general()),
Box::new(EditTool),
Box::new(SearchTool),
]
}
#[must_use]
pub(crate) fn tools(
self,
ws: &Workspace,
is_admin: bool,
chrome_sessions: std::sync::Arc<crate::tools::chrome::ChromeRunSessions>,
) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = match self {
Role::Engineer => {
let mut t = Self::full_core_tools();
t.push(Box::new(AnalyzeTool::new(
DispatchMode::Sync,
Role::Engineer,
)));
t.push(Box::new(ImplementTool::new(
DispatchMode::Sync,
Role::Engineer,
)));
t
}
Role::Manager => {
let reporter = self.as_str();
vec![
Box::new(CreateTicketTool::new(reporter, ws)),
Box::new(UpdateTicketTool::new(ws)),
Box::new(ListTicketsTool::new(ws)),
Box::new(GetTicketTool::new(reporter, ws)),
Box::new(AddCommentTool::new(ws)),
Box::new(SearchArchivedTicketsTool::new(ws)),
Box::new(AnalyzeTool::new(DispatchMode::Async, Role::Manager)),
Box::new(SleepTool),
]
}
Role::Analyst => {
let shell = Self::analyst_shell_allowed(ws).then_some(ShellMode::ReadOnly);
let mut t = Self::inspector_core_tools(shell);
t.push(Box::new(ChromeTool::new(chrome_sessions)));
t
}
Role::Coder => Self::full_core_tools(),
Role::Qa => Self::inspector_core_tools(Some(ShellMode::Full)),
Role::Reviewer | Role::Discovery => {
Self::inspector_core_tools(Some(ShellMode::ReadOnly))
}
Role::Sanitation => {
vec![
Box::new(ReadTool::general()),
Box::new(ShellTool::new(ShellMode::ReadOnly)),
]
}
Role::Maintainer => {
let mut t = Self::inspector_core_tools(Some(ShellMode::ReadOnly));
t.push(Box::new(AnalyzeTool::new(
DispatchMode::Sync,
Role::Maintainer,
)));
t.push(Box::new(CreateTicketTool::new("maintainer", ws)));
t
}
Role::Assistant => {
let mut t: Vec<Box<dyn Tool>> = vec![
Box::new(AnalyzeTool::new(DispatchMode::Async, Role::Assistant)),
Box::new(AddAlarmTool),
Box::new(ListAlarmsTool),
Box::new(RemoveAlarmTool),
Box::new(EditTool),
Box::new(SearchTool),
Box::new(SleepTool),
Box::new(ImageGenTool),
Box::new(VideoGenTool),
Box::new(VideoEditTool),
];
t.push(Box::new(CustomTool));
if is_admin {
t.push(Box::new(ReadTool::general()));
} else {
t.push(Box::new(ReadTool::workspace_only()));
}
if is_admin {
t.push(Box::new(ShellTool::new(ShellMode::Full)));
t.push(Box::new(ImplementTool::new(
DispatchMode::Async,
Role::Assistant,
)));
t.push(Box::new(ResearchTool::new(Role::Assistant)));
#[cfg(not(target_os = "macos"))]
t.push(Box::new(ComputerTool));
t.push(Box::new(SendMessageToManagerTool));
t.push(Box::new(MahbotConfigTool));
t.push(Box::new(MahbotDebugTool));
t.push(Box::new(WorkspaceControlTool));
}
t
}
};
if !matches!(self, Role::Manager | Role::Sanitation) {
Self::add_web_search_tool(&mut tools);
}
tools
}
fn add_web_search_tool(tools: &mut Vec<Box<dyn Tool>>) {
let provider = CONFIG.web_search_provider();
let firecrawl_key = CONFIG.firecrawl_key();
let exa_key = CONFIG.exa_key();
let backend: Option<WebSearchBackend> = match provider.as_deref() {
Some(p) if p.eq_ignore_ascii_case("firecrawl") => {
firecrawl_key.map(|key| WebSearchBackend::Firecrawl { key })
}
Some(p) if p.eq_ignore_ascii_case("exa") => {
exa_key.map(|key| WebSearchBackend::Exa { key })
}
Some(other) => {
tracing::warn!("Unknown web_search_provider: {other}");
None
}
None => firecrawl_key
.map(|key| WebSearchBackend::Firecrawl { key })
.or_else(|| exa_key.map(|key| WebSearchBackend::Exa { key })),
};
if let Some(backend) = backend {
tools.push(Box::new(WebSearchTool::new(backend)));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_sessions() -> std::sync::Arc<crate::tools::chrome::ChromeRunSessions> {
std::sync::Arc::new(crate::tools::chrome::ChromeRunSessions::default())
}
#[test]
fn role_roundtrip() {
for role in Role::iter() {
let parsed: crate::Role = role.as_str().parse().unwrap();
assert_eq!(parsed, role, "roundtrip failed for '{}'", role.as_str());
assert_eq!(role.to_string(), role.as_str());
assert!(
!role.as_str().is_empty(),
"as_str() empty for '{}'",
role.as_str()
);
}
assert!("unknown_role".parse::<crate::Role>().is_err());
}
#[test]
fn badge_colors_set() {
for role in Role::iter() {
let info = super::role_info(&role);
let (r, g, b) = info.badge_fg;
let is_black = r == 0.0 && g == 0.0 && b == 0.0;
assert!(
!is_black,
"{}: badge_fg must not be (0,0,0) — set a visible color",
role.as_str()
);
}
}
#[test]
fn defaults_set() {
for role in Role::iter() {
let info = super::role_info(&role);
assert!(
!info.default_reasoning_effort.is_empty(),
"{}: default_reasoning_effort must not be empty",
role.as_str()
);
}
}
#[test]
fn display_labels_set() {
for role in Role::iter() {
let info = super::role_info(&role);
assert!(
!info.display_label.is_empty(),
"{}: display_label must not be empty — set a display_label in role_info()",
role.as_str()
);
}
}
#[test]
fn all_roles_have_tools() {
for role in Role::iter() {
for is_admin in [false, true] {
let tools = role.tools(
&crate::workspace::test_ws("test"),
is_admin,
test_sessions(),
);
assert!(
!tools.is_empty(),
"{}: Role::tools() must not be empty — every role needs at least one tool",
role.as_str()
);
for tool in &tools {
let _ = tool.spec();
}
}
}
}
#[test]
#[serial_test::serial(config_persist)] fn sanitation_toolset_is_read_and_shell_only() {
let snapshot = crate::config::CONFIG.snapshot();
crate::config::CONFIG.swap(crate::config::ConfigData::STRUCT_FIELDS_DEFAULT);
let _ = crate::config::CONFIG.set_string_field("web_search_provider", "exa");
let _ = crate::config::CONFIG.set_string_field("exa_key", "test-key");
let names: Vec<&str> = crate::Role::Sanitation
.tools(&crate::workspace::test_ws("test"), false, test_sessions())
.iter()
.map(|t| t.name())
.collect();
crate::config::CONFIG.swap(snapshot);
assert_eq!(
names,
["read", "shell"],
"Sanitation toolset must be exactly read + read-only shell, got: {names:?}"
);
}
#[test]
#[serial_test::serial(config_persist)] fn qa_toolset_is_the_build_shell_without_edit() {
let snapshot = crate::config::CONFIG.snapshot();
crate::config::CONFIG.swap(crate::config::ConfigData::STRUCT_FIELDS_DEFAULT);
let ws = crate::workspace::test_ws("test");
let qa = crate::Role::Qa.tools(&ws, false, test_sessions());
let engineer = crate::Role::Engineer.tools(&ws, false, test_sessions());
crate::config::CONFIG.swap(snapshot);
let names: Vec<&str> = qa.iter().map(|t| t.name()).collect();
assert_eq!(
names,
["read", "search", "shell"],
"QA toolset must be exactly read + search + the unrestricted shell, got: {names:?}"
);
let qa_shell = qa.iter().find(|t| t.name() == "shell").expect("QA shell");
let engineer_shell = engineer
.iter()
.find(|t| t.name() == "shell")
.expect("engineer shell");
assert_eq!(
qa_shell.description(),
engineer_shell.description(),
"QA must run the same shell the engineer gets"
);
assert!(
qa_shell.parameters_schema()["properties"]
.as_object()
.is_some_and(|props| props.contains_key("background")),
"QA's shell must advertise the background argument"
);
}
#[test]
fn manager_does_not_advertise_research() {
let tools =
crate::Role::Manager.tools(&crate::workspace::test_ws("test"), false, test_sessions());
assert!(
!tools.iter().any(|t| t.name() == "research"),
"Manager toolset must not contain 'research'"
);
}
#[test]
fn assistant_toolset_gates_admin_tools() {
let ws = crate::workspace::test_ws("test");
let guest = crate::Role::Assistant.tools(&ws, false, test_sessions());
let admin = crate::Role::Assistant.tools(&ws, true, test_sessions());
let admin_only: &[&str] = if cfg!(target_os = "macos") {
&["shell", "implement", "research"]
} else {
&["shell", "implement", "research", "computer"]
};
for &name in admin_only {
assert!(
!guest.iter().any(|t| t.name() == name),
"a guest's Assistant toolset must not contain '{name}'"
);
assert!(
admin.iter().any(|t| t.name() == name),
"the admin's Assistant toolset must contain '{name}'"
);
}
if cfg!(target_os = "macos") {
assert!(
!admin.iter().any(|t| t.name() == "computer"),
"the admin's Assistant must not advertise `computer` on macOS"
);
}
let guest_read = guest.iter().find(|t| t.name() == "read");
let guest_path_desc = guest_read
.map(|t| t.parameters_schema()["properties"]["path"]["description"].to_string())
.unwrap_or_default();
assert!(
guest_path_desc.contains("Only the workspace is accessible"),
"a guest's Assistant read must advertise the workspace-only boundary, got: {guest_path_desc}"
);
let admin_read = admin.iter().find(|t| t.name() == "read");
let admin_path_desc = admin_read
.map(|t| t.parameters_schema()["properties"]["path"]["description"].to_string())
.unwrap_or_default();
assert!(
admin_path_desc.contains("policy allowlist"),
"the admin's Assistant read must advertise the general allowlist boundary, got: {admin_path_desc}"
);
}
#[test]
fn admin_only_tools_only_in_admin_assistant() {
let ws = crate::workspace::test_ws("test");
for role in Role::iter() {
for is_admin in [false, true] {
let names: Vec<&str> = role
.tools(&ws, is_admin, test_sessions())
.iter()
.map(|t| t.name())
.collect();
for name in ["mahbot_config", "mahbot_debug", "workspace_control"] {
let has = names.contains(&name);
if role == crate::Role::Assistant && is_admin {
assert!(has, "the admin's Assistant must advertise `{name}`");
} else {
assert!(
!has,
"{}{} must not advertise `{name}`",
role.as_str(),
if is_admin { " (admin)" } else { "" }
);
}
}
}
}
}
#[test]
fn custom_tool_is_advertised_by_every_assistant_only() {
let ws = crate::workspace::test_ws("test");
for role in Role::iter() {
for is_admin in [false, true] {
let has = role
.tools(&ws, is_admin, test_sessions())
.iter()
.any(|t| t.name() == "custom");
assert_eq!(
has,
role == crate::Role::Assistant,
"{}{} custom-tool availability",
role.as_str(),
if is_admin { " (admin)" } else { "" }
);
}
}
}
#[test]
fn analyst_shell_follows_the_summoning_session() {
for name in ["proj", "personal:admin"] {
let ws = crate::workspace::test_ws_named("/tmp/proj", name);
let has = Role::Analyst
.tools(&ws, false, test_sessions())
.iter()
.any(|t| t.name() == "shell");
assert!(has, "analysts in {name} must keep the shell");
}
for name in ["personal:lyuba", "personal:"] {
let ws = crate::workspace::test_ws_named("/tmp/personal", name);
let names: Vec<&str> = Role::Analyst
.tools(&ws, false, test_sessions())
.iter()
.map(|t| t.name())
.collect();
assert!(
!names.contains(&"shell"),
"analysts in {name} must not get a shell, got {names:?}"
);
for expected in ["read", "search", "chrome"] {
assert!(names.contains(&expected), "{name} keeps {expected}");
}
}
}
#[test]
fn computer_tool_only_in_admin_assistant() {
let ws = crate::workspace::test_ws("test");
for role in Role::iter() {
for is_admin in [false, true] {
let has = role
.tools(&ws, is_admin, test_sessions())
.iter()
.any(|t| t.name() == "computer");
if cfg!(target_os = "macos") {
assert!(
!has,
"{}{} must not advertise `computer` on macOS",
role.as_str(),
if is_admin { " (admin)" } else { "" }
);
} else if role == crate::Role::Assistant && is_admin {
assert!(has, "the admin's Assistant must advertise `computer`");
} else {
assert!(
!has,
"{}{} must not advertise `computer`",
role.as_str(),
if is_admin { " (admin)" } else { "" }
);
}
}
}
}
#[test]
fn manager_chat_tools_only_in_admin_assistant() {
let ws = crate::workspace::test_ws("test");
for role in Role::iter() {
for is_admin in [false, true] {
let names: Vec<&str> = role
.tools(&ws, is_admin, test_sessions())
.iter()
.map(|t| t.name())
.collect();
let name = "send_message_to_manager";
let has = names.contains(&name);
if role == crate::Role::Assistant && is_admin {
assert!(has, "the admin's Assistant must advertise `{name}`");
} else {
assert!(
!has,
"{}{} must not advertise `{name}`",
role.as_str(),
if is_admin { " (admin)" } else { "" }
);
}
}
}
}
#[test]
fn sleep_tool_only_in_assistant_and_manager_toolsets() {
let ws = crate::workspace::test_ws("test");
for role in Role::iter() {
for is_admin in [false, true] {
let has = role
.tools(&ws, is_admin, test_sessions())
.iter()
.any(|t| t.name() == "sleep");
if matches!(role, crate::Role::Assistant | crate::Role::Manager) {
assert!(
has,
"{} (is_admin={is_admin}) must advertise `sleep`",
role.as_str()
);
} else {
assert!(
!has,
"{}{} must not advertise `sleep`",
role.as_str(),
if is_admin { " (admin)" } else { "" }
);
}
}
}
}
#[test]
fn assistant_role_descriptions_are_populated() {
for is_admin in [false, true] {
let desc = crate::Role::Assistant.role_description_for(is_admin);
assert!(
!desc.trim().is_empty(),
"Assistant role description must not be empty"
);
assert!(
!crate::prompt::TEMPLATE_RE.is_match(&desc),
"Assistant role description must not contain unsubstituted template keys"
);
assert!(
desc.contains("<alarm-notification>"),
"Assistant role description must explain the <alarm-notification> marker"
);
}
}
#[test]
fn qa_display_label() {
let info = super::role_info(&crate::Role::Qa);
assert_eq!(info.display_label, "QA");
}
#[test]
fn all_roles_have_role_description() {
for role in Role::iter() {
let desc = role.role_description();
assert!(
!desc.trim().is_empty(),
"{}: role_description() must not be empty",
role.as_str()
);
assert!(
!crate::prompt::TEMPLATE_RE.is_match(&desc),
"{}: role description must not contain unsubstituted template keys",
role.as_str()
);
}
}
#[test]
fn all_roles_have_summary_prompt() {
for role in Role::iter() {
let prompt = role.summary_prompt();
assert!(
!prompt.trim().is_empty(),
"{}: summary_prompt() must not be empty",
role.as_str()
);
assert!(
!crate::prompt::TEMPLATE_RE.is_match(&prompt),
"{}: summary prompt must not contain unsubstituted template keys",
role.as_str()
);
assert!(
prompt.contains("DO NOT USE ANY TOOLS. ONLY RESPOND WITH THE SUMMARY."),
"{}: summary prompt must forbid tools and allow only the summary",
role.as_str()
);
}
}
#[test]
fn all_roles_have_discovery_prompt() {
for role in Role::iter() {
if !super::role_info(&role).has_discovery {
continue;
}
let prompt = role.discovery_prompt();
assert!(
!prompt.trim().is_empty(),
"{}: discovery_prompt() must not be empty",
role.as_str()
);
assert!(
!crate::prompt::TEMPLATE_RE.is_match(&prompt),
"{}: discovery prompt must not contain unsubstituted template keys",
role.as_str()
);
}
}
}