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(crate) const SANITATION_ROLE: &str = "sanitation_admin";
pub struct RoleInfo {
pub has_discovery: bool,
pub requires_multimodal: 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,
requires_multimodal: false,
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),
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",
..BASE_ROLE_INFO
},
Role::Artist => &RoleInfo {
has_discovery: false,
requires_multimodal: true,
badge_fg: (0.808, 0.365, 0.592),
default_reasoning_effort: "high",
display_label: "Artist",
},
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",
..BASE_ROLE_INFO
},
}
}
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 requires_multimodal(&self) -> bool {
role_info(self).requires_multimodal
}
#[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 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;
use crate::tools::{
AddCommentTool, AnalyzeTool, BrowserTool, CreateTicketTool, DispatchMode, EditTool,
GetTicketTool, ImageGenTool, ImplementTool, ListTicketsTool, ReadTool, ResearchTool,
SearchArchivedTicketsTool, SearchTool, ShellMode, ShellTool, UpdateTicketTool, VideoEditTool,
VideoGenTool, WebSearchBackend, WebSearchTool,
};
impl Role {
fn readonly_core_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(ReadTool),
Box::new(SearchTool),
Box::new(ShellTool::new(ShellMode::ReadOnly)),
]
}
fn full_core_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(ShellTool::new(ShellMode::Full)),
Box::new(ReadTool),
Box::new(EditTool),
Box::new(SearchTool),
]
}
#[must_use]
pub(crate) fn tools(self, ws: &Workspace) -> 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));
t
}
Role::Manager => {
vec![
Box::new(CreateTicketTool::new("manager", ws)),
Box::new(UpdateTicketTool::new(ws)),
Box::new(ListTicketsTool::new(ws)),
Box::new(GetTicketTool::new(ws)),
Box::new(AddCommentTool::new(ws)),
Box::new(SearchArchivedTicketsTool::new(ws)),
Box::new(AnalyzeTool::new(DispatchMode::Async, Role::Manager)),
Box::new(ResearchTool::new(Role::Manager)),
]
}
Role::Analyst => {
let mut t = Self::readonly_core_tools();
t.push(Box::new(BrowserTool::default()));
t
}
Role::Coder => Self::full_core_tools(),
Role::Qa | Role::Reviewer | Role::Discovery => Self::readonly_core_tools(),
Role::Sanitation => {
vec![
Box::new(ReadTool),
Box::new(ShellTool::new(ShellMode::ReadOnly)),
]
}
Role::Artist => {
vec![
Box::new(SearchTool),
Box::new(ImageGenTool),
Box::new(VideoGenTool),
Box::new(VideoEditTool),
]
}
Role::Maintainer => {
let mut t = Self::readonly_core_tools();
t.push(Box::new(AnalyzeTool::new(
DispatchMode::Sync,
Role::Maintainer,
)));
t.push(Box::new(CreateTicketTool::new("maintainer", ws)));
t
}
Role::Assistant => {
vec![Box::new(AnalyzeTool::new(
DispatchMode::Async,
Role::Assistant,
))]
}
};
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::*;
#[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 requires_multimodal_only_artist() {
for role in Role::iter() {
let info = super::role_info(&role);
let expected = matches!(role, crate::Role::Artist);
assert_eq!(
info.requires_multimodal,
expected,
"{}: expected requires_multimodal={expected}, got {}",
role.as_str(),
info.requires_multimodal
);
}
}
#[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() {
let tools = role.tools(&crate::workspace::test_ws("test"));
assert!(
!tools.is_empty(),
"{}: Role::tools() must not be empty — every role needs at least one tool",
role.as_str()
);
}
}
#[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"))
.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]
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()
);
}
}
#[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()
);
}
}
}