use crate::mcp::param_names;
use crate::models::field_names;
use rusqlite::Connection;
use serde_json::{Value, json};
use crate::identity::keypair::AgentKeypair;
use crate::models::{MemoryKind, MemoryLinkRelation};
use super::skill_register::{RegisterResult, register_core, resource_digest};
const DEFAULT_SKILL_PROMOTION_MIN_DEPTH: u32 = 1;
struct PromoteOutcome {
skill_id: String,
digest_hex: String,
namespace: String,
name: String,
reflection_id: String,
reflection_depth: i32,
sources_attached: usize,
superseded: Option<String>,
}
#[allow(clippy::too_many_lines)]
pub fn handle_skill_promote_from_reflection(
conn: &Connection,
params: &Value,
active_keypair: Option<&AgentKeypair>,
) -> Result<Value, String> {
let reflection_id = params[field_names::REFLECTION_ID]
.as_str()
.filter(|s| !s.is_empty())
.ok_or("memory_skill_promote_from_reflection requires 'reflection_id'")?;
let skill_name = params[param_names::SKILL_NAME]
.as_str()
.filter(|s| !s.is_empty())
.ok_or("memory_skill_promote_from_reflection requires 'skill_name'")?;
let skill_description = params[field_names::SKILL_DESCRIPTION]
.as_str()
.filter(|s| !s.is_empty())
.ok_or("memory_skill_promote_from_reflection requires 'skill_description'")?;
let parameters_schema: Option<&Value> = params
.get(field_names::PARAMETERS_SCHEMA)
.filter(|v| !v.is_null() && v.is_object());
crate::parsing::skill_md::validate_skill_name(skill_name)?;
let caller = crate::identity::resolve_agent_id(params["agent_id"].as_str(), None)
.unwrap_or_else(|_| crate::identity::sentinels::ANONYMOUS_INVALID.to_string());
crate::governance::audit::record_decision(
&caller,
"allow",
"skill_promote_from_reflection",
"",
serde_json::json!({
(field_names::REFLECTION_ID): reflection_id,
(field_names::SKILL_NAME): skill_name,
}),
);
if skill_description.len() > 1024 {
return Err(format!(
"skill 'description' must be ≤ 1024 characters \
(agentskills.io spec §3.2): got {} characters",
skill_description.len()
));
}
let reflection = crate::db::get(conn, reflection_id)
.map_err(|e| format!("loading reflection '{reflection_id}': {e}"))?
.ok_or_else(|| format!("reflection not found: {reflection_id}"))?;
if reflection.memory_kind != MemoryKind::Reflection {
return Err(format!(
"memory '{reflection_id}' is memory_kind='{}', expected 'reflection' \
(memory_skill_promote_from_reflection is reflection-only)",
reflection.memory_kind
));
}
let min_depth = crate::db::resolve_skill_promotion_min_depth(conn, &reflection.namespace)
.unwrap_or(DEFAULT_SKILL_PROMOTION_MIN_DEPTH);
#[allow(clippy::cast_sign_loss)]
let actual_depth_u32: u32 = reflection.reflection_depth.max(0) as u32;
if actual_depth_u32 < min_depth {
return Err(format!(
"reflection '{reflection_id}' has reflection_depth={} but \
namespace '{}' requires skill_promotion_min_depth={} — \
a depth-0 reflection carries no synthesised insight to promote",
reflection.reflection_depth, reflection.namespace, min_depth,
));
}
let links = crate::db::get_links(conn, reflection_id)
.map_err(|e| format!("loading reflects_on edges: {e}"))?;
let mut source_ids: Vec<String> = links
.into_iter()
.filter(|l| l.source_id == reflection_id && l.relation == MemoryLinkRelation::ReflectsOn)
.map(|l| l.target_id)
.collect();
source_ids.sort();
let mut resources: Vec<(String, String, Vec<u8>)> = Vec::with_capacity(source_ids.len());
for (i, src_id) in source_ids.iter().enumerate() {
let src = crate::db::get(conn, src_id)
.map_err(|e| format!("loading source memory '{src_id}': {e}"))?;
let body = match src {
Some(m) => format!(
"# Source memory: {title}\n\n\
- memory id: `{id}`\n\
- namespace: `{ns}`\n\
- reflection_depth: {depth}\n\
- created_at: {created}\n\n\
## Content\n\n{content}\n",
title = m.title,
id = m.id,
ns = m.namespace,
depth = m.reflection_depth,
created = m.created_at,
content = m.content,
),
None => format!(
"# Source memory: (deleted)\n\n\
- memory id: `{src_id}`\n\
- note: source memory was deleted between reflection and promotion; \
only the id provenance edge is preserved.\n",
),
};
let res_path = format!("references/source_{i}.md");
resources.push((res_path, "reference".to_string(), body.into_bytes()));
}
let mut body = String::new();
body.push_str(&format!("# {skill_name}\n\n"));
body.push_str(&format!("{skill_description}\n\n"));
body.push_str("## Reflection content\n\n");
body.push_str(&reflection.content);
if !reflection.content.ends_with('\n') {
body.push('\n');
}
body.push('\n');
body.push_str("## Applies when\n\n");
body.push_str(
"This skill was promoted from a reflection memory. It applies in contexts \
that resemble the situations described above — the source memories listed \
under `references/` capture the originating evidence.\n\n",
);
body.push_str("## Outputs\n\n");
body.push_str(
"Apply the reflection content as a reusable pattern. Reference the \
per-source resources in `references/` for the underlying evidence \
when the agent needs to re-derive the conclusion.\n",
);
if let Some(schema) = parameters_schema {
let pretty = serde_json::to_string_pretty(schema)
.map_err(|e| format!("parameters_schema serialize: {e}"))?;
body.push_str("\n## Parameters\n\n```json\n");
body.push_str(&pretty);
body.push_str("\n```\n");
}
let metadata = json!({
"derived_from_reflection_id": reflection_id,
"original_reflection_depth": reflection.reflection_depth,
});
let res_digests: Vec<Vec<u8>> = resources
.iter()
.map(|(_, _, content)| resource_digest(content))
.collect();
let sources_attached = resources.len();
let license = Some("Apache-2.0");
let compatibility: Option<&str> = None;
let allowed_tools: Vec<String> = Vec::new();
let RegisterResult {
id: skill_id,
digest,
superseded,
} = register_core(
conn,
&reflection.namespace,
skill_name,
skill_description,
license,
compatibility,
&allowed_tools,
&metadata,
body.as_bytes(),
res_digests,
&resources,
active_keypair,
)?;
let digest_hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
let outcome = PromoteOutcome {
skill_id,
digest_hex,
namespace: reflection.namespace.clone(),
name: skill_name.to_string(),
reflection_id: reflection_id.to_string(),
reflection_depth: reflection.reflection_depth,
sources_attached,
superseded,
};
let mut response = json!({
"promoted": true,
"skill_id": outcome.skill_id,
"namespace": outcome.namespace,
"name": outcome.name,
"digest": outcome.digest_hex,
"derived_from_reflection_id": outcome.reflection_id,
"original_reflection_depth": outcome.reflection_depth,
"sources_attached": outcome.sources_attached,
"signed": active_keypair.is_some(),
});
if let Some(prev) = outcome.superseded {
response[field_names::SUPERSEDED_ID] = json!(prev);
}
Ok(response)
}
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct SkillPromoteFromReflectionRequest {
pub reflection_id: String,
pub skill_name: String,
pub skill_description: String,
#[serde(default)]
pub parameters_schema: Option<serde_json::Value>,
}
#[allow(dead_code)]
pub struct SkillPromoteFromReflectionTool;
impl McpTool for SkillPromoteFromReflectionTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_SKILL_PROMOTE_FROM_REFLECTION
}
fn description() -> &'static str {
"Promote a Reflection into a reusable Agent Skill."
}
fn docs() -> &'static str {
"L2-6 (#671): reflection (depth>=namespace.governance.skill_promotion_min_depth, default 1) -> SKILL.md. Each reflects_on source -> references/source_{i}.md. Frontmatter preserves derived_from_reflection_id + original_reflection_depth. Promote->export->register => identical SHA-256. Refuses depth-0."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<SkillPromoteFromReflectionRequest>()
}
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_promote_from_reflection_parity_986() {
let derived = derived_props_for::<SkillPromoteFromReflectionRequest>();
assert_property_set_parity("memory_skill_promote_from_reflection", &derived);
assert_descriptions_match("memory_skill_promote_from_reflection", &derived);
}
#[test]
fn skill_promote_from_reflection_tool_metadata_986() {
assert_eq!(
SkillPromoteFromReflectionTool::name(),
"memory_skill_promote_from_reflection"
);
assert_eq!(SkillPromoteFromReflectionTool::family(), "other");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::models::{Memory, MemoryKind, Tier};
use serde_json::json as sjson;
fn open_db() -> (rusqlite::Connection, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("promote.db");
let conn = db::open(&path).expect("db open");
(conn, dir)
}
fn insert_observation(conn: &rusqlite::Connection, title: &str, ns: &str) -> String {
let now = chrono::Utc::now().to_rfc3339();
let m = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: ns.to_string(),
title: title.to_string(),
content: format!("body of {title}"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "cli".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: sjson!({}),
reflection_depth: 0,
memory_kind: MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
db::insert(conn, &m).expect("insert observation")
}
fn make_reflection(conn: &rusqlite::Connection, sources: &[String], ns: &str) -> String {
let input = db::ReflectInput {
source_ids: sources.to_vec(),
title: format!("reflection over {} sources", sources.len()),
content: "Synthesised insight: pattern X implies action Y.".to_string(),
namespace: Some(ns.to_string()),
tier: Tier::Mid,
tags: vec![],
priority: 5,
confidence: 1.0,
source: "cli".to_string(),
agent_id: "test-agent".to_string(),
metadata: sjson!({}),
};
db::reflect(conn, &input).expect("reflect").id
}
#[test]
fn refuses_non_reflection_memory() {
let (conn, _dir) = open_db();
let obs_id = insert_observation(&conn, "raw note", "ns");
let params = sjson!({
"reflection_id": obs_id,
"skill_name": "test-skill",
"skill_description": "Test skill from observation (should fail).",
});
let err = handle_skill_promote_from_reflection(&conn, ¶ms, None).unwrap_err();
assert!(
err.contains("memory_kind='observation'"),
"must surface kind mismatch: {err}",
);
}
#[test]
fn refuses_unknown_reflection_id() {
let (conn, _dir) = open_db();
let params = sjson!({
"reflection_id": "nonexistent-id",
"skill_name": "x",
"skill_description": "desc",
});
let err = handle_skill_promote_from_reflection(&conn, ¶ms, None).unwrap_err();
assert!(err.contains("not found"), "expected not found: {err}");
}
#[test]
fn refuses_invalid_skill_name() {
let (conn, _dir) = open_db();
let obs_id = insert_observation(&conn, "source", "ns");
let refl_id = make_reflection(&conn, &[obs_id], "ns");
let params = sjson!({
"reflection_id": refl_id,
"skill_name": "BadName",
"skill_description": "desc",
});
let err = handle_skill_promote_from_reflection(&conn, ¶ms, None).unwrap_err();
assert!(err.contains("spec §3.1"), "must cite spec: {err}");
}
#[test]
fn rejects_missing_required_params() {
let (conn, _dir) = open_db();
let err = handle_skill_promote_from_reflection(&conn, &sjson!({}), None).unwrap_err();
assert!(err.contains("reflection_id"), "{err}");
let err =
handle_skill_promote_from_reflection(&conn, &sjson!({"reflection_id": "x"}), None)
.unwrap_err();
assert!(err.contains("skill_name"), "{err}");
let err = handle_skill_promote_from_reflection(
&conn,
&sjson!({"reflection_id": "x", "skill_name": "n"}),
None,
)
.unwrap_err();
assert!(err.contains("skill_description"), "{err}");
}
}