Files
Agent-System-Backend/internal/prompts/service.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

469 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package prompts
import (
"embed"
"fmt"
"io/fs"
"regexp"
"sort"
"strings"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
)
//go:embed library/*.md
var libraryFS embed.FS
// DefaultLibrary 解析内置提示词库(embed 文件);解析失败 = 构建期问题,启动即报。
func DefaultLibrary() ([]core.PromptModule, error) {
names, err := fs.Glob(libraryFS, "library/*.md")
if err != nil {
return nil, err
}
var out []core.PromptModule
for _, name := range names {
raw, err := libraryFS.ReadFile(name)
if err != nil {
return nil, err
}
m, err := ParseModuleFile(name, string(raw))
if err != nil {
return nil, err
}
out = append(out, m)
}
// 确定性顺序:库文件名即排序键(base-* 先于 env-* 先于 task-*)。
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// TriggerCtx 触发上下文:当前轮用户输入与附件状态。
type TriggerCtx struct {
Text string
HasAttachments bool
}
// EnvData 环境段占位符数据。
type EnvData struct {
Date string
Weekday string
Model string
Attachments string // 已格式化的附件名列表(空 = "无")
DeliverableStyle string // 默认交付样式(空 = modern)
SkillsDir string // 技能目录绝对路径(空 = "未配置")
Inputs string // 本轮原件的工作区相对路径(空 = "无")
}
// Service 提示词库服务:内置(embed)→ 用户(DB)覆盖链;触发匹配与 system 组装。
type Service struct {
st *store.Store
builtin []core.PromptModule
subagents map[string]SubagentSpec
}
func NewService(st *store.Store, lib []core.PromptModule) (*Service, error) {
seen := map[string]bool{}
for _, m := range lib {
if seen[m.ID] {
return nil, fmt.Errorf("prompts: duplicate id %s", m.ID)
}
seen[m.ID] = true
}
specs, err := builtinSubagentSpecs()
if err != nil {
return nil, err
}
return &Service{st: st, builtin: lib, subagents: specs}, nil
}
// SubagentSpec 以内置 spec 为底,用该 owner 的用户行里非空字段逐项覆盖。
func (s *Service) SubagentSpec(owner, id string) (SubagentSpec, bool) {
if s == nil {
return SubagentSpec{}, false
}
spec, ok := s.subagents[id]
if !ok {
return SubagentSpec{}, false
}
if s.st == nil || owner == "" {
return spec, true
}
user, err := s.st.ListPromptModules(owner)
if err != nil {
return spec, true
}
for _, u := range user {
if u.ID != id {
continue
}
if len(u.Tools) > 0 {
spec.Tools = u.Tools
}
if u.Model != "" {
spec.Model = u.Model
}
if u.MaxTurns > 0 {
spec.MaxTurns = u.MaxTurns
}
break
}
return spec, true
}
func builtinSubagentSpecs() (map[string]SubagentSpec, error) {
names, err := fs.Glob(libraryFS, "library/*.md")
if err != nil {
return nil, err
}
out := map[string]SubagentSpec{}
for _, name := range names {
raw, err := libraryFS.ReadFile(name)
if err != nil {
return nil, err
}
text := string(raw)
mod, err := ParseModuleFile(name, text)
if err != nil {
return nil, err
}
if mod.Layer != "agent" || !strings.HasPrefix(mod.ID, "agent.sub.") {
continue
}
out[mod.ID] = specFromModule(mod)
}
return out, nil
}
// Effective 返回覆盖链合并后的模块全集(内置 + 用户行;用户覆盖内置同 ID)。
// UpdateAvailable = 用户行基于的内置版本落后于当前内置版本。
func (s *Service) Effective(owner string) ([]core.PromptModule, error) {
out := make([]core.PromptModule, len(s.builtin))
copy(out, s.builtin)
if s.st == nil {
return out, nil
}
user, err := s.st.ListPromptModules(owner)
if err != nil {
return nil, err
}
idx := map[string]int{}
for i, m := range out {
idx[m.ID] = i
}
for _, u := range user {
if i, ok := idx[u.ID]; ok {
base := out[i]
u.Source = string(core.SourceUser)
if u.BasedOnVersion < base.Version {
u.Drifted = true
}
out[i] = u
continue
}
u.Source = string(core.SourceUser)
out = append(out, u)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// Get 单模块(覆盖链后视图)。
func (s *Service) Get(owner, id string) (core.PromptModule, bool, error) {
mods, err := s.Effective(owner)
if err != nil {
return core.PromptModule{}, false, err
}
for _, m := range mods {
if m.ID == id {
return m, true, nil
}
}
return core.PromptModule{}, false, nil
}
var dispatchTools = map[string]struct{}{
"delegate_task": {}, "squad_orchestrate": {}, "review_findings": {}, "run_workflow": {},
}
// validateSubagentOverride 只允许 agent.sub.* 携带 tools/model/maxTurns,
// 并且不能把派发类工具加进子代理。
func validateSubagentOverride(m core.PromptModule) error {
if len(m.Tools) == 0 && m.Model == "" && m.MaxTurns == 0 {
return nil
}
if !strings.HasPrefix(m.ID, "agent.sub.") {
return fmt.Errorf("tools/model/maxTurns 只允许出现在 agent.sub.*")
}
if m.Model != "" && m.Model != "lightweight" && m.Model != "chat" {
return fmt.Errorf("model 只能是 lightweight 或 chat")
}
if m.MaxTurns < 0 {
return fmt.Errorf("maxTurns 不能为负;0 表示不限制")
}
for _, name := range m.Tools {
if _, bad := dispatchTools[name]; bad {
return fmt.Errorf("子代理不能使用派发工具 %s", name)
}
}
return nil
}
// Save 用户覆盖/新建:覆盖内置时记录 BasedOnVersion 供 drift 提示。
func (s *Service) Save(owner string, m core.PromptModule) error {
if err := validateSubagentOverride(m); err != nil {
return err
}
m.Source = string(core.SourceUser)
m.Owner = owner
for _, b := range s.builtin {
if b.ID == m.ID {
m.BasedOnVersion = b.Version
break
}
}
return s.st.SavePromptModule(owner, m)
}
// Delete 撤销用户覆盖(回到内置)。
func (s *Service) Delete(owner, id string) error { return s.st.DeletePromptModule(owner, id) }
// MatchCommand 命令触发匹配:文本以某技能命令开头时返回该模块与剩余文本。
func (s *Service) MatchCommand(owner, text string) (core.PromptModule, string, bool, error) {
mods, err := s.Effective(owner)
if err != nil {
return core.PromptModule{}, "", false, err
}
for _, m := range mods {
if m.Disabled {
continue
}
for _, t := range m.Triggers {
if t.Kind != core.TriggerCommand || t.Command == "" {
continue
}
if text == t.Command || strings.HasPrefix(text, t.Command+" ") {
rest := strings.TrimSpace(strings.TrimPrefix(text, t.Command))
return m, rest, true, nil
}
}
}
return core.PromptModule{}, "", false, nil
}
// matchModule 判定单模块是否命中(Triggers 任一命中即真;manual 永不自动命中)。
// command 触发视为显式用户意图:命中即真(不受 RequiresAttachments 约束)。
func matchModule(m core.PromptModule, tc TriggerCtx) (string, bool) {
for _, t := range m.Triggers {
switch t.Kind {
case core.TriggerCommand:
if t.Command != "" && (tc.Text == t.Command || strings.HasPrefix(tc.Text, t.Command+" ")) {
return "command:" + t.Command, true
}
case core.TriggerIntent:
for _, kw := range t.Keywords {
if kw != "" && strings.Contains(tc.Text, kw) {
return "intent:" + kw, true
}
}
case core.TriggerAttachment:
if !tc.HasAttachments {
continue
}
if len(t.Keywords) == 0 {
return "attachment", true
}
for _, kw := range t.Keywords {
if kw != "" && strings.Contains(tc.Text, kw) {
return "attachment:" + kw, true
}
}
}
}
return "", false
}
// ResolveSystem 组装 system 段。
// - 基座/环境/风格:画像 promptModules 引用(空 = 默认 base+env 全集,按 ID 序)
// - 技能(task):对全部生效技能做触发匹配;多命中取优先级最高,其余记录让位
// - RequiresAttachments 只约束 intent 触发(command 为显式意图,不设门槛)
func (s *Service) ResolveSystem(prof core.AgentProfile, tc TriggerCtx, env EnvData, owner string) ([]core.PromptBlock, []core.PromptHitRecord, error) {
mods, err := s.Effective(owner)
if err != nil {
return nil, nil, err
}
byID := map[string]core.PromptModule{}
for _, m := range mods {
byID[m.ID] = m
}
// 基座选取:显式引用(可裁剪基座/启用风格块)或缺省 base+env 全集。
// style 是表达变体,默认不开,须画像 promptModules 显式引用。
pick := func(ids []string) []core.PromptModule {
allowed := []string{core.LayerBase, core.LayerEnv, core.LayerStyle}
if len(ids) > 0 {
var out []core.PromptModule
for _, id := range ids {
if m, ok := byID[id]; ok && !m.Disabled && contains(allowed, m.Layer) {
out = append(out, m)
}
}
return out
}
var out []core.PromptModule
for _, m := range mods {
if m.Disabled || !contains([]string{core.LayerBase, core.LayerEnv}, m.Layer) {
continue
}
out = append(out, m)
}
return out
}
var blocks []core.PromptBlock
var hits []core.PromptHitRecord
addBlock := func(m core.PromptModule, reason string, injected bool) {
hits = append(hits, core.PromptHitRecord{ModuleID: m.ID, Layer: m.Layer, Reason: reason, Injected: injected})
if injected {
blocks = append(blocks, core.PromptBlock{ModuleID: m.ID, Version: m.Version, Layer: m.Layer, Body: resolvePlaceholders(m.Body, env)})
}
}
for _, m := range pick(prof.PromptModules) {
addBlock(m, "always", true)
}
// 技能匹配:task 层全员参与(含未列入画像引用的——技能是系统能力,画像定基座)。
type candidate struct {
m core.PromptModule
reason string
}
var matched []candidate
for _, m := range mods {
if m.Disabled || m.Layer != core.LayerTask {
continue
}
reason, ok := matchModule(m, tc)
if !ok {
continue
}
// 附件门槛只约束 intent 命中;command 是显式用户意图,不设门槛。
if m.RequiresAttachments && !tc.HasAttachments && !strings.HasPrefix(reason, "command:") {
continue
}
matched = append(matched, candidate{m, reason})
}
sort.SliceStable(matched, func(i, j int) bool {
return priority(matched[i].m) > priority(matched[j].m)
})
for i, c := range matched {
if i == 0 {
addBlock(c.m, c.reason, true)
injectMethods(c.m.Body, byID, addBlock)
continue
}
addBlock(c.m, "superseded-by:"+matched[0].m.ID, false)
}
return blocks, hits, nil
}
var methodRef = regexp.MustCompile(`method\.[a-z0-9-]+`)
// injectMethods 把任务正文点名的方法模块一并注入。方法是 aux 层,
// 不参与 task 优先级竞争,所以纪要可以同时带上提炼和时间线,而不会挤掉任务本身。
func injectMethods(body string, byID map[string]core.PromptModule, add func(core.PromptModule, string, bool)) {
seen := map[string]bool{}
for _, id := range methodRef.FindAllString(body, -1) {
if seen[id] {
continue
}
seen[id] = true
m, ok := byID[id]
if !ok || m.Disabled || m.Layer != core.LayerAux {
continue
}
add(m, "method-of-task", true)
}
}
// priority 缺省 50;大者优先。
func priority(m core.PromptModule) int {
if m.Priority == 0 {
return 50
}
return m.Priority
}
// AgentPrompt 取子代理/角色提示词(agent 层;缺省回落 general)。
func (s *Service) AgentPrompt(owner, id string) (string, error) {
m, ok, err := s.Get(owner, id)
if err != nil {
return "", err
}
if !ok || m.Layer != core.LayerAgent {
return "", fmt.Errorf("prompts: agent module %q not found", id)
}
return m.Body, nil
}
// ModuleBody 取指定模块正文(用户覆盖优先;found=false 表示无此模块)。
// 供设置驱动的模块注入(如 ui.cardflow → style.cardflow)——不走 PromptModules 显式引用,
// 避免向空 PromptModules 追加会翻转 pick() 的"显式引用"语义。
func (s *Service) ModuleBody(owner, id string) (string, bool, error) {
m, ok, err := s.Get(owner, id)
if err != nil || !ok {
return "", false, err
}
return m.Body, true, nil
}
// ToolPrompt 取工具描述文案(tool 层;found=false 表示库未覆盖该工具,调用方回落注册表描述)。
func (s *Service) ToolPrompt(owner, toolName string) (string, bool, error) {
m, ok, err := s.Get(owner, "tool."+toolName)
if err != nil || !ok {
return "", false, err
}
return m.Body, true, nil
}
// resolvePlaceholders 填充 {{key}};未知占位符原样保留(可见即信号)。
func resolvePlaceholders(body string, env EnvData) string {
repl := map[string]string{
"date": env.Date,
"weekday": env.Weekday,
"model": env.Model,
"attachments": env.Attachments,
"deliverableStyle": env.DeliverableStyle,
"skillsDir": env.SkillsDir,
"inputs": env.Inputs,
}
if repl["attachments"] == "" {
repl["attachments"] = "无"
}
if repl["inputs"] == "" {
repl["inputs"] = "无"
}
if repl["deliverableStyle"] == "" {
repl["deliverableStyle"] = "modern"
}
if repl["skillsDir"] == "" {
repl["skillsDir"] = "未配置"
}
for k, v := range repl {
if v == "" {
v = "未知" // 日期/星期/模型缺失不裸露占位符
}
body = strings.ReplaceAll(body, "{{"+k+"}}", v)
}
return body
}
func contains(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}