agent/pkg/agentic/template.go
Virgil 9d90e7532f feat(agentic): add plan template compatibility tools
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 14:39:40 +00:00

452 lines
14 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"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"`
}
// 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"`
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"`
Commands map[string]string `json:"commands,omitempty"`
}
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, err := loadPlanTemplateDefinition(slug, nil)
if err != nil {
continue
}
if input.Category != "" && definition.Category != input.Category {
continue
}
templates = append(templates, templateSummaryFromDefinition(definition))
}
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, err := loadPlanTemplateDefinition(templateName, input.Variables)
if err != nil {
return nil, TemplatePreviewOutput{}, err
}
return nil, TemplatePreviewOutput{
Success: true,
Template: definition.Slug,
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)
}
if input.Variables == nil {
return nil, TemplateCreatePlanOutput{}, core.E("templateCreatePlan", "variables are required", nil)
}
definition, 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,
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),
}, 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, 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{}, err
}
content := applyTemplateVariables(result.Value.(string), variables)
var definition planTemplateDefinition
if err := yaml.Unmarshal([]byte(content), &definition); err != nil {
return planTemplateDefinition{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), err)
}
if definition.Name == "" || len(definition.Phases) == 0 {
return planTemplateDefinition{}, core.E("loadPlanTemplateDefinition", core.Concat("invalid plan template: ", slug), nil)
}
definition.Slug = slug
return definition, 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) TemplateSummary {
return TemplateSummary{
Slug: definition.Slug,
Name: definition.Name,
Description: definition.Description,
Category: definition.Category,
PhasesCount: len(definition.Phases),
Variables: templateVariableList(definition),
}
}
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"]),
}
}
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")
}