Files
clawmates/crates/cm-api/src/level_up.rs
T
Omar SobhandClaude Opus 5 769e002bb3 feat(skills): deliver on every tier, record what agents receive, let them self-author
Three phases of the approved plan, plus a correction to what the last one
claimed.

CORRECTION: skills reached ONE tier, not all of them

The previous commit said "skills can now reach a mission agent". That was
true only for the container/ZeroClaw tier — the fall-through that queues a
topology_runs row for topology_worker, which drives the executor that was
patched. compose_turn_prompt/pinned_skills_text had exactly one production
caller, and phase_runner's three other paths (composed microVM, solo
microVM, direct session) never called it. CAPABILITY-REVIEW.md said the
broad thing too; both are corrected.

Those three tiers share one task string and have no per-turn alias, so
their skills resolve per PHASE from the mission's crew and are appended
there. The container tier deliberately still injects per turn, with the
running node's own role — appending in both places would put every crew
member's skills in every turn twice.

The behavioural tests prove phase_skills_text and compose_turn_prompt work.
They cannot prove the three launch_* calls pass the composed string, and
that substitution is a one-word edit that would silently return all three
tiers to delivering nothing with every test still green. So there is also a
source-level assertion on the call sites, following the precedent in
mission_events::the_cap_is_enforced_in_one_statement. Its negative control
names the exact tier.

PROVENANCE: what an agent received, and what it said it did

Both were unanswerable. The prompt was never stored anywhere on any tier —
re-deriving it later re-runs the skill lookup against a catalogue that has
since changed, and once agents author their own skills it certainly will
have. The reasoning rows were durably write-only: pushed live once, then
never read from the database again by anything except the GC that deletes
them.

  - prompt.composed records the exact bytes, on all four tiers
  - the session tier writes its checkpoint record and a reasoning row,
    instead of eprintln! and nothing — the same defect the solo microVM
    path was fixed for, in the last tier that still had it
  - narrative_for_mission reads both back

Found while doing it: the 400-event per-phase cap counted EVERY kind, so a
busy phase could push out its own phase.completed and its own provenance.
The cap now counts only the two unbounded kinds it was written for.
Negative control confirms the old behaviour dropped the prompt.

Retention is now a per-mission hold (0080) rather than a raised global —
with a test asserting unheld missions are still reaped, because an
exemption that applies to everything is not an exemption.

SELF-AUTHORING: agents apply their own skill drafts, no human click

By operator decision. level_up has generated complete drafts from a model
since it shipped; only a checkbox stood between propose and apply.

What replaces the gate is not another gate but four properties, each held
by a test:

  - workspace-scoped, so a hand-authored skill can never be modified
  - a draft cannot take a hand-authored skill's name. Ids are scoped and
    bindings resolve by skill_id, so it could not overwrite or shadow one
    anyway — but two procedures under one name means nobody reading a
    transcript can tell which the agent followed, and that ambiguity is
    fatal in a system where the skill is the standard being graded against
  - every revision appends a skill_versions row, so it can be reverted and
    a past run can be read against the text it was actually judged under
  - approved_by = NULL. An agent's decision is never attributed to a person
    who did not make it

Only skill_candidate applies autonomously. identity_refinement and
brain_consolidation still wait for a human: they change what an agent IS
rather than adding a procedure it can consult. State is announced at boot,
because a safety gate that changes silently is one nobody notices changed.
CLAWMATES_SKILL_SELF_AUTHORING=0 restores it.

Also: the test Postgres ran out of /dev/shm mid-suite (Docker's 64MB
default) and surfaced it during MIGRATIONS, which reads like a schema fault
and is not one. --shm-size=1g, and a pointer to the `clean` subcommand that
already existed for the 779 leaked test databases.

Full workspace suite green: 106 binaries, no failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 10:24:35 -07:00

797 lines
28 KiB
Rust

//! Level-up proposer + applier — Slice 8.5.
//!
//! Two entry points:
//! propose_agent(agent_id) — reads agent's brain + last N runs,
//! asks the LLM to propose brain consolidation / identity
//! refinement / skill add / skill candidate items. Persists as
//! a pending `level_up_proposals` row.
//! propose_team(team_id) — aggregates each team agent's context +
//! recent mission outcomes; LLM proposes roster changes + MCP
//! bundle changes on top of per-role items.
//!
//! apply(proposal_id, approved_item_ids) commits only the ids the
//! reviewer picked. Rejected proposals move to status='rejected';
//! partial approvals move to status='partial'.
//!
//! The proposer model resolves through the provider REGISTRY
//! (`Runtime::resolve_provider`), the same path the evaluator uses, and defaults
//! to `glm:glm-4.7`. Configurable via `CLAWMATES_LEVEL_UP_MODEL` as a registry
//! spec (`glm:glm-4.7`, `kimi:k2`, `claude-sonnet-5`, …).
//!
//! It used to call Gemini directly over bespoke HTTP with `GEMINI_API_KEY`. Two
//! problems with that, one fatal: it was the only thing standing between this
//! feature and a dead prepayment balance, and it duplicated a provider client
//! the codebase already has. Going through the registry means every provider the
//! platform can already reach works here, and no single vendor's billing can
//! take the feature down.
use serde_json::{json, Value};
use sqlx::PgPool;
use sqlx::Row;
use uuid::Uuid;
/// Registry spec, not a bare model name — the registry needs the provider.
///
/// GLM: cheap, reliable at structured output, and already the validator this
/// project measured and chose (see `scripts/judge-eval.sh`).
const DEFAULT_MODEL: &str = "glm:glm-4.7";
fn model_name() -> String {
std::env::var("CLAWMATES_LEVEL_UP_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
/// Analyze an agent + insert a pending proposal. Returns the proposal id.
pub async fn propose_agent(
pool: &PgPool,
runtime: &cm_runtime::Runtime,
workspace_id: cm_domain::WorkspaceId,
created_by: cm_domain::UserId,
agent_id: Uuid,
) -> Result<Uuid, String> {
let agent = cm_db::repo::agents::get(pool, cm_domain::AgentId::from(agent_id))
.await
.map_err(|e| format!("load agent: {e}"))?;
if agent.workspace_id != workspace_id {
return Err("agent not in workspace".into());
}
let brain_summary = load_brain_summary(agent_id).await;
let recent_runs = recent_run_summary(pool, agent_id, 10).await?;
let link = cm_db::repo::agent_template_link::get(pool, agent_id)
.await
.ok()
.flatten();
let payload = call_llm_for_agent(
runtime,
&agent.name,
&agent.job_title,
&agent.system_prompt,
&brain_summary,
&recent_runs,
link.as_ref(),
)
.await?;
let model = model_name();
let id = cm_db::repo::level_up::insert(
pool,
cm_db::repo::level_up::NewProposal {
workspace_id: workspace_id.as_uuid(),
agent_id: Some(agent_id),
team_id: None,
payload: &payload,
model: Some(&model),
created_by: Some(created_by.as_uuid()),
},
)
.await
.map_err(|e| format!("insert proposal: {e}"))?;
Ok(id)
}
/// Analyze a team + insert a pending proposal. Returns the proposal id.
pub async fn propose_team(
pool: &PgPool,
runtime: &cm_runtime::Runtime,
workspace_id: cm_domain::WorkspaceId,
created_by: cm_domain::UserId,
team_id: Uuid,
) -> Result<Uuid, String> {
let members = sqlx::query(
"SELECT a.id, a.name, a.job_title, a.system_prompt, m.role_slot
FROM team_members m
JOIN agents a ON a.id = m.claw_id
WHERE m.team_id = $1
ORDER BY m.role_slot",
)
.bind(team_id)
.fetch_all(pool)
.await
.map_err(|e| format!("load members: {e}"))?;
if members.is_empty() {
return Err("team has no members".into());
}
let mut member_summaries: Vec<Value> = Vec::new();
for r in &members {
let id: Uuid = r.get("id");
let name: String = r.get("name");
let role: String = r.get("role_slot");
let brain = load_brain_summary(id).await;
let runs = recent_run_summary(pool, id, 3).await.unwrap_or_default();
member_summaries.push(json!({
"agent_id": id,
"name": name,
"role_slot": role,
"brain": brain,
"recent_runs": runs,
}));
}
let payload = call_llm_for_team(runtime, &member_summaries).await?;
let model = model_name();
let id = cm_db::repo::level_up::insert(
pool,
cm_db::repo::level_up::NewProposal {
workspace_id: workspace_id.as_uuid(),
agent_id: None,
team_id: Some(team_id),
payload: &payload,
model: Some(&model),
created_by: Some(created_by.as_uuid()),
},
)
.await
.map_err(|e| format!("insert team proposal: {e}"))?;
Ok(id)
}
/// Apply the reviewer-approved subset of a proposal. Item kinds
/// (from payload.suggested_items[].kind) each map to a small applier:
/// identity_refinement → agents.set_system_prompt (via patch)
/// skill_add → agent_skills_ext INSERT
/// skill_candidate → skills_catalog::upsert workspace-scoped
/// brain_consolidation → set_agent_md on the brain
/// roster_change → not automated in this slice (logs a
/// reminder — human runs the team-wizard)
/// mcp_bundle_change → not automated in this slice (same)
pub async fn apply(
pool: &PgPool,
workspace_id: cm_domain::WorkspaceId,
approved_by: cm_domain::UserId,
proposal_id: Uuid,
approved_item_ids: &[String],
) -> Result<(), String> {
let proposal = cm_db::repo::level_up::get(pool, proposal_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load proposal: {e}"))?
.ok_or_else(|| "proposal not found".to_string())?;
if proposal.status != "pending" {
return Err(format!("proposal already {}", proposal.status));
}
let items = proposal
.payload
.get("suggested_items")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut actually_applied: Vec<String> = Vec::new();
for item in items {
let Some(item_id) = item.get("id").and_then(|v| v.as_str()) else {
continue;
};
if !approved_item_ids.iter().any(|s| s == item_id) {
continue;
}
let kind = item.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let applied = match kind {
"identity_refinement" => apply_identity(pool, &proposal, &item).await,
"skill_add" => apply_skill_add(pool, &proposal, &item).await,
"skill_candidate" => apply_skill_candidate(pool, &proposal, &item).await,
"brain_consolidation" => apply_brain_consolidation(&proposal, &item).await,
"roster_change" | "mcp_bundle_change" => {
eprintln!(
"level_up: {kind} item {item_id} — not auto-applied, human runs team-wizard"
);
Ok(())
}
other => {
eprintln!("level_up: unknown item kind `{other}` — skipping {item_id}");
Ok(())
}
};
match applied {
Ok(()) => actually_applied.push(item_id.to_string()),
Err(e) => eprintln!("level_up: apply {item_id} failed: {e}"),
}
}
let partial = actually_applied.len() != approved_item_ids.len();
cm_db::repo::level_up::mark_applied(
pool,
proposal_id,
workspace_id.as_uuid(),
approved_by.as_uuid(),
&actually_applied,
partial,
)
.await
.map_err(|e| format!("mark applied: {e}"))?;
Ok(())
}
/// Is autonomous skill authoring on?
///
/// Default ON, by operator decision. Stated at boot rather than assumed: this
/// flips a human approval gate that has existed since the feature shipped, and
/// a safety gate that changes state silently is how nobody notices it changed.
pub fn self_authoring_enabled() -> bool {
!matches!(
std::env::var("CLAWMATES_SKILL_SELF_AUTHORING")
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str(),
"0" | "off" | "false"
)
}
/// Apply a pending proposal's `skill_candidate` items with no human decision.
///
/// ONLY `skill_candidate`. The other item kinds are deliberately left to the
/// human gate: `identity_refinement` rewrites an agent's system prompt and
/// `brain_consolidation` edits its memory, and both change what the agent IS
/// rather than adding a procedure it can consult. Self-authoring a skill is
/// recoverable — the row is workspace-scoped, versioned and revertible, and
/// cannot take a hand-authored name. Rewriting an identity autonomously is not
/// the same bet, and it is not the one that was asked for.
///
/// The remaining items stay pending, so a human still sees them.
pub async fn apply_autonomous(
pool: &PgPool,
workspace_id: cm_domain::WorkspaceId,
proposal_id: Uuid,
) -> Result<Vec<String>, String> {
let proposal = cm_db::repo::level_up::get(pool, proposal_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load proposal: {e}"))?
.ok_or_else(|| "proposal not found".to_string())?;
if proposal.status != "pending" {
return Err(format!("proposal already {}", proposal.status));
}
let items = proposal
.payload
.get("suggested_items")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut applied: Vec<String> = Vec::new();
let mut candidates = 0usize;
for item in items {
let Some(item_id) = item.get("id").and_then(|v| v.as_str()) else {
continue;
};
if item.get("kind").and_then(|v| v.as_str()) != Some("skill_candidate") {
continue;
}
candidates += 1;
match apply_skill_candidate(pool, &proposal, &item).await {
Ok(()) => applied.push(item_id.to_string()),
// A refused draft is a normal outcome (a name collision with a
// hand-authored skill is the common one), not a failure of the
// sweep. Said out loud so a refusal is never mistaken for the
// agent simply not having proposed anything.
Err(e) => eprintln!(
"level_up: autonomous apply refused {item_id} for workspace {}: {e}",
workspace_id.as_uuid()
),
}
}
if candidates == 0 {
return Ok(Vec::new());
}
cm_db::repo::level_up::mark_applied_autonomously(
pool,
proposal_id,
workspace_id.as_uuid(),
&applied,
applied.len() != candidates,
)
.await
.map_err(|e| format!("mark applied: {e}"))?;
Ok(applied)
}
// ── Appliers ───────────────────────────────────────────────────
async fn apply_identity(
pool: &PgPool,
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let Some(agent_id) = proposal.agent_id else {
return Err("identity_refinement requires an agent proposal".into());
};
let Some(new_prompt) = item.get("new_system_prompt").and_then(|v| v.as_str()) else {
return Err("missing new_system_prompt".into());
};
sqlx::query("UPDATE agents SET system_prompt = $1 WHERE id = $2")
.bind(new_prompt)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("update agent: {e}"))?;
Ok(())
}
async fn apply_skill_add(
pool: &PgPool,
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let Some(agent_id) = proposal.agent_id else {
return Err("skill_add on team proposal — use per-agent override".into());
};
let Some(skill_id_str) = item.get("skill_id").and_then(|v| v.as_str()) else {
return Err("missing skill_id".into());
};
let skill_id = Uuid::parse_str(skill_id_str).map_err(|e| format!("parse skill_id: {e}"))?;
let pin = item
.get("pin_in_context")
.and_then(|v| v.as_bool())
.unwrap_or(false);
cm_db::repo::skills_catalog::set_agent_skill(
pool,
agent_id,
skill_id,
true,
pin,
proposal.approved_by,
item.get("rationale").and_then(|v| v.as_str()),
)
.await
.map_err(|e| format!("set agent skill: {e}"))?;
Ok(())
}
async fn apply_skill_candidate(
pool: &PgPool,
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let draft = item
.get("draft")
.ok_or_else(|| "missing draft".to_string())?;
let name = draft
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "draft.name missing".to_string())?;
let description = draft
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("(no description)");
let body = draft.get("body").and_then(|v| v.as_str()).unwrap_or("");
let when_to_use = draft.get("when_to_use").and_then(|v| v.as_str());
let tags: Vec<String> = draft
.get("tags")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
// A draft may never take the name of a hand-authored skill.
//
// The row itself is safe — ids are workspace-scoped, so this cannot
// overwrite a builtin, and bindings resolve by skill_id rather than name,
// so it cannot shadow one either. What it CAN do is put two different
// procedures under one name in the same agent's bundle, and then nobody
// reading a transcript can tell which one the agent followed. That
// ambiguity is the whole problem in a system where the skill is the
// standard the behaviour is graded against.
let collides: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM skills WHERE name = $1 AND workspace_id IS NULL",
)
.bind(name)
.fetch_optional(pool)
.await
.map_err(|e| format!("check builtin collision: {e}"))?;
if collides.is_some() {
return Err(format!(
"skill name {name:?} is hand-authored — an agent-authored draft \
cannot take the name of a skill it is graded against"
));
}
// Workspace-scoped custom skill. Deterministic id per
// (workspace, name) so re-approving the same draft updates in
// place rather than duplicating.
let id = workspace_skill_id(proposal.workspace_id, name);
// Versioned, for the same reason builtins are: a self-authored skill that
// silently replaces its own body has no undo, and the version a run was
// judged under is the only way to read that run back honestly later.
let mut tx = pool.begin().await.map_err(|e| format!("begin: {e}"))?;
let existing: Option<(i32, String)> =
sqlx::query_as("SELECT current_version, body FROM skills WHERE id = $1")
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| format!("read current skill: {e}"))?;
let (next_version, bump) = match &existing {
Some((v, prev)) if prev == body => (*v, false),
Some((v, _)) => (v + 1, true),
None => (1, true),
};
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,'level_up',$3,$4,$5,'promoted_from_brain',$6,$8,$7)
ON CONFLICT (id) DO UPDATE SET
description = EXCLUDED.description,
when_to_use = EXCLUDED.when_to_use,
tags = EXCLUDED.tags,
body = EXCLUDED.body,
current_version = EXCLUDED.current_version,
updated_at = now()",
)
.bind(id)
.bind(name)
.bind(description)
.bind(when_to_use)
.bind(&tags)
.bind(proposal.workspace_id)
.bind(body)
.bind(next_version)
.execute(&mut *tx)
.await
.map_err(|e| format!("upsert skill draft: {e}"))?;
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(id)
.bind(next_version)
.bind(body)
.bind(description)
.bind(when_to_use)
.execute(&mut *tx)
.await
.map_err(|e| format!("record skill version: {e}"))?;
}
tx.commit().await.map_err(|e| format!("commit: {e}"))?;
Ok(())
}
async fn apply_brain_consolidation(
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let Some(agent_id) = proposal.agent_id else {
return Err("brain_consolidation requires agent proposal".into());
};
let Some(new_agent_md) = item.get("brain_md_diff").and_then(|v| v.as_str()) else {
return Err("missing brain_md_diff".into());
};
// Overwrites agent_md — level-up is the sanctioned path, unlike
// brain_seed::ingest which skips if agent_md is populated.
let agent_id_owned = agent_id;
let md_owned = new_agent_md.to_string();
tokio::task::spawn_blocking(move || -> Result<(), String> {
use cm_brain::ClawBrain;
let dir = std::env::var("CLAWMATES_BRAIN_DIR")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/data/brains"));
let path = dir.join(format!("claw_{agent_id_owned}.h5"));
let mut brain = ClawBrain::open_or_create(&path, &agent_id_owned.to_string())
.map_err(|e| format!("open brain: {e}"))?;
brain
.set_agent_md(&md_owned)
.map_err(|e| format!("set agent_md: {e}"))?;
brain
.commit(Some("level_up: brain consolidation"))
.map_err(|e| format!("commit: {e}"))?;
Ok(())
})
.await
.map_err(|e| format!("brain task join: {e}"))?
}
// ── Helpers ────────────────────────────────────────────────────
async fn load_brain_summary(agent_id: Uuid) -> Value {
tokio::task::spawn_blocking(move || {
use cm_brain::ClawBrain;
let dir = std::env::var("CLAWMATES_BRAIN_DIR")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/data/brains"));
let path = dir.join(format!("claw_{agent_id}.h5"));
let Ok(brain) = ClawBrain::open_or_create(&path, &agent_id.to_string()) else {
return json!({ "available": false });
};
let agent_md = brain.agent_md().unwrap_or_default();
let skills: Vec<Value> = brain
.skills()
.into_iter()
.map(|(n, b)| json!({ "name": n, "body_excerpt": excerpt(&b, 400) }))
.collect();
json!({
"available": true,
"agent_md_excerpt": excerpt(&agent_md, 2000),
"agent_md_bytes": agent_md.len(),
"skills": skills,
})
})
.await
.unwrap_or_else(|_| json!({ "available": false }))
}
async fn recent_run_summary(pool: &PgPool, agent_id: Uuid, limit: i64) -> Result<Value, String> {
// Runs the agent participated in — via team_members.claw_id +
// team_id + mission_id. Cheap best-effort join; missing → empty.
let rows = sqlx::query(
"SELECT tr.id, tr.status, tr.error, tr.created_at
FROM topology_runs tr
WHERE tr.mission_id IN (
SELECT m.id FROM missions m
JOIN team_members tm ON tm.team_id = m.team_id
WHERE tm.claw_id = $1
)
ORDER BY tr.created_at DESC LIMIT $2",
)
.bind(agent_id)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| format!("load runs: {e}"))?;
let items: Vec<Value> = rows
.into_iter()
.map(|r| {
let id: Uuid = r.get("id");
let status: String = r.get("status");
let err: Option<String> = r.get("error");
json!({
"run_id": id,
"status": status,
"error": err,
})
})
.collect();
Ok(json!(items))
}
async fn call_llm_for_agent(
runtime: &cm_runtime::Runtime,
name: &str,
role: &str,
system_prompt: &str,
brain: &Value,
runs: &Value,
link: Option<&cm_db::repo::agent_template_link::AgentTemplateLink>,
) -> Result<Value, String> {
let template_note = link
.map(|l| {
format!(
"Template lineage: template {} v{} role {}.",
l.template_id, l.template_version, l.role_slot
)
})
.unwrap_or_else(|| "No template lineage (LLM-derived or manual).".to_string());
let system = r#"You review an AI agent's history and propose targeted improvements.
Return ONLY JSON matching this schema:
{
"kind": "agent",
"current": { "system_prompt": "<current>", "skills": [] },
"suggested_items": [
// 0..5 items, each with `id`, `kind`, `rationale`.
// kind ∈ {identity_refinement, skill_candidate, skill_add, brain_consolidation}
// identity_refinement: extra `new_system_prompt`
// skill_candidate: extra `draft: { name, description, when_to_use, body, tags[] }`
// skill_add: extra `skill_id`
// brain_consolidation: extra `brain_md_diff` (full replacement text)
]
}
Propose changes only when there's evidence — a clear pattern in the
brain or a failure in recent runs. Do not propose changes purely for
the sake of proposing."#;
let user = json!({
"name": name,
"role_slot": role,
"system_prompt": system_prompt,
"brain": brain,
"recent_runs": runs,
"template_lineage_note": template_note,
})
.to_string();
call_llm_json(runtime, system, &user).await
}
async fn call_llm_for_team(
runtime: &cm_runtime::Runtime,
members: &[Value],
) -> Result<Value, String> {
let system = r#"You review an AI team's roster + recent history and propose
targeted improvements. Return ONLY JSON:
{
"kind": "team",
"suggested_items": [
// 0..8 items. Same shapes as agent, plus:
// roster_change: { op: "add"|"drop"|"rename", slot, rationale }
// mcp_bundle_change:{ op: "add"|"drop", bundle, rationale }
// Per-agent items should carry an extra `agent_id` field.
]
}
Prefer removing unused roles over adding new ones. Prefer tightening
prompts over adding skills. Only add skills when a clear
"the team keeps getting stuck on <X>" pattern appears."#;
let user = json!({ "members": members }).to_string();
call_llm_json(runtime, system, &user).await
}
/// Ask the configured proposer model for one JSON object.
///
/// Goes through the provider registry rather than a vendor's HTTP API, so any
/// model the platform can already reach works and no single vendor's billing can
/// take level-up down.
///
/// The JSON is extracted rather than assumed: an anthropic-format model is not
/// bound by Gemini's `response_mime_type: application/json`, and will happily
/// wrap an object in prose or a ```json fence. Parsing the raw reply worked
/// against Gemini and would fail on everything else.
async fn call_llm_json(
runtime: &cm_runtime::Runtime,
system: &str,
user: &str,
) -> Result<Value, String> {
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
use futures::StreamExt as _;
let spec = model_name();
let (provider, model) = runtime.resolve_provider(&spec);
let request = ChatRequest {
system: system.to_string(),
model: model.to_string(),
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text(user)],
}],
tools: vec![],
max_tokens: 8192,
web_search: false,
};
let mut stream = provider
.stream(request)
.await
.map_err(|e| format!("level-up call ({spec}): {e}"))?;
let mut text = String::new();
while let Some(event) = stream.next().await {
match event {
Ok(LlmEvent::TextDelta(t)) => text.push_str(&t),
Ok(_) => {}
Err(e) => return Err(format!("level-up stream ({spec}): {e}")),
}
}
let body = extract_json_object(&text)
.ok_or_else(|| format!("no JSON object in {spec} reply: {}", excerpt(&text, 300)))?;
serde_json::from_str(body).map_err(|e| format!("parse suggestion json: {e}"))
}
/// The outermost `{...}` in a reply, so a fenced or prose-wrapped object parses.
///
/// Brace-counting rather than a regex: a nested object would end a lazy match at
/// the first inner `}`, and these proposals are nested by design (items carry
/// per-role objects).
fn extract_json_object(text: &str) -> Option<&str> {
let start = text.find('{')?;
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (i, c) in text[start..].char_indices() {
if in_string {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&text[start..start + i + 1]);
}
}
_ => {}
}
}
None
}
fn excerpt(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
}
}
fn workspace_skill_id(workspace_id: Uuid, name: &str) -> Uuid {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(b"clawmates.workspace.skill\x00");
h.update(workspace_id.as_bytes());
h.update(name.as_bytes());
let d = h.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&d[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Uuid::from_bytes(bytes)
}
#[cfg(test)]
mod tests {
/// Gemini was asked for `response_mime_type: application/json` and obliged.
/// Anthropic-format models are under no such obligation and routinely wrap
/// the object in prose or a fenced block, so the reply is EXTRACTED, not
/// assumed. Parsing the raw text worked against Gemini and would fail
/// everywhere else — exactly the shape of bug a provider swap hides until
/// the first real proposal.
#[test]
fn a_json_object_is_extracted_from_however_the_model_wrapped_it() {
let bare = r#"{"items":[]}"#;
assert_eq!(super::extract_json_object(bare), Some(bare));
let fenced = "Here is my proposal:\n```json\n{\"items\":[1]}\n```\nDone.";
assert_eq!(super::extract_json_object(fenced), Some(r#"{"items":[1]}"#));
// Nested objects: a lazy match would stop at the first inner brace and
// hand back invalid JSON. These proposals are nested by design.
let nested = r#"prose {"a":{"b":{"c":1}},"d":2} trailing"#;
assert_eq!(
super::extract_json_object(nested),
Some(r#"{"a":{"b":{"c":1}},"d":2}"#)
);
// A brace inside a string must not close the object.
let stringy = r#"{"note":"an unmatched } here","ok":true}"#;
assert_eq!(super::extract_json_object(stringy), Some(stringy));
assert_eq!(super::extract_json_object("no object here"), None);
}
/// The default must not be a vendor whose billing already took a feature
/// down. It is a REGISTRY SPEC (`provider:model`), not a bare model name —
/// `resolve_provider` needs the provider half.
#[test]
fn the_default_proposer_is_a_registry_spec_and_not_gemini() {
assert!(super::DEFAULT_MODEL.contains(':'), "{}", super::DEFAULT_MODEL);
assert!(!super::DEFAULT_MODEL.contains("gemini"), "{}", super::DEFAULT_MODEL);
}
}