Store secrets apart from settings and guard open listeners #63

Merged
weichen merged 4 commits from feature/sillytavern-review into main 2026-10-04 19:42:36 +00:00
29 changed files with 1926 additions and 74 deletions
+2 -1
View File
@@ -14,7 +14,8 @@
## 配置(启动层)
- 来源优先级:内置默认 → 显式配置文件(`--config <json>`,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。
- 来源优先级:内置默认 → 显式配置文件(`--config <json>`,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK/INSTANCE/INSECURE_LISTEN/ALLOWED_HOSTS`)→ 显式 CLI(未传入的 flag 不覆盖)。
- 监听守卫:非回环地址且未开认证时拒绝启动(在打开数据库之前)。出路是改用 `127.0.0.1`、`--auth on`,或显式 `--insecure-listen`(每次启动警告)。`config explain` 会打印 `listen guard`。
- 只读诊断:`agent-system config explain [--config …] [--<flag> …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。
- 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。
- 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。
+25 -6
View File
@@ -42,13 +42,15 @@ func main() {
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)")
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
fs.Parse(os.Args[1:])
cliSet := map[string]bool{}
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
cliSet := visited(fs)
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -57,13 +59,29 @@ func main() {
if err := app.Run(app.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
InstanceID: resolved.Instance,
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
}
// visited 把连字符 flag 收成配置字段名,供 Load 的 cliSet 使用。
func visited(fs *flag.FlagSet) map[string]bool {
set := map[string]bool{}
fs.Visit(func(f *flag.Flag) {
name := f.Name
switch name {
case "insecure-listen":
name = "insecureListen"
case "allowed-hosts":
name = "allowedHosts"
}
set[name] = true
})
return set
}
// runConfig:config explain——只读解析诊断(不建库、不写业务目录;DSN 脱敏)。
func runConfig(args []string) int {
if len(args) == 0 || args[0] != "explain" {
@@ -80,13 +98,14 @@ func runConfig(args []string) int {
artifacts := fs.String("artifacts", "", "")
auth := fs.String("auth", "auto", "")
instance := fs.String("instance", "", "")
insecure := fs.Bool("insecure-listen", false, "")
allowedHosts := fs.String("allowed-hosts", "", "")
fs.Parse(args[1:])
cliSet := map[string]bool{}
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
}, cliSet)
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
return 1
+46 -12
View File
@@ -33,6 +33,7 @@ import (
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/config"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/pgbus"
@@ -54,14 +55,16 @@ import (
)
type Options struct {
Addr string
DataDir string
PluginsDir string
ForceMock bool
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
Addr string
DataDir string
PluginsDir string
ForceMock bool
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
InsecureListen bool
AllowedHosts []string
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -71,6 +74,15 @@ func Run(opt Options) error {
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
})
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir)
resolved := config.Resolved{Config: config.Config{
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
}}
if msg := resolved.ListenRefusal(); msg != "" {
return fmt.Errorf("listen guard: %s", msg)
}
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
}
traceFn := telemetry.StartFunc(logger)
var st *store.Store
var err error
@@ -123,9 +135,25 @@ func Run(opt Options) error {
if norm.DottedKeys > 0 {
log.Printf("settings: normalized %d dotted key(s) in %d scope(s)", norm.DottedKeys, norm.Scopes)
}
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
return fmt.Errorf("migrate secrets: %w", err)
}
runtime.SecretLookup = func(owner, service string) (string, bool) {
if owner == "" {
owner = store.OwnerDefault
}
return st.ActiveSecret(owner, service)
}
rag.SecretLookup = func(service, fallback string) string {
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
}
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
eff := setSvc.Effective(store.OwnerDefault, "")
if keys := splitKeyPool(eff.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
tavilyRaw := eff.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyRaw = active
}
if keys := splitKeyPool(tavilyRaw); len(keys) > 0 {
provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys)
if err := tools.RegisterSearchTools(reg, provider); err != nil {
return fmt.Errorf("search tools: %w", err)
@@ -189,7 +217,11 @@ func Run(opt Options) error {
return nil
},
}
if keys := splitKeyPool(effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
}
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
}
if effNow.GetBool("runtime.exec", true) {
@@ -370,7 +402,7 @@ func Run(opt Options) error {
return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0))
})
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
authOn := opt.AuthMode == "on" || (opt.AuthMode != "off" && opt.StorageDSN != "")
authOn := resolved.AuthEnabled()
srv.Auth = server.NewAuthenticator(st, authOn)
if authOn {
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
@@ -379,7 +411,9 @@ func Run(opt Options) error {
return b.Subscribe(conversationID)
}
httpSrv := &http.Server{Addr: opt.Addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{
AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(),
}}
go func() {
log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir)
+21
View File
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
@@ -105,6 +106,26 @@ func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Han
return nil
}
// RegisterUser 登记用户提示词命令。重复登记视为已存在。执行不走总线,发送时展开模板。
func (b *Bus) RegisterUser(spec agentcommand.CommandSpec, slash string) error {
spec.Binding = agentcommand.Binding{Kind: agentcommand.BindMessage}
if err := b.reg.Register(spec, agentcommand.SourceUser); err != nil && !errors.Is(err, agentcommand.ErrDuplicate) {
return err
}
if slash != "" {
b.slash[strings.ToLower(slash)] = spec.ID
}
return nil
}
// UnregisterUser 卸下用户命令。不存在时忽略。
func (b *Bus) UnregisterUser(id, slash string) {
_ = b.reg.Unregister(id)
if slash != "" {
delete(b.slash, strings.ToLower(slash))
}
}
// RegisterPluginCommands 插件命令 overlay(manifest provides.commands;失败 fail-closed 返回)。
func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
for _, raw := range overlay {
+111 -27
View File
@@ -9,20 +9,23 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"os"
"strings"
)
// Config 启动部署连接类字段(与 app.Options 一一对应)。
type Config struct {
Addr string `json:"addr"`
DataDir string `json:"data"`
PluginsDir string `json:"plugins"`
StorageDSN string `json:"dsn"`
ArtifactsDir string `json:"artifacts"`
AuthMode string `json:"auth"`
Mock bool `json:"mock"`
Instance string `json:"instance"`
Addr string `json:"addr"`
DataDir string `json:"data"`
PluginsDir string `json:"plugins"`
StorageDSN string `json:"dsn"`
ArtifactsDir string `json:"artifacts"`
AuthMode string `json:"auth"`
Mock bool `json:"mock"`
Instance string `json:"instance"`
InsecureListen bool `json:"insecureListen"`
AllowedHostsRaw string `json:"allowedHosts"`
}
var defaults = Config{
@@ -34,14 +37,16 @@ var defaults = Config{
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
var envKey = map[string]string{
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
}
// Source 配置来源层级(数值越大优先级越高)。
@@ -91,8 +96,8 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
}
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
for f := range present {
if f == "mock" {
cfg.Mock = fc.Mock
if isBoolField(f) {
setBool(&cfg, f, boolField(fc, f))
} else {
setString(&cfg, f, stringField(fc, f))
}
@@ -102,12 +107,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
// 环境层:变量存在即参与覆盖(空串按显式空值处理,字段 schema 自行判定)。
for f, key := range envKey {
if v, ok := envLookup(key); ok {
if f == "mock" {
if isBoolField(f) {
b, err := parseBool(key, v)
if err != nil {
return Resolved{}, err
}
cfg.Mock = b
setBool(&cfg, f, b)
} else {
setString(&cfg, f, v)
}
@@ -117,8 +122,8 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
// CLI 层:仅显式传入。
for f := range cliSet {
src[f] = SrcCLI
if f == "mock" {
cfg.Mock = cli.Mock
if isBoolField(f) {
setBool(&cfg, f, boolField(cli, f))
} else {
setString(&cfg, f, stringField(cli, f))
}
@@ -156,6 +161,10 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.Mock
case "instance":
dst = &c.Instance
case "insecureListen":
dst = &c.InsecureListen
case "allowedHosts":
dst = &c.AllowedHostsRaw
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
@@ -183,10 +192,34 @@ func stringField(c Config, f string) string {
return c.AuthMode
case "instance":
return c.Instance
case "allowedHosts":
return c.AllowedHostsRaw
}
return ""
}
func isBoolField(f string) bool {
return f == "mock" || f == "insecureListen"
}
func boolField(c Config, f string) bool {
switch f {
case "insecureListen":
return c.InsecureListen
default:
return c.Mock
}
}
func setBool(c *Config, f string, v bool) {
switch f {
case "insecureListen":
c.InsecureListen = v
default:
c.Mock = v
}
}
func setString(c *Config, f, v string) {
switch f {
case "addr":
@@ -203,6 +236,8 @@ func setString(c *Config, f, v string) {
c.AuthMode = v
case "instance":
c.Instance = v
case "allowedHosts":
c.AllowedHostsRaw = v
}
}
@@ -223,17 +258,66 @@ func (r Resolved) Explain(w io.Writer, file string) {
} else {
fmt.Fprintln(w, "config file: (none)")
}
fmt.Fprintln(w, "field value source")
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance"} {
fmt.Fprintln(w, "field value source")
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts"} {
v := stringField(r.Config, f)
if f == "mock" {
v = fmt.Sprintf("%t", r.Mock)
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
}
if f == "dsn" {
v = redact(v)
}
fmt.Fprintf(w, "%-10s %-56s %s\n", f, v, r.sources[f])
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
}
if msg := r.ListenRefusal(); msg != "" {
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
fmt.Fprintln(w, "listen guard: 放行(已显式 insecureListen,无认证监听非回环地址)")
} else {
fmt.Fprintln(w, "listen guard: 放行")
}
}
// AuthEnabled 是认证开关的唯一判定:显式 on,或 auto 且配置了 PostgreSQL。
func (r Resolved) AuthEnabled() bool {
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
}
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
func (r Resolved) AllowedHosts() []string {
var out []string
for _, part := range strings.Split(r.AllowedHostsRaw, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// ListenRefusal 在非回环、未开认证、且没有显式放行时返回拒绝原因。空串表示可以启动。
func (r Resolved) ListenRefusal() string {
if r.AuthEnabled() || r.InsecureListen || IsLoopbackListen(r.Addr) {
return ""
}
return "非回环且未开认证。改用 127.0.0.1、加上 --auth on,或显式 --insecure-listen"
}
// IsLoopbackListen 判断监听地址是否只绑回环。空主机、0.0.0.0、:: 视为所有网卡。
func IsLoopbackListen(addr string) bool {
host := addr
if h, _, err := net.SplitHostPort(addr); err == nil {
host = h
}
host = strings.Trim(strings.ToLower(host), "[]")
switch host {
case "", "0.0.0.0", "::":
return false
case "localhost":
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// redact 脱敏 DSN:scheme://user:password@… → scheme://user:***@…。
+40
View File
@@ -148,3 +148,43 @@ func TestExplainRedactsDSN(t *testing.T) {
t.Fatalf("source column missing: %s", out)
}
}
func TestListenGuard(t *testing.T) {
cases := []struct {
addr string
ok bool
}{
{"127.0.0.1:8787", true},
{"localhost:8787", true},
{"[::1]:8787", true},
{":8787", false},
{"0.0.0.0:8787", false},
{"[::]:8787", false},
{"192.168.1.9:8787", false},
}
for _, tc := range cases {
if got := IsLoopbackListen(tc.addr); got != tc.ok {
t.Errorf("%s loopback=%v want %v", tc.addr, got, tc.ok)
}
}
refused, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788"}, map[string]bool{"addr": true})
if err != nil {
t.Fatal(err)
}
if refused.ListenRefusal() == "" {
t.Fatal("non-loopback without auth must refuse")
}
var b bytes.Buffer
refused.Explain(&b, "")
if !bytes.Contains(b.Bytes(), []byte("拒绝启动")) {
t.Fatalf("explain missing refusal: %s", b.String())
}
allowed, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788", InsecureListen: true}, map[string]bool{"addr": true, "insecureListen": true})
if err != nil || allowed.ListenRefusal() != "" {
t.Fatalf("explicit insecure listen should pass: %v %q", err, allowed.ListenRefusal())
}
authed, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788", AuthMode: "on"}, map[string]bool{"addr": true, "auth": true})
if err != nil || !authed.AuthEnabled() || authed.ListenRefusal() != "" {
t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal())
}
}
+7 -6
View File
@@ -15,9 +15,11 @@ import (
// Report 记录一次组装的实际构成(可观测:模型看到了什么、裁了什么)。
type Report struct {
Items []string `json:"items"`
Truncations []string `json:"truncations,omitempty"`
EstChars int `json:"estChars"`
Items []string `json:"items"`
Truncations []string `json:"truncations,omitempty"`
EstChars int `json:"estChars"`
Sections []Section `json:"sections,omitempty"`
PrefixHash string `json:"prefixHash,omitempty"`
}
// DefaultMaxAttachmentChars 单个附件注入模型的默认字符上限(settings context.maxAttachmentChars 可调)。
@@ -57,6 +59,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
if maxHist <= 0 {
maxHist = 40
}
history = visibleHistory(history)
// 历史窗口(不含当前轮——当前轮单独注入附件与检索)。
start := 0
if len(history) > maxHist {
@@ -130,9 +133,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
}
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()})
for _, m := range msgs {
rep.EstChars += len(m.Content)
}
fillEstimate(msgs, rep)
return msgs, rep
}
+283
View File
@@ -0,0 +1,283 @@
package ctxasm
import (
"crypto/sha256"
"encoding/hex"
"math"
"regexp"
"strconv"
"strings"
"sync"
agentllm "agent-native/runtime/llm"
"agent-native/runtime/reminders"
core "agent-system-core"
)
// Injection 是一条在组装末尾才决定是否注入的条件条目。
type Injection struct {
Keys []string
Secondary string
Logic string
Content string
Constant bool
ScanDepth int
BudgetChars int
}
// Tail 是前缀稳定之后附在末尾的个人指令、会话要求和条件注入。
type Tail struct {
Instructions string
Note string
Entries []Injection
History []core.Message
Window int
}
var calib struct {
mu sync.Mutex
est int
actual int
}
// NotePromptUsage 用上一请求的真实 prompt token 校准后续估算。
func NotePromptUsage(est, actual int) {
if est <= 0 || actual <= 0 {
return
}
calib.mu.Lock()
calib.est = est
calib.actual = actual
calib.mu.Unlock()
}
func scaleTokens(est int) int {
calib.mu.Lock()
defer calib.mu.Unlock()
if est <= 0 || calib.est <= 0 || calib.actual <= 0 {
return est
}
return est * calib.actual / calib.est
}
// EstimateTokens 用 UTF-8 字节数 / 3.35 估算 token。
func EstimateTokens(text string) int {
n := len([]byte(text))
if n == 0 {
return 0
}
return int(math.Ceil(float64(n) / 3.35))
}
func visibleHistory(history []core.Message) []core.Message {
best := map[string]int{}
for i, m := range history {
if m.Excluded || m.VariantGroup == "" {
continue
}
prev, ok := best[m.VariantGroup]
if !ok || m.VariantIndex >= history[prev].VariantIndex {
best[m.VariantGroup] = i
}
}
out := make([]core.Message, 0, len(history))
for i, m := range history {
if m.Excluded {
continue
}
if m.VariantGroup != "" && best[m.VariantGroup] != i {
continue
}
out = append(out, m)
}
return out
}
// FinishAssembly 把末尾提醒附上、按窗口丢掉最老的历史,并写分段估算与前缀哈希。
func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm.Message, *Report) {
if rep == nil {
rep = &Report{}
}
notes := tailNotes(tail)
reserve := 0
for _, n := range notes {
reserve += EstimateTokens(n) + 8
}
msgs = trimOldest(msgs, tail.Window, reserve, rep)
msgs = reminders.Attach(msgs, notes)
fillEstimate(msgs, rep)
return msgs, rep
}
func tailNotes(tail Tail) []string {
var notes []string
if text := strings.TrimSpace(tail.Instructions); text != "" {
notes = append(notes, "个人指令:\n"+text)
}
if text := strings.TrimSpace(tail.Note); text != "" {
notes = append(notes, "本会话要求:\n"+text)
}
for _, e := range tail.Entries {
if strings.TrimSpace(e.Content) == "" {
continue
}
if !e.Constant && !entryHit(e, tail.History) {
continue
}
body := e.Content
if e.BudgetChars > 0 {
r := []rune(body)
if len(r) > e.BudgetChars {
body = string(r[:e.BudgetChars])
}
}
notes = append(notes, body)
}
return notes
}
func entryHit(e Injection, history []core.Message) bool {
if len(e.Keys) == 0 {
return false
}
depth := e.ScanDepth
if depth <= 0 {
depth = 4
}
start := 0
if len(history) > depth {
start = len(history) - depth
}
var b strings.Builder
for _, m := range history[start:] {
for _, p := range m.Parts {
if p.Type == core.PartText {
b.WriteString(p.Text)
b.WriteByte('\n')
}
}
}
text := b.String()
primary := matchAny(e.Keys, text)
sec := strings.TrimSpace(e.Secondary)
if sec == "" {
return primary
}
secondary := matchOne(sec, text)
switch e.Logic {
case "or":
return primary || secondary
case "not":
return primary && !secondary
default:
return primary && secondary
}
}
func matchAny(keys []string, text string) bool {
for _, k := range keys {
if matchOne(k, text) {
return true
}
}
return false
}
func matchOne(key, text string) bool {
key = strings.TrimSpace(key)
if key == "" {
return false
}
if len(key) > 2 && strings.HasPrefix(key, "/") && strings.HasSuffix(key, "/") {
re, err := regexp.Compile(key[1 : len(key)-1])
if err == nil {
return re.MatchString(text)
}
}
return strings.Contains(strings.ToLower(text), strings.ToLower(key))
}
func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []agentllm.Message {
if window <= 0 {
return msgs
}
limit := window - reserve
if limit < 1 {
limit = 1
}
dropped := 0
for estimateAll(msgs) > limit {
idx := oldestDrop(msgs)
if idx < 0 {
break
}
msgs = append(msgs[:idx], msgs[idx+1:]...)
dropped++
}
if dropped > 0 {
rep.Truncations = append(rep.Truncations, fmtDropped(dropped))
}
return msgs
}
func fmtDropped(n int) string {
return "按上下文窗口丢掉最早的 " + itoa(n) + " 条历史"
}
func itoa(n int) string { return strconv.Itoa(n) }
func oldestDrop(msgs []agentllm.Message) int {
if len(msgs) < 3 {
return -1
}
// 保留开头的系统前缀和最后一条(当前轮)。
start := 0
for start < len(msgs) && msgs[start].Role == agentllm.RoleSystem {
start++
}
if start >= len(msgs)-1 {
return -1
}
return start
}
func estimateAll(msgs []agentllm.Message) int {
n := 0
for _, m := range msgs {
n += EstimateTokens(m.Content)
}
return n
}
func fillEstimate(msgs []agentllm.Message, rep *Report) {
sys, hist, cur := 0, 0, 0
var prefix strings.Builder
last := len(msgs) - 1
for i, m := range msgs {
tok := EstimateTokens(m.Content)
switch {
case m.Role == agentllm.RoleSystem && i < last:
sys += tok
prefix.WriteString(m.Content)
prefix.WriteByte('\n')
case i == last:
cur += tok
default:
hist += tok
}
}
rep.Sections = []Section{
{Name: "system", EstTokens: scaleTokens(sys)},
{Name: "history", EstTokens: scaleTokens(hist)},
{Name: "current", EstTokens: scaleTokens(cur)},
}
sum := sha256.Sum256([]byte(prefix.String()))
rep.PrefixHash = hex.EncodeToString(sum[:8])
rep.EstChars = scaleTokens(sys + hist + cur)
}
// Section 是一段上下文的估算。
type Section struct {
Name string `json:"name"`
EstTokens int `json:"estTokens"`
}
+64
View File
@@ -0,0 +1,64 @@
package ctxasm
import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) {
hist := []core.Message{
{ID: "u", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "问"}}},
{ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}},
{ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}},
}
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil)
blob := ""
for _, m := range msgs {
blob += m.Content
}
if strings.Contains(blob, "旧") || !strings.Contains(blob, "新") {
t.Fatalf("history = %q", blob)
}
}
func TestFinishAssemblyAttachesNoteWithoutTouchingSystem(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "前缀"},
{Role: agentllm.RoleUser, Content: "你好"},
}
out, rep := FinishAssembly(msgs, &Report{Items: []string{"system"}}, Tail{
Note: "用短句",
Entries: []Injection{{
Keys: []string{"你好"}, Content: "提到问候时补充天气", ScanDepth: 2, BudgetChars: 100,
}},
History: []core.Message{{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "你好"}}}},
Window: 32000,
})
if out[0].Content != "前缀" {
t.Fatal("system prefix changed")
}
if !strings.Contains(out[len(out)-1].Content, "本会话要求") || !strings.Contains(out[len(out)-1].Content, "天气") {
t.Fatalf("tail = %q", out[len(out)-1].Content)
}
if rep.PrefixHash == "" || rep.EstChars <= 0 || len(rep.Sections) != 3 {
t.Fatalf("report = %#v", rep)
}
}
func TestTrimDropsOldestHistory(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧", 400)},
{Role: agentllm.RoleUser, Content: "现在"},
}
out, rep := FinishAssembly(msgs, &Report{}, Tail{Window: 30})
if len(out) != 2 {
t.Fatalf("kept %d", len(out))
}
if len(rep.Truncations) == 0 {
t.Fatal("expected truncation")
}
}
+12 -2
View File
@@ -27,6 +27,16 @@ func NewPipeline(st *store.Store, set *settings.Service, embedder core.Embedding
return &Pipeline{st: st, set: set, chunker: FixedChunker{}, embedder: embedder, reranker: reranker}
}
// SecretLookup 由组合根注入。service 是 models.embedding / models.rerank。
var SecretLookup func(service, fallback string) string
func lookupSecret(service, fallback string) string {
if SecretLookup != nil {
return SecretLookup(service, fallback)
}
return fallback
}
// SetEmbedder/SetReranker 允许组合根后注入(embedder 的路由解析依赖本管线自身)。
func (p *Pipeline) SetEmbedder(e core.EmbeddingProvider) { p.embedder = e }
func (p *Pipeline) SetReranker(r core.Reranker) { p.reranker = r }
@@ -41,7 +51,7 @@ func (p *Pipeline) embeddingRoute(ctx context.Context) Route {
eff := p.set.Effective(store.OwnerDefault, "")
return Route{
Base: eff.GetString("models.embedding.baseUrl", ""),
Key: eff.GetString("models.embedding.apiKey", ""),
Key: lookupSecret("models.embedding", eff.GetString("models.embedding.apiKey", "")),
Model: eff.GetString("models.embedding.model", ""),
}
}
@@ -50,7 +60,7 @@ func (p *Pipeline) rerankRoute(ctx context.Context) Route {
eff := p.set.Effective(store.OwnerDefault, "")
return Route{
Base: eff.GetString("models.rerank.baseUrl", ""),
Key: eff.GetString("models.rerank.apiKey", ""),
Key: lookupSecret("models.rerank", eff.GetString("models.rerank.apiKey", "")),
Model: eff.GetString("models.rerank.model", ""),
}
}
+42
View File
@@ -312,6 +312,44 @@ func (c *runCtx) SetContextObservation(o core.ContextObservation) {
c.mgr.mu.Unlock()
}
func (c *runCtx) ContextObservation() *core.ContextObservation {
c.mgr.mu.Lock()
defer c.mgr.mu.Unlock()
return c.h.ctxObs
}
func (c *runCtx) SessionNote() string {
if c.mgr == nil || c.mgr.st == nil {
return ""
}
conv, err := c.mgr.st.GetConversation(c.run.Owner, c.run.ConversationID)
if err != nil {
return ""
}
return conv.SessionNote
}
func (c *runCtx) Injections() []runtime.ContextInjection {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
list, err := c.mgr.st.ListContextEntries(c.run.Owner, c.run.ProjectID, c.run.ConversationID)
if err != nil {
return nil
}
out := make([]runtime.ContextInjection, 0, len(list))
for _, e := range list {
if !e.Enabled {
continue
}
out = append(out, runtime.ContextInjection{
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
})
}
return out
}
func (c *runCtx) Memories() []core.MemoryEntry {
if c.mgr == nil || c.mgr.st == nil {
return nil
@@ -1019,6 +1057,10 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
var msg *core.Message
if len(parts) > 0 {
msg = &core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
if group, index, ok := m.st.TakeRegenPending(rc.run.ConversationID); ok {
msg.VariantGroup = group
msg.VariantIndex = index
}
}
updated, err := m.st.SettleRun(&rc.run, msg)
if err != nil {
+26 -1
View File
@@ -231,9 +231,31 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
sys = ctxasm.InjectMemory(sys, ms.Memories())
}
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
tail := ctxasm.Tail{
Instructions: eff.GetString("context.userInstructions", ""),
History: hist,
Window: int(eff.GetNumber("context.contextWindow", 32000)),
}
if note, ok := rc.(interface{ SessionNote() string }); ok {
tail.Note = note.SessionNote()
}
if src, ok := rc.(interface{ Injections() []ContextInjection }); ok {
for _, e := range src.Injections() {
tail.Entries = append(tail.Entries, ctxasm.Injection{
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
})
}
}
msgs, asmReport = ctxasm.FinishAssembly(msgs, asmReport, tail)
if asmReport != nil {
sections := make([]core.ContextSection, len(asmReport.Sections))
for i, sec := range asmReport.Sections {
sections[i] = core.ContextSection{Name: sec.Name, EstTokens: sec.EstTokens}
}
rc.SetContextObservation(core.ContextObservation{
Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars,
Sections: sections, PrefixHash: asmReport.PrefixHash,
})
}
@@ -290,7 +312,10 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
decls = applyToolSelection(decls, eff.GetString("runtime.toolSelection", ""))
maxTurns, maxTools := loopCaps(prof.Budget)
streamOpts := agentllm.StreamOptions{MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens")}
streamOpts := agentllm.StreamOptions{
MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"),
MessageShape: eff.GetString("context.messageShape", "merge"),
}
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
}
+11
View File
@@ -13,6 +13,7 @@ import (
agentllm "agent-native/runtime/llm"
"agent-native/runtime/reminders"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/squadadapter"
core "agent-system-core"
)
@@ -128,6 +129,9 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
if streamOpts.MaxTokens > 0 {
opts.MaxTokens = streamOpts.MaxTokens
}
if streamOpts.MessageShape != "" {
opts.MessageShape = streamOpts.MessageShape
}
in := stream(ctx, model, messages, decls, opts)
out := make(chan agentllm.Event)
go func() {
@@ -276,6 +280,13 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.turnThink.WriteString(ev.Text)
_ = st.rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: st.thinkingIdx, Text: ev.Text})
case "message_end":
if ev.PromptTokens > 0 {
if src, ok := st.rc.(interface {
ContextObservation() *core.ContextObservation
}); ok && src.ContextObservation() != nil {
ctxasm.NotePromptUsage(src.ContextObservation().EstChars, ev.PromptTokens)
}
}
st.usageTotal.PromptTokens += ev.PromptTokens
st.usageTotal.CompletionTokens += ev.CompletionTokens
st.usageTotal.TotalTokens += ev.TotalTokens
+34 -2
View File
@@ -6,10 +6,42 @@ import (
core "agent-system-core"
)
// SecretLookup 由组合根注入。返回该服务当前生效的明文;没有独立密钥时 ok 为 false,调用方用设置里的旧字段。
var SecretLookup func(owner, service string) (string, bool)
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
func LookupSecret(owner, service, fallback string) string {
return overlayKey(owner, service, fallback)
}
func overlayKey(owner, service, fallback string) string {
if SecretLookup == nil {
return fallback
}
if owner == "" {
owner = "local"
}
if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
return v
}
return fallback
}
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
type ContextInjection struct {
Keys []string
Secondary string
Logic string
Content string
Constant bool
ScanDepth int
BudgetChars int
}
// ResolvedModel 是一次路由解析的结果。
type ResolvedModel struct {
Kind string // chat | lightweight
@@ -26,7 +58,7 @@ func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) Resolv
}
return ResolvedModel{Kind: "chat", Model: m,
Base: eff.GetString("models.chat.baseUrl", ""),
Key: eff.GetString("models.chat.apiKey", "")}
Key: overlayKey(prof.Owner, "models.chat", eff.GetString("models.chat.apiKey", ""))}
}
// ChatFallbacks 解析 models.chat.fallbacks。默认空,不改变现有模型选择。
@@ -52,7 +84,7 @@ func ChatFallbacks(eff core.EffectiveSettings) []string {
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.lightweight.model", "")
base := eff.GetString("models.lightweight.baseUrl", "")
key := eff.GetString("models.lightweight.apiKey", "")
key := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", ""))
if prof.Model.Lightweight != "" {
m = prof.Model.Lightweight
}
+287
View File
@@ -0,0 +1,287 @@
package server
import (
"encoding/json"
"net/http"
"regexp"
"strings"
agentcommand "agent-command"
"agent-system-backend/internal/store"
core "agent-system-core"
)
var userCommandSlug = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
func (s *Server) listSecrets(w http.ResponseWriter, r *http.Request) {
list, err := s.st.ListSecrets(s.owner(r))
writeJSONOr(w, list, err)
}
func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
var req struct {
Service string `json:"service"`
Label string `json:"label"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, sec)
}
func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
}
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
var req struct {
Label string `json:"label"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeOKOr(w, s.st.RenameSecret(s.owner(r), r.PathValue("id"), req.Label))
}
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id")))
}
func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
list, err := s.st.ListContextEntries(s.owner(r), q.Get("projectId"), q.Get("conversationId"))
writeJSONOr(w, list, err)
}
func (s *Server) saveContextEntry(w http.ResponseWriter, r *http.Request) {
var e store.ContextEntry
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
saved, err := s.st.SaveContextEntry(s.owner(r), e)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) deleteContextEntry(w http.ResponseWriter, r *http.Request) {
writeOKOr(w, s.st.DeleteContextEntry(s.owner(r), r.PathValue("id")))
}
func (s *Server) listUserPromptCommands(w http.ResponseWriter, r *http.Request) {
list, err := s.st.ListUserCommands(s.owner(r))
writeJSONOr(w, list, err)
}
func (s *Server) saveUserPromptCommand(w http.ResponseWriter, r *http.Request) {
var c store.UserCommand
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
saved, err := s.st.SaveUserCommand(s.owner(r), c)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.registerUserPrompt(saved)
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) deleteUserPromptCommand(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
id := r.PathValue("id")
list, _ := s.st.ListUserCommands(owner)
var name string
for _, c := range list {
if c.ID == id {
name = c.Name
}
}
if err := s.st.DeleteUserCommand(owner, id); err != nil {
writeStoreErr(w, err)
return
}
if s.Bus != nil && name != "" {
s.Bus.UnregisterUser(userCommandID(name), name)
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
func (s *Server) excludeMessage(w http.ResponseWriter, r *http.Request) {
var req struct {
Excluded *bool `json:"excluded"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
excluded := true
if req.Excluded != nil {
excluded = *req.Excluded
}
msg, err := s.st.MessageByID(r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
if _, err := s.st.GetConversation(s.owner(r), msg.ConversationID); err != nil {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.ExcludeRunGroup(msg.ID, excluded))
}
func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) {
msg, err := s.st.MessageByID(r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
if _, err := s.st.GetConversation(s.owner(r), msg.ConversationID); err != nil {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.SelectVariant(msg.ID))
}
func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
convID := r.PathValue("id")
conv, err := s.st.GetConversation(owner, convID)
if err != nil {
writeStoreErr(w, err)
return
}
var req struct {
MessageID string `json:"messageId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" {
http.Error(w, "messageId required", http.StatusBadRequest)
return
}
target, err := s.st.MessageByID(req.MessageID)
if err != nil || target.ConversationID != convID || target.Role != core.RoleAssistant {
http.Error(w, "assistant message required", http.StatusBadRequest)
return
}
hist, err := s.st.ListMessages(convID)
if err != nil {
writeStoreErr(w, err)
return
}
var user core.Message
found := false
maxIndex := target.VariantIndex
for _, m := range hist {
if m.VariantGroup != "" && m.VariantGroup == target.VariantGroup && m.VariantIndex > maxIndex {
maxIndex = m.VariantIndex
}
if m.ID == target.ID {
found = true
}
if !found && m.Role == core.RoleUser {
user = m
}
}
if user.ID == "" {
http.Error(w, "no user turn to regenerate", http.StatusBadRequest)
return
}
group := target.VariantGroup
if group == "" {
group = "var_" + target.ID
}
if err := s.st.SetMessageFlags(target.ID, true, group, target.VariantIndex); err != nil {
writeStoreErr(w, err)
return
}
if err := s.st.ExcludeRunGroup(target.ID, true); err != nil {
writeStoreErr(w, err)
return
}
if err := s.st.SetRegenPending(convID, group, maxIndex+1); err != nil {
writeStoreErr(w, err)
return
}
if _, err := s.mgr.RewindFiles(owner, convID, user.ID); err != nil {
// 没有文件检查点时仍然重新生成回复。
}
run, err := s.mgr.StartConversationRun(conv, user, nil, nil)
if err != nil {
_, _, _ = s.st.TakeRegenPending(convID)
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"run": run, "variantGroup": group})
}
func (s *Server) expandUserCommand(owner, text string) (string, bool) {
fields := strings.Fields(text)
if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
return "", false
}
list, err := s.st.ListUserCommands(owner)
if err != nil {
return "", false
}
name := strings.ToLower(fields[0])
rest := strings.TrimSpace(strings.TrimPrefix(text, fields[0]))
for _, c := range list {
if strings.ToLower(c.Name) != name {
continue
}
body := c.Template
if strings.Contains(body, "{{input}}") {
body = strings.ReplaceAll(body, "{{input}}", rest)
} else if rest != "" {
body = strings.TrimSpace(body + "\n" + rest)
}
return body, true
}
return "", false
}
func userCommandID(name string) string {
slug := strings.TrimPrefix(strings.ToLower(name), "/")
return "user." + slug
}
func (s *Server) registerUserPrompt(c store.UserCommand) {
if s.Bus == nil {
return
}
slug := strings.TrimPrefix(strings.ToLower(c.Name), "/")
if !userCommandSlug.MatchString(slug) {
return
}
_ = s.Bus.RegisterUser(agentcommand.CommandSpec{
ID: userCommandID(c.Name), Title: c.Title, Description: "展开为提示词,不自动执行",
}, c.Name)
}
func (s *Server) appendUserPromptCommands(owner string, out []map[string]any) []map[string]any {
list, err := s.st.ListUserCommands(owner)
if err != nil {
return out
}
for _, c := range list {
s.registerUserPrompt(c)
out = append(out, map[string]any{
"id": userCommandID(c.Name), "name": c.Name, "title": c.Title,
"description": "展开为提示词", "source": "user", "enabled": true,
})
}
return out
}
+122
View File
@@ -0,0 +1,122 @@
package server
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// RequestGuard 包在组合根的 Handler 外面。
// Handler 本身不改,测试里的 httptest 请求仍直接打 Handler。
type RequestGuard struct {
AuthOn bool
AllowedHosts []string
Next http.Handler
}
func (g RequestGuard) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if isWriteMethod(r.Method) {
if msg := rejectWriteOrigin(r, g.AllowedHosts); msg != "" {
http.Error(w, msg, http.StatusForbidden)
return
}
}
if !g.AuthOn {
if msg := rejectHost(r, g.AllowedHosts); msg != "" {
http.Error(w, msg, http.StatusForbidden)
return
}
}
g.Next.ServeHTTP(w, r)
}
func isWriteMethod(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func rejectWriteOrigin(r *http.Request, allowed []string) string {
origin := r.Header.Get("Origin")
if origin == "null" {
return "拒绝来源为 null 的写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
if origin != "" {
parsed, err := url.Parse(origin)
if err != nil || parsed.Host == "" {
return "拒绝无法识别来源的写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
if hostsMatch(parsed.Host, r.Host) || hostListed(parsed.Hostname(), allowed) {
return ""
}
return "写请求的来源与访问地址不一致。经反向代理访问时,把对外域名加入 allowedHosts。"
}
site := r.Header.Get("Sec-Fetch-Site")
if site != "" && site != "same-origin" && site != "none" {
return "拒绝跨站写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
return ""
}
func rejectHost(r *http.Request, allowed []string) string {
host := requestHostname(r.Host)
if host == "" {
return "拒绝没有 Host 的请求。经反向代理访问时,把对外域名加入 allowedHosts。"
}
if strings.EqualFold(host, "localhost") || net.ParseIP(host) != nil || hostListed(host, allowed) {
return ""
}
return fmt.Sprintf("拒绝主机名 %s。经反向代理访问时,把对外域名加入 allowedHosts。", host)
}
func requestHostname(hostport string) string {
if h, _, err := net.SplitHostPort(hostport); err == nil {
return strings.Trim(h, "[]")
}
return strings.Trim(hostport, "[]")
}
func hostsMatch(a, b string) bool {
return strings.EqualFold(requestHostname(a), requestHostname(b))
}
func hostListed(host string, allowed []string) bool {
for _, item := range allowed {
if strings.EqualFold(requestHostname(item), host) {
return true
}
}
return false
}
const pageCSP = "script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; " +
"img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; frame-src 'self' https:"
const activeCSP = "sandbox allow-scripts allow-popups"
func setPageHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Security-Policy", pageCSP)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
}
func setArtifactHeaders(w http.ResponseWriter, contentType string) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if isActiveContent(contentType) {
w.Header().Set("Content-Security-Policy", activeCSP)
}
}
func isActiveContent(contentType string) bool {
ct := strings.ToLower(contentType)
return strings.Contains(ct, "text/html") ||
strings.Contains(ct, "application/xhtml") ||
strings.Contains(ct, "image/svg") ||
strings.Contains(ct, "xml")
}
+81
View File
@@ -0,0 +1,81 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func guard(auth bool, hosts ...string) RequestGuard {
return RequestGuard{
AuthOn: auth, AllowedHosts: hosts,
Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
}
}
func TestRequestGuardWrites(t *testing.T) {
cases := []struct {
name string
auth bool
host string
origin string
site string
want int
}{
{"same origin", false, "127.0.0.1:8788", "http://127.0.0.1:8788", "", http.StatusNoContent},
{"cross origin", false, "127.0.0.1:8788", "https://evil.example", "", http.StatusForbidden},
{"null origin", false, "127.0.0.1:8788", "null", "", http.StatusForbidden},
{"sec-fetch cross", false, "127.0.0.1:8788", "", "cross-site", http.StatusForbidden},
{"sec-fetch same", false, "127.0.0.1:8788", "", "same-origin", http.StatusNoContent},
{"no browser headers", false, "127.0.0.1:8788", "", "", http.StatusNoContent},
{"allowed host origin", false, "127.0.0.1:8788", "https://app.example", "", http.StatusNoContent},
{"evil host without auth", false, "evil.example", "", "", http.StatusForbidden},
{"evil host with auth", true, "evil.example", "", "", http.StatusNoContent},
{"ip host", false, "192.168.1.9:8788", "", "", http.StatusNoContent},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/memory", strings.NewReader(`{}`))
req.Host = tc.host
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
if tc.site != "" {
req.Header.Set("Sec-Fetch-Site", tc.site)
}
rec := httptest.NewRecorder()
hosts := []string{}
if tc.name == "allowed host origin" {
hosts = []string{"app.example"}
}
guard(tc.auth, hosts...).ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("status %d want %d body %s", rec.Code, tc.want, rec.Body.String())
}
if rec.Code == http.StatusForbidden && !strings.Contains(rec.Body.String(), "allowedHosts") {
t.Fatalf("refusal should mention allowedHosts: %s", rec.Body.String())
}
})
}
}
func TestPageAndArtifactHeaders(t *testing.T) {
rec := httptest.NewRecorder()
setPageHeaders(rec)
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Content-Type-Options") != "nosniff" || rec.Header().Get("Referrer-Policy") != "same-origin" {
t.Fatalf("page headers: %v", rec.Header())
}
art := httptest.NewRecorder()
setArtifactHeaders(art, "text/html; charset=utf-8")
if !strings.Contains(art.Header().Get("Content-Security-Policy"), "sandbox") || art.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("artifact headers: %v", art.Header())
}
plain := httptest.NewRecorder()
setArtifactHeaders(plain, "text/plain")
if plain.Header().Get("Content-Security-Policy") != "" {
t.Fatalf("plain artifact should not be sandboxed: %v", plain.Header())
}
}
+33 -3
View File
@@ -167,6 +167,20 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/conversations/{id}/messages", s.postMessage)
mux.HandleFunc("GET /api/conversations/{id}/checkpoints", s.listCheckpoints)
mux.HandleFunc("POST /api/conversations/{id}/rewind", s.rewindFiles)
mux.HandleFunc("POST /api/conversations/{id}/regenerate", s.regenerateMessage)
mux.HandleFunc("POST /api/messages/{id}/exclude", s.excludeMessage)
mux.HandleFunc("POST /api/messages/{id}/variant", s.selectVariant)
mux.HandleFunc("GET /api/secrets", s.listSecrets)
mux.HandleFunc("POST /api/secrets", s.createSecret)
mux.HandleFunc("POST /api/secrets/{id}/activate", s.activateSecret)
mux.HandleFunc("PATCH /api/secrets/{id}", s.renameSecret)
mux.HandleFunc("DELETE /api/secrets/{id}", s.deleteSecret)
mux.HandleFunc("GET /api/context-entries", s.listContextEntries)
mux.HandleFunc("POST /api/context-entries", s.saveContextEntry)
mux.HandleFunc("DELETE /api/context-entries/{id}", s.deleteContextEntry)
mux.HandleFunc("GET /api/prompt-commands", s.listUserPromptCommands)
mux.HandleFunc("POST /api/prompt-commands", s.saveUserPromptCommand)
mux.HandleFunc("DELETE /api/prompt-commands/{id}", s.deleteUserPromptCommand)
mux.HandleFunc("POST /api/runs/{id}/cancel", s.cancelRun)
mux.HandleFunc("GET /api/runs/{id}", s.getRun)
@@ -379,9 +393,11 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
}
path = "index.html"
}
w.Header().Set("X-Content-Type-Options", "nosniff")
switch {
case strings.HasSuffix(path, ".html"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
setPageHeaders(w)
case strings.HasSuffix(path, ".js"):
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
case strings.HasSuffix(path, ".css"):
@@ -522,10 +538,12 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
AllowCrossRead *bool `json:"allowCrossRead"`
CrossPrivate *bool `json:"crossPrivate"`
PermissionMode *string `json:"permissionMode"`
SessionNote *string `json:"sessionNote"`
}
json.NewDecoder(r.Body).Decode(&req)
onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil
if !onlyMode {
onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.SessionNote == nil
onlyNote := req.SessionNote != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.PermissionMode == nil
if !onlyMode && !onlyNote {
if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil {
writeStoreErr(w, err)
return
@@ -537,6 +555,12 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.SessionNote != nil {
if err := s.st.SetSessionNote(s.owner(r), id, *req.SessionNote); err != nil {
writeStoreErr(w, err)
return
}
}
writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate))
}
@@ -564,6 +588,9 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
return
}
text := strings.TrimSpace(req.Text)
if expanded, ok := s.expandUserCommand(owner, text); ok {
text = expanded
}
if text == "" && len(req.AttachmentIDs) == 0 {
http.Error(w, "text or attachments required", http.StatusBadRequest)
return
@@ -1042,7 +1069,9 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
w.Header().Set("Content-Type", contentTypeOf(a))
ct := contentTypeOf(a)
w.Header().Set("Content-Type", ct)
setArtifactHeaders(w, ct)
w.Header().Set("Content-Disposition", contentDisposition(a.Name, r.URL.Query().Get("download") == "1"))
w.Write(content)
}
@@ -1291,6 +1320,7 @@ func (s *Server) listCommands(w http.ResponseWriter, r *http.Request) {
}
}
}
out = s.appendUserPromptCommands(s.owner(r), out)
writeJSON(w, http.StatusOK, out)
}
+115
View File
@@ -0,0 +1,115 @@
package settings
import (
"encoding/json"
"strings"
"time"
)
type secretSpec struct {
path string
service string
}
var secretSpecs = []secretSpec{
{"models.chat.apiKey", "models.chat"},
{"models.lightweight.apiKey", "models.lightweight"},
{"models.embedding.apiKey", "models.embedding"},
{"models.rerank.apiKey", "models.rerank"},
{"tools.tavily.apiKeys", "tools.tavily"},
}
// SnapshotAndMigrateSecrets 先为每个已存 scope 留快照,再把明文密钥迁入独立表并从表单设置里清掉。
// 该服务已经有密钥行时不再迁入,避免重复。掩码占位符不迁。
func (s *Service) SnapshotAndMigrateSecrets() error {
refs, err := s.st.ListSettingsScopes()
if err != nil {
return err
}
for _, ref := range refs {
cur, err := s.st.GetSettingsScope(ref.Owner, ref.Scope)
if err != nil {
return err
}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
if _, err := s.st.SaveSnapshotIfChanged(ref.Owner, ref.Scope, string(raw), "startup"); err != nil {
return err
}
for _, spec := range secretSpecs {
val, ok := lookupString(cur, spec.path)
if !ok || val == MaskPlaceholder {
continue
}
has, err := s.st.HasSecret(ref.Owner, spec.service)
if err != nil {
return err
}
if !has {
if _, err := s.st.AddSecret(ref.Owner, spec.service, "迁入", val); err != nil {
return err
}
}
path := spec.path
if err := s.st.UpdateSettingsScope(ref.Owner, ref.Scope, func(cur map[string]any) (map[string]any, error) {
deletePath(cur, path)
return cur, nil
}); err != nil {
return err
}
}
}
return nil
}
func (s *Service) snapshotThrottled(owner, scope string) {
if at, ok := s.st.LatestSnapshotAt(owner, scope); ok && time.Since(at) < 10*time.Minute {
return
}
cur, err := s.st.GetSettingsScope(owner, scope)
if err != nil {
return
}
raw, err := json.Marshal(cur)
if err != nil {
return
}
_, _ = s.st.SaveSnapshotIfChanged(owner, scope, string(raw), "save")
}
func lookupString(m map[string]any, path string) (string, bool) {
var cur any = m
for _, p := range strings.Split(path, ".") {
obj, ok := cur.(map[string]any)
if !ok {
return "", false
}
cur, ok = obj[p]
if !ok {
return "", false
}
}
s, ok := cur.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", false
}
return s, true
}
func deletePath(m map[string]any, path string) {
parts := strings.Split(path, ".")
cur := m
for i, p := range parts {
if i == len(parts)-1 {
delete(cur, p)
return
}
next, ok := cur[p].(map[string]any)
if !ok {
return
}
cur = next
}
}
+12 -2
View File
@@ -49,6 +49,9 @@ func SystemDefaults() map[string]any {
"context": map[string]any{
"maxHistoryMessages": 40,
"maxToolResultChars": 20000,
"userInstructions": "",
"contextWindow": 32000,
"messageShape": "merge",
},
"crossSession": map[string]any{
"read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读
@@ -93,6 +96,9 @@ func Schema() []core.SettingDesc {
{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: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
{Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"},
{Key: "context.messageShape", Group: "context", Label: "消息整形", Type: core.SettingEnum, Default: "merge", Options: []string{"merge", "strict"}, Effect: core.EffectNewRun, Description: "merge 合并相邻同角色。strict 再把多余的 system 收成 user,满足严格交替"},
{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},
@@ -156,9 +162,13 @@ func (s *Service) MergeProject(owner, projectID string, values map[string]any) e
// 掩码占位符视为未修改,不覆盖已存密钥。
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
incoming := prepareWrite(values)
return s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
return core.MergeSettings(normalizeKeys(cur), incoming), nil
})
}); err != nil {
return err
}
s.snapshotThrottled(owner, scope)
return nil
}
// NormalizeStats 是启动迁移的结果。MaskPaths 只含 owner、scope 和键路径,不含值。
+455
View File
@@ -0,0 +1,455 @@
package store
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
core "agent-system-core"
)
// Secret 是一把带标签的服务密钥。列表接口不返回 Value。
type Secret struct {
ID string `json:"id"`
Owner string `json:"owner,omitempty"`
Service string `json:"service"`
Label string `json:"label"`
Active bool `json:"active"`
Mask string `json:"mask"`
CreatedAt string `json:"createdAt"`
Value string `json:"-"`
}
// ContextEntry 是一条条件注入。常驻或关键词命中后附在上下文末尾。
type ContextEntry struct {
ID string `json:"id"`
Owner string `json:"owner,omitempty"`
ProjectID string `json:"projectId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
Keys []string `json:"keys"`
Secondary string `json:"secondary,omitempty"`
Logic string `json:"logic"`
Content string `json:"content"`
Constant bool `json:"constant"`
ScanDepth int `json:"scanDepth"`
SortOrder int `json:"sortOrder"`
BudgetChars int `json:"budgetChars"`
Enabled bool `json:"enabled"`
}
// UserCommand 是用户自定义的 / 命令,展开成提示词,不自动触发。
type UserCommand struct {
ID string `json:"id"`
Owner string `json:"owner,omitempty"`
Name string `json:"name"`
Title string `json:"title"`
Template string `json:"template"`
CreatedAt string `json:"createdAt"`
}
func maskSecret(v string) string {
v = strings.TrimSpace(v)
if len(v) <= 4 {
return "••••••"
}
return "••••" + v[len(v)-4:]
}
func (s *Store) ListSecrets(owner string) ([]Secret, error) {
rows, err := s.db.Query(s.q(`SELECT id,service,label,value,active,created_at FROM secret_keys WHERE owner=? ORDER BY service, created_at`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Secret{}
for rows.Next() {
var sec Secret
var active int
if err := rows.Scan(&sec.ID, &sec.Service, &sec.Label, &sec.Value, &active, &sec.CreatedAt); err != nil {
return nil, err
}
sec.Active = active == 1
sec.Mask = maskSecret(sec.Value)
sec.Value = ""
out = append(out, sec)
}
return out, rows.Err()
}
// ActiveSecret 返回该服务当前生效的明文。没有记录时 ok 为 false。
func (s *Store) ActiveSecret(owner, service string) (string, bool) {
var v string
err := s.db.QueryRow(s.q(`SELECT value FROM secret_keys WHERE owner=? AND service=? AND active=1 ORDER BY created_at DESC LIMIT 1`), owner, service).Scan(&v)
if err != nil || strings.TrimSpace(v) == "" {
return "", false
}
return v, true
}
// AddSecret 追加一把密钥并把它设为该服务唯一生效项。
func (s *Store) AddSecret(owner, service, label, value string) (Secret, error) {
service = strings.TrimSpace(service)
value = strings.TrimSpace(value)
label = strings.TrimSpace(label)
if service == "" || value == "" {
return Secret{}, fmt.Errorf("store: secret service and value are required")
}
if label == "" {
label = "未命名"
}
tx, err := s.db.Begin()
if err != nil {
return Secret{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), owner, service); err != nil {
return Secret{}, err
}
sec := Secret{ID: newID("key"), Service: service, Label: label, Active: true, CreatedAt: nowISO(), Mask: maskSecret(value)}
if _, err := tx.Exec(s.q(`INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,?,?)`),
sec.ID, owner, service, label, value, 1, sec.CreatedAt); err != nil {
return Secret{}, err
}
return sec, tx.Commit()
}
func (s *Store) ActivateSecret(owner, id string) error {
var service string
err := s.db.QueryRow(s.q(`SELECT service FROM secret_keys WHERE id=? AND owner=?`), id, owner).Scan(&service)
if err != nil {
return ErrNotFound
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), owner, service); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=1 WHERE id=? AND owner=?`), id, owner); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) RenameSecret(owner, id, label string) error {
label = strings.TrimSpace(label)
if label == "" {
return fmt.Errorf("store: label required")
}
res, err := s.db.Exec(s.q(`UPDATE secret_keys SET label=? WHERE id=? AND owner=?`), label, id, owner)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteSecret(owner, id string) error {
res, err := s.db.Exec(s.q(`DELETE FROM secret_keys WHERE id=? AND owner=?`), id, owner)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) HasSecret(owner, service string) (bool, error) {
var n int
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM secret_keys WHERE owner=? AND service=?`), owner, service).Scan(&n)
return n > 0, err
}
// SaveSnapshotIfChanged 在正文与最近一份不同时留档。相同则跳过。
func (s *Store) SaveSnapshotIfChanged(owner, scope, body, reason string) (bool, error) {
var prev string
err := s.db.QueryRow(s.q(`SELECT body FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&prev)
if err == nil && prev == body {
return false, nil
}
if err != nil && err != sql.ErrNoRows {
return false, err
}
_, err = s.db.Exec(s.q(`INSERT INTO settings_snapshots(id,owner,scope,body,reason,created_at) VALUES(?,?,?,?,?,?)`),
newID("snap"), owner, scope, body, reason, nowISO())
return err == nil, err
}
// LatestSnapshotAge 返回最近一份快照的时间。没有快照时 ok 为 false。
func (s *Store) LatestSnapshotAt(owner, scope string) (time.Time, bool) {
var raw string
err := s.db.QueryRow(s.q(`SELECT created_at FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&raw)
if err != nil {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return time.Time{}, false
}
return t, true
}
func (s *Store) SetSessionNote(owner, id, note string) error {
res, err := s.db.Exec(s.q(`UPDATE conversations SET session_note=?, updated_at=? WHERE id=? AND owner=?`), note, nowISO(), id, owner)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) SetMessageFlags(id string, excluded bool, group string, index int) error {
ex := 0
if excluded {
ex = 1
}
_, err := s.db.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, group, index, id)
return err
}
// ExcludeRunGroup 把这条消息以及同一 run 的消息一起标成排除或恢复。
// 工具调用和结果落在同一条助手消息里,同一 run 的其它落库消息一并处理。
func (s *Store) ExcludeRunGroup(id string, excluded bool) error {
var runID string
err := s.db.QueryRow(s.q(`SELECT run_id FROM messages WHERE id=?`), id).Scan(&runID)
if err != nil {
return ErrNotFound
}
ex := 0
if excluded {
ex = 1
}
if _, err := s.db.Exec(s.q(`UPDATE messages SET excluded=? WHERE id=?`), ex, id); err != nil {
return err
}
if runID == "" {
return nil
}
_, err = s.db.Exec(s.q(`UPDATE messages SET excluded=? WHERE run_id=?`), ex, runID)
return err
}
// SelectVariant 让同一组里只有这条候选进入上下文。
func (s *Store) SelectVariant(id string) error {
var group, conv string
err := s.db.QueryRow(s.q(`SELECT variant_group, conversation_id FROM messages WHERE id=?`), id).Scan(&group, &conv)
if err != nil {
return ErrNotFound
}
if group == "" {
return s.ExcludeRunGroup(id, false)
}
if _, err := s.db.Exec(s.q(`UPDATE messages SET excluded=1 WHERE conversation_id=? AND variant_group=?`), conv, group); err != nil {
return err
}
_, err = s.db.Exec(s.q(`UPDATE messages SET excluded=0 WHERE id=?`), id)
return err
}
func (s *Store) SetRegenPending(conversationID, group string, index int) error {
res, err := s.db.Exec(s.q(`UPDATE regen_pending SET variant_group=?, variant_index=? WHERE conversation_id=?`), group, index, conversationID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n > 0 {
return nil
}
_, err = s.db.Exec(s.q(`INSERT INTO regen_pending(conversation_id, variant_group, variant_index) VALUES(?,?,?)`), conversationID, group, index)
return err
}
func (s *Store) TakeRegenPending(conversationID string) (group string, index int, ok bool) {
err := s.db.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&group, &index)
if err != nil {
return "", 0, false
}
_, _ = s.db.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=?`), conversationID)
return group, index, true
}
func (s *Store) ListContextEntries(owner, projectID, conversationID string) ([]ContextEntry, error) {
rows, err := s.db.Query(s.q(`SELECT id,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled
FROM context_entries WHERE owner=? ORDER BY sort_order, created_at`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ContextEntry{}
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
if e.ProjectID != "" && projectID != "" && e.ProjectID != projectID {
continue
}
if e.ConversationID != "" && conversationID != "" && e.ConversationID != conversationID {
continue
}
out = append(out, e)
}
return out, rows.Err()
}
func scanEntry(rows interface{ Scan(...any) error }) (ContextEntry, error) {
var e ContextEntry
var keys, logic string
var constant, enabled int
err := rows.Scan(&e.ID, &e.ProjectID, &e.ConversationID, &keys, &e.Secondary, &logic, &e.Content, &constant, &e.ScanDepth, &e.SortOrder, &e.BudgetChars, &enabled)
if err != nil {
return e, err
}
_ = json.Unmarshal([]byte(keys), &e.Keys)
if e.Keys == nil {
e.Keys = []string{}
}
e.Logic = logic
if e.Logic == "" {
e.Logic = "and"
}
e.Constant = constant == 1
e.Enabled = enabled == 1
return e, nil
}
func (s *Store) SaveContextEntry(owner string, e ContextEntry) (ContextEntry, error) {
if strings.TrimSpace(e.Content) == "" {
return ContextEntry{}, fmt.Errorf("store: entry content required")
}
if e.Logic != "and" && e.Logic != "or" && e.Logic != "not" {
e.Logic = "and"
}
if e.ScanDepth <= 0 {
e.ScanDepth = 4
}
if e.BudgetChars <= 0 {
e.BudgetChars = 2000
}
raw, _ := json.Marshal(e.Keys)
constant, enabled := 0, 0
if e.Constant {
constant = 1
}
if e.Enabled || e.ID == "" {
enabled = 1
e.Enabled = true
}
if e.ID == "" {
e.ID = newID("ctx")
_, err := s.db.Exec(s.q(`INSERT INTO context_entries(id,owner,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
e.ID, owner, e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, nowISO())
return e, err
}
res, err := s.db.Exec(s.q(`UPDATE context_entries SET project_id=?, conversation_id=?, keys_json=?, secondary=?, logic=?, content=?, constant=?, scan_depth=?, sort_order=?, budget_chars=?, enabled=? WHERE id=? AND owner=?`),
e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, e.ID, owner)
if err != nil {
return e, err
}
n, _ := res.RowsAffected()
if n == 0 {
return e, ErrNotFound
}
return e, nil
}
func (s *Store) DeleteContextEntry(owner, id string) error {
res, err := s.db.Exec(s.q(`DELETE FROM context_entries WHERE id=? AND owner=?`), id, owner)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ListUserCommands(owner string) ([]UserCommand, error) {
rows, err := s.db.Query(s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE owner=? ORDER BY name`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []UserCommand{}
for rows.Next() {
var c UserCommand
if err := rows.Scan(&c.ID, &c.Name, &c.Title, &c.Template, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) SaveUserCommand(owner string, c UserCommand) (UserCommand, error) {
c.Name = strings.TrimSpace(c.Name)
if !strings.HasPrefix(c.Name, "/") {
c.Name = "/" + c.Name
}
if c.Title == "" {
c.Title = c.Name
}
if strings.TrimSpace(c.Template) == "" {
return UserCommand{}, fmt.Errorf("store: command template required")
}
if c.ID == "" {
c.ID = newID("cmd")
c.CreatedAt = nowISO()
_, err := s.db.Exec(s.q(`INSERT INTO user_commands(id,owner,name,title,template,created_at) VALUES(?,?,?,?,?,?)`),
c.ID, owner, c.Name, c.Title, c.Template, c.CreatedAt)
return c, err
}
res, err := s.db.Exec(s.q(`UPDATE user_commands SET name=?, title=?, template=? WHERE id=? AND owner=?`), c.Name, c.Title, c.Template, c.ID, owner)
if err != nil {
return c, err
}
n, _ := res.RowsAffected()
if n == 0 {
return c, ErrNotFound
}
return c, nil
}
func (s *Store) DeleteUserCommand(owner, id string) error {
res, err := s.db.Exec(s.q(`DELETE FROM user_commands WHERE id=? AND owner=?`), id, owner)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// MessageByID 读取一条消息。不存在时返回 ErrNotFound。
func (s *Store) MessageByID(id string) (core.Message, error) {
var m core.Message
var parts, ca string
var excluded, index int
err := s.db.QueryRow(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at,excluded,variant_group,variant_index FROM messages WHERE id=?`), id).
Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca, &excluded, &m.VariantGroup, &index)
if err != nil {
return m, ErrNotFound
}
m.Role = core.Role(m.Role)
m.Parts, _ = unmarshalJSON[[]core.Part](parts)
m.CreatedAt, _ = parseTime(ca)
m.Excluded = excluded == 1
m.VariantIndex = index
return m, nil
}
+5 -2
View File
@@ -63,7 +63,7 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro
}
func (s *Store) ListMessages(conversationID string) ([]core.Message, error) {
rows, err := s.db.Query(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID)
rows, err := s.db.Query(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at,excluded,variant_group,variant_index FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID)
if err != nil {
return nil, err
}
@@ -72,12 +72,15 @@ func (s *Store) ListMessages(conversationID string) ([]core.Message, error) {
for rows.Next() {
var m core.Message
var parts, ca string
if err := rows.Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca); err != nil {
var excluded, index int
if err := rows.Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca, &excluded, &m.VariantGroup, &index); err != nil {
return nil, err
}
m.Role = core.Role(m.Role)
m.Parts, _ = unmarshalJSON[[]core.Part](parts)
m.CreatedAt, _ = parseTime(ca)
m.Excluded = excluded == 1
m.VariantIndex = index
out = append(out, m)
}
return out, rows.Err()
+73
View File
@@ -401,9 +401,82 @@ func init() {
}, "postgres": {
`ALTER TABLE conversations DROP COLUMN permission_mode`,
}}},
migration{version: 16, name: "secrets_context_and_variants",
up: map[string][]string{"sqlite": v16Up, "postgres": v16Up},
down: map[string][]string{"sqlite": v16Down, "postgres": v16Down}},
)
}
// v16 密钥独立表、设置快照、条件注入、用户命令、会话要求、消息排除与候选回复。
var v16Up = []string{
`CREATE TABLE IF NOT EXISTS secret_keys (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
service TEXT NOT NULL,
label TEXT NOT NULL,
value TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_secret_keys_owner_service ON secret_keys(owner, service)`,
`CREATE TABLE IF NOT EXISTS settings_snapshots (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
scope TEXT NOT NULL,
body TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_settings_snapshots_scope ON settings_snapshots(owner, scope, created_at)`,
`ALTER TABLE conversations ADD COLUMN session_note TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE messages ADD COLUMN excluded INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE messages ADD COLUMN variant_group TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE messages ADD COLUMN variant_index INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS regen_pending (
conversation_id TEXT PRIMARY KEY,
variant_group TEXT NOT NULL,
variant_index INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS context_entries (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
keys_json TEXT NOT NULL DEFAULT '[]',
secondary TEXT NOT NULL DEFAULT '',
logic TEXT NOT NULL DEFAULT 'and',
content TEXT NOT NULL,
constant INTEGER NOT NULL DEFAULT 0,
scan_depth INTEGER NOT NULL DEFAULT 4,
sort_order INTEGER NOT NULL DEFAULT 0,
budget_chars INTEGER NOT NULL DEFAULT 2000,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS user_commands (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
name TEXT NOT NULL,
title TEXT NOT NULL,
template TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(owner, name)
)`,
}
var v16Down = []string{
`DROP TABLE IF EXISTS user_commands`,
`DROP TABLE IF EXISTS context_entries`,
`DROP TABLE IF EXISTS regen_pending`,
`ALTER TABLE messages DROP COLUMN variant_index`,
`ALTER TABLE messages DROP COLUMN variant_group`,
`ALTER TABLE messages DROP COLUMN excluded`,
`ALTER TABLE conversations DROP COLUMN session_note`,
`DROP TABLE IF EXISTS settings_snapshots`,
`DROP INDEX IF EXISTS idx_secret_keys_owner_service`,
`DROP TABLE IF EXISTS secret_keys`,
}
func (s *Store) migrate() error {
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
return fmt.Errorf("migrate: %w", err)
+5 -5
View File
@@ -138,7 +138,7 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
}
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
if err != nil {
return nil, err
}
@@ -157,7 +157,7 @@ func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation,
// ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。
func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) {
q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE owner=?`
q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE owner=?`
if !includeArchived {
q += ` AND archived=0`
}
@@ -182,7 +182,7 @@ func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversati
func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error {
var ca, ua string
var pinned, archived, allowCrossRead, crossPrivate int
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua); err != nil {
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua); err != nil {
return err
}
c.Pinned = pinned == 1
@@ -198,8 +198,8 @@ func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
var c core.Conversation
var ca, ua string
var pinned, archived, allowCrossRead, crossPrivate int
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua)
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua)
if errors.Is(err, sql.ErrNoRows) {
return c, ErrNotFound
}
+9
View File
@@ -177,6 +177,15 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message) (bool, error) {
if _, err := tx.Exec(s.q(insert), msg.ID, msg.ConversationID, msg.RunID, string(msg.Role), marshalJSON(msg.Parts), nowISO(), "", msg.ConversationID); err != nil {
return false, err
}
if msg.VariantGroup != "" || msg.Excluded {
ex := 0
if msg.Excluded {
ex = 1
}
if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, msg.VariantGroup, msg.VariantIndex, msg.ID); err != nil {
return false, err
}
}
}
if err := tx.Commit(); err != nil {
return false, err
+2 -2
View File
@@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(got) != 15 {
t.Fatalf("expected versions [1..15], got %v", got)
if len(got) != 16 {
t.Fatalf("expected versions [1..16], got %v", got)
}
// 老库形态(v2 前):events 全局自增、settings 单列主键。
if err := st.MigrateDown(1); err != nil {