slice 3.5d: agent_template_link + brain seed helper + skills merge
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:
co-authored by
Claude Opus 4.7
parent
520a717dec
commit
85a97dffca
@@ -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
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user