feat(agent): 新增通用 {{.Now}} 提示词变量(每轮实时渲染)

- 各 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}} 渲染实时时间、未知变量安全回退
This commit is contained in:
Autumn-27 committed 2026-08-11 10:45:02 -04:00
1 parent 7afff7e5f5
commit 7064216188
8 files changed
+86 -14

No files matched your search

+5 -4
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
}
+1 -2
View File
@@ -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)
}
+9 -2
View File
@@ -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
+50
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
+18 -3
View File
@@ -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 {