feat(agentic): add plan template compatibility tools

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 14:39:40 +00:00
parent 27928fc9b4
commit 9d90e7532f
4 changed files with 594 additions and 61 deletions

View file

@ -14,7 +14,6 @@ import (
"dappco.re/go/core/forge"
coremcp "forge.lthn.ai/core/mcp/pkg/mcp"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gopkg.in/yaml.v3"
)
// options := agentic.AgentOptions{}
@ -202,6 +201,9 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result {
c.Action("state.set", s.handleStateSet).Description = "Store shared plan state for later sessions"
c.Action("state.get", s.handleStateGet).Description = "Read shared plan state by key"
c.Action("state.list", s.handleStateList).Description = "List shared plan state for a plan"
c.Action("template.list", s.handleTemplateList).Description = "List available YAML plan templates"
c.Action("template.preview", s.handleTemplatePreview).Description = "Preview a YAML plan template with variable substitution"
c.Action("template.create_plan", s.handleTemplateCreatePlan).Description = "Create a stored plan from a YAML template"
c.Action("agentic.prompt", s.handlePrompt).Description = "Read a system prompt by slug"
c.Action("agentic.task", s.handleTask).Description = "Read a task plan by slug"
@ -308,6 +310,7 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) {
s.registerStateTools(server)
s.registerPhaseTools(server)
s.registerTaskTools(server)
s.registerTemplateTools(server)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_scan",
@ -778,68 +781,11 @@ func (s *PrepSubsystem) pullWikiContent(ctx context.Context, org, repo string) s
}
func (s *PrepSubsystem) renderPlan(templateSlug string, variables map[string]string, task string) string {
r := lib.Template(templateSlug)
if !r.OK {
definition, err := loadPlanTemplateDefinition(templateSlug, variables)
if err != nil {
return ""
}
content := r.Value.(string)
for key, value := range variables {
content = core.Replace(content, core.Concat("{{", key, "}}"), value)
content = core.Replace(content, core.Concat("{{ ", key, " }}"), value)
}
var tmpl struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Guidelines []string `yaml:"guidelines"`
Phases []struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tasks []any `yaml:"tasks"`
} `yaml:"phases"`
}
if err := yaml.Unmarshal([]byte(content), &tmpl); err != nil {
return ""
}
plan := core.NewBuilder()
plan.WriteString(core.Concat("# ", tmpl.Name, "\n\n"))
if task != "" {
plan.WriteString(core.Concat("**Task:** ", task, "\n\n"))
}
if tmpl.Description != "" {
plan.WriteString(core.Concat(tmpl.Description, "\n\n"))
}
if len(tmpl.Guidelines) > 0 {
plan.WriteString("## Guidelines\n\n")
for _, g := range tmpl.Guidelines {
plan.WriteString(core.Concat("- ", g, "\n"))
}
plan.WriteString("\n")
}
for i, phase := range tmpl.Phases {
plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name))
if phase.Description != "" {
plan.WriteString(core.Concat(phase.Description, "\n\n"))
}
for _, t := range phase.Tasks {
switch v := t.(type) {
case string:
plan.WriteString(core.Concat("- [ ] ", v, "\n"))
case map[string]any:
if name, ok := v["name"].(string); ok {
plan.WriteString(core.Concat("- [ ] ", name, "\n"))
}
}
}
plan.WriteString("\n")
}
return plan.String()
return renderPlanMarkdown(definition, task)
}
func detectLanguage(repoPath string) string {

452
pkg/agentic/template.go Normal file
View file

@ -0,0 +1,452 @@
// 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")
}

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import "fmt"
func Example_templateVariableList() {
variables := templateVariableList(planTemplateDefinition{
Variables: map[string]planTemplateVariableDef{
"feature_name": {Required: true},
"description": {Required: false},
},
})
fmt.Println(variables[0].Name, variables[0].Required)
fmt.Println(variables[1].Name, variables[1].Required)
// Output:
// description false
// feature_name true
}

View file

@ -0,0 +1,115 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTemplate_HandleTemplateList_Good(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleTemplateList(context.Background(), core.NewOptions(
core.Option{Key: "category", Value: "development"},
))
require.True(t, result.OK)
output, ok := result.Value.(TemplateListOutput)
require.True(t, ok)
assert.True(t, output.Success)
assert.NotZero(t, output.Total)
found := false
for _, summary := range output.Templates {
assert.Equal(t, "development", summary.Category)
if summary.Slug == "bug-fix" {
found = true
assert.Equal(t, "Bug Fix", summary.Name)
assert.NotZero(t, summary.PhasesCount)
assert.NotEmpty(t, summary.Variables)
}
}
assert.True(t, found)
}
func TestTemplate_HandleTemplatePreview_Good(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleTemplatePreview(context.Background(), core.NewOptions(
core.Option{Key: "template", Value: "new-feature"},
core.Option{Key: "variables", Value: `{"feature_name":"Authentication"}`},
))
require.True(t, result.OK)
output, ok := result.Value.(TemplatePreviewOutput)
require.True(t, ok)
assert.True(t, output.Success)
assert.Equal(t, "new-feature", output.Template)
assert.Contains(t, output.Preview, "Authentication")
assert.Contains(t, output.Preview, "Phase 1")
}
func TestTemplate_HandleTemplatePreview_Bad(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleTemplatePreview(context.Background(), core.NewOptions())
assert.False(t, result.OK)
}
func TestTemplate_HandleTemplatePreview_Ugly_MissingVariables(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleTemplatePreview(context.Background(), core.NewOptions(
core.Option{Key: "template", Value: "new-feature"},
))
require.True(t, result.OK)
output, ok := result.Value.(TemplatePreviewOutput)
require.True(t, ok)
assert.Contains(t, output.Preview, "{{ feature_name }}")
}
func TestTemplate_HandleTemplateCreatePlan_Good(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleTemplateCreatePlan(context.Background(), core.NewOptions(
core.Option{Key: "template", Value: "new-feature"},
core.Option{Key: "variables", Value: `{"feature_name":"Authentication"}`},
core.Option{Key: "title", Value: "Authentication Rollout"},
core.Option{Key: "plan_slug", Value: "auth-rollout"},
core.Option{Key: "activate", Value: true},
))
require.True(t, result.OK)
output, ok := result.Value.(TemplateCreatePlanOutput)
require.True(t, ok)
assert.True(t, output.Success)
assert.Equal(t, "auth-rollout", output.Plan.Slug)
assert.Equal(t, "active", output.Plan.Status)
plan, err := readPlan(PlansRoot(), "auth-rollout")
require.NoError(t, err)
assert.Equal(t, "Authentication Rollout", plan.Title)
assert.Equal(t, "in_progress", plan.Status)
require.NotEmpty(t, plan.Phases)
require.NotEmpty(t, plan.Phases[0].Tasks)
assert.Equal(t, "pending", plan.Phases[0].Tasks[0].Status)
assert.Equal(t, "new-feature", stringValue(plan.Context["template"]))
}
func TestTemplate_HandleTemplateCreatePlan_Bad(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleTemplateCreatePlan(context.Background(), core.NewOptions(
core.Option{Key: "template", Value: "new-feature"},
))
assert.False(t, result.OK)
}
func TestTemplate_HandleTemplateCreatePlan_Ugly_UnknownTemplate(t *testing.T) {
subsystem := testPrepWithPlatformServer(t, nil, "")
result := subsystem.handleTemplateCreatePlan(context.Background(), core.NewOptions(
core.Option{Key: "template", Value: "unknown-template"},
core.Option{Key: "variables", Value: `{"feature_name":"Authentication"}`},
))
assert.False(t, result.OK)
}