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,197 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// checkChecksums compares every template in fsys with the checksums in
|
||||
// manifest and returns each mismatch, naming the skill and version.
|
||||
func checkChecksums(fsys fs.FS, manifest string) []string {
|
||||
var problems []string
|
||||
recorded := map[string]string{} // template file name -> checksum
|
||||
for i, line := range strings.Split(manifest, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 3 || !fileName.MatchString(f[0]+"."+f[1]+".tmpl") {
|
||||
problems = append(problems, fmt.Sprintf("checksums.txt line %d: want \"<skill> v<version> <sha256>\", got %q", i+1, line))
|
||||
continue
|
||||
}
|
||||
name := f[0] + "." + f[1] + ".tmpl"
|
||||
if _, dup := recorded[name]; dup {
|
||||
problems = append(problems, fmt.Sprintf("skill %s %s: checksums.txt records it twice", f[0], f[1]))
|
||||
continue
|
||||
}
|
||||
recorded[name] = f[2]
|
||||
}
|
||||
|
||||
names, err := fs.Glob(fsys, "*.tmpl")
|
||||
if err != nil {
|
||||
return append(problems, err.Error())
|
||||
}
|
||||
for _, name := range names {
|
||||
m := fileName.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: file name is not <skill>.v<version>.tmpl", name))
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
continue
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
want, ok := recorded[name]
|
||||
delete(recorded, name)
|
||||
switch {
|
||||
case !ok:
|
||||
problems = append(problems, fmt.Sprintf("skill %s v%s: %s has no recorded checksum; add \"%s v%s %s\" to checksums.txt",
|
||||
m[1], m[2], name, m[1], m[2], got))
|
||||
case got != want:
|
||||
problems = append(problems, fmt.Sprintf("skill %s v%s: %s changed without a version bump (sha256 %s, recorded %s); bump the skill's version and rename its template",
|
||||
m[1], m[2], name, got, want))
|
||||
}
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(recorded)) {
|
||||
m := fileName.FindStringSubmatch(name)
|
||||
problems = append(problems, fmt.Sprintf("skill %s v%s: checksums.txt records %s but there is no such template; remove the line", m[1], m[2], name))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func TestTemplateChecksums(t *testing.T) {
|
||||
manifest, err := os.ReadFile("checksums.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range checkChecksums(files, string(manifest)) {
|
||||
t.Error(p)
|
||||
}
|
||||
}
|
||||
|
||||
const echoV1 = `{{define "instructions"}}Repeat words.{{end}}{{define "user"}}Repeat: {{.Word}}{{end}}`
|
||||
|
||||
func sum(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func TestCheckChecksums(t *testing.T) {
|
||||
changed := echoV1 + "\n"
|
||||
for name, tc := range map[string]struct {
|
||||
fsys fstest.MapFS
|
||||
manifest string
|
||||
want []string
|
||||
}{
|
||||
"match": {
|
||||
fsys: fstest.MapFS{"echo.v1.tmpl": {Data: []byte(echoV1)}},
|
||||
manifest: "# comment\necho v1 " + sum(echoV1) + "\n",
|
||||
},
|
||||
"changed without bump": {
|
||||
fsys: fstest.MapFS{"echo.v1.tmpl": {Data: []byte(changed)}},
|
||||
manifest: "echo v1 " + sum(echoV1),
|
||||
want: []string{"skill echo v1: echo.v1.tmpl changed without a version bump (sha256 " + sum(changed) +
|
||||
", recorded " + sum(echoV1) + "); bump the skill's version and rename its template"},
|
||||
},
|
||||
"bumped but not recorded": {
|
||||
fsys: fstest.MapFS{"echo.v2.tmpl": {Data: []byte(changed)}},
|
||||
manifest: "echo v1 " + sum(echoV1),
|
||||
want: []string{
|
||||
`skill echo v2: echo.v2.tmpl has no recorded checksum; add "echo v2 ` + sum(changed) + `" to checksums.txt`,
|
||||
"skill echo v1: checksums.txt records echo.v1.tmpl but there is no such template; remove the line",
|
||||
},
|
||||
},
|
||||
"duplicate and malformed": {
|
||||
fsys: fstest.MapFS{"echo.v1.tmpl": {Data: []byte(echoV1)}},
|
||||
manifest: "echo v1 " + sum(echoV1) + "\necho v1 " + sum(echoV1) + "\necho 1 abc\necho v01 abc",
|
||||
want: []string{
|
||||
"skill echo v1: checksums.txt records it twice",
|
||||
`checksums.txt line 3: want "<skill> v<version> <sha256>", got "echo 1 abc"`,
|
||||
`checksums.txt line 4: want "<skill> v<version> <sha256>", got "echo v01 abc"`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := checkChecksums(tc.fsys, tc.manifest); !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
instructions, user, err := Render("echo", 1, struct{ Word string }{"Taco"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if instructions != "You repeat words. Reply with the word you are given, exactly as written but in lower case." {
|
||||
t.Fatalf("instructions = %q", instructions)
|
||||
}
|
||||
if user != "Repeat: Taco" {
|
||||
t.Fatalf("user = %q", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderErrors(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
skill string
|
||||
version int
|
||||
data any
|
||||
want string
|
||||
}{
|
||||
"unknown version": {"echo", 2, struct{ Word string }{"x"}, "no template echo.v2.tmpl for skill echo v2"},
|
||||
"unknown skill": {"nope", 1, nil, "no template nope.v1.tmpl"},
|
||||
"missing field": {"echo", 1, struct{}{}, "can't evaluate field Word"},
|
||||
"missing key": {"echo", 1, map[string]string{}, "map has no entry for key"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := Render(tc.skill, tc.version, tc.data)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedEmptyIsError(t *testing.T) {
|
||||
ts, err := parse(fstest.MapFS{"blank.v1.tmpl": {Data: []byte(`{{define "instructions"}}x{{end}}{{define "user"}} {{.}} {{end}}`)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = execute(ts["blank.v1.tmpl"], "user", "\n")
|
||||
if err == nil || !strings.Contains(err.Error(), `blank.v1.tmpl: "user" rendered empty`) {
|
||||
t.Fatalf("err = %v, want rendered empty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejects(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
fsys fstest.MapFS
|
||||
want string
|
||||
}{
|
||||
"bad name": {fstest.MapFS{"Echo.tmpl": {Data: []byte(echoV1)}}, "file name is not"},
|
||||
"version zero": {fstest.MapFS{"echo.v0.tmpl": {Data: []byte(echoV1)}}, "file name is not"},
|
||||
"no instructions": {fstest.MapFS{"echo.v1.tmpl": {Data: []byte(`{{define "user"}}u{{end}}`)}}, `does not define "instructions"`},
|
||||
"no user": {fstest.MapFS{"echo.v1.tmpl": {Data: []byte(`{{define "instructions"}}i{{end}}`)}}, `does not define "user"`},
|
||||
"syntax": {fstest.MapFS{"echo.v1.tmpl": {Data: []byte(`{{define "user"}}`)}}, "unexpected EOF"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := parse(tc.fsys)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user