slice 3.5d: agent_template_link + brain seed helper + skills merge
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 38s
ci / rust (push) Failing after 3m1s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Ships the lineage layer that ties agents back to their team template
and wires the MCP skills server to actually merge template default
skills with per-agent overrides.

Migration 0050 adds `agent_template_link` (agent_id PK, template_id,
template_version, role_slot, seeded_at, created_at + indexes for
template/role lookups). Populated at agent-materialization time by
Slice 4's mission-launch orchestrator; read here by the skills MCP
server and by future level-up (Slice 8.5).

New Rust surface:
  - cm_db::repo::agent_template_link  (upsert / get / mark_seeded /
    agents_for_template — the last is what level-up's "prompt upgrade
    on template N+1" query needs)
  - cm_api::brain_seed::ingest(claw_id, seed_md, identity_prompt)
    opens cm_brain::ClawBrain on spawn_blocking, sets system_prompt
    on first touch, writes seed as agent_md, commits. Idempotent —
    skips when agent_md already populated.
  - cm_api::mcp_skills::mcp_skills tools/call now resolves the caller
    agent's template + role via agent_template_link and merges
    template default skills with per-agent overrides (was overrides-
    only in Slice 3.5b).
  - cm_api::team_template_loader now binds template_role_skills after
    upserting each template — looks up each declared skill by name,
    attaches with pin_in_context=true for foundation skills and the
    first two role skills. Missing skills log + skip.
  - Boot ordering: skills load BEFORE team templates so the binding
    lookup resolves.

Follow-up (Slice 4): mission-launch orchestrator calls brain_seed::ingest
+ agent_template_link::upsert when minting a team from a template.
Until that lands, the link is populated only by manual writes; the
MCP merge is silent-no-op for agents without a link (falls through
to overrides-only), which matches the pre-3.5d behavior.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 14:16:16 -07:00
co-authored by Claude Opus 4.7
parent 520a717dec
commit 85a97dffca
8 changed files with 290 additions and 20 deletions
+8 -14
View File
@@ -271,23 +271,17 @@ async fn run() -> Result<(), String> {
runtime.clone(), runtime.clone(),
std::time::Duration::from_secs(3), std::time::Duration::from_secs(3),
); );
// Load builtin team templates from disk into team_templates + // Boot-time content loaders — skills first, then team templates
// template_roles. Idempotent per boot; edits to templates/teams/*.toml // (Slice 3.5d): the team_template_loader binds template_role_skills
// go live on the next deploy. // by looking up skills by name, so the skills catalog must be
// populated first. Both are idempotent per boot.
{ {
let pool = pool.clone(); let pool = pool.clone();
tokio::spawn(async move { tokio::spawn(async move {
let n = cm_api::team_template_loader::load_builtins(&pool).await; let n_skills = cm_api::skills_loader::load_builtins(&pool).await;
eprintln!("team_template_loader: loaded {n} builtin team template(s)"); eprintln!("skills_loader: loaded {n_skills} builtin skill(s)");
}); let n_tpl = cm_api::team_template_loader::load_builtins(&pool).await;
} eprintln!("team_template_loader: loaded {n_tpl} builtin team template(s)");
// Load builtin skills from skills/**/*.md into the skills catalog
// (Slice 3.5c). Same idempotent-per-boot semantics.
{
let pool = pool.clone();
tokio::spawn(async move {
let n = cm_api::skills_loader::load_builtins(&pool).await;
eprintln!("skills_loader: loaded {n} builtin skill(s)");
}); });
} }
// Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert // Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert
+71
View File
@@ -0,0 +1,71 @@
//! Brain-seed ingestion helper — Slice 3.5d.
//!
//! Called at agent-materialization time (from a template role, or
//! from any future code path that wants to prime a fresh brain).
//! Reads the role's `brain_seed` markdown, opens the agent's HDF5
//! brain file via `cm_brain::ClawBrain`, and writes the seed as
//! `agent_md`.
//!
//! `agent_md` is the right slot (not `system_prompt`) because:
//! - system_prompt is the agent's identity + role — comes from
//! the template's role.system_prompt field, ingested separately
//! - agent_md is the "durable knowledge base" the agent reads
//! each session — exactly the semantics of a seed
//!
//! Idempotent: no-op if the brain file already has non-empty agent_md.
//! Level-up (Slice 8.5) diffs current agent_md against the template's
//! current-version brain_seed to propose consolidation.
use std::path::PathBuf;
use uuid::Uuid;
fn brain_dir() -> PathBuf {
std::env::var("CLAWMATES_BRAIN_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/data/brains"))
}
/// Ingest `seed_md` into agent `claw_id`'s brain. Also sets
/// `system_prompt` from `identity_prompt` if the brain doesn't have
/// one yet (first-touch seed mirrors the load-time seeding in the
/// existing routes::claws::load_brain path).
///
/// Runs cm-brain on `spawn_blocking` — HDF5 I/O is sync + can be slow.
pub async fn ingest(claw_id: Uuid, seed_md: String, identity_prompt: String) -> Result<(), String> {
tokio::task::spawn_blocking(move || -> Result<(), String> {
use cm_brain::ClawBrain;
let dir = brain_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir brain dir: {e}"))?;
let path = dir.join(format!("claw_{claw_id}.h5"));
let mut brain = ClawBrain::open_or_create(&path, &claw_id.to_string())
.map_err(|e| format!("open brain: {e}"))?;
// First-touch identity seed.
if brain.system_prompt().is_none() && !identity_prompt.trim().is_empty() {
brain
.set_system_prompt(&identity_prompt)
.map_err(|e| format!("set system_prompt: {e}"))?;
}
// Skip if agent_md already populated — respect prior state.
// Level-up is the right path to overwrite; not this helper.
if brain
.agent_md()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
return Ok(());
}
brain
.set_agent_md(&seed_md)
.map_err(|e| format!("set agent_md: {e}"))?;
brain
.commit(Some("brain_seed: initial seed from template role"))
.map_err(|e| format!("commit: {e}"))?;
Ok(())
})
.await
.map_err(|e| format!("brain seed task join: {e}"))?
}
+1
View File
@@ -1,6 +1,7 @@
//! REST API for Clawmates (spec §13). One route resource per module. //! REST API for Clawmates (spec §13). One route resource per module.
pub mod beszel; pub mod beszel;
pub mod brain_seed;
pub mod cleanup_sweeper; pub mod cleanup_sweeper;
mod error; mod error;
mod extract; mod extract;
+14 -5
View File
@@ -260,14 +260,23 @@ pub async fn mcp_skills(
); );
} }
}; };
// template + role for this agent — Slice 3.5d wires // Resolve template + role via agent_template_link so we
// agent_template_link; until then we can't merge template // can merge template default skills with per-agent
// defaults and only surface agent_skills_ext overrides. // overrides. Missing link (agent wasn't materialized from
// a template) → overrides-only, which is fine.
let link = cm_db::repo::agent_template_link::get(&state.pool, agent_id)
.await
.ok()
.flatten();
let (tpl_id, slot) = link
.as_ref()
.map(|l| (Some(l.template_id), Some(l.role_slot.as_str())))
.unwrap_or((None, None));
let bindings = match cm_db::repo::skills_catalog::effective_for_agent( let bindings = match cm_db::repo::skills_catalog::effective_for_agent(
&state.pool, &state.pool,
agent_id, agent_id,
None, tpl_id,
None, slot,
) )
.await .await
{ {
+47 -1
View File
@@ -147,8 +147,54 @@ async fn load_one(pool: &PgPool, path: &std::path::Path) -> Result<String, Strin
config: file.config.clone(), config: file.config.clone(),
roles, roles,
}; };
upsert_builtin(pool, builtin) let template_id = upsert_builtin(pool, builtin)
.await .await
.map_err(|e| format!("upsert {key}: {e}"))?; .map_err(|e| format!("upsert {key}: {e}"))?;
// Bind skills to template roles (Slice 3.5d): replace the entire
// set so a TOML edit that removes a skill from a role's list also
// removes the DB binding. Skills the loader can't find by name
// are skipped with a log — usually means the skill file hasn't
// been authored yet (or the name is a typo).
if let Err(e) = cm_db::repo::skills_catalog::clear_template_role_skills(pool, template_id).await
{
eprintln!("team_template_loader: clear_template_role_skills({key}) failed: {e}");
}
for role in &file.roles {
for (idx, skill_name) in role.skills.iter().enumerate() {
match cm_db::repo::skills_catalog::get_by_name(pool, None, skill_name).await {
Ok(Some(skill)) => {
// Pin the foundation + first-N-per-role skills for
// now; the finer policy (per-skill pin flag) lives
// in the skill's own metadata in a later slice.
let pin = idx < 2 || skill.tags.iter().any(|t| t == "foundation");
let bind = cm_db::repo::skills_catalog::AttachRoleSkill {
template_id,
slot: &role.slot,
skill_id: skill.id,
pin_in_context: pin,
order_idx: idx as i32,
};
if let Err(e) = cm_db::repo::skills_catalog::attach_role_skill(pool, bind).await
{
eprintln!(
"team_template_loader: attach skill {skill_name}{key}.{}: {e}",
role.slot
);
}
}
Ok(None) => {
eprintln!(
"team_template_loader: skill '{skill_name}' referenced by {key}.{} not found — skipped",
role.slot
);
}
Err(e) => {
eprintln!("team_template_loader: lookup skill '{skill_name}' failed: {e}");
}
}
}
}
Ok(key) Ok(key)
} }
@@ -0,0 +1,115 @@
//! Agent → team-template lineage. Slice 3.5d of the missions
//! consolidation. Populated by the team materialization path when a
//! new agent is created from a template role; read by:
//! - the `clawmates_skills` MCP server to resolve the caller's
//! template + role_slot so it can merge template default skills
//! with agent overrides
//! - level-up (Slice 8.5) to diff learned-vs-seeded knowledge and
//! route "promote to template" proposals
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use sqlx::Row;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTemplateLink {
pub agent_id: Uuid,
pub template_id: Uuid,
pub template_version: i32,
pub role_slot: String,
#[serde(with = "time::serde::rfc3339::option")]
pub seeded_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
/// Idempotent link insert. If the agent already has a link (rare —
/// only via manual overrides) we UPDATE the version/role rather than
/// error, since the intended template may have shifted.
pub async fn upsert(
pool: &PgPool,
agent_id: Uuid,
template_id: Uuid,
template_version: i32,
role_slot: &str,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO agent_template_link
(agent_id, template_id, template_version, role_slot)
VALUES ($1,$2,$3,$4)
ON CONFLICT (agent_id) DO UPDATE SET
template_id = EXCLUDED.template_id,
template_version = EXCLUDED.template_version,
role_slot = EXCLUDED.role_slot",
)
.bind(agent_id)
.bind(template_id)
.bind(template_version)
.bind(role_slot)
.execute(pool)
.await?;
Ok(())
}
pub async fn mark_seeded(pool: &PgPool, agent_id: Uuid) -> Result<(), DbError> {
sqlx::query(
"UPDATE agent_template_link SET seeded_at = now()
WHERE agent_id = $1 AND seeded_at IS NULL",
)
.bind(agent_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn get(pool: &PgPool, agent_id: Uuid) -> Result<Option<AgentTemplateLink>, DbError> {
let row = sqlx::query(
"SELECT agent_id, template_id, template_version, role_slot,
seeded_at, created_at
FROM agent_template_link WHERE agent_id = $1",
)
.bind(agent_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| AgentTemplateLink {
agent_id: r.get("agent_id"),
template_id: r.get("template_id"),
template_version: r.get("template_version"),
role_slot: r.get("role_slot"),
seeded_at: r.get("seeded_at"),
created_at: r.get("created_at"),
}))
}
/// All agents provisioned from a given template — used by level-up
/// when a template gains a new version and we want to prompt each
/// downstream agent for upgrade.
pub async fn agents_for_template(
pool: &PgPool,
template_id: Uuid,
) -> Result<Vec<AgentTemplateLink>, DbError> {
let rows = sqlx::query(
"SELECT agent_id, template_id, template_version, role_slot,
seeded_at, created_at
FROM agent_template_link WHERE template_id = $1
ORDER BY created_at",
)
.bind(template_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| AgentTemplateLink {
agent_id: r.get("agent_id"),
template_id: r.get("template_id"),
template_version: r.get("template_version"),
role_slot: r.get("role_slot"),
seeded_at: r.get("seeded_at"),
created_at: r.get("created_at"),
})
.collect())
}
+1
View File
@@ -1,4 +1,5 @@
pub mod agent_containers; pub mod agent_containers;
pub mod agent_template_link;
pub mod agents; pub mod agents;
pub mod audit; pub mod audit;
pub mod cleanup; pub mod cleanup;
+33
View File
@@ -0,0 +1,33 @@
-- Slice 3.5d — agent → team-template lineage.
--
-- When a team is minted from a template we record which template +
-- version + role_slot produced each agent. Level-up (Slice 8.5) reads
-- this to:
-- - diff the agent's brain against the seeded brain_seed
-- - route "add skill to my team's template" proposals to the right
-- template row + version
-- - offer template-upgrade prompts when the template ships a new
-- version and the agent is still on an older one
--
-- Also: adds a `first_seeded_at` timestamp on agents so the brain-seed
-- ingestion is idempotent — we skip re-seeding an agent whose brain
-- was already populated from a template.
CREATE TABLE agent_template_link (
agent_id UUID PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
template_id UUID NOT NULL REFERENCES team_templates(id) ON DELETE CASCADE,
template_version INT NOT NULL,
role_slot TEXT NOT NULL,
-- When we ingested brain_seed. NULL means the link was recorded
-- but seeding hasn't run yet (rare — recovery / retry path).
seeded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Ensures we can query "all agents on template X, version Y"
-- without a table scan when the level-up proposer runs.
FOREIGN KEY (template_id, role_slot)
REFERENCES template_roles(template_id, slot)
);
CREATE INDEX agent_template_link_template_idx
ON agent_template_link (template_id, template_version);
CREATE INDEX agent_template_link_role_idx
ON agent_template_link (template_id, role_slot);