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
+109
View File
@@ -0,0 +1,109 @@
//! `/api/level-up-proposals/*` + trigger endpoints — Slice 8.5.
use axum::extract::{Path, State};
use axum::response::Json;
use serde::Deserialize;
use uuid::Uuid;
use cm_db::repo::level_up::LevelUpProposal;
use crate::{ApiError, AppState, Authed};
pub async fn list_pending(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<LevelUpProposal>>, ApiError> {
let rows =
cm_db::repo::level_up::list_pending(&state.pool, user.workspace_id.as_uuid()).await?;
Ok(Json(rows))
}
pub async fn get_proposal(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<LevelUpProposal>, ApiError> {
let p = cm_db::repo::level_up::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(p))
}
/// POST /api/claws/{id}/level-up — LLM proposes agent improvements.
/// Returns the new proposal id; reviewer approves via the apply route.
pub async fn propose_for_agent(
State(state): State<AppState>,
Authed(user): Authed,
Path(agent_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let id = crate::level_up::propose_agent(&state.pool, user.workspace_id, user.user_id, agent_id)
.await
.map_err(|e| {
eprintln!("level_up: propose_agent {agent_id} failed: {e}");
ApiError::Internal
})?;
Ok(Json(serde_json::json!({ "proposal_id": id })))
}
/// POST /api/teams/{id}/level-up — LLM proposes team improvements.
pub async fn propose_for_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let id = crate::level_up::propose_team(&state.pool, user.workspace_id, user.user_id, team_id)
.await
.map_err(|e| {
eprintln!("level_up: propose_team {team_id} failed: {e}");
ApiError::Internal
})?;
Ok(Json(serde_json::json!({ "proposal_id": id })))
}
#[derive(Debug, Deserialize)]
pub struct ApplyRequest {
/// Ids from payload.suggested_items[] that the reviewer approved.
pub approved_item_ids: Vec<String>,
}
pub async fn apply_proposal(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<ApplyRequest>,
) -> Result<Json<LevelUpProposal>, ApiError> {
crate::level_up::apply(
&state.pool,
user.workspace_id,
user.user_id,
id,
&body.approved_item_ids,
)
.await
.map_err(|e| {
eprintln!("level_up::apply {id} failed: {e}");
ApiError::Internal
})?;
let p = cm_db::repo::level_up::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(p))
}
pub async fn reject_proposal(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<LevelUpProposal>, ApiError> {
cm_db::repo::level_up::mark_rejected(
&state.pool,
id,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
)
.await?;
let p = cm_db::repo::level_up::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(p))
}
+1
View File
@@ -12,6 +12,7 @@ pub mod files;
pub mod gateway;
pub mod health;
pub mod identity;
pub mod level_up;
pub mod loops;
pub mod missions;
pub mod nodes;