From 706421618812d3a3bc6d2a794a4533ff3599ab54 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:45:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E6=96=B0=E5=A2=9E=E9=80=9A?= =?UTF-8?q?=E7=94=A8=20{{.Now}}=20=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=8F=98?= =?UTF-8?q?=E9=87=8F(=E6=AF=8F=E8=BD=AE=E5=AE=9E=E6=97=B6=E6=B8=B2?= =?UTF-8?q?=E6=9F=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 各 agent 渲染路径(chat/worker/mainagent/goals/planner)统一提供 Now(nowStr()); 自定义会话 agent 每次触发都会重新 renderSystem,故 {{.Now}} 每轮实时刷新 - server 端 globalPromptVars + withGlobalVars:在保存校验/变量列表/预览/详情放行 {{.Now}},否则引用它的自定义提示词会被 validateTemplate 拒绝 - 无 DB 迁移(handler 层注入通用变量,不动 agent_prompt_vars 表) - 用途:配合固定的开始时间戳相减,判断"已运行多久/是否进入最后N小时" - 新增 agent/prompt_now_test.go:{{.Now}} 渲染实时时间、未知变量安全回退 --- agent/chat.go | 9 ++++---- agent/goals.go | 2 +- agent/mainagent.go | 2 +- agent/planner.go | 3 +-- agent/prompt.go | 11 +++++++-- agent/prompt_now_test.go | 50 ++++++++++++++++++++++++++++++++++++++++ agent/worker.go | 2 +- server/server_mgmt.go | 21 ++++++++++++++--- 8 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 agent/prompt_now_test.go diff --git a/agent/chat.go b/agent/chat.go index 93147a2..6562bff 100644 --- a/agent/chat.go +++ b/agent/chat.go @@ -58,12 +58,13 @@ func chatWorkDirSpec(workDir string) string { // chatSystem renders the DB-managed prompt body for agentKey. Custom agents have // no per-key in-code default, so DefaultAssistantPrompt is the render fallback. func chatSystem(agentKey, workDir string) string { - return renderSystem(agentKey, DefaultAssistantPrompt, chatVars{}) + chatWorkDirSpec(workDir) + return renderSystem(agentKey, DefaultAssistantPrompt, chatVars{Now: nowStr()}) + chatWorkDirSpec(workDir) } -// chatVars carries no runtime variables today — a custom prompt referencing any -// {{.X}} simply fails to render and falls back to DefaultAssistantPrompt. -type chatVars struct{} +// chatVars carries the runtime variables a custom agent's prompt may reference. +// Now (server wall-clock, refreshed each turn) is the only one today; any other +// {{.X}} fails to render and falls back to DefaultAssistantPrompt. +type chatVars struct{ Now string } // Chat runs ONE turn of a conversation with the agent identified by agentKey, // resuming prior history keyed by sessionID. maxTurns is the per-turn agent step diff --git a/agent/goals.go b/agent/goals.go index 185182e..3c1341c 100644 --- a/agent/goals.go +++ b/agent/goals.go @@ -102,7 +102,7 @@ func DecomposeGoals(ctx context.Context, c Config, goalText, desc string, as *db // Description rides in the user message (same channel as the goal), NOT via the // {{.EngagementDescription}} template var — else a prompt that references the var // would inject the description twice. System prompt stays pure static instructions. - sys := renderSystem("goals", goalsDefaultTmpl, GoalsVars{}) + sys := renderSystem("goals", goalsDefaultTmpl, GoalsVars{Now: nowStr()}) tools := []actool.CoreTool{submitTool} // Wire add_task_scope only when we have a real asset store + task to write to. // The scope-extraction tail is appended in lockstep so the prompt never asks for diff --git a/agent/mainagent.go b/agent/mainagent.go index dd40a2b..8b0b702 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -55,7 +55,7 @@ const mainAgentDefaultTmpl = `你是一个授权渗透测试系统的"主 agent" 不要编造发现;只根据工具返回的真实数据回答。` func mainAgentSystem(goal, workDir string) string { - body := renderSystem("mainagent", mainAgentDefaultTmpl, MainVars{Goal: goal}) + body := renderSystem("mainagent", mainAgentDefaultTmpl, MainVars{Goal: goal, Now: nowStr()}) return body + artifactSpec(workDir) } diff --git a/agent/planner.go b/agent/planner.go index 46e6047..af105bc 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" "sync" - "time" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/norma/agentcore" @@ -160,7 +159,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者" 宁可不生成,也不要重复或硬凑。简洁、克制、高效。` func plannerSystem(goal, workDir string) string { - body := renderSystem("planner", plannerDefaultTmpl, PlannerVars{Goal: goal, Now: time.Now().Format("2006-01-02 15:04:05 MST")}) + body := renderSystem("planner", plannerDefaultTmpl, PlannerVars{Goal: goal, Now: nowStr()}) return body + artifactSpec(workDir) } diff --git a/agent/prompt.go b/agent/prompt.go index ef4c85d..2860cf7 100644 --- a/agent/prompt.go +++ b/agent/prompt.go @@ -3,6 +3,7 @@ package agent import ( "bytes" "text/template" + "time" ) // PromptOverride, if set, returns the stored system-prompt template for an agent @@ -15,10 +16,16 @@ var PromptOverride func(agentKey string) (string, bool) // user template referencing a catalog variable renders; referencing anything else // fails template execution and falls back to the built-in default. type PlannerVars struct{ Goal, Scope, AssetSummary, Now string } -type WorkerVars struct{ ProxyAddr, WorkerName string } -type MainVars struct{ Goal, AssetSummary, FindingsSummary string } +type WorkerVars struct{ ProxyAddr, WorkerName, Now string } +type MainVars struct{ Goal, AssetSummary, FindingsSummary, Now string } type GoalsVars struct{ EngagementDescription, Now string } +// nowStr is the server-local wall-clock string exposed as the universal {{.Now}} +// prompt variable. renderSystem runs on every agent turn/round, so this is fresh +// each run — a prompt can subtract it from a fixed start stamp to reason about +// elapsed time (e.g. a timed benchmark's "last N hours" window). +func nowStr() string { return time.Now().Format("2006-01-02 15:04:05 MST") } + // renderSystem returns the rendered system-prompt BODY (段 [A]) for agentKey. // Precedence: the DB-stored template (if any) over the built-in default template // (def). BOTH are Go templates now — the built-in default is seeded into the DB diff --git a/agent/prompt_now_test.go b/agent/prompt_now_test.go new file mode 100644 index 0000000..604b38e --- /dev/null +++ b/agent/prompt_now_test.go @@ -0,0 +1,50 @@ +package agent + +import ( + "strings" + "testing" + "time" +) + +// TestChatNowVarRenders verifies the universal {{.Now}} runtime variable: a custom +// (chat) agent prompt referencing it renders the live server time each turn, rather +// than failing template execution and falling back to DefaultAssistantPrompt. +func TestChatNowVarRenders(t *testing.T) { + prev := PromptOverride + defer func() { PromptOverride = prev }() + + PromptOverride = func(key string) (string, bool) { + if key == "tec_benchmark" { + return "当前时间:{{.Now}}", true + } + return "", false + } + + out := chatSystem("tec_benchmark", "/tmp/x") + if !strings.Contains(out, "当前时间:") { + t.Fatalf("custom prompt body missing, likely fell back to default: %q", out) + } + year := time.Now().Format("2006") + if !strings.Contains(out, year) { + t.Fatalf("{{.Now}} did not render the live time (want year %s): %q", year, out) + } +} + +// TestChatUnknownVarFallsBack verifies an out-of-catalog {{.X}} still degrades +// safely to the default assistant prompt (never a half-rendered prompt). +func TestChatUnknownVarFallsBack(t *testing.T) { + prev := PromptOverride + defer func() { PromptOverride = prev }() + + PromptOverride = func(key string) (string, bool) { + return "引用了不存在的变量:{{.Bogus}}", true + } + + out := chatSystem("whatever", "/tmp/x") + if strings.Contains(out, "引用了不存在的变量") { + t.Fatalf("broken template should have fallen back, got custom body: %q", out) + } + if !strings.Contains(out, DefaultAssistantPrompt) { + t.Fatalf("expected fallback to DefaultAssistantPrompt, got: %q", out) + } +} diff --git a/agent/worker.go b/agent/worker.go index 829f381..44f431e 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -184,7 +184,7 @@ func ensureRunDir(base string, taskID, intentID int64) string { func cmdOutDir(dir string) string { return filepath.Join(dir, "cmd-output") } func workerSystem(proxyAddr, runDir string) string { - body := renderSystem("worker", workerDefaultTmpl, WorkerVars{ProxyAddr: proxyAddr}) + body := renderSystem("worker", workerDefaultTmpl, WorkerVars{ProxyAddr: proxyAddr, Now: nowStr()}) return body + workerTrafficBlock(proxyAddr) + workerArtifactSpec(runDir) } diff --git a/server/server_mgmt.go b/server/server_mgmt.go index e54d6d1..1b8301e 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -303,6 +303,7 @@ func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) { } cur, _ := pg.CurrentPrompt(a.ID) vars, _ := pg.PromptVars(a.ID) + vars = withGlobalVars(vars) vers, _ := pg.ListPromptVersions(a.ID) if vers == nil { vers = []db.PromptVersion{} @@ -339,7 +340,7 @@ func (s *Server) pgSavePrompt(w http.ResponseWriter, r *http.Request) { return } vars, _ := pg.PromptVars(a.ID) - if bad := validateTemplate(body.Template, vars); bad != "" { + if bad := validateTemplate(body.Template, withGlobalVars(vars)); bad != "" { writeErr(w, 400, bad) return } @@ -495,7 +496,7 @@ func (s *Server) pgPromptVars(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } - writeJSON(w, 200, map[string]any{"variables": vars}) + writeJSON(w, 200, map[string]any{"variables": withGlobalVars(vars)}) } func (s *Server) pgPreviewPrompt(w http.ResponseWriter, r *http.Request) { @@ -512,7 +513,7 @@ func (s *Server) pgPreviewPrompt(w http.ResponseWriter, r *http.Request) { if body.Template == "" { body.Template, _ = pg.CurrentPrompt(a.ID) } - rendered, err := renderPrompt(body.Template, vars, body.Sample) + rendered, err := renderPrompt(body.Template, withGlobalVars(vars), body.Sample) if err != nil { writeJSON(w, 200, map[string]any{"rendered": "", "error": err.Error()}) return @@ -1706,6 +1707,20 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { // --- prompt template helpers (Go text/template + catalog 白名单) --- +// globalPromptVars are runtime variables available to EVERY agent (built-in and +// custom) regardless of its per-agent catalog. Each agent's render path fills them +// (see agent.nowStr, rendered fresh each turn), so a prompt may always reference +// {{.Now}} — e.g. subtract it from a fixed start stamp to reason about elapsed time. +var globalPromptVars = []db.PromptVar{ + {Name: "Now", Description: "服务端当前时间(每次运行实时刷新;可与固定起始时间相减判断已用时长)", Example: "2026-08-11 14:30:00 CST", Source: "runtime"}, +} + +// withGlobalVars appends the universal runtime vars onto an agent's own catalog, +// so validation / the UI variable list / preview all recognize {{.Now}} etc. +func withGlobalVars(vars []db.PromptVar) []db.PromptVar { + return append(append([]db.PromptVar{}, vars...), globalPromptVars...) +} + // validateTemplate parses the template and rejects any {{.Var}} not in the catalog. // Returns "" if valid, otherwise an error message. func validateTemplate(tmpl string, catalog []db.PromptVar) string {