diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 8209ce2d8c..16fdec76d7 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/common/appctx" "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/sysprompt" v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/pkg/agent" ) @@ -185,16 +186,7 @@ func (sm *SessionManager) getResumeContextPrompt(agentConfig *registry.AgentType // injectKandevContext prepends the Kandev system prompt and session context to the user's prompt. // This provides consistent guidance to the agent about using Kandev tools and the current session IDs. func (sm *SessionManager) injectKandevContext(taskID, sessionID, prompt string) string { - return fmt.Sprintf(` -IMPORTANT KANDEV INSTRUCTIONS: -- When you have questions for the user, use the ask_user_question_kandev MCP tool to ask them directly. -- When you need to create or update a plan for a task, use the Kandev MCP plan tools (plan_get, plan_update, etc.). -- Kandev Task ID: %s -- Kandev Session ID: %s -- Always use these IDs when calling Kandev MCP tools that require task_id or session_id parameters. - - -%s`, taskID, sessionID, prompt) + return sysprompt.InjectKandevContext(taskID, sessionID, prompt) } // loadSession loads an existing session via ACP session/load diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index b0ee765bfb..9d1b63614c 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/dto" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/queue" + "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -218,12 +219,6 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s return execution, nil } -// SystemTagStart marks the beginning of system-injected content that should be hidden from the UI. -const SystemTagStart = "" - -// SystemTagEnd marks the end of system-injected content that should be hidden from the UI. -const SystemTagEnd = "" - // buildWorkflowPrompt constructs the effective prompt using workflow step configuration. // It combines: step.PromptPrefix + basePrompt + step.PromptSuffix // If step.PlanMode is true, it also prepends the plan mode prefix. @@ -235,12 +230,12 @@ func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, tas // Apply plan mode prefix if enabled (wrapped in system tags) if step.PlanMode { - parts = append(parts, wrapSystemContent(PlanModePrefix)) + parts = append(parts, sysprompt.Wrap(sysprompt.PlanMode)) } // Add prompt prefix if set (with interpolation, wrapped in system tags) if step.PromptPrefix != "" { - parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptPrefix, taskID))) + parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptPrefix, taskID))) } // Add base prompt (user's actual message - not wrapped) @@ -248,26 +243,12 @@ func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, tas // Add prompt suffix if set (with interpolation, wrapped in system tags) if step.PromptSuffix != "" { - parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptSuffix, taskID))) + parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptSuffix, taskID))) } return strings.Join(parts, "\n\n") } -// wrapSystemContent wraps content in tags to mark it as system-injected. -func wrapSystemContent(content string) string { - return SystemTagStart + content + SystemTagEnd -} - -// interpolatePrompt replaces placeholders in prompt templates with actual values. -// Supported placeholders: -// - {task_id} - the task ID -func interpolatePrompt(template string, taskID string) string { - result := template - result = strings.ReplaceAll(result, "{task_id}", taskID) - return result -} - // ResumeTaskSession restarts a specific task session using its stored worktree. func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) { s.logger.Debug("resuming task session", @@ -544,10 +525,6 @@ func (s *Service) StopSession(ctx context.Context, sessionID string, reason stri return s.executor.Stop(ctx, sessionID, reason, force) } -// PlanModePrefix is prepended to prompts when plan mode is enabled. -// This instructs agents to analyze and plan before making changes. -const PlanModePrefix = "[PLAN MODE: Analyze and plan before making changes. Do not execute yet.]\n\n" - // PromptTask sends a follow-up prompt to a running agent for a task session. // If planMode is true, a plan mode prefix is prepended to the prompt. func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool) (*PromptResult, error) { @@ -564,7 +541,7 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom // Apply plan mode prefix if enabled effectivePrompt := prompt if planMode { - effectivePrompt = PlanModePrefix + prompt + effectivePrompt = sysprompt.InjectPlanMode(prompt) } // Check if session is in a review step - if so, move back to the previous step diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go new file mode 100644 index 0000000000..4739339f27 --- /dev/null +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -0,0 +1,78 @@ +// Package sysprompt provides centralized system prompts and utilities for +// injecting system-level instructions into agent conversations. +// +// All system prompts are wrapped in tags to mark them as +// system-injected content that can be stripped when displaying to users. +package sysprompt + +import ( + "fmt" + "regexp" + "strings" +) + +// System tag constants for marking system-injected content. +const ( + // TagStart marks the beginning of system-injected content. + TagStart = "" + // TagEnd marks the end of system-injected content. + TagEnd = "" +) + +// systemTagRegex matches ... content including the tags. +var systemTagRegex = regexp.MustCompile(`[\s\S]*?\s*`) + +// StripSystemContent removes all ... blocks from text. +// This is used to hide system-injected content from the frontend UI. +func StripSystemContent(text string) string { + return systemTagRegex.ReplaceAllString(text, "") +} + +// Wrap wraps content in tags to mark it as system-injected. +func Wrap(content string) string { + return TagStart + content + TagEnd +} + +// PlanMode is the system prompt prepended when plan mode is enabled. +// It instructs agents to analyze and plan without using writing/destructive tools. +const PlanMode = `PLAN MODE ACTIVE - READ-ONLY RESTRICTIONS: +You are in plan mode. You MUST NOT use any writing, modifying, or destructive tools. +This includes but is not limited to: file writes, file deletes, git commits, shell commands that modify state. +You CAN use read-only tools (file reads, searches, code analysis) and the Kandev plan MCP tools (plan_get, plan_update, plan_item_update, etc.) if needed to create or update the task plan. +Focus on analyzing the request and creating a detailed plan. This restriction applies to THIS PROMPT ONLY.` + +// KandevContext is the system prompt that provides Kandev-specific instructions +// and session context to agents. Use FormatKandevContext to inject task/session IDs. +const KandevContext = `IMPORTANT KANDEV INSTRUCTIONS: +- When you have questions for the user, use the ask_user_question_kandev MCP tool to ask them directly. +- When you need to create or update a plan for a task, use the Kandev MCP plan tools (plan_get, plan_update, etc.). +- Kandev Task ID: %s +- Kandev Session ID: %s +- Always use these IDs when calling Kandev MCP tools that require task_id or session_id parameters.` + +// FormatKandevContext returns the Kandev context prompt with task and session IDs injected. +func FormatKandevContext(taskID, sessionID string) string { + return fmt.Sprintf(KandevContext, taskID, sessionID) +} + +// InjectKandevContext prepends the Kandev system prompt and session context to a user's prompt. +// The system content is wrapped in tags. +func InjectKandevContext(taskID, sessionID, prompt string) string { + return Wrap(FormatKandevContext(taskID, sessionID)) + "\n\n" + prompt +} + +// InjectPlanMode prepends the plan mode system prompt to a user's prompt. +// The system content is wrapped in tags. +func InjectPlanMode(prompt string) string { + return Wrap(PlanMode) + "\n\n" + prompt +} + +// InterpolatePlaceholders replaces placeholders in prompt templates with actual values. +// Supported placeholders: +// - {task_id} - the task ID +func InterpolatePlaceholders(template string, taskID string) string { + result := template + result = strings.ReplaceAll(result, "{task_id}", taskID) + return result +} + diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 01d26c12fd..557d6b3383 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1,22 +1,12 @@ package models import ( - "regexp" "time" + "github.com/kandev/kandev/internal/sysprompt" v1 "github.com/kandev/kandev/pkg/api/v1" ) -// systemTagRegex matches ... content including the tags. -// This is used to strip system-injected content from messages before displaying to users. -var systemTagRegex = regexp.MustCompile(`[\s\S]*?\s*`) - -// StripSystemContent removes all ... blocks from text. -// This is used to hide workflow step prompt modifications (prefix/suffix) from the frontend UI. -func StripSystemContent(text string) string { - return systemTagRegex.ReplaceAllString(text, "") -} - // ListMessagesOptions defines pagination options for listing messages type ListMessagesOptions struct { Limit int @@ -152,7 +142,7 @@ func (m *Message) ToAPI() *v1.Message { TurnID: m.TurnID, AuthorType: string(m.AuthorType), AuthorID: m.AuthorID, - Content: StripSystemContent(m.Content), + Content: sysprompt.StripSystemContent(m.Content), Type: messageType, Metadata: m.Metadata, RequestsInput: m.RequestsInput, diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 252d9f561f..3152e46627 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/worktree" @@ -2479,7 +2480,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes "turn_id": message.TurnID, "author_type": string(message.AuthorType), "author_id": message.AuthorID, - "content": models.StripSystemContent(message.Content), + "content": sysprompt.StripSystemContent(message.Content), "type": messageType, "requests_input": message.RequestsInput, "created_at": message.CreatedAt.Format(time.RFC3339), diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 79f1d32791..cc8f017a20 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState, memo } from 'react'; import { getWebSocketClient } from '@/lib/ws/connection'; +import { useAppStore } from '@/components/state-provider'; +import { getLocalStorage } from '@/lib/local-storage'; import { useSessionMessages } from '@/hooks/domains/session/use-session-messages'; import { useSettingsData } from '@/hooks/domains/settings/use-settings-data'; import { useSessionState } from '@/hooks/domains/session/use-session-state'; @@ -36,7 +38,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({ onRequestChangesTooltipDismiss, onOpenFileAtLine, }: TaskChatPanelProps) { - const [planModeEnabled, setPlanModeEnabled] = useState(false); const [isSending, setIsSending] = useState(false); const lastAgentMessageCountRef = useRef(0); @@ -55,6 +56,30 @@ export const TaskChatPanel = memo(function TaskChatPanel({ isAgentBusy, } = useSessionState(sessionId); + // Plan mode state from store (persisted per session) + const planModeEnabled = useAppStore((state) => + resolvedSessionId ? (state.chatInput.planModeBySessionId[resolvedSessionId] ?? false) : false + ); + const setPlanMode = useAppStore((state) => state.setPlanMode); + const handlePlanModeChange = useCallback( + (enabled: boolean) => { + if (resolvedSessionId) { + setPlanMode(resolvedSessionId, enabled); + } + }, + [resolvedSessionId, setPlanMode] + ); + + // Initialize plan mode from localStorage on mount + useEffect(() => { + if (resolvedSessionId) { + const stored = getLocalStorage(`plan-mode-${resolvedSessionId}`, false); + if (stored) { + setPlanMode(resolvedSessionId, stored); + } + } + }, [resolvedSessionId, setPlanMode]); + // Fetch messages for this session const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId); @@ -153,15 +178,10 @@ export const TaskChatPanel = memo(function TaskChatPanel({ if (reviewComments && reviewComments.length > 0) { markCommentsSent(reviewComments.map((c) => c.id)); } - - // Reset plan mode after sending - it's a per-message instruction - if (planModeEnabled) { - setPlanModeEnabled(false); - } } finally { setIsSending(false); } - }, [isSending, planModeEnabled, onSend, handleSendMessage, markCommentsSent]); + }, [isSending, onSend, handleSendMessage, markCommentsSent]); return ( @@ -194,7 +214,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ taskTitle={task?.title} taskDescription={taskDescription ?? ''} planModeEnabled={planModeEnabled} - onPlanModeChange={setPlanModeEnabled} + onPlanModeChange={handlePlanModeChange} isAgentBusy={isAgentBusy} isStarting={isStarting} isSending={isSending} diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index b2eb233837..7653be95e3 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -36,6 +36,10 @@ export type MobileSessionState = { isTaskSwitcherOpen: boolean; }; +export type ChatInputState = { + planModeBySessionId: Record; +}; + export type UISliceState = { previewPanel: PreviewPanelState; rightPanel: RightPanelState; @@ -43,6 +47,7 @@ export type UISliceState = { connection: ConnectionState; mobileKanban: MobileKanbanState; mobileSession: MobileSessionState; + chatInput: ChatInputState; }; export type UISliceActions = { @@ -59,6 +64,7 @@ export type UISliceActions = { setMobileKanbanMenuOpen: (open: boolean) => void; setMobileSessionPanel: (sessionId: string, panel: MobileSessionPanel) => void; setMobileSessionTaskSwitcherOpen: (open: boolean) => void; + setPlanMode: (sessionId: string, enabled: boolean) => void; }; export type UISlice = UISliceState & UISliceActions; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 0de8aee519..24aa4c84b6 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -16,6 +16,7 @@ export const defaultUIState: UISliceState = { connection: { status: 'disconnected', error: null }, mobileKanban: { activeColumnIndex: 0, isMenuOpen: false }, mobileSession: { activePanelBySessionId: {}, isTaskSwitcherOpen: false }, + chatInput: { planModeBySessionId: {} }, }; export const createUISlice: StateCreator = ( @@ -82,4 +83,9 @@ export const createUISlice: StateCreator { draft.mobileSession.isTaskSwitcherOpen = open; }), + setPlanMode: (sessionId, enabled) => + set((draft) => { + draft.chatInput.planModeBySessionId[sessionId] = enabled; + setLocalStorage(`plan-mode-${sessionId}`, enabled); + }), }); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index f5d3e4e7a6..9ecb1de211 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -149,6 +149,7 @@ export type AppState = { connection: typeof defaultUIState['connection']; mobileKanban: typeof defaultUIState['mobileKanban']; mobileSession: typeof defaultUIState['mobileSession']; + chatInput: typeof defaultUIState['chatInput']; // Actions from all slices hydrate: (state: Partial, options?: HydrationOptions) => void; @@ -201,6 +202,7 @@ export type AppState = { setMobileKanbanMenuOpen: (open: boolean) => void; setMobileSessionPanel: (sessionId: string, panel: import('./slices/ui/types').MobileSessionPanel) => void; setMobileSessionTaskSwitcherOpen: (open: boolean) => void; + setPlanMode: (sessionId: string, enabled: boolean) => void; setMessages: ( sessionId: string, messages: Message[], @@ -300,6 +302,7 @@ const defaultState = { connection: defaultUIState.connection, mobileKanban: defaultUIState.mobileKanban, mobileSession: defaultUIState.mobileSession, + chatInput: defaultUIState.chatInput, }; function mergeInitialState(initialState?: Partial): typeof defaultState { @@ -351,6 +354,7 @@ function mergeInitialState(initialState?: Partial): typeof defaultStat connection: { ...defaultState.connection, ...initialState.connection }, mobileKanban: { ...defaultState.mobileKanban, ...initialState.mobileKanban }, mobileSession: { ...defaultState.mobileSession, ...initialState.mobileSession }, + chatInput: { ...defaultState.chatInput, ...initialState.chatInput }, }; } @@ -413,6 +417,7 @@ export function createAppStore(initialState?: Partial) { connection: merged.connection, mobileKanban: merged.mobileKanban, mobileSession: merged.mobileSession, + chatInput: merged.chatInput, // Add hydrate method // eslint-disable-next-line @typescript-eslint/no-explicit-any hydrate: (state, options) => set((draft) => hydrateState(draft as any, state, options)),