feat: 主 agent set_goals 加目标时给 planner 记「人新增了目标」触发(批量感知)

- 新增 TriggerEvent{Kind:"goal", Goals:[]string} + Task.NotifyGoal([]string):
  一次 set_goals(不论单条/数组)合并成一条触发,planner 下轮开头据 len 渲染
  「人新增了一个目标:X」或「人新增了 N 个目标:A;B;C」,不再靠翻 goals 列表发现。
- set_goals 工具:addOneGoal 退化为纯落库;唤醒/触发/复活挪到整批写完后统一做
  (notifyGoal(addedTexts) 一次 + resumeTask 一次)。仅 mainagent 接 notifyGoal;
  拆解器/worker 退回纯 notify,round-0 不受影响。
- 复活场景顺序仍成立:goal 触发先入队(终态门在 drain 之前不会吞),resumeTask
  置 running 后那一轮才 drain 渲染。mainagent.Chat 加 notifyGoal 入参。
This commit is contained in:
Autumn-27 committed 2026-08-17 09:12:25 -04:00
1 parent 3c81d3d248
commit 8e03043548
5 files changed
+63 -19

No files matched your search

+4 -3
View File
@@ -64,14 +64,15 @@ 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, resume 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(), notifyGoal func([]string)) (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.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
tsx.SetNotify(notify) // add_hint wakes this task's planner (debounced)
tsx.SetResumeTask(resume) // set_goals 新增目标 → 把已完成/暂停的任务拉回 running
tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
+11 -3
View File
@@ -96,10 +96,13 @@ func renderPlannerTodos(items []actool.Todo) string {
// overview. Kind:
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
type TriggerEvent struct {
Kind string
IntentID int64
Detail string
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
}
// renderTriggers spells out the change(s) that fired this round: for a finished
@@ -113,12 +116,17 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
var b strings.Builder
b.WriteString("\n\n【本次触发本轮的实际变动(先看这里,再决定是否补方向)】:")
for _, ev := range evs {
sum := intentSummary(ts, ev.IntentID)
switch ev.Kind {
case "goal":
if len(ev.Goals) == 1 {
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了一个目标:%s —— 新的待达成目标,请据此补充探索方向(若尚无对应意图)。", ev.Goals[0]))
} else {
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了 %d 个目标:%s —— 均为新的待达成目标,请逐一为尚无对应意图的目标补充探索方向。", len(ev.Goals), strings.Join(ev.Goals, ";")))
}
case "finding":
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, sum, ev.Detail))
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
default: // "done"
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, sum, workerOutput(ts, ev.IntentID)))
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID)))
}
}
b.WriteString("\n(完整细节可 node_detail / get_worker_output / list_findings 再查。)")
+31 -12
View File
@@ -83,8 +83,19 @@ type ToolSet struct {
// 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()
// notifyGoal, if set, wakes the planner AND records ONE "人新增了 N 个目标:…" trigger
// for a whole set_goals call (batch-aware — one call, one trigger, not one per goal)
// so the next round spells out the added goals (instead of the planner having to
// spot new open goals in the overview). Wired ONLY for the main agent; nil for the
// goals decomposer (round-0 has no running planner to inform) and workers → those
// fall back to the bare notify.
notifyGoal func(texts []string)
}
// SetNotifyGoal wires the goal-add trigger callback (see ToolSet.notifyGoal). Set only
// by the main-agent chat, so runtime-added goals are announced to the planner by name.
func (t *ToolSet) SetNotifyGoal(fn func([]string)) { t.notifyGoal = fn }
// 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 }
@@ -814,9 +825,9 @@ type goalItem struct {
VulnClass string `json:"vulnclass"`
}
// addOneGoal 挂一条 goal 节点(open)到探索图:连到任务根(origin fact,rel spawns),
// 并唤醒 planner(运行时新增的目标要尽快被判定)。origin 取 t.worker(缺省 system):
// goals 拆解器写入的记 "goals"、主 agent 运行时新增的记 "human"。
// addOneGoal 挂一条 goal 节点(open)到探索图:连到任务根(origin fact,rel spawns)。
// origin 取 t.worker(缺省 system):goals 拆解器写入的记 "goals"、主 agent 运行时记
// "human"。唤醒 planner 由 setGoals 在整批写完后统一做(见下),这里只负责落库。
func (t *ToolSet) addOneGoal(it goalItem) (int64, error) {
text := strings.TrimSpace(it.Text)
if text == "" {
@@ -837,9 +848,6 @@ func (t *ToolSet) addOneGoal(it goalItem) (int64, error) {
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
}
@@ -872,7 +880,7 @@ func (t *ToolSet) setGoals() actool.CoreTool {
ids := make([]int64, len(items))
errs := map[string]string{}
added := 0
var addedTexts []string
for i, it := range items {
id, err := t.addOneGoal(it)
if err != nil {
@@ -880,12 +888,23 @@ func (t *ToolSet) setGoals() actool.CoreTool {
continue
}
ids[i] = id
added++
addedTexts = append(addedTexts, strings.TrimSpace(it.Text))
}
// 主 agent 运行时新增目标 → 把已完成/暂停的任务拉回 running 继续跑(终态门会吞
// 掉普通 notify,必须显式复活)。仅 mainagent 接了此回调;拆解器/worker 为 nil。
if added > 0 && t.resumeTask != nil {
t.resumeTask()
if len(addedTexts) > 0 {
// 唤醒 planner(整批一次)。优先 notifyGoal:一次 set_goals 记一条「人新增了
// N 个目标:…」触发,不逐条刷屏;拆解器/worker 无此回调 → 退回纯 notify(拆解器
// round-0 连 notify 也没接,即无操作,因为此时 planner 尚未启动)。
switch {
case t.notifyGoal != nil:
t.notifyGoal(addedTexts)
case t.notify != nil:
t.notify()
}
// 主 agent 运行时新增目标 → 把已完成/暂停的任务拉回 running 继续跑(终态门会
// 吞掉普通 notify,必须显式复活)。仅 mainagent 接了此回调;拆解器/worker 为 nil。
if t.resumeTask != nil {
t.resumeTask()
}
}
if !batch { // 单条:保持原返回
+16
View File
@@ -718,6 +718,22 @@ func (t *Task) NotifyFinding(intentID int64, summary string) {
t.Notify()
}
// NotifyGoal records that one OR MORE goals were added in a single set_goals call —
// by the human via the main agent — then wakes the planner, so the next round spells
// out "人新增了 N 个目标:…" instead of the planner having to spot new open goals in
// the overview. One call → one trigger event (set_goals 的一次批量算一条,不逐条刷屏).
// The event survives an early-returning terminal round (drain happens after the gate),
// so a set_goals that revives a done task still surfaces it once the task is running.
func (t *Task) NotifyGoal(texts []string) {
if len(texts) == 0 {
return
}
t.trigMu.Lock()
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "goal", Goals: texts})
t.trigMu.Unlock()
t.Notify()
}
// drainTriggers returns and clears the trigger events accumulated since the last round.
func (t *Task) drainTriggers() []agent.TriggerEvent {
t.trigMu.Lock()
+1 -1
View File
@@ -2053,7 +2053,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
emit := func(rec db.Activity) { s.engine.emitActivity(t, rec) }
maTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
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 {
if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, req.Message, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil {
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"})
}
}()