Files
2026-07-13 12:20:32 +08:00

257 lines
8.6 KiB
JavaScript

const { workspace } = require("../workspace");
const { broadcast } = require("../broadcast");
const { launchPiProcess, writeTrace } = require("../pi/process");
// ─── Plan item helpers ─────────────────────────────────────────────────────
function normalizePlanItems(items) {
return (Array.isArray(items) ? items : [])
.map((item) => String(item || "").trim())
.filter(Boolean)
.slice(0, 12);
}
function normalizePlanPayload(payload, fallback = {}) {
const items = normalizePlanItems(payload?.items);
return {
title: String(payload?.title || fallback.title || "Execution Plan").trim(),
reason: String(payload?.reason || fallback.reason || "").trim(),
items: items.length > 0 ? items : normalizePlanItems(fallback.items),
questions: normalizePlanItems(
payload?.questions || fallback.questions,
).slice(0, 5),
};
}
// ─── Prompt builders ───────────────────────────────────────────────────────
function buildPlanningPrompt(prompt, context = {}) {
const previous = normalizePlanItems(context.previousItems);
const feedback = String(context.feedback || "").trim();
const answers =
context.answers && typeof context.answers === "object"
? JSON.stringify(context.answers, null, 2)
: "";
return (
`Create a concise, editable execution plan for this user task. Call propose_plan exactly once.\n\n` +
`Requirements:\n` +
`- Return 3-8 concrete plan items.\n` +
`- Include only questions that materially affect the output or workflow.\n` +
`- Keep each item short enough for a user to edit in a list.\n` +
`- Do not perform the task. Do not browse, use desktop apps, write files, or call any other tool.\n\n` +
(previous.length ? `Previous rejected plan:\n${previous.map((item) => `- ${item}`).join("\n")}\n` : "") +
(feedback ? `User rejection feedback:\n${feedback}\n` : "") +
(answers ? `User answers/context:\n${answers}\n` : "") +
`User task:\n${prompt}`
);
}
function promptWithApprovedPlan(prompt, items, answers = {}) {
const planText = normalizePlanItems(items)
.map((item) => `- ${item}`)
.join("\n");
const answerText =
answers && Object.keys(answers).length > 0
? `\nUser answers to planning questions:\n${JSON.stringify(answers, null, 2)}\n`
: "";
return (
`Approved execution plan:\n${planText || "- Execute the user task carefully."}\n` +
`${answerText}` +
`Follow this approved plan. If a later blocker or ambiguity changes the output or workflow, ` +
`use ask_user before proceeding. Otherwise execute without asking for routine operations.\n\n` +
`User task:\n${prompt}`
);
}
// promptWithPreflightGuidance is used when plan_first mode is chosen but we
// skip the full plan-review cycle and just give the agent hints inline.
function promptWithPreflightGuidance(prompt, decision) {
if (decision.mode !== "plan_first") return prompt;
const hints = (Array.isArray(decision.plan_hint) ? decision.plan_hint : [])
.map((h) => String(h || "").trim())
.filter(Boolean)
.slice(0, 6);
const hintText = hints.length
? hints.map((hint) => `- ${hint}`).join("\n")
: "- Make a brief checklist before acting.\n- Verify real content before producing outputs.";
return (
`Preflight execution guidance:\n` +
`Before acting, write a brief visible plan for the user. Your first assistant response must ` +
`contain only the plan: do not call any tools in the same turn as the plan. After that, ` +
`execute the plan step by step. If the request is ambiguous in a way that changes the output ` +
`or workflow, use ask_user before proceeding. Do not ask for routine operations; choose ` +
`sensible defaults when safe.\n\nPreflight hint:\n${hintText}\n\nUser task:\n${prompt}`
);
}
// ─── Broadcast helpers ─────────────────────────────────────────────────────
function broadcastPlanReview() {
if (!workspace.pendingPlan) return;
broadcast({
type: "plan_review",
requestId: workspace.pendingPlan.id,
prompt: workspace.pendingPlan.prompt,
title: workspace.pendingPlan.title,
reason: workspace.pendingPlan.reason,
items: workspace.pendingPlan.items,
questions: workspace.pendingPlan.questions,
attempt: workspace.pendingPlan.attempt,
});
}
// ─── Planning pi process management ───────────────────────────────────────
function stopPlanningPi() {
if (workspace.planningProcess) {
try {
workspace.planningProcess.kill("SIGTERM");
} catch {}
}
workspace.planningProcess = null;
}
function clearPendingPlan() {
workspace.pendingPlan = null;
stopPlanningPi();
}
// ─── Planning RPC event handler ────────────────────────────────────────────
// Handles events from the planning pi process. Listens only for
// tool_execution_end of "propose_plan" to extract the plan payload.
function handlePlanningRpcEvent(line, planContext) {
let event;
try {
event = JSON.parse(line);
} catch {
console.log(`[planning-pi] ${line}`);
return;
}
writeTrace({
ts: new Date().toISOString(),
event: "planning_raw_rpc",
type: event.type || event.event,
raw: event,
});
if (event.type === "tool_execution_start" && event.toolName) {
broadcast({
type: "agent_log",
content: `[planning] ${event.toolName}(${JSON.stringify(event.args || {}).slice(0, 300)})`,
});
return;
}
if (
event.type !== "tool_execution_end" ||
event.toolName !== "propose_plan"
) {
return;
}
let payload = event.result?.details;
if (!payload && event.result?.content) {
const text = event.result.content
.filter((part) => part.type === "text" && part.text)
.map((part) => part.text)
.join("");
try {
payload = JSON.parse(text);
} catch {
payload = {};
}
}
const normalized = normalizePlanPayload(payload, {
items: planContext.preflight?.plan_hint,
reason: planContext.preflight?.reason,
});
workspace.pendingPlan = {
id: planContext.id,
prompt: planContext.prompt,
title: normalized.title,
reason: normalized.reason,
items: normalized.items,
questions: normalized.questions,
attempt: planContext.attempt,
createdAt: new Date().toISOString(),
};
console.log(
`[planning] Proposed plan ${planContext.id} (${normalized.items.length} items)`,
);
broadcastPlanReview();
stopPlanningPi();
}
// ─── Plan-first launcher ───────────────────────────────────────────────────
// Starts a constrained pi process whose only job is to call propose_plan once.
// The result is broadcast as a plan_review event for the user to approve/edit.
function launchPlanningPi(prompt, options = {}) {
stopPlanningPi();
const planContext = {
id:
options.id ||
`plan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
prompt,
attempt: options.attempt || 1,
preflight: options.preflight || null,
};
broadcast({ type: "agent_log", content: "[planning] Generating plan..." });
launchPiProcess({
label: "planning",
sessionId: `planning-${planContext.id}`,
extensions: ["plan-review.ts"],
systemPrompt:
"You are a planning-only agent. You must call propose_plan exactly once. You cannot execute the task.",
prompt: buildPlanningPrompt(prompt, options),
onSpawn(proc) {
workspace.planningProcess = proc;
},
onEvent(line) {
handlePlanningRpcEvent(line, planContext);
},
onClose(code, proc) {
if (workspace.planningProcess === proc) workspace.planningProcess = null;
if (
!workspace.pendingPlan ||
workspace.pendingPlan.id !== planContext.id
) {
if (code !== 0) {
broadcast({
type: "agent_log",
content: `[planning] Failed to generate plan (exit ${code})`,
});
}
}
},
});
return planContext.id;
}
module.exports = {
normalizePlanItems,
normalizePlanPayload,
buildPlanningPrompt,
promptWithApprovedPlan,
promptWithPreflightGuidance,
broadcastPlanReview,
stopPlanningPi,
clearPendingPlan,
handlePlanningRpcEvent,
launchPlanningPi,
};