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:
@@ -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),
|
||||||
|
|||||||
@@ -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 ("lead", "critic") 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 ("lead", "critic") 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user