use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::warn;
use crate::cm_internal::tool_stats::ToolOutcomeRecorder;
use crate::cm_config::AgentConfig;
use crate::cm_types::SessionMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoleSystemResolution {
Strict,
}
fn append_session_mode_appendix(system: String, mode: SessionMode) -> String {
let body = crate::cm_config::embedded_session_mode_appendix(mode);
if body.is_empty() {
return system;
}
format!("{}\n\n{}", system.trim_end(), body)
}
fn resolve_role_system_base(cfg: &AgentConfig, agent_role: Option<&str>) -> Result<String, String> {
cfg.system_prompt_for_new_conversation(agent_role)
.map(|s| s.to_string())
.map_err(|e| e.to_string())
}
pub fn resolve_agent_role_for_prompt_compose(
cfg: &AgentConfig,
request_role: Option<&str>,
persisted_role: Option<&str>,
) -> Result<Option<String>, String> {
if let Some(id) = request_role.map(str::trim).filter(|s| !s.is_empty()) {
cfg.system_prompt_for_new_conversation(Some(id))
.map_err(|e| e.to_string())?;
return Ok(Some(id.to_string()));
}
if let Some(id) = persisted_role.map(str::trim).filter(|s| !s.is_empty()) {
if cfg.system_prompt_for_new_conversation(Some(id)).is_ok() {
return Ok(Some(id.to_string()));
}
warn!(
target: "crabmate",
agent_role = %id,
"persisted agent_role no longer in config; using default persona for prompt compose"
);
return Ok(None);
}
Ok(None)
}
pub struct SkillsComposeContext<'a> {
pub base_dir: &'a Path,
pub user_text: &'a str,
pub forced_skill: Option<crate::cm_config::skills::SkillDoc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FirstSystemComposeDiagnostics {
pub layers_applied: Vec<String>,
pub chars_l3_base: usize,
pub chars_l4_augmented: usize,
pub chars_final: usize,
pub skills_total_docs: usize,
pub skills_selected_labels: Vec<String>,
}
#[must_use]
pub fn resolve_skills_base_dir(workspace_root: &Path) -> PathBuf {
if workspace_root.as_os_str().is_empty() {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
} else {
workspace_root.to_path_buf()
}
}
pub fn compose_system_from_base(
base_system: &str,
cfg: &AgentConfig,
tool_recorder: &ToolOutcomeRecorder,
skills: Option<SkillsComposeContext<'_>>,
) -> String {
let augmented = tool_recorder.augment_system_prompt(base_system, cfg);
merge_skills_into_system(augmented, cfg, skills)
}
pub fn compose_system_for_turn(
cfg: &AgentConfig,
agent_role: Option<&str>,
tool_recorder: &ToolOutcomeRecorder,
skills: Option<SkillsComposeContext<'_>>,
_role_resolution: RoleSystemResolution,
) -> Result<String, String> {
let base = resolve_role_system_base(cfg, agent_role)?;
Ok(compose_system_from_base(&base, cfg, tool_recorder, skills))
}
pub struct FirstSystemComposeOpts<'a> {
pub agent_role: Option<&'a str>,
pub user_msg_for_skills: Option<&'a str>,
pub skills_base_dir: Option<PathBuf>,
pub forced_skill: Option<crate::cm_config::skills::SkillDoc>,
pub role_resolution: RoleSystemResolution,
pub session_mode: Option<SessionMode>,
}
pub fn compose_first_system_for_turn(
cfg: &AgentConfig,
tool_recorder: &Arc<ToolOutcomeRecorder>,
opts: FirstSystemComposeOpts<'_>,
) -> Result<String, String> {
let (merged, _) = compose_first_system_for_turn_with_diagnostics(cfg, tool_recorder, opts)?;
Ok(merged)
}
pub fn compose_first_system_for_turn_with_diagnostics(
cfg: &AgentConfig,
tool_recorder: &Arc<ToolOutcomeRecorder>,
opts: FirstSystemComposeOpts<'_>,
) -> Result<(String, FirstSystemComposeDiagnostics), String> {
debug_assert_eq!(
opts.role_resolution,
RoleSystemResolution::Strict,
"pass pre-resolved agent_role via resolve_agent_role_for_prompt_compose"
);
let FirstSystemComposeOpts {
agent_role,
user_msg_for_skills,
skills_base_dir,
forced_skill,
role_resolution: _,
session_mode,
} = opts;
let skills_ctx = skills_base_dir
.as_ref()
.zip(user_msg_for_skills)
.map(|(base, user)| SkillsComposeContext {
base_dir: base.as_path(),
user_text: user,
forced_skill,
});
let base = resolve_role_system_base(cfg, agent_role)?;
let augmented = tool_recorder.augment_system_prompt(&base, cfg);
let chars_l4_augmented = augmented.chars().count();
let (merged, skills_meta) = merge_skills_into_system_with_meta(augmented, cfg, skills_ctx);
let mode = session_mode.unwrap_or(cfg.roles_prompts.default_session_mode);
let merged = append_session_mode_appendix(merged, mode);
let chars_final = merged.chars().count();
let mut layers = vec!["L3".to_string(), "L4".to_string()];
if !skills_meta.selected_labels.is_empty() {
layers.push("L5".to_string());
}
layers.push("mode".to_string());
Ok((
merged,
FirstSystemComposeDiagnostics {
layers_applied: layers,
chars_l3_base: base.chars().count(),
chars_l4_augmented,
chars_final,
skills_total_docs: skills_meta.total_docs,
skills_selected_labels: skills_meta.selected_labels,
},
))
}
pub fn compose_system_for_turn_arc(
cfg: &AgentConfig,
agent_role: Option<&str>,
tool_recorder: &Arc<ToolOutcomeRecorder>,
skills: Option<SkillsComposeContext<'_>>,
role_resolution: RoleSystemResolution,
) -> Result<String, String> {
compose_system_for_turn(
cfg,
agent_role,
tool_recorder.as_ref(),
skills,
role_resolution,
)
}
fn merge_skills_into_system(
system_prompt: String,
cfg: &AgentConfig,
skills: Option<SkillsComposeContext<'_>>,
) -> String {
merge_skills_into_system_with_meta(system_prompt, cfg, skills).0
}
fn merge_skills_into_system_with_meta(
system_prompt: String,
cfg: &AgentConfig,
skills: Option<SkillsComposeContext<'_>>,
) -> (String, crate::cm_config::skills::SkillsSelectionMeta) {
let Some(sk) = skills else {
return (
system_prompt,
crate::cm_config::skills::SkillsSelectionMeta::default(),
);
};
crate::cm_config::skills::merge_system_prompt_with_skills_selected_with_meta(
system_prompt.clone(),
crate::cm_config::skills::SkillsSelectedMergeOpts {
skills_enabled: cfg.skills.skills_enabled,
skills_dir: cfg.skills.skills_dir.as_str(),
skills_user_dir: cfg.skills.skills_user_dir.as_str(),
skills_system_dir: cfg.skills.skills_system_dir.as_str(),
skills_max_chars: cfg.skills.skills_max_chars,
base_dir: sk.base_dir,
user_text: sk.user_text,
top_k: cfg.skills.skills_top_k,
forced_skill: sk.forced_skill.as_ref(),
},
)
.unwrap_or((
system_prompt,
crate::cm_config::skills::SkillsSelectionMeta::default(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn embed_default_config() -> crate::cm_config::AgentConfig {
crate::cm_config::load_config_test_env::without_cm_planner_executor_mode_env(|| {
crate::cm_config::load_config(None).expect("embed default")
})
}
#[test]
fn resolve_persisted_unknown_role_falls_back_to_default() {
let cfg = embed_default_config();
let role =
resolve_agent_role_for_prompt_compose(&cfg, None, Some("nonexistent_role_id_xyz"))
.expect("persisted unknown should not error");
assert_eq!(role, None);
}
#[test]
fn resolve_explicit_unknown_role_errors() {
let cfg = embed_default_config();
let err =
resolve_agent_role_for_prompt_compose(&cfg, Some("nonexistent_role_id_xyz"), None)
.expect_err("explicit unknown");
assert!(err.contains("未知的 agent_role"));
}
#[test]
fn compose_lenient_unknown_role_falls_back_to_global_system() {
let cfg = embed_default_config();
let rec = ToolOutcomeRecorder::new();
let role =
resolve_agent_role_for_prompt_compose(&cfg, None, Some("nonexistent_role_id_xyz"))
.expect("persisted");
let out = compose_system_for_turn(
&cfg,
role.as_deref(),
&rec,
None,
RoleSystemResolution::Strict,
)
.expect("compose");
assert!(!out.trim().is_empty());
let global = cfg.roles_prompts.system_prompt.trim();
assert!(
out.contains(global),
"resolved default compose should include global system prompt"
);
}
#[test]
fn compose_strict_unknown_role_errors() {
let cfg = embed_default_config();
let err =
resolve_agent_role_for_prompt_compose(&cfg, Some("nonexistent_role_id_xyz"), None)
.expect_err("strict resolve");
assert!(err.contains("未知的 agent_role"));
}
#[test]
fn compose_from_base_without_skills_matches_augment_only() {
let cfg = embed_default_config();
let rec = ToolOutcomeRecorder::new();
let base = "BASE_PROMPT_MARKER";
let expected = rec.augment_system_prompt(base, &cfg);
let got = compose_system_from_base(base, &cfg, &rec, None);
assert_eq!(got, expected);
}
#[test]
fn compose_first_system_diagnostics_without_skills_reports_l3_l4_only() {
let cfg = embed_default_config();
let rec = Arc::new(ToolOutcomeRecorder::new());
let (_system, diag) = compose_first_system_for_turn_with_diagnostics(
&cfg,
&rec,
FirstSystemComposeOpts {
agent_role: None,
user_msg_for_skills: None,
skills_base_dir: None,
forced_skill: None,
role_resolution: RoleSystemResolution::Strict,
session_mode: None,
},
)
.expect("compose");
assert_eq!(
diag.layers_applied,
vec!["L3".to_string(), "L4".to_string(), "mode".to_string()]
);
assert!(diag.chars_l3_base > 0);
assert!(diag.chars_l4_augmented >= diag.chars_l3_base);
assert_eq!(diag.skills_total_docs, 0);
assert!(diag.skills_selected_labels.is_empty());
}
#[test]
fn compose_first_system_diagnostics_with_skills_reports_l5_selection() {
let tmp = tempfile::tempdir().expect("tempdir");
let skills_dir = tmp.path().join(".crabmate/skills");
std::fs::create_dir_all(&skills_dir).expect("create skills dir");
let mut f = std::fs::File::create(skills_dir.join("rust.md")).expect("create skill file");
writeln!(
f,
"---\nname: Rust Build Skill\n---\n使用 cargo test 与 cargo clippy 进行验证。"
)
.expect("write skill");
let mut cfg = embed_default_config();
cfg.skills.skills_enabled = true;
cfg.skills.skills_dir = ".crabmate/skills".to_string();
cfg.skills.skills_user_dir.clear();
cfg.skills.skills_system_dir.clear();
cfg.skills.skills_top_k = 4;
let rec = Arc::new(ToolOutcomeRecorder::new());
let (_system, diag) = compose_first_system_for_turn_with_diagnostics(
&cfg,
&rec,
FirstSystemComposeOpts {
agent_role: None,
user_msg_for_skills: Some("请帮我跑 cargo test"),
skills_base_dir: Some(tmp.path().to_path_buf()),
forced_skill: None,
role_resolution: RoleSystemResolution::Strict,
session_mode: None,
},
)
.expect("compose");
assert!(diag.layers_applied.contains(&"L5".to_string()));
assert_eq!(diag.skills_total_docs, 1);
assert_eq!(diag.skills_selected_labels.len(), 1);
assert!(diag.skills_selected_labels[0].contains("Rust Build Skill"));
}
#[test]
fn compose_first_system_forced_skill_skips_topk() {
let tmp = tempfile::tempdir().expect("tempdir");
let skills_dir = tmp.path().join(".crabmate/skills");
std::fs::create_dir_all(&skills_dir).expect("create skills dir");
std::fs::write(
skills_dir.join("a.md"),
"---\nname: alpha\n---\nalpha body\n",
)
.expect("write a");
std::fs::write(
skills_dir.join("b.md"),
"---\nname: beta\n---\nbeta body cargo\n",
)
.expect("write b");
let forced = crate::cm_config::skills_slash::resolve_skill_by_id(
crate::cm_config::skills::SkillsListOpts {
workspace_base_dir: tmp.path(),
skills_dir: ".crabmate/skills",
skills_user_dir: "",
skills_system_dir: "",
},
"alpha",
)
.expect("resolve");
let mut cfg = embed_default_config();
cfg.skills.skills_enabled = true;
cfg.skills.skills_dir = ".crabmate/skills".to_string();
cfg.skills.skills_user_dir.clear();
cfg.skills.skills_system_dir.clear();
cfg.skills.skills_top_k = 4;
let rec = Arc::new(ToolOutcomeRecorder::new());
let (system, diag) = compose_first_system_for_turn_with_diagnostics(
&cfg,
&rec,
FirstSystemComposeOpts {
agent_role: None,
user_msg_for_skills: Some("cargo"),
skills_base_dir: Some(tmp.path().to_path_buf()),
forced_skill: Some(forced),
role_resolution: RoleSystemResolution::Strict,
session_mode: None,
},
)
.expect("compose");
assert!(system.contains("alpha body"));
assert!(!system.contains("beta body"));
assert!(diag.skills_selected_labels[0].contains("[forced]"));
}
}