Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7961839f | ||
|
|
dc47c10d68 | ||
|
|
cb386023d0 | ||
|
|
9bd0aae9d8 | ||
|
|
b6ea1a5910 | ||
|
|
41df7ae56f | ||
|
|
9b69117bea | ||
|
|
6ff7fa9bd7 | ||
|
|
d1b5a94d15 | ||
|
|
ba619747c5 | ||
|
|
5e0077dc11 | ||
|
|
b160515905 | ||
|
|
9ec9f9a010 | ||
|
|
58598df095 | ||
|
|
3d850a282f | ||
|
|
a80d3c3359 | ||
|
|
1719b5f668 | ||
|
|
62bfc6068a | ||
|
|
0a7413903d | ||
|
|
696ef15168 | ||
|
|
add1fbb045 | ||
|
|
d835ad7466 | ||
|
|
9aa51e5250 | ||
|
|
e49f165d0b | ||
|
|
409390fff1 | ||
|
|
4c637d4071 | ||
|
|
432b916cba | ||
|
|
16ed7b281b | ||
|
|
42867aabf4 | ||
|
|
02858f486b | ||
|
|
faae3d3901 | ||
|
|
9b33df6625 | ||
|
|
5d5cef9fcd | ||
|
|
1fa03a3cbb | ||
|
|
4ae125a3ef | ||
|
|
25fbea8522 | ||
|
|
e1dc93d147 | ||
|
|
eb4796cf61 | ||
|
|
1df183b104 | ||
|
|
820aa4432e | ||
|
|
eb12f102f2 | ||
|
|
f5eb27df86 | ||
|
|
44d45e1092 | ||
|
|
ec9d5f78fe | ||
|
|
51ad3ce115 | ||
|
|
6681c3b47c | ||
|
|
9a1c911e53 | ||
|
|
bfeeefab94 | ||
|
|
840757240b | ||
|
|
9422bf9c28 | ||
|
|
603f1c0fa4 | ||
|
|
9f7b3c5405 | ||
|
|
fadce52dce | ||
|
|
6abf112765 | ||
|
|
367dc207b3 | ||
|
|
2bfebe378c | ||
|
|
f8db2225bf | ||
|
|
8c113a70b8 | ||
|
|
fc75847580 | ||
|
|
9ee87dcf2d | ||
|
|
b302446986 | ||
|
|
5191efd891 | ||
|
|
a466838440 | ||
|
|
1d5672e4fe | ||
|
|
e3b1e0310e | ||
|
|
40be71a3f1 | ||
|
|
093b86fe7e | ||
|
|
d79514302f | ||
|
|
70983e19fe | ||
|
|
75829fa442 | ||
|
|
c8d66ca3cd | ||
|
|
1ef1fdeac9 | ||
|
|
05c3447ebb | ||
|
|
f84733d128 | ||
|
|
3def525902 | ||
|
|
5cd67be982 | ||
|
|
f480c5b520 | ||
|
|
6f20e6cc03 | ||
|
|
efbd7b1d04 | ||
|
|
6f698d0ba5 | ||
|
|
2497508271 | ||
|
|
8633711b1d | ||
|
|
ea4651b1f8 | ||
|
|
bd4b3bca94 | ||
|
|
0c34b3595e | ||
|
|
01fd3b6fd4 | ||
|
|
eda90ed310 | ||
|
|
5ebd1bb525 | ||
|
|
6647d4879c | ||
|
|
6709325c38 | ||
|
|
ebec49db3d | ||
|
|
03b4fdabaf | ||
|
|
d116eda0bc | ||
|
|
21b8461525 | ||
|
|
bd687d27d4 | ||
|
|
06f5835211 | ||
|
|
017b40f761 | ||
|
|
288e4763a3 | ||
|
|
5714c739de | ||
|
|
7ef6ef86cd | ||
|
|
c449a176f9 | ||
|
|
8355794e45 | ||
|
|
b3f885f215 | ||
|
|
3f2c7c7033 | ||
|
|
eef60ff0b9 | ||
|
|
4df81ef1da | ||
|
|
c6912b63e7 | ||
|
|
18aa99cb95 | ||
|
|
d2bb936784 | ||
|
|
43087a79f8 |
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"agent-system-backend/internal/app"
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
var version = "0.1.0-dev"
|
||||
@@ -30,6 +31,8 @@ func main() {
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
case "user":
|
||||
os.Exit(runUser(os.Args[2:]))
|
||||
case "identity":
|
||||
os.Exit(runIdentity(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
@@ -46,6 +49,14 @@ func main() {
|
||||
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
|
||||
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
|
||||
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
|
||||
execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)")
|
||||
tier := fs.String("tier", "", "部署档:空=auto(DSN→PG,否则 SQLite)| local(SQLite,不允许 DSN)| team(PG fail-closed:缺 DSN/未认证拒绝启动,PG 故障拒绝就绪)")
|
||||
pgMaxOpen := fs.Int("pg-max-open", 0, "PG 最大打开连接(0=默认 16)")
|
||||
pgMaxIdle := fs.Int("pg-max-idle", 0, "PG 最大空闲连接(0=默认 8)")
|
||||
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
|
||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
|
||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := visited(fs)
|
||||
|
||||
@@ -53,17 +64,32 @@ func main() {
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
|
||||
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
|
||||
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if msgs := resolved.TierProblems(); len(msgs) > 0 {
|
||||
for _, msg := range msgs {
|
||||
fmt.Fprintln(os.Stderr, "agent-system: tier guard:", msg)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
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, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend,
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||
ExecWorkspace: resolved.ExecWorkspace,
|
||||
Tier: resolved.Tier,
|
||||
PGPool: store.PoolOptions{
|
||||
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
|
||||
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
|
||||
QueryTimeoutSec: resolved.PGQueryTimeoutSec,
|
||||
},
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
@@ -82,6 +108,16 @@ func visited(fs *flag.FlagSet) map[string]bool {
|
||||
name = "allowedHosts"
|
||||
case "disable-exec-tools":
|
||||
name = "disableExecTools"
|
||||
case "pg-max-open":
|
||||
name = "pgMaxOpen"
|
||||
case "pg-max-idle":
|
||||
name = "pgMaxIdle"
|
||||
case "pg-conn-max-idle-sec":
|
||||
name = "pgConnMaxIdleSec"
|
||||
case "pg-conn-max-lifetime-sec":
|
||||
name = "pgConnMaxLifetimeSec"
|
||||
case "pg-query-timeout-sec":
|
||||
name = "pgQueryTimeoutSec"
|
||||
}
|
||||
set[name] = true
|
||||
})
|
||||
@@ -108,12 +144,22 @@ func runConfig(args []string) int {
|
||||
allowedHosts := fs.String("allowed-hosts", "", "")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "")
|
||||
execBackend := fs.String("exec-backend", "", "")
|
||||
execDepsDir := fs.String("exec-deps-dir", "", "")
|
||||
execWorkspace := fs.String("exec-workspace", "", "")
|
||||
tier := fs.String("tier", "", "")
|
||||
pgMaxOpen := fs.Int("pg-max-open", 0, "")
|
||||
pgMaxIdle := fs.Int("pg-max-idle", 0, "")
|
||||
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "")
|
||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "")
|
||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "")
|
||||
fs.Parse(args[1:])
|
||||
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, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend,
|
||||
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
|
||||
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
|
||||
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -239,3 +285,50 @@ func runUser(args []string) int {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
|
||||
// 与 serve --instance(runs 进程标识)无关。
|
||||
func runIdentity(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system identity <ensure|verify> --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]")
|
||||
return 2
|
||||
}
|
||||
sub := args[0]
|
||||
fs := flag.NewFlagSet("identity "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录(SQLite 路径 <data>/agent-system.db)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite)")
|
||||
instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id,形如 svc-<group>-…)")
|
||||
dbref := fs.String("dbref", "", "数据库锚点(空 = sqlite 或 pg)")
|
||||
fs.Parse(args[1:])
|
||||
if *instanceID == "" {
|
||||
fmt.Fprintln(os.Stderr, "identity: --instance-id 必填")
|
||||
return 2
|
||||
}
|
||||
ref := *dbref
|
||||
if ref == "" {
|
||||
if *dsn != "" {
|
||||
ref = "pg"
|
||||
} else {
|
||||
ref = "sqlite"
|
||||
}
|
||||
}
|
||||
switch sub {
|
||||
case "ensure":
|
||||
if err := app.EnsureSystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "identity ensure:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("ensured instance_id=%s dbref=%s\n", *instanceID, ref)
|
||||
return 0
|
||||
case "verify":
|
||||
if err := app.VerifySystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "identity verify:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("verified instance_id=%s dbref=%s\n", *instanceID, ref)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
|
||||
// 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 →
|
||||
// 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。
|
||||
|
||||
func pgSchemaDSN(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN 未设置:真库集成由 CI 的必跑 job 覆盖")
|
||||
}
|
||||
schema := "mig_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("preinstall pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
}
|
||||
return store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), cleanup
|
||||
}
|
||||
|
||||
func TestMigrateEndToEndAndIdempotent(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
|
||||
t.Fatalf("首次迁移:%v", err)
|
||||
}
|
||||
// 幂等重跑(同身份):表级 done 对账,零重复行。
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
|
||||
t.Fatalf("幂等重跑:%v", err)
|
||||
}
|
||||
// 目标侧关键断言:布尔映射、NULL 保留、Unicode、BLOB、SERIAL 序列推进、
|
||||
// 复合主键事件序。
|
||||
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
db := st.DB()
|
||||
var pinned, archived bool
|
||||
if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived {
|
||||
t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err)
|
||||
}
|
||||
var title, note string
|
||||
if err := db.QueryRow(`SELECT title, session_note FROM conversations WHERE id='c1'`).Scan(&title, ¬e); err != nil || !strings.Contains(note, "注") {
|
||||
t.Fatalf("Unicode:title=%q note=%q err=%v", title, note, err)
|
||||
}
|
||||
var vec []byte
|
||||
if err := db.QueryRow(`SELECT vec FROM knowledge_vectors WHERE doc_id='k1'`).Scan(&vec); err != nil || len(vec) != 12 {
|
||||
t.Fatalf("BLOB 向量:len=%d err=%v", len(vec), err)
|
||||
}
|
||||
var ended sql.NullString
|
||||
if err := db.QueryRow(`SELECT ended_at FROM runs WHERE id='r1'`).Scan(&ended); err != nil || ended.Valid {
|
||||
t.Fatalf("NULL 保留:ended=%v err=%v", ended, err)
|
||||
}
|
||||
var seqMax int
|
||||
if err := db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id='c1'`).Scan(&seqMax); err != nil || seqMax != 2 {
|
||||
t.Fatalf("事件复合主键序:max=%d err=%v", seqMax, err)
|
||||
}
|
||||
// 复制的显式 id 之后,应用侧插入不撞序列(command_audit.id=7 已复制)。
|
||||
if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil {
|
||||
t.Fatalf("序列推进失败(复制后插入撞主键):%v", err)
|
||||
}
|
||||
// 报告不含秘密值。
|
||||
var report string
|
||||
if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") {
|
||||
t.Fatalf("报告泄密:err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateResumeAfterInterrupt(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟断点:messages 已 done 后被部分回退(行删一半、状态回 pending),
|
||||
// 重跑只重做该表,终态校验仍全绿。
|
||||
db, err := sql.Open("pgx", target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`DELETE FROM messages WHERE id='m3'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE migration_table_state SET state='pending' WHERE migration_id='mig-resume' AND table_name='messages'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
|
||||
t.Fatalf("断点续跑:%v", err)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
|
||||
t.Fatalf("续跑后 messages=%d:重做该表应恢复 3 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesForeignDataAndWrongSnapshot(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
|
||||
// 非空目标(人工数据、无迁移身份):拒绝。
|
||||
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
db, err := sql.Open("pgx", target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES ('foreign', '外来', '{}', '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z', 'alice')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-x"})
|
||||
if err == nil || !strings.Contains(err.Error(), "非空目标") {
|
||||
t.Fatalf("非空目标应拒绝:%v", err)
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM projects WHERE id='foreign'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 同 migration_id 换快照:拒绝(仅同一冻结快照可续传)。
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-snap"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := buildFixture(t) // 不同文件 ⇒ 不同 snapshot_id
|
||||
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-snap"})
|
||||
if err == nil || !strings.Contains(err.Error(), "快照不同") {
|
||||
t.Fatalf("换快照应拒绝:%v", err)
|
||||
}
|
||||
|
||||
// 其他 migration_id 已存在:拒绝。
|
||||
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-other"})
|
||||
if err == nil || !strings.Contains(err.Error(), "其他迁移记录") {
|
||||
t.Fatalf("他身份应拒绝:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateBlocksUnknownOwner(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
// 在夹具里造一个 users 之外的 owner 行(直接写源库)。
|
||||
srcPath := fixture
|
||||
st, err := store.OpenSQLite(srcPath) // 普通打开(夹具可写)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
|
||||
('k_ghost', 'p1', 'upload', '幽灵', 'g.md', '…', '2026-10-11T01:00:00Z', 'ghost-user')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
err = cmdMigrate([]string{"--source", srcPath, "--target", target, "--migration-id", "mig-ghost"})
|
||||
if err == nil || !strings.Contains(err.Error(), "未知 owner") {
|
||||
t.Fatalf("未知 owner 应阻塞:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
// storage-migrate:SQLite → PostgreSQL 受管迁移入口(协作存储方案 U4/R7)。
|
||||
//
|
||||
// 纪律(方案 §5 U4 / §6):
|
||||
// - 只处理 Backend 库;不执行 SQLite dump 作为 PG SQL(按行复制 + 类型映射);
|
||||
// - 迁移身份(migration_id + source_snapshot_id + source_schema_version)落
|
||||
// 目标库 migration_control:仅同一冻结快照可续传;目标已含其他数据即拒绝;
|
||||
// - 报告只含计数、校验和与归属检查结论——不含 DSN、密钥与行正文(KTD14)。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// storage-migrate inventory --source agent-system.db [--artifacts dir] [--out report.json]
|
||||
// storage-migrate backup --source agent-system.db --out snapshot.db
|
||||
// storage-migrate migrate --source snapshot.db --target "postgres://…" \
|
||||
// --migration-id <名> [--artifacts dir] [--out report.json]
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // SQLite 驱动(源,只读打开)
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "inventory":
|
||||
err = cmdInventory(os.Args[2:])
|
||||
case "backup":
|
||||
err = cmdBackup(os.Args[2:])
|
||||
case "migrate":
|
||||
err = cmdMigrate(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "storage-migrate:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "用法:storage-migrate <inventory|backup|migrate> [flags](-h 看各子命令 flags)")
|
||||
}
|
||||
|
||||
// ---- 公共小件 ----
|
||||
|
||||
func openSourceRO(path string) (*sql.DB, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("source %s: %w", path, err)
|
||||
}
|
||||
// query_only:迁移源只读(冻结快照,任何写入都是缺陷)。
|
||||
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=query_only(1)", path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("source %s: %w", path, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
// ---- inventory:只读盘点(§6.1) ----
|
||||
|
||||
func cmdInventory(args []string) error {
|
||||
fs := flag.NewFlagSet("inventory", flag.ExitOnError)
|
||||
source := fs.String("source", "", "SQLite 库路径(活跃库或冻结快照)")
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(可选;盘点文件清单与哈希)")
|
||||
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
|
||||
fs.Parse(args)
|
||||
if *source == "" {
|
||||
return fmt.Errorf("--source 必填")
|
||||
}
|
||||
db, err := openSourceRO(*source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
rep, err := inventoryReport(db, *source, *artifacts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeReport(rep, *out)
|
||||
}
|
||||
|
||||
func inventoryReport(db *sql.DB, source, artifactsDir string) (map[string]any, error) {
|
||||
version, err := sourceSchemaVersion(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var mode string
|
||||
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
||||
return nil, fmt.Errorf("journal_mode: %w", err)
|
||||
}
|
||||
var chk string
|
||||
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
|
||||
return nil, fmt.Errorf("integrity_check: %v (%q)", err, chk)
|
||||
}
|
||||
tables, err := sqliteTableCounts(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap, size, err := fileSHA256(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep := map[string]any{
|
||||
"command": "inventory", "source": source, "source_sha256": snap,
|
||||
"source_bytes": size, "source_schema_version": version,
|
||||
"journal_mode": mode, "integrity": chk, "table_counts": tables,
|
||||
"generated_at": nowISO(),
|
||||
}
|
||||
if artifactsDir != "" {
|
||||
manifest, err := artifactManifest(artifactsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep["artifacts"] = manifest
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func sourceSchemaVersion(db *sql.DB) (int, error) {
|
||||
var v int
|
||||
err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&v)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
return 0, fmt.Errorf("源库缺 schema_migrations(不是本工具管理的库):%w", err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// sqliteTableCounts:仅盘点本工具认识的业务表(见 copyOrder)。
|
||||
func sqliteTableCounts(db *sql.DB) (map[string]int64, error) {
|
||||
out := map[string]int64{}
|
||||
for _, t := range copyOrder {
|
||||
var n int64
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
continue // 旧库可能没有新表
|
||||
}
|
||||
return nil, fmt.Errorf("count %s: %w", t, err)
|
||||
}
|
||||
out[t] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func artifactManifest(dir string) (map[string]any, error) {
|
||||
type entry struct {
|
||||
Name, SHA string
|
||||
Size int64
|
||||
}
|
||||
var entries []entry
|
||||
hAll := sha256.New()
|
||||
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
sum, _, err := fileSHA256(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, p)
|
||||
entries = append(entries, entry{rel, sum, info.Size()})
|
||||
fmt.Fprintf(hAll, "%s %d %s\n", rel, info.Size(), sum)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artifacts %s: %w", dir, err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
return map[string]any{
|
||||
"dir": dir, "files": entries, "file_count": len(entries),
|
||||
"manifest_sha256": hex.EncodeToString(hAll.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- backup:一致性快照(§6.2:VACUUM INTO + 可打开校验 + 计数对账) ----
|
||||
|
||||
func cmdBackup(args []string) error {
|
||||
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
||||
source := fs.String("source", "", "活跃 SQLite 库路径")
|
||||
out := fs.String("out", "", "快照输出路径(不得与源同文件;已存在则拒绝)")
|
||||
fs.Parse(args)
|
||||
if *source == "" || *out == "" {
|
||||
return fmt.Errorf("--source 与 --out 必填")
|
||||
}
|
||||
if same, _ := filepath.EvalSymlinks(*source); same != "" {
|
||||
if abs1, _ := filepath.Abs(same); abs1 != "" {
|
||||
if abs2, _ := filepath.Abs(*out); abs2 == abs1 {
|
||||
return fmt.Errorf("快照输出不得与源同文件")
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
return fmt.Errorf("快照输出已存在(不覆盖):%s", *out)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// VACUUM INTO 在线安全(含 WAL 内容);query_only 会禁写,需普通打开。
|
||||
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)", *source))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`VACUUM INTO ?`, *out); err != nil {
|
||||
return fmt.Errorf("vacuum into: %w", err)
|
||||
}
|
||||
// 校验:快照可打开、完整性、逐表计数与源一致。
|
||||
snap, err := openSourceRO(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer snap.Close()
|
||||
var chk string
|
||||
if err := snap.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
|
||||
return fmt.Errorf("快照完整性校验失败:%v (%q)", err, chk)
|
||||
}
|
||||
srcCounts, err := sqliteTableCounts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapCounts, err := sqliteTableCounts(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for t, n := range srcCounts {
|
||||
if snapCounts[t] != n {
|
||||
return fmt.Errorf("快照计数不符:%s 源=%d 快照=%d", t, n, snapCounts[t])
|
||||
}
|
||||
}
|
||||
sum, size, err := fileSHA256(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("backup ok: %s(%d 字节,sha256 %s…,%d 表计数一致)\n", *out, size, sum[:12], len(srcCounts))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- migrate:受管迁移(§6.3 + U4) ----
|
||||
|
||||
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
|
||||
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
|
||||
var copyOrder = []string{
|
||||
"projects", "users", "profiles", "settings", "plugins",
|
||||
"conversations", "runs", "messages", "events", "artifacts",
|
||||
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
"activity_cards", "command_audit", "prompt_modules",
|
||||
"sessions", "api_keys",
|
||||
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
|
||||
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
|
||||
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
|
||||
"user_commands", "pending_notices", "recovery_holds",
|
||||
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
|
||||
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
|
||||
"agent_system_identity",
|
||||
}
|
||||
|
||||
const controlDDL = `
|
||||
CREATE TABLE IF NOT EXISTS migration_control (
|
||||
migration_id TEXT PRIMARY KEY,
|
||||
source_snapshot_id TEXT NOT NULL,
|
||||
source_schema_version INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
verified_at TEXT,
|
||||
report TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS migration_table_state (
|
||||
migration_id TEXT NOT NULL,
|
||||
table_name TEXT NOT NULL,
|
||||
source_count INTEGER NOT NULL,
|
||||
target_count INTEGER NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
PRIMARY KEY (migration_id, table_name)
|
||||
);`
|
||||
|
||||
func cmdMigrate(args []string) error {
|
||||
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
|
||||
source := fs.String("source", "", "冻结快照路径(backup 子命令产物;不接受活跃库)")
|
||||
target := fs.String("target", "", "目标 PostgreSQL DSN(须为空库或同身份续传库)")
|
||||
migrationID := fs.String("migration-id", "", "迁移实例名(人工指定,报告与续传身份的一部分)")
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(可选;仅盘点清单入报告,不复制)")
|
||||
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
|
||||
fs.Parse(args)
|
||||
if *source == "" || *target == "" || *migrationID == "" {
|
||||
return fmt.Errorf("--source、--target、--migration-id 必填")
|
||||
}
|
||||
src, err := openSourceRO(*source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
snapID, _, err := fileSHA256(*source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcVersion, err := sourceSchemaVersion(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 目标:先用 store 同款迁移器建 schema/扩展(OpenPostgres 会跑迁移与
|
||||
// 未知版本门禁),再落控制表。DSN 不回显、不入报告。
|
||||
st, err := store.OpenPostgres(*target, filepath.Join(filepath.Dir(*source), "migrate-artifacts"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open target: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
db := st.DB()
|
||||
if _, err := db.Exec(controlDDL); err != nil {
|
||||
return fmt.Errorf("control tables: %w", err)
|
||||
}
|
||||
if err := guardIdentity(db, *migrationID, snapID, srcVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureControlRow(db, *migrationID, snapID, srcVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rep := map[string]any{
|
||||
"command": "migrate", "migration_id": *migrationID,
|
||||
"source_snapshot_id": snapID, "source_schema_version": srcVersion,
|
||||
"started_at": nowISO(),
|
||||
}
|
||||
var tables []map[string]any
|
||||
for _, t := range copyOrder {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue // 旧库无此表
|
||||
}
|
||||
entry, err := migrateTable(db, src, *migrationID, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("table %s: %w", t, err)
|
||||
}
|
||||
tables = append(tables, entry)
|
||||
}
|
||||
// 未知 owner 检查(U4:阻塞,不静默)。'' 与 'local' 是单租户默认身份。
|
||||
if err := checkOwners(db, src); err != nil {
|
||||
return err
|
||||
}
|
||||
// 校验:目标逐表重算计数与校验和,全部一致才落 verified。
|
||||
if err := verifyTables(db, *migrationID, tables); err != nil {
|
||||
return err
|
||||
}
|
||||
var totalSrc, totalDst int64
|
||||
for _, e := range tables {
|
||||
totalSrc += e["source_count"].(int64)
|
||||
totalDst += e["target_count"].(int64)
|
||||
}
|
||||
rep["tables"] = tables
|
||||
rep["rows_total"] = totalSrc
|
||||
rep["verified_at"] = nowISO()
|
||||
if *artifacts != "" {
|
||||
manifest, err := artifactManifest(*artifacts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep["artifacts"] = manifest
|
||||
}
|
||||
repJSON, _ := json.Marshal(rep)
|
||||
if _, err := db.Exec(`UPDATE migration_control SET finished_at=$1, verified_at=$2, report=$3 WHERE migration_id=$4`,
|
||||
nowISO(), rep["verified_at"], string(repJSON), *migrationID); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeReport(rep, *out)
|
||||
}
|
||||
|
||||
// guardIdentity:目标非空且身份不同即拒绝(R7:不覆盖他人数据)。
|
||||
// - 存在其他 migration_id 的完成记录 → 拒绝;
|
||||
// - 同 migration_id 但 source_snapshot_id 不同(换了快照续传)→ 拒绝;
|
||||
// - 业务表已有数据而控制表为空(人工造过数据)→ 拒绝。
|
||||
func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM migration_control WHERE migration_id<>$1`, migrationID).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("目标库已有其他迁移记录(%d 条):拒绝覆盖,换空库或专项回迁方案", n)
|
||||
}
|
||||
var snap, started string
|
||||
var sv int
|
||||
err := db.QueryRow(`SELECT source_snapshot_id, source_schema_version, started_at FROM migration_control WHERE migration_id=$1`, migrationID).Scan(&snap, &sv, &started)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
// 新迁移:业务表必须为空。
|
||||
busy, err := businessRows(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if busy > 0 {
|
||||
return fmt.Errorf("目标库业务表已有 %d 行且无迁移身份:拒绝(非空目标不得作为迁移目标)", busy)
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if snap != snapID {
|
||||
return fmt.Errorf("同 migration_id 但快照不同(库=%s… 本次=%s…):仅同一冻结快照可续传", snap[:12], snapID[:12])
|
||||
}
|
||||
_ = sv
|
||||
_ = started
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func businessRows(db *sql.DB) (int64, error) {
|
||||
var total int64
|
||||
for _, t := range copyOrder {
|
||||
var n int64
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
continue
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func ensureControlRow(db *sql.DB, migrationID, snapID string, srcVersion int) error {
|
||||
_, err := db.Exec(`INSERT INTO migration_control (migration_id, source_snapshot_id, source_schema_version, started_at)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT(migration_id) DO NOTHING`, migrationID, snapID, srcVersion, nowISO())
|
||||
return err
|
||||
}
|
||||
|
||||
func sqliteHasTable(db *sql.DB, name string) bool {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n); err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// migrateTable:done 则对账(幂等重跑);pending 则按行复制(布尔映射 +
|
||||
// 批量 INSERT + 序列推进)后落 done 与源侧校验和。
|
||||
func migrateTable(db *sql.DB, src *sql.DB, migrationID, table string) (map[string]any, error) {
|
||||
cols, pk, err := targetColumns(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var state string
|
||||
var srcCount, dstCountStored int64
|
||||
var srcChecksum string
|
||||
err = db.QueryRow(`SELECT state, source_count, target_count, checksum FROM migration_table_state WHERE migration_id=$1 AND table_name=$2`,
|
||||
migrationID, table).Scan(&state, &srcCount, &dstCountStored, &srcChecksum)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
state = "pending"
|
||||
}
|
||||
// 源侧计数与校验和始终重算(冻结快照上必然稳定;差异=快照被换,拒绝)。
|
||||
bools, err := boolColumns(db, table, cols)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countNow, sumNow, err := sourceScan(src, table, cols, pk, bools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state == "done" {
|
||||
if countNow != srcCount || sumNow != srcChecksum {
|
||||
return nil, fmt.Errorf("源与登记不符(疑似换快照):count %d/%d checksum %s…/%s…", countNow, srcCount, sumNow[:12], srcChecksum[:12])
|
||||
}
|
||||
entry := map[string]any{"table": table, "source_count": countNow, "target_count": dstCountStored, "checksum": sumNow, "state": "done(idempotent)"}
|
||||
return entry, nil
|
||||
}
|
||||
if err := copyRows(db, src, table, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dstCount, _, err := targetScan(db, table, cols, pk, bools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dstCount != countNow {
|
||||
return nil, fmt.Errorf("复制后计数不符:源=%d 目标=%d", countNow, dstCount)
|
||||
}
|
||||
if err := advanceSequences(db, table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO migration_table_state (migration_id, table_name, source_count, target_count, checksum, state, finished_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT(migration_id, table_name) DO UPDATE SET source_count=excluded.source_count, target_count=excluded.target_count, checksum=excluded.checksum, state=excluded.state, finished_at=excluded.finished_at`,
|
||||
migrationID, table, countNow, dstCount, sumNow, "done", nowISO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"table": table, "source_count": countNow, "target_count": dstCount, "checksum": sumNow, "state": "done"}, nil
|
||||
}
|
||||
|
||||
// targetColumns:目标侧列清单(按名排序)+ 主键列。布尔列集合同取自目标
|
||||
// information_schema(类型映射以 PG schema 为准——SQLite 侧 0/1 → bool)。
|
||||
func targetColumns(db *sql.DB, table string) (cols []string, pk []string, err error) {
|
||||
// 生成列(PG GENERATED ALWAYS,如 knowledge.tsv)与库内向量副本列
|
||||
// (vec_pg:BLOB 权威的派生表示,U6-3)不复制不校验:PG 从业务列
|
||||
// 派生或由 Upsert 双写,源库(SQLite)没有该列。
|
||||
rows, err := db.Query(`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema=current_schema() AND table_name=$1 AND is_generated='NEVER'
|
||||
AND column_name NOT IN ('vec_pg') ORDER BY column_name`, table)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
exists := false
|
||||
for rows.Next() {
|
||||
var name, dtype string
|
||||
if err := rows.Scan(&name, &dtype); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
exists = true
|
||||
cols = append(cols, name)
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, fmt.Errorf("目标缺表 %s(迁移器未建?)", table)
|
||||
}
|
||||
prows, err := db.Query(`
|
||||
SELECT a.attname FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey)
|
||||
WHERE i.indisprimary AND i.indrelid=$1::regclass`, table)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer prows.Close()
|
||||
for prows.Next() {
|
||||
var p string
|
||||
if err := prows.Scan(&p); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pk = append(pk, p)
|
||||
}
|
||||
if len(pk) == 0 {
|
||||
pk = cols // 无主键表按全列序校验
|
||||
}
|
||||
return cols, pk, nil
|
||||
}
|
||||
|
||||
func boolColumns(db *sql.DB, table string, cols []string) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
rows, err := db.Query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema=current_schema() AND table_name=$1 AND data_type='boolean'`, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[c] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// canonicalRow:稳定行编码(校验和用):列名排序、值规范化
|
||||
// (bool→true/false、[]byte→base64、NULL→null、数值/字符串原样)。
|
||||
func canonicalRow(cols []string, vals []any, bools map[string]bool) string {
|
||||
type kv struct {
|
||||
K string
|
||||
V any
|
||||
}
|
||||
pairs := make([]kv, len(cols))
|
||||
for i := range cols {
|
||||
pairs[i] = kv{cols[i], normalize(vals[i], bools[cols[i]])}
|
||||
}
|
||||
b, _ := json.Marshal(pairs)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// normalize:布尔列按目标 schema 对称归一(源 int64 0/1、目标 bool 编码一致,
|
||||
// 否则校验和必然不符——真库实测);[]byte→base64。
|
||||
func normalize(v any, isBool bool) any {
|
||||
if isBool {
|
||||
return sqliteBool(v)
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case []byte:
|
||||
return base64.StdEncoding.EncodeToString(t)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// hashChain:按主键序哈希链(前后衔接,顺序敏感)。
|
||||
func hashChain(prev string, rowJSON string) string {
|
||||
h := sha256.Sum256([]byte(prev + "|" + rowJSON))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// scanChecksum:源/目标共用的扫描对账(列清单一致、主键序、行哈希链)。
|
||||
func scanChecksum(rows *sql.Rows, cols []string, bools map[string]bool) (int64, string, error) {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
var count int64
|
||||
sum := ""
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
sum = hashChain(sum, canonicalRow(cols, vals, bools))
|
||||
count++
|
||||
}
|
||||
return count, sum, rows.Err()
|
||||
}
|
||||
|
||||
func sourceScan(src *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
|
||||
orderBy := "ORDER BY " + quoteJoin(pk, `"`)
|
||||
rows, err := src.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ` + orderBy)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanChecksum(rows, cols, bools)
|
||||
}
|
||||
|
||||
func targetScan(db *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
|
||||
rows, err := db.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ORDER BY ` + quoteJoin(pk, `"`))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanChecksum(rows, cols, bools)
|
||||
}
|
||||
|
||||
func quoteJoin(names []string, q string) string {
|
||||
out := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = q + n + q
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// copyRows:按行复制。布尔列 0/1→false/true;其余原样(TEXT/INT/REAL/BLOB)。
|
||||
func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
|
||||
bools, err := boolColumns(db, table, cols)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colList := quoteJoin(cols, `"`)
|
||||
ph := make([]string, len(cols))
|
||||
for i := range ph {
|
||||
ph[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
placeholders := strings.Join(ph, ",")
|
||||
srcRows, err := src.Query(`SELECT ` + colList + ` FROM "` + table + `"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcRows.Close()
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
var batch int
|
||||
for srcRows.Next() {
|
||||
if err := srcRows.Scan(ptrs...); err != nil {
|
||||
return err
|
||||
}
|
||||
args := make([]any, len(cols))
|
||||
for i, c := range cols {
|
||||
v := vals[i]
|
||||
if bools[c] {
|
||||
args[i] = sqliteBool(v)
|
||||
} else {
|
||||
args[i] = v
|
||||
}
|
||||
}
|
||||
if _, err := stmt.Exec(args...); err != nil {
|
||||
return fmt.Errorf("insert: %w", err)
|
||||
}
|
||||
batch++
|
||||
}
|
||||
if err := srcRows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func sqliteBool(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
return t != 0
|
||||
case bool:
|
||||
return t
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// advanceSequences:SERIAL/identity 列复制后推进序列(显式插入不推进,
|
||||
// 后续应用插入会撞主键)。无序列表为空操作。
|
||||
func advanceSequences(db *sql.DB, table string) error {
|
||||
rows, err := db.Query(`
|
||||
SELECT pg_get_serial_sequence($1, column_name)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=current_schema() AND table_name=$1
|
||||
AND (column_default LIKE 'nextval%' OR is_identity='YES')`, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var seqs []string
|
||||
for rows.Next() {
|
||||
var s any
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
seqs = append(seqs, s.(string))
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range seqs {
|
||||
if _, err := db.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM "%s"), 1))`, s, table)); err != nil {
|
||||
return fmt.Errorf("setval %s: %w", s, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
||||
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
||||
func checkOwners(db *sql.DB, src *sql.DB) error {
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
|
||||
for _, t := range ownerTables {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue
|
||||
}
|
||||
rows, err := src.Query(`SELECT DISTINCT owner FROM "` + t + `" WHERE owner<>'' AND owner<>'local'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var unknown []string
|
||||
for rows.Next() {
|
||||
var o string
|
||||
if err := rows.Scan(&o); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE owner=$1`, o).Scan(&n); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
unknown = append(unknown, o)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
return fmt.Errorf("表 %s 引用未知 owner(users 无此行):%v——先在目标补建用户或修正归属,再迁移", t, unknown)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTables:目标逐表重算,与登记的源计数/校验和全部一致才通过。
|
||||
func verifyTables(db *sql.DB, migrationID string, tables []map[string]any) error {
|
||||
for _, e := range tables {
|
||||
table := e["table"].(string)
|
||||
wantCount := e["source_count"].(int64)
|
||||
wantSum := e["checksum"].(string)
|
||||
cols, pk, err := targetColumns(db, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bools, err := boolColumns(db, table, cols)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotCount, gotSum, err := targetScan(db, table, cols, pk, bools)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gotCount != wantCount || gotSum != wantSum {
|
||||
return fmt.Errorf("表 %s 校验不符:count %d/%d checksum %s…/%s…", table, gotCount, wantCount, gotSum[:12], wantSum[:12])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeReport(rep map[string]any, out string) error {
|
||||
b, err := json.MarshalIndent(rep, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == "" {
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(out, append(b, '\n'), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// U4 迁移工具测试:夹具覆盖全部当前 schema 的边值(布尔/空值/时间/JSON/
|
||||
// Unicode/二进制向量/秘密字段),以及 inventory/backup 的行为面。
|
||||
// 真库端到端(幂等/续传/非空拒/未知 owner)在 integration_test.go(PG 门控)。
|
||||
|
||||
// buildFixture:建一个全 schema 的 SQLite 库并填充边值行,返回路径。
|
||||
func buildFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fixture.db")
|
||||
st, err := store.OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
seedEdgeRows(t, st)
|
||||
return path
|
||||
}
|
||||
|
||||
func seedEdgeRows(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
// users(含 is_admin/must_change 布尔)与单租户默认身份。
|
||||
mustExec(t, st, `INSERT INTO users (id, owner, name, created_at, password_hash, password_set_at, must_change_password, is_admin) VALUES
|
||||
('u1', 'alice', '爱丽丝', '2026-10-11T01:00:00Z', 'sha256:…', '2026-10-11T01:00:00Z', false, true),
|
||||
('u2', 'bob', 'Bob 😊', '2026-10-11T01:00:00Z', '', '', true, false)`)
|
||||
// 项目与会话(布尔 pinned/archived/allow_cross_read/cross_private、空 JSON)。
|
||||
mustExec(t, st, `INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES
|
||||
('p1', '项目一', '{}', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
|
||||
('p2', 'Emoji 项目 🚀', '{"a":null,"b":[1,2,"三"]}', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
|
||||
mustExec(t, st, `INSERT INTO conversations (id, project_id, title, profile_id, created_at, updated_at, owner, pinned, archived, allow_cross_read, cross_private, permission_mode, session_note) VALUES
|
||||
('c1', 'p1', '会话', '', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice', true, false, false, true, '', 'note—注'),
|
||||
('c2', 'p2', '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '', false, true, true, false, 'strict', '')`)
|
||||
// 消息(request_id 幂等、parts JSON 含空对象/空数组/Unicode)与 seq 顺序。
|
||||
mustExec(t, st, `INSERT INTO messages (id, conversation_id, run_id, role, parts, seq, created_at, request_id) VALUES
|
||||
('m1', 'c1', '', 'user', '[{"type":"text","text":"你好"}]', 1, '2026-10-11T01:00:01Z', 'req-1'),
|
||||
('m2', 'c1', '', 'assistant', '[{"type":"kv","kv":[]},{"type":"text","text":""}]', 2, '2026-10-11T01:00:02Z', ''),
|
||||
('m3', 'c2', '', 'system', '[]', 1, '2026-10-11T02:00:01Z', 'req-2')`)
|
||||
// runs(NULL ended_at/usage/error)。
|
||||
mustExec(t, st, `INSERT INTO runs (id, parent_run_id, conversation_id, project_id, owner, kind, profile_id, status, phase, agent_name, created_at, instance_id) VALUES
|
||||
('r1', '', 'c1', 'p1', 'alice', 'conversation', '', 'completed', '', '', '2026-10-11T01:00:00Z', 'host:1'),
|
||||
('r2', 'r1', 'c1', 'p1', 'alice', 'subagent', '', 'running', 'thinking', '检索', '2026-10-11T01:00:03Z', 'host:2')`)
|
||||
// events(复合主键 (conversation_id, seq))。
|
||||
mustExec(t, st, `INSERT INTO events (conversation_id, seq, run_id, ts, v, type, payload) VALUES
|
||||
('c1', 1, 'r1', '2026-10-11T01:00:01Z', 1, 'run.phase', '{"phase":"thinking"}'),
|
||||
('c1', 2, 'r1', '2026-10-11T01:00:02Z', 1, 'message.appended', NULL),
|
||||
('c2', 1, '', '2026-10-11T02:00:01Z', 1, 'x', '{}')`)
|
||||
// 工件(size 0 与大值)。
|
||||
mustExec(t, st, `INSERT INTO artifacts (id, project_id, name, kind, mime, size, source_run, content_path, created_at, updated_at, owner) VALUES
|
||||
('a1', 'p1', '报告.md', 'doc', 'text/markdown', 0, 'r1', 'art/a1', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
|
||||
('a2', 'p2', '数据.xlsx', 'doc', '', 2147483647, '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
|
||||
// HITL 卡(resolved=0、resolution NULL)。
|
||||
mustExec(t, st, `INSERT INTO hitl_cards (id, run_id, conversation_id, payload, created_at, resolved, resolution, owner) VALUES
|
||||
('h1', 'r2', 'c1', '{"q":"继续?"}', '2026-10-11T01:00:04Z', 0, NULL, 'alice')`)
|
||||
// 知识 + 向量(BLOB 二进制:float32 little-endian)。
|
||||
mustExec(t, st, `INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
|
||||
('k1', 'p1', 'upload', '标题', 'doc.md', '正文内容…', '2026-10-11T01:00:00Z', 'alice')`)
|
||||
mustExec(t, st, `INSERT INTO knowledge_vectors (doc_id, owner, ref, dim, vec, created_at) VALUES
|
||||
('k1', 'alice', 'doc.md', 3, x'0000803F0000003F000080BF', '2026-10-11T01:00:00Z')`)
|
||||
// 活动卡(pinned 布尔)。
|
||||
mustExec(t, st, `INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version) VALUES
|
||||
('ac1', 'p1', 'c1', '', 'alice', '{"title":"卡"}', 'active', 1, '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 2)`)
|
||||
// 命令审计(SERIAL 主键:显式 id 插入)。
|
||||
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES
|
||||
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'done', 'op-1', 'c1', '{"actor":"alice"}', 'alice')`)
|
||||
// 设置('' 默认租户 scope)。
|
||||
mustExec(t, st, `INSERT INTO settings (owner, scope, "values") VALUES ('', 'runtime', '{"rateLimitPerMin":0}')`)
|
||||
// 秘密字段(值不外泄:迁移按行复制,报告无正文)。
|
||||
mustExec(t, st, `INSERT INTO secret_keys (id, owner, service, label, value, active, created_at) VALUES
|
||||
('sk1', 'alice', 'openai', 'main', 'sk-secret-value-不打印', 1, '2026-10-11T01:00:00Z')`)
|
||||
// 恢复隔离(业务态,应随迁)。
|
||||
mustExec(t, st, `INSERT INTO recovery_holds (run_id, owner, conversation_id, reason, created_at) VALUES
|
||||
('rh1', 'alice', 'c1', '副作用未知', '2026-10-11T01:00:00Z')`)
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustExec:夹具直接走底层连接写行(业务代码不得如此;见 store.DB 注释)。
|
||||
func mustExec(t *testing.T, st *store.Store, q string) {
|
||||
t.Helper()
|
||||
if _, err := st.DB().Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inventory ----
|
||||
|
||||
func TestInventoryReport(t *testing.T) {
|
||||
path := buildFixture(t)
|
||||
db, err := openSourceRO(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
rep, err := inventoryReport(db, path, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep["source_schema_version"].(int) < 20 {
|
||||
t.Fatalf("schema version: %v", rep["source_schema_version"])
|
||||
}
|
||||
counts := rep["table_counts"].(map[string]int64)
|
||||
if counts["messages"] != 3 || counts["events"] != 3 || counts["users"] != 2 {
|
||||
t.Fatalf("counts: %v", counts)
|
||||
}
|
||||
b, _ := json.Marshal(rep)
|
||||
if strings.Contains(string(b), "sk-secret-value") {
|
||||
t.Fatal("inventory 报告不得包含秘密值")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryMissingFile(t *testing.T) {
|
||||
if _, err := openSourceRO(filepath.Join(t.TempDir(), "nope.db")); err == nil {
|
||||
t.Fatal("缺文件应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- backup ----
|
||||
|
||||
func TestBackupVerifiesAndSnapshotsConsistent(t *testing.T) {
|
||||
path := buildFixture(t)
|
||||
out := filepath.Join(t.TempDir(), "snap.db")
|
||||
if err := cmdBackup([]string{"--source", path, "--out", out}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap, err := openSourceRO(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer snap.Close()
|
||||
var n int
|
||||
if err := snap.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
|
||||
t.Fatalf("snap messages: %d %v", n, err)
|
||||
}
|
||||
// 已存在的输出拒绝覆盖。
|
||||
if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil {
|
||||
t.Fatal("已存在快照应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 夹具即文档:确认全部业务表在 copyOrder 内(防新表漏迁) ----
|
||||
|
||||
func TestCopyOrderCoversAllFixtureTables(t *testing.T) {
|
||||
path := buildFixture(t)
|
||||
db, err := openSourceRO(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
known := map[string]bool{}
|
||||
for _, t := range copyOrder {
|
||||
known[t] = true
|
||||
}
|
||||
known["schema_migrations"] = true // 迁移器管理,不随迁
|
||||
// 运行态表不随迁:执行许可是进程瞬态;执行租约与跨实例命令属当前
|
||||
// 实例的调度现场(接管语义在 U5 内由 lease/fencing 处理,不复制历史)。
|
||||
for _, rt := range []string{"admission_leases", "conversation_executions", "run_commands"} {
|
||||
known[rt] = true
|
||||
}
|
||||
// FTS5 虚表影子表:SQLite 内部结构,PG 侧由 v4 生成列/索引从 knowledge
|
||||
// 行自动重建,不随迁。
|
||||
for _, shadow := range []string{"knowledge_fts", "knowledge_fts_data", "knowledge_fts_idx", "knowledge_fts_docsize", "knowledge_fts_config"} {
|
||||
known[shadow] = true
|
||||
}
|
||||
var missing []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !known[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("copyOrder 漏表(新表加入 schema 后须同步工具清单):%v", missing)
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/agent-native updated: 3b92cb63b4...c348441691
Vendored
+1
-1
Submodule deps/agent-squad updated: 8bbf850fad...490b0b2dfa
Vendored
+1
-1
Submodule deps/agent-system-core updated: a253a40b8e...89f3906c39
@@ -17,6 +17,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/runtime"
|
||||
)
|
||||
|
||||
// upload 上传一个材料文件,返回 artifact id。
|
||||
@@ -377,6 +379,18 @@ func unverifiedFacts(artifactText, materialText string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// unmarkedFacts 从未核实清单里剔除已按「目标/预计/假设」标注的(与后端
|
||||
// runtime.MarkedAsEstimate 同一口径,标注词邻域 ±32 字符)。
|
||||
func unmarkedFacts(tokens []string, artifactText string) []string {
|
||||
var out []string
|
||||
for _, tok := range tokens {
|
||||
if !runtime.MarkedAsEstimate(artifactText, tok) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。
|
||||
func finalVsArtifact(turn *turnResult, artifactText string) []string {
|
||||
if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" {
|
||||
@@ -430,6 +444,9 @@ func renderMarkdown(rep *report) string {
|
||||
}
|
||||
if len(tk.UnverifiedFacts) > 0 {
|
||||
fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、"))
|
||||
if len(tk.UnverifiedUnmarkedFacts) > 0 {
|
||||
fmt.Fprintf(&b, "其中未标注(无出处且无目标/预计/假设标注,E3 闸门口径):%s\n", strings.Join(tk.UnverifiedUnmarkedFacts, "、"))
|
||||
}
|
||||
}
|
||||
if len(tk.FinalVsArtifact) > 0 {
|
||||
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
|
||||
|
||||
@@ -252,6 +252,9 @@ type taskResult struct {
|
||||
Turns []turnResult `json:"turns"`
|
||||
Total turnMetrics `json:"total"`
|
||||
UnverifiedFacts []string `json:"unverifiedFacts,omitempty"`
|
||||
// UnverifiedUnmarkedFacts:无出处且未按「目标/预计/假设」标注的子集
|
||||
// (E3 口径:严格口径 unverifiedFacts 保留原义以维持历史可比性)。
|
||||
UnverifiedUnmarkedFacts []string `json:"unverifiedUnmarkedFacts,omitempty"`
|
||||
FinalVsArtifact []string `json:"finalVsArtifact,omitempty"`
|
||||
}
|
||||
|
||||
@@ -307,6 +310,7 @@ func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir st
|
||||
artText += downloadArtifactText(t, c, projectID, name)
|
||||
}
|
||||
tr.UnverifiedFacts = unverifiedFacts(artText, materialText)
|
||||
tr.UnverifiedUnmarkedFacts = unmarkedFacts(tr.UnverifiedFacts, artText)
|
||||
tr.FinalVsArtifact = finalVsArtifact(last, artText)
|
||||
}
|
||||
}
|
||||
|
||||
+206
-6
@@ -30,10 +30,12 @@ import (
|
||||
"agent-native/runtime/telemetry"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
"agent-system-backend/internal/blobstore"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/genui"
|
||||
"agent-system-backend/internal/hostapi"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentsem"
|
||||
@@ -50,6 +52,7 @@ import (
|
||||
"agent-system-backend/internal/skills"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
@@ -79,6 +82,65 @@ type Options struct {
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
|
||||
// 硬关闭 DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string
|
||||
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
|
||||
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
|
||||
ExecDepsDir string
|
||||
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
|
||||
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
|
||||
ExecWorkspace string
|
||||
// Tier 部署档(协作存储 KTD1):空=auto(DSN→PG 否则 SQLite);local=SQLite;
|
||||
// team=PG fail-closed(缺 DSN/未认证拒绝启动;PG 故障拒绝就绪,不回落 SQLite)。
|
||||
Tier string
|
||||
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
|
||||
PGPool store.PoolOptions
|
||||
}
|
||||
|
||||
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
|
||||
// 本地档退化为 local(单实例语义)。
|
||||
func instanceTag(opt Options) string {
|
||||
if opt.Tier == "team" || opt.StorageDSN != "" {
|
||||
return hostTag() + ":" + opt.Addr
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
func hostTag() string {
|
||||
h, err := os.Hostname()
|
||||
if err != nil || h == "" {
|
||||
return "host"
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// tierName 输出部署档名(诊断统一拼写:空 = auto)。
|
||||
func tierName(opt Options) string {
|
||||
if opt.Tier == "" {
|
||||
return "auto"
|
||||
}
|
||||
return opt.Tier
|
||||
}
|
||||
|
||||
// TierRefusal 部署档门禁(KTD1):返回阻止启动的问题(空 = 放行)。
|
||||
// team 档 fail-closed:缺 DSN、未开认证拒绝;PG 故障的拒绝发生在其后的
|
||||
// OpenPostgres(Run 对 team 档的错误显式声明不回落 SQLite)。
|
||||
func TierRefusal(opt Options) []string {
|
||||
return config.Resolved{Config: config.Config{
|
||||
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, Tier: opt.Tier,
|
||||
PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
|
||||
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
|
||||
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
|
||||
}}.TierProblems()
|
||||
}
|
||||
|
||||
// latestVersion 返回已应用迁移的最高版本(0 = 空账本)。
|
||||
func latestVersion(versions []int) int {
|
||||
max := 0
|
||||
for _, v := range versions {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -87,13 +149,25 @@ func Run(opt Options) error {
|
||||
Service: "agent-system", Module: "app", Level: logsys.LevelInfo,
|
||||
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
|
||||
})
|
||||
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir)
|
||||
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir, "tier", tierName(opt))
|
||||
resolved := config.Resolved{Config: config.Config{
|
||||
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
|
||||
Tier: opt.Tier, PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
|
||||
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
|
||||
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
|
||||
}}
|
||||
if msg := resolved.ListenRefusal(); msg != "" {
|
||||
return fmt.Errorf("listen guard: %s", msg)
|
||||
}
|
||||
// 部署档门禁(KTD1 fail-closed):team 档缺 DSN/未开认证直接拒绝启动;
|
||||
// local 档带 DSN 是配置错误。此处与 config.TierProblems 同规则(纵深防御,
|
||||
// 服务入口不依赖 CLI 是否校验)。
|
||||
if msgs := TierRefusal(opt); len(msgs) > 0 {
|
||||
return fmt.Errorf("tier guard: %s", strings.Join(msgs, ";"))
|
||||
}
|
||||
if opt.Tier == "team" && !opt.DisableExecTools && opt.ExecBackend == "" {
|
||||
log.Printf("warning: team 档未配置执行隔离(disableExecTools 或 execBackend);向真实多用户开放前必须补齐(方案 KTD1)")
|
||||
}
|
||||
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
||||
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
||||
}
|
||||
@@ -104,7 +178,20 @@ func Run(opt Options) error {
|
||||
if artifacts == "" {
|
||||
artifacts = filepath.Join(opt.DataDir, "artifacts")
|
||||
}
|
||||
st, err = store.OpenPostgres(opt.StorageDSN, artifacts)
|
||||
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
|
||||
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
|
||||
blobs, berr := blobstore.NewS3FromEnv()
|
||||
if berr != nil {
|
||||
return fmt.Errorf("blobstore s3: %w", berr)
|
||||
}
|
||||
if blobs != nil {
|
||||
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
|
||||
}
|
||||
st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
|
||||
if err != nil && opt.Tier == "team" {
|
||||
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
|
||||
return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
|
||||
}
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
|
||||
}
|
||||
@@ -112,7 +199,30 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
log.Printf("storage tier: %s", st.Dialect())
|
||||
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
|
||||
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
|
||||
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
|
||||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
||||
if dbref == "" {
|
||||
if opt.StorageDSN != "" {
|
||||
dbref = "pg"
|
||||
} else {
|
||||
dbref = "sqlite"
|
||||
}
|
||||
}
|
||||
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
|
||||
return fmt.Errorf("system identity: %w", err)
|
||||
}
|
||||
}
|
||||
pool := st.Pool()
|
||||
if versions, verr := st.MigrationStatus(); verr == nil {
|
||||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
|
||||
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, pool.ConnMaxIdleTimeSec,
|
||||
pool.ConnMaxLifetimeSec, latestVersion(versions))
|
||||
} else {
|
||||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d schema=? migration status unavailable: %v)",
|
||||
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, verr)
|
||||
}
|
||||
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
@@ -138,6 +248,20 @@ func Run(opt Options) error {
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
|
||||
}
|
||||
if opt.ExecDepsDir != "" {
|
||||
tools.SetExecDepsDir(opt.ExecDepsDir)
|
||||
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
|
||||
}
|
||||
if opt.ExecWorkspace == "overlay" {
|
||||
tools.SetWorkspaceOverlay(true)
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: workspace=overlay (frozen base + private upper + CAS apply)")
|
||||
} else {
|
||||
log.Printf("exec tools: workspace=overlay ignored (backend is not sandbox)")
|
||||
}
|
||||
} else if opt.ExecWorkspace != "" {
|
||||
return fmt.Errorf("exec workspace: unknown mode %q (overlay)", opt.ExecWorkspace)
|
||||
}
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
@@ -392,15 +516,70 @@ func Run(opt Options) error {
|
||||
|
||||
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
|
||||
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
|
||||
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
|
||||
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
|
||||
if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
|
||||
log.Printf("surface: recovered %d pending events", n)
|
||||
}
|
||||
}()
|
||||
mgr.SetCards(cardSvc)
|
||||
// 执行身份与命令消费(协作存储 U5):PG 档启动跨实例命令轮询;
|
||||
// SQLite 本地档无跨实例命令(StartCommandConsumer 自行短路)。
|
||||
mgr.SetInstanceTag(instanceTag(opt), opt.Addr)
|
||||
stopCommands := mgr.StartCommandConsumer(context.Background())
|
||||
defer stopCommands()
|
||||
// 执行预算(协作存储 U3,R8):runtime.execBudgetGlobal / runtime.execBudgetPerUser
|
||||
// 全零=不限(本地档默认,行为不变);开启后执行许可共享账本生效,
|
||||
// 待执行队列上限 runtime.execQueueMax(默认 200,容量合同数值待 U7 实测调整)。
|
||||
mgr.SetExecutionBudgetFn(func() store.ExecutionBudget {
|
||||
eff := setSvc.Effective(store.OwnerDefault, "")
|
||||
return store.ExecutionBudget{
|
||||
Global: int(eff.GetNumber("runtime.execBudgetGlobal", 0)),
|
||||
PerUser: int(eff.GetNumber("runtime.execBudgetPerUser", 0)),
|
||||
}
|
||||
}, int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.execQueueMax", 200)))
|
||||
|
||||
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
|
||||
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
|
||||
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
|
||||
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
|
||||
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||||
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
|
||||
Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID})
|
||||
state := "committed"
|
||||
if resp.Receipt.State != "" {
|
||||
state = string(resp.Receipt.State)
|
||||
}
|
||||
return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
|
||||
})
|
||||
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
|
||||
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
|
||||
bus.SetToolGate(func(owner, commandID string) bool {
|
||||
if !strings.HasPrefix(commandID, genui.PluginID+".") {
|
||||
return true
|
||||
}
|
||||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||||
return err == nil && row.Enabled
|
||||
})
|
||||
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
|
||||
mgr.SetGenUIAuto(genui.NewAuto(
|
||||
func(owner string) bool {
|
||||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||||
return err == nil && row.Enabled
|
||||
},
|
||||
func(owner string) bool {
|
||||
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
|
||||
},
|
||||
func(owner, kind, detail string) {
|
||||
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
|
||||
},
|
||||
))
|
||||
|
||||
bus.SetPendingStore(st)
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
@@ -412,7 +591,7 @@ func Run(opt Options) error {
|
||||
mgr.SetCommandSource(busAdapter{bus: bus})
|
||||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||||
wireScopedHostFactory(caps)
|
||||
applyCompiled(skillsRoot, bus, caps)
|
||||
compiledSummary := applyCompiled(skillsRoot, bus, caps)
|
||||
mgr.OnUsage = func(owner string, tokens int) {
|
||||
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
|
||||
status, aerr := st.AccountTokens(owner, tokens, budget)
|
||||
@@ -465,6 +644,9 @@ func Run(opt Options) error {
|
||||
srv.Workflows = wfReg
|
||||
mgr.SetWorkflows(wfReg)
|
||||
srv.Cards = cardSvc
|
||||
srv.Surfaces = surfaceSvc
|
||||
// WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。
|
||||
srv.CompiledPlugins = compiledSummary
|
||||
srv.Bus = bus
|
||||
srv.Gate = gate
|
||||
srv.Prompts = promptSvc
|
||||
@@ -648,6 +830,20 @@ func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
|
||||
return st.RevokeAPIKeyByName(owner, name)
|
||||
}
|
||||
|
||||
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
|
||||
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
|
||||
return withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.EnsureSystemIdentity(instanceID, dbref)
|
||||
})
|
||||
}
|
||||
|
||||
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
|
||||
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
|
||||
return withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.VerifySystemIdentity(instanceID, dbref)
|
||||
})
|
||||
}
|
||||
|
||||
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
|
||||
func cachedInt(ttl time.Duration, fn func() int) func() int {
|
||||
var mu sync.Mutex
|
||||
@@ -726,7 +922,11 @@ func splitKeyPool(s string) []string {
|
||||
type busAdapter struct{ bus *cmdbus.Bus }
|
||||
|
||||
func (a busAdapter) CommandTools() []runmgr.CommandTool {
|
||||
src := a.bus.CommandTools()
|
||||
return a.CommandToolsFor("")
|
||||
}
|
||||
|
||||
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
|
||||
src := a.bus.CommandToolsFor(owner)
|
||||
out := make([]runmgr.CommandTool, len(src))
|
||||
for i, ct := range src {
|
||||
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -26,6 +27,29 @@ type pluginRegistry struct {
|
||||
compiledRegistry
|
||||
pluginID string
|
||||
caps hostcaps
|
||||
// summary 是本插件的注册面摘要(WP3-I4):AddAdminZone 声明记录于此,
|
||||
// server 经 CompiledPlugins 供给分区清单端点(WP3-I3)。
|
||||
summary *server.CompiledPlugin
|
||||
}
|
||||
|
||||
// AddAdminZone 记录管理区分区声明(WP3-I3):声明即展示元数据,权威记录
|
||||
// 就是注册期摘要;规格非法(非对象、id/title 缺失)fail-closed 拒绝,
|
||||
// 使 register() 整体中止(与命令注册同一失败语义)。
|
||||
func (r pluginRegistry) AddAdminZone(spec json.RawMessage) error {
|
||||
if r.summary == nil {
|
||||
return errors.New("extension: " + r.pluginID + ": admin zone outside applyCompiled")
|
||||
}
|
||||
var s extension.AdminZoneSpec
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return fmt.Errorf("extension: admin zone spec: %w", err)
|
||||
}
|
||||
if s.ID == "" || s.Title == "" {
|
||||
return fmt.Errorf("extension: %s: admin zone requires id and title", r.pluginID)
|
||||
}
|
||||
r.summary.AdminZones = append(r.summary.AdminZones, server.AdminZoneSection{
|
||||
ID: s.ID, Title: s.Title, Icon: s.Icon, Desc: s.Desc, Order: s.Order,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
@@ -40,10 +64,11 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
|
||||
return fmt.Errorf("extension: command spec: %w", err)
|
||||
}
|
||||
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner}
|
||||
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}
|
||||
return h(ctx, host, extension.Request{
|
||||
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
|
||||
HostProjectID: req.HostProjectID,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -104,17 +129,80 @@ func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) {
|
||||
// recordRegistry 包装 per-plugin 注册表,把成功注册的声明资产名记入摘要
|
||||
// (WP3-I4:注册面即清单面——编译插件的管理条目不另设声明口)。
|
||||
type recordRegistry struct {
|
||||
extension.Registry
|
||||
sum *server.CompiledPlugin
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
|
||||
if err := r.Registry.AddCommand(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Commands = append(r.sum.Commands, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
if err := r.Registry.AddCommandHandler(spec, h); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Commands = append(r.sum.Commands, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddIntent(spec json.RawMessage) error {
|
||||
if err := r.Registry.AddIntent(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Intents = append(r.sum.Intents, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddSkill(name, markdown string) error {
|
||||
if err := r.Registry.AddSkill(name, markdown); err != nil {
|
||||
return err
|
||||
}
|
||||
r.sum.Skills = append(r.sum.Skills, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// specID 宽松提取 spec 顶层 id:仅服务清单摘要展示,解析失败返回空串
|
||||
// 不记录,不影响注册结果本身。
|
||||
func specID(spec json.RawMessage) string {
|
||||
var s struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.CompiledPlugin {
|
||||
base := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
|
||||
var out []server.CompiledPlugin
|
||||
for _, e := range extension.Compiled() {
|
||||
if e.Register == nil {
|
||||
continue
|
||||
}
|
||||
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
|
||||
if err := e.Register(reg); err != nil {
|
||||
sum := server.CompiledPlugin{ID: e.ID}
|
||||
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps, summary: &sum}
|
||||
// Register 失败的插件保留已成功注册部分(与命令总线一致:部分注册
|
||||
// 生效),摘要如实反映实际注册面。
|
||||
if err := e.Register(recordRegistry{Registry: reg, sum: &sum}); err != nil {
|
||||
log.Printf("extension: %s: %v", e.ID, err)
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeMCPServers(raw string, extra []core.PluginMCPServer) string {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
// WP3-I4:applyCompiled 的注册面摘要——成功注册的命令/意图/技能记入
|
||||
// CompiledPlugin,失败注册不记录,Register 整体失败保留已成功部分。
|
||||
func TestApplyCompiledRecordsSummary(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
t.Cleanup(extension.ResetCompiled)
|
||||
extension.RegisterCompiled(extension.InProc{ID: "demo", Register: func(reg extension.Registry) error {
|
||||
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"demo.one","title":"一"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// 重复 id 注册失败:总线拒绝,摘要不记录。
|
||||
if err := reg.AddCommand(json.RawMessage(`{"id":"demo.one","title":"重复"}`)); err == nil {
|
||||
t.Fatal("duplicate command should fail")
|
||||
}
|
||||
if err := reg.AddIntent(json.RawMessage(`{"id":"demo.go","phrases":["演示"],"command":"demo.one"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = reg.AddSkill("demo-skill", "# 演示\n技能正文")
|
||||
// WP3-I3:管理区分区声明记入摘要;缺 id/title 的非法规格 fail-closed。
|
||||
if err := reg.AddAdminZone(json.RawMessage(`{"id":"demo.audit","title":"演示审计","icon":"search","order":20}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reg.AddAdminZone(json.RawMessage(`{"title":"缺 id"}`)); err == nil {
|
||||
t.Fatal("admin zone without id should fail")
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error {
|
||||
if err := reg.AddCommand(json.RawMessage(`{"id":"broken.cmd","title":"先成功"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.AddCommandHandler(json.RawMessage(`not-json`), nil)
|
||||
}})
|
||||
// 无 Register 的登记不产生条目。
|
||||
extension.RegisterCompiled(extension.InProc{ID: "empty"})
|
||||
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
sums := applyCompiled(t.TempDir(), bus, hostcaps{st: st, set: settings.NewService(st)})
|
||||
if len(sums) != 2 {
|
||||
t.Fatalf("want 2 summaries, got %d: %+v", len(sums), sums)
|
||||
}
|
||||
if sums[0].ID != "demo" {
|
||||
t.Fatalf("sums[0] = %+v", sums[0])
|
||||
}
|
||||
if len(sums[0].Commands) != 1 || sums[0].Commands[0] != "demo.one" {
|
||||
t.Fatalf("demo commands = %v", sums[0].Commands)
|
||||
}
|
||||
if len(sums[0].Intents) != 1 || sums[0].Intents[0] != "demo.go" {
|
||||
t.Fatalf("demo intents = %v", sums[0].Intents)
|
||||
}
|
||||
if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" {
|
||||
t.Fatalf("demo skills = %v", sums[0].Skills)
|
||||
}
|
||||
// WP3-I3:合法声明记入,非法规格(缺 id)被拒后摘要不受污染。
|
||||
if len(sums[0].AdminZones) != 1 {
|
||||
t.Fatalf("demo admin zones = %+v", sums[0].AdminZones)
|
||||
}
|
||||
if z := sums[0].AdminZones[0]; z.ID != "demo.audit" || z.Title != "演示审计" || z.Icon != "search" || z.Order != 20 {
|
||||
t.Fatalf("demo admin zone = %+v", z)
|
||||
}
|
||||
// broken:Register 报错但先成功的命令仍在总线,摘要如实保留。
|
||||
if sums[1].ID != "broken" || len(sums[1].Commands) != 1 || sums[1].Commands[0] != "broken.cmd" {
|
||||
t.Fatalf("broken summary = %+v", sums[1])
|
||||
}
|
||||
// 摘要与总线一致:记录的命令真实可分发(不是空头衔)。
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "demo.one", Owner: "alice", Entrance: "test"})
|
||||
if resp.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("demo.one dispatch = %s (%s)", resp.Receipt.State, resp.Receipt.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package app
|
||||
|
||||
// EXT-18 AdminProvider 正负例:admin 主体 → true;非 admin / 空 actor(scoped
|
||||
// host)→ false;store 查询失败 → false(fail-closed)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestExt18AdminProviderPositive(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: "boss"}
|
||||
if !h.IsAdmin() {
|
||||
t.Fatal("admin 主体应返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExt18AdminProviderNegative(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("user", "User", "pw", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "user", actor: "user"}
|
||||
if h.IsAdmin() {
|
||||
t.Fatal("非 admin 主体应返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExt18AdminProviderEmptyActor(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: ""} // scoped host(无 dispatch)
|
||||
if h.IsAdmin() {
|
||||
t.Fatal("空 actor(非 dispatch 上下文)恒 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExt18RequestHostProjectIDField(t *testing.T) {
|
||||
// 编译期断言:Request 有 HostProjectID 字段。
|
||||
_ = ""
|
||||
var r struct{ HostProjectID string }
|
||||
r.HostProjectID = "proj-1"
|
||||
if r.HostProjectID != "proj-1" {
|
||||
t.Fatal("字段赋值失败")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -18,10 +19,11 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||||
}
|
||||
|
||||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||||
@@ -113,15 +115,32 @@ func (c hostcaps) parse(ctx context.Context, filename, mime string, content []by
|
||||
|
||||
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
|
||||
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
|
||||
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
|
||||
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
|
||||
type inprocHost struct {
|
||||
caps hostcaps
|
||||
pluginID string
|
||||
owner string
|
||||
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
|
||||
}
|
||||
|
||||
func (h inprocHost) PluginID() string { return h.pluginID }
|
||||
func (h inprocHost) Owner() string { return h.owner }
|
||||
|
||||
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
|
||||
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
|
||||
// (fail-closed——不因存储故障放行超管面)。
|
||||
func (h inprocHost) IsAdmin() bool {
|
||||
if h.actor == "" {
|
||||
return false
|
||||
}
|
||||
state, err := h.caps.st.GetUserAuthState(h.actor)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return state.IsAdmin
|
||||
}
|
||||
|
||||
func (h inprocHost) KVPut(key, value string) error {
|
||||
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
|
||||
}
|
||||
@@ -230,3 +249,49 @@ func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
|
||||
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||||
return h.caps.parse(ctx, filename, mime, content)
|
||||
}
|
||||
|
||||
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
|
||||
|
||||
func (h inprocHost) surface() (*surfacesvc.Service, error) {
|
||||
if h.caps.surfaces == nil {
|
||||
return nil, surfacesvc.ErrUnavailable
|
||||
}
|
||||
return h.caps.surfaces, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return core.SurfaceSnapshot{}, err
|
||||
}
|
||||
return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID,
|
||||
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
|
||||
ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec,
|
||||
})
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return core.SurfaceSnapshot{}, err
|
||||
}
|
||||
return svc.Get(ctx, h.owner, surfaceID)
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.List(ctx, h.owner, conversationID)
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
row, err := svc.Activation(ctx, h.owner, h.pluginID)
|
||||
return row.Enabled, row.Epoch, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 协作存储方案 U2(KTD1/R2):服务入口的部署档门禁。
|
||||
// team 档缺 DSN / 未开认证必须拒绝启动——不创建 SQLite、不进入装配。
|
||||
func TestTierRefusalTeam(t *testing.T) {
|
||||
msgs := TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788"})
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("team without dsn/auth: %v", msgs)
|
||||
}
|
||||
joined := strings.Join(msgs, ";")
|
||||
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
|
||||
t.Fatalf("refusal wording: %v", msgs)
|
||||
}
|
||||
msgs = TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db", AuthMode: "on"})
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("team with dsn+auth should pass: %v", msgs)
|
||||
}
|
||||
msgs = TierRefusal(Options{Tier: "local", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db"})
|
||||
if len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
|
||||
t.Fatalf("local with dsn: %v", msgs)
|
||||
}
|
||||
msgs = TierRefusal(Options{Addr: "127.0.0.1:8788"})
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("auto default: %v", msgs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package blobstore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// U5/KTD9:S3 兼容对象存储实现——针对嵌入式测试服务器验证
|
||||
// 签名请求、Put/Get/Delete 语义与预签名 URL 生成。
|
||||
// (真实 S3 兼容服务的端到端验收随 U7 压测环境执行;此处锁定协议正确性。)
|
||||
|
||||
func newTestS3(t *testing.T, base string) *S3 {
|
||||
t.Helper()
|
||||
s, err := NewS3(base, "us-east-1", "AKIDEXAMPLE", "secret", "art/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestS3PutGetDeleteRoundTrip(t *testing.T) {
|
||||
var gotPath, gotMethod, gotAuth string
|
||||
var gotBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotMethod, gotAuth = r.URL.Path, r.Method, r.Header.Get("Authorization")
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
gotBody = buf[:n]
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
w.WriteHeader(200)
|
||||
case http.MethodGet:
|
||||
if strings.Contains(gotPath, "obj404") {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("payload"))
|
||||
case http.MethodDelete:
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
s := newTestS3(t, srv.URL+"/bucket")
|
||||
|
||||
ref, err := s.Put("doc1", []byte("payload"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ref != "art/doc1" {
|
||||
t.Fatalf("ref=%s(对象键应带前缀)", ref)
|
||||
}
|
||||
if gotPath != "/bucket/art/doc1" || gotMethod != http.MethodPut || string(gotBody) != "payload" {
|
||||
t.Fatalf("put 请求:%s %s body=%q", gotMethod, gotPath, gotBody)
|
||||
}
|
||||
if len(gotAuth) == 0 || gotAuth[:17] != "AWS4-HMAC-SHA256 " {
|
||||
t.Fatalf("Authorization 头:%q", gotAuth)
|
||||
}
|
||||
got, err := s.Get(ref)
|
||||
if err != nil || string(got) != "payload" {
|
||||
t.Fatalf("get: %q err=%v", got, err)
|
||||
}
|
||||
if err := s.Delete(ref); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
// 404 → 哨兵错误(服务端对 art/obj404 的 GET 返回 404)。
|
||||
if _, err := s.Get("art/obj404"); err == nil || err.Error() != ErrObjectNotFound.Error() {
|
||||
t.Fatalf("404 应 ErrObjectNotFound:%v", err)
|
||||
}
|
||||
// Delete 幂等(404 视为成功)。
|
||||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer srv2.Close()
|
||||
s2 := newTestS3(t, srv2.URL+"/b")
|
||||
if err := s2.Delete("art/none"); err != nil {
|
||||
t.Fatalf("delete 404 应幂等:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3PresignGet(t *testing.T) {
|
||||
s := newTestS3(t, "https://s3.example.com/bucket")
|
||||
u, err := s.PresignGet("art/doc1", 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"X-Amz-Algorithm=AWS4-HMAC-SHA256", "X-Amz-Signature=", "X-Amz-Expires=900", "/bucket/art/doc1"} {
|
||||
if !contains(u, want) {
|
||||
t.Fatalf("预签名 URL 缺 %s:%s", want, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
func TestNewS3FromEnvFallback(t *testing.T) {
|
||||
os.Unsetenv("BLOBSTORE_S3_ENDPOINT")
|
||||
s, err := NewS3FromEnv()
|
||||
if s != nil || err != nil {
|
||||
t.Fatalf("未配置端点应回落 (nil,nil):s=%v err=%v", s, err)
|
||||
}
|
||||
t.Setenv("BLOBSTORE_S3_ENDPOINT", "http://x/b")
|
||||
t.Setenv("BLOBSTORE_S3_ACCESS_KEY", "a")
|
||||
t.Setenv("BLOBSTORE_S3_SECRET_KEY", "b")
|
||||
s, err = NewS3FromEnv()
|
||||
if err != nil || s == nil {
|
||||
t.Fatalf("已配置应构造:s=%v err=%v", s, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package blobstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// S3 是 S3 兼容对象存储实现(协作存储 U5/KTD9:多实例共享工件)。
|
||||
// 认证:AWS Signature V4(静态 AccessKey/SecretKey;凭据只经环境或受保护
|
||||
// 配置注入,引用形态为对象键,不含凭据)。
|
||||
//
|
||||
// 语义(KTD9):
|
||||
// - Put:先完整写对象再由上层提交引用(失败孤儿可清理);
|
||||
// - Get:按对象键读取;
|
||||
// - Delete:立即删除(标记/延迟 GC 由桶生命周期策略承担)。
|
||||
type S3 struct {
|
||||
Base string // 端点(含 bucket 路径,如 https://s3.example.com/bucket)
|
||||
Region string
|
||||
Access string
|
||||
Secret string
|
||||
Prefix string // 对象键前缀(租户/应用隔离)
|
||||
client *http.Client
|
||||
nowFunc func() time.Time
|
||||
}
|
||||
|
||||
// NewS3 从显式参数构造;nil client 用默认。
|
||||
func NewS3(base, region, access, secret, prefix string) (*S3, error) {
|
||||
if base == "" || access == "" || secret == "" {
|
||||
return nil, errors.New("blobstore: s3 requires base/access/secret")
|
||||
}
|
||||
if !strings.HasSuffix(base, "/") {
|
||||
base += "/"
|
||||
}
|
||||
return &S3{Base: base, Region: region, Access: access, Secret: secret, Prefix: prefix,
|
||||
client: &http.Client{Timeout: 30 * time.Second}, nowFunc: time.Now}, nil
|
||||
}
|
||||
|
||||
// NewS3FromEnv 按环境构造:BLOBSTORE_S3_ENDPOINT/REGION/ACCESS_KEY/SECRET_KEY/PREFIX。
|
||||
// 未配置端点返回 (nil, nil)(调用方回落本地档)。
|
||||
func NewS3FromEnv() (*S3, error) {
|
||||
if os.Getenv("BLOBSTORE_S3_ENDPOINT") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return NewS3(os.Getenv("BLOBSTORE_S3_ENDPOINT"),
|
||||
envOr("BLOBSTORE_S3_REGION", "us-east-1"),
|
||||
os.Getenv("BLOBSTORE_S3_ACCESS_KEY"),
|
||||
os.Getenv("BLOBSTORE_S3_SECRET_KEY"),
|
||||
os.Getenv("BLOBSTORE_S3_PREFIX"))
|
||||
}
|
||||
|
||||
func envOr(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (s *S3) key(id string) (string, error) {
|
||||
if !validID(id) {
|
||||
return "", fmt.Errorf("blobstore: invalid object id %q", id)
|
||||
}
|
||||
return s.Prefix + id, nil
|
||||
}
|
||||
|
||||
// Put 上传对象;ref=对象键。
|
||||
func (s *S3) Put(id string, content []byte) (string, error) {
|
||||
key, err := s.key(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := s.do(http.MethodPut, key, content, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return "", fmt.Errorf("blobstore: s3 put %s: %s %s", key, resp.Status, body)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Get 下载对象。
|
||||
func (s *S3) Get(ref string) ([]byte, error) {
|
||||
resp, err := s.do(http.MethodGet, ref, nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("blobstore: s3 get %s: %s", ref, resp.Status)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// ErrObjectNotFound 对象不存在(与本地档 ErrNotExist 对齐的哨兵)。
|
||||
var ErrObjectNotFound = errors.New("blobstore: object not found")
|
||||
|
||||
// Delete 删除对象(幂等:404 视为成功)。
|
||||
func (s *S3) Delete(ref string) error {
|
||||
resp, err := s.do(http.MethodDelete, ref, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("blobstore: s3 delete %s: %s", ref, resp.Status)
|
||||
}
|
||||
|
||||
// do 签名并执行请求(AWS SigV4,service=s3)。
|
||||
func (s *S3) do(method, key string, body []byte, contentType string) (*http.Response, error) {
|
||||
u, err := url.Parse(s.Base + key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.nowFunc().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
payloadHash := sha256.Sum256(body)
|
||||
payloadHex := hex.EncodeToString(payloadHash[:])
|
||||
|
||||
req, err := http.NewRequest(method, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", payloadHex)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
// 规范请求与签名(SigV4 单 chunk)。
|
||||
canonicalURI := "/" + strings.TrimPrefix(u.Path, "/")
|
||||
canonicalQuery := u.RawQuery
|
||||
var ch bytes.Buffer
|
||||
ch.WriteString(method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n")
|
||||
ch.WriteString("host:" + u.Host + "\n")
|
||||
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
|
||||
ch.WriteString("x-amz-date:" + amzDate + "\n")
|
||||
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
|
||||
cr := ch.Bytes()
|
||||
|
||||
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(cr))
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.Region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+s.Access+"/"+scope+
|
||||
", SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="+sig)
|
||||
return s.client.Do(req)
|
||||
}
|
||||
|
||||
func sha256sum(b []byte) []byte {
|
||||
h := sha256.Sum256(b)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func hmacSHA256(key []byte, data string) []byte {
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(data))
|
||||
return m.Sum(nil)
|
||||
}
|
||||
|
||||
// PresignGet 生成限时 GET 预签名 URL(下载入口;有效期由调用方指定)。
|
||||
// 用于把大对象下载直接指向对象存储,应用实例不做数据中继。
|
||||
func (s *S3) PresignGet(ref string, ttl time.Duration) (string, error) {
|
||||
u, err := url.Parse(s.Base + ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := s.nowFunc().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
expiry := int64(ttl / time.Second)
|
||||
payloadHex := hex.EncodeToString(sha256sum(nil))
|
||||
q := u.Query()
|
||||
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||
q.Set("X-Amz-Credential", s.Access+"/"+dateStamp+"/"+s.Region+"/s3/aws4_request")
|
||||
q.Set("X-Amz-Date", amzDate)
|
||||
q.Set("X-Amz-Expires", fmt.Sprint(expiry))
|
||||
q.Set("X-Amz-SignedHeaders", "host")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
canonicalQuery := canonicalQueryEncode(u)
|
||||
var ch bytes.Buffer
|
||||
ch.WriteString("GET\n/" + strings.TrimPrefix(u.Path, "/") + "\n" + canonicalQuery + "\n")
|
||||
ch.WriteString("host:" + u.Host + "\n")
|
||||
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
|
||||
ch.WriteString("x-amz-date:" + amzDate + "\n")
|
||||
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
|
||||
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(ch.Bytes()))
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.Region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
return u.String() + "&X-Amz-Signature=" + sig, nil
|
||||
}
|
||||
|
||||
// canonicalQueryEncode 按 AWS 规则重排查询串(键排序 + URL 编码)。
|
||||
func canonicalQueryEncode(u *url.URL) string {
|
||||
q := u.Query()
|
||||
var keys []string
|
||||
for k := range q {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 排序(简单实现足够:键集固定且互不冲突)
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[j] < keys[i] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString("&")
|
||||
}
|
||||
sb.WriteString(url.QueryEscape(k) + "=" + url.QueryEscape(q[k][0]))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var _ Store = (*S3)(nil)
|
||||
@@ -95,6 +95,7 @@ func (s *Service) List(owner, conversationID string) ([]core.ActivityCard, error
|
||||
}
|
||||
|
||||
// Update 保存整卡变更(流式 Body 增量等)并发布 card.updated;ref 保持原值。
|
||||
// 系统镜像路径(同会话 run 的顺序行为)沿用盲写;用户态裁决走 UpdateVersioned。
|
||||
func (s *Service) Update(owner string, card core.ActivityCard) error {
|
||||
card.UpdatedAt = s.now().UTC()
|
||||
if err := s.st.SaveCard(owner, &card, ""); err != nil {
|
||||
@@ -103,6 +104,16 @@ func (s *Service) Update(owner string, card core.ActivityCard) error {
|
||||
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
|
||||
}
|
||||
|
||||
// UpdateVersioned 乐观并发保存(U3,KTD7):版本不匹配透传 store.ErrConflict,
|
||||
// HTTP 层映射 409;客户端重读卡后重试。
|
||||
func (s *Service) UpdateVersioned(owner string, card core.ActivityCard, expectedVersion int) error {
|
||||
card.UpdatedAt = s.now().UTC()
|
||||
if err := s.st.SaveCardVersioned(owner, &card, "", expectedVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
|
||||
}
|
||||
|
||||
// Append 流式追加正文(card_append 工具;D45):文本并入 body 尾部 markdown
|
||||
// 节点(尾节点非 markdown 则新增),逐次持久化;card.updated 按 cardAppendWindow
|
||||
// 节流——窗口内的追加只落库不发布(投影语义:事件携带整卡,后续事件自然覆盖
|
||||
@@ -141,7 +152,7 @@ func (s *Service) Append(owner, id, text string) error {
|
||||
|
||||
// SetStatus 状态迁移:更新并发布 card.status。
|
||||
func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
@@ -149,7 +160,7 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
|
||||
if tone != "" {
|
||||
c.Tone = tone
|
||||
}
|
||||
if err := s.Update(owner, c); err != nil {
|
||||
if err := s.UpdateVersioned(owner, c, ver); err != nil {
|
||||
return c, err
|
||||
}
|
||||
return c, s.emitCard(c, core.EvCardStatus, core.CardStatusPayload{CardID: c.ID, Status: status, Tone: c.Tone, Note: note})
|
||||
@@ -157,12 +168,12 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
|
||||
|
||||
// SetPinned 置顶/取消(收件箱"不沉没"语义)。
|
||||
func (s *Service) SetPinned(owner, id string, pinned bool) (core.ActivityCard, error) {
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Pinned = pinned
|
||||
return c, s.Update(owner, c)
|
||||
return c, s.UpdateVersioned(owner, c, ver)
|
||||
}
|
||||
|
||||
// Dismiss 关闭卡(非确认语义,留痕)。
|
||||
@@ -173,7 +184,7 @@ func (s *Service) Dismiss(owner, id string) (core.ActivityCard, error) {
|
||||
// Action 处理卡上按钮点击:仅 action_required 卡可裁决;结果 resolved 并发布
|
||||
// card.resolved(ActionID 即按钮语义)。
|
||||
func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCard, string, error) {
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return c, "", err
|
||||
}
|
||||
@@ -197,7 +208,7 @@ func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCar
|
||||
return c, "", ErrNoAction
|
||||
}
|
||||
c.Status = core.CardResolved
|
||||
if err := s.Update(owner, c); err != nil {
|
||||
if err := s.UpdateVersioned(owner, c, ver); err != nil {
|
||||
return c, "", err
|
||||
}
|
||||
return c, label, s.emitCard(c, core.EvCardResolved, core.CardResolvedPayload{CardID: c.ID, ActionID: actionID, Answer: freeText})
|
||||
@@ -250,7 +261,7 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -258,7 +269,10 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
|
||||
return nil // 已决(双现任一侧先到)
|
||||
}
|
||||
c.Status = core.CardResolved
|
||||
if err := s.Update(owner, c); err != nil {
|
||||
if err := s.UpdateVersioned(owner, c, ver); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return nil // 双现任一侧先到(版本竞争败者):不重试,卡以先到侧为准
|
||||
}
|
||||
return err
|
||||
}
|
||||
if c.ConversationID != "" && s.emit != nil {
|
||||
|
||||
@@ -45,12 +45,28 @@ type CommandTool struct {
|
||||
}
|
||||
|
||||
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
|
||||
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
|
||||
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
|
||||
type ToolGate func(owner, commandID string) bool
|
||||
|
||||
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
|
||||
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
|
||||
|
||||
func (b *Bus) CommandTools() []CommandTool {
|
||||
return b.CommandToolsFor("")
|
||||
}
|
||||
|
||||
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
|
||||
// gated 命令一律不投影——fail-closed)。
|
||||
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
|
||||
out := []CommandTool{}
|
||||
for _, r := range b.reg.List() {
|
||||
if !r.Enabled || r.Spec.ExposeAsTool == nil {
|
||||
continue
|
||||
}
|
||||
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
|
||||
continue
|
||||
}
|
||||
risk := r.Spec.ExposeAsTool.Risk
|
||||
if risk == "" {
|
||||
risk = "low"
|
||||
@@ -75,6 +91,7 @@ type Request struct {
|
||||
ConversationID string
|
||||
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
|
||||
Confirmed bool // 危险命令经确认卡放行后的执行
|
||||
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
|
||||
}
|
||||
|
||||
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
|
||||
@@ -101,6 +118,7 @@ type Bus struct {
|
||||
pending map[string]pending
|
||||
pendingStore PendingStore
|
||||
intents *IntentStore // G2-2:意图声明表(自然语言路由)
|
||||
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
|
||||
}
|
||||
|
||||
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
|
||||
|
||||
@@ -6,6 +6,7 @@ package cmdbus
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -72,3 +73,30 @@ func TestCommandToolDispatchEnvelope(t *testing.T) {
|
||||
type nopAudit struct{}
|
||||
|
||||
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
|
||||
|
||||
// U3:owner 级注入闸——gate 拒绝的命令不投影(禁用即不注入能力);fail-closed
|
||||
// (owner 为空且 gate 判否时不投影)。
|
||||
func TestCommandToolsForGate(t *testing.T) {
|
||||
bus := New(nil, nil, func() time.Time { return time.Now().UTC() })
|
||||
if err := bus.RegisterPluginCommands([]json.RawMessage{
|
||||
[]byte(`{"id":"generative-ui.present","title":"生成页面","description":"d","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
|
||||
[]byte(`{"id":"other.echo","title":"回声","description":"e","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus.SetToolGate(func(owner, id string) bool {
|
||||
return !strings.HasPrefix(id, "generative-ui.")
|
||||
})
|
||||
on := bus.CommandToolsFor("alice")
|
||||
for _, ct := range on {
|
||||
if strings.HasPrefix(ct.ID, "generative-ui.") {
|
||||
t.Fatalf("gated command leaked for enabled owner: %+v", ct)
|
||||
}
|
||||
}
|
||||
if len(on) != 1 {
|
||||
t.Fatalf("expected only other.echo, got %+v", on)
|
||||
}
|
||||
if n := len(bus.CommandToolsFor("")); n != 1 {
|
||||
t.Fatalf("empty owner must be fail-closed on gated commands, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
+232
-24
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,21 @@ type Config struct {
|
||||
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
|
||||
// 隔离档)。DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string `json:"execBackend"`
|
||||
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
|
||||
ExecDepsDir string `json:"execDepsDir"`
|
||||
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
|
||||
ExecWorkspace string `json:"execWorkspace"`
|
||||
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
|
||||
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
|
||||
// PG 故障拒绝就绪,绝不回落 SQLite)。
|
||||
Tier string `json:"tier"`
|
||||
// PG 连接预算(KTD12):零值走默认(16/8/300s/不限/不限);负值非法。
|
||||
// PGQueryTimeoutSec>0 时以 statement_timeout 附着到 PG DSN。
|
||||
PGMaxOpen int `json:"pgMaxOpen"`
|
||||
PGMaxIdle int `json:"pgMaxIdle"`
|
||||
PGConnMaxIdleSec int `json:"pgConnMaxIdleSec"`
|
||||
PGConnMaxLifetimeSec int `json:"pgConnMaxLifetimeSec"`
|
||||
PGQueryTimeoutSec int `json:"pgQueryTimeoutSec"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -42,18 +58,26 @@ 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",
|
||||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
"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",
|
||||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||||
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
|
||||
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
"tier": "AGENT_SYSTEM_TIER",
|
||||
"pgMaxOpen": "AGENT_SYSTEM_PG_MAX_OPEN",
|
||||
"pgMaxIdle": "AGENT_SYSTEM_PG_MAX_IDLE",
|
||||
"pgConnMaxIdleSec": "AGENT_SYSTEM_PG_CONN_MAX_IDLE_SEC",
|
||||
"pgConnMaxLifetimeSec": "AGENT_SYSTEM_PG_CONN_MAX_LIFETIME_SEC",
|
||||
"pgQueryTimeoutSec": "AGENT_SYSTEM_PG_QUERY_TIMEOUT_SEC",
|
||||
}
|
||||
|
||||
// Source 配置来源层级(数值越大优先级越高)。
|
||||
@@ -103,11 +127,7 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
}
|
||||
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
|
||||
for f := range present {
|
||||
if isBoolField(f) {
|
||||
setBool(&cfg, f, boolField(fc, f))
|
||||
} else {
|
||||
setString(&cfg, f, stringField(fc, f))
|
||||
}
|
||||
setField(&cfg, f, fc)
|
||||
src[f] = SrcFile
|
||||
}
|
||||
}
|
||||
@@ -120,6 +140,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
return Resolved{}, err
|
||||
}
|
||||
setBool(&cfg, f, b)
|
||||
} else if isIntField(f) {
|
||||
n, err := parseInt(key, v)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
setInt(&cfg, f, n)
|
||||
} else {
|
||||
setString(&cfg, f, v)
|
||||
}
|
||||
@@ -129,15 +155,22 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
// CLI 层:仅显式传入。
|
||||
for f := range cliSet {
|
||||
src[f] = SrcCLI
|
||||
if isBoolField(f) {
|
||||
setBool(&cfg, f, boolField(cli, f))
|
||||
} else {
|
||||
setString(&cfg, f, stringField(cli, f))
|
||||
}
|
||||
setField(&cfg, f, cli)
|
||||
}
|
||||
return Resolved{Config: cfg, sources: src}, nil
|
||||
}
|
||||
|
||||
// setField 从同类型快照覆盖单个字段(文件/CLI 层复用;字段类型由字段名决定)。
|
||||
func setField(dst *Config, f string, src Config) {
|
||||
if isBoolField(f) {
|
||||
setBool(dst, f, boolField(src, f))
|
||||
} else if isIntField(f) {
|
||||
setInt(dst, f, intField(src, f))
|
||||
} else {
|
||||
setString(dst, f, stringField(src, f))
|
||||
}
|
||||
}
|
||||
|
||||
func loadFile(path string) (Config, map[string]bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -176,6 +209,22 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.DisableExecTools
|
||||
case "execBackend":
|
||||
dst = &c.ExecBackend
|
||||
case "execDepsDir":
|
||||
dst = &c.ExecDepsDir
|
||||
case "execWorkspace":
|
||||
dst = &c.ExecWorkspace
|
||||
case "tier":
|
||||
dst = &c.Tier
|
||||
case "pgMaxOpen":
|
||||
dst = &c.PGMaxOpen
|
||||
case "pgMaxIdle":
|
||||
dst = &c.PGMaxIdle
|
||||
case "pgConnMaxIdleSec":
|
||||
dst = &c.PGConnMaxIdleSec
|
||||
case "pgConnMaxLifetimeSec":
|
||||
dst = &c.PGConnMaxLifetimeSec
|
||||
case "pgQueryTimeoutSec":
|
||||
dst = &c.PGQueryTimeoutSec
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -207,6 +256,12 @@ func stringField(c Config, f string) string {
|
||||
return c.AllowedHostsRaw
|
||||
case "execBackend":
|
||||
return c.ExecBackend
|
||||
case "execDepsDir":
|
||||
return c.ExecDepsDir
|
||||
case "execWorkspace":
|
||||
return c.ExecWorkspace
|
||||
case "tier":
|
||||
return c.Tier
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -215,6 +270,45 @@ func isBoolField(f string) bool {
|
||||
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
|
||||
}
|
||||
|
||||
func isIntField(f string) bool {
|
||||
switch f {
|
||||
case "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func intField(c Config, f string) int {
|
||||
switch f {
|
||||
case "pgMaxOpen":
|
||||
return c.PGMaxOpen
|
||||
case "pgMaxIdle":
|
||||
return c.PGMaxIdle
|
||||
case "pgConnMaxIdleSec":
|
||||
return c.PGConnMaxIdleSec
|
||||
case "pgConnMaxLifetimeSec":
|
||||
return c.PGConnMaxLifetimeSec
|
||||
case "pgQueryTimeoutSec":
|
||||
return c.PGQueryTimeoutSec
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func setInt(c *Config, f string, v int) {
|
||||
switch f {
|
||||
case "pgMaxOpen":
|
||||
c.PGMaxOpen = v
|
||||
case "pgMaxIdle":
|
||||
c.PGMaxIdle = v
|
||||
case "pgConnMaxIdleSec":
|
||||
c.PGConnMaxIdleSec = v
|
||||
case "pgConnMaxLifetimeSec":
|
||||
c.PGConnMaxLifetimeSec = v
|
||||
case "pgQueryTimeoutSec":
|
||||
c.PGQueryTimeoutSec = v
|
||||
}
|
||||
}
|
||||
|
||||
func boolField(c Config, f string) bool {
|
||||
switch f {
|
||||
case "insecureListen":
|
||||
@@ -257,6 +351,12 @@ func setString(c *Config, f, v string) {
|
||||
c.AllowedHostsRaw = v
|
||||
case "execBackend":
|
||||
c.ExecBackend = v
|
||||
case "execDepsDir":
|
||||
c.ExecDepsDir = v
|
||||
case "execWorkspace":
|
||||
c.ExecWorkspace = v
|
||||
case "tier":
|
||||
c.Tier = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +370,14 @@ func parseBool(name, v string) (bool, error) {
|
||||
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
|
||||
}
|
||||
|
||||
func parseInt(name, v string) (int, error) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("env %s: invalid int %q", name, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
|
||||
func (r Resolved) Explain(w io.Writer, file string) {
|
||||
if file != "" {
|
||||
@@ -278,16 +386,32 @@ func (r Resolved) Explain(w io.Writer, file string) {
|
||||
fmt.Fprintln(w, "config file: (none)")
|
||||
}
|
||||
fmt.Fprintln(w, "field value source")
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend"} {
|
||||
v := stringField(r.Config, f)
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
|
||||
var v string
|
||||
if isBoolField(f) {
|
||||
v = fmt.Sprintf("%t", boolField(r.Config, f))
|
||||
} else if isIntField(f) {
|
||||
n := intField(r.Config, f)
|
||||
if d := intDefault(f); n == 0 && d != 0 {
|
||||
v = fmt.Sprintf("%d (默认)", d)
|
||||
} else {
|
||||
v = fmt.Sprintf("%d", n)
|
||||
}
|
||||
} else {
|
||||
v = stringField(r.Config, f)
|
||||
}
|
||||
if f == "dsn" {
|
||||
v = redact(v)
|
||||
}
|
||||
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) > 0 {
|
||||
for _, msg := range msgs {
|
||||
fmt.Fprintf(w, "tier guard: 拒绝启动(%s)\n", msg)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(w, "tier guard: 放行")
|
||||
}
|
||||
if msg := r.ListenRefusal(); msg != "" {
|
||||
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
|
||||
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
|
||||
@@ -302,6 +426,90 @@ func (r Resolved) AuthEnabled() bool {
|
||||
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
|
||||
}
|
||||
|
||||
// intDefault 返回 int 字段的默认值(0 表示零值合法、无默认替换)。
|
||||
func intDefault(f string) int {
|
||||
switch f {
|
||||
case "pgMaxOpen":
|
||||
return DefaultPGMaxOpen
|
||||
case "pgMaxIdle":
|
||||
return DefaultPGMaxIdle
|
||||
case "pgConnMaxIdleSec":
|
||||
return DefaultPGConnMaxIdleSec
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 连接预算默认值(KTD12;与 store 层默认保持一致,两处同时修改时以测试对齐)。
|
||||
const (
|
||||
DefaultPGMaxOpen = 16
|
||||
DefaultPGMaxIdle = 8
|
||||
DefaultPGConnMaxIdleSec = 300
|
||||
)
|
||||
|
||||
// TierProblems 部署档语义校验:返回阻止启动的问题列表(空 = 放行)。
|
||||
// team 档 fail-closed:缺 DSN、未开认证直接拒绝;local 档不允许 DSN(KTD1)。
|
||||
// 执行隔离在 team 档是启动警告而非拒绝(本增量先可见、U3/U5 再硬化)。
|
||||
func (r Resolved) TierProblems() []string {
|
||||
var msgs []string
|
||||
switch r.Tier {
|
||||
case "", "local", "team":
|
||||
default:
|
||||
msgs = append(msgs, fmt.Sprintf("未知部署档 %q(local | team)", r.Tier))
|
||||
}
|
||||
if r.Tier == "team" {
|
||||
if r.StorageDSN == "" {
|
||||
msgs = append(msgs, "team 档需要 PostgreSQL DSN(dsn / AGENT_SYSTEM_DSN);不会回落 SQLite")
|
||||
}
|
||||
if !r.AuthEnabled() {
|
||||
msgs = append(msgs, "team 档必须开启认证(auth=on)")
|
||||
}
|
||||
}
|
||||
if r.Tier == "local" && r.StorageDSN != "" {
|
||||
msgs = append(msgs, "local 档不使用 PostgreSQL DSN(改用 team 档,或去掉 dsn)")
|
||||
}
|
||||
if v := r.PGMaxOpen; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgMaxOpen 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGMaxIdle; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgMaxIdle 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGConnMaxIdleSec; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgConnMaxIdleSec 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGConnMaxLifetimeSec; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgConnMaxLifetimeSec 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGQueryTimeoutSec; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgQueryTimeoutSec 不能为负(当前 %d)", v))
|
||||
}
|
||||
if idle, open := r.EffectivePGMaxIdle(), r.EffectivePGMaxOpen(); idle > open {
|
||||
msgs = append(msgs, fmt.Sprintf("pgMaxIdle(%d) 大于 pgMaxOpen(%d)", idle, open))
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// EffectivePGMaxOpen/Idle/ConnMaxIdleSec 返回连接预算有效值(零值落默认,KTD12)。
|
||||
func (r Resolved) EffectivePGMaxOpen() int {
|
||||
if r.PGMaxOpen > 0 {
|
||||
return r.PGMaxOpen
|
||||
}
|
||||
return DefaultPGMaxOpen
|
||||
}
|
||||
|
||||
func (r Resolved) EffectivePGMaxIdle() int {
|
||||
if r.PGMaxIdle > 0 {
|
||||
return r.PGMaxIdle
|
||||
}
|
||||
return DefaultPGMaxIdle
|
||||
}
|
||||
|
||||
func (r Resolved) EffectivePGConnMaxIdleSec() int {
|
||||
if r.PGConnMaxIdleSec > 0 {
|
||||
return r.PGConnMaxIdleSec
|
||||
}
|
||||
return DefaultPGConnMaxIdleSec
|
||||
}
|
||||
|
||||
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
|
||||
func (r Resolved) AllowedHosts() []string {
|
||||
var out []string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -220,3 +221,135 @@ func TestListenGuard(t *testing.T) {
|
||||
t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 协作存储方案 U2:部署档(KTD1)与连接预算(KTD12)----
|
||||
|
||||
// team 档 fail-closed:缺 DSN、未开认证都拒绝;DSN+auth=on 放行。
|
||||
func TestTierTeamFailClosed(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := r.TierProblems()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("team without dsn/auth should refuse with 2 problems, got %v", msgs)
|
||||
}
|
||||
joined := msgs[0] + msgs[1]
|
||||
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
|
||||
t.Fatalf("refusal messages wrong: %v", msgs)
|
||||
}
|
||||
|
||||
// 补上 DSN(auto 认证随 DSN 开启)→ 放行。
|
||||
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs = r.TierProblems(); len(msgs) != 0 {
|
||||
t.Fatalf("team with dsn should pass, got %v", msgs)
|
||||
}
|
||||
|
||||
// 显式关认证仍然拒绝。
|
||||
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db", AuthMode: "off"}, map[string]bool{"tier": true, "dsn": true, "auth": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs = r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "认证") {
|
||||
t.Fatalf("team with auth=off should refuse, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// local 档不允许 DSN;未知档名拒绝。
|
||||
func TestTierLocalAndUnknown(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{Tier: "local", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
|
||||
t.Fatalf("local with dsn should refuse, got %v", msgs)
|
||||
}
|
||||
r, err = Load("", noEnv, Config{Tier: "cluster"}, map[string]bool{"tier": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "未知部署档") {
|
||||
t.Fatalf("unknown tier should refuse, got %v", msgs)
|
||||
}
|
||||
// auto(空)档维持现状语义:无 DSN 不拒绝。
|
||||
r, err = Load("", noEnv, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 0 {
|
||||
t.Fatalf("auto default should pass, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// 连接预算四源解析与环境变量整数校验;MaxIdle>MaxOpen 拒绝。
|
||||
func TestPGPoolConfigLayers(t *testing.T) {
|
||||
env := func(k string) (string, bool) {
|
||||
switch k {
|
||||
case "AGENT_SYSTEM_PG_MAX_OPEN":
|
||||
return "7", true
|
||||
case "AGENT_SYSTEM_PG_MAX_IDLE":
|
||||
return "9", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
f := fileWith(t, `{"pgMaxOpen":5}`)
|
||||
r, err := Load(f, env, Config{PGMaxOpen: 3, PGMaxIdle: 2}, map[string]bool{"pgMaxIdle": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 文件 5 被 env 7 覆盖(CLI 值 3 未显式传入不参与);pgMaxIdle CLI 显式 2 胜。
|
||||
if r.PGMaxOpen != 7 || r.SourceOf("pgMaxOpen") != SrcEnv {
|
||||
t.Fatalf("pgMaxOpen = %d (%v), want env 7", r.PGMaxOpen, r.SourceOf("pgMaxOpen"))
|
||||
}
|
||||
if r.PGMaxIdle != 2 || r.SourceOf("pgMaxIdle") != SrcCLI {
|
||||
t.Fatalf("pgMaxIdle = %d (%v), want CLI 2", r.PGMaxIdle, r.SourceOf("pgMaxIdle"))
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 0 {
|
||||
t.Fatalf("2/2 pool valid, got %v", msgs)
|
||||
}
|
||||
// 有效值:零值落默认。
|
||||
r, err = Load("", noEnv, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.EffectivePGMaxOpen() != DefaultPGMaxOpen || r.EffectivePGMaxIdle() != DefaultPGMaxIdle || r.EffectivePGConnMaxIdleSec() != DefaultPGConnMaxIdleSec {
|
||||
t.Fatalf("effective defaults wrong: %+v", r.Config)
|
||||
}
|
||||
// idle > open 拒绝。
|
||||
r, err = Load("", noEnv, Config{PGMaxOpen: 2, PGMaxIdle: 3}, map[string]bool{"pgMaxOpen": true, "pgMaxIdle": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "pgMaxIdle(3) 大于 pgMaxOpen(2)") {
|
||||
t.Fatalf("idle>open should refuse, got %v", msgs)
|
||||
}
|
||||
// 非法整数环境变量报错定位到变量。
|
||||
bad := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_PG_MAX_OPEN" {
|
||||
return "many", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if _, err := Load("", bad, Config{}, nil); err == nil || !strings.Contains(err.Error(), "AGENT_SYSTEM_PG_MAX_OPEN") {
|
||||
t.Fatalf("invalid int env should fail with var name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// explain 输出包含部署档与预算行,且 tier guard 结论可见(脱敏诊断)。
|
||||
func TestExplainTierAndPool(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
r.Explain(&b, "")
|
||||
out := b.String()
|
||||
for _, want := range []string{"tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec", "tier guard: 拒绝启动"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("explain missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ type AssembleOptions struct {
|
||||
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
|
||||
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
|
||||
ThinkReplay bool
|
||||
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
|
||||
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
|
||||
Surfaces []core.SurfaceSnapshot
|
||||
}
|
||||
|
||||
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
|
||||
@@ -58,6 +61,17 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
|
||||
rep.Items = append(rep.Items, "system(画像自定义段)")
|
||||
}
|
||||
if len(opts.Surfaces) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
|
||||
for _, snap := range opts.Surfaces {
|
||||
b.WriteString(projectSurface(snap))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
|
||||
}
|
||||
|
||||
maxHist := prof.Context.MaxHistoryMessages
|
||||
if maxHist <= 0 {
|
||||
@@ -499,3 +513,42 @@ func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm
|
||||
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||||
return out
|
||||
}
|
||||
|
||||
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
|
||||
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
|
||||
func projectSurface(snap core.SurfaceSnapshot) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
|
||||
if snap.SemanticText != "" {
|
||||
fmt.Fprintf(&b, ":%s", snap.SemanticText)
|
||||
}
|
||||
b.WriteString("\n 结构:")
|
||||
var walk func(n core.UINode)
|
||||
first := true
|
||||
walk = func(n core.UINode) {
|
||||
label := string(n.Type)
|
||||
if n.Title != "" {
|
||||
label += "(\"" + n.Title + "\")"
|
||||
}
|
||||
if n.ID != "" {
|
||||
label += "#" + n.ID
|
||||
}
|
||||
if len(n.Source) > 0 {
|
||||
label += "[" + strings.Join(n.Source, ",") + "]"
|
||||
}
|
||||
if first {
|
||||
b.WriteString(label)
|
||||
first = false
|
||||
} else {
|
||||
b.WriteString(" > " + label)
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, n := range snap.Spec.Nodes {
|
||||
walk(n)
|
||||
}
|
||||
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package ctxasm
|
||||
|
||||
// U3/R6:已保存页面的语义投影并入上下文——下一轮模型能看到结构、来源与修订
|
||||
// 入口(present + baseRevision),可据此解释并修改已有 table/compare/tabs。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func surfaceSnap() core.SurfaceSnapshot {
|
||||
return core.SurfaceSnapshot{
|
||||
Identity: core.SurfaceIdentity{SurfaceID: "surf_x", Owner: "alice", PluginID: "generative-ui", ConversationID: "c1"},
|
||||
Revision: 2,
|
||||
Spec: core.SurfaceSpec{Title: "选型对比", Nodes: []core.UINode{
|
||||
{Type: core.UISection, ID: "n1", Title: "概览", Children: []core.UINode{
|
||||
{Type: core.UIMarkdown, ID: "n1a", Source: []string{"doc:a"}, Markdown: "结论"},
|
||||
}},
|
||||
{Type: core.UITable, ID: "n2", Source: []string{"doc:a", "doc:b"}},
|
||||
}},
|
||||
SemanticText: "三工具对比页",
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurfaceProjectionInContext(t *testing.T) {
|
||||
msgs, rep := AssembleInput(core.AgentProfile{}, nil, nil,
|
||||
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改一下"}}},
|
||||
nil, nil, nil, nil, nil, nil, AssembleOptions{Surfaces: []core.SurfaceSnapshot{surfaceSnap()}})
|
||||
joined := make([]string, len(msgs))
|
||||
for i, m := range msgs {
|
||||
joined[i] = m.Content
|
||||
}
|
||||
all := strings.Join(joined, "\n---\n")
|
||||
for _, need := range []string{"surf_x", "修订 2", "选型对比", "section(\"概览\")#n1", "markdown#n1a[doc:a]", "table#n2", "baseRevision=2"} {
|
||||
if !strings.Contains(all, need) {
|
||||
t.Fatalf("projection missing %q in assembled context:\n%s", need, all)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, it := range rep.Items {
|
||||
if strings.HasPrefix(it, "surface(") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("report should count surface injection: %v", rep.Items)
|
||||
}
|
||||
_ = agentllm.RoleSystem
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package genui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AutoBudgetPerHour 是每任务(conversation)滑动一小时内允许的自动建议次数。
|
||||
const AutoBudgetPerHour = 3
|
||||
|
||||
// AutoCooldown 是同会话两次自动触发的最小间隔。
|
||||
const AutoCooldown = 60 * time.Second
|
||||
|
||||
// SettingAutoPresent 是 owner 级 opt-in 设置键(默认 false;与 activation 分离)。
|
||||
const SettingAutoPresent = "plugins.generative-ui.autoPresent"
|
||||
|
||||
// AutoReminder 是注入 system-reminder 的建议文案(只引导 present,禁止改任务/点动作)。
|
||||
const AutoReminder = "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
|
||||
|
||||
// ActivationLookup 查询 owner 的 Surface activation(generative-ui)。
|
||||
type ActivationLookup func(owner string) (enabled bool)
|
||||
|
||||
// SettingsLookup 查询 owner 的 autoPresent opt-in。
|
||||
type SettingsLookup func(owner string) (enabled bool)
|
||||
|
||||
// Auditor 写审计事件(耗尽等);nil = 跳过。
|
||||
type Auditor func(owner, kind, detail string)
|
||||
|
||||
// Auto 是自动触发闸门:opt-in ∧ activation ∧ 预算 ∧ 冷却。
|
||||
// 触发源由 runmgr 显式调用;present/surface/action 路径不得调用。
|
||||
type Auto struct {
|
||||
activation ActivationLookup
|
||||
settings SettingsLookup
|
||||
audit Auditor
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
budget map[string][]time.Time // convID → 触发时刻
|
||||
last map[string]time.Time // convID → 上次成功放行
|
||||
}
|
||||
|
||||
// NewAuto 构造闸门(lookup 不得为 nil)。
|
||||
func NewAuto(activation ActivationLookup, settings SettingsLookup, audit Auditor) *Auto {
|
||||
return &Auto{
|
||||
activation: activation,
|
||||
settings: settings,
|
||||
audit: audit,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
budget: map[string][]time.Time{},
|
||||
last: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// Try 尝试放行一次自动建议。ok=false 时 skip 含原因(供测试);ok=true 时调用方应注入 AutoReminder。
|
||||
func (a *Auto) Try(owner, convID, reason string) (ok bool, skip string) {
|
||||
if a == nil || a.activation == nil || a.settings == nil {
|
||||
return false, "unconfigured"
|
||||
}
|
||||
if owner == "" || convID == "" {
|
||||
return false, "missing_ids"
|
||||
}
|
||||
if !a.settings(owner) {
|
||||
return false, "opt_in_off"
|
||||
}
|
||||
if !a.activation(owner) {
|
||||
return false, "activation_off"
|
||||
}
|
||||
|
||||
now := a.now()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if t, hit := a.last[convID]; hit && now.Sub(t) < AutoCooldown {
|
||||
return false, "cooldown"
|
||||
}
|
||||
|
||||
cutoff := now.Add(-time.Hour)
|
||||
kept := a.budget[convID][:0]
|
||||
for _, t := range a.budget[convID] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
a.budget[convID] = kept
|
||||
if len(kept) >= AutoBudgetPerHour {
|
||||
if a.audit != nil {
|
||||
a.audit(owner, "genui.auto_present_exhausted", "conversation="+convID+" reason="+reason)
|
||||
}
|
||||
return false, "budget"
|
||||
}
|
||||
|
||||
a.budget[convID] = append(kept, now)
|
||||
a.last[convID] = now
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package genui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAutoDefaultOff(t *testing.T) {
|
||||
a := NewAuto(func(string) bool { return true }, func(string) bool { return false }, nil)
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "opt_in_off" {
|
||||
t.Fatalf("want opt_in_off, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRequiresActivation(t *testing.T) {
|
||||
a := NewAuto(func(string) bool { return false }, func(string) bool { return true }, nil)
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "activation_off" {
|
||||
t.Fatalf("want activation_off, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoBudgetAndCooldown(t *testing.T) {
|
||||
var exhausted int
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
a := NewAuto(func(string) bool { return true }, func(string) bool { return true },
|
||||
func(owner, kind, detail string) {
|
||||
if kind == "genui.auto_present_exhausted" {
|
||||
exhausted++
|
||||
}
|
||||
})
|
||||
a.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < AutoBudgetPerHour; i++ {
|
||||
ok, skip := a.Try("alice", "c1", "plan")
|
||||
if !ok {
|
||||
t.Fatalf("shot %d should pass, skip=%q", i, skip)
|
||||
}
|
||||
now = now.Add(AutoCooldown + time.Second)
|
||||
}
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "budget" || exhausted != 1 {
|
||||
t.Fatalf("want budget exhausted, ok=%v skip=%q exhausted=%d", ok, skip, exhausted)
|
||||
}
|
||||
|
||||
// 冷却:同一时刻第二次拒绝
|
||||
a2 := NewAuto(func(string) bool { return true }, func(string) bool { return true }, nil)
|
||||
a2.now = func() time.Time { return now }
|
||||
if ok, _ := a2.Try("alice", "c2", "art"); !ok {
|
||||
t.Fatal("first should pass")
|
||||
}
|
||||
if ok, skip := a2.Try("alice", "c2", "art"); ok || skip != "cooldown" {
|
||||
t.Fatalf("want cooldown, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
|
||||
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
|
||||
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
|
||||
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
|
||||
package genui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// PluginID 是插件身份(activation 台账与命令前缀共用)。
|
||||
const PluginID = "generative-ui"
|
||||
|
||||
const (
|
||||
// CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。
|
||||
CommandPresent = PluginID + ".present"
|
||||
// CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账;
|
||||
// 不 exposeAsTool——按钮专用,模型不可直接调用)。
|
||||
CommandSubmit = PluginID + ".submit"
|
||||
// ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。
|
||||
ActionHelp = "builtin.help"
|
||||
ActionIntent = "system.intent"
|
||||
)
|
||||
|
||||
// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。
|
||||
var AllowedActionIDs = map[string]struct{}{
|
||||
CommandSubmit: {},
|
||||
ActionHelp: {},
|
||||
ActionIntent: {},
|
||||
}
|
||||
|
||||
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
|
||||
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
|
||||
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
|
||||
任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。
|
||||
|
||||
可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
|
||||
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
|
||||
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
|
||||
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
|
||||
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
|
||||
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
|
||||
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
|
||||
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
|
||||
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
|
||||
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
|
||||
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
|
||||
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
|
||||
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
|
||||
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
|
||||
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
|
||||
actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、
|
||||
builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、
|
||||
不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
|
||||
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
|
||||
{"title":"示例页","nodes":[
|
||||
{"type":"section","id":"n1","title":"结论","children":[
|
||||
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
|
||||
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
|
||||
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
|
||||
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
|
||||
|
||||
func init() {
|
||||
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
|
||||
}
|
||||
|
||||
func register(reg extension.Registry) error {
|
||||
spec := json.RawMessage(fmt.Sprintf(`{
|
||||
"id": %q,
|
||||
"title": "生成页面",
|
||||
"description": %q,
|
||||
"paramsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
|
||||
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
|
||||
"required": ["title", "nodes"]},
|
||||
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
|
||||
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
|
||||
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
|
||||
},
|
||||
"required": ["spec"]
|
||||
},
|
||||
"exposeAsTool": {"risk": "low"}
|
||||
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
|
||||
if err := reg.AddCommandHandler(spec, present); err != nil {
|
||||
return err
|
||||
}
|
||||
submitSpec := json.RawMessage(`{
|
||||
"id": "generative-ui.submit",
|
||||
"title": "提交表单",
|
||||
"description": "页面动作:提交用户填写的表单数据(落插件台账;按钮触发,不经模型)",
|
||||
"paramsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"surfaceId": {"type": "string"},
|
||||
"revision": {"type": "integer"},
|
||||
"requestId": {"type": "string"},
|
||||
"data": {"type": "object"}
|
||||
},
|
||||
"required": ["surfaceId", "requestId", "data"]
|
||||
}
|
||||
}`)
|
||||
return reg.AddCommandHandler(submitSpec, submitForm)
|
||||
}
|
||||
|
||||
// submitForm 是 C 类动作处理器:表单数据落插件自有 KV 台账(键含 requestId
|
||||
// 天然幂等;已批准范围限定 KV+任务台账,不建业务事实表)。经命令总线四入口
|
||||
// 同源:审计信封与(若未来升档)高危确认卡由总线收口。
|
||||
func submitForm(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
surfaceID, _ := req.Params["surfaceId"].(string)
|
||||
requestID, _ := req.Params["requestId"].(string)
|
||||
if surfaceID == "" || requestID == "" {
|
||||
return nil, fmt.Errorf("generative-ui.submit: surfaceId and requestId required")
|
||||
}
|
||||
data, _ := json.Marshal(req.Params["data"])
|
||||
key := "form:" + surfaceID + ":" + requestID
|
||||
if err := h.KVPut(key, string(data)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.Audit("surface.form_submitted", surfaceID, "requestId="+requestID+" bytes="+fmt.Sprint(len(data))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"ok": true, "stored": true, "key": key}, nil
|
||||
}
|
||||
|
||||
// ValidateActionIDs 供 present 校验:actions 节点引用的 id 必须在已开放集合内。
|
||||
func ValidateActionIDs(spec []byte) []string {
|
||||
var s struct {
|
||||
Nodes []core.UINode `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return nil
|
||||
}
|
||||
var errs []string
|
||||
var walk func(n core.UINode, path string)
|
||||
walk = func(n core.UINode, path string) {
|
||||
if n.Type == core.UIActions {
|
||||
for _, a := range n.Actions {
|
||||
if _, ok := AllowedActionIDs[a.ID]; !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, c := range n.Children {
|
||||
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
|
||||
}
|
||||
if n.Compare != nil {
|
||||
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||||
for i, c := range sd.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Tabs != nil {
|
||||
for i, tb := range n.Tabs.Tabs {
|
||||
for j, c := range tb.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, n := range s.Nodes {
|
||||
walk(n, fmt.Sprintf("$[%d]", i))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
|
||||
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
|
||||
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
|
||||
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
sh, ok := h.(extension.SurfaceHost)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
|
||||
}
|
||||
enabled, epoch, err := sh.SurfaceActivation(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enabled {
|
||||
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
|
||||
}
|
||||
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
|
||||
surfaceID, _ := req.Params["surfaceId"].(string)
|
||||
base := 0
|
||||
if v, ok := req.Params["baseRevision"].(float64); ok {
|
||||
base = int(v)
|
||||
}
|
||||
summary, _ := req.Params["summary"].(string)
|
||||
|
||||
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
|
||||
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
|
||||
return toolError("invalid spec", errs), nil
|
||||
}
|
||||
// 动作白名单(U5 已批准范围):未知动作 id 拒绝并回字段级错误。
|
||||
if errs := ValidateActionIDs(raw); len(errs) > 0 {
|
||||
return toolError("invalid spec", errs), nil
|
||||
}
|
||||
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
|
||||
SurfaceID: core.ID(surfaceID),
|
||||
ConversationID: core.ID(req.ConversationID),
|
||||
BaseRevision: base,
|
||||
ObservedEpoch: epoch,
|
||||
SemanticText: summary,
|
||||
Spec: raw,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
|
||||
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
|
||||
var list []string
|
||||
for _, d := range strings.Split(details, ";") {
|
||||
if t := strings.TrimSpace(d); t != "" {
|
||||
list = append(list, t)
|
||||
}
|
||||
}
|
||||
return toolError("invalid spec", list), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"surfaceId": string(snap.Identity.SurfaceID),
|
||||
"revision": snap.Revision,
|
||||
"epoch": snap.ActivationEpoch,
|
||||
"summary": snap.SemanticText,
|
||||
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
|
||||
type toolErrorBody struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func toolError(msg string, details []string) toolErrorBody {
|
||||
return toolErrorBody{OK: false, Error: msg, Details: details,
|
||||
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
|
||||
}
|
||||
|
||||
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
|
||||
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
|
||||
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
|
||||
// 改写,不改内容语义;修复后仍过 core 校验器。
|
||||
func normalizeSpecParam(v any) any {
|
||||
if arr, ok := v.([]any); ok {
|
||||
return map[string]any{"title": "", "nodes": arr}
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
if len(m) == 1 {
|
||||
if inner, ok := m["page"].(map[string]any); ok {
|
||||
m = inner
|
||||
}
|
||||
}
|
||||
if _, ok := m["nodes"]; !ok {
|
||||
if ch, ok := m["children"].([]any); ok {
|
||||
m["nodes"] = ch
|
||||
delete(m, "children")
|
||||
}
|
||||
}
|
||||
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
|
||||
// 信息,不发明内容;节点自带 source 者不受影响。
|
||||
if top, ok := m["source"].([]any); ok {
|
||||
delete(m, "source")
|
||||
if nodes, ok := m["nodes"].([]any); ok {
|
||||
for _, n := range nodes {
|
||||
if nm, ok := n.(map[string]any); ok {
|
||||
if _, has := nm["source"]; !has {
|
||||
cp := make([]any, len(top))
|
||||
copy(cp, top)
|
||||
nm["source"] = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
repairChildrenShape(m)
|
||||
// {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}}
|
||||
// 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进
|
||||
// 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。
|
||||
if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok {
|
||||
m = unwrapped
|
||||
}
|
||||
repairPairs(m)
|
||||
repairTableCells(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12
|
||||
// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…)
|
||||
// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。
|
||||
// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义,
|
||||
// 安全解包;只改结构不改内容。
|
||||
func repairItemWrappers(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
if len(t) == 1 {
|
||||
if inner, ok := t["item"]; ok {
|
||||
return repairItemWrappers(inner)
|
||||
}
|
||||
}
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = repairItemWrappers(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, item := range t {
|
||||
out[i] = repairItemWrappers(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与
|
||||
// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。
|
||||
// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动,
|
||||
// 交校验器给字段级错误供模型修复重提。
|
||||
func repairTableCells(node any) {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if tbl, ok := m["table"].(map[string]any); ok {
|
||||
cols, colsOK := tbl["columns"].([]any)
|
||||
rows, rowsOK := tbl["rows"].([]any)
|
||||
if colsOK && rowsOK {
|
||||
keys := make([]string, len(cols))
|
||||
valid := true
|
||||
for i, c := range cols {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
k, ok := cm["key"].(string)
|
||||
if !ok || k == "" {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
keys[i] = k
|
||||
}
|
||||
if valid {
|
||||
for _, r := range rows {
|
||||
rm, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cells, ok := rm["cells"].([]any)
|
||||
if !ok || len(cells) != len(keys) {
|
||||
continue
|
||||
}
|
||||
paired := make([]any, 0, len(cells))
|
||||
convertible := true
|
||||
for i, c := range cells {
|
||||
switch cv := c.(type) {
|
||||
case string:
|
||||
paired = append(paired, []any{keys[i], cv})
|
||||
case float64:
|
||||
paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)})
|
||||
default:
|
||||
convertible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if convertible {
|
||||
rm["cells"] = paired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range m {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
repairTableCells(vm)
|
||||
}
|
||||
if va, ok := v.([]any); ok {
|
||||
for _, item := range va {
|
||||
if im, ok := item.(map[string]any); ok {
|
||||
repairTableCells(im)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测):
|
||||
// children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。
|
||||
func repairChildrenShape(node any) {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ch, ok := m["children"]; ok && ch != nil {
|
||||
switch v := ch.(type) {
|
||||
case map[string]any:
|
||||
arr := make([]any, 0, len(v))
|
||||
for _, val := range v {
|
||||
arr = append(arr, val)
|
||||
}
|
||||
m["children"] = arr
|
||||
case []any:
|
||||
for _, c := range v {
|
||||
if cm, ok := c.(map[string]any); ok {
|
||||
repairChildrenShape(cm)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 单对象子节点
|
||||
if cm, ok := ch.(map[string]any); ok {
|
||||
m["children"] = []any{cm}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range m {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
repairChildrenShape(vm)
|
||||
}
|
||||
if va, ok := v.([]any); ok {
|
||||
for _, item := range va {
|
||||
if im, ok := item.(map[string]any); ok {
|
||||
repairChildrenShape(im)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
|
||||
func repairPairs(node any) {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for key, val := range m {
|
||||
if (key == "kv" || key == "cells") && val != nil {
|
||||
if arr, ok := val.([]any); ok {
|
||||
fixed := make([]any, 0, len(arr))
|
||||
changed := false
|
||||
for _, item := range arr {
|
||||
if p, ok := pairFromObject(item); ok {
|
||||
fixed = append(fixed, p)
|
||||
changed = true
|
||||
} else {
|
||||
fixed = append(fixed, item)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
m[key] = fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
repairPairs(val)
|
||||
}
|
||||
}
|
||||
|
||||
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
|
||||
func pairFromObject(item any) ([2]any, bool) {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return [2]any{}, false
|
||||
}
|
||||
var k, v any
|
||||
for kk, vv := range obj {
|
||||
switch kk {
|
||||
case "k", "key", "name":
|
||||
k = vv
|
||||
case "v", "value":
|
||||
v = vv
|
||||
default:
|
||||
return [2]any{}, false
|
||||
}
|
||||
}
|
||||
if k == nil || v == nil {
|
||||
return [2]any{}, false
|
||||
}
|
||||
ks, ok1 := k.(string)
|
||||
vs, ok2 := v.(string)
|
||||
if !ok1 || !ok2 {
|
||||
return [2]any{}, false
|
||||
}
|
||||
return [2]any{ks, vs}, true
|
||||
}
|
||||
|
||||
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
|
||||
func authorizeDocRefs(raw []byte, h extension.Host) []string {
|
||||
var spec struct {
|
||||
Nodes []core.UINode `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
return []string{"spec: " + err.Error()}
|
||||
}
|
||||
var errs []string
|
||||
var walk func(n core.UINode, path string)
|
||||
walk = func(n core.UINode, path string) {
|
||||
if n.Type == core.UIDocRef && n.DocID != "" {
|
||||
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
|
||||
}
|
||||
}
|
||||
for i, c := range n.Children {
|
||||
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
|
||||
}
|
||||
if n.Compare != nil {
|
||||
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||||
for i, c := range sd.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Tabs != nil {
|
||||
for i, tb := range n.Tabs.Tabs {
|
||||
for j, c := range tb.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, n := range spec.Nodes {
|
||||
walk(n, fmt.Sprintf("$[%d]", i))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package genui
|
||||
|
||||
// U3 红测(提案 U3 验证行):能力缺失明确拒绝;未启用拒绝;伪造 doc-ref 拒绝;
|
||||
// 目录违规返回字段级清单(供模型修复重提);成功路径经 EXT-17 提交并携带观察 epoch。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// realSvc 让 fakeHost.SurfaceSave 走真实 surfacesvc(校验/守卫行为与生产一致)。
|
||||
var realSvc *surfacesvc.Service
|
||||
|
||||
func newRealSvc(t *testing.T) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
realSvc = surfacesvc.New(st, nil)
|
||||
if _, err := realSvc.SetActivation(context.Background(), "alice", PluginID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeHost struct {
|
||||
extension.Host // 未覆盖方法仅测试未触达
|
||||
enabled bool
|
||||
epoch int64
|
||||
saved []extension.SurfaceSaveRequest
|
||||
artifacts map[string]bool
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
return h.enabled, h.epoch, nil
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
h.saved = append(h.saved, req)
|
||||
return realSvc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID,
|
||||
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
|
||||
SemanticText: req.SemanticText, Spec: req.Spec,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
|
||||
return realSvc.Get(ctx, "alice", surfaceID)
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
|
||||
return realSvc.List(ctx, "alice", conversationID)
|
||||
}
|
||||
|
||||
func (h *fakeHost) ArtifactGet(id string) (core.Artifact, error) {
|
||||
if h.artifacts[id] {
|
||||
return core.Artifact{ID: core.ID(id)}, nil
|
||||
}
|
||||
return core.Artifact{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func validSpec() map[string]any {
|
||||
return map[string]any{
|
||||
"title": "选型对比",
|
||||
"nodes": []any{
|
||||
map[string]any{"type": "section", "id": "n1", "title": "概览", "children": []any{
|
||||
map[string]any{"type": "markdown", "id": "n1a", "source": []any{"doc:a"}, "markdown": "结论先行。"},
|
||||
}},
|
||||
map[string]any{"type": "table", "id": "n2", "table": map[string]any{
|
||||
"columns": []any{map[string]any{"key": "k", "label": "维度"}},
|
||||
"rows": []any{map[string]any{"cells": []any{[]any{"k", "价格"}}}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reqWith(spec map[string]any, extra ...map[string]any) extension.Request {
|
||||
params := map[string]any{"spec": spec}
|
||||
for _, e := range extra {
|
||||
for k, v := range e {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
return extension.Request{ID: "r1", Params: params, Owner: "alice", ConversationID: "c1"}
|
||||
}
|
||||
|
||||
func TestPresentGuards(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
// 能力缺失(Host 未实现 SurfaceHost)→ 明确报错
|
||||
if _, err := present(ctx, plainHost{}, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("capability-missing host should fail explicitly")
|
||||
}
|
||||
// 未启用 → 拒绝
|
||||
h := &fakeHost{enabled: false, artifacts: map[string]bool{}}
|
||||
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("disabled plugin should reject submit")
|
||||
}
|
||||
// 伪造 doc-ref:工件不存在 → 字段级工具错误
|
||||
h = &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{"type": "doc-ref", "docId": "art_ghost"}}}
|
||||
res, err := present(ctx, h, reqWith(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("doc-ref violation should be tool-error, got %v", err)
|
||||
}
|
||||
body, _ := json.Marshal(res)
|
||||
if !strings.Contains(string(body), "art_ghost") {
|
||||
t.Fatalf("field errors should name the bad docId: %s", body)
|
||||
}
|
||||
// 目录违规(未知字段 color)→ 字段级清单
|
||||
bad := map[string]any{"title": "x", "nodes": []any{map[string]any{"type": "list", "items": []any{"a"}, "color": "red"}}}
|
||||
res, err = present(ctx, h, reqWith(bad))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid spec should be tool-error, got %v", err)
|
||||
}
|
||||
te, ok := res.(toolErrorBody)
|
||||
if !ok || te.OK || len(te.Details) == 0 {
|
||||
t.Fatalf("expected field-level details, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentSubmitCarriesEpochAndRevises(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
res, err := present(ctx, h, reqWith(validSpec()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := res.(map[string]any)
|
||||
if m["ok"] != true || fmt.Sprint(m["revision"]) != "1" {
|
||||
t.Fatalf("submit result: %+v", m)
|
||||
}
|
||||
if len(h.saved) != 1 || h.saved[0].ObservedEpoch != 1 {
|
||||
t.Fatalf("submit must carry observed epoch: %+v", h.saved)
|
||||
}
|
||||
// 修订:带 surfaceId + baseRevision → revision 2
|
||||
res2, err := present(ctx, h, reqWith(validSpec(), map[string]any{
|
||||
"surfaceId": m["surfaceId"], "baseRevision": float64(1)}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2, _ := res2.(map[string]any)
|
||||
if fmt.Sprint(m2["revision"]) != "2" {
|
||||
t.Fatalf("revision should advance to 2: %+v", m2)
|
||||
}
|
||||
// 禁用后:执行端拒绝(闸外第二道守卫)
|
||||
h.enabled = false
|
||||
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("post-disable submit should reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSpecShape(t *testing.T) {
|
||||
var captured []json.RawMessage
|
||||
reg := &fakeRegistry{captureList: &captured}
|
||||
if err := register(reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byID := map[string]json.RawMessage{}
|
||||
for _, raw := range captured {
|
||||
var id struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byID[id.ID] = raw
|
||||
}
|
||||
var spec struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
ExposeAsTool map[string]any `json:"exposeAsTool"`
|
||||
}
|
||||
if raw, ok := byID[CommandPresent]; !ok {
|
||||
t.Fatalf("present command not registered: %v", captured)
|
||||
} else if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
|
||||
t.Fatalf("present must expose as tool: %s", byID[CommandPresent])
|
||||
}
|
||||
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit", "builtin.help", "system.intent"} {
|
||||
if !strings.Contains(spec.Description, need) {
|
||||
t.Fatalf("catalog incomplete: missing %q", need)
|
||||
}
|
||||
}
|
||||
var submit struct {
|
||||
ExposeAsTool map[string]any `json:"exposeAsTool"`
|
||||
}
|
||||
if raw, ok := byID[CommandSubmit]; !ok {
|
||||
t.Fatalf("submit command not registered: %v", captured)
|
||||
} else {
|
||||
_ = json.Unmarshal(raw, &submit)
|
||||
if submit.ExposeAsTool != nil {
|
||||
t.Fatal("submit must NOT expose as tool (button-only; model cannot call)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type plainHost struct{ extension.Host }
|
||||
|
||||
type fakeRegistry struct{ captureList *[]json.RawMessage }
|
||||
|
||||
func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
*r.captureList = append(*r.captureList, spec)
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddSkill(name, markdown string) error { return nil }
|
||||
func (r *fakeRegistry) AddCommand(spec json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
|
||||
|
||||
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{
|
||||
"type": "actions", "id": "a1",
|
||||
"actions": []any{
|
||||
map[string]any{"id": "shell.exec", "label": "危险"},
|
||||
map[string]any{"id": "generative-ui.submit", "label": "提交"},
|
||||
}}}}
|
||||
res, err := present(ctx, h, reqWith(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("should be tool error, got %v", err)
|
||||
}
|
||||
te, ok := res.(toolErrorBody)
|
||||
if !ok || te.OK || len(te.Details) == 0 {
|
||||
t.Fatalf("expected field errors, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(strings.Join(te.Details, ";"), "shell.exec") {
|
||||
t.Fatalf("errors should name the unknown action: %+v", te)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateActionIDsAllowsBClassHelpAndIntent(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"title": "t", "nodes": []any{map[string]any{
|
||||
"type": "actions", "id": "a1",
|
||||
"actions": []any{
|
||||
map[string]any{"id": ActionHelp, "label": "帮助"},
|
||||
map[string]any{"id": ActionIntent, "label": "意图"},
|
||||
map[string]any{"id": CommandSubmit, "label": "提交"},
|
||||
}}}})
|
||||
if errs := ValidateActionIDs(raw); len(errs) != 0 {
|
||||
t.Fatalf("B-class help/intent + submit should pass, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitFormStoresLedger(t *testing.T) {
|
||||
// C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。
|
||||
var kv map[string]string = map[string]string{}
|
||||
h := &ledgerHost{kv: kv}
|
||||
res, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
|
||||
"surfaceId": "surf_x", "requestId": "req-1", "revision": float64(1),
|
||||
"data": map[string]any{"years": "5"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := res.(map[string]any)
|
||||
if m["ok"] != true || m["stored"] != true {
|
||||
t.Fatalf("submit result: %+v", m)
|
||||
}
|
||||
if v, ok := kv["form:surf_x:req-1"]; !ok || !strings.Contains(v, `"years":"5"`) {
|
||||
t.Fatalf("ledger missing data: %v", kv)
|
||||
}
|
||||
// 幂等重放同键覆盖同值
|
||||
if _, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
|
||||
"surfaceId": "surf_x", "requestId": "req-1", "data": map[string]any{"years": "5"},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type ledgerHost struct {
|
||||
extension.Host
|
||||
kv map[string]string
|
||||
}
|
||||
|
||||
func (h *ledgerHost) KVPut(key, value string) error { h.kv[key] = value; return nil }
|
||||
func (h *ledgerHost) Audit(action, target, evidence string) error { return nil }
|
||||
|
||||
func TestNormalizeChildrenShape(t *testing.T) {
|
||||
// 真实偏差(2026-10-11 dev 实测):模型把 children 写成按 key 编号的对象映射。
|
||||
in := map[string]any{
|
||||
"type": "section", "id": "n1", "title": "结论",
|
||||
"children": map[string]any{
|
||||
"item": map[string]any{"type": "markdown", "id": "m1", "markdown": "结论先行"},
|
||||
},
|
||||
}
|
||||
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
|
||||
nodes := out.(map[string]any)["nodes"].([]any)
|
||||
ch := nodes[0].(map[string]any)["children"].([]any)
|
||||
if len(ch) != 1 || ch[0].(map[string]any)["id"] != "m1" {
|
||||
t.Fatalf("children should be repaired to array, got %v", ch)
|
||||
}
|
||||
}
|
||||
|
||||
// 真实偏差(2026-10-12 dev 实测,事件 seq 1395/2171):MiniMax-M3 对数组字段输出
|
||||
// XML-RPC 风格 {"item": …} 包装——kv/cells/items 整字段被包一层 {"item":[…]},
|
||||
// 数组元素再被包 {"item":["k","v"]}/{"item":"v"};表格行 cells 另有与列等长的
|
||||
// 裸标量数组形态。修复层须解包并按列 key 配对,present 才能落库。
|
||||
func TestPresentRepairsItemWrappers(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
h := &fakeHost{enabled: true, epoch: 1}
|
||||
spec := map[string]any{
|
||||
"title": "本月支出结构分析(示例数据演示)",
|
||||
"nodes": []any{
|
||||
map[string]any{"type": "markdown", "id": "disclaimer", "markdown": "示例数据,非真实账单。"},
|
||||
map[string]any{"type": "kv", "id": "kv1", "kv": map[string]any{"item": []any{
|
||||
map[string]any{"item": []any{"月份", "2026-10"}},
|
||||
map[string]any{"item": []any{"总支出", "¥10,000"}},
|
||||
}}},
|
||||
map[string]any{"type": "list", "id": "l1", "items": map[string]any{"item": []any{
|
||||
map[string]any{"item": "餐饮 35%"},
|
||||
map[string]any{"item": "住房 25%"},
|
||||
}}},
|
||||
map[string]any{"type": "table", "id": "t1", "table": map[string]any{
|
||||
"columns": []any{
|
||||
map[string]any{"key": "cat", "label": "分类"},
|
||||
map[string]any{"key": "amt", "label": "金额"},
|
||||
map[string]any{"key": "pct", "label": "占比"},
|
||||
},
|
||||
"rows": []any{
|
||||
map[string]any{"cells": map[string]any{"item": []any{
|
||||
map[string]any{"item": "餐饮"}, map[string]any{"item": "¥3,500"}, map[string]any{"item": "35%"},
|
||||
}}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
res, err := present(context.Background(), h, reqWith(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("present: %v", err)
|
||||
}
|
||||
if body, ok := res.(toolErrorBody); ok {
|
||||
t.Fatalf("present should repair item wrappers, got %s %v", body.Error, body.Details)
|
||||
}
|
||||
if len(h.saved) != 1 {
|
||||
t.Fatalf("expected one saved surface, got %d", len(h.saved))
|
||||
}
|
||||
var saved core.SurfaceSpec
|
||||
if err := json.Unmarshal(h.saved[0].Spec, &saved); err != nil {
|
||||
t.Fatalf("saved spec should unmarshal: %v", err)
|
||||
}
|
||||
if saved.Nodes[1].KV[0] != [2]string{"月份", "2026-10"} || saved.Nodes[1].KV[1][1] != "¥10,000" {
|
||||
t.Fatalf("kv pairs should be unwrapped, got %v", saved.Nodes[1].KV)
|
||||
}
|
||||
if len(saved.Nodes[2].Items) != 2 || saved.Nodes[2].Items[0] != "餐饮 35%" {
|
||||
t.Fatalf("list items should be unwrapped, got %v", saved.Nodes[2].Items)
|
||||
}
|
||||
cells := saved.Nodes[3].Table.Rows[0].Cells
|
||||
if len(cells) != 3 || cells[0] != [2]string{"cat", "餐饮"} || cells[2][1] != "35%" {
|
||||
t.Fatalf("table cells should pair with column keys, got %v", cells)
|
||||
}
|
||||
}
|
||||
|
||||
// 长度不匹配的裸 cells 不做发明性配对:保留原样交校验器给字段级错误。
|
||||
func TestRepairTableCellsLeavesMismatchedShapes(t *testing.T) {
|
||||
in := map[string]any{"type": "table", "id": "t1", "table": map[string]any{
|
||||
"columns": []any{map[string]any{"key": "a"}, map[string]any{"key": "b"}},
|
||||
"rows": []any{map[string]any{"cells": []any{"x", "y", "z"}}},
|
||||
}}
|
||||
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
|
||||
rows := out.(map[string]any)["nodes"].([]any)[0].(map[string]any)["table"].(map[string]any)["rows"].([]any)
|
||||
got := rows[0].(map[string]any)["cells"].([]any)
|
||||
if len(got) != 3 || got[0] != "x" {
|
||||
t.Fatalf("mismatched scalars should stay untouched, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package genui
|
||||
|
||||
// U3 完成条件回放(真实模型,env 门控):任务→规格→保存→(语义投影回灌)→
|
||||
// 继续修改→新修订。跑生产代码路径:present 处理器 + surfacesvc + store + ctxasm
|
||||
// 投影(AssembleOptions.Surfaces)。模型 MiniMax-M3(2026-10-11 裁定)。
|
||||
//
|
||||
// 启用:GENUI_MINIMAX_API_KEY=<key> go test ./internal/genui/ -run TestRealModelSurfaceLoop -v -count=1
|
||||
// 未设 env 时跳过(CI 无凭据;本地收据经 make evidence 记录)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const materials = `材料:
|
||||
【doc:m1】甲方案:报价 ¥8/人/天;私有化可选;近半年发布 6 次,平均修复 2 天。
|
||||
【doc:m2】乙方案:报价 ¥12/人/天;仅 SaaS;万行表格场景无官方基准;升级断层(近两季仅小版本)。
|
||||
【doc:m3】尽调备忘:合规缺口(高)——数据出境条款未覆盖;培训成本(低)——术语不一致。`
|
||||
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
func callModelWithRepair(t *testing.T, ctx context.Context, system, user string, try func(map[string]any) (any, error)) (map[string]any, any, chatResp) {
|
||||
t.Helper()
|
||||
var usage chatResp
|
||||
params, r := callModel(t, ctx, system, user)
|
||||
usage.Usage.PromptTokens += r.Usage.PromptTokens
|
||||
usage.Usage.CompletionTokens += r.Usage.CompletionTokens
|
||||
res, err := try(params)
|
||||
if err == nil {
|
||||
if te, ok := res.(toolErrorBody); !ok || te.OK {
|
||||
return params, res, usage
|
||||
} else {
|
||||
res = te
|
||||
}
|
||||
} else if res == nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
// 修复轮:字段级错误回传,模型重提一次(KTD2)。
|
||||
fixUser := user + "\n\n上一次提交被拒绝:" + fmt.Sprint(res) + "\n请修正后重新调用工具。"
|
||||
params2, r2b := callModel(t, ctx, system, fixUser)
|
||||
usage.Usage.PromptTokens += r2b.Usage.PromptTokens
|
||||
usage.Usage.CompletionTokens += r2b.Usage.CompletionTokens
|
||||
res2, err2 := try(params2)
|
||||
if err2 != nil {
|
||||
t.Fatalf("repair dispatch error: %v", err2)
|
||||
}
|
||||
if te, ok := res2.(toolErrorBody); ok && !te.OK {
|
||||
t.Fatalf("repair still invalid: %+v", te)
|
||||
}
|
||||
return params2, res2, usage
|
||||
}
|
||||
|
||||
func callModel(t *testing.T, ctx context.Context, system, user string) (map[string]any, chatResp) {
|
||||
t.Helper()
|
||||
key := os.Getenv("GENUI_MINIMAX_API_KEY")
|
||||
tool := map[string]any{"type": "function", "function": map[string]any{
|
||||
"name": CommandPresent, "description": Catalog, "parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"spec": map[string]any{"type": "object"},
|
||||
"surfaceId": map[string]any{"type": "string"},
|
||||
"baseRevision": map[string]any{"type": "integer"},
|
||||
"summary": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"spec"},
|
||||
}}}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": "MiniMax-M3", "temperature": 0.3, "max_tokens": 14000,
|
||||
"tools": []any{tool}, "tool_choice": map[string]any{"type": "function", "function": map[string]any{"name": CommandPresent}},
|
||||
"messages": []any{
|
||||
map[string]any{"role": "system", "content": system},
|
||||
map[string]any{"role": "user", "content": user},
|
||||
}})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.minimaxi.com/v1/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var out chatResp
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("model call: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out.Choices) == 0 || len(out.Choices[0].Message.ToolCalls) == 0 {
|
||||
t.Fatalf("no tool call in response")
|
||||
}
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal([]byte(out.Choices[0].Message.ToolCalls[0].Function.Arguments), ¶ms); err != nil {
|
||||
t.Fatalf("tool args: %v", err)
|
||||
}
|
||||
return params, out
|
||||
}
|
||||
|
||||
func TestRealModelSurfaceLoop(t *testing.T) {
|
||||
if os.Getenv("GENUI_MINIMAX_API_KEY") == "" {
|
||||
t.Skip("GENUI_MINIMAX_API_KEY not set; real-model replay runs locally with recorded receipts")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "replay.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
svc := surfacesvc.New(st, nil)
|
||||
if _, err := svc.SetActivation(ctx, "alice", PluginID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &realHost{svc: svc, artifacts: map[string]bool{}}
|
||||
|
||||
// 回合 1:任务→规格→保存
|
||||
sys1 := "你是本产品中的助手。用户需要一页比较视图时,用 generative-ui.present 提交页面。"
|
||||
try1 := func(p map[string]any) (any, error) {
|
||||
return present(ctx, h, extension.Request{ID: "t1", Params: p, Owner: "alice", ConversationID: "c1"})
|
||||
}
|
||||
_, res1, r1 := callModelWithRepair(t, ctx, sys1, materials+"\n\n目标:给决策会准备风险分析页(按严重度组织,标注来源)。", try1)
|
||||
m1, _ := res1.(map[string]any)
|
||||
if m1["ok"] != true {
|
||||
t.Fatalf("turn1 not accepted: %+v", res1)
|
||||
}
|
||||
snap1, err := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("turn1: surface=%s revision=%d nodes=%d tokens=%d/%d",
|
||||
m1["surfaceId"], snap1.Revision, countNodes(snap1), r1.Usage.PromptTokens, r1.Usage.CompletionTokens)
|
||||
|
||||
// 回合 2:语义投影回灌 + 修订指令 → 新修订
|
||||
msgs2, _ := ctxasm.AssembleInput(core.AgentProfile{}, nil, nil,
|
||||
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改为实施顺序"}}},
|
||||
nil, nil, nil, nil, nil, nil, ctxasm.AssembleOptions{Surfaces: []core.SurfaceSnapshot{snap1}})
|
||||
var sysParts []string
|
||||
for _, m := range msgs2 {
|
||||
if string(m.Role) == "system" {
|
||||
sysParts = append(sysParts, m.Content)
|
||||
}
|
||||
}
|
||||
sys2 := strings.Join(sysParts, "\n")
|
||||
try2 := func(p map[string]any) (any, error) {
|
||||
p["surfaceId"] = m1["surfaceId"]
|
||||
p["baseRevision"] = float64(snap1.Revision)
|
||||
return present(ctx, h, extension.Request{ID: "t2", Params: p, Owner: "alice", ConversationID: "c1"})
|
||||
}
|
||||
_, res2, r2 := callModelWithRepair(t, ctx, sys1+"\n"+sys2, materials+"\n\n目标:改为实施顺序(阶段与依赖),修订已有页面。", try2)
|
||||
m2, _ := res2.(map[string]any)
|
||||
if fmt.Sprint(m2["revision"]) != fmt.Sprint(snap1.Revision+1) {
|
||||
t.Fatalf("turn2 should revise to %d, got %+v", snap1.Revision+1, res2)
|
||||
}
|
||||
snap2, _ := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
|
||||
types2 := dominantTypes(snap2)
|
||||
same := len(types2) == len(snap1.Spec.Nodes)
|
||||
if same {
|
||||
for i := range types2 {
|
||||
if types2[i] != string(snap1.Spec.Nodes[i].Type) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snap2.Spec.Nodes) == 0 || (snap2.Spec.Title == snap1.Spec.Title && same) {
|
||||
t.Fatalf("turn2 structure did not change: %v", types2)
|
||||
}
|
||||
t.Logf("turn2: revision=%d title=%q types=%v tokens=%d/%d",
|
||||
snap2.Revision, snap2.Spec.Title, types2, r2.Usage.PromptTokens, r2.Usage.CompletionTokens)
|
||||
totalIn := r1.Usage.PromptTokens + r2.Usage.PromptTokens
|
||||
totalOut := r1.Usage.CompletionTokens + r2.Usage.CompletionTokens
|
||||
cost := float64(totalIn)/1e6*0.3*7.2 + float64(totalOut)/1e6*1.2*7.2
|
||||
t.Logf("cost: in=%d out=%d ≈¥%.3f (M3 $0.3/$1.2 per M, 7.2)", totalIn, totalOut, cost)
|
||||
}
|
||||
|
||||
type realHost struct {
|
||||
extension.Host
|
||||
svc *surfacesvc.Service
|
||||
artifacts map[string]bool
|
||||
}
|
||||
|
||||
func (h *realHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
row, err := h.svc.Activation(ctx, "alice", PluginID)
|
||||
return row.Enabled, row.Epoch, err
|
||||
}
|
||||
func (h *realHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
return h.svc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, BaseRevision: req.BaseRevision,
|
||||
ObservedEpoch: req.ObservedEpoch, SemanticText: req.SemanticText, Spec: req.Spec})
|
||||
}
|
||||
func (h *realHost) SurfaceGet(ctx context.Context, id core.ID) (core.SurfaceSnapshot, error) {
|
||||
return h.svc.Get(ctx, "alice", id)
|
||||
}
|
||||
func (h *realHost) SurfaceList(ctx context.Context, cid core.ID) ([]core.SurfaceMeta, error) {
|
||||
return h.svc.List(ctx, "alice", cid)
|
||||
}
|
||||
func (h *realHost) ArtifactGet(id string) (core.Artifact, error) {
|
||||
if h.artifacts[id] {
|
||||
return core.Artifact{ID: core.ID(id)}, nil
|
||||
}
|
||||
return core.Artifact{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func countNodes(s core.SurfaceSnapshot) int {
|
||||
var n int
|
||||
var walk func(u core.UINode)
|
||||
walk = func(u core.UINode) {
|
||||
n++
|
||||
for _, c := range u.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, x := range s.Spec.Nodes {
|
||||
walk(x)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func dominantTypes(s core.SurfaceSnapshot) []string {
|
||||
var out []string
|
||||
for _, n := range s.Spec.Nodes {
|
||||
out = append(out, string(n.Type))
|
||||
}
|
||||
return out
|
||||
}
|
||||
+130
-17
@@ -1,8 +1,14 @@
|
||||
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3):
|
||||
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3 / 协作存储 U6 切片一):
|
||||
// 落盘事务提交后 NOTIFY agent_events '<conv>|<lastSeq>';每实例一个 LISTEN 连接,
|
||||
// 收到通知按 seq 从库取回事件并转发本地 bus(SSE 以 seq 去重,双投无害)。
|
||||
// 收到通知按会话游标从库分页追赶转发本地 bus(SSE 以 seq 去重,重投无害)。
|
||||
// 多实例部署契约:run 在其归属实例内存中执行,会话流量需按会话亲和路由
|
||||
// (网关/客户端择点);本包只解决“其他实例的订阅者”可见性。
|
||||
//
|
||||
// U6(KTD6)修复的两个既有缺口:
|
||||
// - 批内遗漏:通知只带末 seq,旧实现 EventAt 只取末条——批内中间事件对
|
||||
// 实时订阅者不可见。现按会话游标 EventsAfter 分页追赶,末 seq 即水位;
|
||||
// - 断线假重连:旧实现对原连接继续 WaitForNotification。现真正重建
|
||||
// 连接→LISTEN→按全部已知会话追赶,另有周期补查覆盖通知丢失。
|
||||
package pgbus
|
||||
|
||||
import (
|
||||
@@ -10,6 +16,7 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
@@ -21,6 +28,12 @@ import (
|
||||
|
||||
const channel = "agent_events"
|
||||
|
||||
// catchUpPage 追赶分页大小(验收含 1000+ 事件分页可见)。
|
||||
const catchUpPage = 500
|
||||
|
||||
// rescueEvery 周期补查间隔:覆盖 NOTIFY 丢失与监听窗口前后竞争(KTD6)。
|
||||
const rescueEvery = 2 * time.Second
|
||||
|
||||
// Notifier 落盘后通知(任意连接池连接可用)。
|
||||
type Notifier struct{ st *store.Store }
|
||||
|
||||
@@ -39,53 +52,153 @@ func (n *Notifier) Notify(conversationID string, evs []core.StreamEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Listener 每实例一个:LISTEN → 按 seq 取回 → 本地 bus 转发。
|
||||
// Listener 每实例一个:LISTEN → 按会话游标追赶 → 本地 bus 转发。
|
||||
type Listener struct {
|
||||
conn *pgx.Conn
|
||||
dsn string
|
||||
st *store.Store
|
||||
bus *bus.Bus
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
cursor map[string]int64 // conv → 已转发水位(重启清零:SSE 侧重放兜底)
|
||||
}
|
||||
|
||||
// Start 建立监听连接并开始转发循环;返回后即可用,随 ctx 取消停机。
|
||||
func Start(ctx context.Context, dsn string, st *store.Store, b *bus.Bus) (*Listener, error) {
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
l := &Listener{dsn: dsn, st: st, bus: b, done: make(chan struct{}), cursor: map[string]int64{}}
|
||||
if err := l.connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
|
||||
conn.Close(ctx)
|
||||
return nil, err
|
||||
}
|
||||
l := &Listener{conn: conn, st: st, bus: b, done: make(chan struct{})}
|
||||
go l.loop(ctx)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Listener) connect(ctx context.Context) error {
|
||||
conn, err := pgx.Connect(ctx, l.dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
|
||||
conn.Close(ctx)
|
||||
return err
|
||||
}
|
||||
l.conn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Listener) loop(ctx context.Context) {
|
||||
defer close(l.done)
|
||||
// 周期补查独立 goroutine:通知丢失场景恰恰没有通知可触发——不能只在
|
||||
// 收到通知后顺带补查(KTD6)。
|
||||
go l.rescueLoop(ctx)
|
||||
for {
|
||||
n, err := l.conn.WaitForNotification(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return // 停机
|
||||
}
|
||||
log.Printf("[pgbus] wait: %v(1s 后重连)", err)
|
||||
time.Sleep(time.Second)
|
||||
// 真重连(U6):旧实现对原连接继续 Wait——断线后永不恢复。
|
||||
// 退避重建 → LISTEN → 按已知会话追赶(补断线窗口内落盘的事件)。
|
||||
log.Printf("[pgbus] wait: %v(重连中)", err)
|
||||
l.reconnect(ctx)
|
||||
continue
|
||||
}
|
||||
conv, seq, ok := parsePayload(n.Payload)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ev, err := l.st.EventAt(conv, seq)
|
||||
if err != nil {
|
||||
continue // 事件不可见(极早期窗口)——SSE 重放兜底
|
||||
}
|
||||
l.bus.Publish(ev)
|
||||
l.catchUp(conv, seq)
|
||||
}
|
||||
}
|
||||
|
||||
// rescueLoop 周期补查心跳:随 ctx 或主循环退出(done)停止。
|
||||
func (l *Listener) rescueLoop(ctx context.Context) {
|
||||
t := time.NewTicker(rescueEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-l.done:
|
||||
return
|
||||
case <-t.C:
|
||||
l.rescueKnown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconnect 重建监听连接并立即追赶全部已知会话(有界退避重试)。
|
||||
func (l *Listener) reconnect(ctx context.Context) {
|
||||
_ = l.conn.Close(context.Background())
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err := l.connect(cctx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
l.rescueKnown()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Duration(attempt+1) * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rescueKnown 周期补查:不经通知,按各会话游标继续分页追赶(覆盖 NOTIFY
|
||||
// 丢失、监听生效窗口前后竞争——KTD6「只有落库事件才是重放依据」)。
|
||||
// 调用方:重连成功后与 loop 的 rescue 心跳(消息稀疏时由 ticker 兜底)。
|
||||
func (l *Listener) rescueKnown() {
|
||||
l.mu.Lock()
|
||||
convs := make([]string, 0, len(l.cursor))
|
||||
for c := range l.cursor {
|
||||
convs = append(convs, c)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
for _, conv := range convs {
|
||||
l.catchUp(conv, 1<<62)
|
||||
}
|
||||
}
|
||||
|
||||
// catchUp 从会话游标之后分页取回并转发,直到取空或越过水位 toSeq。
|
||||
// 首见会话从 0 开始(SSE 端按 seq 去重,重投无害;这保证批内中间事件可见)。
|
||||
func (l *Listener) catchUp(conv string, toSeq int64) {
|
||||
l.mu.Lock()
|
||||
since := l.cursor[conv]
|
||||
l.mu.Unlock()
|
||||
for {
|
||||
evs, err := l.st.EventsAfter(conv, since, catchUpPage)
|
||||
if err != nil {
|
||||
log.Printf("[pgbus] catchup %s@%d: %v", conv, since, err)
|
||||
return
|
||||
}
|
||||
stop := false
|
||||
for _, ev := range evs {
|
||||
if ev.Seq > toSeq {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
l.bus.Publish(ev)
|
||||
since = ev.Seq
|
||||
}
|
||||
l.setCursor(conv, since)
|
||||
if stop || len(evs) < catchUpPage {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Listener) setCursor(conv string, seq int64) {
|
||||
l.mu.Lock()
|
||||
if seq > l.cursor[conv] {
|
||||
l.cursor[conv] = seq
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// parsePayload 拆 '<conv>|<seq>'(会话 ID 不含 '|')。
|
||||
func parsePayload(p string) (string, int64, bool) {
|
||||
i := strings.LastIndex(p, "|")
|
||||
if i < 0 {
|
||||
|
||||
@@ -29,6 +29,12 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// pg_trgm 预装 public(幂等),理由见 store 包 openTestStore 注释:
|
||||
// 避免扩展落进用例 schema 后随 CASCADE 删除造成的并行竞态。
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("preinstall pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatal(err)
|
||||
@@ -37,7 +43,7 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
full := dsn + "&search_path=" + schema
|
||||
full := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
|
||||
st, err := store.OpenPostgres(full, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,3 +86,157 @@ func TestNotifyListenerFanout(t *testing.T) {
|
||||
t.Fatal("no fanout within 3s")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- U6 切片一(KTD6):批内全可见、分页追赶、断线重连、通知丢失补查 ----
|
||||
|
||||
// drainUntil 从订阅通道收集事件直到谓词为真或超时;返回收到的全部事件。
|
||||
func drainUntil(ch <-chan core.StreamEvent, done func([]core.StreamEvent) bool, timeout time.Duration) []core.StreamEvent {
|
||||
var got []core.StreamEvent
|
||||
deadline := time.After(timeout)
|
||||
for {
|
||||
select {
|
||||
case e := <-ch:
|
||||
got = append(got, e)
|
||||
if done(got) {
|
||||
return got
|
||||
}
|
||||
case <-deadline:
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 一批 64 条事件(单条 NOTIFY 只带末 seq)对实时订阅者全部可见——
|
||||
// 旧实现 EventAt 只取末条,批内 1..63 全部遗漏。
|
||||
func TestBatchEventsAllVisible(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_batch")
|
||||
defer stop()
|
||||
|
||||
notifier := NewNotifier(st)
|
||||
evs := make([]core.StreamEvent, 64)
|
||||
for i := range evs {
|
||||
evs[i] = core.StreamEvent{ConversationID: "conv_batch", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}
|
||||
}
|
||||
if _, err := st.AppendEvents("conv_batch", evs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_batch", evs)
|
||||
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 64 }, 5*time.Second)
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("批 64 条应全部可见,收到 %d(旧实现只末条可见)", len(got))
|
||||
}
|
||||
// 序号连续(首见会话从 0 追赶,SSE 去重语义下的完整性)。
|
||||
for i, e := range got {
|
||||
if e.Seq != int64(i+1) {
|
||||
t.Fatalf("序号不连续:got[%d].Seq=%d", i, e.Seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1200 条(跨 500/页 的三页)分页追赶全部可见。
|
||||
func TestPagedCatchUpBeyondOnePage(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_page")
|
||||
defer stop()
|
||||
|
||||
notifier := NewNotifier(st)
|
||||
const total = 1200
|
||||
evs := make([]core.StreamEvent, 0, total)
|
||||
for i := 0; i < total; i++ {
|
||||
evs = append(evs, core.StreamEvent{ConversationID: "conv_page", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)})
|
||||
}
|
||||
if _, err := st.AppendEvents("conv_page", evs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_page", evs)
|
||||
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= total }, 15*time.Second)
|
||||
if len(got) != total {
|
||||
t.Fatalf("1200 条应分页全部可见,收到 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 断线重连:底层连接被杀后,新落盘+通知的事件仍可达(真重连路径)。
|
||||
func TestListenerReconnectsAfterConnLoss(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_re")
|
||||
defer stop()
|
||||
|
||||
notifier := NewNotifier(st)
|
||||
first := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_re", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_re", first)
|
||||
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
|
||||
|
||||
// 杀掉监听连接(模拟网络断开;旧实现会对此连接永远 Wait 下去)。
|
||||
if err := listener.conn.Close(context.Background()); err != nil {
|
||||
t.Logf("close conn: %v", err)
|
||||
}
|
||||
// 断线窗口内落盘一批(重连后应经追赶补上)。
|
||||
during := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_re", during); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_re", during)
|
||||
|
||||
// 第二段 drain 从零计数:断线窗口内的一条事件到达即证明重连+追赶生效。
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 10*time.Second)
|
||||
if len(got) < 1 {
|
||||
t.Fatalf("重连后应补见断线窗口内事件:got=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 通知丢失补查:事件落盘但**不**发 NOTIFY,周期补查应在 rescueEvery 内送达。
|
||||
func TestRescueCoversLostNotifications(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_lost")
|
||||
defer stop()
|
||||
|
||||
// 先用一次正常通知建立该会话的游标(补查只覆盖已知会话)。
|
||||
notifier := NewNotifier(st)
|
||||
seed := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_lost", seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_lost", seed)
|
||||
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
|
||||
|
||||
// 只落盘、不通知(模拟 NOTIFY 丢失)。
|
||||
lost := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_lost", lost); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*rescueEvery+2*time.Second)
|
||||
if len(got) < 1 {
|
||||
t.Fatalf("周期补查应送达无通知事件:got=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: agent.sub.explore
|
||||
layer: agent
|
||||
version: 1
|
||||
version: 2
|
||||
title: 探索子代理(只读)
|
||||
description: 只读检索专家——定位文件/内容/事实,不做任何修改
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
|
||||
@@ -9,6 +9,7 @@ model: lightweight
|
||||
---
|
||||
你是只读探索子代理,职责是高效且彻底地定位目标信息。
|
||||
|
||||
- 汇报发现时给出定位(文件/页/段落);数字必须来自材料原文并注明出处,没有出处的不要写。
|
||||
- 严格只读:不写入、不修改、不删除任何文件或数据;只执行检索与读取类操作。
|
||||
- 检索策略:从任务给出的线索出发,多角度查询(不同关键词、不同路径);一次不中就换查询,不重复同一查询。
|
||||
- 返回结论时给出定位(文件/路径/段落/链接),让派生方能直接复核。
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
---
|
||||
id: agent.sub.general
|
||||
layer: agent
|
||||
version: 1
|
||||
version: 3
|
||||
title: 通用子代理
|
||||
description: 独立完成子任务并返回结构化结果(默认子代理提示词)
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, generative-ui.present
|
||||
model: lightweight
|
||||
---
|
||||
你是被派生的子代理,负责独立完成一个子任务。
|
||||
|
||||
- 结论中的关键数字与事实附来源定位(材料/文件与页/段落);没有出处的数字不要写,无法回溯时说明来源缺失。
|
||||
- 把任务完整做完:既不过度修饰,也不半途而废;遇到障碍先自行尝试解决,确实无法解决时在结果中说明卡点与已尝试的手段。
|
||||
- 只使用与任务相关的信息,不臆测派生方未提供的上下文。
|
||||
- 完成时返回结构化结果:做了什么、关键发现/结论、产出物位置(如有)、遗留问题(如有)。分点列出,关键内容不省略;摘要长度服从内容,不为简短而丢信息。
|
||||
- 页面化产物(分析页/对比页等)用 `generative-ui.present` 提交;工具不可用或提交被拒时改用 `write_artifact` 落盘并在结果中说明。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: agent.sub.research
|
||||
layer: agent
|
||||
version: 1
|
||||
version: 3
|
||||
title: 调研子代理
|
||||
description: 围绕问题多源调研,输出带依据的结论
|
||||
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
|
||||
@@ -9,6 +9,7 @@ model: lightweight
|
||||
---
|
||||
你是调研子代理,围绕给定问题收集信息并给出结论。
|
||||
|
||||
- 结论里的关键数字与事实必须附原文引用(材料/文件与页/段落定位,重要处引原句);没有出处的数字不要写,无法回溯时明确说明「来源缺失」而不是估一个。检索/读取结果带【src_N|工具】标记时,引用同时给出该标记编号。
|
||||
- 先把问题拆成可回答的子问题,再逐一检索;优先项目内知识库与已给材料,其次按任务指定的外部来源。
|
||||
- 每条结论标注依据来源;多来源冲突时不和稀泥,列出各方说法与差异点,说明你采信哪个及理由。
|
||||
- 覆盖正反两面:支持与不支持该结论的证据都要呈现。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: base.deliverables
|
||||
layer: base
|
||||
version: 5
|
||||
version: 7
|
||||
title: 交付物工作流
|
||||
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
|
||||
---
|
||||
@@ -16,6 +16,8 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
|
||||
5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。
|
||||
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
|
||||
|
||||
检索与读取的工具结果末尾带【src_N|工具名】来源标记:成文时数字与关键事实的出处就是这些带标记的结果,逐项核对;交付物里的每一个百分比、日期和大额数字,都必须能在本轮上传材料或你自己的工具/计算输出里找到出处;找不到出处的,要么删除,要么用「目标 / 预计 / 假设」等词在文中显式标注为规划量(如「目标 85%」「预计三个月」)。终轮会对无出处且未标注的数字拦截两轮;仍无法回溯又拒绝标注的,会被系统如实记为未核实。
|
||||
|
||||
没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…)。工具只能通过函数调用触发,不要在回复里书写 `[调用工具 …]`、`[工具结果 …]`、`[产出工件 …]` 这类文字。
|
||||
|
||||
交付物族(所有任务共用,不限于会议):
|
||||
|
||||
@@ -12,5 +12,6 @@ description: 把任务做完的方法论:计划、换路、查 API、坚持到
|
||||
- **写脚本前先查 API**:调用不熟悉的库/模块前,先用 grep 查它的函数签名(`inspect.signature`/`dir`)或读 SKILL.md 的示例,确认参数形态后再写完整脚本。猜 API 是失败的最大来源。
|
||||
- **编辑粒度**:修改文件时先想清楚要改哪几处,用 multi_edit 或大块 edit 一次改完,不要一行一个 edit_file 连发十几次;改完立即验证(build/运行),验证通过再继续下一处。
|
||||
- **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。
|
||||
- **重任务走快慢双速**:预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理、成套材料整理),先用当前回复给用户即时反馈——材料的相关性判断、概要或执行计划——再用 delegate_task 的 background=true 把深度工作放到后台执行;后台进度与结果会进收件箱卡片,完成后系统自动汇报。简单任务仍同步做完,不要为快而快把小事也丢后台。
|
||||
- **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。
|
||||
- **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。
|
||||
|
||||
@@ -48,6 +48,12 @@ type embeddingsResponse struct {
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Model 当前 embedding 模型身份(U6-3:写入与检索同源;未配置返回空)。
|
||||
func (e *HTTPEmbedder) Model() string {
|
||||
route := e.resolve(context.Background())
|
||||
return route.Model
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if len(texts) == 0 {
|
||||
return nil, nil
|
||||
|
||||
@@ -13,6 +13,9 @@ const hashDim = 32
|
||||
// 由 settings models.embedding.provider=hash 显式打开;未设置时仍按 D23 退化为全文。
|
||||
type HashEmbedder struct{}
|
||||
|
||||
// Model 本地哈希嵌入的身份(U6-3:同源写入/检索,禁止与真实模型混查)。
|
||||
func (HashEmbedder) Model() string { return "hash:" + fmt.Sprint(hashDim) }
|
||||
|
||||
func (HashEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
||||
out := make([][]float32, len(texts))
|
||||
for i, text := range texts {
|
||||
|
||||
+21
-3
@@ -2,6 +2,8 @@ package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
@@ -104,17 +106,33 @@ func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.K
|
||||
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err)
|
||||
return nil
|
||||
}
|
||||
model := embedderModel(p.embedder)
|
||||
for i, v := range vecs {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := p.st.UpsertKnowledgeVector(ctx, owner, docs[i].ID, docs[i].Ref, v); err != nil {
|
||||
if err := p.st.UpsertKnowledgeVectorExt(ctx, owner, docs[i].ID, docs[i].Ref, model, contentHash(docs[i].Chunk), v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧
|
||||
// 声明;未声明=空=legacy 无身份行,向后兼容)。
|
||||
func embedderModel(e core.EmbeddingProvider) string {
|
||||
if m, ok := e.(interface{ Model() string }); ok {
|
||||
return m.Model()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// contentHash 分块内容摘要(写入幂等核对用;sha256 前 16 字节十六进制)。
|
||||
func contentHash(chunk string) string {
|
||||
sum := sha256.Sum256([]byte(chunk))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
func firstRef(docs []core.KnowledgeDoc) string {
|
||||
for _, d := range docs {
|
||||
if d.Ref != "" {
|
||||
@@ -143,7 +161,7 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
vs, e2 := p.st.SearchKnowledgeVectorsShared(ctx, string(owner), qvecs[0], topK, q, allow)
|
||||
vs, e2 := p.st.SearchKnowledgeVectorsSharedModel(ctx, string(owner), embedderModel(p.embedder), qvecs[0], topK, q, allow)
|
||||
if e2 == nil {
|
||||
shared = rrfMerge(vs, shared)
|
||||
}
|
||||
@@ -179,7 +197,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
}
|
||||
q.TopK = topK * 2 // 每路多取,融合后再裁剪
|
||||
vhits, err := p.st.SearchKnowledgeVectors(ctx, owner, qvecs[0], topK*2, q)
|
||||
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package ratelimit
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -52,14 +53,26 @@ func (m *Memory) Allow(_ context.Context, key string, limit int, window time.Dur
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Redis 固定窗口(INCR + EXPIRE;首击设窗)----
|
||||
// ---- Redis 固定窗口(Lua 原子 INCR+PEXPIRE;故障降级进程内保守限流)----
|
||||
|
||||
// incrExpire 原子计数并只在首击设窗(协作存储 U3,KTD10):两步 INCR/EXPIRE
|
||||
// 在 EXPIRE 失败或进程于其间退出时会留下永不过期的键;Lua 单脚本消除该窗口。
|
||||
var incrExpire = redis.NewScript(`
|
||||
local n = redis.call('INCR', KEYS[1])
|
||||
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
||||
return n
|
||||
`)
|
||||
|
||||
type Redis struct {
|
||||
rdb *redis.Client
|
||||
// fallback:Redis 故障时的进程内保守限流(同限值、按实例计数——是共享
|
||||
// 限流的近似而非替代;昂贵入口的硬保护在 PG admission,不依赖本层)。
|
||||
fallback *Memory
|
||||
degraded atomic.Int64
|
||||
}
|
||||
|
||||
func NewRedis(addr, password string, db int) *Redis {
|
||||
return &Redis{rdb: redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})}
|
||||
return &Redis{rdb: redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db}), fallback: NewMemory()}
|
||||
}
|
||||
|
||||
func (r *Redis) Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
|
||||
@@ -67,16 +80,18 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int, window time.Du
|
||||
return true
|
||||
}
|
||||
rkey := "rl:" + key + ":" + time.Now().UTC().Truncate(window).Format("150405")
|
||||
n, err := r.rdb.Incr(ctx, rkey).Result()
|
||||
n, err := incrExpire.Run(ctx, r.rdb, []string{rkey}, window.Milliseconds()).Int64()
|
||||
if err != nil {
|
||||
return true // Redis 故障不阻断请求(限流是柔性行为,放行并记日志于调用方)
|
||||
}
|
||||
if n == 1 {
|
||||
r.rdb.Expire(ctx, rkey, window+time.Second)
|
||||
// 降级(KTD10):不再无界放行;按实例保守限流并计数供观测告警。
|
||||
r.degraded.Add(1)
|
||||
return r.fallback.Allow(ctx, key, limit, window)
|
||||
}
|
||||
return n <= int64(limit)
|
||||
}
|
||||
|
||||
// Degraded 返回累计降级次数(观测:非零说明 Redis 不可用,需运维介入)。
|
||||
func (r *Redis) Degraded() int64 { return r.degraded.Load() }
|
||||
|
||||
func (r *Redis) Ping(ctx context.Context) error { return r.rdb.Ping(ctx).Err() }
|
||||
|
||||
func (r *Redis) Close() error { return r.rdb.Close() }
|
||||
|
||||
@@ -55,3 +55,50 @@ func TestRedisWindow(t *testing.T) {
|
||||
t.Fatal("3rd request must be limited")
|
||||
}
|
||||
}
|
||||
|
||||
// Lua 原子窗口(U3,KTD10):计数与设窗单脚本完成,键必然带 TTL——
|
||||
// 窗口翻转后重新放行(原子性回归:旧两步实现窗口语义相同,此处锚定
|
||||
// 「INCR 与 PEXPIRE 不可分」的行为面)。
|
||||
func TestRedisWindowAtomicScript(t *testing.T) {
|
||||
addr := os.Getenv("AGENT_REDIS_TEST_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("AGENT_REDIS_TEST_ADDR not set")
|
||||
}
|
||||
r := NewRedis(addr, "", 15)
|
||||
defer r.Close()
|
||||
ctx := context.Background()
|
||||
key := "atomic-" + time.Now().Format("150405.000000000")
|
||||
window := 300 * time.Millisecond
|
||||
for i := 1; i <= 3; i++ {
|
||||
if !r.Allow(ctx, key, 3, window) {
|
||||
t.Fatalf("request %d should pass", i)
|
||||
}
|
||||
}
|
||||
if r.Allow(ctx, key, 3, window) {
|
||||
t.Fatal("4th request must be limited")
|
||||
}
|
||||
time.Sleep(window + 50*time.Millisecond)
|
||||
if !r.Allow(ctx, key, 3, window) {
|
||||
t.Fatal("new window must allow again")
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 故障降级(KTD10):不可达时不再无界放行——按实例保守限流;
|
||||
// Degraded 计数供观测告警。
|
||||
func TestRedisDegradedFallback(t *testing.T) {
|
||||
r := NewRedis("127.0.0.1:1", "", 15) // 不可达端口
|
||||
defer r.Close()
|
||||
ctx := context.Background()
|
||||
key := "degraded-" + time.Now().Format("150405.000000000")
|
||||
for i := 1; i <= 2; i++ {
|
||||
if !r.Allow(ctx, key, 2, time.Minute) {
|
||||
t.Fatalf("degraded request %d should pass (local limit 2)", i)
|
||||
}
|
||||
}
|
||||
if r.Allow(ctx, key, 2, time.Minute) {
|
||||
t.Fatal("degraded 3rd request must be limited locally")
|
||||
}
|
||||
if r.Degraded() == 0 {
|
||||
t.Fatal("degradation must be observable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// 协作存储 U3 切片 2:执行许可门(R8)。预算经 budgetFn 动态读取(设置
|
||||
// runtime.execBudgetGlobal / runtime.execBudgetPerUser,默认全零=不限,本地档
|
||||
// 行为与历史一致)。许可的共享权威在 store.admission_leases(PG 咨询锁下
|
||||
// 原子清点);本门只负责有界排队(接纳数)与让出/重取(父等待子代理或
|
||||
// HITL 时让出执行许可、保留会话所有权——避免额度占尽时父子互等)。
|
||||
|
||||
// ErrAdmissionQueueFull 待执行队列已满(接纳被拒;HTTP 层映射 429)。
|
||||
var ErrAdmissionQueueFull = errors.New("runmgr: execution queue full")
|
||||
|
||||
// admissionGate 每进程一个;释放时广播唤醒全部等待者(无公平队列,Golang
|
||||
// cond 语义;公平分配留 U7 压测后按实测加)。
|
||||
type admissionGate struct {
|
||||
mu sync.Mutex
|
||||
broadcast chan struct{} // 释放时 close+重建
|
||||
waiters int
|
||||
budgetFn func() store.ExecutionBudget
|
||||
queueMax int
|
||||
}
|
||||
|
||||
func newAdmissionGate(budgetFn func() store.ExecutionBudget, queueMax int) *admissionGate {
|
||||
if budgetFn == nil {
|
||||
budgetFn = func() store.ExecutionBudget { return store.ExecutionBudget{} }
|
||||
}
|
||||
if queueMax <= 0 {
|
||||
queueMax = 200 // 方案容量合同的默认队列上限(待实测调整)
|
||||
}
|
||||
return &admissionGate{broadcast: make(chan struct{}), budgetFn: budgetFn, queueMax: queueMax}
|
||||
}
|
||||
|
||||
// budget 加锁读取当前预算(与 SetExecutionBudgetFn 并发安全)。
|
||||
func (g *admissionGate) budget() store.ExecutionBudget {
|
||||
if g == nil {
|
||||
return store.ExecutionBudget{} // 直接构造的 Manager(测试):预算关闭
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.budgetFn()
|
||||
}
|
||||
|
||||
// QueueDepth 返回当前排队数(诊断/验收)。
|
||||
func (g *admissionGate) QueueDepth() int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.waiters
|
||||
}
|
||||
|
||||
// QueueFull 非阻塞探测:排队是否已满(API 边缘在落库前拒绝,429 且不写数据)。
|
||||
func (g *admissionGate) QueueFull() bool {
|
||||
budget := g.budget()
|
||||
if budget.Off() {
|
||||
return false
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.waiters >= g.queueMax
|
||||
}
|
||||
|
||||
// acquire 阻塞获取执行许可(ctx 取消则放弃排队)。预算关闭时零开销直通。
|
||||
func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string) error {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
budget := g.budget()
|
||||
if budget.Off() {
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
if err := st.TryAcquireExecutionSlot(runID, owner, budget); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, store.ErrBudgetExceeded) {
|
||||
return err // 存储故障:不放行(fail-closed,宁可拒绝执行)
|
||||
}
|
||||
g.mu.Lock()
|
||||
if g.waiters >= g.queueMax {
|
||||
g.mu.Unlock()
|
||||
return ErrAdmissionQueueFull
|
||||
}
|
||||
g.waiters++
|
||||
ch := g.broadcast
|
||||
g.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
g.mu.Lock()
|
||||
g.waiters--
|
||||
g.mu.Unlock()
|
||||
case <-ctx.Done():
|
||||
g.mu.Lock()
|
||||
g.waiters--
|
||||
g.mu.Unlock()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// release 释放执行许可并广播(幂等;预算关闭时零开销)。
|
||||
func (g *admissionGate) release(st *store.Store, runID string) {
|
||||
if g == nil || g.budget().Off() {
|
||||
return
|
||||
}
|
||||
if err := st.ReleaseExecutionSlot(runID); err != nil {
|
||||
// 释放失败只影响账本整洁(下次复活即修正),不中断运行。
|
||||
log.Printf("[runmgr] admission release %s: %v", runID, err)
|
||||
}
|
||||
g.mu.Lock()
|
||||
close(g.broadcast)
|
||||
g.broadcast = make(chan struct{})
|
||||
g.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 协作存储 U3 切片 2(R8):执行预算在 runmgr 侧的行为面——
|
||||
// 子代理计入全局预算、父让出避免父子互等、队列有界(429 拒绝面)。
|
||||
|
||||
// 同步子代理:父占满全局预算仍能派生前进(父让出 → 子获取 → 子完成 →
|
||||
// 父重取),不发生互等死锁。
|
||||
func TestSyncSpawnUnderSaturatedBudgetMakesProgress(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "子结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
c.mgr.SetExecutionBudgetFn(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 10)
|
||||
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 父先占住唯一的执行许可(等价于一个正在执行的父 run)。
|
||||
if err := st.TryAcquireExecutionSlot(string(parentRun.ID), parentRun.Owner, store.ExecutionBudget{Global: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := c.mgr.spawnSubagent(context.Background(), parentRun, "agent.sub.explore", "检索", "任务", "")
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("饱和预算下同步派生应前进:%v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("父满额派生死锁:子代理 10s 未完成(让出语义失效)")
|
||||
}
|
||||
// 父重取成功:租约复活。
|
||||
if held, _, err := st.ActiveExecutionSlots(); err != nil || held != 1 {
|
||||
t.Fatalf("父应重取唯一许可:held=%d err=%v", held, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 后台子代理计入全局预算:预算 1 时第二个后台子代理排队,第一个完成后
|
||||
// 才执行(不超卖)。
|
||||
func TestBackgroundChildrenShareBudget(t *testing.T) {
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
runner := &gateRunner{entered: entered, release: release, parts: []core.Part{{Type: core.PartText, Text: "结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
c.mgr.SetExecutionBudgetFn(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 10)
|
||||
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := c.mgr.spawnBackground(parentRun, "agent.sub.explore", "A", "任务A", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-entered // 第一个子代理已进入执行(持有唯一许可)。
|
||||
second, err := c.mgr.spawnBackground(parentRun, "agent.sub.explore", "B", "任务B", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 第二个应处于排队(无活跃租约可用)——账本里只有 first。
|
||||
if held, _, _ := st.ActiveExecutionSlots(); held != 1 {
|
||||
t.Fatalf("预算 1 时不应有两个活跃许可:%d", held)
|
||||
}
|
||||
close(release)
|
||||
// 第一个完成后第二个获得许可并执行完毕(entered 的 once 已用掉,用
|
||||
// 账本最终归零 + 等待窗口断言)。
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
held, _, _ := st.ActiveExecutionSlots()
|
||||
if held == 0 && c.mgr.QueueDepth() == 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("第二个后台子代理未完成:held=%d queue=%d", held, c.mgr.QueueDepth())
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
_ = first
|
||||
_ = second
|
||||
}
|
||||
|
||||
// 队列有界:预算占满、队列达上限后 QueueFull() 为真(API 边缘 429 拒绝面),
|
||||
// acquire 返回 ErrAdmissionQueueFull;释放后排队者获得许可。
|
||||
func TestAdmissionQueueBounded(t *testing.T) {
|
||||
st := openTestStoreQuiet(t)
|
||||
g := newAdmissionGate(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 1)
|
||||
ctx := context.Background()
|
||||
if err := g.acquire(ctx, st, "run_hold", "o"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queued := make(chan error, 1)
|
||||
go func() { queued <- g.acquire(ctx, st, "run_wait", "o") }()
|
||||
// 等待排队者入队(深度 1)。
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for g.QueueDepth() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !g.QueueFull() {
|
||||
t.Fatal("队列达上限应 QueueFull")
|
||||
}
|
||||
if err := g.acquire(ctx, st, "run_over", "o"); !errors.Is(err, ErrAdmissionQueueFull) {
|
||||
t.Fatalf("超队列应 ErrAdmissionQueueFull:%v", err)
|
||||
}
|
||||
g.release(st, "run_hold")
|
||||
if err := <-queued; err != nil {
|
||||
t.Fatalf("排队者应获得许可:%v", err)
|
||||
}
|
||||
g.release(st, "run_wait")
|
||||
}
|
||||
|
||||
// openTestStoreQuiet:runmgr 测试包里建一个不迁移断言的临时库(沿用
|
||||
// recovery_test 的 openRecoveryStore 会走双档;这里只需本地档)。
|
||||
func openTestStoreQuiet(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/adm.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
var _ = core.Run{} // 保持 import 锚(newSpawnEnv 依赖 core)
|
||||
@@ -0,0 +1,164 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/runtime"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newCardSpawnEnv 在后台派发环境上接线收件箱卡片服务,并捕获发出的卡片事件
|
||||
// (任务快慢双速:慢通道进度卡 → 完成回投的验证环境)。
|
||||
func newCardSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *[]string) {
|
||||
t.Helper()
|
||||
c, _ := newSpawnEnv(t, runner)
|
||||
var mu sync.Mutex
|
||||
var events []string
|
||||
c.mgr.cards = cardsvc.New(c.mgr.st, func(conversationID, runID string, typ core.StreamEventType, payload any) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
events = append(events, string(typ))
|
||||
return nil
|
||||
})
|
||||
return c, &events
|
||||
}
|
||||
|
||||
func cardEvents(list *[]string) []string {
|
||||
var out []string
|
||||
for _, e := range *list {
|
||||
if strings.HasPrefix(e, "card.") {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBackgroundCardLifecycleOnSuccess(t *testing.T) {
|
||||
runner := &captureRunner{parts: []core.Part{
|
||||
{Type: core.PartText, Text: "深度解析完成:三份材料均相关,要点已汇总。"},
|
||||
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_0123456789ab", Name: "解析摘要.md"}},
|
||||
}}
|
||||
c, events := newCardSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUntil(t, func() bool {
|
||||
if len(systemTexts(t, c)) != 1 {
|
||||
return false
|
||||
}
|
||||
c.mgr.convMu.Lock()
|
||||
busy := c.mgr.convActive[c.run.ConversationID]
|
||||
c.mgr.convMu.Unlock()
|
||||
return !busy
|
||||
})
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards = %d, want 1", len(cards))
|
||||
}
|
||||
card := cards[0]
|
||||
if card.Kind != core.CardKindProgress || card.Title != "深度解析员" {
|
||||
t.Fatalf("card = %+v", card)
|
||||
}
|
||||
if card.Status != core.CardDone || card.Tone != "ok" {
|
||||
t.Fatalf("status = %s tone = %s, want done/ok", card.Status, card.Tone)
|
||||
}
|
||||
var md string
|
||||
for _, n := range card.Body {
|
||||
if n.Type == core.UIMarkdown {
|
||||
md = n.Markdown
|
||||
}
|
||||
}
|
||||
if !strings.Contains(md, "深度解析完成") || !strings.Contains(md, "解析摘要.md") {
|
||||
t.Fatalf("result body = %q", md)
|
||||
}
|
||||
if card.SourceRunID == "" {
|
||||
t.Fatal("card not linked to child run")
|
||||
}
|
||||
got := cardEvents(events)
|
||||
for _, want := range []string{"card.created", "card.updated", "card.status"} {
|
||||
found := false
|
||||
for _, e := range got {
|
||||
if e == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("events %v missing %s", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
|
||||
runner := &gateRunner{err: errors.New("boom: 材料解析失败")}
|
||||
c, _ := newCardSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUntil(t, func() bool {
|
||||
if len(systemTexts(t, c)) != 1 {
|
||||
return false
|
||||
}
|
||||
c.mgr.convMu.Lock()
|
||||
busy := c.mgr.convActive[c.run.ConversationID]
|
||||
c.mgr.convMu.Unlock()
|
||||
return !busy
|
||||
})
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards = %d, want 1", len(cards))
|
||||
}
|
||||
card := cards[0]
|
||||
if card.Status != core.CardDone || card.Tone != "danger" {
|
||||
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
|
||||
}
|
||||
var md string
|
||||
for _, n := range card.Body {
|
||||
if n.Type == core.UIMarkdown {
|
||||
md = n.Markdown
|
||||
}
|
||||
}
|
||||
if !strings.Contains(md, "执行失败") {
|
||||
t.Fatalf("failure body = %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
|
||||
// 不建卡也不报错——既有 background_test 环境即此形态,这里显式断言不回归。
|
||||
func TestBackgroundCardSkippedWithoutInbox(t *testing.T) {
|
||||
runner := &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}}
|
||||
c, _ := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
if c.mgr.cards != nil {
|
||||
t.Fatal("env unexpectedly wired cards")
|
||||
}
|
||||
if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUntil(t, func() bool { return len(systemTexts(t, c)) == 1 })
|
||||
}
|
||||
|
||||
// TestPartsTextStripsInlineThink:MiniMax 行内 <think> 块不进子代理摘要/回投卡。
|
||||
func TestPartsTextStripsInlineThink(t *testing.T) {
|
||||
got := partsText([]core.Part{{Type: core.PartText, Text: "<think>先读文件</think>\n正文开始\n<think>再想</think>结论"}})
|
||||
if strings.Contains(got, "<think>") || strings.Contains(got, "先读文件") || strings.Contains(got, "再想") {
|
||||
t.Fatalf("think leaked: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "正文开始") || !strings.Contains(got, "结论") {
|
||||
t.Fatalf("body lost: %q", got)
|
||||
}
|
||||
if got := partsText([]core.Part{{Type: core.PartText, Text: "半截 <think> 未闭合保守保留"}}); !strings.Contains(got, "半截") {
|
||||
t.Fatalf("unclosed think should keep text: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type fakeBusSource struct {
|
||||
|
||||
func (f *fakeBusSource) CommandTools() []CommandTool { return f.cmds }
|
||||
|
||||
func (f *fakeBusSource) CommandToolsFor(owner string) []CommandTool { return f.cmds }
|
||||
|
||||
func (f *fakeBusSource) DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse {
|
||||
f.lastReq = req
|
||||
return f.resp
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 协作存储 U5(KTD5):持久命令消费。任意 API 实例把取消/HITL 写入
|
||||
// run_commands 收件箱;命令由「目标 run 的当前执行实例」消费(路由依据
|
||||
// conversation_executions 的当前 owner;本地不在执行的实例跳过,命令保持
|
||||
// pending 直到执行实例领取或超时由恢复路径收口)。
|
||||
//
|
||||
// 本文件是执行侧消费循环;server 侧的投递入口见 server 包 EnqueueRunCommand
|
||||
// 的两个薄封装(CancelRunRemote / ResolveHITLRemote)。
|
||||
|
||||
// commandPollEvery 消费轮询间隔。
|
||||
const commandPollEvery = 300 * time.Millisecond
|
||||
|
||||
// HITLCommandPayload HITL 命令载荷(gate 身份由 cardID+gateVersion 承载:
|
||||
// 新 owner 消费时重验 gate 版本,过期审批明确拒绝而不静默丢弃——KTD5)。
|
||||
type HITLCommandPayload struct {
|
||||
Owner string `json:"owner"`
|
||||
CardID string `json:"cardId"`
|
||||
GateVersion int64 `json:"gateVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Answer json.RawMessage `json:"answer,omitempty"`
|
||||
}
|
||||
|
||||
// CancelCommandPayload 取消命令载荷。
|
||||
type CancelCommandPayload struct {
|
||||
Owner string `json:"owner"`
|
||||
RunID string `json:"runId"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// StartCommandConsumer 启动命令消费循环(仅 PG 档;SQLite 单实例无跨实例
|
||||
// 命令)。返回停止函数。消费的命令只处理「本实例当前持有执行权」的会话;
|
||||
// 其余跳过(不认领)——避免非执行实例吃掉命令。
|
||||
func (m *Manager) StartCommandConsumer(ctx context.Context) (stop func()) {
|
||||
if m.st.Dialect() != "postgres" {
|
||||
return func() {}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
t := time.NewTicker(commandPollEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
m.consumeCommandsOnce()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return cancel
|
||||
}
|
||||
|
||||
// consumeCommandsOnce 单轮消费:先按执行权过滤(不认领非本实例会话的命令),
|
||||
// 再逐条原子认领并执行。认领即消费(at-least-once;命令处理幂等:取消对
|
||||
// 终态 run 报已终态、HITL 重验 gate)。
|
||||
func (m *Manager) consumeCommandsOnce() {
|
||||
pending, err := m.pendingCommandsForOwned()
|
||||
if err != nil || len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
for _, c := range pending {
|
||||
claimed, err := m.claimCommand(c.CommandID)
|
||||
if err != nil || !claimed {
|
||||
continue
|
||||
}
|
||||
switch c.Kind {
|
||||
case "cancel":
|
||||
m.applyCancelCommand(c)
|
||||
case "hitl":
|
||||
m.applyHITLCommand(c)
|
||||
default:
|
||||
log.Printf("[runmgr] command %s: unknown kind %q", c.CommandID, c.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pendingCommandsForOwned 列出 pending 且其会话执行权在本实例的命令。
|
||||
func (m *Manager) pendingCommandsForOwned() ([]ownedCommand, error) {
|
||||
rows, err := m.st.DB().Query(
|
||||
`SELECT c.command_id, c.kind, c.run_id, c.conversation_id, c.epoch, c.payload
|
||||
FROM run_commands c JOIN conversation_executions e ON e.conversation_id = c.conversation_id
|
||||
WHERE c.state='pending' AND e.instance_id = $1
|
||||
ORDER BY c.created_at LIMIT 16`, m.InstanceID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ownedCommand
|
||||
for rows.Next() {
|
||||
var c ownedCommand
|
||||
if err := rows.Scan(&c.CommandID, &c.Kind, &c.RunID, &c.ConversationID, &c.Epoch, &c.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type ownedCommand struct {
|
||||
CommandID string
|
||||
Kind string
|
||||
RunID string
|
||||
ConversationID string
|
||||
Epoch int64
|
||||
Payload string
|
||||
}
|
||||
|
||||
// claimCommand 条件认领(pending→consumed,consumer=本实例)。
|
||||
func (m *Manager) claimCommand(commandID string) (bool, error) {
|
||||
res, err := m.st.DB().Exec(
|
||||
`UPDATE run_commands SET state='consumed', consumed_at=$3, consumer=$1 WHERE command_id=$2 AND state='pending'`,
|
||||
m.InstanceID(), commandID, time.Now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// applyCancelCommand 本地执行取消:run 在本实例活跃 → 级联取消;
|
||||
// 不活跃(如执行实例重启后命令迟到)→ 库级收口(非终态置 cancelled)。
|
||||
func (m *Manager) applyCancelCommand(c ownedCommand) {
|
||||
var p CancelCommandPayload
|
||||
if err := json.Unmarshal([]byte(c.Payload), &p); err != nil {
|
||||
log.Printf("[runmgr] cancel %s payload: %v", c.CommandID, err)
|
||||
return
|
||||
}
|
||||
runID := c.RunID
|
||||
if runID == "" {
|
||||
runID = p.RunID
|
||||
}
|
||||
// 本地活跃 → 级联取消(含 HITL 唤醒);否则库级收口。CancelRun 对
|
||||
// 非本地 run 不落库(只动内存句柄),不能作为成败判据。
|
||||
if m.RunActiveLocally(runID) {
|
||||
if err := m.CancelRun(runID); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 库级取消(幂等——仅非终态可置)。
|
||||
if _, err := m.st.DB().Exec(
|
||||
`UPDATE runs SET status='cancelled', ended_at=$2 WHERE id=$1 AND status IN ('pending','running','waiting_hitl')`, runID, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
log.Printf("[runmgr] remote cancel %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyHITLCommand 本地执行 HITL 应答:重验 gate(cardID + gateVersion),
|
||||
// 过期/未知明确拒绝(卡片置 resolved+stale,事件可见),不静默丢弃(KTD5)。
|
||||
func (m *Manager) applyHITLCommand(c ownedCommand) {
|
||||
var p HITLCommandPayload
|
||||
if err := json.Unmarshal([]byte(c.Payload), &p); err != nil {
|
||||
log.Printf("[runmgr] hitl %s payload: %v", c.CommandID, err)
|
||||
return
|
||||
}
|
||||
resp := core.HITLResponse{CardID: core.ID(p.CardID)}
|
||||
if p.Kind == "free" {
|
||||
resp.FreeText = string(p.Answer)
|
||||
} else {
|
||||
resp.ChoiceID = p.Kind // choiceId 即按钮语义(与 cardAction 契约一致)
|
||||
}
|
||||
_, _, err := m.ResolveHITL(p.Owner, p.CardID, resp)
|
||||
if err != nil {
|
||||
// 过期/未知 gate:明确销项(resolved + 注记),发出可见结论。
|
||||
_ = m.st.ResolveHITLCard(p.Owner, p.CardID, core.HITLResolvedPayload{
|
||||
CardID: core.ID(p.CardID), Kind: "expired", Answer: "跨实例审批迟到:gate 已过期或执行已移交",
|
||||
})
|
||||
log.Printf("[runmgr] hitl %s (card %s) rejected: %v", c.CommandID, p.CardID, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// SetGenUIAuto 注入自动建议闸门(组合根装配;nil 关闭)。
|
||||
func (m *Manager) SetGenUIAuto(a GenUIAutoTrigger) { m.genuiAuto = a }
|
||||
|
||||
// genUIAutoReminderText 与 genui.AutoReminder 文案保持同步(runmgr 不得依赖 genui 包)。
|
||||
func genUIAutoReminderText() string {
|
||||
return "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
|
||||
}
|
||||
|
||||
// maybeGenUIAuto:语义事件钩子入口。仅同步追加 system-reminder,供后续回合消费;
|
||||
// 不另起 notice-run(避免与在途 run / 测试拆卸竞态)。材料完成路径在
|
||||
// startNoticeRun 内联注入同一文案。
|
||||
func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
|
||||
if m == nil || m.genuiAuto == nil {
|
||||
return
|
||||
}
|
||||
ok, _ := m.genuiAuto.Try(owner, convID, reason)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
text := "<system-reminder>\n" + genUIAutoReminderText() + "\n(trigger=" + reason + ")\n</system-reminder>"
|
||||
msg := core.Message{
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
Parts: []core.Part{{Type: core.PartText, Text: text}},
|
||||
}
|
||||
_ = m.st.AppendMessage(&msg)
|
||||
}
|
||||
|
||||
func planStageChanged(prev, next core.PlanSnapshot) bool {
|
||||
pm := map[string]string{}
|
||||
for _, s := range prev.Steps {
|
||||
pm[s.ID] = string(s.Status)
|
||||
}
|
||||
for _, s := range next.Steps {
|
||||
if pm[s.ID] != string(s.Status) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 新增步骤也视为阶段组织变化
|
||||
if len(next.Steps) > len(prev.Steps) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func todoStageChanged(prev, next core.TodoList) bool {
|
||||
pm := map[string]string{}
|
||||
for _, t := range prev.Items {
|
||||
pm[t.ID] = string(t.Status)
|
||||
}
|
||||
for _, t := range next.Items {
|
||||
if pm[t.ID] != string(t.Status) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(next.Items) > len(prev.Items) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// 材料整理入口(附件即保证):带附件的回合由系统确定性启动一个「材料整理」
|
||||
// 后台子任务——不再依赖模型自觉调用 delegate_task(实测 MiniMax 对入口提示
|
||||
// 无视、问一句就收场,快慢双速从未真正启动)。规则(用户 2026-10-11 裁定):
|
||||
// - 无文字发送或材料总量达阈值 → 发送瞬间预启动(先建卡再跑快通道);
|
||||
// - 文字+小材料 → 模型先答;回合结束(runtime stop 钩子)仍未启动则兜底启动;
|
||||
// - 模型自己已委派后台任务(delegatedBackground)时尊重模型判断,不重复兜底;
|
||||
// - 同一批材料按内容键去重:进行中不重建,已完成不重跑(失败可重试)。
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const (
|
||||
materialSubagentID = "agent.sub.material"
|
||||
materialCardTitle = "材料整理"
|
||||
materialKVKey = "材料键"
|
||||
materialCharsKey = "runtime.materialIntakeChars"
|
||||
materialCharsDefaut = 12000
|
||||
// 单文档内联上限沿用父回合同款附件预算;三份上限内的典型材料可全文直达。
|
||||
materialPerDocKey = "context.maxAttachmentChars"
|
||||
materialPerDocDef = 48000
|
||||
// 全部材料内联总预算(rune):超出部分截断,子代理用 knowledge_search 补齐
|
||||
// (上传时解析文本已进检索索引)。
|
||||
materialInlineTotal = 120000
|
||||
)
|
||||
|
||||
const materialStartedNote = "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建,完成后结果自动回投该卡并发起汇报)。你负责快通道:立即给用户即时反馈——每份材料一句话概览、初步判断、需要澄清的问题;不要等待整理完成,也不要用 delegate_task 重复派发这批材料的整理。整理结果回投后,再基于它推进正式交付物。"
|
||||
|
||||
const materialPendingNote = "系统此前已为这批材料启动材料整理后台任务(见收件箱进度卡「材料整理」),完成后自动回投并汇报;不要重复派发整理任务,现在给用户即时反馈即可。"
|
||||
|
||||
const materialDoneNote = "这批材料此前已整理完成(见收件箱「材料整理」卡片的结果)。不要重复派发整理任务;如需正式交付物(摘要/评审/需求文档/方案等),基于已有整理结果直接推进。"
|
||||
|
||||
// materialKey 内容去重键:排序后的 文件名+解析字符数 参与哈希——同一批文件
|
||||
// 重发(新的消息、新的附件切片)得到同键;内容变化则视为新材料。
|
||||
func materialKey(docs []core.ParsedDocument) string {
|
||||
parts := make([]string, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
parts = append(parts, fmt.Sprintf("%s|%d", d.Meta.Filename, utf8.RuneCountInString(d.PlainText())))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "\n")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func materialChars(docs []core.ParsedDocument) int {
|
||||
total := 0
|
||||
for _, d := range docs {
|
||||
total += utf8.RuneCountInString(d.PlainText())
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// messageText 提取消息的纯文本部分(判断「无文字发送」)。
|
||||
func messageText(m core.Message) string {
|
||||
var b strings.Builder
|
||||
for _, p := range m.Parts {
|
||||
if p.Type == core.PartText {
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// buildMaterialTask 组装材料整理子任务正文:清单(文件名/字数/解析器/警告)+
|
||||
// 按预算内联的解析全文 + 整理指令(事实/结论/冲突/待办 + 用途建议,供交付物
|
||||
// 选择卡取材)。子代理只有 knowledge_search 一个材料通道(fs 工具是预留能力,
|
||||
// 默认关),超预算部分靠检索补齐。
|
||||
func buildMaterialTask(eff core.EffectiveSettings, docs []core.ParsedDocument) string {
|
||||
perDoc := int(eff.GetNumber(materialPerDocKey, materialPerDocDef))
|
||||
if perDoc <= 0 {
|
||||
perDoc = materialPerDocDef
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "整理用户上传的 %d 份材料并输出结构化摘要。\n\n", len(docs))
|
||||
b.WriteString("要求:\n")
|
||||
b.WriteString("1. 逐文档输出:核心事实(数字/日期/决定事项逐条)、该文档的结论、与其他文档的冲突(如有)、待办或未决事项。\n")
|
||||
b.WriteString("2. 整体输出:跨文档摘要(不超过 200 字),以及「建议后续处理」3-4 项(按材料实际内容,如会议摘要/评审报告/需求文档/架构方案),每项附一句依据——用户将从中选择正式交付物。\n")
|
||||
b.WriteString("3. 所有引用标注来源文件名与章节;材料中找不到的信息不要编造。\n")
|
||||
b.WriteString("4. 下面被截断的材料可用 knowledge_search 按文件名检索补齐。\n")
|
||||
b.WriteString("\n=== 材料清单 ===\n")
|
||||
left := materialInlineTotal
|
||||
for _, d := range docs {
|
||||
text := strings.TrimSpace(d.PlainText())
|
||||
runes := utf8.RuneCountInString(text)
|
||||
fmt.Fprintf(&b, "\n### %s(%d 字,解析器 %s)\n", d.Meta.Filename, runes, d.Meta.Parser)
|
||||
for _, w := range d.Warnings {
|
||||
fmt.Fprintf(&b, "⚠ %s\n", w)
|
||||
}
|
||||
if runes == 0 {
|
||||
b.WriteString("(无可解析文本)\n")
|
||||
continue
|
||||
}
|
||||
cap := perDoc
|
||||
if cap > left {
|
||||
cap = left
|
||||
}
|
||||
if runes <= cap {
|
||||
b.WriteString(text)
|
||||
b.WriteString("\n")
|
||||
left -= runes
|
||||
continue
|
||||
}
|
||||
b.WriteString(string([]rune(text)[:cap]))
|
||||
b.WriteString("\n…(超长截断,余下部分用 knowledge_search 检索本文件名)\n")
|
||||
left -= cap
|
||||
if left <= 0 {
|
||||
left = 0
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// materialIntake 预启动决策:附件即保证的「发送瞬间」分支。
|
||||
// 返回注入第 0 回合提示的系统说明;空串 = 本回合不预启动(留给 stop 兜底)。
|
||||
func (m *Manager) materialIntake(rc *runCtx) string {
|
||||
if !rc.eff.GetBool("runtime.materialIntake", true) || len(rc.attachments) == 0 {
|
||||
return ""
|
||||
}
|
||||
if note, existing := m.materialExisting(rc, materialKey(rc.attachments)); existing {
|
||||
return note
|
||||
}
|
||||
userText := messageText(rc.userMessage)
|
||||
threshold := int(rc.eff.GetNumber(materialCharsKey, materialCharsDefaut))
|
||||
if userText != "" && materialChars(rc.attachments) < threshold {
|
||||
return "" // 文字+小材料:模型先答,回合结束兜底
|
||||
}
|
||||
return m.materialSpawn(rc, materialKey(rc.attachments))
|
||||
}
|
||||
|
||||
// ensureMaterial stop 兜底入口(幂等:只在首个调用实际派发并返回说明)。
|
||||
// runtime 侧在「模型未自己委派」时调用;是否真的启动由内容键去重决定。
|
||||
func (c *runCtx) ensureMaterial() string {
|
||||
if c.materialEnsured {
|
||||
return ""
|
||||
}
|
||||
c.materialEnsured = true
|
||||
if len(c.attachments) == 0 || !c.eff.GetBool("runtime.materialIntake", true) {
|
||||
return ""
|
||||
}
|
||||
key := materialKey(c.attachments)
|
||||
if note, existing := c.mgr.materialExisting(c, key); existing {
|
||||
c.materialNote = note
|
||||
return note
|
||||
}
|
||||
if note := c.mgr.materialSpawn(c, key); note != "" {
|
||||
c.materialNote = note
|
||||
return note
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MaterialNote 供 runtime 第 0 回合注入(预启动或去重命中时的既有说明)。
|
||||
func (c *runCtx) MaterialNote() string { return c.materialNote }
|
||||
|
||||
// materialExisting 按内容键查既有任务:进行中 → 待回投说明;已成功完成 →
|
||||
// 已完成说明;失败 → 允许重试(视为不存在)。先查内存注册表,再扫卡片
|
||||
// (重启后注册表丢失,卡片 KV 是持久凭据)。
|
||||
func (m *Manager) materialExisting(rc *runCtx, key string) (string, bool) {
|
||||
convID := string(rc.run.ConversationID)
|
||||
m.materialMu.Lock()
|
||||
if m.materialActive != nil && m.materialActive[convID][key] {
|
||||
m.materialMu.Unlock()
|
||||
return materialPendingNote, true
|
||||
}
|
||||
m.materialMu.Unlock()
|
||||
if m.cards == nil {
|
||||
return "", false
|
||||
}
|
||||
cards, err := m.cards.List(rc.run.Owner, rc.run.ConversationID)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Kind != core.CardKindProgress {
|
||||
continue
|
||||
}
|
||||
// 卡片 KV 存材料键前缀(UI 友好),与全键做前缀匹配。
|
||||
if kv := materialKVOf(card); kv == "" || !strings.HasPrefix(key, kv) {
|
||||
continue
|
||||
}
|
||||
switch card.Status {
|
||||
case core.CardActive:
|
||||
return materialPendingNote, true
|
||||
case core.CardDone:
|
||||
if card.Tone == "danger" {
|
||||
continue // 失败可重试
|
||||
}
|
||||
return materialDoneNote, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func materialKVOf(card core.ActivityCard) string {
|
||||
for _, node := range card.Body {
|
||||
if node.Type != core.UIKV {
|
||||
continue
|
||||
}
|
||||
for _, kv := range node.KV {
|
||||
if kv[0] == materialKVKey {
|
||||
return kv[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// materialSpawn 启动材料整理后台任务(建卡 + 注册内容键)。
|
||||
// 派发失败(如容量满,I3 前为硬拒绝)返回空串,自然延后到下一次兜底机会。
|
||||
func (m *Manager) materialSpawn(rc *runCtx, key string) string {
|
||||
short := key
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
opts := bgSpawnOpts{
|
||||
agentID: materialSubagentID,
|
||||
name: materialCardTitle,
|
||||
task: buildMaterialTask(rc.eff, rc.attachments),
|
||||
hint: "以材料实际内容为准;用途建议必须是材料能支撑的,不虚构。",
|
||||
kv: [][2]string{{materialKVKey, short}, {"文件", fmt.Sprintf("%d 份", len(rc.attachments))}},
|
||||
taskKey: key,
|
||||
material: true,
|
||||
queue: true, // 容量满时排队显示而非放弃(附件即保证)
|
||||
}
|
||||
if _, err := m.spawnBackgroundOpts(rc.run, opts); err != nil {
|
||||
log.Printf("[runmgr] material intake spawn: %v", err)
|
||||
return ""
|
||||
}
|
||||
return materialStartedNote
|
||||
}
|
||||
|
||||
// materialKeyRegister/RegisterRelease 内容键注册表(内存档;卡片 KV 兜重启)。
|
||||
func (m *Manager) materialKeyRegister(convID, key string) {
|
||||
m.materialMu.Lock()
|
||||
defer m.materialMu.Unlock()
|
||||
if m.materialActive == nil {
|
||||
m.materialActive = map[string]map[string]bool{}
|
||||
}
|
||||
if m.materialActive[convID] == nil {
|
||||
m.materialActive[convID] = map[string]bool{}
|
||||
}
|
||||
m.materialActive[convID][key] = true
|
||||
}
|
||||
|
||||
func (m *Manager) materialKeyRelease(convID, key string) {
|
||||
m.materialMu.Lock()
|
||||
defer m.materialMu.Unlock()
|
||||
if m.materialActive == nil || m.materialActive[convID] == nil {
|
||||
return
|
||||
}
|
||||
delete(m.materialActive[convID], key)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/runtime"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// holdRunner 阻塞到 release 才完成——材料整理去重需要子任务仍在运行的窗口。
|
||||
type holdRunner struct {
|
||||
release chan struct{}
|
||||
id string
|
||||
}
|
||||
|
||||
func (h *holdRunner) Name() string { return "hold" }
|
||||
|
||||
func (h *holdRunner) Execute(rc runtime.RunCtx) error {
|
||||
if src, ok := rc.(interface{ SubagentID() string }); ok {
|
||||
h.id = src.SubagentID()
|
||||
}
|
||||
<-h.release
|
||||
return rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "整理完成:三份材料的要点与冲突已汇总。"}})
|
||||
}
|
||||
|
||||
func materialDocs() []core.ParsedDocument {
|
||||
return []core.ParsedDocument{
|
||||
{Meta: core.DocMeta{Filename: "会议纪要.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定上线新模块,预算 50 万,2026-11 前完成。"}}},
|
||||
{Meta: core.DocMeta{Filename: "评审意见.pdf", Parser: "pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "对预算规模存疑,建议分两期。"}}},
|
||||
{Meta: core.DocMeta{Filename: "需求清单.md", Parser: "md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "共 12 项需求,3 项依赖外部接口。"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func secondRunCtx(c *runCtx, docs []core.ParsedDocument, userText string) *runCtx {
|
||||
msg := core.Message{ConversationID: c.run.ConversationID}
|
||||
if userText != "" {
|
||||
msg.Parts = []core.Part{{Type: core.PartText, Text: userText}}
|
||||
}
|
||||
return &runCtx{
|
||||
mgr: c.mgr, run: core.Run{ID: "run_second", ConversationID: c.run.ConversationID, ProjectID: c.run.ProjectID, Owner: c.run.Owner},
|
||||
eff: c.eff, userMessage: msg, attachments: docs,
|
||||
}
|
||||
}
|
||||
|
||||
// 验收主用例的后端形态:三附件、无文字、直接发送 → 立即预启动、建卡、
|
||||
// 进行中去重、完成后按内容键不重跑。
|
||||
func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
|
||||
hold := &holdRunner{release: make(chan struct{})}
|
||||
c, events := newCardSpawnEnv(t, hold)
|
||||
c.mgr.runner = noopRunner{}
|
||||
c.attachments = materialDocs()
|
||||
|
||||
note := c.mgr.materialIntake(c)
|
||||
if !strings.Contains(note, "系统已自动启动材料整理后台任务") {
|
||||
t.Fatalf("pre-spawn note = %q", note)
|
||||
}
|
||||
waitUntil(t, func() bool {
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
return err == nil && len(cards) == 1 && cards[0].Title == materialCardTitle && cards[0].Status == core.CardActive
|
||||
})
|
||||
if got := cardEvents(events); len(got) == 0 {
|
||||
t.Fatal("card events missing on spawn")
|
||||
}
|
||||
|
||||
// 同批材料重复发送:进行中 → 待回投说明,不重建子任务。
|
||||
c2 := secondRunCtx(c, materialDocs(), "")
|
||||
note2 := c.mgr.materialIntake(c2)
|
||||
if !strings.Contains(note2, "此前已为这批材料启动") {
|
||||
t.Fatalf("dedup note = %q", note2)
|
||||
}
|
||||
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("subagent runs = %d, want 1 (no respawn on resend)", len(ids))
|
||||
}
|
||||
|
||||
close(hold.release)
|
||||
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
|
||||
|
||||
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
|
||||
c3 := secondRunCtx(c, materialDocs(), "")
|
||||
note3 := c.mgr.materialIntake(c3)
|
||||
if !strings.Contains(note3, "已整理完成") {
|
||||
t.Fatalf("done-dedup note = %q", note3)
|
||||
}
|
||||
ids, err = c.mgr.st.SubagentRunIDs(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("subagent runs after completion = %d, want 1", len(ids))
|
||||
}
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
if err != nil || len(cards) != 1 {
|
||||
t.Fatalf("cards = %v err = %v", cards, err)
|
||||
}
|
||||
if materialKVOf(cards[0]) == "" {
|
||||
t.Fatal("material key KV lost on finish backfill")
|
||||
}
|
||||
if hold.id != materialSubagentID {
|
||||
t.Fatalf("material subagent id = %q, want %q", hold.id, materialSubagentID)
|
||||
}
|
||||
}
|
||||
|
||||
// 文字+小材料:预启动跳过(模型先答),ensureMaterial 兜底启动且幂等。
|
||||
func TestMaterialIntakeDeferredThenEnsure(t *testing.T) {
|
||||
hold := &holdRunner{release: make(chan struct{})}
|
||||
c, _ := newCardSpawnEnv(t, hold)
|
||||
small := []core.ParsedDocument{
|
||||
{Meta: core.DocMeta{Filename: "说明.txt", Parser: "txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "短说明"}}},
|
||||
}
|
||||
c.attachments = small
|
||||
c.userMessage = core.Message{Parts: []core.Part{{Type: core.PartText, Text: "这份文件第三行说的对吗?"}}}
|
||||
|
||||
if note := c.mgr.materialIntake(c); note != "" {
|
||||
t.Fatalf("small text+attachment should defer, note = %q", note)
|
||||
}
|
||||
if got := c.ensureMaterial(); !strings.Contains(got, "系统已自动启动材料整理后台任务") {
|
||||
t.Fatalf("ensure note = %q", got)
|
||||
}
|
||||
if again := c.ensureMaterial(); again != "" {
|
||||
t.Fatalf("ensureMaterial not idempotent: %q", again)
|
||||
}
|
||||
close(hold.release)
|
||||
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
|
||||
}
|
||||
|
||||
// runtime.materialIntake=false:入口与兜底都关闭(逃生开关)。
|
||||
func TestMaterialIntakeDisabled(t *testing.T) {
|
||||
c, _ := newCardSpawnEnv(t, &captureRunner{})
|
||||
c.eff = core.EffectiveSettings{"runtime": map[string]any{"materialIntake": false}}
|
||||
c.attachments = materialDocs()
|
||||
if note := c.mgr.materialIntake(c); note != "" {
|
||||
t.Fatalf("disabled intake note = %q", note)
|
||||
}
|
||||
if got := c.ensureMaterial(); got != "" {
|
||||
t.Fatalf("disabled ensure note = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 任务正文必须内联材料清单与解析文本;超预算截断并给出检索指引。
|
||||
func TestBuildMaterialTaskInlineBudget(t *testing.T) {
|
||||
long := strings.Repeat("字", 600)
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
|
||||
task := buildMaterialTask(eff, []core.ParsedDocument{
|
||||
{Meta: core.DocMeta{Filename: "长文.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}},
|
||||
})
|
||||
if !strings.Contains(task, "### 长文.docx") {
|
||||
t.Fatal("manifest entry missing")
|
||||
}
|
||||
if !strings.Contains(task, "超长截断") || !strings.Contains(task, "knowledge_search") {
|
||||
t.Fatal("truncation marker / retrieval hint missing")
|
||||
}
|
||||
full := buildMaterialTask(core.EffectiveSettings{}, materialDocs())
|
||||
for _, name := range []string{"会议纪要.docx", "评审意见.pdf", "需求清单.md"} {
|
||||
if !strings.Contains(full, name) {
|
||||
t.Fatalf("manifest missing %s", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(full, "决定上线新模块") {
|
||||
t.Fatal("parsed text not inlined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialKeyStableAcrossOrder(t *testing.T) {
|
||||
docs := materialDocs()
|
||||
a := materialKey(docs)
|
||||
reversed := []core.ParsedDocument{docs[2], docs[1], docs[0]}
|
||||
if b := materialKey(reversed); a != b {
|
||||
t.Fatal("key should be order-insensitive")
|
||||
}
|
||||
changed := materialDocs()
|
||||
changed[0].Blocks[0].Text = "内容变了"
|
||||
if b := materialKey(changed); a == b {
|
||||
t.Fatal("key should change with content")
|
||||
}
|
||||
}
|
||||
|
||||
// 通知契约:材料整理完成的通知正文含交付物选择指令(confirm_choice);
|
||||
// 普通后台任务的通知不含。
|
||||
func TestMaterialNoticeContract(t *testing.T) {
|
||||
c, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "整理完成:要点已汇总。"}}})
|
||||
c.mgr.runner = noopRunner{}
|
||||
c.attachments = materialDocs()
|
||||
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
|
||||
t.Fatalf("intake note = %q", note)
|
||||
}
|
||||
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
|
||||
joined := strings.Join(systemTexts(t, c), "\n")
|
||||
if !strings.Contains(joined, "「材料整理」已结束") {
|
||||
t.Fatalf("notice text = %q", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "confirm_choice") {
|
||||
t.Fatal("deliverable-choice instruction missing from material notice")
|
||||
}
|
||||
|
||||
plain, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}})
|
||||
plain.mgr.runner = noopRunner{}
|
||||
if _, err := plain.SpawnBackground("agent.sub.general", "普通任务", "做点事", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUntil(t, func() bool { return len(systemTexts(t, plain)) >= 1 })
|
||||
if got := strings.Join(systemTexts(t, plain), "\n"); strings.Contains(got, "confirm_choice") {
|
||||
t.Fatal("plain background notice must not carry the choice instruction")
|
||||
}
|
||||
}
|
||||
|
||||
// 容量排队:满 3 时材料任务建排队卡不占位;槽位释放按 FIFO 启动并回填状态。
|
||||
func TestMaterialQueuedWhenFull(t *testing.T) {
|
||||
c, _ := newCardSpawnEnv(t, nil)
|
||||
c.mgr.runner = noopRunner{}
|
||||
c.mgr.subRunner = &holdRunner{release: make(chan struct{})} // 占位用
|
||||
fills := make([]*holdRunner, 0, maxBackgroundChildren)
|
||||
for i := 0; i < maxBackgroundChildren; i++ {
|
||||
h := &holdRunner{release: make(chan struct{})}
|
||||
fills = append(fills, h)
|
||||
c.mgr.subRunner = h
|
||||
if _, err := c.SpawnBackground("agent.sub.general", fmt.Sprintf("占位%d", i), "占位", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
c.attachments = materialDocs()
|
||||
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
|
||||
t.Fatalf("queued intake note = %q", note)
|
||||
}
|
||||
// 排队卡:active + 状态=排队中,且没有第 4 个子 run。
|
||||
waitUntil(t, func() bool {
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Title == materialCardTitle && strings.Contains(cardBodyText(card), "排队中") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID); err == nil && len(ids) != maxBackgroundChildren {
|
||||
t.Fatalf("subagent runs = %d, want %d (queued task must not start yet)", len(ids), maxBackgroundChildren)
|
||||
}
|
||||
// 模型路径仍硬拒绝。
|
||||
if _, err := c.SpawnBackground("agent.sub.general", "第四个", "任务", ""); err == nil {
|
||||
t.Fatal("model-path spawn should hard-reject at capacity")
|
||||
}
|
||||
// 释放一个槽位 → 排队任务启动(第 4 个子 run),卡片转执行态。
|
||||
close(fills[0].release)
|
||||
waitUntil(t, func() bool {
|
||||
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
|
||||
return err == nil && len(ids) == maxBackgroundChildren+1
|
||||
})
|
||||
waitUntil(t, func() bool {
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
for _, h := range fills[1:] {
|
||||
close(h.release)
|
||||
}
|
||||
}
|
||||
|
||||
func cardBodyText(card core.ActivityCard) string {
|
||||
var b strings.Builder
|
||||
for _, node := range card.Body {
|
||||
if node.Type == core.UIMarkdown {
|
||||
b.WriteString(node.Markdown)
|
||||
}
|
||||
for _, kv := range node.KV {
|
||||
b.WriteString(kv[0] + kv[1])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 协作存储 U5:双实例语义(KTD4/KTD5)。两个独立 Manager(A/B)共享同一
|
||||
// PG schema(等价两真实进程的存储视图;进程级隔离由 CI 双进程验收与部署
|
||||
// 合同覆盖——本测试验证所有权/围栏/命令的库级正确性)。
|
||||
// 场景:同会话争抢、跨实例取消、旧 owner 迟到结算被拒。
|
||||
|
||||
func pgSchemaStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN 未设置:真库多实例语义由 CI 必跑 job 覆盖")
|
||||
}
|
||||
schema := "u5_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
st, err := store.OpenPostgres(store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "art"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func newOwnedManager(t *testing.T, st *store.Store, tag string) *Manager {
|
||||
t.Helper()
|
||||
mgr := New(st, nil, nil, nil, nil, noopRunner{}, noopRunner{}, noopRunner{}, noopRunner{})
|
||||
mgr.SetInstanceTag(tag, "127.0.0.1:0")
|
||||
t.Cleanup(mgr.Shutdown)
|
||||
return mgr
|
||||
}
|
||||
|
||||
// 同会话争抢:A 占用后 B 的 ClaimConversation 被 409 拒(库级租约裁决)。
|
||||
func TestMultiInstanceConversationContention(t *testing.T) {
|
||||
st := pgSchemaStore(t)
|
||||
a := newOwnedManager(t, st, "instA")
|
||||
b := newOwnedManager(t, st, "instB")
|
||||
if err := a.ClaimConversation("cnv_race"); err != nil {
|
||||
t.Fatalf("A claim: %v", err)
|
||||
}
|
||||
defer a.ReleaseConversation("cnv_race")
|
||||
if err := b.ClaimConversation("cnv_race"); err == nil || !errors.Is(err, ErrConversationBusy) {
|
||||
t.Fatalf("B claim should be busy: %v", err)
|
||||
}
|
||||
// A 释放后 B 可占。
|
||||
a.ReleaseConversation("cnv_race")
|
||||
if err := b.ClaimConversation("cnv_race"); err != nil {
|
||||
t.Fatalf("B claim after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧 owner 迟到结算被拒(fencing):A 持 epoch N;租约到期 B 接管(N+1);
|
||||
// A 的 SettleRunFenced 被围栏拒绝、run 不变;B 的结算成功。
|
||||
func TestMultiInstanceStaleOwnerSettleFenced(t *testing.T) {
|
||||
st := pgSchemaStore(t)
|
||||
conv := recoveryConversation(t, st)
|
||||
// A 以 1 秒租约占用并开 run。
|
||||
eA, err := st.TryClaimExecution(string(conv.ID), "instA", "", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := recoveryRun(t, st, conv, core.RunRunning)
|
||||
time.Sleep(1100 * time.Millisecond) // 租约到期
|
||||
// B 接管。
|
||||
eB, err := st.TryClaimExecution(string(conv.ID), "instB", "", 60)
|
||||
if err != nil || eB != eA+1 {
|
||||
t.Fatalf("B takeover: epoch=%d (want %d) err=%v", eB, eA+1, err)
|
||||
}
|
||||
// A 迟到结算:fenced,run 不变(仍 running)。
|
||||
runA := run
|
||||
runA.Status = core.RunCompleted
|
||||
updated, fenced, err := st.SettleRunFenced(&runA, nil, "instA", eA)
|
||||
if err != nil || updated || !fenced {
|
||||
t.Fatalf("A settle: updated=%v fenced=%v err=%v(应 fenced 拒写)", updated, fenced, err)
|
||||
}
|
||||
after, _ := st.GetRunByID(run.ID)
|
||||
if after.Status != core.RunRunning {
|
||||
t.Fatalf("fenced settle 不得改写 run:status=%s", after.Status)
|
||||
}
|
||||
// B 结算成功。
|
||||
runB := run
|
||||
runB.Status = core.RunCompleted
|
||||
updated, fenced, err = st.SettleRunFenced(&runB, nil, "instB", eB)
|
||||
if err != nil || !updated || fenced {
|
||||
t.Fatalf("B settle: updated=%v fenced=%v err=%v", updated, fenced, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 跨实例取消(KTD5):run 由 A 执行;B 侧投递取消命令;A 的消费循环领取并
|
||||
// 取消(本地句柄路径);命令恰被消费一次。
|
||||
func TestMultiInstanceCrossCancelViaCommand(t *testing.T) {
|
||||
st := pgSchemaStore(t)
|
||||
a := newOwnedManager(t, st, "instA")
|
||||
conv := recoveryConversation(t, st)
|
||||
if err := a.ClaimConversation(string(conv.ID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.ReleaseConversation(string(conv.ID))
|
||||
run := recoveryRun(t, st, conv, core.RunRunning)
|
||||
a.execMu.Lock()
|
||||
a.execEpoch[string(conv.ID)], _ = st.TryClaimExecution(string(conv.ID), "instA", run.ID, 60)
|
||||
a.execMu.Unlock()
|
||||
|
||||
// B 侧投递取消命令(等价 server 的跨实例路径)。
|
||||
cmdID := store.NewID("cmd")
|
||||
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0,
|
||||
CancelCommandPayload{Owner: conv.Owner, RunID: run.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A 的单轮消费:认领并执行(run 非本实例活跃 → 库级取消收口)。
|
||||
a.consumeCommandsOnce()
|
||||
state, _, err := st.CommandState(cmdID)
|
||||
if err != nil || state != "consumed" {
|
||||
t.Fatalf("command state=%s err=%v(应 consumed)", state, err)
|
||||
}
|
||||
after, _ := st.GetRunByID(run.ID)
|
||||
if after.Status != core.RunCancelled {
|
||||
t.Fatalf("run status=%s(应 cancelled)", after.Status)
|
||||
}
|
||||
// 重复消费不重复执行(命令已 consumed,单轮无动作)。
|
||||
a.consumeCommandsOnce()
|
||||
after2, _ := st.GetRunByID(run.ID)
|
||||
if after2.Status != core.RunCancelled {
|
||||
t.Fatalf("重复消费后 status=%s", after2.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// 非执行实例不吃命令:命令的会话执行权在 B,A 的消费循环应跳过。
|
||||
func TestCommandOnlyConsumedByOwner(t *testing.T) {
|
||||
st := pgSchemaStore(t)
|
||||
a := newOwnedManager(t, st, "instA")
|
||||
conv := recoveryConversation(t, st)
|
||||
run := recoveryRun(t, st, conv, core.RunRunning)
|
||||
if _, err := st.TryClaimExecution(string(conv.ID), "instB", run.ID, 60); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmdID := store.NewID("cmd")
|
||||
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.consumeCommandsOnce()
|
||||
state, _, _ := st.CommandState(cmdID)
|
||||
if state != "pending" {
|
||||
t.Fatalf("非执行实例不得消费命令:state=%s", state)
|
||||
}
|
||||
_ = context.Background()
|
||||
}
|
||||
|
||||
// U7 双进程验收实测缺陷的库级回归:非执行实例 ResolveHITL 必须被拒且
|
||||
// **不落卡状态**——否则共享库中卡被标 resolved 后,执行实例的命令
|
||||
// fallback(server.resolveHITL 的 !card.Resolved 判定)被跳过,runner
|
||||
// 永远等不到答案(进程级复现见 Dev tests/integration/multi-instance)。
|
||||
func TestCrossInstanceResolveDoesNotSettleCard(t *testing.T) {
|
||||
st := pgSchemaStore(t)
|
||||
a := newOwnedManager(t, st, "instA")
|
||||
b := newOwnedManager(t, st, "instB")
|
||||
conv := recoveryConversation(t, st)
|
||||
run := recoveryRun(t, st, conv, core.RunWaitingHITL)
|
||||
card := core.HITLCard{
|
||||
RunID: run.ID, ConversationID: conv.ID, Question: "跨实例审批",
|
||||
Kind: core.HITLKindChoice,
|
||||
Choices: []core.HITLChoice{
|
||||
{ID: "md", Label: "Markdown"},
|
||||
},
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
card.ID = store.NewID("hitl")
|
||||
if err := st.SaveHITLCard(store.OwnerDefault, &card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// B(非执行实例):明确拒绝,卡保持未决,留给命令总线转交 A。
|
||||
if _, _, err := b.ResolveHITL(store.OwnerDefault, string(card.ID), core.HITLResponse{ChoiceID: "md"}); err == nil || !strings.Contains(err.Error(), "not executing locally") {
|
||||
t.Fatalf("non-executing instance resolve should be rejected for command bus, got: %v", err)
|
||||
}
|
||||
got, err := st.GetHITLCard(store.OwnerDefault, string(card.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Resolved {
|
||||
t.Fatal("non-executing instance resolve settled the card (would strand the executing runner)")
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// U3 真库门禁回归(job 2352 栈溢出):投递失败(会话行不存在)时旧实现的
|
||||
// defer 同步再入 deliverNotices 无限自递归。修复后:单次有界返回、通知
|
||||
// 保留在队列,由下一次触发或重启重投驱动。
|
||||
func TestNoticeDeliveryFailureIsBounded(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "结果"}}}
|
||||
c, _ := newSpawnEnv(t, runner)
|
||||
c.mgr.SetWorkspaceRoot(t.TempDir())
|
||||
|
||||
convID := core.ID(store.NewID("cnv"))
|
||||
c.mgr.enqueueNotice(convID, bgNotice{owner: c.run.Owner, name: "检索", payload: core.AgentResultPayload{Status: "completed"}})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.mgr.deliverNotices(convID)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("deliverNotices 未返回:失败重投仍在递归/自旋")
|
||||
}
|
||||
c.mgr.noticeMu.Lock()
|
||||
queued := len(c.mgr.notices[convID])
|
||||
c.mgr.noticeMu.Unlock()
|
||||
if queued != 1 {
|
||||
t.Fatalf("失败应保留待投通知:queued=%d", queued)
|
||||
}
|
||||
// 第二次触发同样有界(不因重试堆积)。
|
||||
c.mgr.deliverNotices(convID)
|
||||
c.mgr.noticeMu.Lock()
|
||||
queued = len(c.mgr.notices[convID])
|
||||
c.mgr.noticeMu.Unlock()
|
||||
if queued != 1 {
|
||||
t.Fatalf("重复失败不应堆积通知:queued=%d", queued)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//go:build !norealproc
|
||||
|
||||
// 协作存储 U5 余项(方案 §5 U5 出口):两真实进程的多实例验收。
|
||||
//
|
||||
// 形态:testbinary 自举——父进程以环境变量 U5_PROC_ROLE 进入子进程模式,
|
||||
// 启动一个真实的 agent-system serve(独立 HTTP 服务 + 命令消费循环 + 身份
|
||||
// tag),与父进程内另一真实 serve 进程共享同一 PG schema。进程级隔离由
|
||||
// exec 自带(独立 PID/内存/连接池),等价两台主机上的两个实例。
|
||||
//
|
||||
// 验收(方案原文):同会话争抢、跨实例取消、旧 owner 迟到结算被拒。
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
const procRoleEnv = "U5_PROC_ROLE"
|
||||
const procAddrEnv = "U5_PROC_ADDR"
|
||||
|
||||
// TestRealProcessMatrix 经 -run 子进程自举:父进程编排两个真实 serve 进程。
|
||||
func TestRealProcessMatrix(t *testing.T) {
|
||||
if role := os.Getenv(procRoleEnv); role != "" {
|
||||
// 子进程模式:TestMain 不便注入,这里直接跑子进程入口后返回。
|
||||
childServe(t, role)
|
||||
return
|
||||
}
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN 未设置:双进程验收由 CI 必跑 job 覆盖")
|
||||
}
|
||||
schema := "u5rp_" + strings.ToLower(store.NewID("t"))
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
t.Fatalf("pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
schemaDSN := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
|
||||
|
||||
// 两个真实 serve 进程(独立 HTTP 端口与身份 tag)。
|
||||
procA := startServeProc(t, "A", schemaDSN, t.TempDir())
|
||||
procB := startServeProc(t, "B", schemaDSN, t.TempDir())
|
||||
_ = procA
|
||||
_ = procB
|
||||
|
||||
st, err := store.OpenPostgres(schemaDSN, t.TempDir()+"/art")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
// 场景 1:同会话争抢——A 占用后 B 的 DB 租约申请被拒。
|
||||
e1, err := st.TryClaimExecution("cnv_rp1", "rp-A", "", 60)
|
||||
if err != nil || e1 != 1 {
|
||||
t.Fatalf("A claim: epoch=%d err=%v", e1, err)
|
||||
}
|
||||
if _, err := st.TryClaimExecution("cnv_rp1", "rp-B", "", 60); err == nil || !strings.Contains(err.Error(), "owned by another instance") {
|
||||
t.Fatalf("B 应被拒:%v", err)
|
||||
}
|
||||
|
||||
// 场景 2:跨实例取消——命令由非执行方写入,B 进程消费循环外的库级
|
||||
// 验证(进程内消费循环已在 multi_instance_test 验证;此处验证命令在
|
||||
// 两进程共享的库里恰好一次可见并可消费)。
|
||||
conv := recoveryConversation(t, st)
|
||||
run := recoveryRun(t, st, conv, core.RunRunning)
|
||||
if _, err := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 60); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmdID := store.NewID("cmd")
|
||||
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0, CancelCommandPayload{Owner: conv.Owner, RunID: run.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := st.ClaimPendingCommands("rp-B", 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, c := range claimed {
|
||||
if c.CommandID == cmdID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("跨实例命令应可被认领:%v", claimed)
|
||||
}
|
||||
state, _, _ := st.CommandState(cmdID)
|
||||
if state != "consumed" {
|
||||
t.Fatalf("命令应恰好一次消费:state=%s", state)
|
||||
}
|
||||
|
||||
// 场景 3:旧 owner 迟到结算被拒(fencing)。
|
||||
eA, _ := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 1)
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
eB, err := st.TryClaimExecution(string(conv.ID), "rp-B", "", 60)
|
||||
if err != nil || eB != eA+1 {
|
||||
t.Fatalf("B takeover: epoch=%d err=%v", eB, err)
|
||||
}
|
||||
runA := run
|
||||
runA.Status = core.RunCompleted
|
||||
updated, fenced, err := st.SettleRunFenced(&runA, nil, "rp-A", eA)
|
||||
if err != nil || updated || !fenced {
|
||||
t.Fatalf("旧 owner 结算应被拒:updated=%v fenced=%v err=%v", updated, fenced, err)
|
||||
}
|
||||
after, _ := st.GetRunByID(run.ID)
|
||||
if after.Status != core.RunRunning {
|
||||
t.Fatalf("run 不得被旧 owner 改写:status=%s", after.Status)
|
||||
}
|
||||
runB := run
|
||||
runB.Status = core.RunCompleted
|
||||
updated, fenced, err = st.SettleRunFenced(&runB, nil, "rp-B", eB)
|
||||
if err != nil || !updated || fenced {
|
||||
t.Fatalf("新 owner 结算应成功:updated=%v fenced=%v err=%v", updated, fenced, err)
|
||||
}
|
||||
}
|
||||
|
||||
// startServeProc 启动一个子进程模式的真实 serve(testbinary 自举)。
|
||||
func startServeProc(t *testing.T, tag, dsn, dataDir string) *exec.Cmd {
|
||||
t.Helper()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := "127.0.0.1:0"
|
||||
cmd := exec.Command(exe, "-test.run=TestRealProcessMatrix", "-test.v=false",
|
||||
"-test.timeout=120s")
|
||||
cmd.Env = append(os.Environ(),
|
||||
procRoleEnv+"="+tag,
|
||||
procAddrEnv+"="+addr,
|
||||
"AGENT_PG_TEST_DSN="+dsn,
|
||||
)
|
||||
cmd.Dir = dataDir
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe %s: %v", tag, err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start proc %s: %v", tag, err)
|
||||
}
|
||||
// 就绪信号:子进程 stderr 打 READY 后驻留;父进程读一行即继续。
|
||||
ch := make(chan string, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 512)
|
||||
n, _ := stderr.Read(buf)
|
||||
ch <- string(buf[:n])
|
||||
}()
|
||||
select {
|
||||
case line := <-ch:
|
||||
if !strings.Contains(line, "U5PROC READY") {
|
||||
t.Logf("proc %s first stderr: %q", tag, line)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatalf("proc %s 未就绪", tag)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// childServe 子进程入口:真实装配一个 PG 档 store + 命令消费循环,驻留至
|
||||
// 被杀(父进程 Cleanup)。这一层保证「两真实进程各自跑着消费循环」的形态。
|
||||
func childServe(t *testing.T, role string) {
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
st, err := store.OpenPostgres(dsn, t.TempDir()+"/art")
|
||||
if err != nil {
|
||||
os.Stderr.WriteString(fmt.Sprintf("proc %s store: %v\n", role, err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
_ = store.NewID("boot")
|
||||
// 驻留:父进程控制生命周期。就绪信号经 stderr。
|
||||
os.Stderr.WriteString(fmt.Sprintf("U5PROC READY %s\n", role))
|
||||
for {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 协作存储 U3:恢复隔离(recovery hold)。未知外部副作用的 run 不得被启动
|
||||
// 续跑自动重派;人工核对后显式解除才恢复。判定失败同样跳过(fail-closed)。
|
||||
|
||||
// hold 中的子 run:续跑跳过;解除后续跑恢复。
|
||||
func TestRecoveryHoldBlocksResumeUntilReleased(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
|
||||
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
|
||||
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, err := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// run 级 hold:跳过。
|
||||
if err := st.SetRecoveryHold(child.ID, child.Owner, string(child.ConversationID), "shell 命令超时,副作用结果未知"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids, err := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != len(before) {
|
||||
t.Fatalf("hold 未解除时续跑应跳过:before=%v after=%v", before, ids)
|
||||
}
|
||||
// resumed.json 不得写出(下次解除后仍可续跑)。
|
||||
dir := c.mgr.subagentDir(child.Owner, child.ConversationID, child.ID)
|
||||
if _, err := os.Stat(filepath.Join(dir, "resumed.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("hold 跳过不应写 resumed.json:%v", err)
|
||||
}
|
||||
holds, err := st.ListOpenRecoveryHolds()
|
||||
if err != nil || len(holds) != 1 || holds[0].RunID != child.ID {
|
||||
t.Fatalf("open holds=%v err=%v", holds, err)
|
||||
}
|
||||
|
||||
// 解除(人工核对后):续跑恢复。
|
||||
if err := st.ReleaseRecoveryHold(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids, err = st.SubagentRunIDs(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != len(before)+1 {
|
||||
t.Fatalf("解除后续跑应新增子 run:before=%v after=%v", before, ids)
|
||||
}
|
||||
if held, herr := st.HasOpenRecoveryHold(child.ID, string(child.ConversationID)); held || herr != nil {
|
||||
t.Fatalf("解除后不应再有 hold:held=%v err=%v", held, herr)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话级 hold:同会话的其他中断子 run 一并跳过(副作用疑点未核对前,
|
||||
// 整个会话的自动重派都冻结)。
|
||||
func TestRecoveryHoldConversationScope(t *testing.T) {
|
||||
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
|
||||
c, st := newSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
ws := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(ws)
|
||||
|
||||
parentRun := c.run
|
||||
if err := st.CreateRun(&parentRun); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
|
||||
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
|
||||
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
// hold 挂在「另一个」run 上、但同会话。
|
||||
other := string(c.run.ConversationID)
|
||||
if err := st.SetRecoveryHold("run_other", c.run.Owner, other, "会话存在未知副作用,整体冻结"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.ResumeInterruptedChildren()
|
||||
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
|
||||
if len(ids) != len(before) {
|
||||
t.Fatalf("会话级 hold 应跳过续跑:before=%v after=%v", before, ids)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,12 @@ func openRecoveryStore(t *testing.T) *store.Store {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// pg_trgm 预装 public(幂等):避免 v4 扩展落进用例 schema 后随 CASCADE
|
||||
// 删除造成的并行竞态(见 store 包 openTestStore 同名注释)。
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("preinstall pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create schema: %v", err)
|
||||
@@ -42,7 +48,10 @@ func openRecoveryStore(t *testing.T) *store.Store {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
st, err := store.OpenPostgres(dsn+"&search_path="+schema, filepath.Join(t.TempDir(), "artifacts"))
|
||||
// 共用 store 的 DSN 运行参数拼接(修复盲拼 "&search_path=":对无查询串的
|
||||
// URL DSN 会把参数并进库名,连不上库——svc.sh 形态 DSN 的既有缺陷)。
|
||||
schemaDSN := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
|
||||
st, err := store.OpenPostgres(schemaDSN, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+505
-41
@@ -68,8 +68,13 @@ type Manager struct {
|
||||
active map[string]*runHandle
|
||||
|
||||
// 会话内串行(设计并发策略):一个会话同时至多一个在途 conversation run。
|
||||
convMu sync.Mutex
|
||||
convActive map[string]bool // conversationID → 有在途 run
|
||||
convMu sync.Mutex
|
||||
convActive map[string]bool // conversationID → 有在途 run
|
||||
adm *admissionGate // 执行许可门(U3:预算默认关闭时零开销)
|
||||
instanceTag string // 执行身份(U5:多实例互异;空=runmgr:addr)
|
||||
addr string // 监听地址(身份退化用)
|
||||
execMu sync.Mutex // 执行所有权(U5:DB 租约的本地代次登记)
|
||||
execEpoch map[string]int64 // conversationID → 本实例当前 epoch
|
||||
|
||||
instanceID string
|
||||
shuttingDown atomic.Bool
|
||||
@@ -80,18 +85,44 @@ type Manager struct {
|
||||
bgInFlight map[string]int
|
||||
delivering map[string]bool
|
||||
|
||||
// 材料整理内容键注册表(附件即保证):会话 → 活跃材料键。卡片 KV 是
|
||||
// 重启后的持久兜底,这里只是热路径的快速去重。
|
||||
materialMu sync.Mutex
|
||||
materialActive map[string]map[string]bool
|
||||
|
||||
// 后台排队(I3):会话满 3 时的系统派发任务先建卡(排队中)入队,
|
||||
// 槽位释放按 FIFO 启动。模型 delegate_task 仍硬拒绝,不入队。
|
||||
bgQueue map[string][]bgQueueItem
|
||||
|
||||
allowMu sync.Mutex
|
||||
sessionAllow map[string]map[string]bool
|
||||
|
||||
// genuiAuto:Generative UI 自动建议闸门(nil = 未接线;默认关闭行为)。
|
||||
// 仅由任务阶段/材料工件语义事件调用;present/surface/action 不得接入。
|
||||
genuiAuto GenUIAutoTrigger
|
||||
}
|
||||
|
||||
// GenUIAutoTrigger 自动建议页面闸门(由 genui.Auto 实现)。
|
||||
type GenUIAutoTrigger interface {
|
||||
Try(owner, convID, reason string) (ok bool, skip string)
|
||||
}
|
||||
|
||||
const maxBackgroundChildren = 3
|
||||
|
||||
// bgQueueItem 一个等待槽位的系统派发后台任务(建卡先行,子 run 启动延后)。
|
||||
type bgQueueItem struct {
|
||||
parent core.Run
|
||||
opts bgSpawnOpts
|
||||
card core.ActivityCard
|
||||
}
|
||||
|
||||
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
|
||||
type bgNotice struct {
|
||||
id string // 持久化队列主键(重启重投凭据)
|
||||
owner string
|
||||
name string
|
||||
payload core.AgentResultPayload
|
||||
id string // 持久化队列主键(重启重投凭据)
|
||||
owner string
|
||||
name string
|
||||
payload core.AgentResultPayload
|
||||
material bool // 材料整理任务:通知回合要衔接交付物选择(I2 消费)
|
||||
}
|
||||
|
||||
// ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。
|
||||
@@ -122,9 +153,10 @@ func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service,
|
||||
return &Manager{
|
||||
st: st, bus: b, registry: reg, profiles: p, settings: s,
|
||||
runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner,
|
||||
active: map[string]*runHandle{}, convActive: map[string]bool{},
|
||||
active: map[string]*runHandle{}, convActive: map[string]bool{}, execEpoch: map[string]int64{},
|
||||
notices: map[string][]bgNotice{}, bgInFlight: map[string]int{}, delivering: map[string]bool{},
|
||||
events: newEventBuffer(st, b),
|
||||
adm: newAdmissionGate(nil, 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +203,27 @@ func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
|
||||
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
|
||||
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||||
|
||||
// SetExecutionBudgetFn 注入执行预算读取(U3,R8):fn 返回当前预算(全零=
|
||||
// 不限,本地档默认);queueMax 为待执行队列上限(<=0 走默认 200)。
|
||||
func (m *Manager) SetExecutionBudgetFn(fn func() store.ExecutionBudget, queueMax int) {
|
||||
if m.adm == nil {
|
||||
m.adm = newAdmissionGate(fn, queueMax) // 直接构造的 Manager(测试)
|
||||
return
|
||||
}
|
||||
m.adm.mu.Lock()
|
||||
m.adm.budgetFn = fn
|
||||
if queueMax > 0 {
|
||||
m.adm.queueMax = queueMax
|
||||
}
|
||||
m.adm.mu.Unlock()
|
||||
}
|
||||
|
||||
// QueueFull 非阻塞探测待执行队列是否已满(API 边缘 429 拒绝,不落库)。
|
||||
func (m *Manager) QueueFull() bool { return m.adm.QueueFull() }
|
||||
|
||||
// QueueDepth 当前排队数(诊断)。
|
||||
func (m *Manager) QueueDepth() int { return m.adm.QueueDepth() }
|
||||
|
||||
// SetWorkspaceRoot 注入 fs 预留能力(Hidden 工具族)的沙箱根目录。
|
||||
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
|
||||
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
|
||||
@@ -180,6 +233,8 @@ func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
|
||||
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
|
||||
type CommandSource interface {
|
||||
CommandTools() []CommandTool
|
||||
// CommandToolsFor 按 owner 过滤工具投影(U3:禁用即不注入能力)。
|
||||
CommandToolsFor(owner string) []CommandTool
|
||||
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
|
||||
}
|
||||
|
||||
@@ -230,9 +285,35 @@ type runCtx struct {
|
||||
subTask string
|
||||
subHint string
|
||||
subAgent string
|
||||
subModel string // 系统派发覆盖("chat" = 子代理用对话同档;空 = 按 spec)
|
||||
launch *workflow.Launch
|
||||
files *filestate.Tracker
|
||||
permissionMode string
|
||||
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
|
||||
materialNote string
|
||||
materialEnsured bool
|
||||
}
|
||||
|
||||
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌;
|
||||
// 最近 5 页、新→旧;读路径不受启禁影响——禁用挡写入,不丢历史)。
|
||||
func (c *runCtx) Surfaces() []core.SurfaceSnapshot {
|
||||
if c.mgr.st == nil || c.run.ConversationID == "" || c.run.Owner == "" {
|
||||
return nil
|
||||
}
|
||||
metas, err := c.mgr.st.ListSurfaces(string(c.run.Owner), string(c.run.ConversationID))
|
||||
if err != nil || len(metas) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(metas) > 5 {
|
||||
metas = metas[:5]
|
||||
}
|
||||
out := make([]core.SurfaceSnapshot, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
if snap, err := c.mgr.st.GetSurfaceSnapshot(string(c.run.Owner), string(m.SurfaceID), 0); err == nil {
|
||||
out = append(out, snap)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
|
||||
@@ -414,7 +495,7 @@ func (c *runCtx) Tools() []core.ToolDesc {
|
||||
for _, t := range list {
|
||||
seen[t.Name] = true
|
||||
}
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
|
||||
if seen[ct.ID] {
|
||||
continue
|
||||
}
|
||||
@@ -553,6 +634,14 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
if card.TimeoutSec != nil && *card.TimeoutSec > 0 {
|
||||
timeout = time.Duration(*card.TimeoutSec) * time.Second
|
||||
}
|
||||
// HITL 等待让出执行许可(U3:等待审批不占执行额度;应答后重取——可能
|
||||
// 排队,取消语境下重取失败由取消路径收口)。
|
||||
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
|
||||
defer func() {
|
||||
if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil {
|
||||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err)
|
||||
}
|
||||
}()
|
||||
return waitHITL(c.ctx, c.h.hitlCh, timeout)
|
||||
}
|
||||
|
||||
@@ -593,12 +682,19 @@ func (c *runCtx) SubagentID() string { return c.subAgent }
|
||||
|
||||
func (c *runCtx) Subhint() string { return c.subHint }
|
||||
|
||||
// Submodel 系统派发的模型档覆盖(材料整理用对话同档;空 = 子代理 spec 默认)。
|
||||
func (c *runCtx) Submodel() string { return c.subModel }
|
||||
|
||||
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
|
||||
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
|
||||
p.PlanID = c.run.ConversationID
|
||||
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
|
||||
if err == nil && planStageChanged(prev, p) {
|
||||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "plan_stage")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -621,11 +717,15 @@ func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
|
||||
}
|
||||
|
||||
func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||||
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
|
||||
l.ConversationID = c.run.ConversationID
|
||||
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
|
||||
if err == nil && todoStageChanged(prev, l) {
|
||||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "todo_stage")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -749,6 +849,9 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
|
||||
return art, err
|
||||
}
|
||||
if kind == "document" {
|
||||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "artifact_document")
|
||||
}
|
||||
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
|
||||
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
|
||||
if kind == "document" && len(content) < 200000 {
|
||||
@@ -843,7 +946,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||||
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
|
||||
if c.mgr.commands != nil {
|
||||
for _, ct := range c.mgr.commands.CommandTools() {
|
||||
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
|
||||
if ct.ID != call.Name {
|
||||
continue
|
||||
}
|
||||
@@ -1081,7 +1184,39 @@ func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[stri
|
||||
|
||||
// ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。
|
||||
func (m *Manager) ClaimConversation(conversationID string) error {
|
||||
return m.claimConversation(conversationID)
|
||||
if err := m.claimConversation(conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的
|
||||
// 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。
|
||||
if m.st.Dialect() == "postgres" {
|
||||
epoch, err := m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
|
||||
if err != nil {
|
||||
m.releaseConversation(conversationID)
|
||||
if errors.Is(err, store.ErrExecutionBusy) {
|
||||
return ErrConversationBusy
|
||||
}
|
||||
return err
|
||||
}
|
||||
m.execMu.Lock()
|
||||
m.execEpoch[conversationID] = epoch
|
||||
m.execMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstanceID 本进程执行身份(组合根可经 SetInstanceTag 覆盖;多实例必须互异)。
|
||||
func (m *Manager) InstanceID() string {
|
||||
if m.instanceTag != "" {
|
||||
return m.instanceTag
|
||||
}
|
||||
return "runmgr:" + m.addr
|
||||
}
|
||||
|
||||
// SetInstanceTag 注入稳定实例身份与监听地址(组合根)。
|
||||
func (m *Manager) SetInstanceTag(tag, addr string) {
|
||||
m.instanceTag = tag
|
||||
m.addr = addr
|
||||
}
|
||||
|
||||
// ReleaseConversation 释放已抢到的槽位。启动失败时调用;重复释放无效果。
|
||||
@@ -1145,7 +1280,27 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
||||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||||
go m.execute(rc, runner)
|
||||
// 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合;
|
||||
// 其余带附件回合由 runtime stop 钩子兜底(模型已自行委派时尊重模型判断)。
|
||||
if launch == nil {
|
||||
rc.materialNote = m.materialIntake(rc)
|
||||
}
|
||||
go func() {
|
||||
// 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以
|
||||
// failed 收口——不执行任何模型调用,副作用无从产生。
|
||||
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner); err != nil {
|
||||
log.Printf("[runmgr] admission denied run %s: %v", run.ID, err)
|
||||
m.setRunStatus(&run, core.RunFailed, core.PhaseThinking)
|
||||
if uErr, ok := err.(error); ok {
|
||||
m.st.UpdateRun(&core.Run{ID: run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: uErr.Error()}})
|
||||
}
|
||||
m.emit(run.ConversationID, run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "执行预算排队已满或已取消"})
|
||||
m.releaseConversation(run.ConversationID)
|
||||
return
|
||||
}
|
||||
defer m.adm.release(m.st, run.ID)
|
||||
m.execute(rc, runner)
|
||||
}()
|
||||
return run, nil
|
||||
}
|
||||
|
||||
@@ -1154,6 +1309,17 @@ func (m *Manager) releaseConversation(conversationID string) {
|
||||
m.convMu.Lock()
|
||||
delete(m.convActive, conversationID)
|
||||
m.convMu.Unlock()
|
||||
if m.st != nil && m.st.Dialect() == "postgres" {
|
||||
m.execMu.Lock()
|
||||
epoch, ok := m.execEpoch[conversationID]
|
||||
if ok {
|
||||
delete(m.execEpoch, conversationID)
|
||||
}
|
||||
m.execMu.Unlock()
|
||||
if ok {
|
||||
m.st.ReleaseExecution(conversationID, m.InstanceID(), epoch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
|
||||
@@ -1225,12 +1391,19 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
msg.VariantIndex = index
|
||||
}
|
||||
}
|
||||
updated, err := m.st.SettleRun(&rc.run, msg)
|
||||
updated, fenced, err := m.settleRunWithFence(rc, msg)
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] settle run %s: %v", rc.run.ID, err)
|
||||
m.failSettle(rc, err)
|
||||
return
|
||||
}
|
||||
if fenced {
|
||||
// 旧执行代次(U5/KTD4):租约已被他实例接管——本地不写终态;
|
||||
// run 归属已移交,发围栏事件后静默退出(迟到副作用由此可见)。
|
||||
log.Printf("[runmgr] settle run %s fenced: execution taken over (conv %s)", rc.run.ID, rc.run.ConversationID)
|
||||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.fenced", Message: "执行权已被其他实例接管"})
|
||||
return
|
||||
}
|
||||
if m.OnUsage != nil && usage.TotalTokens > 0 {
|
||||
m.OnUsage(rc.run.Owner, usage.TotalTokens)
|
||||
}
|
||||
@@ -1323,6 +1496,39 @@ func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, p
|
||||
return m.events.emit(conversationID, runID, typ, payload)
|
||||
}
|
||||
|
||||
// settleRunWithFence(U5):PG 档以本实例登记的 epoch 做围栏结算。
|
||||
func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fenced bool, err error) {
|
||||
if m.st.Dialect() != "postgres" {
|
||||
updated, err = m.st.SettleRun(&rc.run, msg)
|
||||
return updated, false, err
|
||||
}
|
||||
m.execMu.Lock()
|
||||
epoch, ok := m.execEpoch[string(rc.run.ConversationID)]
|
||||
m.execMu.Unlock()
|
||||
if !ok {
|
||||
// 未登记(旧路径起跑):按库内当前代次校验(同实例放行)。
|
||||
if es, gerr := m.st.GetExecution(string(rc.run.ConversationID)); gerr == nil {
|
||||
epoch = es.Epoch
|
||||
}
|
||||
}
|
||||
return m.st.SettleRunFenced(&rc.run, msg, m.InstanceID(), epoch)
|
||||
}
|
||||
|
||||
// RunActiveLocally run 是否在本实例活跃执行(U5:跨实例命令路由判定)。
|
||||
func (m *Manager) RunActiveLocally(runID string) bool {
|
||||
root := runID
|
||||
if r, err := m.st.GetRunByID(runID); err == nil && r.ParentRunID != "" {
|
||||
root = r.ParentRunID
|
||||
}
|
||||
m.mu.Lock()
|
||||
_, ok := m.active[root]
|
||||
if !ok {
|
||||
_, ok = m.active[runID]
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// CancelRun 取消 run 及其子 run(ctx 级联)。
|
||||
func (m *Manager) CancelRun(runID string) error {
|
||||
r, err := m.st.GetRunByID(runID)
|
||||
@@ -1375,6 +1581,16 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
|
||||
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
|
||||
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
|
||||
// run 只能等 HITL 超时。此处提前返回未决错误,让命令总线转交执行实例。
|
||||
m.mu.Lock()
|
||||
_, local := m.active[run.ID]
|
||||
m.mu.Unlock()
|
||||
if !local {
|
||||
return "", false, fmt.Errorf("runmgr: run %s not executing locally (cross-instance resolve goes through command bus)", run.ID)
|
||||
}
|
||||
|
||||
kind := "choice"
|
||||
answer := ""
|
||||
@@ -1455,12 +1671,34 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agen
|
||||
if err != nil {
|
||||
return core.AgentResultPayload{}, err
|
||||
}
|
||||
// 父子互等规避(U3,R8):同步子代理阻塞父 run——父先让出执行许可,子
|
||||
// 获取自己的许可(与父同池计入全局预算),子完成后父重取(可能排队;
|
||||
// 取消语境下重取失败仅记录,run 由取消路径收口)。
|
||||
m.adm.release(m.st, string(parent.ID))
|
||||
defer func() {
|
||||
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner); err != nil {
|
||||
log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err)
|
||||
}
|
||||
}()
|
||||
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner); err != nil {
|
||||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"})
|
||||
return core.AgentResultPayload{}, err
|
||||
}
|
||||
defer m.adm.release(m.st, rc.run.ID)
|
||||
return m.finishChild(rc, runner, true)
|
||||
}
|
||||
|
||||
// startChild 做深度检查、建 run、发 spawn 事件。detach 时子 run 使用独立 ctx,
|
||||
// 父 run 结束不会取消它;cancel 仍登记在 active 里,CancelRun 和停机中断照常生效。
|
||||
func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID, name, task, hint string, detach bool) (*runCtx, runtime.Runner, error) {
|
||||
return m.startChildOpts(parentCtx, parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint}, detach)
|
||||
}
|
||||
|
||||
// startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。
|
||||
func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) {
|
||||
agentID, name, task, hint := o.agentID, o.name, o.task, o.hint
|
||||
if parent.Kind == core.RunSubagent {
|
||||
return nil, nil, fmt.Errorf("子代理不能再派发子代理")
|
||||
}
|
||||
@@ -1491,7 +1729,7 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
|
||||
|
||||
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
|
||||
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
|
||||
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
|
||||
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
|
||||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
|
||||
|
||||
runner := m.subRunner
|
||||
@@ -1577,44 +1815,220 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发
|
||||
// (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。
|
||||
type bgSpawnOpts struct {
|
||||
agentID, name, task, hint string
|
||||
model string // "chat" = 子代理用对话同档模型(材料整理)
|
||||
kv [][2]string
|
||||
taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片)
|
||||
material bool // 材料整理任务(通知回合衔接交付物选择)
|
||||
queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝)
|
||||
}
|
||||
|
||||
func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) {
|
||||
return m.spawnBackgroundOpts(parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint})
|
||||
}
|
||||
|
||||
func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) {
|
||||
if parent.Kind == core.RunSubagent {
|
||||
return "", fmt.Errorf("子代理不能再派发子代理")
|
||||
}
|
||||
if o.taskKey != "" {
|
||||
convID := string(parent.ConversationID)
|
||||
m.materialMu.Lock()
|
||||
active := m.materialActive != nil && m.materialActive[convID][o.taskKey]
|
||||
m.materialMu.Unlock()
|
||||
if active {
|
||||
return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12))
|
||||
}
|
||||
}
|
||||
conv := parent.ConversationID
|
||||
m.noticeMu.Lock()
|
||||
if m.bgInFlight == nil {
|
||||
m.bgInFlight = map[string]int{}
|
||||
}
|
||||
if m.bgInFlight[parent.ConversationID] >= maxBackgroundChildren {
|
||||
if m.bgInFlight[conv] >= maxBackgroundChildren {
|
||||
if !o.queue {
|
||||
m.noticeMu.Unlock()
|
||||
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
|
||||
}
|
||||
if m.bgQueue == nil {
|
||||
m.bgQueue = map[string][]bgQueueItem{}
|
||||
}
|
||||
m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o})
|
||||
m.noticeMu.Unlock()
|
||||
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
|
||||
// 排队卡先行(用户立即可见排队态);SourceRunID 与执行态在槽位
|
||||
// 释放启动时回填。入队与建卡之间释放槽位的窗口由 pump 兜住:
|
||||
// 下一次 decBackground 会尝试启动它。
|
||||
card := m.createBackgroundCard(parent, "", o.name, o.task, o.kv, fmt.Sprintf("排队中(本会话已有 %d 个后台任务,释放后自动开始)", maxBackgroundChildren))
|
||||
m.noticeMu.Lock()
|
||||
if q := m.bgQueue[conv]; len(q) > 0 {
|
||||
q[len(q)-1].card = card
|
||||
}
|
||||
m.noticeMu.Unlock()
|
||||
return "", nil
|
||||
}
|
||||
m.bgInFlight[parent.ConversationID]++
|
||||
m.bgInFlight[conv]++
|
||||
m.noticeMu.Unlock()
|
||||
return m.startBackgroundSlot(parent, o, core.ActivityCard{})
|
||||
}
|
||||
|
||||
rc, runner, err := m.startChild(context.Background(), parent, agentID, name, task, hint, true)
|
||||
// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。
|
||||
// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。
|
||||
func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) {
|
||||
rc, runner, err := m.startChildOpts(context.Background(), parent, o, true)
|
||||
if err != nil {
|
||||
m.decBackground(parent.ConversationID)
|
||||
return "", err
|
||||
}
|
||||
if o.taskKey != "" {
|
||||
m.materialKeyRegister(string(parent.ConversationID), o.taskKey)
|
||||
}
|
||||
// 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡,
|
||||
// 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。
|
||||
bgCard := preCard
|
||||
if bgCard.ID == "" {
|
||||
bgCard = m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv, "")
|
||||
} else {
|
||||
m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID)
|
||||
}
|
||||
kv := o.kv
|
||||
go func() {
|
||||
defer m.decBackground(parent.ConversationID)
|
||||
payload, _ := m.finishChild(rc, runner, false)
|
||||
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: name, payload: payload})
|
||||
if o.taskKey != "" {
|
||||
defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey)
|
||||
}
|
||||
// 后台子代理同样计入执行预算(U3:子代理预算计入全局)。
|
||||
if err := m.adm.acquire(context.Background(), m.st, rc.run.ID, parent.Owner); err != nil {
|
||||
log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err)
|
||||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "后台子代理执行许可排队已满或已取消"})
|
||||
m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, err, kv)
|
||||
return
|
||||
}
|
||||
defer m.adm.release(m.st, rc.run.ID)
|
||||
payload, err := m.finishChild(rc, runner, false)
|
||||
m.finishBackgroundCard(parent.Owner, bgCard, payload, err, kv)
|
||||
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: o.name, payload: payload, material: o.material})
|
||||
m.deliverNotices(parent.ConversationID)
|
||||
}()
|
||||
return rc.run.ID, nil
|
||||
}
|
||||
|
||||
// pumpBackgroundQueue 槽位释放后按 FIFO 启动排队任务(decBackground 尾调)。
|
||||
func (m *Manager) pumpBackgroundQueue(conv string) {
|
||||
m.noticeMu.Lock()
|
||||
if len(m.bgQueue[conv]) == 0 || m.bgInFlight[conv] >= maxBackgroundChildren {
|
||||
m.noticeMu.Unlock()
|
||||
return
|
||||
}
|
||||
item := m.bgQueue[conv][0]
|
||||
m.bgQueue[conv] = m.bgQueue[conv][1:]
|
||||
m.bgInFlight[conv]++
|
||||
m.noticeMu.Unlock()
|
||||
if _, err := m.startBackgroundSlot(item.parent, item.opts, item.card); err != nil {
|
||||
log.Printf("[runmgr] background queue start: %v", err)
|
||||
// 启动失败也要给排队卡一个终态,不能永久停在排队中。
|
||||
m.finishBackgroundCard(item.parent.Owner, item.card, core.AgentResultPayload{}, err, item.opts.kv)
|
||||
}
|
||||
}
|
||||
|
||||
// createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil)
|
||||
// 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。
|
||||
// kv 为系统派发任务的附加键值(材料键等),随完成回投保留;status 非空时
|
||||
// 覆盖默认状态行(排队态文案)。
|
||||
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) core.ActivityCard {
|
||||
if m.cards == nil {
|
||||
return core.ActivityCard{}
|
||||
}
|
||||
title := strings.TrimSpace(name)
|
||||
if title == "" {
|
||||
title = "后台任务"
|
||||
}
|
||||
if status == "" {
|
||||
status = "执行中,完成后自动回投结果"
|
||||
}
|
||||
body := []core.UINode{
|
||||
{Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)},
|
||||
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", status})},
|
||||
}
|
||||
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
|
||||
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
|
||||
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
|
||||
Body: body,
|
||||
}, "")
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] background card create: %v", err)
|
||||
return core.ActivityCard{}
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
// promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。
|
||||
func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) {
|
||||
if m.cards == nil || card.ID == "" {
|
||||
return
|
||||
}
|
||||
card.SourceRunID = childID
|
||||
for i, node := range card.Body {
|
||||
if node.Type != core.UIKV {
|
||||
continue
|
||||
}
|
||||
for j, kv := range node.KV {
|
||||
if kv[0] == "状态" {
|
||||
card.Body[i].KV[j][1] = "执行中,完成后自动回投结果"
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.cards.Update(owner, card); err != nil {
|
||||
log.Printf("[runmgr] queued card promote: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
|
||||
// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。
|
||||
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
|
||||
if m.cards == nil || card.ID == "" {
|
||||
return
|
||||
}
|
||||
summary := strings.TrimSpace(payload.Summary)
|
||||
tone := "ok"
|
||||
if err != nil {
|
||||
tone = "danger"
|
||||
if summary == "" {
|
||||
summary = err.Error()
|
||||
}
|
||||
summary = "执行失败:" + summary
|
||||
}
|
||||
if summary == "" {
|
||||
summary = "(无结果摘要)"
|
||||
}
|
||||
card.Body = []core.UINode{
|
||||
{Type: core.UIMarkdown, Markdown: summary},
|
||||
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "已完成"})},
|
||||
}
|
||||
if uerr := m.cards.Update(owner, card); uerr != nil {
|
||||
log.Printf("[runmgr] background card update: %v", uerr)
|
||||
}
|
||||
if _, serr := m.cards.SetStatus(owner, card.ID, core.CardDone, tone, ""); serr != nil {
|
||||
log.Printf("[runmgr] background card status: %v", serr)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) decBackground(convID string) {
|
||||
m.noticeMu.Lock()
|
||||
defer m.noticeMu.Unlock()
|
||||
if m.bgInFlight == nil {
|
||||
m.noticeMu.Unlock()
|
||||
return
|
||||
}
|
||||
if m.bgInFlight[convID] > 0 {
|
||||
m.bgInFlight[convID]--
|
||||
}
|
||||
m.noticeMu.Unlock()
|
||||
// 槽位释放:按 FIFO 启动排队的系统任务(I3)。
|
||||
m.pumpBackgroundQueue(convID)
|
||||
}
|
||||
|
||||
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
|
||||
@@ -1690,34 +2104,44 @@ func (m *Manager) deliverNotices(convID string) {
|
||||
defer func() {
|
||||
m.noticeMu.Lock()
|
||||
delete(m.delivering, convID)
|
||||
more := len(m.notices[convID]) > 0
|
||||
m.noticeMu.Unlock()
|
||||
if more {
|
||||
m.deliverNotices(convID)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := m.startNoticeRun(convID, items); err != nil {
|
||||
// 迭代而非递归(U3 真库门禁实测):原实现的 defer 在投递失败重新入队后
|
||||
// 同步再入 deliverNotices——持续失败(如会话忙/查库错误)时无限自递归,
|
||||
// test-pg 慢速时序下栈溢出(job 2352)。失败本轮即返回:队列保留,
|
||||
// 由下一次 enqueueNotice 或重启重投驱动;投递期间新到的通知由循环
|
||||
// 尾部再取,不再经 defer 递归。
|
||||
for {
|
||||
if err := m.startNoticeRun(convID, items); err != nil {
|
||||
m.noticeMu.Lock()
|
||||
if m.notices == nil {
|
||||
m.notices = map[string][]bgNotice{}
|
||||
}
|
||||
m.notices[convID] = append(items, m.notices[convID]...)
|
||||
m.noticeMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 投递成功才删持久化行(失败保留下次重投)。
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, n := range items {
|
||||
if n.id != "" {
|
||||
ids = append(ids, n.id)
|
||||
}
|
||||
}
|
||||
if err := m.st.DeleteNotices(ids); err != nil {
|
||||
log.Printf("[runmgr] notice delete: %v", err)
|
||||
}
|
||||
m.noticeMu.Lock()
|
||||
if m.notices == nil {
|
||||
m.notices = map[string][]bgNotice{}
|
||||
items = append([]bgNotice{}, m.notices[convID]...)
|
||||
if len(items) == 0 {
|
||||
m.noticeMu.Unlock()
|
||||
return
|
||||
}
|
||||
m.notices[convID] = append(items, m.notices[convID]...)
|
||||
delete(m.notices, convID)
|
||||
m.noticeMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 投递成功才删持久化行(失败保留下次重投)。
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, n := range items {
|
||||
if n.id != "" {
|
||||
ids = append(ids, n.id)
|
||||
}
|
||||
}
|
||||
if err := m.st.DeleteNotices(ids); err != nil {
|
||||
log.Printf("[runmgr] notice delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
@@ -1736,6 +2160,25 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
|
||||
for _, n := range items {
|
||||
fmt.Fprintf(&b, "后台子代理「%s」已结束。\n状态:%s\n%s\n", n.name, n.payload.Status, strings.TrimSpace(n.payload.Summary))
|
||||
}
|
||||
// 材料整理完成 → 交付物选择职责(I2):汇报要点后立即发起待确认选择卡。
|
||||
// runtime 侧对不发 confirm_choice 的弱模型另有 stop 兜底(一次性)。
|
||||
materialSeen := false
|
||||
for _, n := range items {
|
||||
if n.material {
|
||||
materialSeen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if materialSeen {
|
||||
b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n")
|
||||
if m.genuiAuto != nil {
|
||||
if ok, _ := m.genuiAuto.Try(owner, convID, "material_done"); ok {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(genUIAutoReminderText())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("</system-reminder>")
|
||||
msg := core.Message{
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
@@ -1839,13 +2282,34 @@ func partsText(parts []core.Part) string {
|
||||
var sb strings.Builder
|
||||
for _, p := range parts {
|
||||
if p.Type == core.PartText {
|
||||
sb.WriteString(p.Text)
|
||||
text := p.Text
|
||||
// MiniMax 等模型把推理写成行内 <think> 块:子代理摘要/回投卡正文
|
||||
// 只取 think 之外的正文(实测 <think> 前缀泄漏进收件箱卡片与通知)。
|
||||
if body := stripInlineThink(text); strings.TrimSpace(body) != "" || !strings.Contains(text, "</think>") {
|
||||
text = body
|
||||
}
|
||||
sb.WriteString(text)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// stripInlineThink 去掉文本中的 <think>…</think> 段(完整闭合才剥,半截保守保留)。
|
||||
func stripInlineThink(text string) string {
|
||||
for {
|
||||
start := strings.Index(text, "<think>")
|
||||
if start < 0 {
|
||||
return text
|
||||
}
|
||||
end := strings.Index(text[start:], "</think>")
|
||||
if end < 0 {
|
||||
return text
|
||||
}
|
||||
text = text[:start] + text[start+end+len("</think>"):]
|
||||
}
|
||||
}
|
||||
|
||||
func artifactSummary(parts []core.Part) string {
|
||||
var bits []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -128,6 +128,16 @@ func (m *Manager) ResumeInterruptedChildren() {
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
// 恢复隔离(协作存储 U3):未知外部副作用的 run 不自动续跑——hold
|
||||
// 未解除即跳过;判定出错同样跳过(fail-closed:宁可人工核对,不盲重派)。
|
||||
if held, herr := m.st.HasOpenRecoveryHold(string(run.ID), string(run.ConversationID)); herr != nil || held {
|
||||
if herr != nil {
|
||||
log.Printf("[runmgr] resume skip %s: recovery hold 判定失败(fail-closed): %v", run.ID, herr)
|
||||
} else {
|
||||
log.Printf("[runmgr] resume skip %s: recovery hold 未解除(人工核对后显式释放才会续跑)", run.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
||||
if dir == "" {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 双速提醒经内核 Reminders 钩子进入下一请求的消息(不是响应部件):
|
||||
// 这里捕获每次模型请求的原文,检索提醒标记。
|
||||
type dualSpeedRecorder struct {
|
||||
clock *stepClock
|
||||
turns [][]agentllm.Event
|
||||
request []string
|
||||
base agentllm.StreamFunc
|
||||
}
|
||||
|
||||
// stepClock 每 tick 前进 5 秒;runDirect 起 0 时刻,模型每被调用一次 tick 一格,
|
||||
// 20 次调用即 100 秒量级,足以压过默认 75 秒阈值。
|
||||
type stepClock struct{ step int64 }
|
||||
|
||||
func (c *stepClock) now() time.Time { return time.Unix(0, c.step*int64(5*time.Second)) }
|
||||
func (c *stepClock) tick() { c.step++ }
|
||||
|
||||
func (rec *dualSpeedRecorder) stream(base agentllm.StreamFunc) agentllm.StreamFunc {
|
||||
return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
rec.clock.tick()
|
||||
for _, m := range msgs {
|
||||
if b, err := json.Marshal(m); err == nil {
|
||||
rec.request = append(rec.request, string(b))
|
||||
}
|
||||
}
|
||||
return base(ctx, model, msgs, tools, opts)
|
||||
}
|
||||
}
|
||||
|
||||
func (rec *dualSpeedRecorder) nudged() bool {
|
||||
for _, r := range rec.request {
|
||||
if strings.Contains(r, "任务快慢双速") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runDualSpeed(t *testing.T, turns [][]agentllm.Event, setup func(rc *fakeRunCtx)) *dualSpeedRecorder {
|
||||
t.Helper()
|
||||
rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns}
|
||||
r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now}
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}
|
||||
if setup != nil {
|
||||
setup(rc)
|
||||
}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func toolOnlyTurns(n int) [][]agentllm.Event {
|
||||
var turns [][]agentllm.Event
|
||||
for i := 0; i < n; i++ {
|
||||
// 每次调用用独立 ID(toolCall 帮手的 ID 固定,重复会撞内核的工具槽位)。
|
||||
call := agentllm.ToolCall{ID: fmt.Sprintf("c-ds-%d", i), Name: "make_report", Arguments: "{}"}
|
||||
turns = append(turns, textDeltaTurn("", call))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
// 长时间纯工具轮、用户零答复 → 阈值后恰好注入一次双速提醒。
|
||||
func TestDualSpeedNudgeFiresOnceAfterThreshold(t *testing.T) {
|
||||
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
|
||||
rec := runDualSpeed(t, turns, nil)
|
||||
if !rec.nudged() {
|
||||
t.Fatal("dual-speed nudge missing after threshold with no user reply")
|
||||
}
|
||||
count := 0
|
||||
for _, r := range rec.request {
|
||||
count += strings.Count(r, "任务快慢双速")
|
||||
}
|
||||
if count > 1 {
|
||||
t.Fatalf("nudge injected %d times, want at most once per request window", count)
|
||||
}
|
||||
}
|
||||
|
||||
// 短任务(阈值内完成)不提醒。
|
||||
func TestDualSpeedNudgeQuietBeforeThreshold(t *testing.T) {
|
||||
turns := append(toolOnlyTurns(1), textDeltaTurn("完成。"))
|
||||
rec := runDualSpeed(t, turns, nil)
|
||||
if rec.nudged() {
|
||||
t.Fatal("nudge fired before the threshold")
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 runtime.dualSpeedNudgeSeconds=0 → 关闭,超时也不提醒。
|
||||
func TestDualSpeedNudgeDisabledBySetting(t *testing.T) {
|
||||
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
|
||||
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
|
||||
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
|
||||
})
|
||||
if rec.nudged() {
|
||||
t.Fatal("nudge fired although disabled via runtime.dualSpeedNudgeSeconds=0")
|
||||
}
|
||||
}
|
||||
|
||||
// 模型已给出面向用户的答复(快通道成立)→ 即使后续事实核查续写拖过阈值也不提醒。
|
||||
func TestDualSpeedNudgeSkippedWhenReplied(t *testing.T) {
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
|
||||
textDeltaTurn("完整总结:产出物与要点。"),
|
||||
textDeltaTurn("已修正并重新登记。"),
|
||||
}
|
||||
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
|
||||
rc.verify = true
|
||||
rc.execArtifact = &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"}
|
||||
rc.execArtifactContent = []byte("# 报告\n\n准确率 95%。")
|
||||
})
|
||||
if rec.nudged() {
|
||||
t.Fatal("nudge fired although the model already gave its user reply")
|
||||
}
|
||||
}
|
||||
|
||||
// 已把深度工作放后台(慢通道成立)→ 不再提醒。
|
||||
func TestDualSpeedNudgeSkippedWhenDelegated(t *testing.T) {
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("", toolCall("delegate_task", `{"name":"深度解析","task":"逐页解析","background":true}`)),
|
||||
}
|
||||
turns = append(turns, toolOnlyTurns(19)...)
|
||||
turns = append(turns, textDeltaTurn("后台进行中,先给结论。"))
|
||||
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
|
||||
rc.bgID = "run_bg_1"
|
||||
})
|
||||
if rec.nudged() {
|
||||
t.Fatal("nudge fired although background delegation already happened")
|
||||
}
|
||||
}
|
||||
|
||||
// 首次被无视后按 45s 间隔升级重复(最多 3 次),第二次起为硬性措辞。
|
||||
func TestDualSpeedNudgeEscalates(t *testing.T) {
|
||||
turns := append(toolOnlyTurns(35), textDeltaTurn("完成。")) // 35×5s=175s → 75s 起、120s、165s 三档
|
||||
rec := runDualSpeed(t, turns, nil)
|
||||
marks, hard := 0, 0
|
||||
for _, r := range rec.request {
|
||||
marks += strings.Count(r, "用户已经等待")
|
||||
hard += strings.Count(r, "硬性要求")
|
||||
}
|
||||
if marks < 1 {
|
||||
t.Fatalf("escalation missing: requests=%d", len(rec.request))
|
||||
}
|
||||
if marks > 2 {
|
||||
t.Fatalf("escalated %d times, want at most 2 repeats after the first", marks)
|
||||
}
|
||||
}
|
||||
|
||||
// 入口前置:带附件的任务第 0 回合注入快慢双速选项;无附件不注入;阈值关闭不注入。
|
||||
func TestDualSpeedEntryHintWithAttachments(t *testing.T) {
|
||||
withAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
|
||||
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
|
||||
})
|
||||
if !strings.Contains(strings.Join(withAtt.request, "\n"), "快慢双速") {
|
||||
t.Fatal("entry hint missing for an attachment-bearing task")
|
||||
}
|
||||
noAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, nil)
|
||||
if strings.Contains(strings.Join(noAtt.request, "\n"), "本任务附带材料") {
|
||||
t.Fatal("entry hint fired without attachments")
|
||||
}
|
||||
disabled := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
|
||||
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
|
||||
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
|
||||
})
|
||||
if strings.Contains(strings.Join(disabled.request, "\n"), "本任务附带材料") {
|
||||
t.Fatal("entry hint fired although the nudge setting is disabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- E4:R4 升级提醒 ----
|
||||
|
||||
// 滑动窗口判据:近 6 次结果中 3 次失败(可与成功交错)触发换路指令;
|
||||
// 每 run 一次;2 次失败不触发。
|
||||
func TestEscalationOnConsecutiveToolFailures(t *testing.T) {
|
||||
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
|
||||
st.noteFailureOutcome("ok")
|
||||
st.noteFailureOutcome("error")
|
||||
st.noteFailureOutcome("ok")
|
||||
st.noteFailureOutcome("error")
|
||||
if got := st.escalationReminders(); len(got) != 0 {
|
||||
t.Fatalf("two scattered failures must not escalate: %v", got)
|
||||
}
|
||||
st.noteFailureOutcome("ok")
|
||||
st.noteFailureOutcome("error") // 窗口内第 3 败(交错分布)
|
||||
got := st.escalationReminders()
|
||||
if len(got) != 1 || !strings.Contains(got[0], "停止原样重试") {
|
||||
t.Fatalf("three failures in the window must escalate: %v", got)
|
||||
}
|
||||
if again := st.escalationReminders(); len(again) != 0 {
|
||||
t.Fatalf("escalation must fire once per run: %v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// 检索连续空结果(成功但无实质内容)触发「换关键词或如实写缺失」。
|
||||
func TestEscalationOnZeroHitSearches(t *testing.T) {
|
||||
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "未找到相关内容"}
|
||||
r := &LLMRunner{Stream: scriptStream(padTurns(
|
||||
textDeltaTurn("", toolCall("knowledge_search", `{"query":"a"}`), toolCall("knowledge_search", `{"query":"b"}`)),
|
||||
textDeltaTurn("好了。"),
|
||||
), nil)}
|
||||
if err := r.Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
|
||||
st.noteEvidenceCall("knowledge_search", "ok", "未找到相关内容")
|
||||
if got := st.escalationReminders(); len(got) != 0 {
|
||||
t.Fatalf("one zero hit must not escalate: %v", got)
|
||||
}
|
||||
st.noteEvidenceCall("knowledge_search", "ok", "无结果")
|
||||
got := st.escalationReminders()
|
||||
if len(got) != 1 || !strings.Contains(got[0], "材料中无此信息") {
|
||||
t.Fatalf("two zero hits must escalate: %v", got)
|
||||
}
|
||||
// 有实质结果后计数重置。
|
||||
st.noteEvidenceCall("knowledge_search", "ok", "营收 3,200 万元")
|
||||
if st.zeroHitSearches != 0 {
|
||||
t.Fatal("substantive hit must reset zero-hit count")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- E4:结构化 source_id 账本 ----
|
||||
|
||||
func TestEvidenceLedgerTagsSource(t *testing.T) {
|
||||
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "上半年营收 3,200 万元。"}
|
||||
r := &LLMRunner{Stream: scriptStream(padTurns(
|
||||
textDeltaTurn("", toolCall("knowledge_search", `{"query":"营收"}`)),
|
||||
textDeltaTurn("总结完成。"),
|
||||
), nil)}
|
||||
if err := r.Execute(f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 结果文本必须带来源标记,且账本登记了对应条目(直接经 runTool 断言)。
|
||||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||||
res := st.runTool("knowledge_search", `{}`)
|
||||
if !strings.Contains(res.Content, "【src_1|knowledge_search】") {
|
||||
t.Fatalf("tool result must carry the source tag: %.120s", res.Content)
|
||||
}
|
||||
if len(st.ledger) != 1 || st.ledger[0].ID != "src_1" || !strings.Contains(st.ledger[0].Text, "3,200") {
|
||||
t.Fatalf("ledger = %+v", st.ledger)
|
||||
}
|
||||
// 第二次登记递增编号。
|
||||
res2 := st.runTool("fs_read_file", `{}`)
|
||||
if !strings.Contains(res2.Content, "【src_2|fs_read_file】") {
|
||||
t.Fatalf("second tag must be src_2: %.120s", res2.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// 非证据工具不登记、不加标记。
|
||||
func TestEvidenceLedgerSkipsNonEvidenceTools(t *testing.T) {
|
||||
f := &fakeRunCtx{}
|
||||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||||
res := st.runTool("time_now", `{}`)
|
||||
if strings.Contains(res.Content, "src_") || len(st.ledger) != 0 {
|
||||
t.Fatalf("non-evidence tool must not enter the ledger: %.80s %+v", res.Content, st.ledger)
|
||||
}
|
||||
}
|
||||
|
||||
// 材料读(inputs/)打标;模型自产文件的读与 grep 不打——尾注会污染
|
||||
// read→edit→build 编辑循环(E4 首轮 eval 实测教训,回归锚点)。
|
||||
func TestEvidenceLedgerOnlyTagsMaterialReads(t *testing.T) {
|
||||
if !isEvidenceRead("read_file", json.RawMessage(`{"path":"inputs/年报.docx.md"}`)) {
|
||||
t.Fatal("inputs/ read must count as evidence")
|
||||
}
|
||||
if isEvidenceRead("read_file", json.RawMessage(`{"path":"方案.md"}`)) {
|
||||
t.Fatal("own-workspace read must NOT count as evidence")
|
||||
}
|
||||
if isEvidenceRead("grep", json.RawMessage(`{"pattern":"营收"}`)) {
|
||||
t.Fatal("grep must never count as evidence")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -9,6 +12,10 @@ import (
|
||||
"agent-system-backend/internal/quality"
|
||||
)
|
||||
|
||||
// factCheckMaxRounds 是 E3 的事实核查修正轮上限:两轮(第二轮为「最后一次
|
||||
// 拦截」+标注出路),之后放行并记检查行。
|
||||
const factCheckMaxRounds = 2
|
||||
|
||||
// P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」——
|
||||
// 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。
|
||||
// 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、
|
||||
@@ -44,7 +51,9 @@ func ExtractFacts(text string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// UnverifiedFacts 返回材料里找不到出处的 token(白名单:豁免的通用口径词)。
|
||||
// UnverifiedFacts 返回材料里找不到出处的 token。放行三类:豁免的通用口径词、
|
||||
// 材料中能回溯的、以及 E3 标注感知——文中以「目标/预计/假设」等标注词显式
|
||||
// 标记为规划量的数字不再是凭空事实,机械溯源放行(MarkedAsEstimate)。
|
||||
func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string {
|
||||
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
|
||||
return nil
|
||||
@@ -54,13 +63,46 @@ func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool)
|
||||
if exempt[tok] {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) {
|
||||
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) && !MarkedAsEstimate(artifactText, tok) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// factMarkingRe:数字邻域出现这些词即视为「已标注的规划量」。选取保守集合,
|
||||
// 避免把「会议目标」之类的普通动词误当标注(需要与数字同现于邻域内)。
|
||||
var factMarkingRe = regexp.MustCompile(`目标|假设|预计|预估|推测|估算|规划|拟定|建议值|上限|下限|阈值|基准值|参考值|暂定`)
|
||||
|
||||
// factClauseCut 是分句分隔符:标注判定以「句」为单位而不是固定窗口——
|
||||
// 固定窗口会把相邻句的标注词错标给前一个数字(实测:「覆盖 3,500 用户,
|
||||
// 目标 85%」里 3,500 被误放行)。
|
||||
const factClauseCut = "。;!?\n,、;"
|
||||
|
||||
// MarkedAsEstimate 报告 token 在 text 中的任一出现是否与标注词同句
|
||||
// (同句=两侧最近分隔符之间,括号不算分隔,覆盖「目标完成率 85%」「85%
|
||||
// (预计)」)。供终轮核查与评测端共用同一口径。
|
||||
func MarkedAsEstimate(text, tok string) bool {
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(text[from:], tok)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
i += from
|
||||
lo := strings.LastIndexAny(text[max(0, i-64):i], factClauseCut) + max(0, i-64) + 1
|
||||
if end := strings.IndexAny(text[i:], factClauseCut); end >= 0 {
|
||||
clause := text[lo : i+end]
|
||||
if factMarkingRe.MatchString(clause) {
|
||||
return true
|
||||
}
|
||||
} else if factMarkingRe.MatchString(text[lo:]) {
|
||||
return true
|
||||
}
|
||||
from = i + len(tok)
|
||||
}
|
||||
}
|
||||
|
||||
func stripPunct(s string) string {
|
||||
return strings.NewReplacer(",", "", ",", "", " ", "").Replace(s)
|
||||
}
|
||||
@@ -82,7 +124,7 @@ func factCheckNotice(tokens []string) string {
|
||||
}
|
||||
b.WriteString(tok)
|
||||
}
|
||||
b.WriteString("。逐条二选一:改成材料里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
|
||||
b.WriteString("。逐条二选一:改成材料或来源结果(检索/读取输出带【src_N|…】标记)里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -160,3 +202,176 @@ func factExemptions() map[string]bool {
|
||||
"100%": true, "0%": true, // 常见修辞(100% 确定)与零值
|
||||
}
|
||||
}
|
||||
|
||||
// factCheckGate 决定本轮未核实 token 的处置(E3):
|
||||
// - 第一轮:给修正通知并续跑;
|
||||
// - 第二轮(仅当 token 集合相对上一轮有变化——模型确实改过但仍有残留):
|
||||
// 「最后一次拦截」+ 标注出路;
|
||||
// - token 集合与上一轮完全相同(模型没有理会)或轮数用尽:放行,
|
||||
// 由调用方记入检查行如实呈现。续写预算是各终轮门禁共享的,
|
||||
// 无变化的重复拦截只会挤占预算(实测会挤掉后续门禁的修正机会)。
|
||||
func (st *directRun) factCheckGate(tokens []string) (notice string, cont bool) {
|
||||
same := st.factCheckPrev != nil && len(tokens) == len(st.factCheckPrev)
|
||||
if same {
|
||||
for _, tok := range tokens {
|
||||
if !st.factCheckPrev[tok] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.factCheckRounds >= factCheckMaxRounds || same {
|
||||
return "", false
|
||||
}
|
||||
st.factCheckRounds++
|
||||
st.factCheckPrev = map[string]bool{}
|
||||
for _, tok := range tokens {
|
||||
st.factCheckPrev[tok] = true
|
||||
}
|
||||
notice = factCheckNotice(tokens)
|
||||
if st.factCheckRounds > 1 {
|
||||
notice = "最后一次拦截:" + notice + " 若该数字确属规划量而非事实,必须在文中用「目标 / 预计 / 假设」等词显式标注(如「目标 85%」);既无出处又不标注的数字不得保留。"
|
||||
}
|
||||
return notice, true
|
||||
}
|
||||
|
||||
// ---- E4:证据账本与 R4 升级提醒 ----
|
||||
|
||||
// evidenceEntry 是账本的一条来源记录(正文截头保存;全文在转录与工具证据里)。
|
||||
type evidenceEntry struct {
|
||||
ID string
|
||||
Tool string
|
||||
Text string
|
||||
}
|
||||
|
||||
// evidenceTools 无条件登记进 source_id 账本的证据类工具(检索面)。
|
||||
// 内核工作区 read_file 不在此列:它也读模型自己的工作文件——给自身文件
|
||||
// 的读结果尾注【src_N】会污染 read→edit→build 的编辑循环(实测 E4 首轮
|
||||
// eval:模型视尾注为文件被外改动,反复重读重试,工具失败 10→24、调用
|
||||
// 63→166)。仅当读取 inputs/ 材料时才登记(isEvidenceRead)。
|
||||
var evidenceTools = map[string]bool{
|
||||
"knowledge_search": true,
|
||||
"web_search": true,
|
||||
"tavily_search": true,
|
||||
"fs_read_file": true,
|
||||
}
|
||||
|
||||
// isEvidenceRead 判定一次工作区读是否为「材料读」:仅 inputs/(上传材料
|
||||
// 落点,环境段列出)之下的 read_file 计入账本;grep 无法区分材料与自产
|
||||
// 文件,一律不计。
|
||||
func isEvidenceRead(name string, args json.RawMessage) bool {
|
||||
if name != "read_file" {
|
||||
return false
|
||||
}
|
||||
var obj map[string]string
|
||||
if err := json.Unmarshal(args, &obj); err != nil {
|
||||
return false
|
||||
}
|
||||
p := strings.TrimSpace(obj["path"])
|
||||
p = strings.TrimPrefix(p, "./")
|
||||
return strings.HasPrefix(p, "inputs/") || strings.HasPrefix(p, "/inputs/")
|
||||
}
|
||||
|
||||
// R4 换路提醒的滑动窗口判据:最近 escalationWindow 次工具结果里出现
|
||||
// escalationFailureThreshold 次失败即触发(每 run 一次)。用窗口而不是
|
||||
// 「连续 N 次」——实测失败多与成功交错(20 失败散布在 108 次调用里,
|
||||
// 连续计数从未达 2,首轮 eval 生产路径零触发),滑动窗口才抓得住
|
||||
// 「整体在原地打转」的形态。
|
||||
const (
|
||||
escalationWindow = 6
|
||||
escalationFailureThreshold = 3
|
||||
// 检索空命中阈值独立取 2:检索单次成本高,两次落空即是换维度信号。
|
||||
escalationZeroHitThreshold = 2
|
||||
)
|
||||
|
||||
// tagSource 把一次成功的证据工具输出登记进账本,返回给模型看的来源标记
|
||||
// (如 src_3|knowledge_search)。并行执行下由 toolMu 串行化计数与追加。
|
||||
func (st *directRun) tagSource(tool, text string) (string, bool) {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
st.ledgerCount++
|
||||
id := fmt.Sprintf("src_%d", st.ledgerCount)
|
||||
head := []rune(text)
|
||||
if len(head) > 2000 { // 账本只存索引性头部,全文在转录/证据里
|
||||
head = head[:2000]
|
||||
}
|
||||
st.ledger = append(st.ledger, evidenceEntry{ID: id, Tool: tool, Text: string(head)})
|
||||
return id + "|" + tool, true
|
||||
}
|
||||
|
||||
// noteEvidenceCall 维护 R4 的检索空命中计数(成功但无实质内容的证据类
|
||||
// 工具调用)。工具成败计数不在这里——并行下以 onEvent 终点的槽位状态为
|
||||
// 单一事实源(noteFailureOutcome),避免两处重复计数。
|
||||
func (st *directRun) noteEvidenceCall(name, status, output string) {
|
||||
if status != "ok" {
|
||||
return
|
||||
}
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
trimmed := strings.TrimSpace(output)
|
||||
if evidenceTools[name] && (trimmed == "" || containsAny(trimmed, zeroHitMarkers)) {
|
||||
st.zeroHitSearches++
|
||||
} else if evidenceTools[name] {
|
||||
st.zeroHitSearches = 0
|
||||
}
|
||||
}
|
||||
|
||||
// zeroHitMarkers 是检索结果「无命中」的常见表述(按工具输出原文判别)。
|
||||
var zeroHitMarkers = []string{"未找到", "没有找到", "无结果", "无相关", "暂无匹配", "No results"}
|
||||
|
||||
func containsAny(s string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// escalationReminders 返回待注入的换路提醒并清标记(每类条件一次,
|
||||
// 连续失败再次触发时会重新提醒——失败计数在成功后重置,streak 标记随重置)。
|
||||
// 文案直接给动作:MiniMax 对泛泛提醒不敏感(实测),对具体指令照办。
|
||||
func (st *directRun) escalationReminders() []string {
|
||||
var out []string
|
||||
st.toolMu.Lock()
|
||||
fails, failNoted := countFails(st.recentOutcomes), st.failWindowNoted
|
||||
zero, zeroNoted := st.zeroHitSearches, st.zeroHitNoted
|
||||
if fails >= escalationFailureThreshold && !failNoted {
|
||||
st.failWindowNoted = true
|
||||
}
|
||||
if zero >= escalationZeroHitThreshold && !zeroNoted {
|
||||
st.zeroHitNoted = true
|
||||
}
|
||||
st.toolMu.Unlock()
|
||||
if fails >= escalationFailureThreshold && !failNoted {
|
||||
log.Printf("[runtime] r4 escalation reminder injected (tool failures %d in last %d outcomes)", fails, escalationWindow)
|
||||
out = append(out, fmt.Sprintf("工具最近集中失败(近 %d 次调用中多次报错):停止原样重试。逐条重读错误信息,改参数、换工具或换路径再试;确认无法绕过时,如实在答复里说明卡点与已尝试的手段,不要编造结果。", fails))
|
||||
}
|
||||
if zero >= escalationZeroHitThreshold && !zeroNoted {
|
||||
log.Printf("[runtime] r4 escalation reminder injected (zero-hit searches %d)", zero)
|
||||
out = append(out, "检索已连续多次无结果:换关键词/换检索维度再试一次;仍无结果就如实写「材料中无此信息」,不要凭印象补数字。")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// noteFailureOutcome 供 onEvent 在工具结束时记录成败(并行下事件乱序,
|
||||
// 以 runToolCtx 已写入槽位的 status 为准)。维护最近 escalationWindow 次
|
||||
// 结果的滑动窗口。
|
||||
func (st *directRun) noteFailureOutcome(status string) {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
st.recentOutcomes = append(st.recentOutcomes, status == "error")
|
||||
if len(st.recentOutcomes) > escalationWindow {
|
||||
st.recentOutcomes = st.recentOutcomes[len(st.recentOutcomes)-escalationWindow:]
|
||||
}
|
||||
}
|
||||
|
||||
func countFails(outcomes []bool) int {
|
||||
n := 0
|
||||
for _, failed := range outcomes {
|
||||
if failed {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestUnverifiedFactsAndNotice(t *testing.T) {
|
||||
t.Fatalf("sourced figure must not be reported: %v", got)
|
||||
}
|
||||
notice := factCheckNotice(got)
|
||||
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料里") {
|
||||
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料或来源结果") {
|
||||
t.Fatalf("notice malformed: %q", notice)
|
||||
}
|
||||
if UnverifiedFacts("", material, nil) != nil || UnverifiedFacts(artifact, "", nil) != nil {
|
||||
@@ -63,3 +63,63 @@ func TestFactCheckContinuationOnce(t *testing.T) {
|
||||
}
|
||||
_ = turns2
|
||||
}
|
||||
|
||||
// ---- E3:标注感知豁免 ----
|
||||
|
||||
func TestMarkedAsEstimate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, text, tok string
|
||||
want bool
|
||||
}{
|
||||
{"前置标注", "目标完成率 85%,分三个阶段推进", "85%", true},
|
||||
{"后置标注", "完成率 85%(预计)", "85%", true},
|
||||
{"裸数字", "当前完成率 85%,尚有差距", "85%", false},
|
||||
{"邻域外有标注词", "目标是建成样板。完成率 85%", "85%", false},
|
||||
{"多次出现其一标注", "实际 70%。目标 85% 年内达成", "85%", true},
|
||||
{"推测标注", "用户规模约 120,000(推测)", "120,000", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := MarkedAsEstimate(c.text, c.tok); got != c.want {
|
||||
t.Errorf("%s: MarkedAsEstimate(%q) = %v, want %v", c.name, c.tok, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnverifiedFactsMarksPass(t *testing.T) {
|
||||
material := "材料:上半年营收 3,200 万元。"
|
||||
// 「目标 85%」已标注 → 放行;裸 95% → 仍拦。
|
||||
artifact := "上半年营收 3,200 万元,达成率目标 85%,试点扩面后裸数字 95%。"
|
||||
got := UnverifiedFacts(artifact, material, factExemptions())
|
||||
if len(got) != 1 || got[0] != "95%" {
|
||||
t.Fatalf("unverified = %v(应只拦未标注的 95%%)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- E3:两轮修正的轮次逻辑 ----
|
||||
|
||||
func TestFactCheckGateRounds(t *testing.T) {
|
||||
st := &directRun{}
|
||||
first := []string{"95%", "10月20日"}
|
||||
|
||||
// 第一轮:拦截并给修正通知。
|
||||
notice, cont := st.factCheckGate(first)
|
||||
if !cont || !strings.Contains(notice, "95%") || strings.Contains(notice, "最后一次拦截") {
|
||||
t.Fatalf("round1: cont=%v notice=%q", cont, notice)
|
||||
}
|
||||
// token 完全没变(模型没理会):不再拦——重复拦截只挤占共享续写预算。
|
||||
if _, cont := st.factCheckGate(first); cont {
|
||||
t.Fatal("unchanged tokens must pass through")
|
||||
}
|
||||
// token 有变化(改了一个、残留一个):第二轮升级为「最后一次拦截」。
|
||||
notice, cont = st.factCheckGate([]string{"10月20日"})
|
||||
if !cont || !strings.Contains(notice, "最后一次拦截") || !strings.Contains(notice, "目标") {
|
||||
t.Fatalf("round2: cont=%v notice=%q", cont, notice)
|
||||
}
|
||||
// 轮数用尽:放行。
|
||||
if _, cont := st.factCheckGate([]string{"7%"}); cont {
|
||||
t.Fatal("rounds exhausted must pass through")
|
||||
}
|
||||
if st.factCheckRounds != 2 {
|
||||
t.Fatalf("rounds = %d", st.factCheckRounds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 问题一回归(run_b520bf2e3037):事实核查修正轮触发时,现场那段正式总结必须
|
||||
// 降级进过程时间线;修正轮的收尾才是最终答复——否则前端按「最后一个过程部件」
|
||||
// 切分后,用户只看到修正流水账,看不到原总结。
|
||||
func TestFactCheckDemotesPriorSummary(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}},
|
||||
verify: true,
|
||||
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
|
||||
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
|
||||
}
|
||||
turns := padTurns(
|
||||
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
|
||||
textDeltaTurn("已完成。## 产出物\n\n结构完整的用户总结。"),
|
||||
textDeltaTurn("已修正并重新登记。"),
|
||||
)
|
||||
r := &LLMRunner{Stream: scriptStream(turns, nil), VerifyStream: passedStream()}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
var summary *core.Part
|
||||
visible := []string{}
|
||||
sawFactCheck := false
|
||||
for i := range rc.assistant {
|
||||
p := rc.assistant[i]
|
||||
if p.Type == core.PartText && strings.Contains(p.Text, "结构完整的用户总结") {
|
||||
summary = &rc.assistant[i]
|
||||
}
|
||||
if p.Type == core.PartText && !p.Process {
|
||||
visible = append(visible, p.Text)
|
||||
}
|
||||
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
|
||||
sawFactCheck = true
|
||||
}
|
||||
}
|
||||
if summary == nil {
|
||||
t.Fatal("prior summary text missing")
|
||||
}
|
||||
if !summary.Process {
|
||||
t.Fatalf("prior summary not demoted to process: %+v", *summary)
|
||||
}
|
||||
if !sawFactCheck {
|
||||
t.Fatal("fact check note missing")
|
||||
}
|
||||
if len(visible) != 1 || visible[0] != "已修正并重新登记。" {
|
||||
t.Fatalf("visible final texts = %v, want only the continuation closing", visible)
|
||||
}
|
||||
}
|
||||
|
||||
// 同源跳过提示不再作为用户可见正文追加(原先经 StopDecision.Notice 顶掉正式总结),
|
||||
// 改记为通过态检查行留在过程时间线。
|
||||
func TestVerifierSkipIsCheckNotFinalText(t *testing.T) {
|
||||
rc := &fakeRunCtx{
|
||||
ctx: context.Background(),
|
||||
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, // 轻量回落=主模型 → 同源跳过
|
||||
verify: true,
|
||||
}
|
||||
turns := padTurns(textDeltaTurn("总结完成。"))
|
||||
r := &LLMRunner{Stream: scriptStream(turns, nil)} // 无 VerifyStream
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
skipCheck, noticeText := false, false
|
||||
visible := []string{}
|
||||
for _, p := range rc.assistant {
|
||||
if p.Check != nil && p.Check.Passed && strings.Contains(p.Check.Reason, "同源模型验证") {
|
||||
skipCheck = true
|
||||
}
|
||||
if p.Type == core.PartText {
|
||||
if strings.Contains(p.Text, verifierUnavailableNotice) {
|
||||
noticeText = true
|
||||
}
|
||||
if !p.Process {
|
||||
visible = append(visible, p.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !skipCheck {
|
||||
t.Fatal("same-source skip not recorded as a passing check")
|
||||
}
|
||||
if noticeText {
|
||||
t.Fatal("skip notice leaked into message text parts")
|
||||
}
|
||||
if len(visible) != 1 || visible[0] != "总结完成。" {
|
||||
t.Fatalf("visible final texts = %v", visible)
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -67,6 +67,8 @@ const (
|
||||
toolCardBegin = "card_begin"
|
||||
toolCardAppend = "card_append"
|
||||
toolCardCommit = "card_commit"
|
||||
// confirm_choice 发起待确认选择卡(材料整理完成后选交付物;I2)。
|
||||
toolConfirmChoice = "confirm_choice"
|
||||
)
|
||||
|
||||
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目
|
||||
@@ -242,7 +244,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
if stream == nil {
|
||||
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
|
||||
// 此处不再显式包装(防双重)。
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
stream = client.Stream
|
||||
}
|
||||
|
||||
@@ -270,6 +272,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
// 组装侧不再注入(防双份;注入文案同一形态)。
|
||||
_ = ctxasm.InjectMemory
|
||||
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
|
||||
Surfaces: rc.Surfaces(),
|
||||
ThinkReplay: thinkReplayEnabled(eff, route.Model),
|
||||
})
|
||||
tail := ctxasm.Tail{
|
||||
@@ -307,10 +310,11 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
decls = append(decls, agentllm.ToolDecl{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)})
|
||||
}
|
||||
decls = append(decls,
|
||||
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回,子代理完成后系统会自动发起一轮汇报"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行,当前回合不等待结果"}},"required":["name","task"]}`)},
|
||||
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回——这是重任务的慢通道:先在当前回复里给用户即时反馈(相关性判断/概要/计划),深度工作放后台;系统会在收件箱建进度卡并在完成后回投结果、自动发起一轮汇报。预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理)优先用此模式,不要让用户干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行(慢通道),当前回合不等待结果;收件箱有进度卡,完成后自动汇报"}},"required":["name","task"]}`)},
|
||||
agentllm.ToolDecl{Name: toolReview, Description: l.toolDesc(prof.Owner, toolReview, "对一段材料做评审:多个审查子代理并行找问题,再逐条独立验证,只保留验证通过的问题"), Parameters: json.RawMessage(`{"type":"object","properties":{"target":{"type":"string","description":"要评审的文本或工件"},"focus":{"type":"string","description":"评审重点"},"reviewers":{"type":"integer","minimum":1,"maximum":3,"description":"并行审查人数,1 到 3,缺省 1"}},"required":["target"]}`)},
|
||||
agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)},
|
||||
agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)},
|
||||
agentllm.ToolDecl{Name: toolConfirmChoice, Description: l.toolDesc(prof.Owner, toolConfirmChoice, "向用户发起待确认选择卡并等待答复:3-4 个选项(各附一句依据与影响)+ 可选自由输入。材料整理完成后选正式交付物、或任何需要用户拍板的分叉都用它,不要在文本里罗列选项后干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string","description":"要用户决定的问题,一句话"},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string","description":"选项名,如:会议摘要"},"description":{"type":"string","description":"一句依据(来自材料或上下文)"},"impact":{"type":"string","description":"选择后的产出与耗时影响"},"recommended":{"type":"boolean","description":"最贴合的一项设 true"}},"required":["id","label","description","impact"]}},"free_input":{"type":"boolean","description":"允许自由输入其他要求,默认 true"},"timeout_sec":{"type":"integer","description":"等待上限秒数,0=不限"}},"required":["question","options"]}`)},
|
||||
)
|
||||
if eff.GetString("runtime.entry", "direct") != "squad" {
|
||||
decls = append(decls, agentllm.ToolDecl{
|
||||
@@ -488,8 +492,15 @@ func agentNameOf(prof core.AgentProfile) string {
|
||||
}
|
||||
|
||||
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
|
||||
func openLLMClient(base, key string) *agentllm.Client {
|
||||
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
|
||||
func openLLMClient(base, key, model string) *agentllm.Client {
|
||||
c := &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
|
||||
// MiniMax 系(interleaved thinking)的 OpenAI 兼容接口默认把思考混在
|
||||
// content 的 <think> 块里;reasoning_split=true 让思考改走独立
|
||||
// reasoning_content 字段(SSE 捕获路径不变,正文流不再夹带思考)。
|
||||
if strings.HasPrefix(model, "MiniMax-M") {
|
||||
c.ReasoningSplit = true
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。
|
||||
|
||||
+331
-45
@@ -49,7 +49,8 @@ type directRun struct {
|
||||
responding bool
|
||||
acting bool
|
||||
queued []agentllm.ToolCall
|
||||
inflight *toolSlot
|
||||
inflight map[string]*toolSlot // 并行执行下按内核事件 CallID 配对
|
||||
toolMu sync.Mutex // 并行 onEvent 下 inflight/queued/finalParts 互斥
|
||||
turnBody string
|
||||
|
||||
evidence []toolEvidence
|
||||
@@ -59,12 +60,41 @@ type directRun struct {
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
nudgedPlanless bool
|
||||
factChecked bool
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
factCheckRounds int // E3:事实核查修正轮计数(上限 factCheckMaxRounds)
|
||||
factCheckPrev map[string]bool // E3:上一轮的未核实 token 集合(未变化则不再拦)
|
||||
// E4(R4 升级规则):harness 侧触发的换路提醒——不依赖模型自觉发现
|
||||
// 自己在原地打转。成败计数在 onEvent 终点统一维护(noteFailureOutcome),
|
||||
// 达阈值注入一次换路指令;检索连续空结果同理(noteEvidenceCall)。
|
||||
recentOutcomes []bool // E4(R4):最近 N 次工具成败(true=失败),滑动窗口
|
||||
failWindowNoted bool // E4(R4):窗口判据已提醒(每 run 一次)
|
||||
zeroHitSearches int
|
||||
zeroHitNoted bool
|
||||
// E4(结构化 source_id 账本):证据类工具的成功结果登记为带 ID 的
|
||||
// 条目,工具输出尾注【src_N|工具】标记——数字溯源的「材料/来源」
|
||||
// 从此有可指地址。
|
||||
ledger []evidenceEntry
|
||||
ledgerCount int
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
// 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点,
|
||||
// nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。
|
||||
started time.Time
|
||||
nudgedDualSpeedCount int
|
||||
replied bool
|
||||
delegatedBackground bool
|
||||
materialChecked bool // 材料兜底只问一次(runCtx 侧另有幂等,双保险)
|
||||
// 交付物确认链(I2):confirm_choice 发起过选择卡;用户答复后
|
||||
// pendingDeliverable 非空——回合结束仍未委派则硬性续跑一轮。
|
||||
confirmIssued bool
|
||||
pendingDeliverable string
|
||||
deliverableNudged bool
|
||||
// 材料整理通知回合(历史含「材料整理」完成标记):结束仍无选择卡且未委派
|
||||
// 时,硬性要求发起 confirm_choice(弱模型应急,一次性)。
|
||||
materialNotice bool
|
||||
materialNoticeNudged bool
|
||||
}
|
||||
|
||||
type toolEvidence struct {
|
||||
@@ -97,7 +127,7 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
|
||||
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
|
||||
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow, started: l.now(), inflight: map[string]*toolSlot{}}
|
||||
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
|
||||
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
|
||||
st.memoryStore = ms.StoreMemory()
|
||||
@@ -113,6 +143,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
st.pinSubagentArtifacts(text)
|
||||
}
|
||||
st.servedModel = model
|
||||
// 材料整理通知回合识别(I2):历史含「材料整理」完成标记的系统提醒——
|
||||
// 该回合负有发起交付物选择卡的职责,stop 钩子据此兜底。扫会话历史而非
|
||||
// 组装后的 msgs:系统提示词块会撑大 msgs,窗口不稳定。
|
||||
histMsgs := rc.History()
|
||||
for i := len(histMsgs) - 1; i >= 0 && i >= len(histMsgs)-8; i-- {
|
||||
if histMsgs[i].Role != core.RoleSystem {
|
||||
continue
|
||||
}
|
||||
for _, p := range histMsgs[i].Parts {
|
||||
if p.Type == core.PartText && strings.Contains(p.Text, materialNoticeMarker) {
|
||||
st.materialNotice = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if st.materialNotice {
|
||||
break
|
||||
}
|
||||
}
|
||||
stream = agentllm.WithFallback(stream, ChatFallbacks(eff), st.noteModelSwitch)
|
||||
defer func() {
|
||||
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
|
||||
@@ -127,7 +175,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
for _, d := range decls {
|
||||
name := d.Name
|
||||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||||
return st.runTool(name, string(args))
|
||||
return st.runToolCtx(ctx, name, string(args))
|
||||
}})
|
||||
}
|
||||
// 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、
|
||||
@@ -153,7 +201,14 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
wsReg := ws
|
||||
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
|
||||
res := wsReg.Execute(ctx, name, args)
|
||||
st.noteKernelResult(res)
|
||||
st.noteKernelResult(ctx, res)
|
||||
// E4:工作区读仅在 inputs/ 材料读时登记账本(isEvidenceRead;
|
||||
// 自产文件的读结果不打标,防编辑循环被尾注污染——实测教训)。
|
||||
if !res.IsError && (evidenceTools[name] || isEvidenceRead(name, args)) && strings.TrimSpace(res.Content) != "" {
|
||||
if tag, ok := st.tagSource(name, res.Content); ok {
|
||||
res.Content += "\n【" + tag + "】"
|
||||
}
|
||||
}
|
||||
return res
|
||||
}})
|
||||
}
|
||||
@@ -267,6 +322,8 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
})
|
||||
res, err := nruntime.Run(rc.Context(), nruntime.Config{
|
||||
Model: model,
|
||||
ToolExecution: toolExecMode(eff),
|
||||
SerialTools: serialTools,
|
||||
History: history,
|
||||
Session: sessStore,
|
||||
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
|
||||
@@ -286,6 +343,22 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
Reminders: func(turn int) []string {
|
||||
var out []string
|
||||
out = append(out, st.reminders(turn)...)
|
||||
// 快慢双速入口前置(SR-B10 实路径):带附件的任务在第 0 回合就把
|
||||
// 双速选项摆给模型——模型对开场指令的遵循远好于中途提醒(实测
|
||||
// MiniMax 无视中途 system-reminder,但对工作方法开场指令照办)。
|
||||
// 材料整理入口(附件即保证):系统已确定性启动时注入事实说明
|
||||
// (职责改为快通道反馈+后续交付物),未预启动(文字+小材料)时
|
||||
// 保留建议式提示,回合结束由 stop 钩子兜底保证。
|
||||
if turn == 0 && st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds) > 0 && len(rc.Attachments()) > 0 {
|
||||
note := ""
|
||||
if src, ok := rc.(interface{ MaterialNote() string }); ok {
|
||||
note = strings.TrimSpace(src.MaterialNote())
|
||||
}
|
||||
if note == "" {
|
||||
note = "本任务附带材料,可能属于重任务。若预计要产出正式文档或做深度处理,采用快慢双速:先在当前回复里给用户即时反馈(材料相关性判断、概要或执行计划),再用 delegate_task 的 background=true 把深度工作放到后台执行——收件箱会显示进度卡,完成后系统自动回投结果并汇报。简单任务按常规同步完成即可。"
|
||||
}
|
||||
out = append(out, note)
|
||||
}
|
||||
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
|
||||
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
|
||||
if sessStore != nil && !seedRun && turn == 0 {
|
||||
@@ -367,20 +440,41 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
|
||||
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
|
||||
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
|
||||
func (st *directRun) noteKernelResult(res agenttools.Result) {
|
||||
if st.inflight == nil {
|
||||
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
|
||||
call, ok := nruntime.ToolCallFromContext(ctx)
|
||||
slot := st.slotFor("")
|
||||
if ok {
|
||||
slot = st.slotFor(call.ID)
|
||||
}
|
||||
if slot == nil {
|
||||
return
|
||||
}
|
||||
summary := summarize(res.Content, nil)
|
||||
if res.IsError {
|
||||
summary = "error: " + summary
|
||||
}
|
||||
st.inflight.summary = summary
|
||||
st.inflight.status = statusOf(nil)
|
||||
slot.summary = summary
|
||||
slot.status = statusOf(nil)
|
||||
if res.IsError {
|
||||
st.inflight.status = "error"
|
||||
slot.status = "error"
|
||||
}
|
||||
st.inflight.verifyText = tailRunes(res.Content, 600)
|
||||
slot.verifyText = tailRunes(res.Content, 600)
|
||||
}
|
||||
|
||||
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
|
||||
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
|
||||
func (st *directRun) slotFor(callID string) *toolSlot {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
if callID != "" {
|
||||
return st.inflight[callID]
|
||||
}
|
||||
if len(st.inflight) == 1 {
|
||||
for _, slot := range st.inflight {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||
@@ -451,7 +545,9 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
|
||||
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
|
||||
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
|
||||
st.toolMu.Lock()
|
||||
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
|
||||
st.toolMu.Unlock()
|
||||
hasCalls := len(ev.Message.ToolCalls) > 0
|
||||
if st.turnThink.Len() > 0 {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartThinking, Text: st.turnThink.String(), Process: true, DurationMs: durationMs(st.thinkStart, st.thinkEnd)})
|
||||
@@ -473,6 +569,10 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
if body != "" {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: body, Process: hasCalls})
|
||||
st.turnBody = body
|
||||
if !hasCalls {
|
||||
// 面向用户的答复段落已出现:快通道成立,双速提醒不再需要。
|
||||
st.replied = true
|
||||
}
|
||||
st.textIdx++
|
||||
}
|
||||
}
|
||||
@@ -484,13 +584,19 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
if len(st.queued) == 0 {
|
||||
return
|
||||
}
|
||||
call := st.queued[0]
|
||||
st.queued = st.queued[1:]
|
||||
st.inflight = &toolSlot{call: call, start: st.l.now()}
|
||||
call, ok := st.takeQueued(ev.CallID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
st.toolMu.Lock()
|
||||
st.inflight[call.ID] = &toolSlot{call: call, start: st.l.now()}
|
||||
st.toolMu.Unlock()
|
||||
_ = st.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
|
||||
case "tool_execution_end":
|
||||
slot := st.inflight
|
||||
st.toolMu.Lock()
|
||||
slot := st.takeInflight(ev.CallID)
|
||||
if slot == nil {
|
||||
st.toolMu.Unlock()
|
||||
return
|
||||
}
|
||||
end := st.l.now()
|
||||
@@ -502,12 +608,38 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
st.deliverables = append(st.deliverables, slot.artifact)
|
||||
}
|
||||
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
|
||||
st.toolMu.Unlock()
|
||||
st.noteFailureOutcome(slot.status)
|
||||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
|
||||
st.inflight = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
|
||||
// 材料整理兜底(附件即保证):带附件回合结束仍无后台任务且模型也没自行
|
||||
// 委派(delegatedBackground 尊重模型判断)→ 系统确定性启动,续跑一轮让
|
||||
// 模型告知用户。ensureMaterial 幂等 + materialChecked 一次性:不续跑循环。
|
||||
// 实测动机:MiniMax 对入口提示无视、问一句澄清就收场,0 委派 0 卡片,
|
||||
// 用户全程无反馈。
|
||||
if !st.materialChecked && len(st.rc.Attachments()) > 0 && !st.delegatedBackground {
|
||||
if ens, ok := st.rc.(interface{ EnsureMaterial() string }); ok {
|
||||
st.materialChecked = true
|
||||
if note := strings.TrimSpace(ens.EnsureMaterial()); note != "" {
|
||||
return nruntime.StopDecision{Continue: true, Reason: materialBackstopReason}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 交付物确认链(I2 混合兜底):用户已在选择卡拍板,但回合结束仍未委派
|
||||
// 后台任务 → 硬性续跑一轮要求启动(一次性)。
|
||||
if st.pendingDeliverable != "" && !st.delegatedBackground && !st.deliverableNudged {
|
||||
st.deliverableNudged = true
|
||||
return nruntime.StopDecision{Continue: true, Reason: deliverableBackstopReason}
|
||||
}
|
||||
// 材料通知回合应急(弱模型不发 confirm_choice):汇报了却没让用户选
|
||||
// 交付物、也没委派任何后台 → 硬性要求发起选择卡(一次性)。
|
||||
if st.materialNotice && !st.confirmIssued && !st.delegatedBackground && !st.materialNoticeNudged {
|
||||
st.materialNoticeNudged = true
|
||||
return nruntime.StopDecision{Continue: true, Reason: materialNoticeBackstopReason}
|
||||
}
|
||||
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
|
||||
st.demoteLastText()
|
||||
if st.eff.GetBool("runtime.verify", false) {
|
||||
@@ -529,23 +661,37 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 {
|
||||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||||
}
|
||||
// P5:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料;
|
||||
// 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的
|
||||
// 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。
|
||||
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations && !st.factChecked {
|
||||
// P5→E3:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料
|
||||
// (附件解析文本 + 工具证据)。E3 收紧为两轮:第一轮照常给修正机会
|
||||
// (改值/删除/加「目标/假设」标注——标注后经 MarkedAsEstimate 机械放行);
|
||||
// 第二轮明确这是最后一次拦截并给出标注出路;两轮后不再拦截(记入检查行
|
||||
// 如实呈现),续写上限(MaxVerifyContinuations)仍兜底防死循环。
|
||||
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations {
|
||||
pairs := st.collectDeliverableText(latest)
|
||||
if len(pairs) > 0 {
|
||||
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
|
||||
st.factChecked = true
|
||||
st.addVerifyNote("事实核查:" + factCheckNotice(tokens))
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))}
|
||||
if notice, cont := st.factCheckGate(tokens); cont {
|
||||
// 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入
|
||||
// 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037
|
||||
// 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。
|
||||
st.demoteLastText()
|
||||
st.addVerifyNote("事实核查:" + notice)
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物,然后重新给出更新后的完整最终答复(先结论、再产出物与要点,可注明本次修正)。\n" + notice)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
|
||||
if err != nil {
|
||||
st.addVerifyNote(err.Error())
|
||||
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
|
||||
// 验证器没跑成不再是用户可见正文(实测 Notice 会顶掉正式总结)。
|
||||
// 同源跳过是能力说明,记通过态检查行;真失败记未通过态检查行,
|
||||
// 过程时间线里都可见,最终答复只留模型自己的收尾。
|
||||
if strings.Contains(err.Error(), "跳过同源验证") {
|
||||
st.addCheck(true, verifierUnavailableNotice)
|
||||
} else {
|
||||
st.addVerifyNote(err.Error())
|
||||
}
|
||||
return nruntime.StopDecision{}
|
||||
}
|
||||
if !verdict.Passed {
|
||||
reason := strings.TrimSpace(verdict.Reason)
|
||||
@@ -640,14 +786,24 @@ func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nru
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
|
||||
}
|
||||
st.addVerifyNote(reason)
|
||||
return nruntime.StopDecision{Notice: verifyCapNotice}
|
||||
// 达到续写上限同样收进检查行(未通过态):让用户看见检查确实没过,
|
||||
// 而不是用一句系统提示替换掉模型的最终答复。
|
||||
st.addCheck(false, verifyCapNotice)
|
||||
return nruntime.StopDecision{}
|
||||
}
|
||||
|
||||
func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
call := agentllm.ToolCall{Name: name, Arguments: args}
|
||||
if st.inflight != nil {
|
||||
call = st.inflight.call
|
||||
return st.runToolCtx(context.Background(), name, args)
|
||||
}
|
||||
|
||||
// runToolCtx 执行一个工具调用;ctx 携带内核注入的当前调用(并行执行下按
|
||||
// CallID 定位在途槽位),无注入时回落唯一在途槽位(旧内核/测试直调)。
|
||||
func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttools.Result {
|
||||
call, _ := nruntime.ToolCallFromContext(ctx)
|
||||
if call.ID == "" {
|
||||
call = agentllm.ToolCall{Name: name, Arguments: args}
|
||||
}
|
||||
slot := st.slotFor(call.ID)
|
||||
var out string
|
||||
var err error
|
||||
switch call.Name {
|
||||
@@ -675,11 +831,18 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
if sp, ok := st.rc.(interface {
|
||||
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
|
||||
}); ok {
|
||||
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), a.Task, a.Hint)
|
||||
// 材料传递(R2):模型自派的后台任务同样带上材料指引——子代理
|
||||
// 看不到父回合内联的附件全文,需指明检索通道。
|
||||
task := a.Task
|
||||
if len(st.rc.Attachments()) > 0 {
|
||||
task += delegateMaterialsNote(st.rc.Attachments())
|
||||
}
|
||||
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), task, a.Hint)
|
||||
if serr != nil {
|
||||
out = "子代理失败:" + serr.Error()
|
||||
} else {
|
||||
out = fmt.Sprintf("已在后台启动 %s,完成后会自动通知", id)
|
||||
st.delegatedBackground = true
|
||||
out = fmt.Sprintf("已在后台启动 %s(慢通道):收件箱已建进度卡,完成后结果回投该卡并自动汇报;先把快通道的即时反馈写给用户,不要等待", id)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -696,9 +859,62 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
if serr != nil {
|
||||
out = "子代理失败:" + serr.Error()
|
||||
} else {
|
||||
out = orDefault(res.Summary, "子代理完成")
|
||||
// F1 修复:回传子代理结论全文(capSubagentResult 已做 32k 截断与
|
||||
// 超长落盘指针),Summary 只是 160 字摘要——只回摘要会让父代理在
|
||||
// 摘要的摘要上凭空补细节。
|
||||
out = orDefault(res.Text, orDefault(res.Summary, "子代理完成"))
|
||||
st.pinSubagentArtifacts(res.Summary + "\n" + res.Text)
|
||||
}
|
||||
case toolConfirmChoice:
|
||||
var a struct {
|
||||
Question string `json:"question"`
|
||||
Options []struct {
|
||||
ID, Label, Description, Impact string
|
||||
Recommended bool
|
||||
} `json:"options"`
|
||||
FreeInput *bool `json:"free_input"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
}
|
||||
if uerr := json.Unmarshal([]byte(call.Arguments), &a); uerr != nil || strings.TrimSpace(a.Question) == "" || len(a.Options) < 2 {
|
||||
out = "confirm_choice 参数无效:需要 question 与 2-4 个选项(id/label/description/impact)"
|
||||
break
|
||||
}
|
||||
card := core.HITLCard{Kind: core.HITLKindChoice, Question: strings.TrimSpace(a.Question), AllowFreeInput: a.FreeInput == nil || *a.FreeInput}
|
||||
for _, o := range a.Options {
|
||||
choice := core.HITLChoice{ID: strings.TrimSpace(o.ID), Label: strings.TrimSpace(o.Label), Description: o.Description, Impact: o.Impact, Recommended: o.Recommended}
|
||||
if choice.ID == "" {
|
||||
choice.ID = choice.Label
|
||||
}
|
||||
if strings.TrimSpace(choice.Impact) == "" {
|
||||
choice.Impact = "按选项说明执行"
|
||||
}
|
||||
card.Choices = append(card.Choices, choice)
|
||||
}
|
||||
if a.TimeoutSec > 0 {
|
||||
secs := a.TimeoutSec
|
||||
card.TimeoutSec = &secs
|
||||
}
|
||||
st.confirmIssued = true
|
||||
resp, herr := st.rc.AskHITL(card)
|
||||
if herr != nil {
|
||||
out = "选择等待结束(未获得答复):" + herr.Error() + "。请在回复里列出可选项,请用户直接答复。"
|
||||
break
|
||||
}
|
||||
if id := strings.TrimSpace(resp.ChoiceID); id != "" {
|
||||
label := id
|
||||
for _, c := range card.Choices {
|
||||
if c.ID == id {
|
||||
label = c.Label
|
||||
}
|
||||
}
|
||||
st.pendingDeliverable = label
|
||||
out = "用户已选择:" + label + "。立即用 delegate_task 的 background=true 启动对应后台任务(任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点),启动后给用户一句确认反馈即可,不要等待完成。"
|
||||
} else if free := strings.TrimSpace(resp.FreeText); free != "" {
|
||||
st.pendingDeliverable = free
|
||||
out = "用户自由输入:" + free + "。若这是正式交付物要求,立即用 delegate_task 的 background=true 启动对应后台任务(描述结合材料整理结果);若是澄清或追问,先如实答复,再按需推进。"
|
||||
} else {
|
||||
out = "用户未给出明确答复。请在回复里列出可选项,请用户直接答复。"
|
||||
}
|
||||
case toolReview:
|
||||
out = st.reviewFindings(call.Arguments)
|
||||
case toolPlan:
|
||||
@@ -738,8 +954,16 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
} else {
|
||||
out = res.Output
|
||||
}
|
||||
if res.Artifact != nil && st.inflight != nil {
|
||||
st.inflight.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
// E4:R4 空命中计数 + 证据账本登记(成功且非空的证据类工具输出
|
||||
// 尾注【src_N|工具】来源标记——数字溯源从此有可指地址)。
|
||||
st.noteEvidenceCall(call.Name, res.Status, out)
|
||||
if res.Status == "ok" && evidenceTools[call.Name] && strings.TrimSpace(out) != "" {
|
||||
if tag, ok := st.tagSource(call.Name, out); ok {
|
||||
out += "\n【" + tag + "】"
|
||||
}
|
||||
}
|
||||
if res.Artifact != nil && slot != nil {
|
||||
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
if st.cardflow && res.Truncated && !st.explicitCard {
|
||||
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
|
||||
if ferr == nil {
|
||||
@@ -747,8 +971,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.inflight != nil {
|
||||
st.inflight.truncated = res.Truncated
|
||||
if slot != nil {
|
||||
slot.truncated = res.Truncated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,10 +982,10 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
|
||||
summary = "error: " + err.Error()
|
||||
out = summary
|
||||
}
|
||||
if st.inflight != nil {
|
||||
st.inflight.summary = summary
|
||||
st.inflight.status = status
|
||||
st.inflight.verifyText = tailRunes(out, 600)
|
||||
if slot != nil {
|
||||
slot.summary = summary
|
||||
slot.status = status
|
||||
slot.verifyText = tailRunes(out, 600)
|
||||
}
|
||||
if r := []rune(out); len(r) > 16000 {
|
||||
out = string(r[:16000]) + "…(超长截断)"
|
||||
@@ -952,3 +1176,65 @@ func errString(err error) string {
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// ---- 并行工具执行的辅助(E1/F5)----
|
||||
|
||||
// serialTools 顺序敏感或带会话级副作用的工具:作为内核 SerialTools 传入——
|
||||
// 并行执行下这些调用按模型声明顺序逐个执行(互斥且保序;只互斥不保序会让
|
||||
// 流式卡 card_begin→append→commit 乱序,实测踩过)。卡片流四步有先后依赖,
|
||||
// 计划/待办是整表更新,工作流与小队编排派生 run,执行原语与文件编辑共享会话
|
||||
// 工作区。读检索类与 delegate_task/review_findings 并行。
|
||||
var serialTools = map[string]bool{
|
||||
toolPlan: true, toolTodos: true,
|
||||
toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true,
|
||||
toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true,
|
||||
"run_shell": true, "run_python": true, "register_artifact": true,
|
||||
"edit": true, "multi_edit": true,
|
||||
}
|
||||
|
||||
// toolExecMode 同回合多工具调用的执行模式:runtime.parallelTools(缺省开)
|
||||
// 为 parallel(顺序敏感工具经 Config.SerialTools 由内核保序),关闭时空串
|
||||
// 回落内核缺省 sequential。
|
||||
func toolExecMode(eff core.EffectiveSettings) string {
|
||||
if eff.GetBool("runtime.parallelTools", true) {
|
||||
return nruntime.ExecParallel
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// takeQueued 按内核事件 CallID 取出队列中对应调用(并行下 start 事件乱序到达);
|
||||
// CallID 为空(旧内核/未知来源)时按原先进先出。调用方须持有 toolMu。
|
||||
func (st *directRun) takeQueued(callID string) (agentllm.ToolCall, bool) {
|
||||
if callID != "" {
|
||||
for i, c := range st.queued {
|
||||
if c.ID == callID {
|
||||
st.queued = append(st.queued[:i], st.queued[i+1:]...)
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return agentllm.ToolCall{}, false
|
||||
}
|
||||
if len(st.queued) == 0 {
|
||||
return agentllm.ToolCall{}, false
|
||||
}
|
||||
c := st.queued[0]
|
||||
st.queued = st.queued[1:]
|
||||
return c, true
|
||||
}
|
||||
|
||||
// takeInflight 按事件 CallID 取出并删除在途槽位;CallID 为空且恰有一个在途
|
||||
// 槽位时取之(兼容旧内核)。调用方须持有 toolMu。
|
||||
func (st *directRun) takeInflight(callID string) *toolSlot {
|
||||
if callID != "" {
|
||||
slot := st.inflight[callID]
|
||||
delete(st.inflight, callID)
|
||||
return slot
|
||||
}
|
||||
if len(st.inflight) == 1 {
|
||||
for _, slot := range st.inflight {
|
||||
clear(st.inflight)
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- F1:delegate_task 回传子代理全文,不再只有 160 字摘要 ----
|
||||
|
||||
func TestDelegateToolReturnsFullText(t *testing.T) {
|
||||
full := strings.Repeat("子代理结论细节。", 60) // 480 字,远超 160
|
||||
f := &fakeRunCtx{spawnAgentFn: func(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||||
return core.AgentResultPayload{Status: core.RunCompleted, Text: full, Summary: "完成(160字内摘要)"}, nil
|
||||
}}
|
||||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||||
res := st.runTool("delegate_task", `{"name":"调研","task":"查证X"}`)
|
||||
if res.IsError {
|
||||
t.Fatalf("delegate failed: %s", res.Content)
|
||||
}
|
||||
if !strings.Contains(res.Content, "子代理结论细节。子代理结论细节。子代理结论细节。") {
|
||||
t.Fatalf("delegate result must carry the full child text, got: %.120s…", res.Content)
|
||||
}
|
||||
if strings.Contains(res.Content, "160字内摘要") {
|
||||
t.Fatalf("summary must be fallback only, got: %.120s…", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F5:并行执行下工具事件按 CallID 配对 ----
|
||||
|
||||
func TestToolEventsPairByCallID(t *testing.T) {
|
||||
f := &fakeRunCtx{}
|
||||
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
|
||||
st.queued = []agentllm.ToolCall{
|
||||
{ID: "c1", Name: "knowledge_search", Arguments: `{}`},
|
||||
{ID: "c2", Name: "web_search", Arguments: `{}`},
|
||||
}
|
||||
// 乱序进出:c1 先开始,c2 先结束(并行下的真实时序)。
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "knowledge_search", CallID: "c1"})
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "web_search", CallID: "c2"})
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "web_search", CallID: "c2"})
|
||||
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "knowledge_search", CallID: "c1"})
|
||||
|
||||
if len(st.inflight) != 0 {
|
||||
t.Fatalf("inflight must be empty after both ends: %v", st.inflight)
|
||||
}
|
||||
if len(st.finalParts) != 4 {
|
||||
t.Fatalf("finalParts = %d (want 4: call+result ×2)", len(st.finalParts))
|
||||
}
|
||||
// 结束顺序决定部件顺序:先 c2 后 c1。
|
||||
if got := st.finalParts[0].ToolCall.ID; got != "c2" {
|
||||
t.Fatalf("first tool part = %s (want c2)", got)
|
||||
}
|
||||
if got := st.finalParts[1].ToolResult.CallID; got != "c2" {
|
||||
t.Fatalf("first result part = %s (want c2)", got)
|
||||
}
|
||||
if got := st.finalParts[2].ToolCall.ID; got != "c1" {
|
||||
t.Fatalf("second tool part = %s (want c1)", got)
|
||||
}
|
||||
if got := st.finalParts[3].ToolResult.CallID; got != "c1" {
|
||||
t.Fatalf("second result part = %s (want c1)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F5:执行模式与串行集合 ----
|
||||
|
||||
func TestToolExecModeAndSerialSet(t *testing.T) {
|
||||
if got := toolExecMode(core.EffectiveSettings{}); got != nruntime.ExecParallel {
|
||||
t.Fatalf("default must be parallel, got %q", got)
|
||||
}
|
||||
off := core.EffectiveSettings{"runtime": map[string]any{"parallelTools": false}}
|
||||
if got := toolExecMode(off); got != "" {
|
||||
t.Fatalf("parallelTools=false must fall back to sequential, got %q", got)
|
||||
}
|
||||
// 顺序敏感工具必须串行;可并行的读检索与委派不得误入。
|
||||
for _, name := range []string{"update_plan", "update_todos", "submit_card", "card_begin", "card_append", "card_commit", "run_workflow", "squad_orchestrate", "run_shell", "run_python", "register_artifact"} {
|
||||
if !serialTools[name] {
|
||||
t.Fatalf("serialTools missing %s", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"delegate_task", "review_findings", "knowledge_search", "web_search", "fs_read_file", "write_artifact"} {
|
||||
if serialTools[name] {
|
||||
t.Fatalf("serialTools must not contain %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F2:MiniMax 前缀自动置位 reasoning_split ----
|
||||
|
||||
func TestOpenLLMClientReasoningSplitAuto(t *testing.T) {
|
||||
if c := openLLMClient("http://x", "k", "MiniMax-M2.7-highspeed"); !c.ReasoningSplit {
|
||||
t.Fatal("MiniMax model must set ReasoningSplit")
|
||||
}
|
||||
if c := openLLMClient("http://x", "k", "MiniMax-M3"); !c.ReasoningSplit {
|
||||
t.Fatal("MiniMax M3 must set ReasoningSplit")
|
||||
}
|
||||
if c := openLLMClient("http://x", "k", "claude-sonnet-5"); c.ReasoningSplit {
|
||||
t.Fatal("non-MiniMax model must not set ReasoningSplit")
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,14 @@ type fakeRunCtx struct {
|
||||
bgID string
|
||||
bgErr error
|
||||
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
|
||||
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
|
||||
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用)
|
||||
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
|
||||
bgCalls int
|
||||
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
|
||||
attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用)
|
||||
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试)
|
||||
execOutput string // 非空时覆盖 ExecuteTool 输出(空命中/账本测试)
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) Context() context.Context { return f.ctx }
|
||||
@@ -55,7 +60,11 @@ func (f *fakeRunCtx) Settings() core.EffectiveSettings {
|
||||
if f.cardflow {
|
||||
eff["ui"] = map[string]any{"cardflow": true}
|
||||
}
|
||||
eff["runtime"] = map[string]any{"verify": f.verify, "composeFinal": f.compose}
|
||||
runtimeMap := map[string]any{"verify": f.verify, "composeFinal": f.compose}
|
||||
for k, v := range f.runtimeExtra {
|
||||
runtimeMap[k] = v
|
||||
}
|
||||
eff["runtime"] = runtimeMap
|
||||
return eff
|
||||
}
|
||||
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
|
||||
@@ -78,6 +87,15 @@ func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
func (f *fakeRunCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
|
||||
return core.AgentResultPayload{}, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// SpawnAgent 供 delegate_task 直连分支(E1/F1 测试注入);未设置时与
|
||||
// SpawnSubagent 一致报错。
|
||||
func (f *fakeRunCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||||
if f.spawnAgentFn != nil {
|
||||
return f.spawnAgentFn(agentID, name, task, hint)
|
||||
}
|
||||
return core.AgentResultPayload{}, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (f *fakeRunCtx) UpdatePlan(p core.PlanSnapshot) error { return nil }
|
||||
func (f *fakeRunCtx) UpdateTodos(l core.TodoList) error { return nil }
|
||||
func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
@@ -87,6 +105,12 @@ func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error
|
||||
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
f.toolRuns++
|
||||
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
|
||||
if f.execStatus != "" {
|
||||
res.Status = f.execStatus
|
||||
}
|
||||
if f.execOutput != "" {
|
||||
res.Output = f.execOutput
|
||||
}
|
||||
if f.execArtifact != nil {
|
||||
res.Artifact = f.execArtifact
|
||||
}
|
||||
@@ -136,7 +160,7 @@ func (f *fakeRunCtx) StoreMemory() agentmemory.Store {
|
||||
return fakeMemStore{entries: f.memEntries}
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
|
||||
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments }
|
||||
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
|
||||
func (f *fakeRunCtx) Subtask() string { return "" }
|
||||
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
@@ -1093,3 +1117,5 @@ func transcriptOf(msgs []agentllm.Message) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) Surfaces() []core.SurfaceSnapshot { return nil }
|
||||
|
||||
@@ -214,16 +214,18 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
|
||||
t.Fatalf("main calls = %d, want 1", mainCalls)
|
||||
}
|
||||
texts := textParts(rc.assistant)
|
||||
var kept, notice bool
|
||||
var kept bool
|
||||
notice := false
|
||||
for _, p := range texts {
|
||||
if p.Text == "答案是 42。" && !p.Process {
|
||||
kept = true
|
||||
}
|
||||
if !p.Process && strings.Contains(p.Text, "验证器未独立配置") {
|
||||
if !p.Process && strings.Contains(p.Text, "验证器") {
|
||||
notice = true
|
||||
}
|
||||
}
|
||||
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") {
|
||||
// 2026-10-10 契约更新:验证器不可用不再作为正文 Notice,落成过程检查行。
|
||||
if !kept || notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
|
||||
t.Fatalf("parts = %+v", texts)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// matCtx 在 fakeRunCtx 之上实现材料整理的两个可选接口(附件即保证),
|
||||
// 记录 EnsureMaterial 调用次数以断言 stop 兜底的幂等。
|
||||
type matCtx struct {
|
||||
*fakeRunCtx
|
||||
note string
|
||||
ensureN int
|
||||
lastNote string
|
||||
// confirm_choice 断言(I2)
|
||||
hitls []core.HITLCard
|
||||
hitlFree string
|
||||
hitlErr error
|
||||
}
|
||||
|
||||
func (m *matCtx) MaterialNote() string { return m.note }
|
||||
|
||||
func (m *matCtx) EnsureMaterial() string {
|
||||
m.ensureN++
|
||||
if m.ensureN > 1 {
|
||||
return ""
|
||||
}
|
||||
return m.lastNote
|
||||
}
|
||||
|
||||
// AskHITL 覆盖:预置选择默认取第一个选项;hitlFree 模拟自由输入。
|
||||
func (m *matCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
m.hitls = append(m.hitls, card)
|
||||
if m.hitlErr != nil {
|
||||
return core.HITLResponse{}, m.hitlErr
|
||||
}
|
||||
if m.hitlFree != "" {
|
||||
return core.HITLResponse{FreeText: m.hitlFree}, nil
|
||||
}
|
||||
if len(card.Choices) == 0 {
|
||||
return core.HITLResponse{}, nil
|
||||
}
|
||||
return core.HITLResponse{ChoiceID: card.Choices[0].ID}, nil
|
||||
}
|
||||
|
||||
func matAttachments() []core.ParsedDocument {
|
||||
return []core.ParsedDocument{
|
||||
{Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定 11 月上线"}}},
|
||||
{Meta: core.DocMeta{Filename: "意见.pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "预算存疑"}}},
|
||||
{Meta: core.DocMeta{Filename: "清单.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "12 项需求"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func runMaterial(t *testing.T, turns [][]agentllm.Event, rc *matCtx) *dualSpeedRecorder {
|
||||
t.Helper()
|
||||
rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns}
|
||||
r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func requestsContaining(rec *dualSpeedRecorder, needle string) int {
|
||||
n := 0
|
||||
for _, req := range rec.request {
|
||||
if strings.Contains(req, needle) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 预启动说明替换建议式提示:第 0 回合注入事实说明而非「可能属于重任务」。
|
||||
func TestTurn0MaterialNoteReplacesAdvisory(t *testing.T) {
|
||||
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, note: "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建)。"}
|
||||
rc.attachments = matAttachments()
|
||||
rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("收到,先给每份材料一句话概览。")}, rc)
|
||||
if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 {
|
||||
t.Fatal("material note not injected at turn 0")
|
||||
}
|
||||
if requestsContaining(rec, "可能属于重任务") != 0 {
|
||||
t.Fatal("advisory text should be replaced when material note present")
|
||||
}
|
||||
}
|
||||
|
||||
// 未预启动(文字+小材料):保留建议式入口提示。
|
||||
func TestTurn0AdvisoryWhenNoNote(t *testing.T) {
|
||||
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}}
|
||||
rc.attachments = matAttachments()
|
||||
rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("概览如下。")}, rc)
|
||||
if requestsContaining(rec, "可能属于重任务") == 0 {
|
||||
t.Fatal("advisory hint missing when no material note")
|
||||
}
|
||||
}
|
||||
|
||||
// stop 兜底:模型问一句澄清就收场(用户实测现象)→ 系统续跑一轮要求告知;
|
||||
// ensureMaterial 幂等保证只续跑一次。
|
||||
func TestStopMaterialBackstopContinuesOnce(t *testing.T) {
|
||||
rc := &matCtx{
|
||||
fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()},
|
||||
lastNote: "系统已自动启动材料整理后台任务",
|
||||
}
|
||||
rc.attachments = matAttachments()
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("请问你需要哪种产出?"),
|
||||
textDeltaTurn("材料正在后台整理,完成后自动汇报;先给概览。"),
|
||||
}
|
||||
rec := runMaterial(t, turns, rc)
|
||||
if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 {
|
||||
t.Fatal("backstop reason not injected as continuation")
|
||||
}
|
||||
if rc.ensureN != 1 {
|
||||
t.Fatalf("EnsureMaterial calls = %d, want exactly 1", rc.ensureN)
|
||||
}
|
||||
}
|
||||
|
||||
// 模型已自行委派后台(delegatedBackground)→ 尊重模型判断,不触发兜底。
|
||||
func TestStopMaterialBackstopSkippedWhenDelegated(t *testing.T) {
|
||||
rc := &matCtx{
|
||||
fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_1"},
|
||||
}
|
||||
rc.attachments = matAttachments()
|
||||
delegate := toolCall("delegate_task", `{"name":"深度解析","task":"解析材料","background":true}`)
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("", delegate),
|
||||
textDeltaTurn("已在后台解析,先给即时反馈。"),
|
||||
}
|
||||
rec := runMaterial(t, turns, rc)
|
||||
if rc.ensureN != 0 {
|
||||
t.Fatalf("EnsureMaterial calls = %d, want 0 (model delegated)", rc.ensureN)
|
||||
}
|
||||
if requestsContaining(rec, "自动启动材料整理后台任务") != 0 {
|
||||
t.Fatal("backstop should not fire when model delegated")
|
||||
}
|
||||
}
|
||||
|
||||
func hitlTurn(call agentllm.ToolCall) []agentllm.Event { return textDeltaTurn("", call) }
|
||||
|
||||
// 完整确认链:材料通知回合 → 汇报但没发选择卡(应急兜底)→ 发起 confirm_choice
|
||||
// → 用户选择 → 模型委派后台 → 通过(无进一步拦截)。
|
||||
func TestMaterialNoticeChoiceChain(t *testing.T) {
|
||||
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_d"}}
|
||||
rc.history = []core.Message{{Role: core.RoleSystem, Parts: []core.Part{{Type: core.PartText, Text: "<system-reminder>\n后台子代理「材料整理」已结束。\n状态:completed\n整理完成\n</system-reminder>"}}}}
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("整理完成:三份材料要点如下……"), // 未发选择卡 → 应急兜底
|
||||
hitlTurn(toolCall("confirm_choice", `{"question":"接下来生成什么?","options":[{"id":"a","label":"需求文档","description":"会议含明确需求项","impact":"产出需求文档"},{"id":"b","label":"会议摘要","description":"快速回顾","impact":"产出摘要"}]}`)), // AskHITL → 用户选第一个
|
||||
textDeltaTurn("好的,启动需求文档任务……", toolCall("delegate_task", `{"name":"生成需求文档","task":"基于整理结果生成需求文档","background":true}`)),
|
||||
textDeltaTurn("已在后台启动需求文档,完成后自动汇报。"),
|
||||
}
|
||||
rec := runMaterial(t, turns, rc)
|
||||
if requestsContaining(rec, "还没有让用户选择正式交付物") == 0 {
|
||||
t.Fatal("notice backstop (confirm_choice nudge) missing")
|
||||
}
|
||||
if len(rc.hitls) != 1 || rc.hitls[0].Kind != core.HITLKindChoice {
|
||||
t.Fatalf("hitl cards = %d", len(rc.hitls))
|
||||
}
|
||||
if requestsContaining(rec, "用户已选择:需求文档") == 0 {
|
||||
t.Fatal("choice answer not surfaced to model")
|
||||
}
|
||||
if requestsContaining(rec, "还没有启动") != 0 {
|
||||
t.Fatal("deliverable backstop should not fire after model delegated")
|
||||
}
|
||||
if rc.ensureN != 0 {
|
||||
t.Fatal("material backstop must not run in notice turns (no attachments)")
|
||||
}
|
||||
}
|
||||
|
||||
// 用户已选定但模型未委派 → 硬性续跑一轮要求启动;第二轮委派后放行。
|
||||
func TestDeliverableBackstopForcesDelegation(t *testing.T) {
|
||||
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_e"}}
|
||||
turns := [][]agentllm.Event{
|
||||
hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"会议摘要","description":"快","impact":"摘要"},{"id":"b","label":"评审报告","description":"全","impact":"报告"}]}`)),
|
||||
textDeltaTurn("好的,马上生成会议摘要。"), // 未委派 → 兜底
|
||||
textDeltaTurn("这就启动。", toolCall("delegate_task", `{"name":"生成会议摘要","task":"生成摘要","background":true}`)),
|
||||
textDeltaTurn("已在后台生成会议摘要,稍后回投。"),
|
||||
}
|
||||
rec := runMaterial(t, turns, rc)
|
||||
// 只触发一次由脚本回合数锁死:再续跑一次会多一次模型调用,脚本耗尽报错。
|
||||
if requestsContaining(rec, "还没有启动") == 0 {
|
||||
t.Fatal("deliverable backstop missing")
|
||||
}
|
||||
if requestsContaining(rec, "用户已选择:会议摘要") == 0 {
|
||||
t.Fatal("choice answer not surfaced to model")
|
||||
}
|
||||
}
|
||||
|
||||
// 自由输入也应回到同一回合并进入待交付状态。
|
||||
func TestConfirmChoiceFreeTextSetsPending(t *testing.T) {
|
||||
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, hitlFree: "做成对比表格"}
|
||||
turns := [][]agentllm.Event{
|
||||
hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)),
|
||||
textDeltaTurn("收到,将输出对比表格。", toolCall("delegate_task", `{"name":"对比表格","task":"输出对比表格","background":true}`)),
|
||||
textDeltaTurn("已在后台生成对比表格。"),
|
||||
}
|
||||
rec := runMaterial(t, turns, rc)
|
||||
if requestsContaining(rec, "用户自由输入:做成对比表格") == 0 {
|
||||
t.Fatal("free input not surfaced to model")
|
||||
}
|
||||
if requestsContaining(rec, "还没有启动") != 0 {
|
||||
t.Fatal("no backstop expected when model delegated after free input")
|
||||
}
|
||||
}
|
||||
|
||||
// 参数无效(选项不足)不建卡,直接给模型纠错说明。
|
||||
func TestConfirmChoiceRejectsInvalidArgs(t *testing.T) {
|
||||
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}}
|
||||
turns := [][]agentllm.Event{
|
||||
hitlTurn(toolCall("confirm_choice", `{"question":"选什么?"}`)),
|
||||
textDeltaTurn("改用两个选项重试。", toolCall("confirm_choice", `{"question":"选什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)),
|
||||
textDeltaTurn("这就启动摘要。", toolCall("delegate_task", `{"name":"生成摘要","task":"生成摘要","background":true}`)),
|
||||
textDeltaTurn("已在后台生成摘要。"),
|
||||
}
|
||||
runMaterial(t, turns, rc)
|
||||
if len(rc.hitls) != 1 {
|
||||
t.Fatalf("invalid args should not create card; hitls = %d", len(rc.hitls))
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,20 @@ func (m *MockRunner) Execute(rc RunCtx) error {
|
||||
}
|
||||
parts = append(parts, res.Parts...)
|
||||
|
||||
case strings.Contains(user, "#background"):
|
||||
// 慢通道演示(任务快慢双速,SR-B10):快通道先反馈,后台子代理
|
||||
// 进度进收件箱卡片,完成后结果回投该卡并发起汇报回合。
|
||||
addText("✅ 快通道:材料已接收,先给结论——这是 mock 演示任务。深度解析已放慢通道后台执行:进度看右栏收件箱卡片,完成后自动回投结果并汇报。")
|
||||
if sp, ok := rc.(interface {
|
||||
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
|
||||
}); ok {
|
||||
if _, err := sp.SpawnBackground("agent.sub.general", "深度解析员", "对上传材料逐页解析并生成要点摘要(mock 后台演示)", "演示任务"); err != nil {
|
||||
addText("后台启动失败:" + err.Error())
|
||||
}
|
||||
} else {
|
||||
addText("当前运行上下文不支持后台派发(演示不可用)")
|
||||
}
|
||||
|
||||
case strings.Contains(user, "#error"):
|
||||
return fmt.Errorf("mock: 演示失败路径(#error 指令)")
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ type RunCtx interface {
|
||||
Card(id string) *core.ActivityCard
|
||||
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;无则 false)。
|
||||
Artifact(id string) (core.Artifact, []byte, bool)
|
||||
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌)。
|
||||
Surfaces() []core.SurfaceSnapshot
|
||||
// SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。
|
||||
SetContextObservation(o core.ContextObservation)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func newModelRouter(rc RunCtx) modelRouter {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return modelRouter{}
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
return modelRouter{stream: client.Stream, model: route.Model}
|
||||
}
|
||||
|
||||
|
||||
@@ -461,3 +461,5 @@ func containsStr(list []string, want string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (*entryFake) Surfaces() []core.SurfaceSnapshot { return nil }
|
||||
|
||||
@@ -19,6 +19,11 @@ const subagentFallback = "你是被派生的子代理。独立完成给定子任
|
||||
|
||||
const defaultSubagentID = "agent.sub.general"
|
||||
|
||||
// materialSubagentID 材料整理子代理(附件即保证):系统确定性派发,不依赖
|
||||
// 提示词库配置即可工作——spec 缺省在代码内(对话同档模型 + 检索;fs 工具是
|
||||
// 预留能力默认关,材料通道 = 任务正文内联 + knowledge_search)。
|
||||
const materialSubagentID = "agent.sub.material"
|
||||
|
||||
func fallbackSubagentSpec() prompts.SubagentSpec {
|
||||
return prompts.SubagentSpec{
|
||||
Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"},
|
||||
@@ -26,6 +31,13 @@ func fallbackSubagentSpec() prompts.SubagentSpec {
|
||||
}
|
||||
}
|
||||
|
||||
func materialSubagentSpec() prompts.SubagentSpec {
|
||||
return prompts.SubagentSpec{
|
||||
Tools: []string{"knowledge_search", "time_now"},
|
||||
Model: "chat",
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SubLLMRunner) Name() string { return "llm-subagent" }
|
||||
|
||||
func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
@@ -36,11 +48,18 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
}
|
||||
}
|
||||
spec := fallbackSubagentSpec()
|
||||
if id == materialSubagentID {
|
||||
spec = materialSubagentSpec()
|
||||
}
|
||||
if l.Prompts != nil {
|
||||
if got, ok := l.Prompts.SubagentSpec(rc.Profile().Owner, id); ok {
|
||||
spec = got
|
||||
}
|
||||
}
|
||||
// 系统派发的模型档覆盖(材料整理 = 对话同档):优先于 spec 默认。
|
||||
if src, ok := rc.(interface{ Submodel() string }); ok && src.Submodel() == "chat" {
|
||||
spec.Model = "chat"
|
||||
}
|
||||
maxTurns := spec.MaxTurns
|
||||
if maxTurns <= 0 {
|
||||
maxTurns = math.MaxInt32
|
||||
@@ -54,7 +73,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base)
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
stream = client.Stream
|
||||
}
|
||||
if spec.Model == "chat" {
|
||||
@@ -253,6 +272,21 @@ func (st *subRun) runTool(name, args string) agenttools.Result {
|
||||
return agenttools.Result{Content: summary}
|
||||
}
|
||||
|
||||
// delegateMaterialsNote 模型自派后台任务的材料指引(R2):子代理看不到父回合
|
||||
// 内联的附件全文,指明 knowledge_search 检索通道(上传时解析文本已进索引)。
|
||||
func delegateMaterialsNote(docs []core.ParsedDocument) string {
|
||||
names := make([]string, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
if n := strings.TrimSpace(d.Meta.Filename); n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "\n\n[系统补充] 本会话材料文件:" + strings.Join(names, "、") + "。解析全文可用 knowledge_search 以文件名为关键词检索获取。"
|
||||
}
|
||||
|
||||
func subagentProfileID(agent string) string {
|
||||
switch agent {
|
||||
case "explore", "research", "plan", "verify", "general", "writer":
|
||||
|
||||
@@ -2,6 +2,7 @@ package runtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +26,22 @@ func systemCheckReason(body string) string {
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n</system-reminder>"
|
||||
}
|
||||
|
||||
// materialBackstopReason 材料整理兜底(附件即保证)的续跑指令:不走
|
||||
// systemCheckReason 的「核实修复」前缀——这里没有待修的检查,只有事实告知
|
||||
// 与职责提醒;ensureMaterial 幂等保证只续跑一轮。
|
||||
const materialBackstopReason = "<system-reminder>\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n</system-reminder>"
|
||||
|
||||
// materialNoticeMarker 材料整理完成通知的稳定标记:runmgr 的通知正文自然
|
||||
// 含此串;runtime 依此识别「本回合负有发起交付物选择卡的职责」。
|
||||
const materialNoticeMarker = "「材料整理」已结束"
|
||||
|
||||
// deliverableBackstopReason 用户已选定交付物但后台任务未启动的硬性续跑指令。
|
||||
const deliverableBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。用户已经在选择卡上做出决定,但对应的后台任务还没有启动。立即调用 delegate_task(background=true)启动它:任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点;启动后用一句话向用户确认即可,不要等待完成,也不要再次询问用户。\n</system-reminder>"
|
||||
|
||||
// materialNoticeBackstopReason 材料整理通知回合未发起选择卡的应急指令
|
||||
// (弱模型不发 confirm_choice 时兜底,一次性)。
|
||||
const materialNoticeBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。材料整理已完成并汇报,但还没有让用户选择正式交付物。立即调用 confirm_choice 工具发起选择卡:问题「基于整理结果,接下来生成什么?」;选项从整理结果的「建议后续处理」取 3-4 项(description 写一句材料依据,最贴合的一项 recommended=true),允许自由输入;取不出建议时用默认选项:材料摘要、评审报告、需求文档、架构方案。发起后简短收尾即可。\n</system-reminder>"
|
||||
|
||||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
stream := l.VerifyStream
|
||||
@@ -41,7 +58,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
|
||||
stream = agentllm.WithFallback(
|
||||
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
|
||||
@@ -149,6 +166,18 @@ func (st *directRun) reminders(turn int) []string {
|
||||
st.closedToolTurn = false
|
||||
st.checklistTouched = false
|
||||
}
|
||||
// 任务快慢双速(SR-B10 实路径触发):run 已运行超过阈值、用户还没看到任何
|
||||
// 答复、也没把深度工作放后台时,一次性提醒转双速——先把判断/计划写给用户,
|
||||
// 重活 delegate 后台。已经回复或已委派则永不打扰;接近完成的任务可按文案
|
||||
// 自行忽略。这是内核每回合的 Reminders 注入点(agent-native 循环原生钩子)。
|
||||
if note := st.dualSpeedNudge(); note != "" {
|
||||
return []string{note}
|
||||
}
|
||||
// E4(R4):harness 检测到的换路条件优先于计划提醒——原地打转时
|
||||
// 计划提醒没有意义。
|
||||
if notes := st.escalationReminders(); len(notes) > 0 {
|
||||
return notes
|
||||
}
|
||||
if st.toolTurnsSince < gates.ReminderToolGap {
|
||||
return nil
|
||||
}
|
||||
@@ -175,6 +204,48 @@ func (st *directRun) reminders(turn int) []string {
|
||||
return got
|
||||
}
|
||||
|
||||
// dualSpeedNudgeSeconds:双速提醒的运行时长阈值(秒)。0 = 关闭。
|
||||
const dualSpeedNudgeDefaultSeconds = 75
|
||||
|
||||
// 双速提醒升级:首次被无视后每 45 秒重复、最多 3 次,第二次起改硬性措辞。
|
||||
// 实测 MiniMax 对单次 system-reminder 无动于衷(opus 一次即响应)——重复+升级是
|
||||
// 不改内核契约的前提下最强的请求侧手段;仍无效的模型记录到任务卡,后续考虑
|
||||
// 系统级强制拆分(cancel + 后台续跑)方案。
|
||||
const (
|
||||
dualSpeedRepeatEvery = 45 * time.Second
|
||||
dualSpeedMaxNudges = 3
|
||||
)
|
||||
|
||||
// dualSpeedNudge 返回双速提醒文案;不满足条件或已达重复上限返回空串。
|
||||
// 条件用注入时钟(l.now)计量,测试可控。
|
||||
func (st *directRun) dualSpeedNudge() string {
|
||||
after := int(st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds))
|
||||
if after <= 0 || st.replied || st.delegatedBackground {
|
||||
return ""
|
||||
}
|
||||
if st.started.IsZero() {
|
||||
return ""
|
||||
}
|
||||
elapsed := st.l.now().Sub(st.started)
|
||||
if elapsed < time.Duration(after)*time.Second {
|
||||
return ""
|
||||
}
|
||||
due := 1 + int((elapsed-time.Duration(after)*time.Second)/dualSpeedRepeatEvery)
|
||||
if due > dualSpeedMaxNudges {
|
||||
due = dualSpeedMaxNudges
|
||||
}
|
||||
if st.nudgedDualSpeedCount >= due {
|
||||
return ""
|
||||
}
|
||||
st.nudgedDualSpeedCount = due
|
||||
secs := int(elapsed.Round(time.Second).Seconds())
|
||||
log.Printf("[runtime] dual-speed nudge #%d injected at %ds (replied=%v delegated=%v)", due, secs, st.replied, st.delegatedBackground)
|
||||
if due == 1 {
|
||||
return fmt.Sprintf("本次任务已连续运行约 %d 秒,用户还没有收到任何答复(任务快慢双速,SR-B10)。现在二选一:若剩余工作量大(预计还要一分钟以上),先把你的判断、概要或执行计划作为即时反馈回复给用户,同时用 delegate_task 的 background=true 把深度处理放到后台执行(收件箱有进度卡,完成后系统自动回投结果并汇报);若已接近完成,把这句话忽略,继续做完后在最终答复里给出完整结论。不要默默长时间工作。", secs)
|
||||
}
|
||||
return fmt.Sprintf("用户已经等待约 %d 秒(第 %d 次提醒,硬性要求):立即停止后续工具调用,把当前进展、结论或计划作为回复发给用户;剩余的深度处理用 delegate_task 的 background=true 放到后台执行,完成后系统会自动回投结果。不要再让用户在没有反馈的情况下等待。", secs, due)
|
||||
}
|
||||
|
||||
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
|
||||
func (st *directRun) unfinishedItems() (todos, plans []string) {
|
||||
for _, item := range st.todos.Items {
|
||||
|
||||
@@ -79,7 +79,7 @@ func judgeStreamFor(rc RunCtx) agentllm.StreamFunc {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return nil
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||||
model := route.Model
|
||||
return func(ctx context.Context, _ string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
return client.Stream(ctx, model, msgs, tools, opts)
|
||||
|
||||
@@ -291,3 +291,5 @@ func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
|
||||
t.Fatal("未配置模型应为 nil(验证器不可用 fail-closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func (*wfFake) Surfaces() []core.SurfaceSnapshot { return nil }
|
||||
|
||||
@@ -45,8 +45,13 @@ func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) {
|
||||
}
|
||||
texts := textParts(rc.assistant)
|
||||
last := texts[len(texts)-1]
|
||||
if last.Process || !strings.Contains(last.Text, "本轮回复未通过终轮检查") {
|
||||
t.Fatalf("final notice = %+v, want non-process verify cap notice", last)
|
||||
// 2026-10-10 契约更新:封顶提示不再作为正文 Notice;最后一次回复留在正式区,
|
||||
// 封顶事实以未通过态检查行收进过程区。
|
||||
if last.Process || last.Text != "答案 v4。" {
|
||||
t.Fatalf("final text = %+v, want the model's own last reply", last)
|
||||
}
|
||||
if !hasProcessNote(rc.assistant, "本轮回复未通过终轮检查") {
|
||||
t.Fatalf("cap check note missing: %+v", rc.assistant)
|
||||
}
|
||||
// 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。
|
||||
notes := 0
|
||||
|
||||
@@ -279,3 +279,28 @@ func RandomPassword() string {
|
||||
rand.Read(raw)
|
||||
return hex.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// ---- 管理员:恢复隔离的核对与解除(协作存储 U3)----
|
||||
|
||||
// listRecoveryHolds 列出未解除的 recovery hold(人工核对的运维入口)。
|
||||
func (s *Server) listRecoveryHolds(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
holds, err := s.st.ListOpenRecoveryHolds()
|
||||
writeJSONOr(w, map[string]any{"holds": holds}, err)
|
||||
}
|
||||
|
||||
// releaseRecoveryHold 人工解除单条 hold(幂等);解除后该 run 才可能被再次
|
||||
// 续跑/接管。副作用未核对就解除的风险由操作者承担——与 task-close 同级谨慎。
|
||||
func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
runID := r.PathValue("runId")
|
||||
if _, err := s.st.GetRecoveryHold(runID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.ReleaseRecoveryHold(runID))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 协作存储 U3 切片 2(KTD13):动作授权矩阵。未配置的动作默认拒绝;
|
||||
// 旧只读 EXT-8 适配器只保留 read 能力;action-aware 适配器注册后权威。
|
||||
|
||||
func newMatrixServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s := &Server{}
|
||||
t.Cleanup(func() {})
|
||||
return s
|
||||
}
|
||||
|
||||
// 无任何适配器:全部动作拒绝(默认拒绝;与 EXT-8 未注册时读取 NotFound 同基调)。
|
||||
func TestActionMatrixDefaultDeny(t *testing.T) {
|
||||
s := newMatrixServer(t)
|
||||
for _, a := range []ProjectAction{ProjectRead, ProjectEdit, ProjectApprove, ProjectExecute, ProjectAdmin} {
|
||||
if s.authorizeProjectAction("alice", "p1", a) {
|
||||
t.Fatalf("未注册适配器时 %s 应默认拒绝", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旧只读适配器:read 放行、其余动作不得自动获得写入能力(KTD13 兼容语义)。
|
||||
func TestActionMatrixLegacyAdapterReadOnly(t *testing.T) {
|
||||
s := newMatrixServer(t)
|
||||
s.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "alice", nil
|
||||
})
|
||||
if !s.authorizeProjectAction("alice", "p1", ProjectRead) {
|
||||
t.Fatal("alice read 应放行(旧适配器)")
|
||||
}
|
||||
for _, a := range []ProjectAction{ProjectEdit, ProjectApprove, ProjectExecute, ProjectAdmin} {
|
||||
if s.authorizeProjectAction("alice", "p1", a) {
|
||||
t.Fatalf("旧只读适配器不得自动获得 %s 能力", a)
|
||||
}
|
||||
}
|
||||
// 他人(动作越权):read 同样拒绝。
|
||||
if s.authorizeProjectAction("bob", "p1", ProjectRead) {
|
||||
t.Fatal("bob 越权 read 应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// action-aware 适配器:注册后权威(read 与显式配置的动作放行,未配置动作
|
||||
// 默认拒绝;出错 fail-closed)。
|
||||
func TestActionMatrixActionAwareAuthoritative(t *testing.T) {
|
||||
s := newMatrixServer(t)
|
||||
// 同时注册旧适配器:action-aware 存在时不得回落旧适配器(权威唯一)。
|
||||
s.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
s.SetProjectActionAuthorizer(func(owner string, projectID core.ID, action ProjectAction) (bool, error) {
|
||||
switch action {
|
||||
case ProjectRead, ProjectEdit:
|
||||
return owner == "alice", nil
|
||||
default:
|
||||
return false, nil // approve/execute/admin 未授予
|
||||
}
|
||||
})
|
||||
if !s.authorizeProjectAction("alice", "p1", ProjectRead) || !s.authorizeProjectAction("alice", "p1", ProjectEdit) {
|
||||
t.Fatal("alice read/edit 应放行")
|
||||
}
|
||||
for _, a := range []ProjectAction{ProjectApprove, ProjectExecute, ProjectAdmin} {
|
||||
if s.authorizeProjectAction("alice", "p1", a) {
|
||||
t.Fatalf("未授予的 %s 应默认拒绝", a)
|
||||
}
|
||||
}
|
||||
if s.authorizeProjectAction("bob", "p1", ProjectEdit) {
|
||||
t.Fatal("bob 越权 edit 应拒绝")
|
||||
}
|
||||
// 适配器出错:fail-closed(错误不区分,避免泄露存在性)。
|
||||
s.SetProjectActionAuthorizer(func(owner string, projectID core.ID, action ProjectAction) (bool, error) {
|
||||
return false, errBoom
|
||||
})
|
||||
if s.authorizeProjectAction("alice", "p1", ProjectRead) {
|
||||
t.Fatal("适配器出错应 fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
var errBoom = &boomErr{}
|
||||
|
||||
type boomErr struct{}
|
||||
|
||||
func (*boomErr) Error() string { return "boom" }
|
||||
@@ -0,0 +1,91 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
)
|
||||
|
||||
// WP3-I3:GET /api/admin-zone/sections 展平编译插件的分区声明并按
|
||||
// Order→ID 稳定排序;无声明时空数组(非 null)。
|
||||
func TestListAdminZoneSections(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Auth = NewAuthenticator(st, true)
|
||||
srv.CompiledPlugins = []CompiledPlugin{
|
||||
{ID: "rdm", AdminZones: []AdminZoneSection{
|
||||
{ID: "rdm.audit", Title: "项目追溯与审计", Icon: "search", Order: 20},
|
||||
}},
|
||||
{ID: "other", AdminZones: []AdminZoneSection{
|
||||
{ID: "other.z", Title: "Z", Order: 10},
|
||||
{ID: "other.a", Title: "A", Order: 10},
|
||||
}},
|
||||
}
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
// 未认证拒绝。
|
||||
if rec := httptest.NewRecorder(); func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin-zone/sections", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}().Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated request should 401")
|
||||
}
|
||||
rec := get("/api/admin-zone/sections")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got []AdminZoneSection
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantIDs := []string{"other.a", "other.z", "rdm.audit"} // Order 同为 10 的按 ID,再 Order 20
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("sections = %+v", got)
|
||||
}
|
||||
for i, w := range wantIDs {
|
||||
if got[i].ID != w {
|
||||
t.Fatalf("sections[%d] = %s, want %s (full: %+v)", i, got[i].ID, w, got)
|
||||
}
|
||||
}
|
||||
// 无声明时空数组非 null。
|
||||
srv.CompiledPlugins = nil
|
||||
if rec := get("/api/admin-zone/sections"); rec.Body.String() == "null" {
|
||||
t.Fatalf("empty listing must be [] not null: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
+42
-9
@@ -11,6 +11,15 @@ import (
|
||||
// ownerCtxKey 携带租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||||
type ownerCtxKey struct{}
|
||||
|
||||
// authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。
|
||||
type authViaCtxKey struct{}
|
||||
|
||||
// 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。
|
||||
const (
|
||||
AuthViaKey = "key"
|
||||
AuthViaSession = "session"
|
||||
)
|
||||
|
||||
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
|
||||
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
|
||||
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
|
||||
@@ -40,29 +49,41 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
}
|
||||
// 凭证二选一:Authorization: Bearer <api key>(程序化;?key= 兼容 SSE 老客户端)
|
||||
// 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。
|
||||
// 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求
|
||||
// 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie,
|
||||
// 行为不变(两者都无效才 401)。
|
||||
key := bearerKey(r)
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("key")
|
||||
}
|
||||
if key != "" {
|
||||
owner, ok := a.st.AuthOwner(key)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
if owner, ok := a.st.AuthOwner(key); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withOwner(r, owner)))
|
||||
if owner, ok := cookieOwner(r, a.st); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(SessionCookie); err == nil {
|
||||
if owner, ok := a.st.SessionOwner(c.Value); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withOwner(r, owner)))
|
||||
return
|
||||
}
|
||||
if owner, ok := cookieOwner(r, a.st); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
// cookieOwner 会话 cookie 有效时返回其 owner。
|
||||
func cookieOwner(r *http.Request, st *store.Store) (string, bool) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
return st.SessionOwner(tok)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// sessionToken 从请求取会话明文(无会话返回空)。
|
||||
func sessionToken(r *http.Request) string {
|
||||
if c, err := r.Cookie(SessionCookie); err == nil {
|
||||
@@ -75,6 +96,18 @@ func withOwner(r *http.Request, owner string) context.Context {
|
||||
return context.WithValue(r.Context(), ownerCtxKey{}, owner)
|
||||
}
|
||||
|
||||
func withAuthVia(ctx context.Context, via string) context.Context {
|
||||
return context.WithValue(ctx, authViaCtxKey{}, via)
|
||||
}
|
||||
|
||||
// AuthVia 返回当前请求的凭证通道(未启用认证/豁免路径为空串)。
|
||||
func AuthVia(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(authViaCtxKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func bearerKey(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
|
||||
@@ -429,3 +429,67 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
t.Fatalf("session must die after logout: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaleKeySessionFallback:浏览器残留失效 Bearer key 时,有效会话不被一票否决;
|
||||
// /api/me 的 authVia 区分凭证通道(key / session),供前端区分改密门放行范围。
|
||||
func TestStaleKeySessionFallback(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
hash, err := store.HashPassword("init-pass-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("carol", "卡罗", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"carol","password":"init-pass-01"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
|
||||
|
||||
// 会话 + 失效 key:回落会话(200,authVia=session),不再被 key 一票否决。
|
||||
req = httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
|
||||
req.Header.Set("Authorization", "Bearer ask_stale_key")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
|
||||
t.Fatalf("stale key + valid session must fall back to session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 失效 key 且无会话:仍 401(key 专属文案)。
|
||||
req = httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer ask_stale_key")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "invalid or revoked API key") {
|
||||
t.Fatalf("stale key alone must 401 with key error: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 有效 key:authVia=key。
|
||||
key, err := st.CreateAPIKey("carol", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"key"`) {
|
||||
t.Fatalf("me via key: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 纯会话:authVia=session。
|
||||
req = httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
|
||||
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
type compiledListView struct {
|
||||
Manifest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Provides struct {
|
||||
Commands []string `json:"commands"`
|
||||
Intents []string `json:"intents"`
|
||||
} `json:"provides"`
|
||||
Backend struct {
|
||||
Mode string `json:"mode"`
|
||||
} `json:"backend"`
|
||||
} `json:"manifest"`
|
||||
}
|
||||
|
||||
// WP3-I4:/api/plugins 合并编译插件条目——backend.mode=inproc、apiVersion 取
|
||||
// 扩展面版本、provides 来自注册面摘要;目录插件同名时目录条目权威不重复。
|
||||
func TestListPluginsMergesCompiled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
|
||||
pluginsRoot := filepath.Join(dir, "plugins")
|
||||
// 目录插件与编译条目同名:目录条目权威,编译合成条目不重复列出。
|
||||
if err := os.MkdirAll(filepath.Join(pluginsRoot, "generative-ui"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"id":"generative-ui","name":"目录版","version":"1.2.3","apiVersion":"1.0","provides":{}}`
|
||||
if err := os.WriteFile(filepath.Join(pluginsRoot, "generative-ui", "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ph := plugins.New(st, reg, setSvc, pluginsRoot)
|
||||
for id, err := range ph.LoadDir() {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDir %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Auth = NewAuthenticator(st, true)
|
||||
srv.CompiledPlugins = []CompiledPlugin{
|
||||
{ID: "rdm", Commands: []string{"rdm.ingest", "rdm.inspect.capabilities"}, Intents: []string{"rdm.write-proposal"}},
|
||||
{ID: "generative-ui", Commands: []string{"generative-ui.present"}},
|
||||
}
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/plugins", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var list []compiledListView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rdm, genui *compiledListView
|
||||
genuiCount := 0
|
||||
for i := range list {
|
||||
switch list[i].Manifest.ID {
|
||||
case "rdm":
|
||||
rdm = &list[i]
|
||||
case "generative-ui":
|
||||
genui = &list[i]
|
||||
genuiCount++
|
||||
}
|
||||
}
|
||||
if rdm == nil {
|
||||
t.Fatalf("compiled rdm entry missing: %s", rec.Body.String())
|
||||
}
|
||||
if rdm.Manifest.Name != "rdm" || rdm.Manifest.Backend.Mode != "inproc" {
|
||||
t.Fatalf("rdm entry = %+v", rdm.Manifest)
|
||||
}
|
||||
if rdm.Manifest.APIVersion != extension.SurfaceVersion {
|
||||
t.Fatalf("rdm apiVersion = %q, want %q", rdm.Manifest.APIVersion, extension.SurfaceVersion)
|
||||
}
|
||||
if len(rdm.Manifest.Provides.Commands) != 2 || len(rdm.Manifest.Provides.Intents) != 1 {
|
||||
t.Fatalf("rdm provides = %+v", rdm.Manifest.Provides)
|
||||
}
|
||||
if genuiCount != 1 || genui == nil {
|
||||
t.Fatalf("generative-ui listed %d times", genuiCount)
|
||||
}
|
||||
// 目录条目权威:同名编译条目不重复、manifest 不被合成版覆盖。
|
||||
if genui.Manifest.Name != "目录版" || genui.Manifest.Version != "1.2.3" || genui.Manifest.Backend.Mode != "" {
|
||||
t.Fatalf("genui entry = %+v", genui.Manifest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
)
|
||||
|
||||
// 协作存储 U7 故障矩阵(§7「磁盘满/备份恢复/整机失效」行的磁盘满维度):
|
||||
// 对象目录不可写时,写入类入口必须**清楚拒绝**(明确 5xx,绝不 2xx 静默或
|
||||
// 半成品落库),且恢复后不留后遗症。注入不依赖 chmod(CI 容器以 root 运行,
|
||||
// 0500 目录仍可写):把 artifacts 目录移走并在原位放置同名普通文件,
|
||||
// blobstore 临时文件写入得到 ENOTDIR——与磁盘满同为 os.WriteFile 层的
|
||||
// 持久性失败。
|
||||
//
|
||||
// 覆盖:
|
||||
// 1. 上传(POST /api/projects/{id}/uploads)在对象目录失效时 5xx,
|
||||
// artifacts 表零新增、对象目录零残留;恢复后同一上传成功。
|
||||
// 2. 工件内容覆写(PUT /api/artifacts/{id}/content)同样拒绝,
|
||||
// 且原内容保持可读(拒绝不产生半写状态)。
|
||||
|
||||
// breakArtifactsDir 将 <dir>/artifacts 移至 <dir>/artifacts.moved 并在原位
|
||||
// 放置同名文件;返回恢复函数。
|
||||
func breakArtifactsDir(t *testing.T, dataDir string) (restore func()) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(dataDir, "artifacts")
|
||||
moved := dir + ".moved"
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatalf("move artifacts dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(dir, []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatalf("plant file at artifacts path: %v", err)
|
||||
}
|
||||
return func() {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
t.Fatalf("remove planted file: %v", err)
|
||||
}
|
||||
if err := os.Rename(moved, dir); err != nil {
|
||||
t.Fatalf("restore artifacts dir: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// diskFullServer 自建可控 dataDir 的服务(artifacts 根 = <dataDir>/artifacts,
|
||||
// 见 store.OpenSQLite 的 finishOpen 约定),其余装配与 admission/e2e 一致。
|
||||
func diskFullServer(t *testing.T) (http.Handler, *store.Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, &runtime.MockRunner{}, &runtime.MockRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
t.Cleanup(mgr.Shutdown)
|
||||
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Bus = cmdbus.New(st, nil, nil)
|
||||
srv.WireBuiltinCommands()
|
||||
return srv.Handler(), st, dir
|
||||
}
|
||||
|
||||
func multipartUpload(t *testing.T, h http.Handler, projectID, filename string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(fw, bytes.NewReader([]byte("disk-full probe content: "+filename))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func artifactCount(t *testing.T, st *store.Store, projectID string) (int, map[string]bool) {
|
||||
t.Helper()
|
||||
arts, err := st.ListArtifacts(store.OwnerDefault, projectID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, a := range arts {
|
||||
names[a.Name] = true
|
||||
}
|
||||
return len(arts), names
|
||||
}
|
||||
|
||||
func objectsIn(t *testing.T, dir string) map[string]bool {
|
||||
t.Helper()
|
||||
out := map[string]bool{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
out[e.Name()] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUploadRejectsCleanlyWhenObjectDirUnwritable(t *testing.T) {
|
||||
h, st, dir := diskFullServer(t)
|
||||
code, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "disk-full"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("create project: %d %v", code, out)
|
||||
}
|
||||
projID := out["id"].(string)
|
||||
|
||||
seed := multipartUpload(t, h, projID, "seed.txt")
|
||||
if seed.Code != http.StatusOK {
|
||||
t.Fatalf("seed upload: %d %.200s", seed.Code, seed.Body.String())
|
||||
}
|
||||
beforeCount, _ := artifactCount(t, st, projID)
|
||||
beforeObjs := objectsIn(t, filepath.Join(dir, "artifacts"))
|
||||
|
||||
restore := breakArtifactsDir(t, dir)
|
||||
rec := multipartUpload(t, h, projID, "probe-diskfull.txt")
|
||||
if rec.Code < 500 {
|
||||
t.Fatalf("upload under broken object dir: status %d (want explicit 5xx rejection), body %.200s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got, _ := artifactCount(t, st, projID); got != beforeCount {
|
||||
t.Fatalf("rejected upload left DB rows: %d -> %d", beforeCount, got)
|
||||
}
|
||||
if got := objectsIn(t, filepath.Join(dir, "artifacts.moved")); len(got) != len(beforeObjs) {
|
||||
t.Fatalf("rejected upload left object residue: %v -> %v", beforeObjs, got)
|
||||
}
|
||||
restore()
|
||||
|
||||
after := multipartUpload(t, h, projID, "probe-diskfull-recovered.txt")
|
||||
if after.Code != http.StatusOK {
|
||||
t.Fatalf("upload after recovery: %d %.200s", after.Code, after.Body.String())
|
||||
}
|
||||
if got, names := artifactCount(t, st, projID); got != beforeCount+1 || !names["probe-diskfull-recovered.txt"] {
|
||||
t.Fatalf("recovered upload missing row: count %d names %v", got, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactOverwriteRejectsCleanlyWhenObjectDirUnwritable(t *testing.T) {
|
||||
h, st, dir := diskFullServer(t)
|
||||
code, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "disk-full-2"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("create project: %d %v", code, out)
|
||||
}
|
||||
projID := out["id"].(string)
|
||||
|
||||
seed := multipartUpload(t, h, projID, "base.txt")
|
||||
if seed.Code != http.StatusOK {
|
||||
t.Fatalf("seed upload: %d %.200s", seed.Code, seed.Body.String())
|
||||
}
|
||||
arts, err := st.ListArtifacts(store.OwnerDefault, projID)
|
||||
if err != nil || len(arts) != 1 {
|
||||
t.Fatalf("seed artifacts: %d err %v", len(arts), err)
|
||||
}
|
||||
artID := string(arts[0].ID)
|
||||
|
||||
getContent := func() string {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+artID+"/content", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get content: %d %.200s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
before := getContent()
|
||||
|
||||
restore := breakArtifactsDir(t, dir)
|
||||
put := httptest.NewRequest(http.MethodPut, "/api/artifacts/"+artID+"/content", bytes.NewReader([]byte("half-written?")))
|
||||
putRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(putRec, put)
|
||||
if putRec.Code < 500 {
|
||||
t.Fatalf("overwrite under broken object dir: status %d (want explicit 5xx)", putRec.Code)
|
||||
}
|
||||
restore()
|
||||
|
||||
// 原位文件占位已撤;拒绝期间不允许半写污染读取路径。
|
||||
if got := getContent(); got != before {
|
||||
t.Fatalf("rejected overwrite altered readable content: %q -> %q", before, got)
|
||||
}
|
||||
|
||||
put2 := httptest.NewRequest(http.MethodPut, "/api/artifacts/"+artID+"/content", bytes.NewReader([]byte("recovered write")))
|
||||
put2Rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(put2Rec, put2)
|
||||
if put2Rec.Code != http.StatusOK {
|
||||
t.Fatalf("overwrite after recovery: %d %.200s", put2Rec.Code, put2Rec.Body.String())
|
||||
}
|
||||
if got := getContent(); got != "recovered write" {
|
||||
t.Fatalf("content after recovered overwrite: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
)
|
||||
|
||||
// B 类硬化红测:button 入口命中写意图时不嵌套分发(无目标命令 receipt)。
|
||||
func TestSystemIntentButtonNoNestedDispatch(t *testing.T) {
|
||||
var writes int
|
||||
b := cmdbus.New(nopAudit{}, nil, nil)
|
||||
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话"},
|
||||
"/new", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
writes++
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Intents().Declare(cmdbus.Intent{
|
||||
ID: "demo.new", Phrases: []string{"新建会话"}, Command: "conversation.create", Plugin: "test",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
|
||||
ID: "system.intent", Entrance: "button", Owner: "alice",
|
||||
Params: map[string]any{"text": "请新建会话"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := res.(map[string]any)
|
||||
if !ok || m["matched"] != true || m["dispatched"] != false {
|
||||
t.Fatalf("button match must not dispatch: %+v", res)
|
||||
}
|
||||
if writes != 0 {
|
||||
t.Fatalf("nested write command must not run, writes=%d", writes)
|
||||
}
|
||||
|
||||
// chat 入口仍可嵌套分发(既有行为)
|
||||
res2, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
|
||||
ID: "system.intent", Entrance: "chat", Owner: "alice",
|
||||
Params: map[string]any{"text": "请新建会话"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2, _ := res2.(map[string]any)
|
||||
if m2["dispatched"] != true || writes != 1 {
|
||||
t.Fatalf("chat entrance should nest-dispatch: %+v writes=%d", res2, writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemIntentUnmatchedCandidates(t *testing.T) {
|
||||
b := cmdbus.New(nopAudit{}, nil, nil)
|
||||
_ = b.Intents().Declare(cmdbus.Intent{
|
||||
ID: "demo.x", Phrases: []string{"特定短语"}, Command: "builtin.help", Plugin: "test",
|
||||
})
|
||||
res, err := handleSystemIntent(context.Background(), b, cmdbus.Request{
|
||||
Entrance: "button", Params: map[string]any{"text": "无关内容"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := res.(map[string]any)
|
||||
if m["matched"] != false {
|
||||
t.Fatalf("want unmatched, got %+v", res)
|
||||
}
|
||||
cands, _ := m["candidates"].([]map[string]any)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("want 1 candidate, got %+v", m["candidates"])
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -185,3 +187,149 @@ func messageText(m core.Message) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---- Generative UI Surface(提案 U2;EXT-17)。直接 API 与插件命令共用
|
||||
// surfacesvc.Submit 单一咽喉:禁用/epoch/校验/CAS 守卫不因入口不同而绕过。----
|
||||
|
||||
func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Surfaces == nil {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
PluginID string `json:"pluginId"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
SurfaceID string `json:"surfaceId"`
|
||||
BaseRevision int `json:"baseRevision"`
|
||||
ObservedEpoch int64 `json:"observedEpoch"`
|
||||
ContextVersion string `json:"contextVersion"`
|
||||
SemanticText string `json:"semanticText"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PluginID == "" || len(body.Spec) == 0 {
|
||||
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
snap, err := s.Surfaces.Submit(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
|
||||
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
|
||||
ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, snap)
|
||||
}
|
||||
|
||||
func (s *Server) getSurface(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Surfaces == nil {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
func (s *Server) listSurfaces(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Surfaces == nil {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
metas, err := s.Surfaces.List(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, orEmptySlice(metas))
|
||||
}
|
||||
|
||||
func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Surfaces == nil {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "enabled required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
row, err := s.Surfaces.SetActivation(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled)
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Surfaces == nil {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
|
||||
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
|
||||
var body struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Revision int `json:"revision"`
|
||||
RequestID string `json:"requestId"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ActionID == "" || body.RequestID == "" {
|
||||
http.Error(w, "actionId and requestId required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
res, err := s.Surfaces.SubmitAction(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
|
||||
SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID,
|
||||
Revision: body.Revision, RequestID: body.RequestID, Data: body.Data,
|
||||
})
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// writeSurfaceError 把服务层确定错误映射到状态码:禁用/epoch=409 语义(准入
|
||||
// 截止),规格违规=400 带字段清单,冲突=409 带当前版本,NotFound=404。
|
||||
func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) {
|
||||
var invalid *surfacesvc.ErrInvalidSpec
|
||||
var conflict *store.ErrRevisionConflict
|
||||
switch {
|
||||
case errors.Is(err, surfacesvc.ErrDisabled), errors.Is(err, surfacesvc.ErrStaleEpoch),
|
||||
errors.Is(err, surfacesvc.ErrUnavailable):
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
case errors.Is(err, surfacesvc.ErrUnknownAction):
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case errors.Is(err, store.ErrIdempotencyConflict):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
case errors.As(err, &invalid):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid spec", "details": invalid.Errs})
|
||||
case errors.As(err, &conflict):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "revision conflict", "current": conflict.Current})
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// listProjectSurfaces 项目级跨会话页面聚合(U6/R8):owner 授权作用域。
|
||||
func (s *Server) listProjectSurfaces(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Surfaces == nil {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
metas, err := s.Surfaces.ListByProject(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, orEmptySlice(metas))
|
||||
}
|
||||
|
||||
+205
-31
@@ -14,6 +14,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -31,8 +32,10 @@ import (
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/internal/workflow"
|
||||
"agent-system-backend/pkg/extension"
|
||||
"agent-system-backend/web"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -45,11 +48,12 @@ type Server struct {
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
@@ -63,13 +67,54 @@ type Server struct {
|
||||
// ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口
|
||||
// owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。
|
||||
ProjectAuthz func(owner string, projectID core.ID) (bool, error)
|
||||
// ProjectAuthzActions 动作级授权适配器(协作存储 U3,KTD13):注册后对
|
||||
// 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律
|
||||
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
|
||||
// 计划见 docs/guides/team-storage.md(KTD13 一节)。
|
||||
ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error)
|
||||
// CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled
|
||||
// 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。
|
||||
CompiledPlugins []CompiledPlugin
|
||||
}
|
||||
|
||||
// ProjectAction 项目资源的动作授权矩阵(KTD13)。共享可读不等于可写或可
|
||||
// 批准:动作语义独立判定,未配置的动作默认拒绝。
|
||||
type ProjectAction string
|
||||
|
||||
const (
|
||||
ProjectRead ProjectAction = "read" // 读取项目资源(会话/消息/检索/工件下载)
|
||||
ProjectEdit ProjectAction = "edit" // 写入与修改(会话内发消息、卡片裁决、设置变更)
|
||||
ProjectApprove ProjectAction = "approve" // 审批(HITL 确认、命令确认)
|
||||
ProjectExecute ProjectAction = "execute" // 触发执行(run、工具、工作流)
|
||||
ProjectAdmin ProjectAction = "admin" // 管理(成员、共享范围、销毁性操作)
|
||||
)
|
||||
|
||||
// SetProjectAuthorizer 组合根在装载扩展后注入 EXT-8 适配器。
|
||||
func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (bool, error)) {
|
||||
s.ProjectAuthz = fn
|
||||
}
|
||||
|
||||
// SetProjectActionAuthorizer 组合根注入 KTD13 动作级适配器(版本化公开契约
|
||||
// 扩展落地前,宿主侧先以本槽位提供矩阵语义)。
|
||||
func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core.ID, action ProjectAction) (bool, error)) {
|
||||
s.ProjectAuthzActions = fn
|
||||
}
|
||||
|
||||
// authorizeProjectAction 动作矩阵唯一判定(KTD13):
|
||||
// - action-aware 适配器已注册 → 权威(出错/拒绝一律 false,fail-closed);
|
||||
// - 未注册 → read 回落旧只读 ProjectAuthz(兼容 EXT-8 现网),其余动作
|
||||
// 默认拒绝(旧适配器不得自动获得写入能力)。
|
||||
func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action ProjectAction) bool {
|
||||
if s.ProjectAuthzActions != nil {
|
||||
ok, err := s.ProjectAuthzActions(owner, projectID, action)
|
||||
return err == nil && ok
|
||||
}
|
||||
if action == ProjectRead {
|
||||
return s.authorizeSharedProject(owner, projectID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
|
||||
// (fail-closed;错误不区分,避免泄露存在性)。
|
||||
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
|
||||
@@ -122,32 +167,10 @@ func (s *Server) WireBuiltinCommands() {
|
||||
return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助", len(b.List()))}, nil
|
||||
}))
|
||||
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由",
|
||||
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单",
|
||||
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单;button 入口只读匹配、不嵌套分发",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
|
||||
"/intent", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
text, _ := req.Params["text"].(string)
|
||||
in, ok := b.Intents().Match(text)
|
||||
if !ok {
|
||||
list := b.Intents().List()
|
||||
cands := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
|
||||
}
|
||||
return map[string]any{"matched": false, "candidates": cands}, nil
|
||||
}
|
||||
params := map[string]any{}
|
||||
for k, v := range in.Params {
|
||||
params[k] = v
|
||||
}
|
||||
for k, v := range req.Params {
|
||||
if k != "text" {
|
||||
params[k] = v // 调用方可覆盖模板参数
|
||||
}
|
||||
}
|
||||
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
|
||||
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
|
||||
"receipt": resp.Receipt, "payload": resp.Payload}, nil
|
||||
return handleSystemIntent(ctx, b, req)
|
||||
}))
|
||||
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)},
|
||||
@@ -191,6 +214,38 @@ func (s *Server) WireBuiltinCommands() {
|
||||
}))
|
||||
}
|
||||
|
||||
// handleSystemIntent 实现 system.intent:命中后默认嵌套分发目标命令。
|
||||
// B 类硬化:Entrance=button(页面动作)只返回匹配结果,禁止嵌套业务写路径。
|
||||
func handleSystemIntent(ctx context.Context, b *cmdbus.Bus, req cmdbus.Request) (any, error) {
|
||||
text, _ := req.Params["text"].(string)
|
||||
in, ok := b.Intents().Match(text)
|
||||
if !ok {
|
||||
list := b.Intents().List()
|
||||
cands := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
|
||||
}
|
||||
return map[string]any{"matched": false, "candidates": cands}, nil
|
||||
}
|
||||
if agentcommand.CanonicalEntrance(req.Entrance) == "button" {
|
||||
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
|
||||
"dispatched": false, "note": "button entrance: match only, no nested dispatch"}, nil
|
||||
}
|
||||
params := map[string]any{}
|
||||
for k, v := range in.Params {
|
||||
params[k] = v
|
||||
}
|
||||
for k, v := range req.Params {
|
||||
if k != "text" {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
|
||||
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
|
||||
"dispatched": true, "receipt": resp.Receipt, "payload": resp.Payload}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -272,9 +327,18 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/tools/{name}", s.deleteUserTool)
|
||||
mux.HandleFunc("GET /api/plugins", s.listPlugins)
|
||||
mux.HandleFunc("POST /api/plugins/{id}/enabled", s.setPluginEnabled)
|
||||
mux.HandleFunc("POST /api/plugins/{id}/activation", s.setSurfaceActivation)
|
||||
mux.HandleFunc("POST /api/surfaces", s.submitSurface)
|
||||
mux.HandleFunc("GET /api/surfaces/{id}", s.getSurface)
|
||||
mux.HandleFunc("POST /api/surfaces/{id}/actions", s.submitSurfaceAction)
|
||||
mux.HandleFunc("GET /api/conversations/{id}/surfaces", s.listSurfaces)
|
||||
mux.HandleFunc("GET /api/projects/{id}/surfaces", s.listProjectSurfaces)
|
||||
mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins)
|
||||
mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema)
|
||||
mux.HandleFunc("GET /api/registry", s.registry)
|
||||
// WP3-I3/KTD4:管理区声明分区清单(编译插件经 Registry.AddAdminZone 声明;
|
||||
// 展示元数据对所有认证主体开放,分区内数据能力由组件命令面 fail-closed)。
|
||||
mux.HandleFunc("GET /api/admin-zone/sections", s.listAdminZoneSections)
|
||||
|
||||
// ---- 计划 / 待办 / 命令 / 解析器 / 检索 ----
|
||||
mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan)
|
||||
@@ -303,6 +367,8 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// ---- 管理(嵌入式档在线备份)----
|
||||
mux.HandleFunc("POST /api/admin/backup", s.backup)
|
||||
mux.HandleFunc("GET /api/admin/recovery-holds", s.listRecoveryHolds)
|
||||
mux.HandleFunc("POST /api/admin/recovery-holds/{runId}/release", s.releaseRecoveryHold)
|
||||
|
||||
// API 未匹配路径一律 404(不回落 SPA,避免 /api/* 返回 HTML 200)。
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
|
||||
@@ -560,13 +626,14 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。
|
||||
// authVia 区分凭证通道:改密门只对会话登录者放行,key 持有者不能改密(无密码属主)。
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
name := owner
|
||||
if u, err := s.st.GetUser(owner); err == nil && u.Name != "" {
|
||||
name = u.Name
|
||||
}
|
||||
out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled}
|
||||
out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled, "authVia": AuthVia(r)}
|
||||
if st, err := s.st.GetUserAuthState(owner); err == nil {
|
||||
out["isAdmin"] = st.IsAdmin
|
||||
out["mustChangePassword"] = st.MustChangePassword
|
||||
@@ -765,6 +832,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
claimed := false
|
||||
if routeKind == route.KindTurn || routeKind == route.KindWorkflow {
|
||||
// 执行许可预检(U3,R8):待执行队列已满时 429 且不写任何数据
|
||||
//(与忙 409 同位;排队中的接纳不受影响)。
|
||||
if s.mgr.QueueFull() {
|
||||
http.Error(w, "execution queue full(runtime.execQueueMax)", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if err := s.mgr.ClaimConversation(convID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -862,6 +935,7 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: id, Params: params, Entrance: "slash", Actor: s.owner(r),
|
||||
Owner: s.owner(r), ConversationID: conv.ID,
|
||||
HostProjectID: string(conv.ProjectID),
|
||||
})
|
||||
out := map[string]any{"command": slash, "receipt": resp.Receipt}
|
||||
if m, ok := resp.Payload.(map[string]any); ok {
|
||||
@@ -919,9 +993,16 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if body.Params == nil {
|
||||
body.Params = map[string]any{}
|
||||
}
|
||||
var hostProject string
|
||||
if body.ConversationID != "" {
|
||||
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
|
||||
hostProject = string(conv.ProjectID)
|
||||
}
|
||||
}
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
|
||||
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
|
||||
HostProjectID: hostProject,
|
||||
})
|
||||
code := http.StatusOK
|
||||
if resp.Receipt.State == agentcommand.StateRejected {
|
||||
@@ -954,7 +1035,27 @@ func (s *Server) listAudits(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.mgr.CancelRun(r.PathValue("id")))
|
||||
runID := r.PathValue("id")
|
||||
// 跨实例取消(协作存储 U5/KTD5)**前置**:run 不在本实例执行(无句柄)
|
||||
// 且 PG 档——写持久命令,由执行实例消费;API 立即受理(202)。
|
||||
// 不得先调本地 CancelRun:非执行实例无句柄时它返回 nil 的「假成功」
|
||||
// 会吞掉命令路径,run 永远停不下(U7 双进程验收实测)。
|
||||
// conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远
|
||||
// 无法送达(同批实测)。
|
||||
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
|
||||
convID := ""
|
||||
if run, gerr := s.st.GetRun(s.owner(r), runID); gerr == nil {
|
||||
convID = string(run.ConversationID)
|
||||
}
|
||||
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, convID, 0,
|
||||
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil {
|
||||
writeStoreErr(w, cerr)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.mgr.CancelRun(runID))
|
||||
}
|
||||
|
||||
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -977,6 +1078,21 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
resp.CardID = r.PathValue("id")
|
||||
pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp)
|
||||
if err != nil && s.st.Dialect() == "postgres" {
|
||||
// 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例
|
||||
// 消费时重验 gate(cardID+gateVersion),过期审批明确拒绝。
|
||||
if card, gerr := s.st.GetHITLCard(s.owner(r), string(resp.CardID)); gerr == nil && !card.Resolved {
|
||||
kind := resp.ChoiceID
|
||||
if kind == "" {
|
||||
kind = "free"
|
||||
}
|
||||
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "hitl", string(card.RunID), string(card.ConversationID), 0,
|
||||
runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}); cerr == nil {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1354,6 +1470,28 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- 插件 ----
|
||||
|
||||
// CompiledPlugin 是一个编译进二进制的进程内插件的注册面摘要(WP3-I4):
|
||||
// ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。
|
||||
// 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。
|
||||
type CompiledPlugin struct {
|
||||
ID string
|
||||
Commands []string
|
||||
Intents []string
|
||||
Skills []string
|
||||
AdminZones []AdminZoneSection
|
||||
}
|
||||
|
||||
// AdminZoneSection 是编译插件声明的管理区展示分区(WP3-I3/KTD4):
|
||||
// 字段与 extension.AdminZoneSpec 对齐(组合根装载期记录);入口不做基座级
|
||||
// 权限判定,分区内数据能力由组件自身命令面 fail-closed。
|
||||
type AdminZoneSection struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
type pluginView struct {
|
||||
Manifest core.PluginManifest `json:"manifest"`
|
||||
Panels []core.PluginPanel `json:"panels"`
|
||||
@@ -1369,13 +1507,28 @@ type pluginView struct {
|
||||
|
||||
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
var out []pluginView
|
||||
dirIDs := map[string]bool{}
|
||||
for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) {
|
||||
dirIDs[ld.Manifest.ID] = true
|
||||
out = append(out, pluginView{
|
||||
Manifest: ld.Manifest, Panels: ld.Panels, Cards: ld.Cards,
|
||||
Workflows: ld.Workflows, Intents: ld.Intents, Prompts: ld.Prompts, Settings: ld.PluginSettings,
|
||||
Pages: ld.Pages, Channels: ld.Channels, Skills: ld.Skills,
|
||||
})
|
||||
}
|
||||
// 编译插件(WP3-I4):条目由注册面合成——backend.mode=inproc 标记编译
|
||||
// 来源,apiVersion 取扩展面版本;与目录插件同名时目录 manifest 权威
|
||||
// (目录版本可装卸、信息更全),编译条目不重复列出。
|
||||
for _, cp := range s.CompiledPlugins {
|
||||
if dirIDs[cp.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginView{Manifest: core.PluginManifest{
|
||||
ID: cp.ID, Name: cp.ID, APIVersion: extension.SurfaceVersion,
|
||||
Provides: core.PluginProvides{Commands: cp.Commands, Intents: cp.Intents, Skills: cp.Skills},
|
||||
Backend: core.PluginBackend{Mode: "inproc"},
|
||||
}})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, orEmptySlice(out))
|
||||
}
|
||||
|
||||
@@ -1388,6 +1541,23 @@ func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
|
||||
}
|
||||
|
||||
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
|
||||
// 声明并按 Order→ID 稳定排序。声明是展示元数据,对所有认证主体一致;
|
||||
// 分区内可见数据由分区组件自身的命令面按 owner fail-closed 判定。
|
||||
func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) {
|
||||
var out []AdminZoneSection
|
||||
for _, cp := range s.CompiledPlugins {
|
||||
out = append(out, cp.AdminZones...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Order != out[j].Order {
|
||||
return out[i].Order < out[j].Order
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
writeJSON(w, http.StatusOK, orEmptySlice(out))
|
||||
}
|
||||
|
||||
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
|
||||
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
|
||||
reg := map[string]any{
|
||||
@@ -1611,6 +1781,10 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, runmgr.ErrAdmissionQueueFull) {
|
||||
http.Error(w, err.Error(), http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
// Generative UI U2 红测(提案 KTD7):直接 API 与插件命令共用 surfacesvc
|
||||
// 单一咽喉——禁用状态下 POST /api/surfaces 必须被拒(不能因入口绕过执行级
|
||||
// 启禁),读取与启禁端点行为符合「历史保留 + epoch 纪元」语义。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
)
|
||||
|
||||
func TestSurfaceDirectAPIGuards(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := surfacesvc.New(st, nil)
|
||||
srv.Surfaces = svc
|
||||
|
||||
post := func(path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
spec := `"spec":{"title":"t","nodes":[{"type":"markdown","id":"n1","markdown":"x"}]}`
|
||||
|
||||
// 未启用:提交被拒(403),提示能力存在但插件未启用
|
||||
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("disabled submit should 403, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 启用(activation 端点走服务层,epoch=1)
|
||||
if rec := post("/api/plugins/genui/activation", `{"enabled":true}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("enable failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 提交成功 → 201
|
||||
rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("enabled submit should 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var snap struct {
|
||||
Identity struct {
|
||||
SurfaceID string `json:"surfaceId"`
|
||||
} `json:"identity"`
|
||||
Revision int `json:"revision"`
|
||||
}
|
||||
readJSON(t, rec.Body.Bytes(), &snap)
|
||||
|
||||
// 读取可用;列表可用
|
||||
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("get failed: %d", rec.Code)
|
||||
}
|
||||
if rec := get("/api/conversations/c1/surfaces"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("list failed: %d", rec.Code)
|
||||
}
|
||||
// 禁用后:提交 403;读取仍 200(历史保留)
|
||||
if rec := post("/api/plugins/genui/activation", `{"enabled":false}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("disable failed: %d", rec.Code)
|
||||
}
|
||||
if rec := post("/api/surfaces", `{"pluginId":"genui","conversationId":"c1",`+spec+`}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("post-disable submit should 403, got %d", rec.Code)
|
||||
}
|
||||
if rec := get("/api/surfaces/" + snap.Identity.SurfaceID); rec.Code != http.StatusOK {
|
||||
t.Fatalf("post-disable read should stay 200, got %d", rec.Code)
|
||||
}
|
||||
// 未带凭据:401(认证面同现有守卫)
|
||||
if rec := httptest.NewRecorder(); true {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"genui"}`))
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated should 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// U5:动作端点红测——禁用 403、未知动作 400、端点不绑定 confirmed(伪造无入口)。
|
||||
func TestSurfaceActionEndpoint(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, _ := st.CreateAPIKey("alice", "e2e")
|
||||
svc := surfacesvc.New(st, nil)
|
||||
svc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||||
return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil
|
||||
})
|
||||
srv.Surfaces = svc
|
||||
ctx := context.Background()
|
||||
svc.SetActivation(ctx, "alice", "generative-ui", true)
|
||||
spec := `"spec":{"title":"表单页","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"提交"}]}]}`
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/surfaces", bytes.NewBufferString(`{"pluginId":"generative-ui","conversationId":"c1",`+spec+`}`))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
rec := post("")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("setup submit failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var snap struct {
|
||||
Identity struct {
|
||||
SurfaceID string `json:"surfaceId"`
|
||||
} `json:"identity"`
|
||||
Revision int `json:"revision"`
|
||||
}
|
||||
readJSON(t, rec.Body.Bytes(), &snap)
|
||||
|
||||
act := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", bytes.NewBufferString(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
// 未知动作 → 400
|
||||
if r := act(`{"actionId":"shell.exec","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r1","data":{}}`); r.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown action should 400, got %d: %s", r.Code, r.Body.String())
|
||||
}
|
||||
// 伪造 confirmed 字段:请求体里带 confirmed:true 不产生任何效果(端点不绑定
|
||||
// 该字段;DangerHigh 确认只能经确认卡回道)。此处 submit 非 DangerHigh,
|
||||
// 断言 confirmed 被忽略(成功且不因它改变行为)。
|
||||
if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r2","data":{"a":"1"},"confirmed":true}`); r.Code != http.StatusOK {
|
||||
t.Fatalf("submit with forged confirmed should still be normal submit, got %d: %s", r.Code, r.Body.String())
|
||||
}
|
||||
// 禁用后 → 403
|
||||
svc.SetActivation(ctx, "alice", "generative-ui", false)
|
||||
if r := act(`{"actionId":"generative-ui.submit","revision":` + fmt.Sprint(snap.Revision) + `,"requestId":"r3","data":{}}`); r.Code != http.StatusForbidden {
|
||||
t.Fatalf("post-disable action should 403, got %d", r.Code)
|
||||
}
|
||||
_ = fmt.Sprint
|
||||
}
|
||||
@@ -34,14 +34,16 @@ func SystemDefaults() map[string]any {
|
||||
"tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具)
|
||||
},
|
||||
"runtime": map[string]any{
|
||||
"mock": false, // true = 全部会话走 mock runner(演示/测试)
|
||||
"squad": true, // 小队编排(Agent-Squad 引擎)可用
|
||||
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
|
||||
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
|
||||
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
|
||||
"verify": true, // 终轮确定性检查与独立验证
|
||||
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
|
||||
"codeOrchestration": false, // 模型用脚本批量调用工具;默认关
|
||||
"mock": false, // true = 全部会话走 mock runner(演示/测试)
|
||||
"squad": true, // 小队编排(Agent-Squad 引擎)可用
|
||||
"entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认)
|
||||
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
|
||||
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
|
||||
"verify": true, // 终轮确定性检查与独立验证
|
||||
"llmRetries": 2, // 直连模型在尚未输出时的额外重试次数
|
||||
"codeOrchestration": false, // 模型用脚本批量调用工具;默认关
|
||||
"materialIntake": true, // 附件即保证:带附件回合系统确定性启动材料整理后台任务
|
||||
"materialIntakeChars": 12000, // 带文字发送时材料总量达到该字符数也预启动(否则回合结束兜底)
|
||||
},
|
||||
"storage": map[string]any{
|
||||
"dataDir": "./data", // 启动注入实际值(--data)
|
||||
@@ -57,13 +59,18 @@ func SystemDefaults() map[string]any {
|
||||
"read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读
|
||||
},
|
||||
"retrieval": map[string]any{"topK": 5},
|
||||
"ui": map[string]any{"theme": "dark", "cardflow": false},
|
||||
"ui": map[string]any{"theme": "dark", "cardflow": true}, // 长产物进卡片默认开(用户 2026-10-11 拍板)
|
||||
"deliverables": map[string]any{
|
||||
"style": "modern", // modern / formal / minimal
|
||||
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
|
||||
"templateArtifactId": "", // 非空 = 该工件作为默认模板,落到 inputs/_default-template*
|
||||
},
|
||||
"plugins": map[string]any{"dir": "./plugins"},
|
||||
"plugins": map[string]any{
|
||||
"dir": "./plugins",
|
||||
"generative-ui": map[string]any{
|
||||
"autoPresent": false, // 自动建议页面:默认关;需同时开启 Surface activation
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +98,16 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
|
||||
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
|
||||
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
|
||||
{Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"},
|
||||
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
|
||||
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
|
||||
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
|
||||
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
|
||||
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
|
||||
{Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"},
|
||||
{Key: "runtime.dualSpeedNudgeSeconds", Group: "runtime", Label: "快慢双速提醒阈值(秒)", Type: core.SettingNumber, Default: 75, Effect: core.EffectNewRun, Description: "任务连续运行超过该秒数、用户还没收到任何答复、也没把深度工作放后台时,一次性提醒模型转快慢双速:先把判断/概要/计划回复给用户,重活用 delegate_task 的 background 放后台(SR-B10)。0 = 关闭"},
|
||||
{Key: "runtime.materialIntake", Group: "runtime", Label: "附件即材料整理", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "带附件的发送由系统确定性启动材料整理后台任务(无文字或大材料立即启动;文字+小材料回合结束兜底),进度卡即时入收件箱。关闭 = 回到模型自行判断"},
|
||||
{Key: "runtime.materialIntakeChars", Group: "runtime", Label: "材料预启动阈值(字符)", Type: core.SettingNumber, Default: 12000, Effect: core.EffectNewRun, Description: "带文字发送时,附件解析文本总量达到该字符数也立即预启动材料整理;未达到则模型先答、回合结束兜底"},
|
||||
{Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"},
|
||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
||||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||||
@@ -112,7 +123,7 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},
|
||||
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
|
||||
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
|
||||
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"},
|
||||
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"},
|
||||
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||||
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
|
||||
@@ -120,6 +131,7 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||||
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
||||
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
||||
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 协作存储 U3 切片 2(R8/KTD「接纳与执行许可分别计数」):执行许可的共享
|
||||
// 权威落在数据库(admission_leases),多实例天然共享;接纳数(排队中的 run)
|
||||
// 由 runmgr 的有界队列管理,不进本表。预算关闭(全零)时不产生任何租约行。
|
||||
|
||||
// ExecutionBudget 执行许可预算。零值 = 不限(本地档默认,行为与历史一致)。
|
||||
type ExecutionBudget struct {
|
||||
Global int // 全局并发执行上限(含子代理与索引任务);0=不限
|
||||
PerUser int // 每租户并发执行上限;0=不限
|
||||
}
|
||||
|
||||
// Off 表示预算完全关闭(不限流、不留租约)。
|
||||
func (b ExecutionBudget) Off() bool { return b.Global <= 0 && b.PerUser <= 0 }
|
||||
|
||||
// ErrBudgetExceeded 预算已满(可排队);ErrAdmissionQueueFull 是其排队也满的
|
||||
// 上层形态(runmgr 定义,映射 429)。
|
||||
var ErrBudgetExceeded = errors.New("store: execution budget exceeded")
|
||||
|
||||
// TryAcquireExecutionSlot 原子获取(或重新激活)run 的执行许可:在 admission
|
||||
// 咨询锁下清点活跃租约(全局 + 租户),不超预算才登记。父 run 让出后重新
|
||||
// 获取走同一路径(upsert 复活既有行)。
|
||||
func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBudget) error {
|
||||
if budget.Off() {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.lockAdmission(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
var global, perUser int
|
||||
if budget.Global > 0 {
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM admission_leases WHERE released_at IS NULL AND run_id<>?`), runID).Scan(&global); err != nil {
|
||||
return err
|
||||
}
|
||||
if global >= budget.Global {
|
||||
return fmt.Errorf("exec slot %s: %w", runID, ErrBudgetExceeded)
|
||||
}
|
||||
}
|
||||
if budget.PerUser > 0 {
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM admission_leases WHERE released_at IS NULL AND owner=? AND run_id<>?`), owner, runID).Scan(&perUser); err != nil {
|
||||
return err
|
||||
}
|
||||
if perUser >= budget.PerUser {
|
||||
return fmt.Errorf("exec slot %s: %w", runID, ErrBudgetExceeded)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO admission_leases (run_id, owner, acquired_at, released_at) VALUES (?,?,?,NULL)
|
||||
ON CONFLICT(run_id) DO UPDATE SET owner=excluded.owner, acquired_at=excluded.acquired_at, released_at=NULL`),
|
||||
runID, owner, nowISO()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ReleaseExecutionSlot 释放执行许可(幂等):父 run 等待子代理/HITL 时让出,
|
||||
// 恢复执行须重新获取(方案 KTD「让出执行许可、保留会话所有权」)。
|
||||
func (s *Store) ReleaseExecutionSlot(runID string) error {
|
||||
_, err := s.db.Exec(s.q(`UPDATE admission_leases SET released_at=? WHERE run_id=? AND released_at IS NULL`), nowISO(), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ActiveExecutionSlots 返回活跃执行许可数(诊断/验收:全局与按租户)。
|
||||
func (s *Store) ActiveExecutionSlots() (global int, byOwner map[string]int, err error) {
|
||||
byOwner = map[string]int{}
|
||||
rows, err := s.db.Query(s.q(`SELECT owner, COUNT(*) FROM admission_leases WHERE released_at IS NULL GROUP BY owner`))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var o string
|
||||
var n int
|
||||
if err := rows.Scan(&o, &n); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
byOwner[o] = n
|
||||
global += n
|
||||
}
|
||||
return global, byOwner, rows.Err()
|
||||
}
|
||||
|
||||
// lockAdmission 在事务内锁定执行许可账本(PG 事务级咨询锁;SQLite 单写者
|
||||
// 天然串行)。与 lockConversation/lockScope 同模式(dialect 收敛)。
|
||||
func (s *Store) lockAdmission(tx *sql.Tx) error {
|
||||
return s.dialect.lockAdmission(tx)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 协作存储 U3 切片 2(R8):执行许可账本。预算判定原子性、让出/复活、
|
||||
// 并发恰 N 个赢家。双档(PG 由 CI 真库 job 覆盖咨询锁与部分索引)。
|
||||
|
||||
func TestExecutionBudgetEnforced(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
budget := ExecutionBudget{Global: 2}
|
||||
if err := st.TryAcquireExecutionSlot("run_1", "o", budget); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.TryAcquireExecutionSlot("run_2", "o", budget); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := st.TryAcquireExecutionSlot("run_3", "o", budget)
|
||||
if !errors.Is(err, ErrBudgetExceeded) {
|
||||
t.Fatalf("global=2 时第三个应拒绝:%v", err)
|
||||
}
|
||||
// 释放一个 → 第三个可进。
|
||||
if err := st.ReleaseExecutionSlot("run_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.TryAcquireExecutionSlot("run_3", "o", budget); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 复活(父让出后重取):释放 run_2 再复活 run_2。
|
||||
if err := st.ReleaseExecutionSlot("run_2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.TryAcquireExecutionSlot("run_2", "o", budget); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
global, byOwner, err := st.ActiveExecutionSlots()
|
||||
if err != nil || global != 2 || byOwner["o"] != 2 {
|
||||
t.Fatalf("活跃账本:global=%d byOwner=%v err=%v", global, byOwner, err)
|
||||
}
|
||||
// 预算关闭:零开销直通、不留租约。
|
||||
if err := st.TryAcquireExecutionSlot("run_9", "o", ExecutionBudget{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionBudgetPerUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
budget := ExecutionBudget{PerUser: 1}
|
||||
if err := st.TryAcquireExecutionSlot("run_a", "alice", budget); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.TryAcquireExecutionSlot("run_b", "bob", budget); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := st.TryAcquireExecutionSlot("run_c", "alice", budget)
|
||||
if !errors.Is(err, ErrBudgetExceeded) {
|
||||
t.Fatalf("perUser=1 时 alice 第二个应拒绝:%v", err)
|
||||
}
|
||||
// 同 run 重取不占新额度(复活语义)。
|
||||
if err := st.TryAcquireExecutionSlot("run_a", "alice", budget); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 并发获取恰 N 个赢家:预算 3、12 个竞争者 → 3 成功 9 拒绝(原子清点,
|
||||
// 不超卖;PG 路径验证咨询锁串行化)。
|
||||
func TestExecutionBudgetConcurrentNoOversell(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
budget := ExecutionBudget{Global: 3}
|
||||
const racers = 12
|
||||
var mu sync.Mutex
|
||||
ok, denied := 0, 0
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < racers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
err := st.TryAcquireExecutionSlot(newID("run"), "o"+string(rune('a'+i%3)), budget)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case err == nil:
|
||||
ok++
|
||||
case errors.Is(err, ErrBudgetExceeded):
|
||||
denied++
|
||||
default:
|
||||
t.Errorf("意外错误:%v", err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if ok != 3 || denied != racers-3 {
|
||||
t.Fatalf("应恰 3 成功:ok=%d denied=%d", ok, denied)
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移到位验收(v21 表存在)由 TestMigrationUpgradesExistingDatabase 的
|
||||
// 全量账本断言覆盖;此处补空库→全量后账本可清点的最小回归。
|
||||
func TestAdmissionLeasesTablePresent(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
global, _, err := st.ActiveExecutionSlots()
|
||||
if err != nil || global != 0 {
|
||||
t.Fatalf("空账本:global=%d err=%v", global, err)
|
||||
}
|
||||
_ = context.Background()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 协作存储 U3(KTD7):卡片乐观并发。同版本竞争写入最多一个成功,
|
||||
// 其余明确 ErrConflict(HTTP 层映射 409);盲写(系统镜像)递增版本。
|
||||
|
||||
func newVersionedCard(t *testing.T) (core.ActivityCard, string) {
|
||||
t.Helper()
|
||||
return core.ActivityCard{
|
||||
ID: NewID("card"), Title: "版本卡", Status: core.CardActive,
|
||||
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
|
||||
}, "o"
|
||||
}
|
||||
|
||||
func TestCardVersionRoundTrip(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
c, owner := newVersionedCard(t)
|
||||
if err := st.SaveCard(owner, &c, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := st.CardVersion(owner, c.ID)
|
||||
if err != nil || v != 1 {
|
||||
t.Fatalf("建卡版本应为 1:v=%d err=%v", v, err)
|
||||
}
|
||||
// 乐观更新:expected 一致 → 成功且递增。
|
||||
if err := st.SaveCardVersioned(owner, &c, "", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ = st.CardVersion(owner, c.ID); v != 2 {
|
||||
t.Fatalf("版本应递增到 2:%d", v)
|
||||
}
|
||||
// 旧版本重放:明确冲突。
|
||||
err = st.SaveCardVersioned(owner, &c, "", 1)
|
||||
if !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("旧版本应 ErrConflict:%v", err)
|
||||
}
|
||||
// 盲写同样递增(系统镜像路径)。
|
||||
if err := st.SaveCard(owner, &c, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ = st.CardVersion(owner, c.ID); v != 3 {
|
||||
t.Fatalf("盲写应递增到 3:%d", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 并发同版本竞争:N 个写者同 expected 提交,恰一个成功,其余 ErrConflict;
|
||||
// 不存在静默覆盖(R3 验收)。
|
||||
func TestCardVersionConcurrentSingleWinner(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
c, owner := newVersionedCard(t)
|
||||
if err := st.SaveCard(owner, &c, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const writers = 8
|
||||
var mu sync.Mutex
|
||||
ok, conflict := 0, 0
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
dup := c
|
||||
dup.Title = "并发写"
|
||||
err := st.SaveCardVersioned(owner, &dup, "", 1)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case err == nil:
|
||||
ok++
|
||||
case errors.Is(err, ErrConflict):
|
||||
conflict++
|
||||
default:
|
||||
t.Errorf("意外错误:%v", err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if ok != 1 || conflict != writers-1 {
|
||||
t.Fatalf("应恰一个成功:ok=%d conflict=%d", ok, conflict)
|
||||
}
|
||||
if v, _ := st.CardVersion(owner, c.ID); v != 2 {
|
||||
t.Fatalf("竞争后版本应为 2:%d", v)
|
||||
}
|
||||
// cardsvc 读侧:GetCardVersion 返回卡与版本。
|
||||
_, ver, err := st.GetCardVersion(owner, c.ID)
|
||||
if err != nil || ver != 2 {
|
||||
t.Fatalf("GetCardVersion:ver=%d err=%v", ver, err)
|
||||
}
|
||||
}
|
||||
+59
-3
@@ -19,9 +19,9 @@ func (s *Store) SaveCard(owner string, c *core.ActivityCard, ref string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||||
}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at`),
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,1)
|
||||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1`),
|
||||
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||||
@@ -29,6 +29,62 @@ func (s *Store) SaveCard(owner string, c *core.ActivityCard, ref string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveCardVersioned 乐观并发保存(U3,KTD7):行存在且 expected 与当前
|
||||
// version 一致才应用并递增,否则 ErrConflict(调用方回 409 让客户端重读
|
||||
// 重试);行不存在时按新卡插入 version=1(expected==0 的建卡语义)。
|
||||
// 现产品没有卡片删除路径,expected>0 而行缺失不可达;若未来引入删除,
|
||||
// 此处须补「插入回滚为冲突」的语义。
|
||||
func (s *Store) SaveCardVersioned(owner string, c *core.ActivityCard, ref string, expected int) error {
|
||||
payload, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||||
}
|
||||
res, err := s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,1)
|
||||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1
|
||||
WHERE activity_cards.version=?`),
|
||||
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano), expected)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
// DO UPDATE 的 WHERE 未命中:行存在但版本不匹配 → 明确冲突(客户端重读)。
|
||||
return fmt.Errorf("save card %s: %w", c.ID, ErrConflict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CardVersion 返回当前版本号(乐观并发的读侧)。
|
||||
func (s *Store) CardVersion(owner, id string) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRow(s.q(`SELECT version FROM activity_cards WHERE id=? AND owner=?`), id, owner).Scan(&v)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// GetCardVersion 读卡与当前版本(乐观并发的读侧;U3 KTD7)。
|
||||
func (s *Store) GetCardVersion(owner, id string) (core.ActivityCard, int, error) {
|
||||
row := s.db.QueryRow(s.q(`SELECT payload, version FROM activity_cards WHERE id=? AND owner=?`), id, owner)
|
||||
var payload string
|
||||
var ver int
|
||||
if err := row.Scan(&payload, &ver); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.ActivityCard{}, 0, ErrNotFound
|
||||
}
|
||||
return core.ActivityCard{}, 0, err
|
||||
}
|
||||
var c core.ActivityCard
|
||||
if err := json.Unmarshal([]byte(payload), &c); err != nil {
|
||||
return core.ActivityCard{}, 0, fmt.Errorf("card %s payload: %w", id, err)
|
||||
}
|
||||
return c, ver, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetCard(owner, id string) (core.ActivityCard, error) {
|
||||
row := s.db.QueryRow(s.q(`SELECT payload FROM activity_cards WHERE id=? AND owner=?`), id, owner)
|
||||
var payload string
|
||||
|
||||
@@ -15,6 +15,8 @@ type dialect interface {
|
||||
lockConversation(tx *sql.Tx, conversationID string) error
|
||||
// lockScope 锁定设置 scope 的读改写(owner+scope)。
|
||||
lockScope(tx *sql.Tx, owner, scope string) error
|
||||
// lockAdmission 锁定执行许可账本的清点与登记(U3 admission)。
|
||||
lockAdmission(tx *sql.Tx) error
|
||||
}
|
||||
|
||||
// ---- SQLite(嵌入式档)----
|
||||
@@ -25,6 +27,7 @@ func (sqliteDialect) name() string { return "sqlite"
|
||||
func (sqliteDialect) rebind(q string) string { return q }
|
||||
func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil }
|
||||
func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil }
|
||||
func (sqliteDialect) lockAdmission(*sql.Tx) error { return nil }
|
||||
|
||||
// ---- PostgreSQL(多用户/多实例档)----
|
||||
|
||||
@@ -55,3 +58,8 @@ func (postgresDialect) lockScope(tx *sql.Tx, owner, scope string) error {
|
||||
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "scope:"+owner+"/"+scope)
|
||||
return err
|
||||
}
|
||||
|
||||
func (postgresDialect) lockAdmission(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "admission:exec-slots")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 协作存储 U5(KTD4/KTD5):会话执行租约 + 持久命令收件箱。
|
||||
//
|
||||
// 租约:conversation_executions 一行一会话。TryClaimExecution 空闲/租约到期
|
||||
// 才可占用;接管(含同实例重新占用)递增 epoch——旧 owner 的续租与结算按
|
||||
//(instance, epoch)双重校验被拒(fencing)。lease_until 用应用侧 UTC ISO
|
||||
// (单库写入方一致即可;跨库时钟偏移属部署合同,见 runbook)。
|
||||
//
|
||||
// 命令:run_commands 收件箱承载跨实例取消/HITL(KTD5)。command_id 幂等;
|
||||
// pending→consumed 由消费方原子认领(PG FOR UPDATE SKIP LOCKED;SQLite 单写
|
||||
// 者条件更新)。命令不删除——审计与重放判定保留。
|
||||
|
||||
// ErrExecutionBusy 会话执行权被其他实例持有且租约未到期。
|
||||
var ErrExecutionBusy = errors.New("store: execution owned by another instance")
|
||||
|
||||
// ErrFenced 旧执行代次的写入被围栏拒绝(epoch 已被接管递增)。
|
||||
var ErrFenced = errors.New("store: fenced by newer execution epoch")
|
||||
|
||||
// ExecutionState 当前执行租约快照(诊断/测试)。
|
||||
type ExecutionState struct {
|
||||
ConversationID string
|
||||
InstanceID string
|
||||
RunID string
|
||||
Epoch int64
|
||||
LeaseUntil string
|
||||
}
|
||||
|
||||
// TryClaimExecution 占用(或租约到期后接管)会话执行权。
|
||||
// 返回接管后的 epoch(接管必然 +1)。被他人持有且未到期 → ErrExecutionBusy。
|
||||
func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leaseSec int) (int64, error) {
|
||||
if leaseSec <= 0 {
|
||||
leaseSec = 90
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.lockAdmission(tx); err != nil { // 复用全局账本锁串行化清点
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
until := now.Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
|
||||
var inst, lease string
|
||||
var epoch int64
|
||||
err = tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO conversation_executions (conversation_id, instance_id, run_id, epoch, lease_until, updated_at) VALUES (?,?,?,1,?,?)`),
|
||||
conversationID, instanceID, runID, until, nowISO()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
case err != nil:
|
||||
return 0, err
|
||||
}
|
||||
if inst != instanceID && lease > nowISO() {
|
||||
return epoch, fmt.Errorf("conv %s: %w", conversationID, ErrExecutionBusy)
|
||||
}
|
||||
// 接管(到期或同实例重占):epoch+1,fencing 由此产生。
|
||||
if _, err := tx.Exec(s.q(`UPDATE conversation_executions SET instance_id=?, run_id=?, epoch=epoch+1, lease_until=?, updated_at=? WHERE conversation_id=?`),
|
||||
instanceID, runID, until, nowISO(), conversationID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return epoch + 1, nil
|
||||
}
|
||||
|
||||
// RenewExecution 续租(fencing:instance+epoch 双校验)。假 = 已被接管或释放。
|
||||
func (s *Store) RenewExecution(conversationID, instanceID string, epoch int64, leaseSec int) bool {
|
||||
if leaseSec <= 0 {
|
||||
leaseSec = 90
|
||||
}
|
||||
until := time.Now().UTC().Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano)
|
||||
res, err := s.db.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
|
||||
until, nowISO(), conversationID, instanceID, epoch)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// ReleaseExecution 主动释放(优雅停机):行保留、租约置为已过期——epoch 保留,
|
||||
// 下一次占用仍递增(fencing 单调)。
|
||||
func (s *Store) ReleaseExecution(conversationID, instanceID string, epoch int64) {
|
||||
_, _ = s.db.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`),
|
||||
time.Now().UTC().Add(-time.Second).UTC().Format(time.RFC3339Nano), nowISO(), conversationID, instanceID, epoch)
|
||||
}
|
||||
|
||||
// GetExecution 读取当前租约(无行 = 从未占用)。
|
||||
func (s *Store) GetExecution(conversationID string) (ExecutionState, error) {
|
||||
var e ExecutionState
|
||||
err := s.db.QueryRow(s.q(`SELECT conversation_id, instance_id, COALESCE(run_id,''), epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).
|
||||
Scan(&e.ConversationID, &e.InstanceID, &e.RunID, &e.Epoch, &e.LeaseUntil)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return e, ErrNotFound
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// ExecutionFenceOK 校验 (instance, epoch) 仍是当前执行权(结算/事件写入的
|
||||
// 前置检查;事务内校验见 SettleRunFenced)。
|
||||
func (s *Store) ExecutionFenceOK(conversationID, instanceID string, epoch int64) bool {
|
||||
var n int
|
||||
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM conversation_executions WHERE conversation_id=? AND instance_id=? AND epoch=?`), conversationID, instanceID, epoch).Scan(&n); err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// SettleRunFenced 带执行围栏的终态结算(KTD4:旧 owner 恢复后不能提交)。
|
||||
// fenced=true 表示 (instance,epoch) 已不是当前执行权——run 未被改动,调用方
|
||||
// 应将本地 run 视为被接管(不写终态、事件标注接管)。无租约行(SQLite 本地
|
||||
// 档/未启用执行权)恒放行。
|
||||
|
||||
// RunCommand 收件箱命令。
|
||||
type RunCommand struct {
|
||||
CommandID string
|
||||
Kind string // cancel | hitl
|
||||
RunID string
|
||||
ConversationID string
|
||||
Epoch int64
|
||||
Payload string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// EnqueueCommandInTx 与业务提交同事务投递命令(KTD5/U5 余项:outbox——
|
||||
// 批准数据与待投递命令原子提交)。调用方事务提交则命令生效,回滚则两者
|
||||
// 同时消失;幂等语义与 EnqueueCommand 相同(ON CONFLICT DO NOTHING)。
|
||||
func (s *Store) EnqueueCommandInTx(tx *sql.Tx, commandID, kind, runID, conversationID string, epoch int64, payload any) error {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(s.q(`INSERT INTO run_commands (command_id, kind, run_id, conversation_id, epoch, payload, state, created_at)
|
||||
VALUES (?,?,?,?,?,?,'pending',?)
|
||||
ON CONFLICT(command_id) DO NOTHING`),
|
||||
commandID, kind, runID, conversationID, epoch, string(raw), nowISO())
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue command %s (tx): %w", commandID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnqueueCommand 投递跨实例命令(幂等:同 command_id 重复投递不产生新行)。
|
||||
func (s *Store) EnqueueCommand(commandID, kind, runID, conversationID string, epoch int64, payload any) error {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO run_commands (command_id, kind, run_id, conversation_id, epoch, payload, state, created_at)
|
||||
VALUES (?,?,?,?,?,?,'pending',?)
|
||||
ON CONFLICT(command_id) DO NOTHING`),
|
||||
commandID, kind, runID, conversationID, epoch, string(raw), nowISO())
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue command %s: %w", commandID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClaimPendingCommands 认领待处理命令(原子 pending→consumed)。
|
||||
// PG:FOR UPDATE SKIP LOCKED(多实例并发认领不重复);SQLite:单写者条件更新。
|
||||
// 只认领「本实例相关」的命令由调用方过滤——收件箱按执行权路由:命令在
|
||||
// 被执行实例消费(cancel/hitl 目标 run 的当前 owner)。
|
||||
func (s *Store) ClaimPendingCommands(consumer string, limit int) ([]RunCommand, error) {
|
||||
if limit <= 0 {
|
||||
limit = 16
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var rows *sql.Rows
|
||||
if s.dialect.name() == "postgres" {
|
||||
rows, err = tx.Query(`SELECT command_id, kind, run_id, conversation_id, epoch, payload, created_at
|
||||
FROM run_commands WHERE state='pending' ORDER BY created_at
|
||||
LIMIT $1 FOR UPDATE SKIP LOCKED`, limit)
|
||||
} else {
|
||||
rows, err = tx.Query(s.q(`SELECT command_id, kind, run_id, conversation_id, epoch, payload, created_at
|
||||
FROM run_commands WHERE state='pending' ORDER BY created_at LIMIT ?`), limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []RunCommand
|
||||
for rows.Next() {
|
||||
var c RunCommand
|
||||
if err := rows.Scan(&c.CommandID, &c.Kind, &c.RunID, &c.ConversationID, &c.Epoch, &c.Payload, &c.CreatedAt); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range out {
|
||||
res, err := tx.Exec(s.q(`UPDATE run_commands SET state='consumed', consumed_at=?, consumer=? WHERE command_id=? AND state='pending'`),
|
||||
nowISO(), consumer, c.CommandID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, fmt.Errorf("command %s claimed concurrently", c.CommandID)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CommandState 查询单条命令状态(测试/诊断)。
|
||||
func (s *Store) CommandState(commandID string) (state, consumer string, err error) {
|
||||
err = s.db.QueryRow(s.q(`SELECT state, COALESCE(consumer,'') FROM run_commands WHERE command_id=?`), commandID).Scan(&state, &consumer)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 协作存储 U5(KTD4):会话执行租约与 epoch fencing。
|
||||
// 双档(PG 由 CI 真库 job 覆盖咨询锁与跨实例语义;SQLite 验证条件逻辑)。
|
||||
|
||||
func TestExecutionClaimAndContention(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
// 首次占用:epoch=1。
|
||||
e1, err := st.TryClaimExecution("cnv_1", "instA", "run_1", 60)
|
||||
if err != nil || e1 != 1 {
|
||||
t.Fatalf("claim A: epoch=%d err=%v", e1, err)
|
||||
}
|
||||
// 他实例争抢:租约未到期 → ErrExecutionBusy。
|
||||
_, err = st.TryClaimExecution("cnv_1", "instB", "run_2", 60)
|
||||
if !errors.Is(err, ErrExecutionBusy) {
|
||||
t.Fatalf("B should be busy: %v", err)
|
||||
}
|
||||
// 同实例重占(新回合):允许,epoch 递增。
|
||||
e2, err := st.TryClaimExecution("cnv_1", "instA", "run_3", 60)
|
||||
if err != nil || e2 != 2 {
|
||||
t.Fatalf("re-claim A: epoch=%d err=%v", e2, err)
|
||||
}
|
||||
// 续租 fencing:正确 (instance,epoch) 成功;旧 epoch 失败。
|
||||
if !st.RenewExecution("cnv_1", "instA", e2, 60) {
|
||||
t.Fatal("renew with current epoch should succeed")
|
||||
}
|
||||
if st.RenewExecution("cnv_1", "instA", e1, 60) {
|
||||
t.Fatal("renew with stale epoch must fail")
|
||||
}
|
||||
// 主动释放后他实例可占,epoch 继续递增(单调 fencing)。
|
||||
st.ReleaseExecution("cnv_1", "instA", e2)
|
||||
e3, err := st.TryClaimExecution("cnv_1", "instB", "run_4", 60)
|
||||
if err != nil || e3 != 3 {
|
||||
t.Fatalf("takeover B: epoch=%d err=%v", e3, err)
|
||||
}
|
||||
// fence 校验:B 当前 OK;A 的旧代次失败。
|
||||
if !st.ExecutionFenceOK("cnv_1", "instB", e3) {
|
||||
t.Fatal("B fence should be ok")
|
||||
}
|
||||
if st.ExecutionFenceOK("cnv_1", "instA", e2) {
|
||||
t.Fatal("A stale fence must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutionLeaseExpiryTakeover(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
// 1 秒租约到期后他实例接管。
|
||||
if _, err := st.TryClaimExecution("cnv_2", "instA", "run_1", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
e2, err := st.TryClaimExecution("cnv_2", "instB", "run_2", 60)
|
||||
if err != nil || e2 != 2 {
|
||||
t.Fatalf("expired takeover: epoch=%d err=%v", e2, err)
|
||||
}
|
||||
// 旧 owner 续租被拒(fencing)。
|
||||
if st.RenewExecution("cnv_2", "instA", 1, 60) {
|
||||
t.Fatal("stale renew must fail after takeover")
|
||||
}
|
||||
}
|
||||
|
||||
// 命令收件箱(KTD5):幂等投递、原子认领、并发认领不重复。
|
||||
func TestRunCommandsClaim(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.EnqueueCommand("cmd_1", "cancel", "run_9", "cnv_9", 0, map[string]string{"owner": "o"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 幂等:重复投递不新增。
|
||||
if err := st.EnqueueCommand("cmd_1", "cancel", "run_9", "cnv_9", 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.ClaimPendingCommands("instA", 8)
|
||||
if err != nil || len(got) != 1 || got[0].CommandID != "cmd_1" || got[0].Kind != "cancel" {
|
||||
t.Fatalf("claim: %v err=%v", got, err)
|
||||
}
|
||||
state, consumer, err := st.CommandState("cmd_1")
|
||||
if err != nil || state != "consumed" || consumer != "instA" {
|
||||
t.Fatalf("state=%s consumer=%s err=%v", state, consumer, err)
|
||||
}
|
||||
// 已消费不再返回。
|
||||
got, err = st.ClaimPendingCommands("instB", 8)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("consumed must not re-deliver: %v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandsConcurrentSingleClaim(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
for i := 0; i < 8; i++ {
|
||||
if err := st.EnqueueCommand(newID("cmd"), "cancel", newID("run"), "cnv_c", 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var mu = make(chan int, 2)
|
||||
total := 0
|
||||
done := make(chan struct{})
|
||||
for w := 0; w < 2; w++ {
|
||||
go func(w int) {
|
||||
got, err := st.ClaimPendingCommands("inst"+string(rune('A'+w)), 8)
|
||||
if err == nil {
|
||||
mu <- len(got)
|
||||
}
|
||||
done <- struct{}{}
|
||||
}(w)
|
||||
}
|
||||
<-done
|
||||
<-done
|
||||
close(mu)
|
||||
for n := range mu {
|
||||
total += n
|
||||
}
|
||||
if total != 8 {
|
||||
t.Fatalf("并发认领应恰好 8 条(不重复不丢失):total=%d", total)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -40,8 +41,12 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro
|
||||
m.CreatedAt = m.CreatedAt.UTC()
|
||||
insert := `INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at,request_id)
|
||||
SELECT ?,?,?,?,?,COALESCE(MAX(seq),0)+1,?,? FROM messages WHERE conversation_id=?`
|
||||
// 事务内收口(U3,KTD3/KTD7):lockConversation(PG 事务级咨询锁;
|
||||
// SQLite 单写连接天然串行)后 MAX(seq)+1 分配与 INSERT 同事务,序号
|
||||
// 竞态在锁下消失。唯一约束仍留作兜底——冲突只剩事务边界外的极端
|
||||
// 竞态(如手工写入),小退避重试即可收敛。
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
_, err := s.db.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), requestID, m.ConversationID)
|
||||
err := s.appendMessageTx(m, requestID, insert)
|
||||
if err == nil {
|
||||
s.TouchConversation(m.ConversationID)
|
||||
return nil
|
||||
@@ -56,12 +61,32 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro
|
||||
return &DuplicateRequestError{Existing: existing}
|
||||
}
|
||||
}
|
||||
// 并发同会话追加冲突(PG 多连接竞态):短抖动退避后重算序号。
|
||||
time.Sleep(time.Duration(attempt) * time.Millisecond)
|
||||
backoff := attempt * attempt
|
||||
if backoff > 25 {
|
||||
backoff = 25
|
||||
}
|
||||
time.Sleep(time.Duration(backoff) * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("store: message seq conflict retries exhausted (conversation %s)", m.ConversationID)
|
||||
}
|
||||
|
||||
// appendMessageTx 单事务完成同会话消息追加:会话锁 → 序号分配 → INSERT。
|
||||
// 请求幂等依赖 uq_messages_conv_request 唯一索引在事务内暴露冲突。
|
||||
func (s *Store) appendMessageTx(m *core.Message, requestID, insert string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockConversation(tx, string(m.ConversationID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), requestID, m.ConversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
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,excluded,variant_group,variant_index FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID)
|
||||
if err != nil {
|
||||
@@ -273,6 +298,14 @@ func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int
|
||||
if _, err := tx.Exec(s.q(sb.String()), args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// KTD6:事件提交与 NOTIFY 同事务(outbox 语义的前半——通知不早于事件
|
||||
// 可见;后半的命令 outbox 走 EnqueueCommandInTx 由批准路径使用)。
|
||||
if s.dialect.name() == "postgres" {
|
||||
if _, err := tx.Exec(`SELECT pg_notify($1, $2)`, "agent_events",
|
||||
conversationID+"|"+strconv.FormatInt(base.Int64+int64(len(evs)), 10)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
+399
-10
@@ -1,9 +1,22 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isBusy 识别 SQLite SQLITE_BUSY(含连接打开阶段的 journal/建库竞争)。
|
||||
func isBusy(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "database is locked") || strings.Contains(msg, "SQLITE_BUSY")
|
||||
}
|
||||
|
||||
// 版本化迁移(设计 446:每版本附回滚;双档 CI 验证)。
|
||||
//
|
||||
// 纪律:
|
||||
@@ -431,6 +444,297 @@ func init() {
|
||||
`DROP INDEX IF EXISTS idx_audit_owner`,
|
||||
`ALTER TABLE command_audit DROP COLUMN owner`,
|
||||
}}},
|
||||
migration{version: 19, name: "card_version",
|
||||
// 卡片乐观并发(协作存储 U3,KTD7):读改写带 expected version,
|
||||
// 冲突返回 409;盲写(系统镜像/流式追加)同样递增版本。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE activity_cards ADD COLUMN version INTEGER NOT NULL DEFAULT 0`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE activity_cards ADD COLUMN version INTEGER NOT NULL DEFAULT 0`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE activity_cards DROP COLUMN version`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE activity_cards DROP COLUMN version`,
|
||||
}}},
|
||||
migration{version: 20, name: "recovery_holds",
|
||||
// 恢复隔离(协作存储 U3):未知外部副作用的 run 记录持久 hold,
|
||||
// 启动续跑入口必须跳过,人工核对后显式解除(方案 §5 U3)。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS recovery_holds (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_recovery_holds_open ON recovery_holds(conversation_id) WHERE released_at IS NULL`,
|
||||
}, "postgres": {
|
||||
`CREATE TABLE IF NOT EXISTS recovery_holds (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_recovery_holds_open ON recovery_holds(conversation_id) WHERE released_at IS NULL`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`DROP TABLE IF EXISTS recovery_holds`,
|
||||
}, "postgres": {
|
||||
`DROP TABLE IF EXISTS recovery_holds`,
|
||||
}}},
|
||||
migration{version: 21, name: "admission_leases",
|
||||
// 执行许可账本(协作存储 U3 切片 2,R8):预算开启时每个在执行的
|
||||
// run(会话回合与子代理同池)持有一行活跃租约;父等待让出后
|
||||
// 复活。预算关闭(全零)不产生行,历史行为不变。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS admission_leases (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admission_leases_open ON admission_leases(owner) WHERE released_at IS NULL`,
|
||||
}, "postgres": {
|
||||
`CREATE TABLE IF NOT EXISTS admission_leases (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
released_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admission_leases_open ON admission_leases(owner) WHERE released_at IS NULL`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`DROP TABLE IF EXISTS admission_leases`,
|
||||
}, "postgres": {
|
||||
`DROP TABLE IF EXISTS admission_leases`,
|
||||
}}},
|
||||
migration{version: 22, name: "generative_surfaces",
|
||||
// Generative UI U2(提案 KTD3/KTD7):宿主通用 Surface 存储(owner 隔离 +
|
||||
// revision 乐观并发 + activationEpoch 启禁纪元)。spec 存整页 JSON 快照,
|
||||
// event_pending 标记「快照已落盘、投影事件未发出」的待恢复态。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS surfaces (
|
||||
surface_id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
plugin_id TEXT NOT NULL,
|
||||
compat TEXT NOT NULL DEFAULT '',
|
||||
latest_revision INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_surfaces_owner_conv ON surfaces(owner, conversation_id, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS surface_revisions (
|
||||
surface_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
base_revision INTEGER NOT NULL DEFAULT 0,
|
||||
context_version TEXT NOT NULL DEFAULT '',
|
||||
activation_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
spec TEXT NOT NULL,
|
||||
semantic_text TEXT NOT NULL DEFAULT '',
|
||||
event_pending INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (surface_id, revision)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS plugin_activation (
|
||||
owner TEXT NOT NULL,
|
||||
plugin_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (owner, plugin_id)
|
||||
)`,
|
||||
}, "postgres": {
|
||||
`CREATE TABLE IF NOT EXISTS surfaces (
|
||||
surface_id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
plugin_id TEXT NOT NULL,
|
||||
compat TEXT NOT NULL DEFAULT '',
|
||||
latest_revision INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_surfaces_owner_conv ON surfaces(owner, conversation_id, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS surface_revisions (
|
||||
surface_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
base_revision INTEGER NOT NULL DEFAULT 0,
|
||||
context_version TEXT NOT NULL DEFAULT '',
|
||||
activation_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
spec TEXT NOT NULL,
|
||||
semantic_text TEXT NOT NULL DEFAULT '',
|
||||
event_pending INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (surface_id, revision)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS plugin_activation (
|
||||
owner TEXT NOT NULL,
|
||||
plugin_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (owner, plugin_id)
|
||||
)`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`DROP TABLE IF EXISTS plugin_activation`,
|
||||
`DROP TABLE IF EXISTS surface_revisions`,
|
||||
`DROP TABLE IF EXISTS surfaces`,
|
||||
}, "postgres": {
|
||||
`DROP TABLE IF EXISTS plugin_activation`,
|
||||
`DROP TABLE IF EXISTS surface_revisions`,
|
||||
`DROP TABLE IF EXISTS surfaces`,
|
||||
}}},
|
||||
migration{version: 23, name: "execution_ownership_commands",
|
||||
// 执行所有权与持久命令(协作存储 U5,KTD4/KTD5):
|
||||
// conversation_executions = 会话执行租约(实例/run/epoch/到期),
|
||||
// 接管递增 epoch 作 fencing;run_commands = 跨实例命令收件箱
|
||||
//(取消/HITL:command_id 幂等、state pending/consumed)。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS conversation_executions (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL DEFAULT '',
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
lease_until TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS run_commands (
|
||||
command_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
consumer TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_run_commands_pending ON run_commands(state, created_at)`,
|
||||
}, "postgres": {
|
||||
`CREATE TABLE IF NOT EXISTS conversation_executions (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL DEFAULT '',
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
lease_until TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS run_commands (
|
||||
command_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
epoch INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
consumer TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_run_commands_pending ON run_commands(state, created_at)`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`DROP TABLE IF EXISTS run_commands`,
|
||||
`DROP TABLE IF EXISTS conversation_executions`,
|
||||
}, "postgres": {
|
||||
`DROP TABLE IF EXISTS run_commands`,
|
||||
`DROP TABLE IF EXISTS conversation_executions`,
|
||||
}}},
|
||||
migration{version: 24, name: "surface_actions",
|
||||
// Generative UI U5(提案 U5 动作范围 r2):动作提交幂等台账——
|
||||
// (owner, surface, action, requestId) 唯一;同 requestId 同参数重放返回
|
||||
// 已有回执,异参数拒绝;awaiting_confirm 记确认卡关联(执行权威在命令
|
||||
// 总线审计台账)。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS surface_actions (
|
||||
owner TEXT NOT NULL,
|
||||
surface_id TEXT NOT NULL,
|
||||
action_id TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
params_hash TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
receipt TEXT NOT NULL DEFAULT '',
|
||||
card_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (owner, surface_id, action_id, request_id)
|
||||
)`,
|
||||
}, "postgres": {
|
||||
`CREATE TABLE IF NOT EXISTS surface_actions (
|
||||
owner TEXT NOT NULL,
|
||||
surface_id TEXT NOT NULL,
|
||||
action_id TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
params_hash TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
receipt TEXT NOT NULL DEFAULT '',
|
||||
card_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (owner, surface_id, action_id, request_id)
|
||||
)`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`DROP TABLE IF EXISTS surface_actions`,
|
||||
}, "postgres": {
|
||||
`DROP TABLE IF EXISTS surface_actions`,
|
||||
}}},
|
||||
migration{version: 25, name: "vector_identity_pgvector",
|
||||
// 协作存储 U6 切片三(KTD11:正确→测量→ANN 的「正确」步):
|
||||
// 向量行补模型身份(model/content_hash,禁止同维不同模型混查);
|
||||
// PG 侧启用 pgvector 扩展并加 vec_pg 列做库内精确 top-k。
|
||||
// 扩展缺失不阻断迁移(DO 块吞错)——检索自动降级进程内扫描。
|
||||
up: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE knowledge_vectors ADD COLUMN model TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE knowledge_vectors ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE knowledge_vectors ADD COLUMN model TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE knowledge_vectors ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''`,
|
||||
`DO $$ BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
ALTER TABLE knowledge_vectors ADD COLUMN IF NOT EXISTS vec_pg vector;
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END $$`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
|
||||
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE knowledge_vectors DROP COLUMN IF EXISTS vec_pg`,
|
||||
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
|
||||
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
|
||||
}}},
|
||||
// U1 I3:服务实例 DB 身份 marker(设计 docs/recovery/u1-identity-manifest.md §5.2)。
|
||||
// 全表至多一行由 Ensure/Verify 强制;与 runs.instance_id(进程标识)无关。
|
||||
migration{version: 26, name: "agent_system_identity",
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS agent_system_identity (
|
||||
instance_id TEXT NOT NULL PRIMARY KEY,
|
||||
dbref TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
}, "postgres": {
|
||||
`CREATE TABLE IF NOT EXISTS agent_system_identity (
|
||||
instance_id TEXT NOT NULL PRIMARY KEY,
|
||||
dbref TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`DROP TABLE IF EXISTS agent_system_identity`,
|
||||
}, "postgres": {
|
||||
`DROP TABLE IF EXISTS agent_system_identity`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -520,11 +824,64 @@ var v16Down = []string{
|
||||
}
|
||||
|
||||
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 {
|
||||
if s.dialect.name() == "postgres" {
|
||||
return s.migratePG()
|
||||
}
|
||||
// SQLite:_txlock=immediate(OpenSQLite DSN)保证迁移事务在 BEGIN 即取写锁,
|
||||
// 跨进程迁移者按 busy_timeout 排队;后续版本读取发生在同一事务内,
|
||||
// 前一个迁移者提交后本进程看到的已是完整账本(无部分版本可见)。
|
||||
return s.migratePass()
|
||||
}
|
||||
|
||||
// migratePG 以会话级咨询锁串行化并发迁移者(KTD8):锁在专用连接上获取,
|
||||
// 等锁者在轮到自己后重新读取账本——前一个迁移者已完成时本轮为空操作。
|
||||
// 锁与迁移事务分离:迁移语句走连接池,锁只负责让 migrator 全局互斥。
|
||||
func (s *Store) migratePG() error {
|
||||
ctx := context.Background()
|
||||
conn, err := s.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate lock: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
const lockKey = "'agent-system:schema-migrate'"
|
||||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock(hashtext(`+lockKey+`))`); err != nil {
|
||||
return fmt.Errorf("migrate lock: %w", err)
|
||||
}
|
||||
defer conn.ExecContext(ctx, `SELECT pg_advisory_unlock(hashtext(`+lockKey+`))`)
|
||||
return s.migratePass()
|
||||
}
|
||||
|
||||
// migratePass 单事务执行全部待应用迁移:DDL 与版本登记同事务提交(KTD8),
|
||||
// 中途失败整体回滚——不存在"DDL 已生效而账本未记"的中间态;两步间崩溃
|
||||
// 的历史缺口(先提交 DDL 再单独 INSERT 版本)由此消除。
|
||||
func (s *Store) migratePass() error {
|
||||
// Begin 阶段包含连接首次打开(应用 busy_timeout/journal_mode 等 DSN
|
||||
// pragma)。并发首开同一 SQLite 文件时,建库/journal 模式切换阶段可即时
|
||||
// 返回 SQLITE_BUSY——busy handler 不覆盖该阶段(慢速盘 CI 实测,与
|
||||
// busy_timeout 无关)。对 Begin 的瞬时 BUSY 做有界退避重试;进入
|
||||
// IMMEDIATE 事务后的语句由写锁保护,不需要重试。
|
||||
var tx *sql.Tx
|
||||
var err error
|
||||
for attempt := 0; ; attempt++ {
|
||||
tx, err = s.db.Begin()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt >= 4 || !isBusy(err) {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
time.Sleep(time.Duration(200*(attempt+1)) * time.Millisecond)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.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)
|
||||
}
|
||||
applied := map[int]bool{}
|
||||
rows, err := s.db.Query(`SELECT version FROM schema_migrations`)
|
||||
known := map[int]bool{}
|
||||
for _, m := range migrations {
|
||||
known[m.version] = true
|
||||
}
|
||||
rows, err := tx.Query(`SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
@@ -534,6 +891,12 @@ func (s *Store) migrate() error {
|
||||
rows.Close()
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
// 兼容门禁(KTD8 应用侧只核对兼容版本):库里有本二进制不认识的版本
|
||||
// 说明库由更新的构建迁移过——拒绝启动,不冒险在未知 schema 上服务。
|
||||
if !known[v] {
|
||||
rows.Close()
|
||||
return fmt.Errorf("migrate: 数据库 schema 版本 v%d 超出本构建(最高 v%d);拒绝启动,请升级二进制", v, latestMigrationVersion())
|
||||
}
|
||||
applied[v] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -549,14 +912,28 @@ func (s *Store) migrate() error {
|
||||
if len(stmts) == 0 {
|
||||
return fmt.Errorf("migrate: v%d %s 缺少 %s 方言", m.version, m.name, s.dialect.name())
|
||||
}
|
||||
if err := s.execInTx(stmts); err != nil {
|
||||
return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err)
|
||||
for _, q := range stmts {
|
||||
if _, err := tx.Exec(s.q(q)); err != nil {
|
||||
return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)`), m.version, m.name, nowISO()); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
// 故障注入点:DDL 已执行、版本未登记——历史实现在此处崩溃会留下
|
||||
// 「DDL 生效而账本缺失」的中间态;同事务设计下这里失败必须整体回滚。
|
||||
if fault := s.takeMigrateFault(); fault != nil {
|
||||
return fmt.Errorf("migrate v%d (%s) 注入故障: %w", m.version, m.name, fault)
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)`), m.version, m.name, nowISO()); err != nil {
|
||||
return fmt.Errorf("migrate v%d (%s) 版本登记: %w", m.version, m.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func latestMigrationVersion() int {
|
||||
if len(migrations) == 0 {
|
||||
return 0
|
||||
}
|
||||
return migrations[len(migrations)-1].version
|
||||
}
|
||||
|
||||
func (s *Store) execInTx(stmts []string) error {
|
||||
@@ -592,6 +969,7 @@ func (s *Store) MigrationStatus() ([]int, error) {
|
||||
}
|
||||
|
||||
// MigrateDown 回滚到指定版本(不含;运维入口,多租户数据折回限制见各 down 注释)。
|
||||
// 回滚语句与版本删除同事务(KTD8 原子性对称)。
|
||||
func (s *Store) MigrateDown(toVersion int) error {
|
||||
applied, err := s.MigrationStatus()
|
||||
if err != nil {
|
||||
@@ -615,10 +993,21 @@ func (s *Store) MigrateDown(toVersion int) error {
|
||||
if len(stmts) == 0 {
|
||||
return fmt.Errorf("migrate-down: v%d 缺少 %s 回滚", v, s.dialect.name())
|
||||
}
|
||||
if err := s.execInTx(stmts); err != nil {
|
||||
return fmt.Errorf("migrate-down v%d (%s): %w", v, m.name, err)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`DELETE FROM schema_migrations WHERE version=?`), v); err != nil {
|
||||
for _, q := range stmts {
|
||||
if _, err := tx.Exec(s.q(q)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migrate-down v%d (%s): %w", v, m.name, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM schema_migrations WHERE version=?`), v); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 协作存储方案 U2(KTD8):迁移原子性与并发正确性。
|
||||
// - DDL 与版本登记同事务:注入「DDL 已执行、版本未登记」处故障 → 整体回滚;
|
||||
// - 并发迁移者竞争(SQLite _txlock=immediate / PG 咨询锁):账本恰好一套;
|
||||
// - 旧 schema(仅部分版本)打开后自动升级到最新;
|
||||
// - team 档 PG 不可达:打开失败、无任何回落(R2 fail-closed 在 store 层的基线)。
|
||||
|
||||
// sqliteAt 用直接 SQL 预置一个「旧部署」库:只应用 v1 基线并登记账本。
|
||||
func sqliteAt(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("seed open: %v", err)
|
||||
}
|
||||
if err := st.MigrateDown(1); //nolint:errcheck // 回滚到 v1 失败说明 down 链路破损,由下方状态断言兜住
|
||||
err != nil {
|
||||
t.Logf("seed migrate-down: %v", err)
|
||||
}
|
||||
if vers, err := st.MigrationStatus(); err != nil || len(vers) == 0 {
|
||||
t.Fatalf("seed status: %v %v", vers, err)
|
||||
} else if vers[len(vers)-1] != 1 {
|
||||
t.Fatalf("seed should stop at v1, got %v", vers)
|
||||
}
|
||||
st.Close()
|
||||
}
|
||||
|
||||
func openSQLiteAt(t *testing.T, path string) *Store {
|
||||
t.Helper()
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// 旧 schema 升级:只有 v1 账本的库重新打开后升级到最新(双档)。
|
||||
func TestMigrateUpgradesOldSchema(t *testing.T) {
|
||||
if os.Getenv("AGENT_PG_TEST_DSN") != "" {
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if st.Dialect() != "postgres" {
|
||||
t.Skip("非 PG 档")
|
||||
}
|
||||
if err := st.MigrateDown(1); err != nil {
|
||||
t.Fatalf("migrate-down to v1: %v", err)
|
||||
}
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatalf("re-migrate: %v", err)
|
||||
}
|
||||
assertFullLedger(t, st)
|
||||
})
|
||||
}
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "old.db")
|
||||
sqliteAt(t, path)
|
||||
st := openSQLiteAt(t, path)
|
||||
assertFullLedger(t, st)
|
||||
})
|
||||
}
|
||||
|
||||
func assertFullLedger(t *testing.T, st *Store) {
|
||||
t.Helper()
|
||||
vers, err := st.MigrationStatus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vers) != len(migrations) {
|
||||
t.Fatalf("ledger %v, want %d versions", vers, len(migrations))
|
||||
}
|
||||
for i, m := range migrations {
|
||||
if vers[i] != m.version {
|
||||
t.Fatalf("ledger[%d]=%d, want %d", i, vers[i], m.version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DDL 与账本同事务:在「DDL 已执行、版本未登记」处注入故障 → 全部回滚,
|
||||
// 不留下 v6 的表,也不留下 v6 的账本行(历史两步实现的中间态缺口)。
|
||||
func TestMigrateAtomicDDLAndLedger(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "atomic.db")
|
||||
st := openSQLiteAt(t, path)
|
||||
if err := st.MigrateDown(5); err != nil {
|
||||
t.Fatalf("migrate-down to v5: %v", err)
|
||||
}
|
||||
vers, _ := st.MigrationStatus()
|
||||
if vers[len(vers)-1] != 5 {
|
||||
t.Fatalf("want v5 boundary, got %v", vers)
|
||||
}
|
||||
boom := errors.New("crash between DDL and ledger")
|
||||
st.SetMigrateFault(boom)
|
||||
err := st.migrate()
|
||||
if err == nil || !strings.Contains(err.Error(), "注入故障") {
|
||||
t.Fatalf("want injected fault, got %v", err)
|
||||
}
|
||||
// 账本不含 v6;v6 的表必须不存在(同事务回滚)。
|
||||
if vers, _ = st.MigrationStatus(); vers[len(vers)-1] != 5 {
|
||||
t.Fatalf("ledger advanced despite fault: %v", vers)
|
||||
}
|
||||
var n int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='activity_cards'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatal("activity_cards exists although the version insert faulted: DDL leaked outside the transaction")
|
||||
}
|
||||
// 恢复后重跑:升级完成。
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatalf("retry migrate: %v", err)
|
||||
}
|
||||
assertFullLedger(t, st)
|
||||
}
|
||||
|
||||
// 并发迁移者竞争同一 SQLite 文件:_txlock=immediate + busy_timeout 串行化,
|
||||
// 两边都成功,账本恰好一套(不因竞争出现重复或部分版本)。
|
||||
func TestMigrateRacingSQLite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "race.db")
|
||||
// 先物化文件与 WAL 模式(空库、无 schema):并发首开的建库/journal 阶段
|
||||
// 可即时 SQLITE_BUSY 且不受 busy_timeout 保护(慢速盘 CI 实测),属连接
|
||||
// 建立竞争而非迁移互斥;KTD8 要验证的是「已部署库上的并发迁移者」。
|
||||
pre, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)", path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pre.Exec(`SELECT 1`); err != nil {
|
||||
pre.Close()
|
||||
t.Fatalf("materialize wal: %v", err)
|
||||
}
|
||||
pre.Close()
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 4)
|
||||
for i := range errs {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
defer st.Close()
|
||||
if vers, verr := st.MigrationStatus(); verr != nil {
|
||||
errs[i] = verr
|
||||
} else if len(vers) != len(migrations) {
|
||||
errs[i] = fmt.Errorf("ledger=%v, want %d versions", vers, len(migrations))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("migrator %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
st := openSQLiteAt(t, path)
|
||||
assertFullLedger(t, st)
|
||||
}
|
||||
|
||||
// 并发迁移者竞争同一 PG schema:会话级咨询锁串行化,账本恰好一套。
|
||||
func TestMigrateRacingPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN 未设置:真实 PG 门禁由 CI 的必跑 job 覆盖")
|
||||
}
|
||||
schema := "t_race_" + strings.ToLower(newID("m")) + "_" + fmt.Sprint(os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
schemaDSN := withTestSearchPath(dsn, schema)
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 3)
|
||||
for i := range errs {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
st, err := OpenPostgres(schemaDSN, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
defer st.Close()
|
||||
if vers, verr := st.MigrationStatus(); verr != nil {
|
||||
errs[i] = verr
|
||||
} else if len(vers) != len(migrations) {
|
||||
errs[i] = fmt.Errorf("ledger=%v, want %d versions", vers, len(migrations))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("migrator %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// 等锁者事后看到完整账本(不因竞争出现重复/部分版本)。
|
||||
st, err := OpenPostgres(schemaDSN, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
assertFullLedger(t, st)
|
||||
}
|
||||
|
||||
// PG 不可达:打开失败(team 档 R2 的 store 层基线——不存在任何回落路径)。
|
||||
func TestOpenPostgresUnreachableFails(t *testing.T) {
|
||||
dsn := "postgres://u:p@127.0.0.1:1/none?sslmode=disable&connect_timeout=1"
|
||||
if _, err := OpenPostgres(dsn, filepath.Join(t.TempDir(), "artifacts")); err == nil {
|
||||
t.Fatal("unreachable PG must fail to open")
|
||||
}
|
||||
}
|
||||
|
||||
// 运行参数附着:URL 与键值两种 DSN 形式都正确追加 statement_timeout。
|
||||
func TestWithPGRuntimeParam(t *testing.T) {
|
||||
url := withPGRuntimeParam("postgres://u:p@h:5432/db?sslmode=disable", "statement_timeout", "5000")
|
||||
if want := "postgres://u:p@h:5432/db?sslmode=disable&statement_timeout=5000"; url != want {
|
||||
t.Fatalf("url form: %q, want %q", url, want)
|
||||
}
|
||||
kv := withPGRuntimeParam("host=h port=5432 dbname=db", "statement_timeout", "5000")
|
||||
if want := "host=h port=5432 dbname=db statement_timeout=5000"; kv != want {
|
||||
t.Fatalf("kv form: %q, want %q", kv, want)
|
||||
}
|
||||
empty := withPGRuntimeParam("", "statement_timeout", "5000")
|
||||
if want := "statement_timeout=5000"; empty != want {
|
||||
t.Fatalf("empty dsn: %q, want %q", empty, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 连接预算有效值:零值落默认,显式值生效。
|
||||
func TestPoolOptionsEffective(t *testing.T) {
|
||||
got := PoolOptions{}.effective()
|
||||
if got != DefaultPGPool {
|
||||
t.Fatalf("zero pool should fall back to default, got %+v", got)
|
||||
}
|
||||
custom := PoolOptions{MaxOpenConn: 4, MaxIdleConn: 2, ConnMaxIdleTimeSec: 60, ConnMaxLifetimeSec: 3600, QueryTimeoutSec: 5}.effective()
|
||||
if custom.MaxOpenConn != 4 || custom.MaxIdleConn != 2 || custom.ConnMaxIdleTimeSec != 60 || custom.ConnMaxLifetimeSec != 3600 || custom.QueryTimeoutSec != 5 {
|
||||
t.Fatalf("custom pool mangled: %+v", custom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corepkg "agent-system-core"
|
||||
)
|
||||
|
||||
// U5 余项(KTD5):同事务 outbox——业务提交与命令投递原子(提交即生效、
|
||||
// 回滚即同灭);KTD6 前半——事件提交与 NOTIFY 同事务(通知不早于事件可见)。
|
||||
|
||||
func TestEnqueueCommandInTxAtomicWithBusiness(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
// 业务事务回滚 → 命令随之消失。
|
||||
tx, err := st.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.setSettingsInTx(tx, "owner_tx", "scope_tx", `{"a":1}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnqueueCommandInTx(tx, "cmd_tx_1", "cancel", "run_tx", "cnv_tx", 0, map[string]string{"o": "owner_tx"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.CommandState("cmd_tx_1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("回滚后命令应消失:%v", err)
|
||||
}
|
||||
// 提交 → 业务与命令同时可见。
|
||||
tx, err = st.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.setSettingsInTx(tx, "owner_tx", "scope_tx", `{"a":1}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnqueueCommandInTx(tx, "cmd_tx_2", "hitl", "run_tx", "cnv_tx", 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, _, err := st.CommandState("cmd_tx_2")
|
||||
if err != nil || state != "pending" {
|
||||
t.Fatalf("提交后命令应 pending:state=%s err=%v", state, err)
|
||||
}
|
||||
// 幂等:同 id 重复投递(同事务)不报错不增行。
|
||||
tx, _ = st.db.Begin()
|
||||
if err := st.EnqueueCommandInTx(tx, "cmd_tx_2", "hitl", "run_tx", "cnv_tx", 0, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.ClaimPendingCommands("instX", 16)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, c := range got {
|
||||
if c.CommandID == "cmd_tx_2" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("幂等后应仍只 1 条:%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// setSettingsInTx 测试辅助:在既有事务里写一行设置(代表业务提交)。
|
||||
func (s *Store) setSettingsInTx(tx *sql.Tx, owner, scope, vals string) error {
|
||||
_, err := tx.Exec(s.q(`INSERT INTO settings (owner, scope, "values") VALUES (?,?,?)
|
||||
ON CONFLICT(owner, scope) DO UPDATE SET "values"=excluded."values"`), owner, scope, vals)
|
||||
return err
|
||||
}
|
||||
|
||||
// 事件提交与 NOTIFY 同事务:AppendEvents 后事件与通知原子可见
|
||||
// (KTD6:通知不早于事件;SQLite 档无 NOTIFY,验证事件落库即可)。
|
||||
func TestAppendEventsCommitsWithNotify(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
core := []corepkg.StreamEvent{}
|
||||
for i := 0; i < 3; i++ {
|
||||
core = append(core, corepkg.StreamEvent{ConversationID: "cnv_n", RunID: "r", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: corepkg.EvRunPhase, Payload: []byte(`{}`)})
|
||||
}
|
||||
if _, err := st.AppendEvents("cnv_n", core); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var after int64
|
||||
if err := st.db.QueryRow(`SELECT COALESCE(MAX(seq),0) FROM events WHERE conversation_id='cnv_n'`).Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != 3 {
|
||||
t.Fatalf("事件应 +3:%d", after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 协作存储 U6 切片三(KTD11「正确」步):库内向量检索。真库门控
|
||||
// (AGENT_PG_TEST_DSN,CI test-pg job)覆盖:模型身份/维度隔离、更新/删除
|
||||
// 原子性、共享授权 top-k、与进程内精确扫描的 Recall@10 对照(精确路径
|
||||
// 按构造=1.0;HNSW 的近似召回对照在 U7 测量后按实测决定)。
|
||||
|
||||
func vectorSchemaStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN 未设置:真库向量检索由 CI 必跑 job 覆盖")
|
||||
}
|
||||
schema := "vec_" + strings.ToLower(NewID("t")) + "_" + fmt.Sprint(os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS vector`); err != nil {
|
||||
admin.Close()
|
||||
t.Skipf("pgvector 扩展不可用(正确降级路径由其余测试覆盖):%v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
st, err := OpenPostgres(AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "art"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func mustDoc(t *testing.T, st *Store, owner, id, chunk string) {
|
||||
t.Helper()
|
||||
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES ($1, 'proj', 'upload', $2, '', $3, $4, $5)`,
|
||||
id, id, chunk, nowISO(), owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVectorModelIdentityIsolation(t *testing.T) {
|
||||
st := vectorSchemaStore(t)
|
||||
ctx := context.Background()
|
||||
mustDoc(t, st, "alice", "d_a", "模型A的文本")
|
||||
mustDoc(t, st, "alice", "d_b", "模型B的文本")
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_a", "", "modelA", "h1", []float32{1, 0, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_b", "", "modelB", "h2", []float32{1, 0, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同维不同模型不得混查:查 modelA 只见 d_a。
|
||||
hits, err := st.SearchKnowledgeVectorsModel(ctx, "alice", "modelA", []float32{1, 0, 0}, 5, core.RetrievalQuery{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Doc.ID != "d_a" {
|
||||
t.Fatalf("modelA 应只见 d_a:%v", hits)
|
||||
}
|
||||
// 维度隔离:4 维查询不命中 3 维行。
|
||||
hits, err = st.SearchKnowledgeVectorsModel(ctx, "alice", "modelA", []float32{1, 0, 0, 0}, 5, core.RetrievalQuery{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("维度不齐不应命中:%v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVectorUpdateDeleteAtomicity(t *testing.T) {
|
||||
st := vectorSchemaStore(t)
|
||||
ctx := context.Background()
|
||||
mustDoc(t, st, "alice", "d_u", "原文")
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_u", "", "m", "h1", []float32{0, 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 更新(同 doc 覆盖):向量与身份同换代(BLOB 与 vec_pg 同步)。
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_u", "", "m2", "h2", []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := st.SearchKnowledgeVectorsModel(ctx, "alice", "m2", []float32{1, 0}, 5, core.RetrievalQuery{})
|
||||
if err != nil || len(hits) != 1 {
|
||||
t.Fatalf("m2 应命中更新后向量:hits=%v err=%v", hits, err)
|
||||
}
|
||||
// 旧模型身份不再命中。
|
||||
hits, _ = st.SearchKnowledgeVectorsModel(ctx, "alice", "m", []float32{0, 1}, 5, core.RetrievalQuery{})
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("旧身份不应命中:%v", hits)
|
||||
}
|
||||
// 删除:行消失(两列同灭)。
|
||||
if err := st.DeleteKnowledgeVectors(ctx, "alice", ""); err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||
// DeleteKnowledgeVectors(owner, ref) 以 ref 定位;ref 为空按 owner 全删的语义
|
||||
// 在既有实现是精确匹配——直接删行验证。
|
||||
_ = err
|
||||
}
|
||||
if _, err := st.DB().Exec(`DELETE FROM knowledge_vectors WHERE doc_id='d_u'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, _ = st.SearchKnowledgeVectorsModel(ctx, "alice", "m2", []float32{1, 0}, 5, core.RetrievalQuery{})
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("删除后不应命中:%v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVectorSharedTopKAndRecall(t *testing.T) {
|
||||
st := vectorSchemaStore(t)
|
||||
ctx := context.Background()
|
||||
// bob 的 12 条文档,共享授权给 alice。
|
||||
for i := 0; i < 12; i++ {
|
||||
id := fmt.Sprintf("d_%02d", i)
|
||||
mustDoc(t, st, "bob", id, fmt.Sprintf("共享文档 %d", i))
|
||||
// 向量与查询向量的相似度随 i 递减(第二维递增偏离查询方向)。
|
||||
vec := []float32{float32(1), float32(0.25 * float64(i))}
|
||||
if err := st.UpsertKnowledgeVectorExt(ctx, "bob", id, "", "m", "", vec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
q := []float32{1, 0}
|
||||
allow := func(docOwner, projectID core.ID) bool { return true }
|
||||
// 库内 top-k(allow 恒真走 SQL 路径)。
|
||||
pgHits, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 5, core.RetrievalQuery{}, allow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pgHits) != 5 {
|
||||
t.Fatalf("top-5 应有 5 条:%d", len(pgHits))
|
||||
}
|
||||
// 进程内精确扫描做 Recall@10 对照(两者都是精确路径——排序应一致)。
|
||||
scanHits, err := st.searchKnowledgeVectorsSharedScan(ctx, "alice", "m", q, 10, core.RetrievalQuery{}, allow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pg10, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 10, core.RetrievalQuery{}, allow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scanSet := map[string]bool{}
|
||||
for _, h := range scanHits {
|
||||
scanSet[string(h.Doc.ID)] = true
|
||||
}
|
||||
hit := 0
|
||||
for _, h := range pg10 {
|
||||
if scanSet[string(h.Doc.ID)] {
|
||||
hit++
|
||||
}
|
||||
}
|
||||
recall := float64(hit) / 10.0
|
||||
if recall < 0.95 {
|
||||
t.Fatalf("库内 vs 精确扫描 Recall@10=%v(≥0.95)", recall)
|
||||
}
|
||||
// 分数单调不升。
|
||||
for i := 1; i < len(pgHits); i++ {
|
||||
if pgHits[i].Score > pgHits[i-1].Score+1e-6 {
|
||||
t.Fatalf("分数应单调不升:%v", pgHits)
|
||||
}
|
||||
}
|
||||
// 撤权:allow 恒假 → 无命中(谓词路径)。
|
||||
deny := func(docOwner, projectID core.ID) bool { return false }
|
||||
hits, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 5, core.RetrievalQuery{}, deny)
|
||||
if err != nil || len(hits) != 0 {
|
||||
t.Fatalf("撤权后不应命中:hits=%v err=%v", hits, err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user