agent/pkg/agentic/template.go
Virgil 8fab46dcdc fix(agentic): snapshot template content in versions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:07:48 +00:00

534 lines
17 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"crypto/sha256"
"encoding/hex"
"sort"
"dappco.re/go/agent/pkg/lib"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gopkg.in/yaml.v3"
)
// variable := agentic.TemplateVariable{Name: "feature_name", Required: true}
type TemplateVariable struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Required bool `json:"required"`
}
// summary := agentic.TemplateSummary{Slug: "bug-fix", Name: "Bug Fix", PhasesCount: 6}
type TemplateSummary struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
PhasesCount int `json:"phases_count"`
Variables []TemplateVariable `json:"variables,omitempty"`
Version PlanTemplateVersion `json:"version"`
}
// input := agentic.TemplateListInput{Category: "development"}
type TemplateListInput struct {
Category string `json:"category,omitempty"`
}
// out := agentic.TemplateListOutput{Success: true, Total: 2}
type TemplateListOutput struct {
Success bool `json:"success"`
Templates []TemplateSummary `json:"templates"`
Total int `json:"total"`
}
// input := agentic.TemplatePreviewInput{Template: "new-feature", Variables: map[string]string{"feature_name": "Auth"}}
type TemplatePreviewInput struct {
Template string `json:"template,omitempty"`
TemplateSlug string `json:"template_slug,omitempty"`
Slug string `json:"slug,omitempty"`
Variables map[string]string `json:"variables,omitempty"`
}
// out := agentic.TemplatePreviewOutput{Success: true, Template: "bug-fix"}
type TemplatePreviewOutput struct {
Success bool `json:"success"`
Template string `json:"template"`
Version PlanTemplateVersion `json:"version"`
Preview string `json:"preview"`
}
// input := agentic.TemplateCreatePlanInput{Template: "new-feature", Variables: map[string]string{"feature_name": "Auth"}}
type TemplateCreatePlanInput struct {
Template string `json:"template,omitempty"`
TemplateSlug string `json:"template_slug,omitempty"`
Variables map[string]string `json:"variables,omitempty"`
Slug string `json:"slug,omitempty"`
Title string `json:"title,omitempty"`
Activate bool `json:"activate,omitempty"`
}
// out := agentic.TemplateCreatePlanOutput{Success: true, Plan: agentic.PlanCompatibilitySummary{Slug: "auth-feature"}}
type TemplateCreatePlanOutput struct {
Success bool `json:"success"`
Plan PlanCompatibilitySummary `json:"plan"`
Version PlanTemplateVersion `json:"version"`
Commands map[string]string `json:"commands,omitempty"`
}
// version := agentic.PlanTemplateVersion{Slug: "new-feature", Version: 1, ContentHash: "..." }
type PlanTemplateVersion struct {
Slug string `json:"slug"`
Version int `json:"version"`
Name string `json:"name"`
Content planTemplateDefinition `json:"content,omitempty"`
ContentHash string `json:"content_hash"`
}
type planTemplateDefinition struct {
Slug string `yaml:"-"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Variables map[string]planTemplateVariableDef `yaml:"variables"`
Guidelines []string `yaml:"guidelines"`
Phases []planTemplatePhaseDef `yaml:"phases"`
}
type planTemplateVariableDef struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
}
type planTemplatePhaseDef struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tasks []any `yaml:"tasks"`
}
// result := c.Action("template.list").Run(ctx, core.NewOptions())
func (s *PrepSubsystem) handleTemplateList(ctx context.Context, options core.Options) core.Result {
_, output, err := s.templateList(ctx, nil, TemplateListInput{
Category: optionStringValue(options, "category"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("template.preview").Run(ctx, core.NewOptions(core.Option{Key: "template", Value: "bug-fix"}))
func (s *PrepSubsystem) handleTemplatePreview(ctx context.Context, options core.Options) core.Result {
_, output, err := s.templatePreview(ctx, nil, TemplatePreviewInput{
Template: optionStringValue(options, "template"),
TemplateSlug: optionStringValue(options, "template_slug", "template-slug", "slug"),
Variables: optionStringMapValue(options, "variables"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("template.create_plan").Run(ctx, core.NewOptions(core.Option{Key: "template", Value: "bug-fix"}))
func (s *PrepSubsystem) handleTemplateCreatePlan(ctx context.Context, options core.Options) core.Result {
_, output, err := s.templateCreatePlan(ctx, nil, TemplateCreatePlanInput{
Template: optionStringValue(options, "template"),
TemplateSlug: optionStringValue(options, "template_slug", "template-slug"),
Variables: optionStringMapValue(options, "variables"),
Slug: optionStringValue(options, "slug", "plan_slug", "plan-slug"),
Title: optionStringValue(options, "title"),
Activate: optionBoolValue(options, "activate"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
func (s *PrepSubsystem) registerTemplateTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "template_list",
Description: "List available plan templates with variables, category, and phase counts.",
}, s.templateList)
mcp.AddTool(server, &mcp.Tool{
Name: "template_preview",
Description: "Preview a plan template with variable substitution before creating a stored plan.",
}, s.templatePreview)
mcp.AddTool(server, &mcp.Tool{
Name: "template_create_plan",
Description: "Create a stored plan from an embedded YAML template, with optional activation.",
}, s.templateCreatePlan)
}
func (s *PrepSubsystem) templateList(_ context.Context, _ *mcp.CallToolRequest, input TemplateListInput) (*mcp.CallToolResult, TemplateListOutput, error) {
templates := make([]TemplateSummary, 0, len(lib.ListTasks()))
for _, slug := range lib.ListTasks() {
definition, version, err := loadPlanTemplateDefinition(slug, nil)
if err != nil {
continue
}
if input.Category != "" && definition.Category != input.Category {
continue
}
templates = append(templates, templateSummaryFromDefinition(definition, version))
}
sort.Slice(templates, func(i, j int) bool {
return templates[i].Slug < templates[j].Slug
})
return nil, TemplateListOutput{
Success: true,
Templates: templates,
Total: len(templates),
}, nil
}
func (s *PrepSubsystem) templatePreview(_ context.Context, _ *mcp.CallToolRequest, input TemplatePreviewInput) (*mcp.CallToolResult, TemplatePreviewOutput, error) {
templateName := templateNameValue(input.Template, input.TemplateSlug, input.Slug)
if templateName == "" {
return nil, TemplatePreviewOutput{}, core.E("templatePreview", "template is required", nil)
}
definition, version, err := loadPlanTemplateDefinition(templateName, input.Variables)
if err != nil {
return nil, TemplatePreviewOutput{}, err
}
return nil, TemplatePreviewOutput{
Success: true,
Template: definition.Slug,
Version: version,
Preview: renderPlanMarkdown(definition, ""),
}, nil
}
func (s *PrepSubsystem) templateCreatePlan(ctx context.Context, _ *mcp.CallToolRequest, input TemplateCreatePlanInput) (*mcp.CallToolResult, TemplateCreatePlanOutput, error) {
templateName := templateNameValue(input.Template, input.TemplateSlug)
if templateName == "" {
return nil, TemplateCreatePlanOutput{}, core.E("templateCreatePlan", "template is required", nil)
}
definition, version, err := loadPlanTemplateDefinition(templateName, input.Variables)
if err != nil {
return nil, TemplateCreatePlanOutput{}, err
}
if missing := missingTemplateVariables(definition, input.Variables); len(missing) > 0 {
return nil, TemplateCreatePlanOutput{}, core.E("templateCreatePlan", core.Concat("missing required variables: ", core.Join(", ", missing...)), nil)
}
title := core.Trim(input.Title)
if title == "" {
title = definition.Name
}
contextData := map[string]any{
"template": definition.Slug,
}
if len(input.Variables) > 0 {
contextData["variables"] = input.Variables
}
_, created, err := s.planCreate(ctx, nil, PlanCreateInput{
Title: title,
Slug: input.Slug,
Objective: definition.Description,
Description: definition.Description,
Context: contextData,
TemplateVersion: version,
Phases: templatePlanPhases(definition),
Notes: templateGuidelinesNote(definition.Guidelines),
})
if err != nil {
return nil, TemplateCreatePlanOutput{}, err
}
plan, err := readPlan(PlansRoot(), created.ID)
if err != nil {
return nil, TemplateCreatePlanOutput{}, err
}
if input.Activate {
_, updated, updateErr := s.planUpdate(ctx, nil, PlanUpdateInput{
Slug: plan.Slug,
Status: planCompatibilityInputStatus("active"),
})
if updateErr != nil {
return nil, TemplateCreatePlanOutput{}, updateErr
}
plan = &updated.Plan
}
return nil, TemplateCreatePlanOutput{
Success: true,
Plan: planCompatibilitySummary(*plan),
Version: version,
}, nil
}
func templateNameValue(values ...string) string {
for _, value := range values {
if trimmed := core.Trim(value); trimmed != "" {
return trimmed
}
}
return ""
}
func loadPlanTemplateDefinition(slug string, variables map[string]string) (planTemplateDefinition, PlanTemplateVersion, error) {
result := lib.Task(slug)
if !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("loadPlanTemplateDefinition", core.Concat("template not found: ", slug), nil)
}
return planTemplateDefinition{}, PlanTemplateVersion{}, err
}
rawContent := result.Value.(string)
content := applyTemplateVariables(rawContent, variables)
var definition planTemplateDefinition
if err := yaml.Unmarshal([]byte(content), &definition); err != nil {
return planTemplateDefinition{}, PlanTemplateVersion{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), err)
}
if definition.Name == "" || len(definition.Phases) == 0 {
return planTemplateDefinition{}, PlanTemplateVersion{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), nil)
}
definition.Slug = slug
return definition, templateVersionFromContent(slug, definition.Name, rawContent), nil
}
func applyTemplateVariables(content string, variables map[string]string) string {
for key, value := range variables {
content = core.Replace(content, core.Concat("{{", key, "}}"), value)
content = core.Replace(content, core.Concat("{{ ", key, " }}"), value)
}
return content
}
func renderPlanMarkdown(definition planTemplateDefinition, task string) string {
plan := core.NewBuilder()
plan.WriteString(core.Concat("# ", definition.Name, "\n\n"))
if task != "" {
plan.WriteString(core.Concat("**Task:** ", task, "\n\n"))
}
if definition.Description != "" {
plan.WriteString(core.Concat(definition.Description, "\n\n"))
}
if len(definition.Guidelines) > 0 {
plan.WriteString("## Guidelines\n\n")
for _, guideline := range definition.Guidelines {
plan.WriteString(core.Concat("- ", guideline, "\n"))
}
plan.WriteString("\n")
}
for index, phase := range definition.Phases {
plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", index+1, phase.Name))
if phase.Description != "" {
plan.WriteString(core.Concat(phase.Description, "\n\n"))
}
for _, taskItem := range templatePlanTasks(phase.Tasks) {
plan.WriteString(core.Concat("- [ ] ", taskItem.Title, "\n"))
}
plan.WriteString("\n")
}
return plan.String()
}
func templateSummaryFromDefinition(definition planTemplateDefinition, version PlanTemplateVersion) TemplateSummary {
return TemplateSummary{
Slug: definition.Slug,
Name: definition.Name,
Description: definition.Description,
Category: definition.Category,
PhasesCount: len(definition.Phases),
Variables: templateVariableList(definition),
Version: version,
}
}
func templateVersionFromContent(slug, name, content string) PlanTemplateVersion {
sum := sha256.Sum256([]byte(content))
hash := hex.EncodeToString(sum[:])
version := templateVersionForHash(slug, hash)
var snapshot planTemplateDefinition
if err := yaml.Unmarshal([]byte(content), &snapshot); err != nil {
snapshot = planTemplateDefinition{}
}
snapshot.Slug = slug
if snapshot.Name == "" {
snapshot.Name = name
}
return PlanTemplateVersion{
Slug: slug,
Version: version,
Name: name,
Content: snapshot,
ContentHash: hash,
}
}
func templateVersionForHash(slug, contentHash string) int {
if core.Trim(slug) == "" {
return 1
}
version := 0
matchedVersion := 0
for _, path := range core.PathGlob(core.JoinPath(PlansRoot(), "*.json")) {
id := core.TrimSuffix(core.PathBase(path), ".json")
planResult := readPlanResult(PlansRoot(), id)
if !planResult.OK {
continue
}
plan, ok := planResult.Value.(*Plan)
if !ok || plan == nil {
continue
}
if plan.TemplateVersion.Slug != slug || plan.TemplateVersion.Version <= 0 {
continue
}
if plan.TemplateVersion.ContentHash == contentHash {
if plan.TemplateVersion.Version > matchedVersion {
matchedVersion = plan.TemplateVersion.Version
}
}
if plan.TemplateVersion.Version > version {
version = plan.TemplateVersion.Version
}
}
if matchedVersion > 0 {
return matchedVersion
}
if version > 0 {
return version + 1
}
return 1
}
func templateVariableList(definition planTemplateDefinition) []TemplateVariable {
if len(definition.Variables) == 0 {
return nil
}
names := make([]string, 0, len(definition.Variables))
for name := range definition.Variables {
names = append(names, name)
}
sort.Strings(names)
variables := make([]TemplateVariable, 0, len(names))
for _, name := range names {
definitionValue := definition.Variables[name]
variables = append(variables, TemplateVariable{
Name: name,
Description: definitionValue.Description,
Required: definitionValue.Required,
})
}
return variables
}
func missingTemplateVariables(definition planTemplateDefinition, variables map[string]string) []string {
var missing []string
for name, variable := range definition.Variables {
if !variable.Required {
continue
}
if core.Trim(variables[name]) == "" {
missing = append(missing, name)
}
}
sort.Strings(missing)
return missing
}
func templatePlanPhases(definition planTemplateDefinition) []Phase {
phases := make([]Phase, 0, len(definition.Phases))
for index, phaseDefinition := range definition.Phases {
phases = append(phases, Phase{
Number: index + 1,
Name: phaseDefinition.Name,
Description: phaseDefinition.Description,
Status: "pending",
Tasks: templatePlanTasks(phaseDefinition.Tasks),
})
}
return phases
}
func templatePlanTasks(items []any) []PlanTask {
tasks := make([]PlanTask, 0, len(items))
for index, item := range items {
task := templatePlanTask(item, index+1)
if task.Title == "" {
continue
}
tasks = append(tasks, task)
}
return tasks
}
func templatePlanTask(item any, number int) PlanTask {
switch value := item.(type) {
case string:
return PlanTask{
ID: core.Sprint(number),
Title: core.Trim(value),
Status: "pending",
}
case map[string]any:
title := stringValue(value["title"])
if title == "" {
title = stringValue(value["name"])
}
status := stringValue(value["status"])
if status == "" {
status = "pending"
}
taskID := stringValue(value["id"])
if taskID == "" {
taskID = core.Sprint(number)
}
return PlanTask{
ID: taskID,
Title: title,
Description: stringValue(value["description"]),
Status: status,
Notes: stringValue(value["notes"]),
File: stringValue(value["file"]),
Line: intValue(value["line"]),
}
}
return PlanTask{}
}
func templateGuidelinesNote(guidelines []string) string {
cleaned := cleanStrings(guidelines)
if len(cleaned) == 0 {
return ""
}
builder := core.NewBuilder()
builder.WriteString("Guidelines:\n")
for _, guideline := range cleaned {
builder.WriteString(core.Concat("- ", guideline, "\n"))
}
return core.TrimSuffix(builder.String(), "\n")
}