feat(planner): 触发上下文带实际变动 + 心跳触发改为「定时巡检」措辞
把 planner 每轮的触发信号从「固定一句"图发生了变化"」升级为具体变动:
- 统一 TriggerEvent{Kind,IntentID,Detail} 累加器(替换原 doneIntents):
worker 结束→{done}, report_finding→{finding,摘要}。Task.NotifyDone/新增
NotifyFinding 记录, drainTriggers 消费, Plan 参数改为 []TriggerEvent。
- renderTriggers 渲染:worker 结束→哪个意图(摘要)+worker 输出结论(取 result/text
全文,截断800字);finding→哪个意图+finding 摘要。
- worker 回调 notify()→notifyFinding(intentID,summary),report_finding 落库带上下文。
- 时间/心跳触发(triggers 为空)不再谎称"图发生了变化",开场白改为「定时巡检」并
提示复查在跑意图(steer_work 纠偏/kill_work 止损),对齐心跳的飞行中监督职责。
This commit is contained in:
1 parent
8e31a75263
commit
678025f850
6 files changed
+151
-50
No files matched your search
+92
-16
@@ -91,18 +91,88 @@ func renderPlannerTodos(items []actool.Todo) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderDoneIntents surfaces the intent(s) whose completion triggered this round
|
||||
// so the planner looks first at their fresh yields (in the态势 below) before
|
||||
// deciding whether a new direction opened up. Empty for non-completion wakes.
|
||||
func renderDoneIntents(ids []int64) string {
|
||||
if len(ids) == 0 {
|
||||
// TriggerEvent describes what concretely caused this planning round to fire, so
|
||||
// the planner looks first at the actual change instead of re-scanning the whole
|
||||
// overview. Kind:
|
||||
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
|
||||
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
|
||||
type TriggerEvent struct {
|
||||
Kind string
|
||||
IntentID int64
|
||||
Detail string
|
||||
}
|
||||
|
||||
// renderTriggers spells out the change(s) that fired this round: for a finished
|
||||
// worker — which intent + its output conclusion; for a finding — which intent +
|
||||
// what was found. Empty for time/heartbeat wakes. Reads the store (best-effort;
|
||||
// a blank field never blocks the round).
|
||||
func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
|
||||
if len(evs) == 0 || ts == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
parts[i] = fmt.Sprintf("#%d", id)
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n【本次触发本轮的实际变动(先看这里,再决定是否补方向)】:")
|
||||
for _, ev := range evs {
|
||||
sum := intentSummary(ts, ev.IntentID)
|
||||
switch ev.Kind {
|
||||
case "finding":
|
||||
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, sum, ev.Detail))
|
||||
default: // "done"
|
||||
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, sum, workerOutput(ts, ev.IntentID)))
|
||||
}
|
||||
}
|
||||
return "\n\n【本次触发本轮的、刚完成的意图】:" + strings.Join(parts, "、")
|
||||
b.WriteString("\n(完整细节可 node_detail / get_worker_output / list_findings 再查。)")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// intentSummary reads an intent node's one-line summary (best-effort, "?" on miss).
|
||||
func intentSummary(ts *db.ExplorationStore, id int64) string {
|
||||
n, err := ts.GetNode(id)
|
||||
if err != nil || n == nil {
|
||||
return "?"
|
||||
}
|
||||
var p map[string]any
|
||||
if json.Unmarshal(n.Payload, &p) == nil {
|
||||
if s, ok := p["summary"].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// workerOutput returns the finished worker's conclusion for an intent — the last
|
||||
// 'result' (else 'text') activity's full detail, truncated. Same source get_worker_output uses.
|
||||
func workerOutput(ts *db.ExplorationStore, id int64) string {
|
||||
acts, _, err := ts.ActivityList(&id, 0, 1000)
|
||||
if err != nil {
|
||||
return "(取输出失败)"
|
||||
}
|
||||
var pick *db.Activity
|
||||
for i := range acts {
|
||||
if acts[i].Kind == "result" {
|
||||
pick = &acts[i]
|
||||
} else if acts[i].Kind == "text" && pick == nil {
|
||||
pick = &acts[i]
|
||||
}
|
||||
}
|
||||
if pick == nil {
|
||||
return "(该 work 尚无输出记录)"
|
||||
}
|
||||
out, _ := ts.ActivityDetail(pick.ID)
|
||||
if out == "" {
|
||||
out = pick.Summary
|
||||
}
|
||||
return truncOutput(out, 800)
|
||||
}
|
||||
|
||||
// truncOutput caps a worker-output blob so the trigger context doesn't bloat the
|
||||
// system prompt every round; full text is one get_worker_output call away.
|
||||
func truncOutput(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + " …(已截断,完整见 get_worker_output)"
|
||||
}
|
||||
|
||||
// renderGraphOverview folds the pre-computed graph_overview snapshot into the
|
||||
@@ -167,11 +237,11 @@ func plannerSystem(goal, workDir string) string {
|
||||
// steps (so users can see how it reads the situation and judges goals — the
|
||||
// planner is the intent generator and was previously a black box). Returns whether
|
||||
// the planner judged the goal met.
|
||||
// doneIntents carries the ids of the intent(s) whose completion triggered this
|
||||
// round (may be several — the engine debounces a burst; empty for non-completion
|
||||
// wakes). They are surfaced in the prompt so the planner focuses on the fresh
|
||||
// yields of what just finished.
|
||||
func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal string, doneIntents []int64, emit func(db.Activity)) (met bool, reason string, err error) {
|
||||
// triggers carries the concrete change(s) that fired this round — worker(s) done
|
||||
// and/or finding(s) reported (may be several — the engine debounces a burst; empty
|
||||
// for time/heartbeat wakes). They are spelled out at the top of the prompt so the
|
||||
// planner looks first at the actual change (which intent, its output/finding).
|
||||
func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal string, triggers []TriggerEvent, emit func(db.Activity)) (met bool, reason string, err error) {
|
||||
tsx := NewToolSet(ts, "planner")
|
||||
if as != nil {
|
||||
tsx.SetAssetStore(as, as.Companies())
|
||||
@@ -189,7 +259,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
// 关键态势(刚完成的意图 + 预取的完整图)放进 system prompt,而不是 user 输入:
|
||||
// system 段永不被 compaction 压缩,长回合里也保证这份态势始终在场;轮内固定,还能吃到
|
||||
// system 缓存。它落在指令之后、deferred 工具块之前(deferredSystem 保证块在最后)。
|
||||
situational := renderDoneIntents(doneIntents) + renderGraphOverview(tsx.graphOverviewData())
|
||||
situational := renderTriggers(ts, triggers) + renderGraphOverview(tsx.graphOverviewData())
|
||||
// 任务级 deadline / 终局模式(经 ctx 注入,见 taskclock.go)。终局那一轮把任务超时
|
||||
// planner 收尾词作为【本轮操作指令】直接注入 system,让它只做最后目标判定、不产新意图。
|
||||
tc := taskClockFrom(ctx)
|
||||
@@ -243,7 +313,13 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
}
|
||||
// 态势(刚完成的意图 + 完整图)已在上面拼进 system prompt(抗压缩、轮内常驻)。这里
|
||||
// 的 user 输入只留指令 + 跨唤醒待办(todo 是模型自己的规划便签,可再生,放 user 即可)。
|
||||
input := "图发生了变化,请规划下一步:读取上方 system 里的态势,判定目标。**本轮若无未被覆盖的新方向,直接结束即可(生成 0 个意图是正常且常见的,尤其刚派完意图在等 worker 产出时)。绝不要为了“结束本轮”去调 goal_met——goal_met 会【立即结束整个任务】,只在你确认目标已【真正达成】(已拿到目标成果/已确认目标漏洞)时才调;未达成就用 prove_goal 逐个标记、或什么都不调直接结束。**" +
|
||||
// 开场白按「本轮有无具体变动」分两种:有变动 → 指向上方【实际变动】块;无变动
|
||||
// (心跳定时巡检 / hint / 恢复等) → 别谎称"图发生了变化",转而提示顺带复查在跑意图。
|
||||
lead := "刚有具体变动(见上方 system 的【本次触发本轮的实际变动】),据此规划下一步:"
|
||||
if len(triggers) == 0 {
|
||||
lead = "本轮是**定时巡检(心跳到点)/无具体变动信号**的唤醒——图不一定有新变动。顺带复查在跑意图:长时间无进展或跑偏的用 steer_work 纠偏、方向整个错的用 kill_work 止损;再判定目标、决定是否补方向:"
|
||||
}
|
||||
input := lead + "读取上方 system 里的态势,判定目标。**本轮若无未被覆盖的新方向,直接结束即可(生成 0 个意图是正常且常见的,尤其刚派完意图在等 worker 产出时)。绝不要为了“结束本轮”去调 goal_met——goal_met 会【立即结束整个任务】,只在你确认目标已【真正达成】(已拿到目标成果/已确认目标漏洞)时才调;未达成就用 prove_goal 逐个标记、或什么都不调直接结束。**" +
|
||||
renderPlannerTodos(opts.Todos.List())
|
||||
// 有 deadline 夹逼时加硬 ctx 兜底(软预算 + grace),防单轮卡死绕过轮边界软超时。
|
||||
runCtx := ctx
|
||||
|
||||
+17
-2
@@ -73,12 +73,19 @@ type ToolSet struct {
|
||||
// re-planned promptly (currently: a new hint). nil = no wake (the hint is still
|
||||
// stored and read on the next round triggered by other events). debounced.
|
||||
notify func()
|
||||
// notifyFinding, if set, wakes the task's planner when this run reports a finding,
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
// SetNotifyFinding wires the finding-wake callback (see ToolSet.notifyFinding).
|
||||
func (t *ToolSet) SetNotifyFinding(fn func(int64, string)) { t.notifyFinding = fn }
|
||||
|
||||
// EnrichTrigger is the enrichment engine seen from the tool layer (see package
|
||||
// enrich). Kept as an interface here to avoid coupling agent → enrich.
|
||||
type EnrichTrigger interface {
|
||||
@@ -647,8 +654,16 @@ func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> finding
|
||||
}
|
||||
_, _ = t.ts.AddStandaloneFinding(t.taskID, id, a.VulnClass, a.Severity, a.Summary, a.Evidence, t.worker, anchors)
|
||||
if t.notify != nil {
|
||||
t.notify() // 确证漏洞落库 → 当场唤醒本任务 planner(不等 worker 收工,debounce 合并)
|
||||
// 确证漏洞落库 → 当场唤醒本任务 planner(不等 worker 收工,debounce 合并)。
|
||||
// 优先带上下文(哪个意图+finding摘要);intent 用工具参数,缺省回退到 owner 意图。
|
||||
if t.notifyFinding != nil {
|
||||
iid := pid(a.IntentID)
|
||||
if iid <= 0 {
|
||||
iid = t.ownerNode
|
||||
}
|
||||
t.notifyFinding(iid, a.Summary)
|
||||
} else if t.notify != nil {
|
||||
t.notify()
|
||||
}
|
||||
} else {
|
||||
// conversation context: no exploration store available, cannot record finding
|
||||
|
||||
+7
-6
@@ -234,21 +234,22 @@ func renderWorkerGraphOverview(data map[string]any) string {
|
||||
|
||||
// Execute runs one intent. hooks (the per-task Guard) gates every tool call; may
|
||||
// be nil. emit, if non-nil, receives one ActivityRecord per execution step.
|
||||
// notify, if non-nil, is called when this worker writes a finding (report_finding)
|
||||
// so the task's planner wakes mid-flight instead of waiting for the worker to finish.
|
||||
// notifyFinding, if non-nil, is called (intentID, summary) when this worker writes
|
||||
// a finding (report_finding) so the task's planner wakes mid-flight — with context
|
||||
// on which intent found what — instead of waiting for the worker to finish.
|
||||
// Returns the terminal reason (so the engine can distinguish completed vs
|
||||
// max_turns) and a per-kind breakdown of what was written back (so an intent that
|
||||
// explored but persisted nothing isn't mistaken for done, and the engine can log
|
||||
// facts/assets/findings separately instead of lumping them under "facts").
|
||||
func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notify func()) (harness.TerminalReason, WriteCounts, error) {
|
||||
func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string)) (harness.TerminalReason, WriteCounts, error) {
|
||||
tsx := NewToolSet(ts, name)
|
||||
tsx.SetTaskID(taskID)
|
||||
if as != nil {
|
||||
tsx.SetAssetStore(as, as.Companies())
|
||||
}
|
||||
tsx.SetOwnerNode(intent.ID) // assets this worker discovers anchor to its intent → visible to the task
|
||||
tsx.SetEnrich(enr) // async DNS/HTTP auto-completion for assets this worker writes
|
||||
tsx.SetNotify(notify) // finding 写回时当场唤醒本任务 planner(report_finding 触发)
|
||||
tsx.SetOwnerNode(intent.ID) // assets this worker discovers anchor to its intent → visible to the task
|
||||
tsx.SetEnrich(enr) // async DNS/HTTP auto-completion for assets this worker writes
|
||||
tsx.SetNotifyFinding(notifyFinding) // report_finding 落库时当场唤醒 planner,带上「哪个意图+finding」
|
||||
// base = built-in worker tools ∪ host tools (traffic) ∪ default tools (incl. Bash);
|
||||
// then augment with the agent's visible skills/MCP. During the SDK settlement
|
||||
// phase, Bash is hidden via Settlement.DisabledTools (no local gating needed).
|
||||
|
||||
+6
-6
@@ -511,12 +511,12 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
// UI can separate rounds in the transcript (kind='round').
|
||||
e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
|
||||
Summary: fmt.Sprintf("第 %d 轮规划", e.nextPlannerRound(t.ID))})
|
||||
// which intents' completion triggered this round (may be several — debounce
|
||||
// coalesces a burst; empty for non-completion wakes like a new hint / heartbeat).
|
||||
doneIntents := t.drainDone()
|
||||
// what fired this round (worker done / finding; may be several — debounce
|
||||
// coalesces a burst; empty for time/heartbeat wakes).
|
||||
triggers := t.drainTriggers()
|
||||
e.incInflight(t.ID)
|
||||
taskIDInt, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
met, reason, err := planner.Plan(ectx, taskIDInt, e.m.assets, t.Store, t.Goal, doneIntents, emit)
|
||||
met, reason, err := planner.Plan(ectx, taskIDInt, e.m.assets, t.Store, t.Goal, triggers, emit)
|
||||
e.decInflight(t.ID)
|
||||
switch {
|
||||
case err != nil && ectx.Err() == nil:
|
||||
@@ -611,7 +611,7 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
|
||||
hooks := steerHooks{inner: t.Guard.Hooks(), drain: func() (string, bool) { return e.drainSteer(iid) }}
|
||||
wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
e.incInflight(t.ID) // 计入在跑,供收尾时序 drain 等待
|
||||
reason, wrote, err := worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.Notify)
|
||||
reason, wrote, err := worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
|
||||
// model_error 收场 → 额外重跑几次(退避后再试)。仅在意图仍属本 work、任务
|
||||
// 未暂停/未终止/未取消【且未进入收尾】时重试;否则让位给对应分支处理(收尾期不
|
||||
// 再重试,避免退避挤占其他 worker 的优雅收尾窗口)。
|
||||
@@ -623,7 +623,7 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
|
||||
if sleepCtx(workCtx, modelErrorRetryBackoff) {
|
||||
break // 退避期间被取消(终止/暂停)→ 交给下方分支处理
|
||||
}
|
||||
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.Notify)
|
||||
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
|
||||
}
|
||||
e.decInflight(t.ID)
|
||||
// CAPTURE kill state BEFORE unregisterWork cancels workCtx. kill = this work's
|
||||
|
||||
@@ -209,7 +209,7 @@ func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
|
||||
fctx := e.clockCtx(ctx, t, true)
|
||||
e.incInflight(t.ID)
|
||||
tTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
met, reason, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainDone(), emit)
|
||||
met, reason, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainTriggers(), emit)
|
||||
e.decInflight(t.ID)
|
||||
if err != nil {
|
||||
log.Printf("[deadline] task %s 终局规划出错: %v", t.ID, err)
|
||||
|
||||
+28
-19
@@ -44,11 +44,11 @@ type Task struct {
|
||||
Guard *guard.Guard `json:"-"`
|
||||
notify chan struct{}
|
||||
|
||||
// doneIntents accumulates the ids of intents that completed since the last
|
||||
// planning round consumed them. The debounce coalesces a burst of completions
|
||||
// into one round, so several ids may pile up before drainDone() clears them.
|
||||
doneMu sync.Mutex
|
||||
doneIntents []int64
|
||||
// pendingTriggers accumulates the concrete changes (worker done / finding) that
|
||||
// fired planning rounds since the last one consumed them. The debounce coalesces
|
||||
// a burst into one round, so several may pile up before drainTriggers() clears them.
|
||||
trigMu sync.Mutex
|
||||
pendingTriggers []agent.TriggerEvent
|
||||
}
|
||||
|
||||
// Manager owns the PostgreSQL data source (asset graph + every task's exploration
|
||||
@@ -696,24 +696,33 @@ func (t *Task) Notify() {
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyDone is Notify plus a hint: intentID just finished and is what triggered
|
||||
// this wake-up. The planner reads the accumulated ids next round so it knows
|
||||
// which intents' fresh yields to focus on. Ids pile up (debounce) until the
|
||||
// round drains them via drainDone.
|
||||
// NotifyDone is Notify plus a hint: a worker just finished intentID and that is
|
||||
// what triggered this wake-up. The planner reads the accumulated triggers next
|
||||
// round so it can spell out which intent finished (+ its output). Events pile up
|
||||
// (debounce) until the round drains them via drainTriggers.
|
||||
func (t *Task) NotifyDone(intentID int64) {
|
||||
if intentID > 0 {
|
||||
t.doneMu.Lock()
|
||||
t.doneIntents = append(t.doneIntents, intentID)
|
||||
t.doneMu.Unlock()
|
||||
t.trigMu.Lock()
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "done", IntentID: intentID})
|
||||
t.trigMu.Unlock()
|
||||
}
|
||||
t.Notify()
|
||||
}
|
||||
|
||||
// drainDone returns and clears the intent ids completed since the last round.
|
||||
func (t *Task) drainDone() []int64 {
|
||||
t.doneMu.Lock()
|
||||
defer t.doneMu.Unlock()
|
||||
ids := t.doneIntents
|
||||
t.doneIntents = nil
|
||||
return ids
|
||||
// NotifyFinding records that a worker reported a finding on intentID (summary),
|
||||
// then wakes the planner — so the round spells out which intent found what.
|
||||
func (t *Task) NotifyFinding(intentID int64, summary string) {
|
||||
t.trigMu.Lock()
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "finding", IntentID: intentID, Detail: summary})
|
||||
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()
|
||||
defer t.trigMu.Unlock()
|
||||
ev := t.pendingTriggers
|
||||
t.pendingTriggers = nil
|
||||
return ev
|
||||
}
|
||||
Reference in new issue
Block a user