From 10f6a4fbc7bde0edec0934594fc116b270ba54b8 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:15:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20list=5Ffacts=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=88=86=E9=A1=B5+=E5=85=B3=E9=94=AE=E8=AF=8D?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=EF=BC=8C=E9=81=BF=E5=85=8D=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E8=86=A8=E8=83=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fact 多时旧的 list_facts 一次返回每任务至多 500 条且 summary 不截断, 一次调用就可能撑爆上下文。改为分页(默认 20,最新在前)+ q 关键词过滤, 返回 {facts, total, has_more, next_before},summary 超长按 rune 截断。 - db: listByKindPageFiltered/countByKindFiltered(payload->>'summary' ILIKE) + ListByKindPageWithSources 跨本任务/继承任务合并分页 - agent: list_facts 加 limit/before/q 参数,compactFact 截断 summary; worker/planner 提示词同步说明分页语义 - server: 旧库一次性迁移把 list_facts 新 schema 刷进 tools 目录表 (SeedTool 首插入only,否则工具管理页显示「无参数」);flag bump 到 v7 --- agent/blackboard_inheritance_test.go | 2 +- agent/planner.go | 2 +- agent/tools.go | 57 +++++++++++++++++++++++++--- agent/worker.go | 2 +- db/exploration.go | 34 +++++++++++++++++ db/exploration_sources.go | 47 +++++++++++++++++++++++ server/orchestration.go | 6 ++- 7 files changed, 139 insertions(+), 11 deletions(-) diff --git a/agent/blackboard_inheritance_test.go b/agent/blackboard_inheritance_test.go index fcbcb6f..4611bc1 100644 --- a/agent/blackboard_inheritance_test.go +++ b/agent/blackboard_inheritance_test.go @@ -223,7 +223,7 @@ func TestBlackboardToolsReadDirectSources(t *testing.T) { t.Fatalf("inherited host context missing: %#v", coverage) } - facts := callReadJSON(t, tools.listFacts(), `{}`).([]any) + facts := callReadJSON(t, tools.listFacts(), `{}`).(map[string]any)["facts"].([]any) seenSource, seenGrand := false, false for _, raw := range facts { item := raw.(map[string]any) diff --git a/agent/planner.go b/agent/planner.go index 88f2013..95802f8 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -248,7 +248,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者" 反过来:若确有【未被任何意图覆盖、且不依赖在跑 work】的新方向,或【目标尚未达成且范围内仍有未测面】,就【不要】因为"0 意图常见"而收手——该派就派。别把 0 意图当成偷懒的默认。 决策流程(每次唤醒): -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 列全部事实、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。 +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**(由哪个意图产生)。 - sites_without_endpoints / findings——"哪些方向【可能】需要探索"。 - 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。 diff --git a/agent/tools.go b/agent/tools.go index f8493ab..ebed6d8 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -898,19 +898,64 @@ func (t *ToolSet) listFindings() actool.CoreTool { }) } +// factsPageSize is the default page size for list_facts. Facts pile up on long +// tasks; returning all of them at once (the old behaviour) could blow up the +// context, so default to the newest page and let the agent page/filter for more. +const factsPageSize = 20 + func (t *ToolSet) listFacts() actool.CoreTool { - return readTool("list_facts", "列本任务及直接关联任务的【探索事实/结论】(紧凑:id+摘要+状态)。关联任务条目带 source_task_id/inherited=true 且只读。详情用 node_detail(id),漏洞看 list_findings。", - obj(map[string]any{}), - func(context.Context, json.RawMessage) (actool.Result, error) { - f, _ := t.ts.ListByKindWithSources(db.KindFact, 500) + return readTool("list_facts", "分页列本任务及直接关联任务的【探索事实/结论】,最新在前(紧凑:id+摘要+状态,摘要过长会截断,全文用 node_detail(id))。参数均可选:limit(默认 20,上限 100)、before(游标,传上一页返回的 next_before 取更旧的一页;省略/0=最新一页)、q(按摘要关键词过滤)。返回 {facts, total, has_more, next_before}:total 是过滤后的总数,has_more=true 时用 next_before 继续翻页。关联任务条目带 source_task_id/inherited=true 且只读。漏洞看 list_findings。", + obj(map[string]any{ + "limit": intp("返回条数,默认 20,上限 100"), + "before": intp("分页游标:只返回 id 小于该值的更旧事实;省略或 0 = 最新一页"), + "q": str("按事实摘要关键词过滤(不区分大小写);省略 = 不过滤"), + }), + func(_ context.Context, in json.RawMessage) (actool.Result, error) { + var a struct { + Limit int `json:"limit"` + Before int64 `json:"before"` + Q string `json:"q"` + } + _ = json.Unmarshal(in, &a) + limit := a.Limit + if limit <= 0 { + limit = factsPageSize + } + if limit > 100 { + limit = 100 + } + f, hasMore, total, err := t.ts.ListByKindPageWithSources(db.KindFact, a.Before, limit, strings.TrimSpace(a.Q)) + if err != nil { + return actool.Result{}, err + } out := make([]map[string]any, 0, len(f)) for _, n := range f { - out = append(out, compactNode(n)) + out = append(out, compactFact(n)) } - return jsonResult(out) + res := map[string]any{"facts": out, "total": total, "has_more": hasMore} + if hasMore && len(f) > 0 { + res["next_before"] = f[len(f)-1].ID // 传回它取下一页(更旧的) + } + return jsonResult(res) }) } +// factSummaryMax caps a fact summary in list_facts output. Facts carry one-line +// conclusions, but nothing enforces brevity; a runaway summary must not bloat a +// whole page. Full text stays available via node_detail(id). +const factSummaryMax = 160 + +// compactFact is compactNode with the summary rune-capped for list_facts, so a +// page of facts stays bounded regardless of how long any single summary grew. +func compactFact(n *db.Node) map[string]any { + m := compactNode(n) + if s, ok := m["summary"].(string); ok && len([]rune(s)) > factSummaryMax { + m["summary"] = string([]rune(s)[:factSummaryMax]) + "…" + m["summary_truncated"] = true + } + return m +} + func (t *ToolSet) nodeDetail() actool.CoreTool { return readTool("node_detail", "按 id 取本任务或直接关联任务的【探索图节点】完整内容。继承节点带 source_task_id/inherited=true 且只读。仅限 list_facts/list_findings/graph_overview 返回的探索节点 id;资产请用 list_assets/asset_neighbors。", obj(map[string]any{"id": idp("探索图节点 id(非资产 id)")}, "id"), diff --git a/agent/worker.go b/agent/worker.go index c75c97b..2c77722 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -171,7 +171,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo - insert_assets:登记新资产(资产图)。**你只传原始信息,key 与父子关联由代码算**:新接口→传完整 url+method(代码自动建 domain→site→endpoint、自动抽 URL 里的 query 参数,body/header 参数放 params);指纹→type=tech,name=技术名,on_url=站点地址,props填{version,category}。多个资产放 assets 数组一次批量登记。属性写在资产自己的 props 上,探索结论不要写这里。 - record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论的证据门槛**(不可注入/端口关闭/无登录入口等【可能让规划者放弃一整条方向】的结论):下结论前先确认你已【穷尽这条意图内的合理手段】(换编码/换参数/换路径/换方法);手段没走完、或证据只是"看起来像/大概率如此",一律标 confidence=inferred。**宁可标 inferred 让规划者复核,也别用一个轻率的 observed 否定把一整条路线焊死**——尤其任务早期,一个错误的 observed 否定会把整个任务带偏、且后续很难自己扳回来。 - report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。 -- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。 +- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实,分页最新在前;默认 20 条,可传 q 关键词过滤、before 翻页) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。 只在授权范围内操作;若系统提示顶部附有【操作约束】,那是最高优先级红线——任何命令/探测在执行前先自检是否违反,违反即不做(哪怕它落在你领到的意图里)。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。` diff --git a/db/exploration.go b/db/exploration.go index 3a8c563..2ede3b5 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -672,6 +672,40 @@ ORDER BY id DESC LIMIT $4`, s.expID, kind, before, limit+1) return nodes, hasMore, nil } +// listByKindPageFiltered is ListByKindPage with an optional summary keyword +// filter (payload->>'summary' ILIKE %q%). Fetches one extra row so the caller can +// probe hasMore. Empty q = no filter. Newest-first by id. +func (s *ExplorationStore) listByKindPageFiltered(kind string, before int64, limit int, q string) ([]*Node, error) { + where := `exploration_id=$1 AND kind=$2 AND ($3 <= 0 OR id < $3)` + args := []any{s.expID, kind, before} + if q != "" { + args = append(args, "%"+q+"%") + where += fmt.Sprintf(` AND payload->>'summary' ILIKE $%d`, len(args)) + } + args = append(args, limit+1) + rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes +WHERE `+where+` ORDER BY id DESC LIMIT $`+fmt.Sprintf("%d", len(args)), args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanNodes(rows) +} + +// countByKindFiltered counts nodes of a kind under the same summary filter, for a +// page's total. Empty q = count all of that kind. +func (s *ExplorationStore) countByKindFiltered(kind, q string) (int, error) { + where := `exploration_id=$1 AND kind=$2` + args := []any{s.expID, kind} + if q != "" { + args = append(args, "%"+q+"%") + where += fmt.Sprintf(` AND payload->>'summary' ILIKE $%d`, len(args)) + } + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM exploration_nodes WHERE `+where, args...).Scan(&n) + return n, err +} + // GetNode returns one node of this exploration by id (nil, nil if not found). func (s *ExplorationStore) GetNode(id int64) (*Node, error) { n, err := scanNode(s.db.QueryRow(`SELECT `+nodeCols+` FROM exploration_nodes WHERE id=$1 AND exploration_id=$2`, id, s.expID)) diff --git a/db/exploration_sources.go b/db/exploration_sources.go index 3ccbb96..c12d5ba 100644 --- a/db/exploration_sources.go +++ b/db/exploration_sources.go @@ -108,6 +108,53 @@ func (s *ExplorationStore) ListByKindWithSources(kind string, limit int) ([]*Nod return out, nil } +// ListByKindPageWithSources is the paginated, keyword-filterable sibling of +// ListByKindWithSources: it returns one newest-first page (id < before, before<=0 +// = newest) spanning this exploration and its direct sources, plus hasMore and the +// filtered total across all of them. Node ids are globally unique, so merging each +// store's own page and re-sorting by id DESC yields the true global page; fetching +// limit+1 per store guarantees the merged top-`limit` is complete. +func (s *ExplorationStore) ListByKindPageWithSources(kind string, before int64, limit int, q string) (nodes []*Node, hasMore bool, total int, err error) { + if limit <= 0 { + limit = 20 + } + own, err := s.listByKindPageFiltered(kind, before, limit, q) + if err != nil { + return nil, false, 0, err + } + merged := own + total, err = s.countByKindFiltered(kind, q) + if err != nil { + return nil, false, 0, err + } + + sources, err := s.DirectSourceStores() + if err != nil { + return nil, false, 0, err + } + for _, source := range sources { + page, err := source.Store.listByKindPageFiltered(kind, before, limit, q) + if err != nil { + return nil, false, 0, err + } + for _, n := range page { + merged = append(merged, markInheritedNode(n, source.Task.TaskID)) + } + cnt, err := source.Store.countByKindFiltered(kind, q) + if err != nil { + return nil, false, 0, err + } + total += cnt + } + + sort.Slice(merged, func(i, j int) bool { return merged[i].ID > merged[j].ID }) + hasMore = len(merged) > limit + if hasMore { + merged = merged[:limit] + } + return merged, hasMore, total, nil +} + // GetNodeWithSources reads a node only when it belongs to this exploration or // one of its direct sources. Inherited nodes are tagged so tool callers can keep // them read-only and show their provenance. diff --git a/server/orchestration.go b/server/orchestration.go index 287eaa0..08f0167 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -466,7 +466,7 @@ func (s *Server) seedOrchestrationTools() { // reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag. // Bump the flag whenever these tools' schemas/descriptions change in code. func (s *Server) refreshBuiltinToolSchemas() { - const flag = "tool_schema_refresh_v6_insert_assets_related" + const flag = "tool_schema_refresh_v7_list_facts_paging" if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { return } @@ -482,7 +482,9 @@ func (s *Server) refreshBuiltinToolSchemas() { // “结束空轮”的手段、刚开跑就误判整个任务完成。 // - insert_assets:新增 related 入参(标记资产是否与当前任务相关、决定是否入覆盖度), // SeedTool 首插入only,旧库已 seed 的 schema 否则收不到这个新参数。 - refreshBuiltin := map[string]bool{"goal_met": true, "insert_assets": true} + // - list_facts:改为分页,新增 limit/before/q 入参;旧库已 seed 的空 schema 否则 + // 在工具管理页显示「无参数」,模型也拿不到这几个参数说明。 + refreshBuiltin := map[string]bool{"goal_met": true, "insert_assets": true, "list_facts": true} for _, sd := range agent.BuiltinToolSeeds() { if !refreshBuiltin[sd.Key] { continue