Files
kandev/apps/backend/internal/sysprompt/sysprompt.go
T
João Salavisa 2b74f4e189 fix: gate step_complete_kandev tool on per-step signal flag (#1300)
* fix: gate step_complete_kandev tool on per-step signal flag

Two ADR-0015 follow-up bugs:

- "Wait for agent completion signal" checkbox snapped back because
  updateWorkflowStepAction dropped auto_advance_requires_signal from the
  PUT body. Add it to the Pick<>, body builder, BackendWorkflowStep, and
  transformWorkflowStep.
- step_complete_kandev MCP tool was injected into every first-turn
  prompt, polluting context and tempting agents on non-signal-gated
  steps to call it. Replace the static description in
  kandev-context.md with a {step_complete_section} placeholder filled
  only when the current step has auto_advance_requires_signal=true.
  Add orchestrator.Service.StepRequiresCompletionSignal helper and
  thread it through all four InjectKandevContext call sites.

* fix: address PR 1300 review feedback

- Hoist ProcessOnTurnStart above the first-turn MCP wrap so an
  on_turn_start step transition is reflected in the
  auto_advance_requires_signal flag baked into the stored/forwarded
  prompt (coderabbit, cubic).
- Add Service.WorkflowStepRequiresCompletionSignal step-ID variant and
  use it from task_operations.go / autoStartStepPrompt to skip the
  redundant GetTask round-trip at hot first-turn launch sites (claude).
- Add table-driven tests covering every silent-false branch and the
  positive path of both helpers (greptile, claude, cubic).

* docs(sysprompt): document stepCompleteSection trailing newline contract

Address Claude review suggestion on PR 1300: make explicit that the
constant must end with "\n" because the kandev-context.md template inlines
{step_complete_section} immediately before the next bullet, so the
trailing newline is what separates the two list items.

* fix: restore task-id lookup at startTask wrap site

Address PR 1300 review regression: the previous swap to
WorkflowStepRequiresCompletionSignal(ctx, workflowStepID) silently hid
step_complete_kandev on manual starts where the workflowStepID parameter
is empty but the task is already bound to a signal-gated step in the DB.

The `task` variable in scope at this call site is `*v1.Task` (from the
scheduler) and does not carry WorkflowStepID, so the cheaper step-ID
variant is not applicable here — go back through StepRequiresCompletionSignal
so the repo lookup pulls the canonical step.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-08 08:14:52 +01:00

195 lines
8.7 KiB
Go

// 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.
//
// Prompt templates are stored as markdown files in config/prompts/ and loaded
// via the prompts package (go:embed). Placeholders use {key} syntax and are
// resolved by [Resolve].
package sysprompt
import (
"regexp"
"strconv"
"strings"
"github.com/kandev/kandev/config/prompts"
)
// 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*`)
// placeholderRegex matches {key} placeholders in prompt templates.
var placeholderRegex = regexp.MustCompile(`\{([A-Za-z0-9_]+)\}`)
// 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 strings.TrimSpace(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
}
// HasSystemContent checks whether the text contains any <kandev-system> tags.
func HasSystemContent(text string) bool {
return systemTagRegex.MatchString(text)
}
// kandevContextMarker is a stable string from kandev-context.md that lets
// callers detect a prompt that has already been wrapped with the Kandev MCP
// system block. Used by [HasKandevContext] to make the wrap step idempotent
// across the multiple call sites that record first-turn prompts (WS handler,
// workflow auto-start, orchestrator).
const kandevContextMarker = "KANDEV MCP TOOLS"
// HasKandevContext reports whether the prompt already contains the Kandev MCP
// system block produced by [InjectKandevContext]. Use this to gate a wrap at
// any call site so the same prompt never gets double-wrapped on its way down
// to the agent or the DB.
//
// The marker is matched only inside a <kandev-system>...</kandev-system>
// block — a user message body that happens to mention "KANDEV MCP TOOLS"
// would not falsely signal that the wrap is already applied.
func HasKandevContext(text string) bool {
for _, block := range systemTagRegex.FindAllString(text, -1) {
if strings.Contains(block, kandevContextMarker) {
return true
}
}
return false
}
// PlanMode returns the system prompt prepended when plan mode is enabled.
// It instructs agents to collaborate on the plan without implementing changes.
func PlanMode() string { return prompts.Get("plan-mode") }
// KandevContext returns the system prompt template that provides Kandev-specific
// instructions and session context to agents. Contains {task_id}, {session_id},
// and {step_complete_section} placeholders — use [FormatKandevContext] to inject values.
func KandevContext() string { return prompts.Get("kandev-context") }
// stepCompleteSection is the description + instruction block for the
// step_complete_kandev MCP tool. Only injected when the current workflow step
// has `auto_advance_requires_signal = true` (ADR 0015). Agents on legacy
// auto-advance steps never see the tool so they cannot fire false transitions.
//
// MUST end with "\n": the template inlines {step_complete_section}
// immediately before the next bullet (`- create_task_plan_kandev:`), so the
// trailing newline is what separates the two list items in the enabled case
// without forcing the template to add its own. Dropping the "\n" silently
// merges the two bullets onto one line; the omit path (empty string) is
// unaffected since the next line in the template already starts the bullet.
const stepCompleteSection = "- step_complete_kandev: Signal that every user-stated requirement for the CURRENT workflow step is satisfied. " +
"Call this as the LAST action of the step (after the final tool call / commit / answer). " +
"Idempotent — a second call within the same step is a no-op. " +
"Do NOT call when asking a question, mid-conversation, or on partial progress. " +
"Required params: summary (one-paragraph plain text). Optional: handoff, blockers.\n"
// FormatKandevContext returns the Kandev context prompt with task and session IDs injected.
// When requiresCompletionSignal is true, the step_complete_kandev tool description is
// included; otherwise the placeholder is collapsed to an empty string.
func FormatKandevContext(taskID, sessionID string, requiresCompletionSignal bool) string {
section := ""
if requiresCompletionSignal {
section = stepCompleteSection
}
return Resolve("kandev-context", map[string]string{
"task_id": taskID,
"session_id": sessionID,
"step_complete_section": section,
})
}
// ConfigContext returns the system prompt for config-mode MCP sessions.
// Contains a {session_id} placeholder — use [FormatConfigContext] to inject values.
func ConfigContext() string { return prompts.Get("config-context") }
// FormatConfigContext returns the config context prompt with the session ID injected.
func FormatConfigContext(sessionID string) string {
return Resolve("config-context", map[string]string{
"session_id": sessionID,
})
}
// InjectConfigContext prepends the config system prompt to a user's prompt.
// The system content is wrapped in <kandev-system> tags.
func InjectConfigContext(sessionID, prompt string) string {
return Wrap(FormatConfigContext(sessionID)) + "\n\n" + prompt
}
// InjectKandevContext prepends the Kandev system prompt and session context to a user's prompt.
// The system content is wrapped in <kandev-system> tags. Pass requiresCompletionSignal=true
// when the current workflow step has `auto_advance_requires_signal` enabled (ADR 0015) so the
// step_complete_kandev tool description is exposed; otherwise the tool is hidden from the agent.
func InjectKandevContext(taskID, sessionID, prompt string, requiresCompletionSignal bool) string {
return Wrap(FormatKandevContext(taskID, sessionID, requiresCompletionSignal)) + "\n\n" + prompt
}
// DefaultPlanPrefix returns the planning instruction prompt used when plan mode
// is requested but no workflow step provides its own prompt prefix.
func DefaultPlanPrefix() string { return prompts.Get("default-plan-prefix") }
// 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
}
// SessionHandoverContext returns the template injected when a new session starts
// for a task that already has previous sessions. Contains {session_count} and
// {plan_section} placeholders — use [FormatSessionHandover] to inject values.
func SessionHandoverContext() string { return prompts.Get("session-handover") }
// FormatSessionHandover formats the session handover context.
// planSection should be pre-formatted (empty string if no plan exists).
func FormatSessionHandover(sessionCount int, planSection string) string {
return Resolve("session-handover", map[string]string{
"session_count": strconv.Itoa(sessionCount),
"plan_section": planSection,
})
}
// InjectSessionHandover prepends session handover context to a prompt, wrapped in system tags.
func InjectSessionHandover(sessionCount int, planSection, prompt string) string {
return Wrap(FormatSessionHandover(sessionCount, planSection)) + "\n\n" + prompt
}
// Resolve loads a prompt template by name and replaces all {key} placeholders
// with the corresponding values from vars. Every placeholder in the template
// should have a corresponding entry in vars; unreplaced placeholders are left
// as-is and passed through to the caller.
//
// Replacement is single-pass: values that themselves contain placeholder-like
// text (e.g. a plan section containing "{session_count}") are never re-processed.
func Resolve(name string, vars map[string]string) string {
template := prompts.Get(name)
return placeholderRegex.ReplaceAllStringFunc(template, func(placeholder string) string {
key := placeholder[1 : len(placeholder)-1]
if value, ok := vars[key]; ok {
return value
}
return placeholder
})
}
// 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
}