master planner: wire user-locked topology_kind through chat + scaffold
Frontend: send topologyKind to /api/planner/chat so the planner's user prompt gets a USER-LOCKED TOPOLOGY block telling Opus to use it verbatim. On buildTeam, override proposal.topology_kind with the user's pick (belt-and-braces — if the planner ignored the lock, we still ship the right shape). Proposal chip renders the effective kind in a lavender tint when it was overridden, with a hover title showing what was replaced. Backend PlannerChatRequest gains an optional topology_kind. Empty / absent = planner picks. Not honored for 'swarm' mode (swarm planner doesn't take a topology kind).
This commit is contained in:
@@ -54,6 +54,11 @@ pub struct PlannerChatRequest {
|
|||||||
/// Deploy mode: specialists | team | swarm | scheduled | triggered (default specialists).
|
/// Deploy mode: specialists | team | swarm | scheduled | triggered (default specialists).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
|
/// User-locked topology from the gallery. When set the planner is told to
|
||||||
|
/// use this kind verbatim (no rewrite). Ignored for `swarm` mode (the swarm
|
||||||
|
/// planner doesn't take a topology kind).
|
||||||
|
#[serde(default)]
|
||||||
|
pub topology_kind: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPECIALISTS_NOTE: &str = "\n\nMODE: Specialists. Propose a TIGHTLY-SCOPED group of 2 or 3 DOMAIN \
|
const SPECIALISTS_NOTE: &str = "\n\nMODE: Specialists. Propose a TIGHTLY-SCOPED group of 2 or 3 DOMAIN \
|
||||||
@@ -113,7 +118,15 @@ pub async fn planner_chat(
|
|||||||
let convo = body.messages.iter()
|
let convo = body.messages.iter()
|
||||||
.map(|m| format!("{}: {}", if m.role == "user" { "USER" } else { "PLANNER" }, m.content))
|
.map(|m| format!("{}: {}", if m.role == "user" { "USER" } else { "PLANNER" }, m.content))
|
||||||
.collect::<Vec<_>>().join("\n\n");
|
.collect::<Vec<_>>().join("\n\n");
|
||||||
let user_prompt = format!("{hierarchy}\n\n=== CONVERSATION ===\n{convo}\n\nRespond now (JSON only).");
|
let topology_lock = match body.topology_kind.as_deref() {
|
||||||
|
Some(k) if !k.is_empty() && body.mode != "swarm" => format!(
|
||||||
|
"\n\n=== USER-LOCKED TOPOLOGY ===\nThe user has selected topology_kind='{k}' from the gallery. \
|
||||||
|
Use this kind verbatim in the proposal; do NOT choose a different one. Tailor the roles + \
|
||||||
|
member count to work well within this shape."
|
||||||
|
),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let user_prompt = format!("{hierarchy}{topology_lock}\n\n=== CONVERSATION ===\n{convo}\n\nRespond now (JSON only).");
|
||||||
let system = planner_system_for(&body.mode);
|
let system = planner_system_for(&body.mode);
|
||||||
let raw = match runtime.complete(&system, &user_prompt, "claude-opus-4-8", 8000, true).await {
|
let raw = match runtime.complete(&system, &user_prompt, "claude-opus-4-8", 8000, true).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await readSse("/api/planner/chat", { mode: originMode, messages: next.map((m) => ({ role: m.role === "user" ? "user" : "assistant", content: m.content })) }, (e) => {
|
await readSse("/api/planner/chat", { mode: originMode, topology_kind: topologyKind ?? undefined, messages: next.map((m) => ({ role: m.role === "user" ? "user" : "assistant", content: m.content })) }, (e) => {
|
||||||
if (e.stage === "error") setError(String(e.label || "error"));
|
if (e.stage === "error") setError(String(e.label || "error"));
|
||||||
else if (e.stage === "done") {
|
else if (e.stage === "done") {
|
||||||
if (e.reply) routeMessages((m) => [...m, { role: "planner", content: String(e.reply) }]);
|
if (e.reply) routeMessages((m) => [...m, { role: "planner", content: String(e.reply) }]);
|
||||||
@@ -288,9 +288,14 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
async function buildTeam() {
|
async function buildTeam() {
|
||||||
if (!proposal || building) return;
|
if (!proposal || building) return;
|
||||||
|
// If the user picked a topology from the gallery, force it into the proposal
|
||||||
|
// — the planner is instructed to honor it but this is the belt-and-braces.
|
||||||
|
const effectiveProposal: Proposal = topologyKind && (mode === "team" || mode === "swarm")
|
||||||
|
? { ...proposal, topology_kind: topologyKind }
|
||||||
|
: proposal;
|
||||||
setBuilding(true); setError(null); setBuildProg({ pct: 2, label: "Starting…" });
|
setBuilding(true); setError(null); setBuildProg({ pct: 2, label: "Starting…" });
|
||||||
try {
|
try {
|
||||||
await readSse("/api/planner/scaffold", proposal, async (e) => {
|
await readSse("/api/planner/scaffold", effectiveProposal, async (e) => {
|
||||||
if (e.stage === "error") { setError(String(e.label || "build error")); setBuilding(false); }
|
if (e.stage === "error") { setError(String(e.label || "build error")); setBuilding(false); }
|
||||||
else if (e.stage === "done") {
|
else if (e.stage === "done") {
|
||||||
setBuildProg({ pct: 100, label: String(e.label || "Deployed") });
|
setBuildProg({ pct: 100, label: String(e.label || "Deployed") });
|
||||||
@@ -388,7 +393,17 @@ export function MasterPlannerModal({ onClose }: { onClose: () => void }) {
|
|||||||
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#c98af0", marginBottom: 6 }}>PROPOSED TEAM</div>
|
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#c98af0", marginBottom: 6 }}>PROPOSED TEAM</div>
|
||||||
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>{proposal.team_name}</div>
|
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>{proposal.team_name}</div>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}>
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}>
|
||||||
<span style={chip}>{proposal.topology_kind}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
...chip,
|
||||||
|
...(topologyKind && topologyKind !== proposal.topology_kind
|
||||||
|
? { background: "rgba(201,138,240,.18)", color: "#e6cff8" }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
title={topologyKind && topologyKind !== proposal.topology_kind ? `overridden from ${proposal.topology_kind}` : undefined}
|
||||||
|
>
|
||||||
|
{topologyKind && (mode === "team" || mode === "swarm") ? topologyKind : proposal.topology_kind}
|
||||||
|
</span>
|
||||||
<span style={chip}>{proposal.members.length} agents</span>
|
<span style={chip}>{proposal.members.length} agents</span>
|
||||||
{proposal.schedule?.cron ? <span style={{ ...chip, color: "#7fd0a0" }}>⏰ {proposal.schedule.cron}</span> : null}
|
{proposal.schedule?.cron ? <span style={{ ...chip, color: "#7fd0a0" }}>⏰ {proposal.schedule.cron}</span> : null}
|
||||||
{proposal.schedule?.one_shot_at ? <span style={{ ...chip, color: "#7fd0a0" }}>⏰ {proposal.schedule.one_shot_at}</span> : null}
|
{proposal.schedule?.one_shot_at ? <span style={{ ...chip, color: "#7fd0a0" }}>⏰ {proposal.schedule.one_shot_at}</span> : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user