wizard: in-place auto-provision team from topic (LLM-derived, sonnet-5)
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:
@@ -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,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user