Files
Agent-System-Backend/internal/prompts/service.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
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.
2026-10-14 07:33:54 +00:00

490 lines
14 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"
"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
}