Why: A parent turn should be able to finish while children keep working, and a review should keep only findings that a second pass confirms. Users also need to override a profile's tools, model, and turn cap without adding dispatch tools. Evidence: go test ./... passed. New cases cover overlay rejection, role mapping, queued notices, and dropping FAIL or unparsed findings. Impact: Notices live only in memory, and each conversation allows three background children. Squad writer steps now use the writer profile. Refs: feature/agent-harness-p3b Co-authored-by: Cursor <cursoragent@cursor.com>
192 lines
4.8 KiB
Go
192 lines
4.8 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:" 行提供。
|
|
case "tools":
|
|
m.Tools = splitList(v)
|
|
case "model":
|
|
m.Model = v
|
|
case "maxTurns":
|
|
m.MaxTurns = atoi(v)
|
|
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
|
|
}
|
|
|
|
// SubagentSpec 是子代理 profile。空字段表示沿用内置。
|
|
type SubagentSpec struct {
|
|
Tools []string
|
|
Model string
|
|
MaxTurns int
|
|
}
|
|
|
|
// specFromModule 由模块字段派生 profile。未写的 model / maxTurns 用默认值。
|
|
func specFromModule(m core.PromptModule) SubagentSpec {
|
|
spec := SubagentSpec{Tools: m.Tools, Model: m.Model, MaxTurns: m.MaxTurns}
|
|
if spec.Model == "" {
|
|
spec.Model = "lightweight"
|
|
}
|
|
if spec.MaxTurns <= 0 {
|
|
spec.MaxTurns = 8
|
|
}
|
|
return spec
|
|
}
|
|
|
|
// ParseSubagentSpec 从库文件 frontmatter 取出 tools、model、maxTurns。
|
|
// 标准模块字段可以同时出现。其他未知键报错。
|
|
func ParseSubagentSpec(content string) (SubagentSpec, error) {
|
|
m, err := ParseModuleFile("subagent", content)
|
|
if err != nil {
|
|
return SubagentSpec{}, err
|
|
}
|
|
return specFromModule(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
|
|
}
|