Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
490 lines
14 KiB
Go
490 lines
14 KiB
Go
package prompts
|
||
|
||
import (
|
||
"embed"
|
||
"fmt"
|
||
"io/fs"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
|
||
"agent-system-backend/internal/store"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
//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) }
|
||
|
||
// SaveAccountChecked applies the same user override rules as Save and uses
|
||
// the Store's final actor-state transaction for the database write.
|
||
func (s *Service) SaveAccountChecked(actor string, m core.PromptModule, allowImplicitDefault bool) error {
|
||
if err := validateSubagentOverride(m); err != nil {
|
||
return err
|
||
}
|
||
m.Source = string(core.SourceUser)
|
||
m.Owner = actor
|
||
for _, b := range s.builtin {
|
||
if b.ID == m.ID {
|
||
m.BasedOnVersion = b.Version
|
||
break
|
||
}
|
||
}
|
||
return s.st.SavePromptModuleAccountChecked(actor, m, allowImplicitDefault)
|
||
}
|
||
|
||
func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||
return s.st.DeletePromptModuleAccountChecked(actor, id, allowImplicitDefault)
|
||
}
|
||
|
||
// 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
|
||
}
|