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,548 @@
|
|||||||
|
//! 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'.
|
||||||
|
//!
|
||||||
|
//! Uses Gemini 2.5 Flash as the default proposer model — cheap,
|
||||||
|
//! JSON-mode-native, plenty of room for structured output. Configurable
|
||||||
|
//! via CLAWMATES_LEVEL_UP_MODEL.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_MODEL: &str = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
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,
|
||||||
|
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(
|
||||||
|
&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,
|
||||||
|
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(&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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
// 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);
|
||||||
|
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,1,$7)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
when_to_use = EXCLUDED.when_to_use,
|
||||||
|
tags = EXCLUDED.tags,
|
||||||
|
body = EXCLUDED.body,
|
||||||
|
updated_at = now()",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.bind(when_to_use)
|
||||||
|
.bind(&tags)
|
||||||
|
.bind(proposal.workspace_id)
|
||||||
|
.bind(body)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("upsert skill draft: {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(
|
||||||
|
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_gemini_json(system, &user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_llm_for_team(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_gemini_json(system, &user).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_gemini_json(system: &str, user: &str) -> Result<Value, String> {
|
||||||
|
let api_key =
|
||||||
|
std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?;
|
||||||
|
let model = model_name();
|
||||||
|
let url = format!(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
|
||||||
|
model, api_key
|
||||||
|
);
|
||||||
|
let body = json!({
|
||||||
|
"system_instruction": { "parts": [{ "text": system }] },
|
||||||
|
"contents": [{ "role": "user", "parts": [{ "text": user }] }],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 0.2,
|
||||||
|
"response_mime_type": "application/json",
|
||||||
|
"maxOutputTokens": 8192,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("http client: {e}"))?;
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("gemini call: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let code = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("gemini {code}: {}", &body[..body.len().min(500)]));
|
||||||
|
}
|
||||||
|
let json: Value = resp.json().await.map_err(|e| format!("gemini json: {e}"))?;
|
||||||
|
let text = json
|
||||||
|
.pointer("/candidates/0/content/parts/0/text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| "gemini response missing text".to_string())?;
|
||||||
|
serde_json::from_str(text).map_err(|e| format!("parse suggestion json: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod cleanup_sweeper;
|
|||||||
mod error;
|
mod error;
|
||||||
mod extract;
|
mod extract;
|
||||||
pub mod fleet;
|
pub mod fleet;
|
||||||
|
pub mod level_up;
|
||||||
mod mcp_door;
|
mod mcp_door;
|
||||||
mod mcp_skills;
|
mod mcp_skills;
|
||||||
pub mod mission_orchestrator;
|
pub mod mission_orchestrator;
|
||||||
@@ -446,6 +447,31 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/missions/{id}/security-scan",
|
"/api/missions/{id}/security-scan",
|
||||||
post(routes::missions::trigger_security_scan),
|
post(routes::missions::trigger_security_scan),
|
||||||
)
|
)
|
||||||
|
// Level-up (Slice 8.5)
|
||||||
|
.route(
|
||||||
|
"/api/level-up-proposals",
|
||||||
|
get(routes::level_up::list_pending),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/level-up-proposals/{id}",
|
||||||
|
get(routes::level_up::get_proposal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/level-up-proposals/{id}/apply",
|
||||||
|
post(routes::level_up::apply_proposal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/level-up-proposals/{id}/reject",
|
||||||
|
post(routes::level_up::reject_proposal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/claws/{id}/level-up",
|
||||||
|
post(routes::level_up::propose_for_agent),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/teams/{id}/level-up",
|
||||||
|
post(routes::level_up::propose_for_team),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/research",
|
"/api/research",
|
||||||
get(routes::research::list_topics).post(routes::research::create_topic),
|
get(routes::research::list_topics).post(routes::research::create_topic),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod files;
|
|||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
pub mod level_up;
|
||||||
pub mod loops;
|
pub mod loops;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
|
|||||||
@@ -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 files;
|
||||||
pub mod fleet_beszel;
|
pub mod fleet_beszel;
|
||||||
pub mod fleet_tailscale;
|
pub mod fleet_tailscale;
|
||||||
|
pub mod level_up;
|
||||||
pub mod loops;
|
pub mod loops;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- Slice 8.5 — level-up proposals.
|
||||||
|
--
|
||||||
|
-- A proposal is an LLM-generated diff of "current state → suggested
|
||||||
|
-- state" for either a specific agent or a whole team. Human approves
|
||||||
|
-- (all or per-item) via a diff-review UI; the applier commits the
|
||||||
|
-- approved subset.
|
||||||
|
--
|
||||||
|
-- Proposal payload (JSONB) shape — kept schemaless because proposal
|
||||||
|
-- kinds evolve. Documented shapes:
|
||||||
|
--
|
||||||
|
-- agent proposal:
|
||||||
|
-- {
|
||||||
|
-- "kind": "agent",
|
||||||
|
-- "current": { "system_prompt": "…", "skills": ["…"] },
|
||||||
|
-- "suggested_items": [
|
||||||
|
-- { "id": "u1", "kind": "brain_consolidation",
|
||||||
|
-- "rationale": "…", "brain_md_diff": "…" },
|
||||||
|
-- { "id": "u2", "kind": "identity_refinement",
|
||||||
|
--- "rationale": "…", "new_system_prompt": "…" },
|
||||||
|
-- { "id": "u3", "kind": "skill_add", "skill_id": "…",
|
||||||
|
-- "rationale": "…" },
|
||||||
|
-- { "id": "u4", "kind": "skill_candidate",
|
||||||
|
-- "rationale": "…", "draft": { "name": "…",
|
||||||
|
-- "description": "…",
|
||||||
|
-- "when_to_use": "…",
|
||||||
|
-- "body": "…",
|
||||||
|
-- "tags": ["…"] } }
|
||||||
|
-- ]
|
||||||
|
-- }
|
||||||
|
--
|
||||||
|
-- team proposal — same shape plus:
|
||||||
|
-- { "id": "u5", "kind": "roster_change",
|
||||||
|
-- "op": "add"|"drop"|"rename", "slot": "…", "rationale": "…" }
|
||||||
|
-- { "id": "u6", "kind": "mcp_bundle_change",
|
||||||
|
-- "op": "add"|"drop", "bundle": "…", "rationale": "…" }
|
||||||
|
--
|
||||||
|
-- `applied_items` is the array of `id`s that the reviewer approved;
|
||||||
|
-- the applier only touches those.
|
||||||
|
|
||||||
|
CREATE TABLE level_up_proposals (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
-- Exactly one of these is set; enforced by the CHECK below.
|
||||||
|
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
-- 'pending' | 'applied' | 'rejected' | 'partial' (applied a subset)
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
-- Full proposal JSONB (see header comment for shape).
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
-- Ids from payload.suggested_items[] that reviewer approved.
|
||||||
|
applied_items TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
-- Model used for the LLM analysis pass (audit trail).
|
||||||
|
model TEXT,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
approved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
applied_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT level_up_target_one CHECK (
|
||||||
|
(agent_id IS NOT NULL AND team_id IS NULL) OR
|
||||||
|
(agent_id IS NULL AND team_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX level_up_workspace_idx
|
||||||
|
ON level_up_proposals (workspace_id, created_at DESC);
|
||||||
|
CREATE INDEX level_up_pending_idx
|
||||||
|
ON level_up_proposals (status, created_at DESC)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
CREATE INDEX level_up_agent_idx
|
||||||
|
ON level_up_proposals (agent_id, created_at DESC)
|
||||||
|
WHERE agent_id IS NOT NULL;
|
||||||
|
CREATE INDEX level_up_team_idx
|
||||||
|
ON level_up_proposals (team_id, created_at DESC)
|
||||||
|
WHERE team_id IS NOT NULL;
|
||||||
Reference in New Issue
Block a user