Why: prompts lived inline in Go (profile const, subagent string, squad role one-liners, tool descriptions) - every copy change shipped code, and task guidance like meeting-minutes quality lived permanently in the base prompt whether or not the task was at hand. Evidence: new prompts package tests (library integrity, default assembly, skill triggering incl. attachment gate and command bypass, priority supersession, override chain drift, command match, frontmatter parse); composite make test green; runtime check on a live server (40 modules across 7 layers, override round-trip, /minutes starts a run instead of hitting the command bus). Impact: 40 builtin modules as embed data (5 base + 1 env + 12 task skills + 10 agent roles + 7 tool descriptions + 3 aux templates + 2 styles); migration v8 adds prompt_modules for user overrides with basedOnVersion drift markers; ctxasm assembles multi-segment system messages with per-module observability; subagent/squad/tool copy now resolve from the library with inline fallbacks; skill commands surface in the / menu and route to runs; default profile SystemPrompt empties onto the library base; pins deps/agent-system-core to the contract commit. Refs: feature/prompt-library task card; design guide in Documents.
157 lines
3.9 KiB
Go
157 lines
3.9 KiB
Go
// Package prompts 实现提示词库(prompt-library):内置库 embed 加载、用户覆盖链、
|
|
// 触发匹配与 system 组装。提示词是数据不是代码——文案变更走库文件 diff。
|
|
package prompts
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// ParseModuleFile 解析一个库文件(frontmatter + 正文)。
|
|
// frontmatter 语法(刻意最小,不引 YAML 依赖):
|
|
//
|
|
// id: task.doc-minutes
|
|
// layer: task
|
|
// version: 1
|
|
// title: 会议纪要
|
|
// description: 依据上传文档生成完整会议纪要
|
|
// priority: 60
|
|
// requiresAttachments: true
|
|
// outputContract: |
|
|
// 多行契约文本(缩进块,支持)
|
|
// triggers:
|
|
// - kind: intent
|
|
// keywords: 纪要, 会议记录
|
|
// - kind: command
|
|
// command: /minutes
|
|
func ParseModuleFile(name, content string) (core.PromptModule, error) {
|
|
m := core.PromptModule{Source: string(core.SourceBuiltin)}
|
|
if !strings.HasPrefix(content, "---\n") {
|
|
return m, fmt.Errorf("%s: missing frontmatter", name)
|
|
}
|
|
end := strings.Index(content[4:], "\n---\n")
|
|
if end < 0 {
|
|
return m, fmt.Errorf("%s: unterminated frontmatter", name)
|
|
}
|
|
fm := content[4 : 4+end]
|
|
m.Body = strings.TrimLeft(content[4+end+5:], "\n")
|
|
|
|
lines := strings.Split(fm, "\n")
|
|
var trigger *core.PromptTrigger
|
|
blockKey := "" // 当前块标量(outputContract: |)归属的键
|
|
for i := 0; i < len(lines); i++ {
|
|
line := lines[i]
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
if blockKey != "" {
|
|
// 块标量体:任何非空且带缩进的行并入;遇到顶格键结束块。
|
|
if line[0] == ' ' || line[0] == '\t' {
|
|
appendBlock(&m, blockKey, strings.TrimSpace(line))
|
|
continue
|
|
}
|
|
blockKey = ""
|
|
}
|
|
indented := line[0] == ' ' || line[0] == '\t'
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "- ") {
|
|
if indented {
|
|
return m, fmt.Errorf("%s: nested list unsupported: %q", name, trimmed)
|
|
}
|
|
k, v := splitKV(strings.TrimPrefix(trimmed, "- "))
|
|
if k != "kind" {
|
|
return m, fmt.Errorf("%s: list item outside triggers: %q", name, trimmed)
|
|
}
|
|
m.Triggers = append(m.Triggers, core.PromptTrigger{Kind: v})
|
|
trigger = &m.Triggers[len(m.Triggers)-1]
|
|
continue
|
|
}
|
|
k, v := splitKV(trimmed)
|
|
if indented && trigger != nil {
|
|
switch k {
|
|
case "keywords":
|
|
trigger.Keywords = splitList(v)
|
|
case "command":
|
|
trigger.Command = v
|
|
default:
|
|
return m, fmt.Errorf("%s: unknown trigger field %q", name, k)
|
|
}
|
|
continue
|
|
}
|
|
if indented {
|
|
return m, fmt.Errorf("%s: unexpected indented line %q", name, trimmed)
|
|
}
|
|
if v == "|" {
|
|
blockKey = k
|
|
continue
|
|
}
|
|
switch k {
|
|
case "id":
|
|
m.ID = v
|
|
case "layer":
|
|
m.Layer = v
|
|
case "version":
|
|
m.Version = atoi(v)
|
|
case "title":
|
|
m.Title = v
|
|
case "description":
|
|
m.Description = v
|
|
case "priority":
|
|
m.Priority = atoi(v)
|
|
case "requiresAttachments":
|
|
m.RequiresAttachments = v == "true"
|
|
case "outputContract":
|
|
m.OutputContract = v
|
|
case "triggers":
|
|
// 值留空;列表项由后续 "- kind:" 行提供。
|
|
default:
|
|
return m, fmt.Errorf("%s: unknown field %q", name, k)
|
|
}
|
|
}
|
|
if m.ID == "" || m.Layer == "" || m.Body == "" {
|
|
return m, fmt.Errorf("%s: id/layer/body required", name)
|
|
}
|
|
if m.Version == 0 {
|
|
m.Version = 1
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func appendBlock(m *core.PromptModule, key, line string) {
|
|
switch key {
|
|
case "outputContract":
|
|
if m.OutputContract == "" {
|
|
m.OutputContract = line
|
|
} else {
|
|
m.OutputContract += "\n" + line
|
|
}
|
|
}
|
|
}
|
|
|
|
// splitKV 拆 "key: value"。
|
|
func splitKV(s string) (string, string) {
|
|
i := strings.Index(s, ":")
|
|
if i < 0 {
|
|
return s, ""
|
|
}
|
|
return strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
|
|
}
|
|
|
|
func splitList(s string) []string {
|
|
var out []string
|
|
for _, p := range strings.Split(s, ",") {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func atoi(s string) int {
|
|
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
|
return n
|
|
}
|