feat: set_goals 升级为受管领域工具(可 web 管理,默认绑 goals+mainagent)+ 主 agent 加目标复活任务

- set_goals 从目标拆解器内联提交工具,改为入 tools 目录的领域工具:直接写 goal 节点、
  连任务根(RelSpawns)、notify 唤醒 planner;支持单条/批量;origin 按来源区分
  (拆解器=goals、主 agent 运行时=human)。新 key 走 SeedTool 首插入,旧库无需迁移。
- 默认绑定 goals + mainagent:目录 seed 新增 goals 项并入 order,MainAgentTools 纳入;
  DecomposeGoals 统一改用同一受管工具直接落库,createGoals 只保留无产出兜底。
- 主 agent 通过 set_goals 新增目标时,把终态(done/failed/timeout)/暂停的任务拉回
  running 继续跑:新增 ToolSet.resumeTask 回调(仅 mainagent 接线),经 resumeTaskForGoal
  置 running + engine.Run(幂等/重启后重起循环)+ 解除暂停。拆解器/worker 不触发。
- mainagent 提示词补充 set_goals 用法。
This commit is contained in:
Autumn-27 committed 2026-08-17 09:01:19 -04:00
1 parent 1029c4a84c
commit 2ac176b96a
7 files changed
+184 -45

No files matched your search

+28 -27
View File
@@ -72,43 +72,30 @@ type GoalSpec struct {
//
// as + taskID, when non-nil/positive, wire the add_task_scope tool so the
// decomposer can register the explicit asset scope it extracts from the goal.
func DecomposeGoals(ctx context.Context, c Config, dataDir, goalText, desc string, as *db.AssetStore, taskID int64, emit func(db.Activity)) []GoalSpec {
//
// ts is the task's exploration store: set_goals writes the decomposed goal nodes
// straight into it (the same managed tool the main agent uses to add goals at
// runtime). The returned specs are read back from the store so callers can emit
// per-goal activity and detect the "LLM produced nothing" case for their fallback.
func DecomposeGoals(ctx context.Context, c Config, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
prov, err := c.NewProvider()
if err != nil {
return nil
}
var out struct {
Goals []GoalSpec `json:"goals"`
}
captured := false
submitTool := actool.Build(actool.Spec{
Name: "set_goals",
Description: "提交拆分后的目标列表。",
Schema: map[string]any{"type": "object", "properties": map[string]any{
"goals": map[string]any{"type": "array", "items": map[string]any{
"type": "object", "properties": map[string]any{
"text": map[string]any{"type": "string", "description": "一个独立可验证的目标"},
"vulnclass": map[string]any{"type": "string", "description": "对应漏洞类(若明确),如 SQLi/IDOR;业务逻辑目标可留空"},
}, "required": []string{"text"},
}},
}, "required": []string{"goals"}},
Permissions: func(context.Context, json.RawMessage, acperm.Context) acperm.Decision { return acperm.Allowed() },
Run: func(_ context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
_ = json.Unmarshal(in, &out)
captured = true
return actool.Text("ok"), nil
},
})
// worker="goals" tags the goal nodes' provenance; ts/taskID let set_goals link
// each goal under the task root. This is the catalog's real set_goals tool, so a
// web-edited description/schema on it applies here too.
tsx := &ToolSet{as: as, ts: ts, taskID: taskID, worker: "goals"}
// 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{DataDir: dataDir, Now: nowStr()})
tools := []actool.CoreTool{submitTool}
tools := []actool.CoreTool{tsx.setGoals()}
// 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
// a tool that isn't present.
if as != nil && taskID > 0 {
tools = append(tools, (&ToolSet{as: as, taskID: taskID}).addTaskScope())
tools = append(tools, tsx.addTaskScope())
sys += goalsScopeTail
}
userMsg := "任务目标:\n" + goalText
@@ -131,8 +118,22 @@ func DecomposeGoals(ctx context.Context, c Config, dataDir, goalText, desc strin
DisableBackgroundTasks: true,
MaxTurns: 6,
}, userMsg, captureEmit)
if !captured {
// set_goals persisted the goals directly; read them back so the caller sees what
// was written (empty slice ⇒ the LLM produced nothing ⇒ caller falls back).
if ts == nil {
return nil
}
return out.Goals
nodes, _ := ts.ListByKind(db.KindGoal, 10000)
var out []GoalSpec
for _, n := range nodes {
var p struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
_ = json.Unmarshal(n.Payload, &p)
if strings.TrimSpace(p.Text) != "" {
out = append(out, GoalSpec{Text: p.Text, VulnClass: p.VulnClass})
}
}
return out
}
+4 -2
View File
@@ -48,6 +48,7 @@ const mainAgentDefaultTmpl = `你是一个授权渗透测试系统的"主 agent"
2. 操舵(把人的意图落到系统):
- 人想"改方向/强调某类漏洞/重点某区域" → 用 add_hint 写提示(规划者下次会读到)。
- 人想"立刻测某个具体目标" → 用 add_intent 直接注入一条高优先级意图(priority 8-10)。
- 人想"新增一个要达成的最终目标" → 用 set_goals 增补目标。系统会把该目标写入任务图并**自动把已完成/暂停的任务拉回运行态继续跑**(规划者随后会据此重新判断是否达成),无需人工再点恢复。
3. 用人话简洁回复,说明你做了什么。
当前任务目标:{{.Goal}}
@@ -63,13 +64,14 @@ func mainAgentSystem(goal, dataDir, workDir string) string {
// non-nil, receives each execution step (thinking / tool_use / tool_result /
// text / result) so the main-agent session shows its work — exactly like the
// worker/planner sessions — not just the final answer.
func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, emit func(db.Activity), notify func()) (string, error) {
func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, emit func(db.Activity), notify, resume func()) (string, error) {
tsx := NewToolSet(ts, "human")
if as != nil {
tsx.SetAssetStore(as, as.Companies())
}
tsx.SetTaskID(taskID)
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
+4 -1
View File
@@ -37,6 +37,9 @@ func builtinToolsByAgent() map[string][]actool.CoreTool {
"mainagent": ts.MainAgentTools(),
"planner": ts.PlannerTools(),
"worker": ts.WorkerTools(),
// goals(目标拆解器)默认绑 set_goals:它靠这个工具把拆出的目标写进图。
// 与 mainagent 共用同一受管工具,web 端可改描述/schema、按 agent 勾选。
"goals": {ts.setGoals()},
// auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。
// 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。
"auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()},
@@ -50,7 +53,7 @@ func builtinToolsByAgent() map[string][]actool.CoreTool {
// list_assets 三者都有)合成一条,Agents 取并集。顺序稳定(mainagent→planner→worker)。
func BuiltinToolSeeds() []ToolSeed {
byAgent := builtinToolsByAgent()
order := []string{"mainagent", "planner", "worker", "auto", "pentest"}
order := []string{"mainagent", "goals", "planner", "worker", "auto", "pentest"}
type acc struct {
tool actool.CoreTool
+103
View File
@@ -77,8 +77,18 @@ type ToolSet struct {
// carrying (intentID, summary) so the round can spell out which intent found what.
// Wired for workers; nil elsewhere → falls back to notify (bare wake).
notifyFinding func(intentID int64, summary string)
// resumeTask, if set, revives the task after a graph change that should make a
// stopped task run again (currently: set_goals adds a goal). It flips a terminal/
// paused task back to running and (re)starts the engine loops — a plain notify()
// can't, because the planner's terminal gate swallows wakes. Wired ONLY for the
// main agent (human steering); nil for the goals decomposer and workers.
resumeTask func()
}
// SetResumeTask wires the task-revive callback (see ToolSet.resumeTask). Set only by
// the main-agent chat, so runtime-added goals can pull a finished task back to running.
func (t *ToolSet) SetResumeTask(fn func()) { t.resumeTask = fn }
// SetNotify wires the planner-wake callback (see ToolSet.notify). Set by callers
// that hold the task handle (main-agent chat, cross-task orchestration).
func (t *ToolSet) SetNotify(fn func()) { t.notify = fn }
@@ -799,6 +809,99 @@ func (t *ToolSet) addOneHint(it hintItem) (int64, error) {
return id, err
}
type goalItem struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
// addOneGoal 挂一条 goal 节点(open)到探索图:连到任务根(origin fact,rel spawns),
// 并唤醒 planner(运行时新增的目标要尽快被判定)。origin 取 t.worker(缺省 system):
// goals 拆解器写入的记 "goals"、主 agent 运行时新增的记 "human"。
func (t *ToolSet) addOneGoal(it goalItem) (int64, error) {
text := strings.TrimSpace(it.Text)
if text == "" {
return 0, fmt.Errorf("text 不能为空")
}
payload := map[string]any{"text": text}
if vc := strings.TrimSpace(it.VulnClass); vc != "" {
payload["vulnclass"] = vc
}
origin := t.worker
if origin == "" {
origin = "system"
}
id, err := t.ts.AddNode(db.KindGoal, payload, 0, "open", origin, nil)
if err != nil {
return 0, err
}
if of, _ := t.ts.OriginFactID(); of > 0 && id > 0 {
_ = t.ts.Link(of, db.RelSpawns, id) // goals descend from the task root (origin fact)
}
if t.notify != nil {
t.notify() // wake the planner so a runtime-added goal is judged promptly (debounced)
}
return id, nil
}
// setGoals 给【本任务】新增探索目标(goal 节点)。既是目标拆解器的提交工具,也是主
// agent 运行时补目标的工具——同一个受管工具,可在 web 端改描述/schema、按 agent 绑定。
func (t *ToolSet) setGoals() actool.CoreTool {
return writeTool("set_goals",
"给【本任务】新增探索目标(goal)。目标=最终可交付/可核验的结果,不是攻击步骤或侦察动作。\n"+
"★优先批量:多个目标放进 goals 数组一次提交,返回 ids 与之等长同序(失败项 id=0,详情见 errors)。单条则省略 goals 直接给顶层 text。\n"+
"vulnclass 可选:对应漏洞类(如 SQLi/IDOR),业务逻辑类目标留空。目标是否达成由系统判定标记 met,本工具只负责新增。",
obj(map[string]any{
"goals": map[string]any{"type": "array", "description": "【优先用这个】要新增的目标数组,按顺序处理。每个元素:text(必填,一个独立可验证的最终目标)+ vulnclass(可选)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
"text": str("[单条] 一个独立可验证的最终目标"),
"vulnclass": str("[单条] 对应漏洞类(若明确),如 SQLi/IDOR;业务逻辑目标可留空"),
}),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.ts == nil {
return actool.Errorf("set_goals 未启用: ExplorationStore 未初始化"), nil
}
var a struct {
Goals []goalItem `json:"goals"`
goalItem // 单条模式:顶层 text/vulnclass
}
_ = json.Unmarshal(in, &a)
batch := len(a.Goals) > 0
items := a.Goals
if !batch {
items = []goalItem{a.goalItem}
}
ids := make([]int64, len(items))
errs := map[string]string{}
added := 0
for i, it := range items {
id, err := t.addOneGoal(it)
if err != nil {
errs[strconv.Itoa(i)] = err.Error()
continue
}
ids[i] = id
added++
}
// 主 agent 运行时新增目标 → 把已完成/暂停的任务拉回 running 继续跑(终态门会吞
// 掉普通 notify,必须显式复活)。仅 mainagent 接了此回调;拆解器/worker 为 nil。
if added > 0 && t.resumeTask != nil {
t.resumeTask()
}
if !batch { // 单条:保持原返回
if e, bad := errs["0"]; bad {
return actool.Errorf(e), nil
}
return actool.Text(fmt.Sprintf("goal added: %d", ids[0])), nil
}
out := map[string]any{"ids": ids}
if len(errs) > 0 {
out["errors"] = errs
}
return jsonResult(out)
})
}
func (t *ToolSet) addHint() actool.CoreTool {
return writeTool("add_hint", "把人类/主 agent 的战略提示挂到探索图,规划者下次生成意图时会读到它。\n"+
"★优先批量:多条提示放进 hints 数组一次提交(比逐条调用省往返)。返回 ids 数组,与 hints 等长同序(失败项 id=0,详情见 errors)。单条则省略 hints 直接给顶层 text。",
+2
View File
@@ -595,6 +595,8 @@ func (t *ToolSet) MainAgentTools() []actool.CoreTool {
return []actool.CoreTool{
t.graphOverview(), t.listFindings(), t.listFacts(), t.nodeDetail(),
t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.addHint(), t.addIntent(),
// set_goals:人可在运行时给本任务补一个新的最终目标(规划者据此重判是否达成)。
t.setGoals(),
// asset management (handlers guard nil store internally)
t.insertAssets(), t.addCompanyScope(), t.listAssets(),
t.addFinding(), t.recordFact(),
+41 -14
View File
@@ -46,6 +46,35 @@ func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) {
}()
}
// resumeTaskForGoal 让「主 agent 通过 set_goals 新增目标」的任务能继续跑:把终态
// (done/failed/timeout)拉回 running、解除暂停,并(重)启动引擎循环 + 唤醒 planner。
// 已在 running 且未暂停的任务:只剩 Run 里的一次 Notify,近乎无副作用。
//
// 为什么必须显式复活:planner 循环的终态门(engine.go)会吞掉普通 notify——光新增 goal
// 节点 + Notify 唤不醒已判完成的任务;重启后终态任务的 goroutine 也可能已不在,故还要 Run。
func (s *Server) resumeTaskForGoal(t *Task) {
if t == nil {
return
}
// 终态 → 拉回 running(SetTaskStatus 会清 completed_at 并同步内存 handle 的 Status,
// 使 planner 循环的终态门放行)。
if isTerminalStatus(t.Status) {
if err := s.m.SetTaskStatus(t.ID, "running"); err != nil {
log.Printf("[chat] task %s set_goals 复活置 running 失败: %v", t.ID, err)
}
}
// 确保引擎循环存活:已 started 只会 Notify;未 started(重启后未恢复的终态任务)则重起循环。
s.engine.Run(s.ctx, t)
// 暂停中 → 解除暂停(清引擎内存态 + Notify),并持久化 paused=false(存活过重启)。
if t.Paused || s.engine.IsPaused(t.ID) {
s.engine.Resume(t)
t.Paused = false
if err := s.m.SetTaskPaused(t.ID, false); err != nil {
log.Printf("[chat] task %s set_goals 复活解除暂停失败: %v", t.ID, err)
}
}
}
// createGoals materializes the goal node(s) under the task root (rel objective).
// Decomposition is done ENTIRELY by the LLM (the project requires an LLM). There is
// no rule-based fallback splitter — it only ever produced garbage (shredded URLs,
@@ -65,31 +94,29 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
as = s.m.Assets()
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
for _, g := range agent.DecomposeGoals(ctx, cfg, s.m.dir, t.Goal, t.Description, as, taskID, emit) {
// DecomposeGoals persists each decomposed goal via the set_goals tool straight
// into t.Store; it returns the specs it wrote so we can emit activity + spot the
// empty (LLM error / no provider) case below. No write-back needed here anymore.
for _, g := range agent.DecomposeGoals(ctx, cfg, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit) {
if strings.TrimSpace(g.Text) != "" {
specs = append(specs, goalSpec{Text: g.Text, VulnClass: g.VulnClass})
}
}
}
if len(specs) == 0 {
// No rule-based splitting: use the raw task goal verbatim as a single goal
// (only reached on an LLM error — normal runs get real decomposed goals).
// No decomposed goals (LLM error / no provider): use the raw task goal verbatim
// as a single goal so the task still has something to judge against. This is the
// only path that writes here — decomposed goals are already persisted by the tool.
if g := strings.TrimSpace(t.Goal); g != "" {
log.Printf("[goals] task %s: LLM 目标拆解无产出,回退为「原始目标作为单目标」", t.ID)
origin, _ := t.Store.OriginFactID()
id, _ := t.Store.AddNode(db.KindGoal, map[string]any{"text": g}, 0, "open", "system", nil)
if origin > 0 && id > 0 {
_ = t.Store.Link(origin, db.RelSpawns, id) // goal descends from the task root (origin fact)
}
specs = []goalSpec{{Text: g}}
}
}
origin, _ := t.Store.OriginFactID()
for _, g := range specs {
payload := map[string]any{"text": g.Text}
if g.VulnClass != "" {
payload["vulnclass"] = g.VulnClass
}
id, _ := t.Store.AddNode(db.KindGoal, payload, 0, "open", "system", nil)
if origin > 0 && id > 0 {
_ = t.Store.Link(origin, db.RelSpawns, id) // goals descend from the task root (origin fact)
}
}
return specs
}
+2 -1
View File
@@ -2052,7 +2052,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
// is the captured "result" step — no separate reply emit (would duplicate).
emit := func(rec db.Activity) { s.engine.emitActivity(t, rec) }
maTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, req.Message, emit, t.Notify); err != nil && ctx.Err() == nil {
resume := func() { s.resumeTaskForGoal(t) } // set_goals 新增目标 → 把任务拉回 running
if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, req.Message, emit, t.Notify, resume); err != nil && ctx.Err() == nil {
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"})
}
}()