slice 8.5: per-agent + per-team level-up endpoints
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:
co-authored by
Claude Opus 4.7
parent
58963d5083
commit
9b5e63cbb7
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod credits;
|
||||
pub mod files;
|
||||
pub mod fleet_beszel;
|
||||
pub mod fleet_tailscale;
|
||||
pub mod level_up;
|
||||
pub mod loops;
|
||||
pub mod messages;
|
||||
pub mod missions;
|
||||
|
||||
Reference in New Issue
Block a user