revert: remove cross-agent worker direction synchronization

This commit is contained in:
pphua committed 2026-08-29 22:05:28 +08:00
1 parent 7e633b2056
commit 76210b9933
9 files changed
+11 -425

No files matched your search

+1 -23
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"github.com/Autumn-27/artex/db"
@@ -86,27 +85,6 @@ func mainAgentSystem(goal, dataDir, workDir string) string {
return body + artifactSpec(workDir)
}
// mainAgentInput keeps Worker-side human steering visible to the Main Agent on
// every fresh Chat session. The direction records remain separate from the
// current user turn so the model does not mistake persisted context for a new
// instruction that must be sent to the Worker again.
func mainAgentInput(ts *db.ExplorationStore, message string) string {
if ts == nil {
return message
}
directions, err := ts.LatestWorkerDirections(workerDirectionOverviewLimit)
if err != nil || len(directions) == 0 {
return message
}
raw, err := json.Marshal(compactWorkerDirections(directions))
if err != nil {
return message
}
return "【系统同步的 Worker 最新人工方向】\n" +
"以下内容来自持久化的 Worker 对话。回答、观察和调度时必须纳入,但不要把它当作本轮新消息重复下发。\n" +
string(raw) + "\n\n【用户本轮消息】\n" + message
}
// Chat handles one human message and returns the assistant reply. emit, if
// non-nil, receives each execution step (thinking / tool_use / tool_result /
// text / result) so the main-agent session shows its work — exactly like the
@@ -175,7 +153,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
// C2: this session is fresh each turn; re-unlock skill-gated MCPs from prior
// Skill() calls in the reloaded history so revealed tools stay callable.
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
text, _, err := captureRunSession(ctx, s, mainAgentInput(ts, message), func(r db.Activity) {
text, _, err := captureRunSession(ctx, s, message, func(r db.Activity) {
if emit != nil {
r.Worker = "mainagent"
emit(r)
+6 -14
View File
@@ -123,7 +123,6 @@ func renderPlannerTodos(items []actool.Todo) string {
//
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
// "worker_message" — the human changed one Worker's direction (Detail = 新方向).
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
// "goal_deleted" — the human deleted a goal from 总览的目标管理 (Detail = 被删目标文本).
@@ -131,17 +130,14 @@ func renderPlannerTodos(items []actool.Todo) string {
// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is
// stopped (not deleted) and the reason is attached to it as a fact.
type TriggerEvent struct {
Kind string
ActivityID int64 // persistent activity id; worker_message notifications use it for de-duplication
IntentID int64
Detail string
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
Kind string
IntentID int64
Detail string
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
}
const TriggerWorkerMessage = db.PlannerEventWorkerMessage
// 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;
@@ -166,9 +162,6 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
b.WriteString(fmt.Sprintf("\n- 人修改了目标,由「%s」变为「%s」—— 请据新目标调整探索方向(原方向若已不适用请停派)。", ev.OldGoal, ev.NewGoal))
case "finding":
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
case TriggerWorkerMessage:
b.WriteString(fmt.Sprintf("\n- 用户向意图 #%d(%s)的 Worker 下发了新的人工方向:%s —— 该方向已优先交给同一 Worker;后续规划必须以它替代或补充原意图描述,避免生成冲突、回退或重复方向。",
ev.IntentID, intentSummary(ts, ev.IntentID), truncOutput(ev.Detail, 2000)))
case "cancelled":
b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已停止(不再执行),其原因已作为事实挂在该意图上;请据此重新规划。", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
default: // "done"
@@ -257,7 +250,6 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
决策流程(每次唤醒):
1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。
- open_intents / running_intents / recent_done_intents——"哪些方向已经有意图在覆盖/已尝试"。每个意图还带 **parents(上游:它派生自哪些事实/意图)和 yields(下游:它产生了哪些事实/发现)**——这就是探索图的**血缘关系**,据此理解"哪些事实来自哪个方向、能否综合成新方向"。recent_facts 里每个事实带 **from_intent**(由哪个意图产生)。
- worker_directions——用户在 Worker 对话里对每个意图最近一次下发的人工方向(持久化、重启后仍存在)。它比该意图最初的 summary 更新;判断在跑方向、补意图和使用 steer/kill 时必须以它为准,禁止重新派发与人工方向冲突的工作。
- sites_without_endpoints / findings——"哪些方向【可能】需要探索"。
- 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。
2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。
-40
View File
@@ -48,38 +48,6 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[
return out
}
const workerDirectionOverviewLimit = 300
const workerDirectionMessageRunes = 1200
// compactWorkerDirections keeps the latest durable human direction for each
// intent small enough for every Planner/Main Agent turn. The full message stays
// available in the Worker activity trace when an unusually long direction is
// truncated here.
func compactWorkerDirections(items []db.WorkerDirection) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
message := item.Message
truncated := false
if runes := []rune(message); len(runes) > workerDirectionMessageRunes {
message = string(runes[:workerDirectionMessageRunes]) + "…"
truncated = true
}
row := map[string]any{
"activity_id": item.ActivityID,
"intent_id": item.IntentID,
"intent_state": item.IntentState,
"intent_summary": item.IntentSummary,
"message": message,
"updated_at": item.CreatedAt,
}
if truncated {
row["truncated"] = true
}
out = append(out, row)
}
return out
}
// ToolSet exposes the PG-backed dual graph (asset + exploration) to an LLM agent.
// One ToolSet is created per planner/worker run; per-run signals live here.
type ToolSet struct {
@@ -392,14 +360,6 @@ func (t *ToolSet) graphOverview() actool.CoreTool {
// an empty context and always needs this first).
func (t *ToolSet) graphOverviewData() map[string]any {
out := map[string]any{}
// worker_directions is the persistent cross-agent steering channel. It has
// one latest accepted human message per local intent, so Planner and Main
// Agent do not have to infer a changed direction from the original summary.
if directions, err := t.ts.LatestWorkerDirections(workerDirectionOverviewLimit); err == nil {
out["worker_directions"] = compactWorkerDirections(directions)
} else {
out["worker_directions"] = []map[string]any{}
}
// goals summary folded in so the planner needn't call list_goals each round.
goals, _ := t.ts.ListByKind(db.KindGoal, 100)
gsum := make([]map[string]any, 0, len(goals))
-155
View File
@@ -1,155 +0,0 @@
package agent
import (
"context"
"iter"
"strings"
"sync"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
)
type directionCaptureProvider struct {
mu sync.Mutex
requests []llm.CompletionRequest
}
func (p *directionCaptureProvider) Stream(_ context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
p.mu.Lock()
p.requests = append(p.requests, req)
p.mu.Unlock()
return func(yield func(llm.StreamEvent, error) bool) {
if !yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "同步完成"}, nil) {
return
}
if !yield(llm.StreamEvent{Type: llm.SEMessageDelta, StopReason: "end_turn"}, nil) {
return
}
yield(llm.StreamEvent{Type: llm.SEMessageStop}, nil)
}
}
func (p *directionCaptureProvider) Complete(_ context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
p.mu.Lock()
p.requests = append(p.requests, req)
p.mu.Unlock()
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("同步完成")}}, "end_turn", llm.Usage{}, nil
}
func (p *directionCaptureProvider) lastRequest(t *testing.T) llm.CompletionRequest {
t.Helper()
p.mu.Lock()
defer p.mu.Unlock()
if len(p.requests) == 0 {
t.Fatal("provider received no completion request")
}
return p.requests[len(p.requests)-1]
}
func lastUserText(t *testing.T, req llm.CompletionRequest) string {
t.Helper()
for i := len(req.Messages) - 1; i >= 0; i-- {
if req.Messages[i].Role == llm.RoleUser {
return req.Messages[i].Text()
}
}
t.Fatal("completion request has no user message")
return ""
}
type directionAgentFixture struct {
d *db.DB
task *db.Task
store *db.ExplorationStore
intentID int64
activity db.Activity
}
func newDirectionAgentFixture(t *testing.T) *directionAgentFixture {
t.Helper()
d := testDB(t)
task, err := d.CreateTask("agent direction sync", "synchronize worker steering", nil, 0, 0)
if err != nil {
d.Close()
t.Fatal(err)
}
t.Cleanup(func() {
_ = d.DeleteTask(task.ID)
_ = d.Close()
})
store := d.Exploration(task.ExplorationID)
intentID, err := store.AddIntent(map[string]any{"summary": "原始接口枚举方向"}, 1, nil, "planner")
if err != nil {
t.Fatal(err)
}
if claimed, err := store.ClaimIntent(intentID, "work#direction"); err != nil || !claimed {
t.Fatalf("claim direction fixture: claimed=%v err=%v", claimed, err)
}
const requestID = "agent-direction-sync-1"
const direction = "停止接口枚举,立即聚焦登录后的水平越权"
if _, err := store.ReserveIntentIntervention(intentID, requestID, direction); err != nil {
t.Fatal(err)
}
if changed, err := store.CompareAndSetIntentState(intentID, "running", "paused"); err != nil || !changed {
t.Fatalf("pause direction fixture: changed=%v err=%v", changed, err)
}
activity, duplicate, err := store.AcceptIntentIntervention(intentID, requestID, direction)
if err != nil || duplicate {
t.Fatalf("accept direction fixture: activity=%+v duplicate=%v err=%v", activity, duplicate, err)
}
return &directionAgentFixture{d: d, task: task, store: store, intentID: intentID, activity: activity}
}
func TestGraphOverviewIncludesLatestWorkerDirection(t *testing.T) {
f := newDirectionAgentFixture(t)
overview := NewToolSet(f.store, "planner").graphOverviewData()
directions, ok := overview["worker_directions"].([]map[string]any)
if !ok || len(directions) != 1 {
t.Fatalf("worker_directions=%T %+v", overview["worker_directions"], overview["worker_directions"])
}
if directions[0]["activity_id"] != f.activity.ID || directions[0]["intent_id"] != f.intentID ||
directions[0]["intent_summary"] != "原始接口枚举方向" ||
directions[0]["message"] != "停止接口枚举,立即聚焦登录后的水平越权" {
t.Fatalf("worker direction overview=%+v", directions[0])
}
}
func TestPlannerRequestIncludesWorkerMessageTriggerAndPersistedDirection(t *testing.T) {
f := newDirectionAgentFixture(t)
provider := &directionCaptureProvider{}
planner := NewPlanner(provider, "test-model", t.TempDir(), nil, 0, 1)
triggerDetail := "本轮只验证登录后的对象级授权"
_, _, err := planner.Plan(context.Background(), f.task.ID, nil, f.store, f.task.Goal, []TriggerEvent{{
Kind: TriggerWorkerMessage, ActivityID: f.activity.ID, IntentID: f.intentID, Detail: triggerDetail,
}}, nil)
if err != nil {
t.Fatal(err)
}
input := lastUserText(t, provider.lastRequest(t))
for _, want := range []string{"【本次触发本轮的实际变动】", triggerDetail, `"worker_directions"`, "停止接口枚举,立即聚焦登录后的水平越权"} {
if !strings.Contains(input, want) {
t.Fatalf("Planner input missing %q:\n%s", want, input)
}
}
}
func TestMainAgentNextTurnIncludesPersistedWorkerDirection(t *testing.T) {
f := newDirectionAgentFixture(t)
provider := &directionCaptureProvider{}
mainAgent := NewMainAgent(provider, "test-model", t.TempDir(), nil, 0, 1)
const userMessage = "汇报当前执行方向"
if _, err := mainAgent.Chat(context.Background(), f.task.ID, nil, f.store, f.task.Goal, userMessage, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
input := lastUserText(t, provider.lastRequest(t))
for _, want := range []string{"【系统同步的 Worker 最新人工方向】", "停止接口枚举,立即聚焦登录后的水平越权", "【用户本轮消息】", userMessage} {
if !strings.Contains(input, want) {
t.Fatalf("Main Agent input missing %q:\n%s", want, input)
}
}
if strings.Index(input, "【系统同步的 Worker 最新人工方向】") > strings.Index(input, "【用户本轮消息】") {
t.Fatalf("Main Agent context is not separated before current user message:\n%s", input)
}
}