slice 3.5a: skills catalog — the "how to think" layer
Introduce the skills catalog: the second half of the two-layer agent
model (skills teach agents HOW to think about a problem; MCP servers
give them the ABILITY to act). Delivery via MCP resources lands in
Slice 3.5b; this slice ships the data model + API surface.
Migration 0049 extends the legacy `skills` table (from 0001_init.sql,
originally a workspace catalog of markdown snippets) with the richer
typing we need — name, when_to_use, tags, source_kind, current_version
— rather than duplicating tables. Also adds:
- skill_versions (version history for level-up promotions +
rollback; back-pointer via promoted_from
JSONB records agent_id / research artifact /
brain memory that produced it)
- template_role_skills (m2m binding skills to team-template roles
with pin_in_context + order_idx)
- agent_skills_ext (per-agent overlay: include=true adds a skill
to the bundle; include=false prunes a
template default for this specific agent)
Rust surface:
- cm_db::repo::skills_catalog with typed Skill/SkillVersion/
AgentSkillBinding structs + upsert_builtin (idempotent — bumps
version + appends to skill_versions ONLY when body changes) +
list_visible/get/get_by_name reads + template + agent binding
helpers + effective_for_agent (merges template defaults with
agent overrides, applies exclude precedence, batch-fetches skill
bodies)
- cm_api::routes::skills_catalog with:
GET /api/skills — list visible
GET /api/skills/{id} — detail
GET /api/claws/{id}/skills — effective binding (accepts
template_id + role_slot as query args to merge in template
defaults)
Follow-ups:
- Slice 3.5b: clawmates_skills MCP server exposes catalog as MCP
resources, honoring pin_in_context for auto-injection
- Slice 3.5c: seed ~40-60 builtin skills across the 6 stacks
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3f77370a6e
commit
1153d72d00
@@ -28,6 +28,7 @@ pub mod run_events;
|
||||
pub mod runs;
|
||||
pub mod sessions;
|
||||
pub mod skills;
|
||||
pub mod skills_catalog;
|
||||
pub mod steps;
|
||||
pub mod structure_reify;
|
||||
pub mod team_templates;
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Skills catalog — the second half of the two-layer agent model
|
||||
//! (skills = how to think, MCP = how to act). Slice 3.5a of the
|
||||
//! missions consolidation.
|
||||
//!
|
||||
//! Note: this module intentionally sits alongside the existing
|
||||
//! `crate::repo::skills` module (which is unrelated — that older
|
||||
//! module deals with a different concept). The two coexist until
|
||||
//! we get around to renaming the legacy one.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Skill {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub when_to_use: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub source_kind: String,
|
||||
pub workspace_id: Option<Uuid>,
|
||||
pub current_version: i32,
|
||||
pub body: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillVersion {
|
||||
pub skill_id: Uuid,
|
||||
pub version: i32,
|
||||
pub body: String,
|
||||
pub description: String,
|
||||
pub when_to_use: Option<String>,
|
||||
pub promoted_from: Option<Value>,
|
||||
pub author_id: Option<Uuid>,
|
||||
pub approved_by: Option<Uuid>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Effective skill binding for a specific agent — the merged view of
|
||||
/// (template role skills) + (agent overrides). Slice 3.5b's MCP
|
||||
/// server reads this to figure out what to expose.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AgentSkillBinding {
|
||||
pub skill: Skill,
|
||||
pub pin_in_context: bool,
|
||||
/// Where the binding came from — helpful for the level-up UI.
|
||||
/// "template" | "agent_override" | "agent_only"
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
// ── Upsert builtins ──────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpsertBuiltinSkill<'a> {
|
||||
/// Caller-provided deterministic id (sha256-derived in the loader).
|
||||
pub id: Uuid,
|
||||
pub name: &'a str,
|
||||
pub description: &'a str,
|
||||
pub when_to_use: Option<&'a str>,
|
||||
pub tags: Vec<String>,
|
||||
pub body: &'a str,
|
||||
}
|
||||
|
||||
/// Idempotent upsert for builtin skills. Bumps `current_version` +
|
||||
/// appends to `skill_versions` only when `body` actually changes.
|
||||
/// Never touches non-builtin rows.
|
||||
pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltinSkill<'_>) -> Result<Uuid, DbError> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Look up the current stored body (if any) to decide whether the
|
||||
// upsert should bump the version. Builtins have workspace_id NULL.
|
||||
let existing: Option<(i32, String)> = sqlx::query(
|
||||
"SELECT current_version, body
|
||||
FROM skills WHERE id = $1 AND source_kind = 'builtin'",
|
||||
)
|
||||
.bind(b.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<i32, _>("current_version"),
|
||||
r.get::<String, _>("body"),
|
||||
)
|
||||
});
|
||||
|
||||
let (next_version, bump) = match &existing {
|
||||
Some((v, prev)) if prev == b.body => (*v, false),
|
||||
Some((v, _)) => (v + 1, true),
|
||||
None => (1, true),
|
||||
};
|
||||
|
||||
// Legacy `skills` table (0001_init.sql) still requires `title` +
|
||||
// `author` — populate from `name` + a system marker until Slice 9
|
||||
// drops the legacy columns.
|
||||
sqlx::query(
|
||||
"INSERT INTO skills
|
||||
(id, name, title, author, description, when_to_use, tags,
|
||||
source_kind, workspace_id, current_version, body)
|
||||
VALUES ($1,$2,$2,'system',$3,$4,$5,'builtin',NULL,$6,$7)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
when_to_use = EXCLUDED.when_to_use,
|
||||
tags = EXCLUDED.tags,
|
||||
current_version = EXCLUDED.current_version,
|
||||
body = EXCLUDED.body,
|
||||
updated_at = now()",
|
||||
)
|
||||
.bind(b.id)
|
||||
.bind(b.name)
|
||||
.bind(b.description)
|
||||
.bind(b.when_to_use)
|
||||
.bind(&b.tags)
|
||||
.bind(next_version)
|
||||
.bind(b.body)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if bump {
|
||||
sqlx::query(
|
||||
"INSERT INTO skill_versions
|
||||
(skill_id, version, body_md, description, when_to_use)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(b.id)
|
||||
.bind(next_version)
|
||||
.bind(b.body)
|
||||
.bind(b.description)
|
||||
.bind(b.when_to_use)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(b.id)
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────
|
||||
|
||||
/// All builtin + workspace-scoped skills the caller can see.
|
||||
pub async fn list_visible(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<Skill>, DbError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, description, when_to_use, tags, source_kind,
|
||||
workspace_id, current_version, body, created_at, updated_at
|
||||
FROM skills
|
||||
WHERE workspace_id IS NULL OR workspace_id = $1
|
||||
ORDER BY workspace_id NULLS FIRST, name ASC",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_skill).collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<Skill>, DbError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, description, when_to_use, tags, source_kind,
|
||||
workspace_id, current_version, body, created_at, updated_at
|
||||
FROM skills WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_skill))
|
||||
}
|
||||
|
||||
pub async fn get_by_name(
|
||||
pool: &PgPool,
|
||||
workspace_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> Result<Option<Skill>, DbError> {
|
||||
// COALESCE-based unique lookup mirroring the partial UNIQUE index
|
||||
// in the migration.
|
||||
let sentinel: Uuid = "00000000-0000-0000-0000-000000000000".parse().unwrap();
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, description, when_to_use, tags, source_kind,
|
||||
workspace_id, current_version, body, created_at, updated_at
|
||||
FROM skills
|
||||
WHERE COALESCE(workspace_id, $1::uuid) = COALESCE($2::uuid, $1::uuid)
|
||||
AND name = $3",
|
||||
)
|
||||
.bind(sentinel)
|
||||
.bind(workspace_id)
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_skill))
|
||||
}
|
||||
|
||||
// ── Template-role bindings ───────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttachRoleSkill<'a> {
|
||||
pub template_id: Uuid,
|
||||
pub slot: &'a str,
|
||||
pub skill_id: Uuid,
|
||||
pub pin_in_context: bool,
|
||||
pub order_idx: i32,
|
||||
}
|
||||
|
||||
pub async fn attach_role_skill(pool: &PgPool, a: AttachRoleSkill<'_>) -> Result<(), DbError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO template_role_skills
|
||||
(template_id, slot, skill_id, pin_in_context, order_idx)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT (template_id, slot, skill_id) DO UPDATE SET
|
||||
pin_in_context = EXCLUDED.pin_in_context,
|
||||
order_idx = EXCLUDED.order_idx",
|
||||
)
|
||||
.bind(a.template_id)
|
||||
.bind(a.slot)
|
||||
.bind(a.skill_id)
|
||||
.bind(a.pin_in_context)
|
||||
.bind(a.order_idx)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wipe all skill bindings for a template's roles. Used by the
|
||||
/// template loader before it re-attaches the current-truth set from
|
||||
/// TOML, so a role that no longer references a skill loses that
|
||||
/// binding without an explicit detach call.
|
||||
pub async fn clear_template_role_skills(pool: &PgPool, template_id: Uuid) -> Result<(), DbError> {
|
||||
sqlx::query("DELETE FROM template_role_skills WHERE template_id = $1")
|
||||
.bind(template_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Agent-level overlay ──────────────────────────────────────────
|
||||
|
||||
pub async fn set_agent_skill(
|
||||
pool: &PgPool,
|
||||
agent_id: Uuid,
|
||||
skill_id: Uuid,
|
||||
included: bool,
|
||||
pin_in_context: bool,
|
||||
added_by: Option<Uuid>,
|
||||
added_reason: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_skills_ext
|
||||
(agent_id, skill_id, included, pin_in_context, added_by, added_reason)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (agent_id, skill_id) DO UPDATE SET
|
||||
included = EXCLUDED.included,
|
||||
pin_in_context = EXCLUDED.pin_in_context,
|
||||
added_by = COALESCE(EXCLUDED.added_by, agent_skills_ext.added_by),
|
||||
added_reason = COALESCE(EXCLUDED.added_reason, agent_skills_ext.added_reason)",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(skill_id)
|
||||
.bind(included)
|
||||
.bind(pin_in_context)
|
||||
.bind(added_by)
|
||||
.bind(added_reason)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Effective binding for the specified agent — merges template
|
||||
/// defaults with per-agent overrides. Excluded skills (agent_skills_ext.included=false)
|
||||
/// are filtered out here so callers see one flat truth list.
|
||||
pub async fn effective_for_agent(
|
||||
pool: &PgPool,
|
||||
agent_id: Uuid,
|
||||
template_id: Option<Uuid>,
|
||||
role_slot: Option<&str>,
|
||||
) -> Result<Vec<AgentSkillBinding>, DbError> {
|
||||
// Two queries + client-side merge — the join is straightforward
|
||||
// but the "template default overridden by agent" precedence is
|
||||
// cleaner in Rust than in SQL. In-workspace call so N stays small
|
||||
// (dozens, not thousands).
|
||||
let mut template_bindings: Vec<(Uuid, bool, i32)> = Vec::new();
|
||||
if let (Some(tid), Some(slot)) = (template_id, role_slot) {
|
||||
let rows = sqlx::query(
|
||||
"SELECT skill_id, pin_in_context, order_idx
|
||||
FROM template_role_skills
|
||||
WHERE template_id = $1 AND slot = $2
|
||||
ORDER BY order_idx ASC",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(slot)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
template_bindings = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<Uuid, _>("skill_id"),
|
||||
r.get::<bool, _>("pin_in_context"),
|
||||
r.get::<i32, _>("order_idx"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let overrides: Vec<(Uuid, bool, bool)> = sqlx::query(
|
||||
"SELECT skill_id, included, pin_in_context
|
||||
FROM agent_skills_ext WHERE agent_id = $1",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<Uuid, _>("skill_id"),
|
||||
r.get::<bool, _>("included"),
|
||||
r.get::<bool, _>("pin_in_context"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
use std::collections::HashMap;
|
||||
let override_map: HashMap<Uuid, (bool, bool)> = overrides
|
||||
.iter()
|
||||
.map(|(id, inc, pin)| (*id, (*inc, *pin)))
|
||||
.collect();
|
||||
|
||||
// Build the final id set + per-id (pin, source) tuple.
|
||||
let mut ordered: Vec<(Uuid, bool, &'static str)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (id, tpl_pin, _order) in &template_bindings {
|
||||
let (included, pin) = match override_map.get(id) {
|
||||
Some((false, _)) => continue, // agent explicitly excluded
|
||||
Some((true, agent_pin)) => (true, *agent_pin || *tpl_pin),
|
||||
None => (true, *tpl_pin),
|
||||
};
|
||||
if included && seen.insert(*id) {
|
||||
ordered.push((*id, pin, "template"));
|
||||
}
|
||||
}
|
||||
for (id, included, agent_pin) in &overrides {
|
||||
if !included || seen.contains(id) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(*id) {
|
||||
ordered.push((*id, *agent_pin, "agent_only"));
|
||||
}
|
||||
}
|
||||
|
||||
if ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-fetch the skill bodies in one query.
|
||||
let ids: Vec<Uuid> = ordered.iter().map(|(id, _, _)| *id).collect();
|
||||
let skill_rows = sqlx::query(
|
||||
"SELECT id, name, description, when_to_use, tags, source_kind,
|
||||
workspace_id, current_version, body, created_at, updated_at
|
||||
FROM skills WHERE id = ANY($1)",
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let skill_map: HashMap<Uuid, Skill> = skill_rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let s = row_to_skill(r);
|
||||
(s.id, s)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ordered
|
||||
.into_iter()
|
||||
.filter_map(|(id, pin, source)| {
|
||||
skill_map.get(&id).cloned().map(|skill| AgentSkillBinding {
|
||||
skill,
|
||||
pin_in_context: pin,
|
||||
source: source.to_string(),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn row_to_skill(r: sqlx::postgres::PgRow) -> Skill {
|
||||
Skill {
|
||||
id: r.get("id"),
|
||||
name: r.get("name"),
|
||||
description: r.get("description"),
|
||||
when_to_use: r.get("when_to_use"),
|
||||
tags: r.get("tags"),
|
||||
source_kind: r.get("source_kind"),
|
||||
workspace_id: r.get("workspace_id"),
|
||||
current_version: r.get("current_version"),
|
||||
body: r.get("body"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user