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
+4
View File
@@ -365,6 +365,10 @@ pub fn router(state: AppState) -> Router {
"/api/teams/{id}/runtime-config", "/api/teams/{id}/runtime-config",
axum::routing::patch(routes::teams::set_runtime_config), axum::routing::patch(routes::teams::set_runtime_config),
) )
.route(
"/api/teams/auto-provision",
post(routes::teams::auto_provision),
)
.route( .route(
"/api/companies", "/api/companies",
get(routes::companies::list_companies).post(routes::companies::create_company), get(routes::companies::list_companies).post(routes::companies::create_company),
+227
View File
@@ -5,6 +5,7 @@ use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json; use axum::Json;
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus}; use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent};
use cm_topology::{build, TopologyKind}; use cm_topology::{build, TopologyKind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; 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,
}),
))
}
@@ -11,16 +11,18 @@ import { X } from "lucide-react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { import {
autoProvisionTeam,
createTopic, createTopic,
wizardRefine, wizardRefine,
wizardRepoEnsure, wizardRepoEnsure,
wizardRepoRelease, wizardRepoRelease,
type AutoProvisionResponse,
type OutcomeKind, type OutcomeKind,
type TopologyKind, type TopologyKind,
type WizardRepoReply, type WizardRepoReply,
} from "@/lib/api/research"; } from "@/lib/api/research";
import { RepoPicker, type PickedRepo } from "./RepoPicker"; import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -148,6 +150,38 @@ export function ResearchWizard({
const [ensureError, setEnsureError] = useState<string | null>(null); const [ensureError, setEnsureError] = useState<string | null>(null);
const [ensureReply, setEnsureReply] = useState<WizardRepoReply | null>(null); const [ensureReply, setEnsureReply] = useState<WizardRepoReply | null>(null);
const [committed, setCommitted] = useState(false); const [committed, setCommitted] = useState(false);
// Inline team auto-provisioning (default when the workspace roster is
// empty). LLM-derived roster + claws are materialized on step-5;
// response drops straight into the submit body's agents[].
const [autoProvisioning, setAutoProvisioning] = useState(false);
const [autoProvisionError, setAutoProvisionError] = useState<string | null>(null);
const [autoTeam, setAutoTeam] = useState<AutoProvisionResponse | null>(null);
async function runAutoProvision() {
setAutoProvisionError(null);
setAutoProvisioning(true);
try {
const r = await autoProvisionTeam({
title: title.trim() || prompt.trim().slice(0, 60),
description: description.trim() || prompt.trim(),
outcome_kind: outcome,
topology_kind: topology,
model: "claude-sonnet-5",
});
setAutoTeam(r);
// Adopt the topology the LLM chose (it may differ if we passed
// hub_spoke but the outcome_kind maps naturally to pipeline).
setTopology(r.topology_kind);
// Preselect the returned agents so submit() flows through the
// existing path without a second click.
setSelected(
r.agents.map((a) => ({ agent_id: a.agent_id, role_slot: a.role_slot })),
);
} catch (e) {
setAutoProvisionError(e instanceof Error ? e.message : "auto-provision failed");
} finally {
setAutoProvisioning(false);
}
}
async function maybeEnsureRepo(): Promise<boolean> { async function maybeEnsureRepo(): Promise<boolean> {
if (!repo) return true; if (!repo) return true;
@@ -248,7 +282,11 @@ export function ResearchWizard({
step === 2 || step === 2 ||
(step === 3 && title.trim().length > 0 && description.trim().length > 0) || (step === 3 && title.trim().length > 0 && description.trim().length > 0) ||
step === 4 || step === 4 ||
step === 5 || // step 5 (team): allow when EITHER auto-provision landed OR the
// user handpicked at least one agent from the workspace roster.
// Empty-workspace + no auto-team blocks Next so users don't hit
// step 6 with no team bound.
(step === 5 && (autoTeam !== null || selected.length > 0 || agents.length > 0)) ||
step === 6; step === 6;
return ( return (
@@ -318,10 +356,7 @@ export function ResearchWizard({
{/* Body */} {/* Body */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}> <div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{agents.length === 0 ? ( {step === 1 && (
<NoAgentsGate what="research topic" onDismiss={handleClose} />
) : null}
{agents.length > 0 && step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label htmlFor="topic-prompt" style={{ fontFamily: mono, fontSize: 11, color: "#b5b5bd" }}> <label htmlFor="topic-prompt" style={{ fontFamily: mono, fontSize: 11, color: "#b5b5bd" }}>
Topic prompt Topic prompt
@@ -341,7 +376,7 @@ export function ResearchWizard({
</div> </div>
)} )}
{agents.length > 0 && step === 2 && ( {step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Repository (optional)</span> <span style={labelStyle}>Repository (optional)</span>
<p style={hintStyle}> <p style={hintStyle}>
@@ -374,7 +409,7 @@ export function ResearchWizard({
</div> </div>
)} )}
{agents.length > 0 && step === 3 && ( {step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{!title && !refining ? ( {!title && !refining ? (
<button <button
@@ -430,7 +465,7 @@ export function ResearchWizard({
</div> </div>
)} )}
{agents.length > 0 && step === 4 && ( {step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Outcome kind</span> <span style={labelStyle}>Outcome kind</span>
@@ -540,10 +575,81 @@ export function ResearchWizard({
</div> </div>
)} )}
{agents.length > 0 && step === 5 && ( {step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Assign agents</span> <span style={labelStyle}>Team</span>
<p style={hintStyle}>Zero or more. Each optional role slot (&quot;lead&quot;, &quot;critic&quot;) groups avatars on the canvas.</p> {/* Auto-provision panel — always shown; the pick-existing
list beneath it is skipped when the workspace has no
claws yet. */}
<div
style={{
padding: 12,
borderRadius: 10,
border: `1px solid ${autoTeam ? "rgba(95,208,138,.35)" : "rgba(94,200,216,.35)"}`,
background: autoTeam ? "rgba(95,208,138,.06)" : "rgba(94,200,216,.06)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
{autoTeam
? `Team ready · ${autoTeam.agents.length} agents · ${autoTeam.topology_kind}`
: "Auto-provision a team from this topic"}
</div>
{!autoTeam && (
<p style={hintStyle}>
LLM derives the roster (roles + system prompts) from the topic + outcome kind,
materializes the claws, and stamps a runtime posture. Every agent runs on
<code> claude-sonnet-5</code>. You never leave this wizard.
</p>
)}
{autoTeam ? (
<ul style={{ margin: 0, paddingLeft: 18, color: "#cfcfd5", fontFamily: mono, fontSize: 12 }}>
{autoTeam.agents.map((a) => (
<li key={a.agent_id}>
<strong style={{ color: "#f3f3f5" }}>{a.name}</strong> · <em>{a.role_slot}</em>
</li>
))}
</ul>
) : (
<button
type="button"
onClick={runAutoProvision}
disabled={autoProvisioning || !title.trim() || !description.trim()}
style={{
alignSelf: "flex-start",
padding: "8px 14px",
borderRadius: 8,
background: autoProvisioning
? "rgba(94,200,216,.2)"
: "rgba(94,200,216,.15)",
border: "1px solid rgba(94,200,216,.5)",
color: "#e5f6fb",
cursor:
autoProvisioning || !title.trim() || !description.trim()
? "not-allowed"
: "pointer",
fontFamily: mono,
fontSize: 12,
opacity:
!title.trim() || !description.trim() ? 0.5 : 1,
}}
>
{autoProvisioning ? "Provisioning…" : "Auto-provision team"}
</button>
)}
{autoProvisionError && (
<p style={{ fontFamily: mono, fontSize: 11, color: "#ff8a7a" }}>
{autoProvisionError}
</p>
)}
</div>
{agents.length > 0 && !autoTeam ? (
<>
<div style={{ ...labelStyle, marginTop: 8 }}>Or pick from workspace roster</div>
<p style={hintStyle}>Zero or more. Each optional role slot (&quot;lead&quot;, &quot;critic&quot;) groups avatars on the canvas.</p>
{agents.length === 0 ? ( {agents.length === 0 ? (
<p style={hintStyle}>No agents in this workspace yet.</p> <p style={hintStyle}>No agents in this workspace yet.</p>
) : ( ) : (
@@ -597,6 +703,8 @@ export function ResearchWizard({
); );
}) })
)} )}
</>
) : null}
{submitError && ( {submitError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}> <p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
{submitError} {submitError}
@@ -605,7 +713,7 @@ export function ResearchWizard({
</div> </div>
)} )}
{agents.length > 0 && step === 6 && ( {step === 6 && (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}> <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<span style={labelStyle}>How should this research run?</span> <span style={labelStyle}>How should this research run?</span>
<p style={hintStyle}> <p style={hintStyle}>
@@ -737,7 +845,6 @@ export function ResearchWizard({
</div> </div>
{/* Footer */} {/* Footer */}
{agents.length > 0 && (
<div <div
style={{ style={{
padding: 14, padding: 14,
@@ -775,7 +882,6 @@ export function ResearchWizard({
</button> </button>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
); );
+29
View File
@@ -251,3 +251,32 @@ export interface ActiveRunsReply {
* Feeds the live-log panel; runs are ordered newest first. */ * Feeds the live-log panel; runs are ordered newest first. */
export const getActiveRuns = (topicId: string) => export const getActiveRuns = (topicId: string) =>
api<ActiveRunsReply>(`/api/research/${encodeURIComponent(topicId)}/active-runs`); api<ActiveRunsReply>(`/api/research/${encodeURIComponent(topicId)}/active-runs`);
export interface AutoProvisionedAgent {
agent_id: string;
name: string;
role_slot: string;
model: string;
}
export interface AutoProvisionResponse {
team_id: string;
topology_kind: TopologyKind;
agents: AutoProvisionedAgent[];
risk_profile?: string;
mcp_bundles: string[];
}
/** LLM-derive a full team + claws for a topic/loop. The response's
* agents[] shape drops straight into POST /api/research's `agents`
* field so the wizard can skip its handpick step entirely. */
export const autoProvisionTeam = (body: {
title: string;
description: string;
outcome_kind: OutcomeKind;
topology_kind?: TopologyKind;
model?: string;
}) =>
api<AutoProvisionResponse>("/api/teams/auto-provision", {
method: "POST",
body: JSON.stringify(body),
});