wizard: in-place auto-provision team from topic (LLM-derived, sonnet-5)
ci / gates (push) Successful in 17s
ci / frontend (push) Successful in 36s
ci / rust (push) Successful in 4m17s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m9s

Step 5 of ResearchWizard was 'assign agents from workspace roster'.
When the roster was empty, the wizard body was hard-swapped for
NoAgentsGate — you couldn't reach step 5 at all.

Now step 5 shows a 'Team' panel:
- Big cyan card: 'Auto-provision team from this topic'. One click
  runs an LLM plan pass, gets 3-5 role slots + system prompts back,
  materializes claws via the existing build_team pipeline, stamps
  runtime posture, returns a shape that drops straight into the
  submit body's agents[]. Card flips green with the derived roster.
- Below that: the classic roster picker, but only when the workspace
  actually has ≥1 claw AND auto-provision hasn't landed. Otherwise
  hidden — no dead empty-state affordance.

Every gate that required agents.length > 0 to render the wizard body
or the footer is gone. canNext gains a step-5 clause: allow Next when
EITHER auto-team is ready OR the user handpicked from a non-empty
roster.

Backend
- POST /api/teams/auto-provision — accepts {title, description,
  outcome_kind, topology_kind?, model?, risk_profile?, mcp_bundles?}.
  Derives topology from outcome_kind (integrations → pipeline; else
  hub_spoke). LLM plan pass yields a JSON roster of 3-5 roles
  (role_slot, name, system_prompt). Materializes team + claws via
  build_team, stamps risk_profile (default research_web_readonly) +
  mcp_bundles (default [clawmates_door, gitea_forge]). Response
  carries team_id + agents[] in the shape /api/research already
  expects.
- Every provisioned claw runs on claude-sonnet-5 by default;
  overridable via the model field.

Follow-ups (not in this slice):
- Same picker in LoopsWizard (slice C — parallel change, same API).
- Post-create 'Team' section on ResearchCanvas / LoopsCanvas so
  users can rebind after the fact (slice D).
- Full Teams tier UI + Agents-page deprecation (slice E).
This commit is contained in:
Omar Sobh
2026-07-17 16:08:07 -07:00
parent 47a42423e3
commit 956be2cf4f
4 changed files with 381 additions and 15 deletions
+227
View File
@@ -5,6 +5,7 @@ use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
use cm_topology::{build, TopologyKind};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -516,3 +517,229 @@ pub async fn run_team(
}),
))
}
// ── auto-provision (0047 fold): LLM-derived team ─────────────────────
#[derive(Deserialize)]
pub struct AutoProvisionRequest {
/// Topic title (short — used to name the team).
pub title: String,
/// Topic description (drives the LLM roster derivation).
pub description: String,
/// Outcome kind (spec / prod_plan / roadmap / paper / integrations)
/// — steers the roster + topology.
pub outcome_kind: String,
/// Optional user hint for topology. When absent, derived from
/// outcome_kind (integrations → pipeline; everything else →
/// hub_spoke). Accepts the same strings as the wizard.
#[serde(default)]
pub topology_kind: Option<String>,
/// Optional preferred model for every provisioned agent. Falls back
/// to `claude-sonnet-5` when omitted. Kept overridable so the same
/// endpoint serves cost-conscious topics too.
#[serde(default)]
pub model: Option<String>,
/// Optional `["file_read","web_search",...]` risk-profile hint —
/// when omitted the endpoint picks by outcome_kind (research →
/// research_web_readonly; coding-adjacent → coding_readwrite).
#[serde(default)]
pub risk_profile: Option<String>,
/// MCP bundle aliases — same fall-back rule applies (always
/// clawmates_door; gitea_forge when a repo is bound; deep-research
/// skill for research profiles).
#[serde(default)]
pub mcp_bundles: Vec<String>,
}
#[derive(Serialize)]
pub struct AutoProvisionedAgent {
pub agent_id: String,
pub name: String,
pub role_slot: String,
pub model: String,
}
#[derive(Serialize)]
pub struct AutoProvisionResponse {
pub team_id: String,
pub topology_kind: String,
pub agents: Vec<AutoProvisionedAgent>,
/// Echo of the runtime-config applied to the team row (0045 fields).
#[serde(skip_serializing_if = "Option::is_none")]
pub risk_profile: Option<String>,
pub mcp_bundles: Vec<String>,
}
const AUTOPROVISION_SYSTEM: &str = "You are a team-composition assistant. \
Given a research/coding topic prompt + outcome kind, produce a compact roster of \
3 to 5 agents that could execute the pipeline end to end. For each agent output:\n\
- role_slot: short lowercase snake_case slot name (e.g. \"harvester\", \"tdd_implementer\"). \
Must be unique within the roster.\n\
- name: short human-friendly display name (1-2 words, ASCII, distinct per agent).\n\
- system_prompt: 2-4 sentence system prompt describing this agent's job in the \
topology. Should be actionable and reference the topic where relevant.\n\
Return a SINGLE JSON object with no code fences and no additional keys:\n\
{\"roles\": [ {\"role_slot\": \"...\", \"name\": \"...\", \"system_prompt\": \"...\"}, ... ]}\n\
Order matters: it dictates the pipeline / hub_spoke position. First role is the \
coordinator or first stage.";
/// `POST /api/teams/auto-provision` — LLM-derive a roster then materialize
/// a team + all claws + node→claw bindings, and stamp the runtime posture
/// (risk_profile + mcp_bundles) so it's ready for wizard step-5 to bind.
/// Returns the shape the wizard's `agents[]` submit expects, so the caller
/// can flow straight into `POST /api/research` (or `/api/loops`) with
/// `agents: response.agents` and no user-visible detour to the roster page.
pub async fn auto_provision(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<AutoProvisionRequest>,
) -> Result<(StatusCode, Json<AutoProvisionResponse>), ApiError> {
if body.title.trim().is_empty() || body.description.trim().is_empty() {
return Err(ApiError::BadRequest);
}
// Derive topology + defaults from outcome_kind if the caller didn't override.
let outcome_kind = body.outcome_kind.trim().to_string();
let topology_kind = body.topology_kind.clone().unwrap_or_else(|| {
if outcome_kind == "integrations" {
"pipeline".to_string()
} else {
"hub_spoke".to_string()
}
});
let model = body
.model
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "claude-sonnet-5".to_string());
// Auto-pick runtime posture when the caller didn't. Research topics
// default to the read-only + web-fetch profile so agents can fetch
// papers; coding-adjacent kinds don't apply here (loops.wizard picks
// them from a different path).
let risk_profile = body.risk_profile.clone().or_else(|| {
Some(match outcome_kind.as_str() {
"integrations" | "paper" | "spec" | "prod_plan" | "roadmap" => {
"research_web_readonly".to_string()
}
_ => "research_web_readonly".to_string(),
})
});
let mut mcp_bundles = body.mcp_bundles.clone();
if mcp_bundles.is_empty() {
mcp_bundles.push("clawmates_door".to_string());
// gitea_forge is scoped to teams that will touch repos; the
// wizard's downstream repo-binding step is what earns it.
// Always safe to add now — the MCP layer no-ops when the token
// isn't present in the container env.
mcp_bundles.push("gitea_forge".to_string());
}
// 1) LLM plan pass → roster JSON.
let user_message = format!(
"Outcome kind: {outcome_kind}\nTopology: {topology_kind}\n\nTopic title: {}\n\nTopic description:\n{}",
body.title.trim(),
body.description.trim(),
);
let request = ChatRequest {
system: AUTOPROVISION_SYSTEM.into(),
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::Text { text: user_message }],
}],
tools: Vec::new(),
model: state.runtime.model().to_string(),
max_tokens: 2048,
web_search: false,
};
let provider = state.runtime.provider();
let mut stream = provider
.stream(request)
.await
.map_err(|_| ApiError::Internal)?;
let mut buf = String::new();
use futures::StreamExt;
while let Some(event) = stream.next().await {
match event.map_err(|_| ApiError::Internal)? {
LlmEvent::TextDelta(delta) => buf.push_str(&delta),
LlmEvent::Stop(_) => break,
_ => {}
}
}
#[derive(Deserialize)]
struct DerivedRole {
role_slot: String,
name: String,
system_prompt: String,
}
#[derive(Deserialize)]
struct DerivedRoster {
roles: Vec<DerivedRole>,
}
let roster: DerivedRoster = serde_json::from_str(buf.trim()).map_err(|_| ApiError::Internal)?;
if roster.roles.is_empty() || roster.roles.len() > 8 {
return Err(ApiError::Internal);
}
// 2) Materialize the team + all claws via the existing build_team pipeline.
let members: Vec<TeamMemberInput> = roster
.roles
.iter()
.map(|r| TeamMemberInput {
role: r.role_slot.trim().to_string(),
name: r.name.trim().to_string(),
model: model.clone(),
system_prompt: r.system_prompt.trim().to_string(),
accent: String::new(),
})
.collect();
let team_name = format!("Auto · {}", body.title.trim());
let (team_id, claw_ids) = build_team(
&state,
user.workspace_id,
user.user_id,
&team_name,
&topology_kind,
&members,
)
.await?;
// 3) Stamp the runtime posture so the container spawn slice (3b) has
// the right risk_profile + bundles when it fires.
if let Err(e) = cm_db::repo::teams::set_team_runtime_config(
&state.pool,
team_id,
user.workspace_id,
&cm_db::repo::teams::TeamRuntimeConfig {
risk_profile: risk_profile.clone(),
mcp_bundles: mcp_bundles.clone(),
},
)
.await
{
eprintln!("auto_provision({team_id}): runtime-config write failed: {e:?}");
}
// 4) Shape the response for the wizard: agent_id + role_slot in the
// exact form the /api/research submit body expects.
let agents: Vec<AutoProvisionedAgent> = claw_ids
.iter()
.zip(members.iter())
.map(|(cid, m)| AutoProvisionedAgent {
agent_id: cid.to_string(),
name: m.name.clone(),
role_slot: m.role.clone(),
model: model.clone(),
})
.collect();
Ok((
StatusCode::CREATED,
Json(AutoProvisionResponse {
team_id: team_id.to_string(),
topology_kind,
agents,
risk_profile,
mcp_bundles,
}),
))
}