use crate::Role;
#[allow(clippy::struct_excessive_bools)]
pub struct RoleInfo {
pub has_discovery: bool,
pub selectable: bool,
pub temperature: f32,
pub requires_multimodal: bool,
pub badge_fg: (f32, f32, f32),
pub default_model: &'static str,
pub default_reasoning_effort: &'static str,
pub display_label: &'static str,
}
const BASE_ROLE_INFO: RoleInfo = RoleInfo {
has_discovery: true,
selectable: false,
temperature: 0.1,
requires_multimodal: false,
badge_fg: (0.0, 0.0, 0.0),
default_model: "deepseek/deepseek-v4-flash",
default_reasoning_effort: "xhigh",
display_label: "",
};
#[must_use]
#[allow(clippy::too_many_lines)]
pub const fn role_info(role: &Role) -> &'static RoleInfo {
match role {
Role::Manager => &RoleInfo {
selectable: true,
temperature: 0.01,
badge_fg: (0.816, 0.635, 0.082),
default_model: "deepseek/deepseek-v4-pro",
display_label: "Manager",
..BASE_ROLE_INFO
},
Role::Engineer => &RoleInfo {
selectable: true,
badge_fg: (0.855, 0.439, 0.173),
default_model: "deepseek/deepseek-v4-pro",
display_label: "Engineer",
..BASE_ROLE_INFO
},
Role::Analyst => &RoleInfo {
selectable: true,
temperature: 0.3,
badge_fg: (0.263, 0.522, 0.745),
display_label: "Analyst",
..BASE_ROLE_INFO
},
Role::Coder => &RoleInfo {
temperature: 0.01,
badge_fg: (0.353, 0.604, 0.416),
display_label: "Coder",
..BASE_ROLE_INFO
},
Role::Qa => &RoleInfo {
temperature: 0.4,
badge_fg: (0.545, 0.494, 0.784),
display_label: "QA",
..BASE_ROLE_INFO
},
Role::Reviewer => &RoleInfo {
temperature: 0.2,
badge_fg: (0.431, 0.494, 0.784),
display_label: "Reviewer",
..BASE_ROLE_INFO
},
Role::Discovery => &RoleInfo {
has_discovery: false,
badge_fg: (0.227, 0.663, 0.624),
display_label: "Discovery",
..BASE_ROLE_INFO
},
Role::Artist => &RoleInfo {
has_discovery: false,
selectable: true,
requires_multimodal: true,
badge_fg: (0.808, 0.365, 0.592),
default_model: "qwen/qwen3.6-plus",
default_reasoning_effort: "medium",
display_label: "Artist",
..BASE_ROLE_INFO
},
Role::Maintainer => &RoleInfo {
temperature: 0.5,
badge_fg: (0.753, 0.376, 0.502),
default_model: "deepseek/deepseek-v4-pro",
display_label: "Maintainer",
..BASE_ROLE_INFO
},
}
}
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 as strum::IntoEnumIterator>::iter()
.find(|r| r.as_str() == lower)
.ok_or_else(|| {
let names: Vec<&str> = <Role as strum::IntoEnumIterator>::iter()
.map(|r| r.as_str())
.collect();
anyhow::anyhow!("Unknown role '{s}', expected one of: {}", names.join(", "))
})
}
}
impl Role {
#[must_use]
pub fn as_str(&self) -> &'static str {
self.into()
}
#[must_use]
pub const fn is_selectable(&self) -> bool {
role_info(self).selectable
}
#[must_use]
pub const fn requires_multimodal(&self) -> bool {
role_info(self).requires_multimodal
}
#[must_use]
pub fn selectable_roles() -> Vec<&'static str> {
<Role as strum::IntoEnumIterator>::iter()
.filter(Role::is_selectable)
.map(|r| r.as_str())
.collect()
}
#[must_use]
pub fn all_roles() -> Vec<Role> {
<Role as strum::IntoEnumIterator>::iter().collect()
}
#[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::tools::{
AddCommentTool, AskTool, BrowserTool, CreateTicketTool, EditTool, GetTicketTool, ImageGenTool,
ListTicketsTool, ReadTool, SearchArchivedTicketsTool, SearchTool, ShellMode, ShellTool,
UpdateTicketTool, VideoGenTool, WebSearchTool,
};
impl Role {
#[must_use]
pub fn tools(&self) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = match self {
Role::Engineer => {
vec![
Box::new(ShellTool::new(ShellMode::Full)),
Box::new(ReadTool),
Box::new(EditTool),
Box::new(SearchTool),
Box::new(AskTool::new(vec![Role::Analyst, Role::Coder], None)),
]
}
Role::Manager => {
vec![
Box::new(CreateTicketTool::new("manager")),
Box::new(UpdateTicketTool),
Box::new(ListTicketsTool),
Box::new(GetTicketTool),
Box::new(AddCommentTool),
Box::new(SearchArchivedTicketsTool),
]
}
Role::Analyst => {
vec![
Box::new(ReadTool),
Box::new(SearchTool),
Box::new(BrowserTool::default()),
Box::new(ShellTool::new(ShellMode::ReadOnly)),
]
}
Role::Coder => {
vec![
Box::new(ShellTool::new(ShellMode::Full)),
Box::new(ReadTool),
Box::new(EditTool),
Box::new(SearchTool),
]
}
Role::Qa | Role::Reviewer | Role::Discovery => {
vec![
Box::new(ReadTool),
Box::new(SearchTool),
Box::new(ShellTool::new(ShellMode::ReadOnly)),
]
}
Role::Artist => {
vec![
Box::new(BrowserTool::default()),
Box::new(SearchTool),
Box::new(ImageGenTool),
Box::new(VideoGenTool),
]
}
Role::Maintainer => {
vec![
Box::new(ReadTool),
Box::new(SearchTool),
Box::new(AskTool::new(vec![Role::Analyst], None)),
Box::new(CreateTicketTool::new("maintainer")),
Box::new(ShellTool::new(ShellMode::ReadOnly)),
]
}
};
if let Some(key) = crate::config::CONFIG.exa_key() {
tools.push(Box::new(WebSearchTool::new(key)));
}
tools
}
}
#[cfg(test)]
mod tests {
#[test]
fn role_roundtrip() {
for role in <crate::Role as strum::IntoEnumIterator>::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 <crate::Role as strum::IntoEnumIterator>::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 <crate::Role as strum::IntoEnumIterator>::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 <crate::Role as strum::IntoEnumIterator>::iter() {
let info = super::role_info(&role);
assert!(
!info.default_model.is_empty(),
"{}: default_model must not be empty",
role.as_str()
);
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 <crate::Role as strum::IntoEnumIterator>::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 <crate::Role as strum::IntoEnumIterator>::iter() {
let tools = role.tools();
assert!(
!tools.is_empty(),
"{}: Role::tools() must not be empty — every role needs at least one tool",
role.as_str()
);
}
}
#[test]
fn qa_display_label() {
let info = super::role_info(&crate::Role::Qa);
assert_eq!(info.display_label, "QA");
}
#[test]
fn all_roles_have_summary_prompt() {
for role in <crate::Role as strum::IntoEnumIterator>::iter() {
let prompt = role.summary_prompt();
assert!(
!prompt.trim().is_empty(),
"{}: summary_prompt() must not be empty",
role.as_str()
);
assert!(
prompt.contains("DO NOT USE ANY TOOLS"),
"{}: summary prompt must instruct no tool use",
role.as_str()
);
}
}
}