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:
Omar Sobh
2026-07-07 04:23:00 -07:00
parent 5e49086af7
commit b0acdfd987
2 changed files with 32 additions and 4 deletions
+14 -1
View File
@@ -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}