From 9fad54316809b4ee45e549e1b20fbf8140aa880e Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:14:28 -0400 Subject: [PATCH] =?UTF-8?q?fix(prompt-vars):=20=E6=B6=88=E9=99=A4=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E7=9B=AE=E5=BD=95=E9=87=8C=E4=B8=8E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=92=9E=E5=90=8D=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20key=20=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goals 内置 agent 的变量目录里定义了 Now,seed 进 agent_prompt_vars 后, withGlobalVars 又追加了同名的全局 runtime 变量,getAgent 返回两条 Now → 前端 variables.map 的 key={v.name} 重复,React 报错并可能丢/重渲子节点。 - withGlobalVars: 追加全局变量前剔除同名的库内条目,全局(权威 runtime 定义) 胜出,保证返回列表名字唯一。对所有现存库立即生效,无需迁移。 - db.go: 从 goals 内置目录删掉冗余的 Now(已是全局变量);扩展既有清理 DELETE 一并清掉旧库残留的 'Now' 行(已发版→DB 改动带迁移)。 - 新增 withGlobalVars 去重回归测试。 go build / go vet / go test ./server 全绿。 --- db/db.go | 7 +++-- server/prompt_vars_test.go | 58 ++++++++++++++++++++++++++++++++++++++ server/server_mgmt.go | 16 ++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 server/prompt_vars_test.go diff --git a/db/db.go b/db/db.go index 6a35129..de0a738 100644 --- a/db/db.go +++ b/db/db.go @@ -117,7 +117,8 @@ func intp(v int) *int { return &v } var builtinAgents = []builtinAgent{ {"goals", "目标拆解", "goals", "把渗透任务目标拆解成若干独立、可验证的子目标。", []promptVar{ {"EngagementDescription", "任务描述(测试对象/背景)", "测试 example.com 站点", "exploration"}, - {"Now", "服务端当前时间(RFC3339)", "2026-06-25T00:00:00Z", "runtime"}, + // Now 是全局 runtime 变量(见 server.globalPromptVars),不再在各 agent 目录里 + // 重复定义,否则 withGlobalVars 追加时会与全局项撞名。 }, false, nil}, {"planner", "规划", "planner", "读取态势、判定目标,只在确有未覆盖的新方向时补充探索意图(每任务一个规划循环)。", []promptVar{ {"Goal", "任务总目标", "拿下 example.com 的管理员权限", "exploration"}, @@ -163,7 +164,9 @@ ON CONFLICT (agent_id, var_name) DO UPDATE } // Drop catalog entries for variables that were renamed, so the white-list no // longer advertises a name templates can't resolve (EngagementTitle→Description). - if _, err := d.Exec(`DELETE FROM agent_prompt_vars WHERE var_name IN ('EngagementTitle', 'CoverageGaps')`); err != nil { + // 'Now' 从各 agent 目录提升为全局 runtime 变量后,旧库里 goals 仍残留一条 'Now' + // 会与全局项撞名(前端变量列表 key 重复);一并清掉。 + if _, err := d.Exec(`DELETE FROM agent_prompt_vars WHERE var_name IN ('EngagementTitle', 'CoverageGaps', 'Now')`); err != nil { return fmt.Errorf("cleanup renamed vars: %w", err) } // Default-on interactive_shell for the runtime agents (planner/worker/mainagent/auto) diff --git a/server/prompt_vars_test.go b/server/prompt_vars_test.go new file mode 100644 index 0000000..1ab9eae --- /dev/null +++ b/server/prompt_vars_test.go @@ -0,0 +1,58 @@ +package server + +import ( + "testing" + + "github.com/Autumn-27/artex/db" +) + +// A stored catalog entry that collides with a global runtime var (e.g. a legacy +// seeded goals."Now") must not produce a duplicate: the global wins and the name +// appears exactly once, so the UI never sees duplicate React keys. +func TestWithGlobalVarsDedupesCollidingNames(t *testing.T) { + t.Parallel() + stored := []db.PromptVar{ + {Name: "EngagementDescription", Description: "task", Source: "exploration"}, + {Name: "Now", Description: "stale per-agent copy", Source: "runtime"}, + } + out := withGlobalVars(stored) + + counts := map[string]int{} + var now db.PromptVar + for _, v := range out { + counts[v.Name]++ + if v.Name == "Now" { + now = v + } + } + if counts["Now"] != 1 { + t.Fatalf("Now appeared %d times, want 1: %+v", counts["Now"], out) + } + if counts["EngagementDescription"] != 1 { + t.Fatalf("non-colliding stored var was dropped or duplicated: %+v", out) + } + // The surviving Now must be the authoritative global definition, not the stale + // stored one. + if now.Description == "stale per-agent copy" { + t.Fatalf("stored var shadowed the global instead of the other way around: %+v", now) + } + // Every global is present exactly once. + for _, g := range globalPromptVars { + if counts[g.Name] != 1 { + t.Fatalf("global %q present %d times, want 1", g.Name, counts[g.Name]) + } + } +} + +// The common case (no collisions) keeps every stored var and appends all globals. +func TestWithGlobalVarsKeepsDistinctVars(t *testing.T) { + t.Parallel() + stored := []db.PromptVar{{Name: "Goal", Source: "exploration"}} + out := withGlobalVars(stored) + if len(out) != len(stored)+len(globalPromptVars) { + t.Fatalf("len=%d, want %d: %+v", len(out), len(stored)+len(globalPromptVars), out) + } + if out[0].Name != "Goal" { + t.Fatalf("stored var order not preserved: %+v", out) + } +} diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 49c0998..fbe2179 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -2008,8 +2008,22 @@ var globalPromptVars = []db.PromptVar{ // withGlobalVars appends the universal runtime vars onto an agent's own catalog, // so validation / the UI variable list / preview all recognize {{.Now}} etc. +// A stored catalog entry that collides with a global name is dropped: the global +// runtime var is authoritative (it's what the render path actually resolves), and +// this keeps the returned list name-unique so the UI never sees duplicate keys. func withGlobalVars(vars []db.PromptVar) []db.PromptVar { - return append(append([]db.PromptVar{}, vars...), globalPromptVars...) + globalNames := make(map[string]bool, len(globalPromptVars)) + for _, g := range globalPromptVars { + globalNames[g.Name] = true + } + out := make([]db.PromptVar, 0, len(vars)+len(globalPromptVars)) + for _, v := range vars { + if globalNames[v.Name] { + continue // shadowed by the authoritative global runtime var + } + out = append(out, v) + } + return append(out, globalPromptVars...) } // validateTemplate parses the template and rejects any {{.Var}} not in the catalog.