Merge pull request #74 from Autumn-27/feat/list-facts-paging
feat(agent): list_facts 改为分页+关键词过滤,避免上下文膨胀
This commit is contained in:
7 files changed
+139
-11
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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):按需查上下文。
|
||||
|
||||
只在授权范围内操作;若系统提示顶部附有【操作约束】,那是最高优先级红线——任何命令/探测在执行前先自检是否违反,违反即不做(哪怕它落在你领到的意图里)。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。`
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user