Files
Agent-System-Backend/internal/settings/settings.go
T
weichen 2c5c68d16c Add layered cross-session reading with gated peer tools
Why: conversation transcripts were the only project channel not
readable across sessions (artifacts/knowledge are already shared),
which caused duplicate parallel work; see proposal
2026-09-30-cross-session-reading.

Evidence: runmgr/crossread_test.go covers the full gate matrix
(project gate on/off x outbound flag x private target x cross-project
x cross-tenant x self-read x archived x limit clamping) against a real
store + settings chain; store tests cover migration v11, flag
persistence and tenant isolation; tools/peers_test.go covers args
validation, transcript rendering and the untrusted-content banner;
go test ./... green.

Impact: setting crossSession.read (default off, schema-driven);
conversation columns allow_cross_read (default true) and cross_private
(default false) with PATCH support; builtin tools list_conversations
and read_conversation gated fail-closed in runmgr toolCtx; nested
agent-system-core pin bumped to fd44179 (Core PR #6).

Refs: Agent-System-Documents proposals/2026-09-30-cross-session-reading.md
(Dev task feature/cross-session-reading).
2026-09-30 15:13:36 +00:00

154 lines
8.7 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 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": "",
},
"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 引擎)可用
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
},
"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"},
"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.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.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.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: "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: "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: "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"
}