refactor: consolidate system prompts into sysprompt package (#166)

This commit is contained in:
João Salavisa
2026-02-03 21:40:15 +00:00
committed by GitHub
parent 7c7ff2d016
commit b2f0e156db
9 changed files with 134 additions and 59 deletions
@@ -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(`<kandev-system>
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.
</kandev-system>
%s`, taskID, sessionID, prompt)
return sysprompt.InjectKandevContext(taskID, sessionID, prompt)
}
// loadSession loads an existing session via ACP session/load
@@ -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 = "<kandev-system>"
// SystemTagEnd marks the end of system-injected content that should be hidden from the UI.
const SystemTagEnd = "</kandev-system>"
// 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 <kandev-system> 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
@@ -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 <kandev-system> 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 = "<kandev-system>"
// TagEnd marks the end of system-injected content.
TagEnd = "</kandev-system>"
)
// systemTagRegex matches <kandev-system>...</kandev-system> content including the tags.
var systemTagRegex = regexp.MustCompile(`<kandev-system>[\s\S]*?</kandev-system>\s*`)
// StripSystemContent removes all <kandev-system>...</kandev-system> 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 <kandev-system> 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 <kandev-system> 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 <kandev-system> 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
}
+2 -12
View File
@@ -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 <kandev-system>...</kandev-system> content including the tags.
// This is used to strip system-injected content from messages before displaying to users.
var systemTagRegex = regexp.MustCompile(`<kandev-system>[\s\S]*?</kandev-system>\s*`)
// StripSystemContent removes all <kandev-system>...</kandev-system> 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,
@@ -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),
+28 -8
View File
@@ -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}
+6
View File
@@ -36,6 +36,10 @@ export type MobileSessionState = {
isTaskSwitcherOpen: boolean;
};
export type ChatInputState = {
planModeBySessionId: Record<string, boolean>;
};
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;
+6
View File
@@ -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<UISlice, [['zustand/immer', never]], [], UISlice> = (
@@ -82,4 +83,9 @@ export const createUISlice: StateCreator<UISlice, [['zustand/immer', never]], []
set((draft) => {
draft.mobileSession.isTaskSwitcherOpen = open;
}),
setPlanMode: (sessionId, enabled) =>
set((draft) => {
draft.chatInput.planModeBySessionId[sessionId] = enabled;
setLocalStorage(`plan-mode-${sessionId}`, enabled);
}),
});
+5
View File
@@ -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<AppState>, 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<AppState>): typeof defaultState {
@@ -351,6 +354,7 @@ function mergeInitialState(initialState?: Partial<AppState>): 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<AppState>) {
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)),