feat(constraints): 操作约束抽取→注入 planner/worker 提示→总览管理

- 新表 task_constraints(allow/deny),CREATE TABLE IF NOT EXISTS 无需迁移
- 新工具 set_constraints:目标拆解器 round-0 先抽约束再拆目标、主 agent 运行时可补;
  拆解器提示词要求约束自包含(把「当前目标」等指代替换成具体域名/端口)
- constraintBlock 按最高优先级注入 planner+worker 系统提示;去偏:多样性/拓面显式服从约束
- 注入范围可配置(constraints_inject_planner/worker,默认都开,每轮读、即时生效)
- 总览「约束管理」卡片 + CRUD 接口(增删改不通知 planner,下轮规划自然读到)
- goals 提示词把抽约束步并入可编辑正文;旧库经 flag 守卫的一次性 reseed 追加新默认版本
This commit is contained in:
Autumn-27 committed 2026-08-23 12:48:01 -04:00
1 parent d8f34cc79d
commit 27c3248844
19 files changed
+820 -15

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/Autumn-27/artex/db"
)
// 总览「约束管理」的人工 CRUD 接口 + 注入范围开关的解析。操作约束(allow/deny)与 agent 侧的
// set_constraints 工具写同一张 task_constraints 表;这里是人类在 UI 上直接增删改。约束仅是
// 提示上下文——增删改后【不】通知 planner,下一轮规划自然读库生效(按产品决策)。每个变更 handler
// 都走 beginTaskOperation/decInflight,避免与任务删除竞态(与目标/意图 CRUD 一致)。
// 注入范围开关的 settings key,默认都开(GetBool 第二参数 = true)。
const (
settingConstraintsInjectPlanner = "constraints_inject_planner"
settingConstraintsInjectWorker = "constraints_inject_worker"
)
// constraintInjectPlanner / constraintInjectWorker 报告是否把操作约束注入对应 agent 的
// 系统提示(默认开)。作为 resolver 传给 planner/worker,每轮读 → 改开关即时生效。
func (s *Server) constraintInjectPlanner() bool {
return s.m.pg.GetBool(settingConstraintsInjectPlanner, true)
}
func (s *Server) constraintInjectWorker() bool {
return s.m.pg.GetBool(settingConstraintsInjectWorker, true)
}
// listConstraints 返回本任务的全部操作约束(allow 在前、deny 在后)。
func (s *Server) listConstraints(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
rows, err := t.Store.ListConstraints()
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"constraints": constraintDTOs(rows)})
}
// addConstraint 人工新增一条操作约束(kind=allow|deny)。不通知 planner。
func (s *Server) addConstraint(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "任务正在删除,无法新增约束")
return
}
defer s.engine.decInflight(t.ID)
var body struct {
Text string `json:"text"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "invalid JSON")
return
}
text := strings.TrimSpace(body.Text)
if text == "" {
writeErr(w, 400, "约束内容不能为空")
return
}
kind := normalizeConstraintKind(body.Kind)
if kind == "" {
writeErr(w, 400, "kind 必须是 allow 或 deny")
return
}
id, err := t.Store.AddConstraint(kind, text, "human")
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, ConstraintDTO{ID: strconv.FormatInt(id, 10), Kind: kind, Text: text, Origin: "human"})
}
// editConstraint 人工修改一条约束(kind + text)。不通知 planner。
func (s *Server) editConstraint(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "任务正在删除,无法修改约束")
return
}
defer s.engine.decInflight(t.ID)
cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64)
if err != nil || cid <= 0 {
writeErr(w, 400, "bad constraint id")
return
}
var body struct {
Text string `json:"text"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "invalid JSON")
return
}
text := strings.TrimSpace(body.Text)
if text == "" {
writeErr(w, 400, "约束内容不能为空")
return
}
kind := normalizeConstraintKind(body.Kind)
if kind == "" {
writeErr(w, 400, "kind 必须是 allow 或 deny")
return
}
if err := t.Store.UpdateConstraint(cid, kind, text); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, ConstraintDTO{ID: strconv.FormatInt(cid, 10), Kind: kind, Text: text})
}
// deleteConstraint 人工删除一条约束。不通知 planner。
func (s *Server) deleteConstraint(w http.ResponseWriter, r *http.Request) {
t, ok := s.m.Task(r.PathValue("id"))
if !ok {
writeErr(w, 404, "task not found")
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "任务正在删除,无法删除约束")
return
}
defer s.engine.decInflight(t.ID)
cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64)
if err != nil || cid <= 0 {
writeErr(w, 400, "bad constraint id")
return
}
if err := t.Store.DeleteConstraint(cid); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
// normalizeConstraintKind lowercases + validates the kind; "" on invalid.
func normalizeConstraintKind(k string) string {
k = strings.TrimSpace(strings.ToLower(k))
if k == "allow" || k == "deny" {
return k
}
return ""
}
// constraintDTOs converts db rows to the frontend shape.
func constraintDTOs(in []db.Constraint) []ConstraintDTO {
out := make([]ConstraintDTO, 0, len(in))
for _, c := range in {
out = append(out, ConstraintDTO{
ID: strconv.FormatInt(c.ID, 10),
Kind: c.Kind,
Text: c.Text,
Origin: c.Origin,
TS: rfc3339(c.CreatedAt),
})
}
return out
}
+9
View File
@@ -208,6 +208,15 @@ func goalDTOs(in []*db.Node) []GoalDTO {
return out
}
// ConstraintDTO is one operation constraint (allow/deny) for the 总览「约束管理」UI.
type ConstraintDTO struct {
ID string `json:"id"`
Kind string `json:"kind"` // allow | deny
Text string `json:"text"`
Origin string `json:"origin,omitempty"`
TS string `json:"ts,omitempty"`
}
func taskNodeDTOs(in []*db.Node) []TaskNodeDTO {
out := make([]TaskNodeDTO, 0, len(in))
for _, n := range in {
+31
View File
@@ -450,6 +450,7 @@ func (s *Server) seedOrchestrationTools() {
s.seedPlannerDefaultBindings()
s.seedAutoReportFindingBinding()
s.unbindGoalMetDefault()
s.reseedGoalsPrompt() // goals 提示词加入「抽操作约束」步 → 旧库追加一版新默认(一次性)
s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性)
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
@@ -509,6 +510,36 @@ func (s *Server) unbindGoalMetDefault() {
_ = s.m.pg.SetSetting(flag, "true")
}
// reseedGoalsPrompt 把 goals 目标拆解器的提示词刷成【当前代码默认】——因为默认正文新增了
// 「先抽操作约束(set_constraints)再拆目标」这一步,而 SeedPromptIfEmpty 首插入only,旧库
// 已有的 version 1 收不到这步。这里用版本管理【追加一个新版本】并切过去(ResetPromptToDefault),
// 旧的版本仍保留在历史里,用户若自定义过可从版本记录找回。settings flag 守卫 → 只做一次;
// 以后默认再变就 bump 这个 flag。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。
func (s *Server) reseedGoalsPrompt() {
const flag = "goals_prompt_constraint_step_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("goals")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["goals"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] goals 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] goals 提示词已追加新默认版本(加入抽操作约束步,一次性)")
}
// seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除):
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。
+27 -3
View File
@@ -387,12 +387,14 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
wk.SetWebSearch(s.webSearchFor("worker"))
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读)
pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg)
pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner"))
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
pl.SetWebSearch(s.webSearchFor("planner"))
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读)
return pl, wk
}
@@ -669,6 +671,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务)
mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务)
mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活)
mux.HandleFunc("GET /api/tasks/{id}/constraints", s.listConstraints) // 约束管理:列出本任务操作约束
mux.HandleFunc("POST /api/tasks/{id}/constraints", s.addConstraint) // 约束管理:新增约束(不通知 planner)
mux.HandleFunc("PATCH /api/tasks/{id}/constraints/{cid}", s.editConstraint) // 约束管理:修改约束
mux.HandleFunc("DELETE /api/tasks/{id}/constraints/{cid}", s.deleteConstraint) // 约束管理:删除约束
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler)
@@ -3005,6 +3011,9 @@ func (s *Server) settingsPayload() map[string]any {
// 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。
"llm_pool_enabled": s.m.LLMPoolEnabled(),
"llm_pool_bind_fallback": s.m.LLMPoolBindFallback(),
// 操作约束注入范围(默认都开):把本任务的 allow/deny 约束拼进对应 agent 的系统提示。
"constraints_inject_planner": s.constraintInjectPlanner(),
"constraints_inject_worker": s.constraintInjectWorker(),
}
}
@@ -3045,11 +3054,26 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
// 重建 provider 链才生效,走下面的 changed → applyLLM 路径。
LLMPoolEnabled *bool `json:"llm_pool_enabled"`
LLMPoolBindFallback *bool `json:"llm_pool_bind_fallback"`
// 操作约束注入范围开关(默认都开);即时生效(planner/worker 每轮读),无需重建 agent。
ConstraintsInjectPlanner *bool `json:"constraints_inject_planner"`
ConstraintsInjectWorker *bool `json:"constraints_inject_worker"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
return
}
if req.ConstraintsInjectPlanner != nil {
if err := s.m.pg.SetBool(settingConstraintsInjectPlanner, *req.ConstraintsInjectPlanner); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.ConstraintsInjectWorker != nil {
if err := s.m.pg.SetBool(settingConstraintsInjectWorker, *req.ConstraintsInjectWorker); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.Workers != nil {
if err := s.m.SetWorkers(*req.Workers); err != nil {
writeErr(w, 400, err.Error())
+2
View File
@@ -378,12 +378,14 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
wk.SetWebSearch(s.webSearchFor("worker"))
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开)
pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
pl.SetKillWork(s.engine.KillWork)
pl.SetSteerWork(s.engine.SteerWork)
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
pl.SetWebSearch(s.webSearchFor("planner"))
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开)
main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())