From 8eda35f3652689e79839c9290d337e40fb9548f9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 4 Oct 2026 17:28:46 +0000 Subject: [PATCH 1/4] Refuse open listeners and cross-site writes without auth Why: The embedded tier starts without auth, and nothing checked Origin or Host, so a public bind or a foreign page could call write APIs. Evidence: go test ./internal/config ./internal/server covering listen policy, origin rejection, and response headers. Impact: Non-loopback without auth exits before opening the database. HTML artifacts get a sandbox CSP. Reverse proxies must list their public host in allowedHosts. Refs: feature/sillytavern-review Co-authored-by: Cursor --- PROJECT.md | 3 +- cmd/agent-system/main.go | 31 ++++++-- internal/app/app.go | 34 +++++--- internal/config/config.go | 138 ++++++++++++++++++++++++++------- internal/config/config_test.go | 40 ++++++++++ internal/server/guard.go | 122 +++++++++++++++++++++++++++++ internal/server/guard_test.go | 81 +++++++++++++++++++ internal/server/server.go | 6 +- 8 files changed, 410 insertions(+), 45 deletions(-) create mode 100644 internal/server/guard.go create mode 100644 internal/server/guard_test.go diff --git a/PROJECT.md b/PROJECT.md index 83ed98f..9dd72c7 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -14,7 +14,8 @@ ## 配置(启动层) -- 来源优先级:内置默认 → 显式配置文件(`--config `,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。 +- 来源优先级:内置默认 → 显式配置文件(`--config `,无自动发现)→ 环境变量(`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 …] [-- …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。 - 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。 - 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。 diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 13d8aa7..8d9b1b3 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -42,13 +42,15 @@ func main() { artifacts := fs.String("artifacts", "", "工件对象目录(默认 /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 diff --git a/internal/app/app.go b/internal/app/app.go index 9598eb9..bea839e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 档工件对象目录(空 = /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 档工件对象目录(空 = /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 @@ -370,7 +382,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 )") @@ -379,7 +391,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) diff --git a/internal/config/config.go b/internal/config/config.go index d1f2160..ba40f89 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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:***@…。 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32b3b57..d9acda1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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()) + } +} diff --git a/internal/server/guard.go b/internal/server/guard.go new file mode 100644 index 0000000..1ae05f6 --- /dev/null +++ b/internal/server/guard.go @@ -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") +} diff --git a/internal/server/guard_test.go b/internal/server/guard_test.go new file mode 100644 index 0000000..1211630 --- /dev/null +++ b/internal/server/guard_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 616fc90..72c793f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -379,9 +379,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"): @@ -1042,7 +1044,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) } -- 2.54.0 From 42cca8fadea73de04e1127fe758954910cb7ac5d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 4 Oct 2026 18:20:10 +0000 Subject: [PATCH 2/4] Store secrets apart from settings and assemble context at the end Why: Masks must not be the only copy of a key, and personal instructions, lore hits, and candidate replies have to reach the model without rewriting the system prefix or deleting history. Evidence: go test ./internal/store ./internal/ctxasm ./internal/settings ./internal/runtime ./internal/server. Migration v16 upgrades and rolls back. Startup snapshots settings before moving raw keys. Impact: One labeled key is active per service. Excluding a message hides its whole run from the model. Regenerating appends another candidate and rewinds files to the user turn. User slash commands expand to prompt text and do not fire on their own. Refs: docs/records/2026-10-04-sillytavern-review.md --- deps/agent-native | 2 +- deps/agent-system-core | 2 +- internal/app/app.go | 24 +- internal/cmdbus/cmdbus.go | 21 ++ internal/ctxasm/ctxasm.go | 13 +- internal/ctxasm/finish.go | 283 ++++++++++++++++++++ internal/ctxasm/finish_test.go | 64 +++++ internal/rag/hybrid.go | 14 +- internal/runmgr/runmgr.go | 42 +++ internal/runtime/llm.go | 27 +- internal/runtime/llm_direct.go | 11 + internal/runtime/model.go | 36 ++- internal/server/borrow.go | 287 +++++++++++++++++++++ internal/server/server.go | 30 ++- internal/settings/secrets.go | 115 +++++++++ internal/settings/settings.go | 14 +- internal/store/borrow.go | 455 +++++++++++++++++++++++++++++++++ internal/store/messages.go | 7 +- internal/store/migrate.go | 73 ++++++ internal/store/projects.go | 10 +- internal/store/recovery.go | 9 + internal/store/store_test.go | 4 +- 22 files changed, 1515 insertions(+), 28 deletions(-) create mode 100644 internal/ctxasm/finish.go create mode 100644 internal/ctxasm/finish_test.go create mode 100644 internal/server/borrow.go create mode 100644 internal/settings/secrets.go create mode 100644 internal/store/borrow.go diff --git a/deps/agent-native b/deps/agent-native index e4020d2..3a110a7 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit e4020d297c168347626f9fd75456a71374a0a6c8 +Subproject commit 3a110a728cf520cfbbbafc0c4c75f14bf78bec3f diff --git a/deps/agent-system-core b/deps/agent-system-core index 5dc190e..bd63223 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 5dc190ee63174be71208609309f0b36a5fe070e0 +Subproject commit bd632236c2d0a21d7f6dd9a2f67a5d2df22e57f7 diff --git a/internal/app/app.go b/internal/app/app.go index bea839e..1c4931f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -135,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) @@ -201,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) { diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 20c6fee..e867905 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -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 { diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index ad34f3c..e2ca562 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -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 } diff --git a/internal/ctxasm/finish.go b/internal/ctxasm/finish.go new file mode 100644 index 0000000..ad4c17f --- /dev/null +++ b/internal/ctxasm/finish.go @@ -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"` +} diff --git a/internal/ctxasm/finish_test.go b/internal/ctxasm/finish_test.go new file mode 100644 index 0000000..4c8ceb1 --- /dev/null +++ b/internal/ctxasm/finish_test.go @@ -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") + } +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index 287ffd4..f683186 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -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", ""), } } diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d04a944..42a0dec 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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 { diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index e7ed564..527a715 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -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) } diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index c0f4384..5532a75 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -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 diff --git a/internal/runtime/model.go b/internal/runtime/model.go index 7e615f1..542ec84 100644 --- a/internal/runtime/model.go +++ b/internal/runtime/model.go @@ -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 } diff --git a/internal/server/borrow.go b/internal/server/borrow.go new file mode 100644 index 0000000..e0147fb --- /dev/null +++ b/internal/server/borrow.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 72c793f..a404a64 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) @@ -524,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 @@ -539,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)) } @@ -566,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 @@ -1295,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) } diff --git a/internal/settings/secrets.go b/internal/settings/secrets.go new file mode 100644 index 0000000..712d59f --- /dev/null +++ b/internal/settings/secrets.go @@ -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 + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index e53c128..f3b65df 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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 和键路径,不含值。 diff --git a/internal/store/borrow.go b/internal/store/borrow.go new file mode 100644 index 0000000..2eea5cc --- /dev/null +++ b/internal/store/borrow.go @@ -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 +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 8aaecec..205f464 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -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() diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 2eb67ec..d1aa0a5 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -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) diff --git a/internal/store/projects.go b/internal/store/projects.go index 9a56c51..0715496 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -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 } diff --git a/internal/store/recovery.go b/internal/store/recovery.go index d892219..f1c2878 100644 --- a/internal/store/recovery.go +++ b/internal/store/recovery.go @@ -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 diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1729391..a8720cb 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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 { -- 2.54.0 From 8c7f3d9a7ef81abc0046393dae913c58bb6f2a83 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 4 Oct 2026 18:28:30 +0000 Subject: [PATCH 3/4] Pin agent-squad to the shared agent-native commit Why: The pin check requires Backend's squad gitlink to match Dev, and that squad commit must itself pin the message-shaping agent-native. Evidence: Squad commit 5a57636 points agent-native at 3a110a7. Impact: Backend and Dev resolve the same squad and native trees. Refs: docs/records/2026-10-04-sillytavern-review.md --- deps/agent-squad | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/agent-squad b/deps/agent-squad index dcd523e..5a57636 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit dcd523ea05fe44901a6ab06e9cc8a2f0d074e0e6 +Subproject commit 5a57636cc0b8426a86a72bbbdd5b7f30e4fcac88 -- 2.54.0 From c05832dd6a3624cfdbd528f23288b9c20dc76f24 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 4 Oct 2026 18:36:58 +0000 Subject: [PATCH 4/4] Pin agent-system-core after the contract type cleanup Why: Dev and the backend checkout must name the same core commit, and the section type is no longer a separate export. Evidence: modules/core is 0b5c774. The nested checkout is detached at that commit. Impact: Backend still compiles the same Go observation type. Only the TypeScript export changed. Refs: .task/feature-sillytavern-review.md Co-authored-by: Cursor --- deps/agent-system-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/agent-system-core b/deps/agent-system-core index bd63223..0b5c774 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit bd632236c2d0a21d7f6dd9a2f67a5d2df22e57f7 +Subproject commit 0b5c7747a3165bf0d0f2219204b7b70ec3ed7645 -- 2.54.0