slice 8.5: per-agent + per-team level-up endpoints
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 40s
ci / rust (push) Successful in 3m30s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m30s

Level-up analyzes an agent's brain + recent run outcomes (or a
whole team's aggregate state), calls Gemini 2.5 Flash for structured
JSON proposals, and persists them as pending level_up_proposals
rows. Reviewer approves a subset via /apply; the applier commits
only those items.

Migration 0052 adds level_up_proposals (id, workspace_id, agent_id
XOR team_id via CHECK constraint, status, payload JSONB,
applied_items[], model, created_by, approved_by, created_at,
applied_at) + workspace/pending/agent/team indexes.

Rust surface:
  - cm_db::repo::level_up::{insert, get, list_pending, mark_applied,
    mark_rejected}
  - cm_api::level_up::{propose_agent, propose_team, apply}
    Item kinds handled by apply():
      identity_refinement    → UPDATE agents.system_prompt
      skill_add              → agent_skills_ext INSERT
      skill_candidate        → workspace-scoped skills INSERT
                              (deterministic id per (workspace, name))
      brain_consolidation    → set_agent_md on the brain (unlike
                              brain_seed::ingest, this overwrites)
      roster_change / mcp_bundle_change — logged as
                              "not auto-applied, human runs
                              team-wizard" (structural changes need
                              human review of side effects).

API:
  - POST /api/claws/{id}/level-up   → { proposal_id }
  - POST /api/teams/{id}/level-up   → { proposal_id }
  - GET  /api/level-up-proposals    → pending list
  - GET  /api/level-up-proposals/{id}
  - POST /api/level-up-proposals/{id}/apply  { approved_item_ids }
  - POST /api/level-up-proposals/{id}/reject

Uses Gemini 2.5 Flash with response_mime_type: "application/json"
so the model returns structured JSON directly (no ```json fence
stripping needed). Configurable via CLAWMATES_LEVEL_UP_MODEL.

Follow-ups:
  - Frontend diff-review UI (pick items, approve/reject)
  - roster_change / mcp_bundle_change appliers (currently manual)
  - Anthropic + OpenAI proposer variants
  - Promote workspace-scoped skills to builtin via a curator flow

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 16:36:29 -07:00
co-authored by Claude Opus 4.7
parent 58963d5083
commit 9b5e63cbb7
7 changed files with 918 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
//! Level-up proposals — Slice 8.5.
//!
//! A proposal is a diff of "current state → suggested state" for an
//! agent or a team. Reviewers approve a subset of items; the applier
//! commits only those. All shape lives in `payload` JSONB — this
//! module is pure storage.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::Row;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelUpProposal {
pub id: Uuid,
pub workspace_id: Uuid,
pub agent_id: Option<Uuid>,
pub team_id: Option<Uuid>,
pub status: String,
pub payload: Value,
pub applied_items: Vec<String>,
pub model: Option<String>,
pub created_by: Option<Uuid>,
pub approved_by: Option<Uuid>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub applied_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone)]
pub struct NewProposal<'a> {
pub workspace_id: Uuid,
pub agent_id: Option<Uuid>,
pub team_id: Option<Uuid>,
pub payload: &'a Value,
pub model: Option<&'a str>,
pub created_by: Option<Uuid>,
}
pub async fn insert(pool: &PgPool, p: NewProposal<'_>) -> Result<Uuid, DbError> {
let id = Uuid::now_v7();
sqlx::query(
"INSERT INTO level_up_proposals
(id, workspace_id, agent_id, team_id, status, payload, model, created_by)
VALUES ($1,$2,$3,$4,'pending',$5,$6,$7)",
)
.bind(id)
.bind(p.workspace_id)
.bind(p.agent_id)
.bind(p.team_id)
.bind(p.payload)
.bind(p.model)
.bind(p.created_by)
.execute(pool)
.await?;
Ok(id)
}
pub async fn get(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
) -> Result<Option<LevelUpProposal>, DbError> {
let row = sqlx::query(
"SELECT id, workspace_id, agent_id, team_id, status, payload,
applied_items, model, created_by, approved_by,
created_at, applied_at
FROM level_up_proposals WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_proposal))
}
pub async fn list_pending(
pool: &PgPool,
workspace_id: Uuid,
) -> Result<Vec<LevelUpProposal>, DbError> {
let rows = sqlx::query(
"SELECT id, workspace_id, agent_id, team_id, status, payload,
applied_items, model, created_by, approved_by,
created_at, applied_at
FROM level_up_proposals
WHERE workspace_id = $1 AND status = 'pending'
ORDER BY created_at DESC",
)
.bind(workspace_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_proposal).collect())
}
pub async fn mark_applied(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
approved_by: Uuid,
applied_items: &[String],
partial: bool,
) -> Result<(), DbError> {
let status = if partial { "partial" } else { "applied" };
sqlx::query(
"UPDATE level_up_proposals
SET status = $3, applied_items = $4, approved_by = $5,
applied_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(status)
.bind(applied_items)
.bind(approved_by)
.execute(pool)
.await?;
Ok(())
}
pub async fn mark_rejected(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
approved_by: Uuid,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE level_up_proposals
SET status = 'rejected', approved_by = $3, applied_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(approved_by)
.execute(pool)
.await?;
Ok(())
}
fn row_to_proposal(r: sqlx::postgres::PgRow) -> LevelUpProposal {
LevelUpProposal {
id: r.get("id"),
workspace_id: r.get("workspace_id"),
agent_id: r.get("agent_id"),
team_id: r.get("team_id"),
status: r.get("status"),
payload: r.get("payload"),
applied_items: r.get("applied_items"),
model: r.get("model"),
created_by: r.get("created_by"),
approved_by: r.get("approved_by"),
created_at: r.get("created_at"),
applied_at: r.get("applied_at"),
}
}