ci / vet-and-test (pull_request) Successful in 39s
Why: workspace writes had no checkpoint, and a 5xx before any output stopped the turn instead of trying the next configured model. Evidence: GOWORK=off go test ./internal/runtime/ ./internal/runmgr/ ./internal/tools/ ./internal/settings/ ./internal/server/ ./internal/app/ Impact: models.chat.fallbacks defaults to empty, so existing runs keep their model. Rewind restores only tracked writes and appends a system note. Usage records the model that served. Refs: feature/durable-rewind Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
12 KiB
Go
174 lines
12 KiB
Go
// Package settings 实现分层设置(4.9):system 默认 → user → project → conversation,
|
||
// schema 驱动设置页(生效时机 hot / new-run / restart 显式标注)。
|
||
package settings
|
||
|
||
import (
|
||
"agent-system-backend/internal/store"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// Scope 层级键:user 与 project:<id> 落库;conversation 经会话画像选择实现。
|
||
const (
|
||
ScopeUser = "user"
|
||
ScopeProjectPrefix = "project:"
|
||
)
|
||
|
||
// SystemDefaults 是系统级默认(代码内置,settings.system.json 语义)。
|
||
func SystemDefaults() map[string]any {
|
||
return map[string]any{
|
||
"models": map[string]any{
|
||
"chat": map[string]any{
|
||
"baseUrl": "", // 例:https://api.openai.com/v1 (OpenAI 兼容)
|
||
"apiKey": "",
|
||
"model": "",
|
||
"fallbacks": "", // 逗号分隔;空 = 不回退
|
||
},
|
||
"lightweight": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
|
||
"embedding": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
|
||
"rerank": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
|
||
},
|
||
"tools": map[string]any{
|
||
"tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具)
|
||
},
|
||
"runtime": map[string]any{
|
||
"mock": false, // true = 全部会话走 mock runner(演示/测试)
|
||
"squad": true, // 小队编排(Agent-Squad 引擎)可用
|
||
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
|
||
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
|
||
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
|
||
"verify": true, // 终轮确定性检查与独立验证
|
||
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
|
||
},
|
||
"storage": map[string]any{
|
||
"dataDir": "./data", // 启动注入实际值(--data)
|
||
},
|
||
"context": map[string]any{
|
||
"maxHistoryMessages": 40,
|
||
"maxToolResultChars": 20000,
|
||
},
|
||
"crossSession": map[string]any{
|
||
"read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读
|
||
},
|
||
"retrieval": map[string]any{"topK": 5},
|
||
"ui": map[string]any{"theme": "dark", "cardflow": false},
|
||
"deliverables": map[string]any{
|
||
"style": "modern", // modern / formal / minimal
|
||
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
|
||
"templateArtifactId": "", // 非空 = 该工件作为默认模板,落到 inputs/_default-template*
|
||
},
|
||
"plugins": map[string]any{"dir": "./plugins"},
|
||
}
|
||
}
|
||
|
||
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
|
||
func Schema() []core.SettingDesc {
|
||
return []core.SettingDesc{
|
||
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
|
||
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
|
||
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"},
|
||
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||
{Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"},
|
||
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||
{Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||
{Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
|
||
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
|
||
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
|
||
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
|
||
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
|
||
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
|
||
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
|
||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
|
||
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},
|
||
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
|
||
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
|
||
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"},
|
||
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
|
||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
||
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
||
}
|
||
}
|
||
|
||
// Service 提供有效设置计算与分层写入。
|
||
type Service struct {
|
||
st *store.Store
|
||
system map[string]any // 启动注入的系统层覆盖(如数据目录),位于 defaults 与 user 之间
|
||
}
|
||
|
||
func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||
|
||
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
|
||
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
||
|
||
// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。
|
||
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
||
eff := core.MergeSettings(SystemDefaults(), map[string]any{})
|
||
if s.system != nil {
|
||
eff = core.MergeSettings(eff, s.system)
|
||
}
|
||
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
|
||
eff = core.MergeSettings(eff, user)
|
||
}
|
||
if projectID != "" {
|
||
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
|
||
eff = core.MergeSettings(eff, proj)
|
||
}
|
||
}
|
||
return eff
|
||
}
|
||
|
||
func (s *Service) UserScope(owner string) (map[string]any, error) {
|
||
return s.st.GetSettingsScope(owner, ScopeUser)
|
||
}
|
||
|
||
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
|
||
return s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID)
|
||
}
|
||
|
||
func (s *Service) MergeUser(owner string, values map[string]any) error {
|
||
return s.st.MergeSettingsScope(owner, ScopeUser, values)
|
||
}
|
||
|
||
func (s *Service) MergeProject(owner, projectID string, values map[string]any) error {
|
||
return s.st.MergeSettingsScope(owner, ScopeProjectPrefix+projectID, values)
|
||
}
|
||
|
||
// Masked 返回脱敏视图(secret 类 key 掩码),用于 API 响应。Effective 每次
|
||
// 深合并产出新 map,就地掩码不影响后续计算。
|
||
func Masked(eff core.EffectiveSettings) map[string]any {
|
||
out := map[string]any(eff)
|
||
maskSecrets(out)
|
||
return out
|
||
}
|
||
|
||
func maskSecrets(m map[string]any) {
|
||
for k, v := range m {
|
||
if vm, ok := v.(map[string]any); ok {
|
||
maskSecrets(vm)
|
||
continue
|
||
}
|
||
if isSecretKey(k) {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
m[k] = "••••••"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func isSecretKey(k string) bool {
|
||
return k == "apiKey" || k == "api_key" || k == "secret" || k == "token"
|
||
}
|