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
@@ -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_template_link;
pub mod agents;
pub mod audit;
pub mod cleanup;