use crate::orchestration::estimate_chunk_tokens;
pub const SKILL_ACTIVATION_EVIDENCE_SCHEMA_VERSION: u32 = 1;
pub const CATALOG_HEADER: &str = concat!(
"## Available skills\n\n",
"These skills are available. Call `load_skill({ name: \"<skill-id>\" })` to load the full body of a skill when it becomes relevant.\n\n",
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillBodyLifecycle {
Eligible,
Shown,
Omitted,
Loaded,
Used,
}
impl SkillBodyLifecycle {
pub fn label(self) -> &'static str {
match self {
SkillBodyLifecycle::Eligible => "eligible",
SkillBodyLifecycle::Shown => "shown",
SkillBodyLifecycle::Omitted => "omitted",
SkillBodyLifecycle::Loaded => "loaded",
SkillBodyLifecycle::Used => "used",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillOmittedReason {
Budget,
Limit,
DisableModelInvocation,
}
impl SkillOmittedReason {
pub fn label(self) -> &'static str {
match self {
SkillOmittedReason::Budget => "budget",
SkillOmittedReason::Limit => "catalog_limit",
SkillOmittedReason::DisableModelInvocation => "disable_model_invocation",
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SkillMatchEvidence {
pub score: f64,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct SkillCardInput {
pub id: String,
pub name: String,
pub source: Option<String>,
pub description: String,
pub when_to_use: String,
pub disable_model_invocation: bool,
pub block: String,
pub in_catalog: bool,
pub matched: Option<SkillMatchEvidence>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkillCardEvidence {
pub id: String,
pub name: String,
pub source: Option<String>,
pub description: String,
pub when_to_use: String,
pub disable_model_invocation: bool,
pub selected: bool,
pub omitted_reason: Option<SkillOmittedReason>,
pub char_estimate: usize,
pub token_estimate: usize,
pub lifecycle: SkillBodyLifecycle,
pub matched: Option<SkillMatchEvidence>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkillActivationEvidence {
pub schema_version: u32,
pub budget_chars: usize,
pub used_chars: usize,
pub budget_tokens: usize,
pub used_tokens: usize,
pub shown: Vec<String>,
pub omitted: Vec<String>,
pub cards: Vec<SkillCardEvidence>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogFit {
pub rendered: String,
pub shown: usize,
}
pub fn fit_catalog(header: &str, blocks: &[String], budget: usize) -> CatalogFit {
let omission_template = "\n\n... 1 more skill(s) omitted to stay within budget.";
let budget = budget.max(header.len() + omission_template.len());
let mut visible = 0usize;
let mut rendered = String::from(header);
while visible < blocks.len() {
let candidate_len = rendered.len()
+ if visible == 0 {
blocks[visible].len()
} else {
1 + blocks[visible].len()
};
if candidate_len > budget {
break;
}
if visible > 0 {
rendered.push('\n');
}
rendered.push_str(&blocks[visible]);
visible += 1;
}
let mut omitted = blocks.len().saturating_sub(visible);
if omitted > 0 {
loop {
let suffix = format!("\n\n... {omitted} more skill(s) omitted to stay within budget.");
if rendered.len() + suffix.len() <= budget {
rendered.push_str(&suffix);
break;
}
if visible == 0 {
break;
}
visible -= 1;
omitted += 1;
rendered = String::from(header);
for (index, block) in blocks.iter().take(visible).enumerate() {
if index > 0 {
rendered.push('\n');
}
rendered.push_str(block);
}
}
}
CatalogFit {
rendered,
shown: visible,
}
}
pub fn build_activation_evidence(
inputs: &[SkillCardInput],
budget: usize,
loaded: &[String],
used: &[String],
) -> SkillActivationEvidence {
let mut fit_blocks: Vec<String> = Vec::new();
let mut fit_input_index: Vec<usize> = Vec::new();
for (index, input) in inputs.iter().enumerate() {
if input.in_catalog {
fit_blocks.push(input.block.clone());
fit_input_index.push(index);
}
}
let fit = if fit_blocks.is_empty() {
CatalogFit {
rendered: CATALOG_HEADER.to_string(),
shown: 0,
}
} else {
fit_catalog(CATALOG_HEADER, &fit_blocks, budget)
};
let shown_inputs: std::collections::BTreeSet<usize> =
fit_input_index.iter().take(fit.shown).copied().collect();
let mut cards = Vec::with_capacity(inputs.len());
let mut shown = Vec::new();
let mut omitted = Vec::new();
for (index, input) in inputs.iter().enumerate() {
let (selected, omitted_reason) = if input.disable_model_invocation {
(false, Some(SkillOmittedReason::DisableModelInvocation))
} else if !input.in_catalog {
(false, Some(SkillOmittedReason::Limit))
} else if shown_inputs.contains(&index) {
(true, None)
} else {
(false, Some(SkillOmittedReason::Budget))
};
let mut lifecycle = if selected {
SkillBodyLifecycle::Shown
} else {
SkillBodyLifecycle::Omitted
};
if used.iter().any(|id| id == &input.id) {
lifecycle = SkillBodyLifecycle::Used;
} else if loaded.iter().any(|id| id == &input.id) {
lifecycle = SkillBodyLifecycle::Loaded;
}
if selected {
shown.push(input.id.clone());
} else {
omitted.push(input.id.clone());
}
cards.push(SkillCardEvidence {
id: input.id.clone(),
name: input.name.clone(),
source: input.source.clone(),
description: input.description.clone(),
when_to_use: input.when_to_use.clone(),
disable_model_invocation: input.disable_model_invocation,
selected,
omitted_reason,
char_estimate: input.block.len(),
token_estimate: estimate_chunk_tokens(&input.block),
lifecycle,
matched: input.matched.clone(),
});
}
let used_chars = fit.rendered.len();
SkillActivationEvidence {
schema_version: SKILL_ACTIVATION_EVIDENCE_SCHEMA_VERSION,
budget_chars: budget,
used_chars,
budget_tokens: estimate_chunk_tokens_for_budget(budget),
used_tokens: estimate_chunk_tokens(&fit.rendered),
shown,
omitted,
cards,
}
}
fn estimate_chunk_tokens_for_budget(budget_chars: usize) -> usize {
budget_chars.div_ceil(4)
}
#[cfg(test)]
mod tests {
use super::*;
fn card(id: &str, disable: bool, in_catalog: bool) -> SkillCardInput {
SkillCardInput {
id: id.to_string(),
name: id.to_string(),
source: Some("project".to_string()),
description: format!("does {id}"),
when_to_use: format!("when {id}"),
disable_model_invocation: disable,
block: format!("- `{id}`: does {id}\n when: when {id}"),
in_catalog: !disable && in_catalog,
matched: None,
}
}
#[test]
fn shown_and_budget_omission_are_disjoint_and_ordered() {
let inputs = vec![
card("alpha", false, true),
card("beta", false, true),
card("gamma", false, true),
];
let evidence = build_activation_evidence(&inputs, 260, &[], &[]);
assert_eq!(evidence.shown, vec!["alpha".to_string()]);
assert_eq!(
evidence.omitted,
vec!["beta".to_string(), "gamma".to_string()]
);
let alpha = &evidence.cards[0];
assert!(alpha.selected);
assert_eq!(alpha.lifecycle, SkillBodyLifecycle::Shown);
assert!(alpha.omitted_reason.is_none());
let beta = &evidence.cards[1];
assert!(!beta.selected);
assert_eq!(beta.omitted_reason, Some(SkillOmittedReason::Budget));
assert_eq!(beta.lifecycle, SkillBodyLifecycle::Omitted);
}
#[test]
fn disable_model_invocation_is_omitted_with_reason() {
let inputs = vec![card("manual", true, false), card("auto", false, true)];
let evidence = build_activation_evidence(&inputs, 10_000, &[], &[]);
let manual = evidence.cards.iter().find(|c| c.id == "manual").unwrap();
assert!(!manual.selected);
assert_eq!(
manual.omitted_reason,
Some(SkillOmittedReason::DisableModelInvocation)
);
assert!(manual.disable_model_invocation);
let auto = evidence.cards.iter().find(|c| c.id == "auto").unwrap();
assert!(auto.selected);
}
#[test]
fn over_limit_entries_report_limit_reason() {
let inputs = vec![card("kept", false, true), card("trimmed", false, false)];
let evidence = build_activation_evidence(&inputs, 10_000, &[], &[]);
let trimmed = evidence.cards.iter().find(|c| c.id == "trimmed").unwrap();
assert_eq!(trimmed.omitted_reason, Some(SkillOmittedReason::Limit));
assert!(!trimmed.disable_model_invocation);
}
#[test]
fn runtime_lifecycle_overrides_registry_state() {
let inputs = vec![card("alpha", false, true), card("beta", false, true)];
let evidence = build_activation_evidence(
&inputs,
10_000,
&["alpha".to_string()],
&["beta".to_string()],
);
let alpha = evidence.cards.iter().find(|c| c.id == "alpha").unwrap();
assert_eq!(alpha.lifecycle, SkillBodyLifecycle::Loaded);
let beta = evidence.cards.iter().find(|c| c.id == "beta").unwrap();
assert_eq!(beta.lifecycle, SkillBodyLifecycle::Used);
}
#[test]
fn token_and_char_estimates_are_populated() {
let inputs = vec![card("alpha", false, true)];
let evidence = build_activation_evidence(&inputs, 10_000, &[], &[]);
let alpha = &evidence.cards[0];
assert!(alpha.char_estimate > 0);
assert_eq!(alpha.token_estimate, alpha.char_estimate.div_ceil(4));
assert!(evidence.used_chars >= CATALOG_HEADER.len());
assert_eq!(evidence.budget_tokens, 10_000usize.div_ceil(4));
}
#[test]
fn empty_catalog_yields_header_only_fit() {
let inputs = vec![card("manual", true, false)];
let evidence = build_activation_evidence(&inputs, 2000, &[], &[]);
assert_eq!(evidence.used_chars, CATALOG_HEADER.len());
assert!(evidence.shown.is_empty());
}
}