FN-8439: align Planning Mode with workflow triage prompts
Planning Mode now uses the selected workflow's triage planning template with its interview adapter. - Resolve planning prompts from workflow seams and explicit overrides - Pass and persist the selected workflow for planning sessions - Document the prompt behavior and cover prompt and UI request flow Files changed: .../fn-8439-planning-prompt-triage-template.md | 7 ++ docs/dashboard-guide.md | 2 + packages/core/src/prompt-overrides.ts | 42 +----------- packages/dashboard/app/api/legacy.ts | 3 +- .../dashboard/app/components/PlanningModeModal.tsx | 3 +- .../PlanningModeModal.planning-flow.test.tsx | 10 +++ packages/dashboard/app/utils/builtinPrompts.ts | 4 +- .../__tests__/planning-prompt-resolution.test.ts | 40 ++++++++++++ packages/dashboard/src/planning.ts | 74 ++++++++++++++++++---- .../src/routes/register-planning-subtask-routes.ts | 18 ++++-- 10 files changed, 142 insertions(+), 61 deletions(-) Fusion-Task-Id: FN-8439 Fusion-Task-Lineage: 1680a0cd-27c8-47f1-8cd2-de808d1404cf Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8439-planning-prompt-triage-template.md
Normal file
7
.changeset/fn-8439-planning-prompt-triage-template.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Planning Mode now uses the same workflow triage planning prompt template as newly added tasks.
|
||||||
|
category: fix
|
||||||
|
dev: Resolves Planning Mode from the workflow planning seam plus JSON interview adapter; explicit planning-system overrides still replace it fully.
|
||||||
@@ -528,6 +528,8 @@ When an active Planning AI generation appears stuck, Planning Mode automatically
|
|||||||
<!-- FNXC:PlanningMode 2026-07-19-15:55: FN-8400 replaces the duplicate prompt-recovery controls with a focused three-pane interview; restarting remains a deliberate New session action. -->
|
<!-- FNXC:PlanningMode 2026-07-19-15:55: FN-8400 replaces the duplicate prompt-recovery controls with a focused three-pane interview; restarting remains a deliberate New session action. -->
|
||||||
Use **New session** to restart planning with a different idea.
|
Use **New session** to restart planning with a different idea.
|
||||||
|
|
||||||
|
Planning Mode uses the selected workflow's `planning` seam—the same triage template used for newly added tasks—as its quality bar, then layers the user-controlled interview adapter on top. An explicitly configured `planning-system` prompt override replaces that full system prompt.
|
||||||
|
|
||||||
<!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. -->
|
<!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. -->
|
||||||
<!-- FNXC:PlanningMode 2026-07-20-12:42: FN-8438 requires the first AI turn to draft the running plan from the operator idea, then refine that work product after every answer instead of showing an interview transcript. -->
|
<!-- FNXC:PlanningMode 2026-07-20-12:42: FN-8438 requires the first AI turn to draft the running plan from the operator idea, then refine that work product after every answer instead of showing an interview transcript. -->
|
||||||
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; the AI drafts its title, description, and concrete deliverables from your idea, then refines them after each answer. It is an evolving work product, not a transcript or list of interview questions. You can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. On tablet, mobile, and phone-class short landscape, the interview switches between labeled **Question**, **Running plan**, and **Answered questions** surfaces so the current question stays usable instead of competing with three columns. On mobile, Planning opens to the full-pane, scrollable saved-session list when sessions exist; **Running plan** appears only after you intentionally open a session and choose its tab. **Sessions** (and mobile Back) return to that list with **New session** pinned as its footer. This escape remains available from interview, summary, breakdown, and a new-session composer whenever saved sessions exist, while **Validate plan** remains available on the Running plan surface. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
|
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; the AI drafts its title, description, and concrete deliverables from your idea, then refines them after each answer. It is an evolving work product, not a transcript or list of interview questions. You can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. On tablet, mobile, and phone-class short landscape, the interview switches between labeled **Question**, **Running plan**, and **Answered questions** surfaces so the current question stays usable instead of competing with three columns. On mobile, Planning opens to the full-pane, scrollable saved-session list when sessions exist; **Running plan** appears only after you intentionally open a session and choose its tab. **Sessions** (and mobile Back) return to that list with **New session** pinned as its footer. This escape remains available from interview, summary, breakdown, and a new-session composer whenever saved sessions exist, while **Validate plan** remains available on the Running plan surface. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
|
||||||
|
|||||||
@@ -260,46 +260,8 @@ Output ONLY the prompt text (no markdown, no explanations).`,
|
|||||||
key: "planning-system",
|
key: "planning-system",
|
||||||
name: "Planning System",
|
name: "Planning System",
|
||||||
roles: ["triage"],
|
roles: ["triage"],
|
||||||
description: "System prompt for the AI planning assistant that guides users through task definition",
|
description: "Explicit full system-prompt replacement for Planning Mode; otherwise it uses the workflow planning seam plus interview adapter",
|
||||||
defaultContent: `You are a planning assistant for the fn task board system.
|
defaultContent: "Runtime default: the selected workflow planning seam (the same triage template used for new tasks) plus the user-validated JSON interview adapter. Set an explicit override to replace the full system prompt.",
|
||||||
|
|
||||||
Your job: help users transform vague, high-level ideas into well-defined, actionable tasks.
|
|
||||||
|
|
||||||
## Conversation Flow
|
|
||||||
1. User provides a high-level plan (e.g., "Build a user auth system")
|
|
||||||
2. You ask clarifying questions to understand scope, requirements, and constraints
|
|
||||||
3. You present UI-friendly selection options when appropriate
|
|
||||||
4. Once you have enough information, generate a structured summary
|
|
||||||
|
|
||||||
## Question Types to Use
|
|
||||||
- "text": Open-ended follow-up questions for detailed input
|
|
||||||
- "single_select": When user must choose one option (e.g., tech stack preference)
|
|
||||||
- "multi_select": When multiple options can apply (e.g., features to include)
|
|
||||||
- "confirm": Yes/No questions for quick decisions
|
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
- Ask 3-7 questions depending on complexity
|
|
||||||
- Start broad, then narrow down specifics
|
|
||||||
- Suggest sensible defaults based on project context
|
|
||||||
- Keep questions focused and actionable
|
|
||||||
- When asking about file scope, reference actual project structure
|
|
||||||
|
|
||||||
## Summary Generation
|
|
||||||
When ready to complete, generate:
|
|
||||||
- A concise but descriptive title (max 80 chars)
|
|
||||||
- A detailed description with context gathered
|
|
||||||
- Size estimate (S/M/L) based on scope
|
|
||||||
- Any suggested dependencies on existing tasks
|
|
||||||
- Key deliverables as a checklist
|
|
||||||
|
|
||||||
## Response Format
|
|
||||||
Always respond with valid JSON in one of these formats:
|
|
||||||
|
|
||||||
For questions:
|
|
||||||
{\n "type": "question",\n "data": {\n "id": "unique-id",\n "type": "text|single_select|multi_select|confirm",\n "question": "The question text",\n "description": "Helpful context",\n "options": [{"id": "opt1", "label": "Option 1", "description": "Details"}]\n }\n}
|
|
||||||
|
|
||||||
For completion:
|
|
||||||
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`,
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* FNXC:AgentOnboardingRuntime 2026-07-15-15:25:
|
* FNXC:AgentOnboardingRuntime 2026-07-15-15:25:
|
||||||
|
|||||||
@@ -2600,7 +2600,7 @@ export function startPlanningStreaming(
|
|||||||
initialPlan: string,
|
initialPlan: string,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
modelOverride?: { planningModelProvider?: string; planningModelId?: string; thinkingLevel?: ThinkingLevel },
|
modelOverride?: { planningModelProvider?: string; planningModelId?: string; thinkingLevel?: ThinkingLevel },
|
||||||
planningOptions?: { clarificationEnabled?: boolean },
|
planningOptions?: { clarificationEnabled?: boolean; workflowId?: string | null },
|
||||||
existingSessionId?: string,
|
existingSessionId?: string,
|
||||||
): Promise<{ sessionId: string }> {
|
): Promise<{ sessionId: string }> {
|
||||||
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
|
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
|
||||||
@@ -2611,6 +2611,7 @@ export function startPlanningStreaming(
|
|||||||
planningModelId: modelOverride?.planningModelId,
|
planningModelId: modelOverride?.planningModelId,
|
||||||
thinkingLevel: modelOverride?.thinkingLevel,
|
thinkingLevel: modelOverride?.thinkingLevel,
|
||||||
clarificationEnabled: planningOptions?.clarificationEnabled,
|
clarificationEnabled: planningOptions?.clarificationEnabled,
|
||||||
|
...(planningOptions?.workflowId ? { workflowId: planningOptions.workflowId } : {}),
|
||||||
...(existingSessionId ? { existingSessionId } : {}),
|
...(existingSessionId ? { existingSessionId } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1232,7 +1232,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
startedPlan,
|
startedPlan,
|
||||||
projectId,
|
projectId,
|
||||||
modelOverride,
|
modelOverride,
|
||||||
{ clarificationEnabled: true },
|
{ clarificationEnabled: true, ...(workflowId ? { workflowId } : {}) },
|
||||||
draftSessionId ?? undefined,
|
draftSessionId ?? undefined,
|
||||||
);
|
);
|
||||||
draftSessionIdRef.current = null;
|
draftSessionIdRef.current = null;
|
||||||
@@ -1256,6 +1256,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
planningModelProvider,
|
planningModelProvider,
|
||||||
planningThinkingLevel,
|
planningThinkingLevel,
|
||||||
projectId,
|
projectId,
|
||||||
|
workflowId,
|
||||||
resetPlanningAutoRetryBudget,
|
resetPlanningAutoRetryBudget,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -477,6 +477,16 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(screen.queryByText("Your plan so far")).toBeNull();
|
expect(screen.queryByText("Your plan so far")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards the selected workflow to the streaming start request", async () => {
|
||||||
|
render(<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} workflowId="WF-custom-planning" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build workflow-aware plan" } });
|
||||||
|
fireEvent.click(screen.getByText("Start Planning"));
|
||||||
|
await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledWith(
|
||||||
|
"Build workflow-aware plan", undefined, undefined,
|
||||||
|
{ clarificationEnabled: true, workflowId: "WF-custom-planning" }, undefined,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMultiTab 2026-07-14-00:00:
|
FNXC:PlanningMultiTab 2026-07-14-00:00:
|
||||||
Planning has no cross-tab locking. Even when another tab is using the same session, this
|
Planning has no cross-tab locking. Even when another tab is using the same session, this
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ export const PROMPT_KEY_CATALOG: Record<PromptKey, PromptKeyMetadata> = {
|
|||||||
key: "planning-system",
|
key: "planning-system",
|
||||||
name: "Planning System",
|
name: "Planning System",
|
||||||
roles: ["triage"],
|
roles: ["triage"],
|
||||||
description: "System prompt for the AI planning assistant",
|
description: "Explicit full system-prompt replacement for Planning Mode; otherwise it uses the workflow planning seam plus interview adapter",
|
||||||
defaultContent: "You are a planning assistant for the fn task board system...",
|
defaultContent: "Runtime default: selected workflow planning seam plus the user-validated JSON interview adapter.",
|
||||||
},
|
},
|
||||||
"subtask-breakdown-system": {
|
"subtask-breakdown-system": {
|
||||||
key: "subtask-breakdown-system",
|
key: "subtask-breakdown-system",
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PROMPT_KEY_CATALOG, type TaskStore } from "@fusion/core";
|
||||||
|
import { resolvePlanningModeSystemPrompt } from "../planning.js";
|
||||||
|
|
||||||
|
function store(settings: Record<string, unknown> = {}, workflowPrompt?: string): TaskStore {
|
||||||
|
return {
|
||||||
|
getSettings: vi.fn().mockResolvedValue(settings),
|
||||||
|
getWorkflowDefinition: vi.fn().mockResolvedValue(workflowPrompt ? {
|
||||||
|
ir: { version: 1, nodes: [{ id: "plan", kind: "prompt", config: { seam: "planning", prompt: workflowPrompt } }], edges: [], columns: [] },
|
||||||
|
} : undefined),
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolvePlanningModeSystemPrompt", () => {
|
||||||
|
it("composes the selected workflow planning seam with the interview adapter", async () => {
|
||||||
|
const prompt = await resolvePlanningModeSystemPrompt(store({}, "CUSTOM WORKFLOW PLANNING SEAM"), undefined, "WF-custom");
|
||||||
|
expect(prompt).toContain("CUSTOM WORKFLOW PLANNING SEAM");
|
||||||
|
expect(prompt).toContain('"type":"question"');
|
||||||
|
expect(prompt).toContain("exactly one");
|
||||||
|
expect(prompt).toContain("Only the user can validate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake the catalog fallback for an explicit override", async () => {
|
||||||
|
const prompt = await resolvePlanningModeSystemPrompt(store({}, "WORKFLOW SEAM MARKER"), undefined, "WF-custom");
|
||||||
|
expect(prompt).toContain("WORKFLOW SEAM MARKER");
|
||||||
|
expect(prompt).not.toBe(PROMPT_KEY_CATALOG["planning-system"].defaultContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an explicit planning-system override replace the full prompt", async () => {
|
||||||
|
await expect(resolvePlanningModeSystemPrompt(store(), { "planning-system": "OPERATOR REPLACEMENT" }))
|
||||||
|
.resolves.toBe("OPERATOR REPLACEMENT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the configured triage assignment and fails soft to a builtin seam", async () => {
|
||||||
|
const assigned = await resolvePlanningModeSystemPrompt(store({ agentPrompts: { roleAssignments: { triage: "custom-triage" }, templates: [{ id: "custom-triage", role: "triage", prompt: "TRIAGE ASSIGNMENT MARKER" }] } }));
|
||||||
|
expect(assigned).toContain("TRIAGE ASSIGNMENT MARKER");
|
||||||
|
const fallback = await resolvePlanningModeSystemPrompt({ getSettings: vi.fn().mockRejectedValue(new Error("broken")), getWorkflowDefinition: vi.fn().mockRejectedValue(new Error("broken")) } as unknown as TaskStore);
|
||||||
|
expect(fallback).toContain("task specification agent");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
PlanningResponse,
|
PlanningResponse,
|
||||||
TaskPriority,
|
TaskPriority,
|
||||||
TaskStore,
|
TaskStore,
|
||||||
|
Settings,
|
||||||
NtfyNotificationEvent,
|
NtfyNotificationEvent,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
MessageStore,
|
MessageStore,
|
||||||
@@ -27,8 +28,13 @@ import {
|
|||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
resolvePrompt,
|
|
||||||
summarizeTitle,
|
summarizeTitle,
|
||||||
|
builtinSeamPrompt,
|
||||||
|
renderTriagePolicyPlaceholders,
|
||||||
|
resolveAgentPrompt,
|
||||||
|
resolveEffectivePlannerHeartbeatPatrolEnabled,
|
||||||
|
resolvePlanningPromptFromIr,
|
||||||
|
resolveWorkflowIrById,
|
||||||
type PromptOverrideMap,
|
type PromptOverrideMap,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { SubtaskItem } from "./subtask-breakdown.js";
|
import type { SubtaskItem } from "./subtask-breakdown.js";
|
||||||
@@ -71,6 +77,8 @@ type PlanningSessionOptions = {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
ntfyConfig?: PlanningNtfyConfig;
|
ntfyConfig?: PlanningNtfyConfig;
|
||||||
clarificationEnabled?: boolean;
|
clarificationEnabled?: boolean;
|
||||||
|
/** Workflow selected by the planning entry point; retained for agent rebuilds. */
|
||||||
|
workflowId?: string;
|
||||||
/** Runtime-only mailbox dependency; never serialize this store. */
|
/** Runtime-only mailbox dependency; never serialize this store. */
|
||||||
messageStore?: MessageStore;
|
messageStore?: MessageStore;
|
||||||
pluginRunner?: SkillPluginRunner;
|
pluginRunner?: SkillPluginRunner;
|
||||||
@@ -228,14 +236,51 @@ Planning Mode is user-terminated: each answered turn must produce one consequent
|
|||||||
The model may update the running plan but must never infer completion; only the visible Validate plan action can make a session terminal.
|
The model may update the running plan but must never infer completion; only the visible Validate plan action can make a session terminal.
|
||||||
*/
|
*/
|
||||||
/** Planning system prompt for the AI agent */
|
/** Planning system prompt for the AI agent */
|
||||||
export const PLANNING_SYSTEM_PROMPT = `You are a planning assistant for the fn task board system. First analyze the codebase and active board with the available read tools, fn_task_list, and fn_task_show. Turn a raw idea into an incrementally maintained plan.
|
export const PLANNING_SYSTEM_PROMPT = `## Planning Mode interaction adapter
|
||||||
|
|
||||||
Ask exactly one next, high-impact question on every turn. Use every prior answer as context, avoid repeated questions, and never decide that the interview is complete or emit a terminal/complete response. The user alone validates the plan.
|
First analyze the codebase and active board with the available readonly tools, fn_task_list, and fn_task_show. Treat the workflow planning template above as the quality bar and PROMPT.md structure for the evolving plan, but do not write PROMPT.md or use write tools during this interview.
|
||||||
|
|
||||||
Respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["...]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}.
|
Ask exactly one next, high-impact question on every turn. Use every prior answer as context, avoid repeated questions, and never decide that the interview is complete or emit a terminal/complete response. Only the user can validate the plan.
|
||||||
|
|
||||||
|
Respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}.
|
||||||
|
|
||||||
Every turn must include runningPlan: only title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes this lean plan as plan.md without priority; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
Every turn must include runningPlan: only title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes this lean plan as plan.md without priority; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-07-20-14:30:
|
||||||
|
Planning Mode must use the same workflow planning seam as triage for a newly added task, then layer its infinite JSON interview contract over that template. A catalog defaultContent is Settings UI documentation, not proof that an operator explicitly replaced the full system prompt.
|
||||||
|
*/
|
||||||
|
export async function resolvePlanningModeSystemPrompt(
|
||||||
|
store: TaskStore,
|
||||||
|
promptOverrides?: PromptOverrideMap,
|
||||||
|
workflowId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const settings: Partial<Settings> = (await store.getSettings().catch(() => ({}))) ?? {};
|
||||||
|
const overrides = promptOverrides ?? settings.promptOverrides;
|
||||||
|
const explicitOverride = overrides && Object.prototype.hasOwnProperty.call(overrides, "planning-system")
|
||||||
|
&& typeof overrides["planning-system"] === "string"
|
||||||
|
&& overrides["planning-system"].trim().length > 0
|
||||||
|
? overrides["planning-system"]
|
||||||
|
: undefined;
|
||||||
|
if (explicitOverride) return explicitOverride;
|
||||||
|
|
||||||
|
const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(settings);
|
||||||
|
const assignedTriagePrompt = settings.agentPrompts?.roleAssignments?.triage
|
||||||
|
? resolveAgentPrompt("triage", settings.agentPrompts, { plannerHeartbeatPatrolEnabled })
|
||||||
|
: "";
|
||||||
|
let workflowPrompt: string | undefined;
|
||||||
|
try {
|
||||||
|
const ir = await resolveWorkflowIrById(store, workflowId || settings.defaultWorkflowId || "builtin:coding");
|
||||||
|
workflowPrompt = resolvePlanningPromptFromIr(ir);
|
||||||
|
} catch {
|
||||||
|
// Resolve fail-soft below so a missing custom workflow cannot prevent planning.
|
||||||
|
}
|
||||||
|
const fallback = builtinSeamPrompt("planning")
|
||||||
|
|| resolveAgentPrompt("triage", undefined, { plannerHeartbeatPatrolEnabled });
|
||||||
|
const base = assignedTriagePrompt || workflowPrompt || fallback;
|
||||||
|
return `${renderTriagePolicyPlaceholders(base, settings)}\n\n${PLANNING_SYSTEM_PROMPT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** Placeholder title for draft sessions before the user starts planning. */
|
/** Placeholder title for draft sessions before the user starts planning. */
|
||||||
@@ -265,6 +310,7 @@ export interface DraftInputPayload {
|
|||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
summarizedFor?: string;
|
summarizedFor?: string;
|
||||||
validated?: boolean;
|
validated?: boolean;
|
||||||
|
workflowId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Session TTL in milliseconds (7 days) */
|
/** Session TTL in milliseconds (7 days) */
|
||||||
@@ -317,6 +363,8 @@ interface Session {
|
|||||||
initialPlan: string;
|
initialPlan: string;
|
||||||
title: string;
|
title: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
/** Workflow selected at session start, retained for agent reconstruction. */
|
||||||
|
workflowId?: string;
|
||||||
/** Model override the user picked at draft-create time. Persisted in inputPayload so reopen restores it. */
|
/** Model override the user picked at draft-create time. Persisted in inputPayload so reopen restores it. */
|
||||||
draftModelProvider?: string;
|
draftModelProvider?: string;
|
||||||
draftModelId?: string;
|
draftModelId?: string;
|
||||||
@@ -539,6 +587,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
|
|||||||
...(session.draftModelId ? { modelId: session.draftModelId } : {}),
|
...(session.draftModelId ? { modelId: session.draftModelId } : {}),
|
||||||
...(session.draftThinkingLevel ? { thinkingLevel: session.draftThinkingLevel } : {}),
|
...(session.draftThinkingLevel ? { thinkingLevel: session.draftThinkingLevel } : {}),
|
||||||
...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}),
|
...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}),
|
||||||
|
...(session.workflowId ? { workflowId: session.workflowId } : {}),
|
||||||
validated: session.validated,
|
validated: session.validated,
|
||||||
...(typeof session.clarificationEnabled === "boolean"
|
...(typeof session.clarificationEnabled === "boolean"
|
||||||
? { clarificationEnabled: session.clarificationEnabled }
|
? { clarificationEnabled: session.clarificationEnabled }
|
||||||
@@ -670,6 +719,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
|||||||
initialPlan: payload.initialPlan ?? "",
|
initialPlan: payload.initialPlan ?? "",
|
||||||
title: row.title,
|
title: row.title,
|
||||||
projectId: row.projectId ?? undefined,
|
projectId: row.projectId ?? undefined,
|
||||||
|
workflowId: payload.workflowId,
|
||||||
draftModelProvider: payload.modelProvider,
|
draftModelProvider: payload.modelProvider,
|
||||||
draftModelId: payload.modelId,
|
draftModelId: payload.modelId,
|
||||||
draftThinkingLevel: thinkingLevel,
|
draftThinkingLevel: thinkingLevel,
|
||||||
@@ -962,7 +1012,7 @@ export async function createSession(
|
|||||||
rootDir?: string,
|
rootDir?: string,
|
||||||
promptOverrides?: PromptOverrideMap,
|
promptOverrides?: PromptOverrideMap,
|
||||||
pluginRunner?: SkillPluginRunner,
|
pluginRunner?: SkillPluginRunner,
|
||||||
options?: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled">,
|
options?: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled" | "workflowId">,
|
||||||
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion; summary: PlanningSummary; validated: boolean }> {
|
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion; summary: PlanningSummary; validated: boolean }> {
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
if (!checkRateLimit(ip)) {
|
if (!checkRateLimit(ip)) {
|
||||||
@@ -998,6 +1048,7 @@ export async function createSession(
|
|||||||
rootDir,
|
rootDir,
|
||||||
pluginRunner,
|
pluginRunner,
|
||||||
clarificationEnabled: options?.clarificationEnabled === true,
|
clarificationEnabled: options?.clarificationEnabled === true,
|
||||||
|
workflowId: options?.workflowId,
|
||||||
ntfyConfig: options?.ntfyConfig,
|
ntfyConfig: options?.ntfyConfig,
|
||||||
messageStore: options?.messageStore,
|
messageStore: options?.messageStore,
|
||||||
};
|
};
|
||||||
@@ -1005,9 +1056,7 @@ export async function createSession(
|
|||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
persistSession(session, "generating");
|
persistSession(session, "generating");
|
||||||
|
|
||||||
// Resolve the effective system prompt (override or default)
|
const systemPrompt = await resolvePlanningModeSystemPrompt(store, promptOverrides, session.workflowId);
|
||||||
const baseSystemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
|
|
||||||
const systemPrompt = baseSystemPrompt;
|
|
||||||
|
|
||||||
// Create AI agent and get the first question
|
// Create AI agent and get the first question
|
||||||
// Only await engineReady if createFnAgent hasn't been set externally (e.g., via __setCreateFnAgent)
|
// Only await engineReady if createFnAgent hasn't been set externally (e.g., via __setCreateFnAgent)
|
||||||
@@ -1414,7 +1463,7 @@ export async function startExistingSession(
|
|||||||
thinkingLevelOrPromptOverrides?: ThinkingLevel | PromptOverrideMap,
|
thinkingLevelOrPromptOverrides?: ThinkingLevel | PromptOverrideMap,
|
||||||
promptOverridesOrPluginRunner?: PromptOverrideMap | SkillPluginRunner,
|
promptOverridesOrPluginRunner?: PromptOverrideMap | SkillPluginRunner,
|
||||||
pluginRunnerMaybe?: SkillPluginRunner,
|
pluginRunnerMaybe?: SkillPluginRunner,
|
||||||
runtimeOptions?: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled">,
|
runtimeOptions?: Pick<PlanningSessionOptions, "ntfyConfig" | "messageStore" | "clarificationEnabled" | "workflowId">,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const thinkingLevel = isThinkingLevel(thinkingLevelOrPromptOverrides) ? thinkingLevelOrPromptOverrides : undefined;
|
const thinkingLevel = isThinkingLevel(thinkingLevelOrPromptOverrides) ? thinkingLevelOrPromptOverrides : undefined;
|
||||||
const promptOverrides = isThinkingLevel(thinkingLevelOrPromptOverrides)
|
const promptOverrides = isThinkingLevel(thinkingLevelOrPromptOverrides)
|
||||||
@@ -1422,6 +1471,7 @@ export async function startExistingSession(
|
|||||||
: (thinkingLevelOrPromptOverrides as PromptOverrideMap | undefined);
|
: (thinkingLevelOrPromptOverrides as PromptOverrideMap | undefined);
|
||||||
const pluginRunner = (isThinkingLevel(thinkingLevelOrPromptOverrides) ? pluginRunnerMaybe : promptOverridesOrPluginRunner) as SkillPluginRunner | undefined;
|
const pluginRunner = (isThinkingLevel(thinkingLevelOrPromptOverrides) ? pluginRunnerMaybe : promptOverridesOrPluginRunner) as SkillPluginRunner | undefined;
|
||||||
let session = sessions.get(sessionId);
|
let session = sessions.get(sessionId);
|
||||||
|
if (session && runtimeOptions?.workflowId) session.workflowId = runtimeOptions.workflowId;
|
||||||
|
|
||||||
// Draft sessions aren't included in rehydrateFromStore (which only loads
|
// Draft sessions aren't included in rehydrateFromStore (which only loads
|
||||||
// recoverable in-flight sessions), and a backend restart drops the in-memory
|
// recoverable in-flight sessions), and a backend restart drops the in-memory
|
||||||
@@ -1446,6 +1496,7 @@ export async function startExistingSession(
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||||
}
|
}
|
||||||
|
if (runtimeOptions?.workflowId) session.workflowId = runtimeOptions.workflowId;
|
||||||
|
|
||||||
// Drafts are sync'd via aiSessionStore.updateDraft, which only writes
|
// Drafts are sync'd via aiSessionStore.updateDraft, which only writes
|
||||||
// SQLite. Pull the latest initialPlan + persisted model override + the
|
// SQLite. Pull the latest initialPlan + persisted model override + the
|
||||||
@@ -1567,6 +1618,7 @@ export async function createSessionWithAgent(
|
|||||||
initialPlan,
|
initialPlan,
|
||||||
title: initialPlan.slice(0, 120),
|
title: initialPlan.slice(0, 120),
|
||||||
projectId: options?.projectId,
|
projectId: options?.projectId,
|
||||||
|
workflowId: options?.workflowId,
|
||||||
ntfyConfig: options?.ntfyConfig
|
ntfyConfig: options?.ntfyConfig
|
||||||
? {
|
? {
|
||||||
enabled: options.ntfyConfig.enabled,
|
enabled: options.ntfyConfig.enabled,
|
||||||
@@ -1701,9 +1753,7 @@ async function createPlanningAgent(
|
|||||||
// Ensure engine is loaded before using createFnAgent
|
// Ensure engine is loaded before using createFnAgent
|
||||||
await ensureEngineReady();
|
await ensureEngineReady();
|
||||||
|
|
||||||
// Resolve the effective system prompt (override or default)
|
const systemPrompt = await resolvePlanningModeSystemPrompt(store, promptOverrides, session.workflowId);
|
||||||
const baseSystemPrompt = resolvePrompt("planning-system", promptOverrides) || PLANNING_SYSTEM_PROMPT;
|
|
||||||
const systemPrompt = baseSystemPrompt;
|
|
||||||
|
|
||||||
const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner);
|
const skillContext = buildSessionSkillContextSync(null, "executor", rootDir, pluginRunner);
|
||||||
|
|
||||||
|
|||||||
@@ -511,12 +511,14 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
*/
|
*/
|
||||||
router.post("/planning/start", async (req, res) => {
|
router.post("/planning/start", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { initialPlan } = req.body;
|
const { initialPlan, workflowId } = req.body;
|
||||||
|
|
||||||
if (!initialPlan || typeof initialPlan !== "string") {
|
if (!initialPlan || typeof initialPlan !== "string") {
|
||||||
throw badRequest("initialPlan is required and must be a string");
|
throw badRequest("initialPlan is required and must be a string");
|
||||||
}
|
}
|
||||||
|
if (workflowId !== undefined && typeof workflowId !== "string") {
|
||||||
|
throw badRequest("workflowId must be a string when provided");
|
||||||
|
}
|
||||||
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
@@ -538,7 +540,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
rootDir,
|
rootDir,
|
||||||
settings.promptOverrides,
|
settings.promptOverrides,
|
||||||
ctx.options?.pluginRunner as SkillPluginRunner,
|
ctx.options?.pluginRunner as SkillPluginRunner,
|
||||||
runtime,
|
{ ...runtime, workflowId },
|
||||||
);
|
);
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -645,6 +647,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
existingSessionId,
|
existingSessionId,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
clarificationEnabled,
|
clarificationEnabled,
|
||||||
|
workflowId,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!initialPlan || typeof initialPlan !== "string") {
|
if (!initialPlan || typeof initialPlan !== "string") {
|
||||||
@@ -660,6 +663,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (workflowId !== undefined && typeof workflowId !== "string") {
|
||||||
|
throw badRequest("workflowId must be a string when provided");
|
||||||
|
}
|
||||||
|
|
||||||
if (clarificationEnabled !== undefined && typeof clarificationEnabled !== "boolean") {
|
if (clarificationEnabled !== undefined && typeof clarificationEnabled !== "boolean") {
|
||||||
throw badRequest("clarificationEnabled must be a boolean when provided");
|
throw badRequest("clarificationEnabled must be a boolean when provided");
|
||||||
}
|
}
|
||||||
@@ -730,7 +737,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
validatedThinkingLevel,
|
validatedThinkingLevel,
|
||||||
settings.promptOverrides,
|
settings.promptOverrides,
|
||||||
ctx.options?.pluginRunner as SkillPluginRunner,
|
ctx.options?.pluginRunner as SkillPluginRunner,
|
||||||
runtime,
|
{ ...runtime, workflowId },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await startExistingSession(
|
await startExistingSession(
|
||||||
@@ -742,7 +749,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
settings.promptOverrides,
|
settings.promptOverrides,
|
||||||
ctx.options?.pluginRunner as SkillPluginRunner,
|
ctx.options?.pluginRunner as SkillPluginRunner,
|
||||||
undefined,
|
undefined,
|
||||||
runtime,
|
{ ...runtime, workflowId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
res.status(201).json({ sessionId: existingSessionId });
|
res.status(201).json({ sessionId: existingSessionId });
|
||||||
@@ -752,6 +759,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
const { createSessionWithAgent, RateLimitError: _RateLimitError2 } = await import("../planning.js");
|
const { createSessionWithAgent, RateLimitError: _RateLimitError2 } = await import("../planning.js");
|
||||||
const planningOptions = {
|
const planningOptions = {
|
||||||
projectId,
|
projectId,
|
||||||
|
workflowId,
|
||||||
...runtime,
|
...runtime,
|
||||||
pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner,
|
pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user