Merge pull request #74 from Autumn-27/feat/list-facts-paging

feat(agent): list_facts 改为分页+关键词过滤,避免上下文膨胀
This commit is contained in:
Autumn authored and GitHub committed 2026-08-28 00:17:06 +08:00
commit 88bca9783f
7 files changed
+139 -11

No files matched your search

+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。
+51 -6
View File
@@ -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"),
+1 -1
View File
@@ -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):按需查上下文。
只在授权范围内操作;若系统提示顶部附有【操作约束】,那是最高优先级红线——任何命令/探测在执行前先自检是否违反,违反即不做(哪怕它落在你领到的意图里)。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。`
+34
View File
@@ -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))
+47
View File
@@ -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.
+4 -2
View File
@@ -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