공식 리뷰가 지적한 두 가지 승인 경계 결함을 고친다. `plan_file`/`review_file`은
edge.yaml 디렉터리 기준 상대 경로만 허용하고 절대/빈 경로는 파일 접근 전에 거부한다.
필수 heading과 Review `PASS`는 부분 문자열이 아니라 정확한 단독 줄로 검증하며,
문서화된 placeholder를 제거한 뒤 남는 `{{`/`}}`를 거부해 문법을 닫는다.
리뷰가 존재한다고 기술했지만 실제로는 없던 회귀 근거를 추가한다. admission 시
고정된 effective 템플릿 쌍이 clone과 workspace 재검증을 통과하는지, refresh가
이미 승인된 요청이 아니라 새 요청에만 적용되는지, custom Review 템플릿이 내부
artifact만 바꾸고 caller 최종 출력은 그대로인지를 각각 확정 검증한다.
현재 문서도 실제 동작에 맞춘다. Edge 실행 spec의 낡은 Plan JSON Schema 서술을
direct PlanMD 검증으로 고치고, outer Anthropic 계약과 input spec에 템플릿이
Edge 내부 stage 입력일 뿐 caller 요청/응답 계약을 바꾸지 않음을 명시한다.
Refs: agent-task/single_request_plan_review_templates/PLAN-cloud-G08.md
319 lines
9 KiB
Go
319 lines
9 KiB
Go
package singlerequesttemplate
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const MaxTemplateBytes = 8192
|
|
|
|
const DefaultPlanTemplate = `# Plan
|
|
|
|
## Goal
|
|
{{goal}}
|
|
|
|
## Steps
|
|
{{steps}}
|
|
|
|
## Verification
|
|
{{verification}}
|
|
`
|
|
|
|
const DefaultReviewTemplate = `# Review
|
|
|
|
## Result
|
|
PASS
|
|
|
|
## Checks
|
|
{{checks}}
|
|
|
|
## Verification
|
|
{{verification}}
|
|
|
|
## Summary
|
|
{{summary}}
|
|
`
|
|
|
|
var (
|
|
ErrInvalidTemplate = errors.New("single-request template: invalid template")
|
|
ErrTemplateTooLarge = errors.New("single-request template: exceeds maximum size")
|
|
ErrMalformedPlan = errors.New("single-request template: malformed plan output")
|
|
ErrMalformedReview = errors.New("single-request template: malformed review fields")
|
|
)
|
|
|
|
var placeholderRegex = regexp.MustCompile(`\{\{[^}]*\}\}`)
|
|
|
|
var (
|
|
planPlaceholders = []string{"{{goal}}", "{{steps}}", "{{verification}}"}
|
|
planHeadings = []string{"# Plan", "## Goal", "## Steps", "## Verification"}
|
|
reviewPlaceholders = []string{"{{checks}}", "{{verification}}", "{{summary}}"}
|
|
reviewLines = []string{"# Review", "## Result", "PASS", "## Checks", "## Verification", "## Summary"}
|
|
)
|
|
|
|
type ReviewFields struct {
|
|
Checks string
|
|
Verification string
|
|
Summary string
|
|
}
|
|
|
|
// exactLineOffsets returns the byte offset of the first standalone line equal to
|
|
// want and how many standalone lines matched. A standalone line is a maximal
|
|
// "\n"-delimited segment compared byte for byte, so decorated variants such as
|
|
// "### Plan", "# Plan Mismatch", or "NOTPASS" never satisfy a required line.
|
|
func exactLineOffsets(tmpl, want string) (int, int) {
|
|
index, count, offset := -1, 0, 0
|
|
for {
|
|
var line string
|
|
end := strings.IndexByte(tmpl[offset:], '\n')
|
|
if end < 0 {
|
|
line = tmpl[offset:]
|
|
} else {
|
|
line = tmpl[offset : offset+end]
|
|
}
|
|
if line == want {
|
|
count++
|
|
if index < 0 {
|
|
index = offset
|
|
}
|
|
}
|
|
if end < 0 {
|
|
return index, count
|
|
}
|
|
offset += end + 1
|
|
}
|
|
}
|
|
|
|
// requireExactLines resolves every required standalone line, rejecting missing
|
|
// and duplicated occurrences, and returns their offsets in the requested order.
|
|
// Only the documented required line is echoed on failure; template content is
|
|
// never included in the error.
|
|
func requireExactLines(tmpl string, lines []string) ([]int, error) {
|
|
offsets := make([]int, len(lines))
|
|
for i, line := range lines {
|
|
index, count := exactLineOffsets(tmpl, line)
|
|
if count != 1 {
|
|
return nil, fmt.Errorf("%w: must contain the standalone line %q exactly once", ErrInvalidTemplate, line)
|
|
}
|
|
offsets[i] = index
|
|
}
|
|
return offsets, nil
|
|
}
|
|
|
|
// requirePlaceholderInventory closes the placeholder grammar. Each documented
|
|
// placeholder must appear exactly once; after removing those exact occurrences
|
|
// no template delimiter may survive, so unknown placeholders and unbalanced
|
|
// "{{"/"}}" residue are both rejected. Only documented placeholder names are
|
|
// echoed on failure; template content is never included in the error.
|
|
func requirePlaceholderInventory(tmpl string, placeholders []string) ([]int, error) {
|
|
offsets := make([]int, len(placeholders))
|
|
residue := tmpl
|
|
for i, placeholder := range placeholders {
|
|
if strings.Count(tmpl, placeholder) != 1 {
|
|
return nil, fmt.Errorf("%w: must contain %s exactly once", ErrInvalidTemplate, placeholder)
|
|
}
|
|
offsets[i] = strings.Index(tmpl, placeholder)
|
|
residue = strings.Replace(residue, placeholder, "", 1)
|
|
}
|
|
if placeholderRegex.MatchString(residue) {
|
|
return nil, fmt.Errorf("%w: template declares an unknown placeholder", ErrInvalidTemplate)
|
|
}
|
|
if strings.Contains(residue, "{{") || strings.Contains(residue, "}}") {
|
|
return nil, fmt.Errorf("%w: template leaves an unbalanced placeholder delimiter", ErrInvalidTemplate)
|
|
}
|
|
return offsets, nil
|
|
}
|
|
|
|
func ascending(values ...int) bool {
|
|
for i := 1; i < len(values); i++ {
|
|
if values[i-1] >= values[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func Digest(content string) string {
|
|
h := sha256.Sum256([]byte(content))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func ValidatePlanTemplate(tmpl string) error {
|
|
if len(tmpl) == 0 {
|
|
return fmt.Errorf("%w: template is empty", ErrInvalidTemplate)
|
|
}
|
|
if len(tmpl) > MaxTemplateBytes {
|
|
return fmt.Errorf("%w: template size %d exceeds max %d", ErrTemplateTooLarge, len(tmpl), MaxTemplateBytes)
|
|
}
|
|
|
|
placeholders, err := requirePlaceholderInventory(tmpl, planPlaceholders)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idxGoal, idxSteps, idxVerif := placeholders[0], placeholders[1], placeholders[2]
|
|
if !ascending(idxGoal, idxSteps, idxVerif) {
|
|
return fmt.Errorf("%w: placeholders must appear in order {{goal}}, {{steps}}, {{verification}}", ErrInvalidTemplate)
|
|
}
|
|
|
|
headings, err := requireExactLines(tmpl, planHeadings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idxPlanH, idxGoalH, idxStepsH, idxVerifH := headings[0], headings[1], headings[2], headings[3]
|
|
if !ascending(idxPlanH, idxGoalH, idxGoal, idxStepsH, idxSteps, idxVerifH, idxVerif) {
|
|
return fmt.Errorf("%w: headings and placeholders must follow exact structural order", ErrInvalidTemplate)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ValidateReviewTemplate(tmpl string) error {
|
|
if len(tmpl) == 0 {
|
|
return fmt.Errorf("%w: template is empty", ErrInvalidTemplate)
|
|
}
|
|
if len(tmpl) > MaxTemplateBytes {
|
|
return fmt.Errorf("%w: template size %d exceeds max %d", ErrTemplateTooLarge, len(tmpl), MaxTemplateBytes)
|
|
}
|
|
|
|
placeholders, err := requirePlaceholderInventory(tmpl, reviewPlaceholders)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idxChecks, idxVerif, idxSumm := placeholders[0], placeholders[1], placeholders[2]
|
|
if !ascending(idxChecks, idxVerif, idxSumm) {
|
|
return fmt.Errorf("%w: placeholders must appear in order {{checks}}, {{verification}}, {{summary}}", ErrInvalidTemplate)
|
|
}
|
|
|
|
lines, err := requireExactLines(tmpl, reviewLines)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idxReviewH, idxResultH, idxPass := lines[0], lines[1], lines[2]
|
|
idxChecksH, idxVerifH, idxSummH := lines[3], lines[4], lines[5]
|
|
if !ascending(idxReviewH, idxResultH, idxPass, idxChecksH, idxChecks, idxVerifH, idxVerif, idxSummH, idxSumm) {
|
|
return fmt.Errorf("%w: headings and placeholders must follow exact structural order", ErrInvalidTemplate)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ParsePlan(tmpl string, rawOutput string, maxOutputBytes int) ([]byte, error) {
|
|
if maxOutputBytes < 1 || len(rawOutput) > maxOutputBytes {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
if err := ValidatePlanTemplate(tmpl); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if strings.Contains(rawOutput, "{{") || strings.Contains(rawOutput, "}}") {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
|
|
idxGoalPlaceholder := strings.Index(tmpl, "{{goal}}")
|
|
idxStepsPlaceholder := strings.Index(tmpl, "{{steps}}")
|
|
idxVerifPlaceholder := strings.Index(tmpl, "{{verification}}")
|
|
|
|
f0 := tmpl[:idxGoalPlaceholder]
|
|
f1 := tmpl[idxGoalPlaceholder+len("{{goal}}") : idxStepsPlaceholder]
|
|
f2 := tmpl[idxStepsPlaceholder+len("{{steps}}") : idxVerifPlaceholder]
|
|
f3 := tmpl[idxVerifPlaceholder+len("{{verification}}"):]
|
|
|
|
if !strings.HasPrefix(rawOutput, f0) {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
rem := rawOutput[len(f0):]
|
|
|
|
i1 := strings.Index(rem, f1)
|
|
if i1 < 0 {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
vGoal := rem[:i1]
|
|
rem = rem[i1+len(f1):]
|
|
|
|
i2 := strings.Index(rem, f2)
|
|
if i2 < 0 {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
vSteps := rem[:i2]
|
|
rem = rem[i2+len(f2):]
|
|
|
|
var vVerif string
|
|
if f3 == "" {
|
|
vVerif = rem
|
|
} else {
|
|
if !strings.HasSuffix(rem, f3) {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
vVerif = rem[:len(rem)-len(f3)]
|
|
}
|
|
|
|
trimmedGoal := strings.TrimSpace(vGoal)
|
|
if trimmedGoal == "" || strings.Contains(trimmedGoal, "\n") {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
|
|
trimmedSteps := strings.TrimSpace(vSteps)
|
|
if trimmedSteps == "" {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
stepLines := strings.Split(trimmedSteps, "\n")
|
|
if len(stepLines) < 2 || len(stepLines) > 6 {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
for _, l := range stepLines {
|
|
trimmedLine := strings.TrimSpace(l)
|
|
if !strings.HasPrefix(trimmedLine, "- ") || strings.TrimSpace(trimmedLine[2:]) == "" {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
}
|
|
|
|
trimmedVerif := strings.TrimSpace(vVerif)
|
|
if trimmedVerif == "" {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
verifLines := strings.Split(trimmedVerif, "\n")
|
|
if len(verifLines) < 1 || len(verifLines) > 3 {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
for _, l := range verifLines {
|
|
trimmedLine := strings.TrimSpace(l)
|
|
if !strings.HasPrefix(trimmedLine, "- ") || strings.TrimSpace(trimmedLine[2:]) == "" {
|
|
return nil, ErrMalformedPlan
|
|
}
|
|
}
|
|
|
|
return []byte(rawOutput), nil
|
|
}
|
|
|
|
func RenderReview(tmpl string, fields ReviewFields, maxOutputBytes int) ([]byte, error) {
|
|
if maxOutputBytes < 1 {
|
|
return nil, ErrMalformedReview
|
|
}
|
|
if err := ValidateReviewTemplate(tmpl); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c := strings.TrimSpace(fields.Checks)
|
|
v := strings.TrimSpace(fields.Verification)
|
|
s := strings.TrimSpace(fields.Summary)
|
|
if c == "" || v == "" || s == "" {
|
|
return nil, ErrMalformedReview
|
|
}
|
|
|
|
res := strings.ReplaceAll(tmpl, "{{checks}}", c)
|
|
res = strings.ReplaceAll(res, "{{verification}}", v)
|
|
res = strings.ReplaceAll(res, "{{summary}}", s)
|
|
|
|
if strings.Contains(res, "{{") || strings.Contains(res, "}}") {
|
|
return nil, ErrMalformedReview
|
|
}
|
|
|
|
if len(res) > maxOutputBytes {
|
|
return nil, ErrMalformedReview
|
|
}
|
|
|
|
return []byte(res), nil
|
|
}
|