use crate::models::field_names;
use rusqlite::Connection;
use serde_json::{Value, json};
use crate::models::skill::ComposesWithReflectionEntry;
const DEFAULT_BUDGET_TOKENS: usize = 4_000;
const MAX_BUDGET_TOKENS: usize = 32_000;
const RECALL_SATURATION: i64 = 50;
pub fn handle_skill_compositional_context(
conn: &Connection,
params: &Value,
) -> Result<Value, String> {
let skill_id = params["skill_id"]
.as_str()
.filter(|s| !s.is_empty())
.ok_or("memory_skill_compositional_context requires 'skill_id'")?;
let budget_tokens = parse_budget_tokens(params);
let (body, metadata_json, namespace, name) = match conn.query_row(
"SELECT body_blob, metadata, namespace, name FROM skills WHERE id = ?1",
[skill_id],
|row| {
Ok((
row.get::<_, Vec<u8>>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
) {
Ok(t) => t,
Err(_) => return Err(crate::errors::msg::skill_not_found(skill_id)),
};
let body_bytes = zstd::decode_all(body.as_slice())
.map_err(|e| crate::errors::msg::zstd_decompress_body(e))?;
let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
let composes = parse_composes_from_metadata(&metadata_json);
let mut scored: Vec<ScoredReflection> = Vec::new();
let mut bounded_namespaces: Vec<Value> = Vec::with_capacity(composes.len());
let now_epoch = chrono::Utc::now().timestamp();
for entry in &composes {
let ceiling = max_reflection_depth_for(conn, &entry.namespace);
bounded_namespaces.push(json!({
"namespace": entry.namespace,
"min_depth": entry.min_depth,
"max_reflection_depth": ceiling,
}));
if u64::from(entry.min_depth) > u64::from(ceiling) {
continue;
}
let mut stmt = conn
.prepare(
"SELECT id, namespace, title, content, created_at, access_count, \
reflection_depth, memory_kind \
FROM memories \
WHERE namespace = ?1 \
AND memory_kind = 'reflection' \
AND reflection_depth >= ?2 \
AND reflection_depth <= ?3 \
AND (expires_at IS NULL OR expires_at > ?4) \
ORDER BY created_at DESC",
)
.map_err(|e| format!("reflections SELECT prepare: {e}"))?;
let now_iso = chrono::Utc::now().to_rfc3339();
let rows = stmt
.query_map(
rusqlite::params![
&entry.namespace,
i64::from(entry.min_depth),
i64::from(ceiling),
now_iso,
],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, i64>(5)?,
row.get::<_, i32>(6)?,
row.get::<_, String>(7)?,
))
},
)
.map_err(|e| format!("reflections SELECT exec: {e}"))?;
for row in rows {
let (id, ns, title, content, created_at, access_count, depth, kind) =
row.map_err(|e| format!("reflections row: {e}"))?;
let recency = recency_score(&created_at, now_epoch);
let recall = recall_score(access_count);
let score = recency + recall;
scored.push(ScoredReflection {
id,
namespace: ns,
title,
content,
created_at,
access_count,
reflection_depth: depth,
memory_kind: kind,
score,
});
}
}
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut tokens_used: usize = 0;
let mut dropped: usize = 0;
let mut emitted: Vec<Value> = Vec::with_capacity(scored.len());
for r in scored {
let r_tokens = crate::db::count_tokens_cl100k(&r.content);
if tokens_used.saturating_add(r_tokens) > budget_tokens {
dropped += 1;
continue;
}
tokens_used += r_tokens;
emitted.push(json!({
"id": r.id,
"namespace": r.namespace,
"title": r.title,
"content": r.content,
(field_names::CREATED_AT): r.created_at,
(field_names::ACCESS_COUNT): r.access_count,
(field_names::REFLECTION_DEPTH): r.reflection_depth,
(field_names::MEMORY_KIND): r.memory_kind,
"score": r.score,
}));
}
Ok(json!({
"skill_id": skill_id,
"skill_namespace": namespace,
(field_names::SKILL_NAME): name,
"body": body_str,
"compositional_namespaces": bounded_namespaces,
"reflections": emitted,
(field_names::BUDGET_TOKENS): budget_tokens,
(field_names::TOKENS_USED): tokens_used,
(field_names::MEMORIES_DROPPED): dropped,
}))
}
struct ScoredReflection {
id: String,
namespace: String,
title: String,
content: String,
created_at: String,
access_count: i64,
reflection_depth: i32,
memory_kind: String,
score: f64,
}
fn parse_budget_tokens(params: &Value) -> usize {
let raw = params
.get(field_names::BUDGET_TOKENS)
.and_then(serde_json::Value::as_u64);
match raw {
Some(n) => {
let clamped =
usize::try_from(n.min(u64::try_from(MAX_BUDGET_TOKENS).unwrap_or(u64::MAX)))
.unwrap_or(MAX_BUDGET_TOKENS);
clamped.min(MAX_BUDGET_TOKENS)
}
None => DEFAULT_BUDGET_TOKENS,
}
}
fn parse_composes_from_metadata(metadata_json: &str) -> Vec<ComposesWithReflectionEntry> {
let Ok(value) = serde_json::from_str::<Value>(metadata_json) else {
return Vec::new();
};
let Some(array) = value
.get("composes_with_reflections")
.and_then(Value::as_array)
else {
return Vec::new();
};
array
.iter()
.filter_map(|v| serde_json::from_value::<ComposesWithReflectionEntry>(v.clone()).ok())
.collect()
}
fn max_reflection_depth_for(conn: &Connection, namespace: &str) -> u32 {
crate::db::resolve_governance_policy(conn, namespace)
.map_or(3, |p| p.effective_max_reflection_depth())
}
fn recency_score(created_at: &str, now_epoch: i64) -> f64 {
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(created_at) else {
return 0.0;
};
let then = parsed.timestamp();
let age_secs = (now_epoch.saturating_sub(then)).max(0);
const YEAR_SECS: f64 = 365.25 * crate::SECS_PER_DAY as f64;
let normalised = 1.0 - (age_secs as f64 / YEAR_SECS).min(1.0);
normalised.clamp(0.0, 1.0)
}
fn recall_score(access_count: i64) -> f64 {
let bounded = access_count.clamp(0, RECALL_SATURATION) as f64;
bounded / RECALL_SATURATION as f64
}
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct SkillCompositionalContextRequest {
pub skill_id: String,
#[serde(default)]
pub budget_tokens: Option<i64>,
}
#[allow(dead_code)]
pub struct SkillCompositionalContextTool;
impl McpTool for SkillCompositionalContextTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_SKILL_COMPOSITIONAL_CONTEXT
}
fn description() -> &'static str {
"Skill body + composes_with_reflections (bounded by max_reflection_depth)."
}
fn docs() -> &'static str {
"L2-7 (#672): compose skill activation with reflections from SKILL.md composes_with_reflections list. Per-entry min_depth filter; per-namespace max_reflection_depth is the authoritative ceiling (CANNOT bypass bounded-recursion). Reflections ranked recency + recall_count; budget_tokens caps cumulative reflection content (default 4000, max 32000)."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<SkillCompositionalContextRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Other.name()
}
}
#[cfg(test)]
mod d1_5_986_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn skill_compositional_context_parity_986() {
let derived = derived_props_for::<SkillCompositionalContextRequest>();
assert_property_set_parity("memory_skill_compositional_context", &derived);
assert_descriptions_match("memory_skill_compositional_context", &derived);
}
#[test]
fn skill_compositional_context_tool_metadata_986() {
assert_eq!(
SkillCompositionalContextTool::name(),
"memory_skill_compositional_context"
);
assert_eq!(SkillCompositionalContextTool::family(), "other");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_budget_tokens_defaults_when_absent() {
let v = json!({});
assert_eq!(parse_budget_tokens(&v), DEFAULT_BUDGET_TOKENS);
}
#[test]
fn parse_budget_tokens_respects_request() {
let v = json!({"budget_tokens": 1000});
assert_eq!(parse_budget_tokens(&v), 1000);
}
#[test]
fn parse_budget_tokens_clamps_to_ceiling() {
let v = json!({"budget_tokens": 1_000_000});
assert_eq!(parse_budget_tokens(&v), MAX_BUDGET_TOKENS);
}
#[test]
fn recall_score_saturates_at_50() {
assert!((recall_score(0) - 0.0).abs() < 1e-9);
assert!((recall_score(50) - 1.0).abs() < 1e-9);
assert!((recall_score(10_000) - 1.0).abs() < 1e-9);
}
#[test]
fn recency_score_unreadable_timestamp_is_zero() {
let now = 1_700_000_000;
assert!((recency_score("not-an-rfc3339", now) - 0.0).abs() < 1e-9);
}
#[test]
fn parse_composes_from_metadata_handles_empty() {
let composes = parse_composes_from_metadata("{}");
assert!(composes.is_empty());
}
#[test]
fn parse_composes_from_metadata_reads_mirror() {
let meta = r#"{"composes_with_reflections":[{"namespace":"foo/obs","min_depth":1}]}"#;
let composes = parse_composes_from_metadata(meta);
assert_eq!(composes.len(), 1);
assert_eq!(composes[0].namespace, "foo/obs");
assert_eq!(composes[0].min_depth, 1);
}
#[test]
fn parse_composes_from_metadata_tolerates_garbage() {
let composes = parse_composes_from_metadata(r#"{"composes_with_reflections":"oops"}"#);
assert!(composes.is_empty());
}
}