Layered prompt rendering from embedded templates
Skill prompts are text/template files embedded from internal/ai/skills/prompts/, one per skill version (<skill>.v<version>.tmpl) defining "instructions" and "user". Templates are parsed at start with missingkey=error; a bad template stops the service. harness.Render builds the system prompt in fixed order (D-007): [1] the admin persona from ai_settings.system_prompt (Config.Persona), [2] the skill instructions, [3] the household context block, [4] the output contract quoting the JSON Schema. The contract is always the last layer whatever the persona says. The household context is a seam of titled sections until BuildContext lands; an empty one renders a defined block. Skill.Render is replaced by Skill.PromptInput. Golden tests pin the exact system prompt and user message for the echo test skill, with and without persona, and under a hostile persona that says "ignore the format below" or fakes a contract heading. checksums.txt records each template's SHA-256; a template change without a version bump fails TestTemplateChecksums naming the skill and version. Maestro task bceca7a8-bc30-48df-944d-84557a1f4154. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// Package prompts holds the skills' prompt templates, embedded from this
|
||||
// directory. Each skill version has one text/template file named
|
||||
// <skill>.v<version>.tmpl that defines two templates: "instructions", the
|
||||
// skill's layer of the system prompt, and "user", the user message. Both
|
||||
// execute with the data the skill derives from its input.
|
||||
//
|
||||
// A template never changes under a released version: checksums.txt records
|
||||
// the SHA-256 of every template, and a test fails when a template no longer
|
||||
// matches it. Changing a prompt means bumping the skill's version, which
|
||||
// renames its template file.
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed *.tmpl
|
||||
var files embed.FS
|
||||
|
||||
// fileName matches a template file name and captures the skill name and
|
||||
// version.
|
||||
var fileName = regexp.MustCompile(`^([a-z][a-z0-9_]*)\.v([1-9][0-9]*)\.tmpl$`)
|
||||
|
||||
// templates maps a template file name to its parsed template. A bad
|
||||
// template stops the service at start.
|
||||
var templates = mustParse(files)
|
||||
|
||||
func mustParse(fsys fs.FS) map[string]*template.Template {
|
||||
t, err := parse(fsys)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// parse parses every template in fsys, each on its own so that every skill
|
||||
// can define "instructions" and "user".
|
||||
func parse(fsys fs.FS) (map[string]*template.Template, error) {
|
||||
names, err := fs.Glob(fsys, "*.tmpl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]*template.Template, len(names))
|
||||
for _, name := range names {
|
||||
if !fileName.MatchString(name) {
|
||||
return nil, fmt.Errorf("prompts: %s: file name is not <skill>.v<version>.tmpl", name)
|
||||
}
|
||||
t, err := template.New(name).Option("missingkey=error").ParseFS(fsys, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prompts: %w", err)
|
||||
}
|
||||
for _, part := range []string{"instructions", "user"} {
|
||||
if t.Lookup(part) == nil {
|
||||
return nil, fmt.Errorf("prompts: %s: does not define %q", name, part)
|
||||
}
|
||||
}
|
||||
out[name] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// File is the name of the template file for version of skill.
|
||||
func File(skill string, version int) string {
|
||||
return skill + ".v" + strconv.Itoa(version) + ".tmpl"
|
||||
}
|
||||
|
||||
// Render executes the template for version of skill with data and returns
|
||||
// its instructions and user message, trimmed of surrounding white space.
|
||||
// Either being empty is an error.
|
||||
func Render(skill string, version int, data any) (instructions, user string, err error) {
|
||||
name := File(skill, version)
|
||||
t, ok := templates[name]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("prompts: no template %s for skill %s v%d", name, skill, version)
|
||||
}
|
||||
if instructions, err = execute(t, "instructions", data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if user, err = execute(t, "user", data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return instructions, user, nil
|
||||
}
|
||||
|
||||
func execute(t *template.Template, part string, data any) (string, error) {
|
||||
var b bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&b, part, data); err != nil {
|
||||
return "", fmt.Errorf("prompts: %w", err)
|
||||
}
|
||||
s := strings.TrimSpace(b.String())
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("prompts: %s: %q rendered empty", t.Name(), part)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
Reference in New Issue
Block a user