buildDomainReg 构造的 server 级 ToolSet 恒持 nil ExplorationStore(taskID=0), tools 表可把其中任一工具绑给不在任务内运行的 agent(auto/pentest/reporter/ 自定义/旁路提问)。node_detail 等探索图工具直接解引用 t.ts,被这类 agent 调用即 nil deref;panic 发生在 harness 自己的 goroutine 上,server 侧任何 recover 都 够不到,整进程退出,重启后调度器又拉起同一 agent 再崩,形成崩溃循环。 - 新增 readExpTool/writeExpTool + needExploration:t.ts==nil 时返回可读的工具 错误(提示改用带 task_id 的跨任务读取工具),把 17 个探索图工具切过去 - anchorOwner 补判空 - AugmentTools 出口给每个工具套 guardPanic:handler panic 降级成工具错误并打 stack,覆盖域工具/SDK/MCP/skill,进程不再陪葬 - 注明 buildDomainReg 的 nil-store 契约
300 lines
9.9 KiB
Go
300 lines
9.9 KiB
Go
package agent
|
||
|
||
// cold-digest §6: graph_overview folding + the restore tools.
|
||
//
|
||
// coldDigestOverview — builds the folded cold region for graph_overview:
|
||
// cold_digests (flat {id, body, member_count}) and cold_index (§6.2, the
|
||
// per-asset directory that collapses independent directions).
|
||
// expand_digest(id) — level-1 restore: a digest's member compact list.
|
||
// expand_index(asset_id) — level-0 restore: the digests under one asset.
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
actool "github.com/Autumn-27/norma/tool"
|
||
)
|
||
|
||
// coldDigestOverview returns the folded cold region for graph_overview: the flat
|
||
// digest bodies and the asset-grouped index (§6.1/§6.2). covered is unused here
|
||
// (kept for symmetry with the caller's coverage computation).
|
||
func (t *ToolSet) coldDigestOverview() (digests []map[string]any, index []map[string]any) {
|
||
ads, err := t.ts.ActiveDigests()
|
||
if err != nil || len(ads) == 0 {
|
||
return nil, nil
|
||
}
|
||
memByDigest := map[int64][]int64{}
|
||
var allMembers []int64
|
||
for _, d := range ads {
|
||
ms, _ := t.ts.DigestMembers(d.ID)
|
||
memByDigest[d.ID] = ms
|
||
allMembers = append(allMembers, ms...)
|
||
}
|
||
assetsByNode, _ := t.ts.NodeAssets(allMembers)
|
||
|
||
digests = make([]map[string]any, 0, len(ads))
|
||
for _, d := range ads {
|
||
var p struct {
|
||
Body string `json:"body"`
|
||
}
|
||
_ = json.Unmarshal(d.Payload, &p)
|
||
digests = append(digests, map[string]any{
|
||
"id": d.ID,
|
||
"body": p.Body,
|
||
"member_count": len(memByDigest[d.ID]),
|
||
})
|
||
}
|
||
|
||
// index (§6.2): asset → digests. Each digest lands in EXACTLY ONE bucket — its
|
||
// representative asset = the asset anchored on the most of its members (mode;
|
||
// tie-break lowest id). This is what makes the index converge: bucketing a digest
|
||
// into every asset its members touch would duplicate it across dozens of buckets
|
||
// and blow up the top-level count instead of shrinking it (asset_ids are fine-
|
||
// grained — a real task has ~144 of them). A digest whose members anchor no asset
|
||
// falls into the 0 bucket ("(未锚定资产)").
|
||
byAsset := map[int64]map[int64]bool{} // asset id → set of digest ids
|
||
assetSet := map[int64]bool{}
|
||
for dID, ms := range memByDigest {
|
||
counts := map[int64]int{}
|
||
for _, m := range ms {
|
||
for _, a := range assetsByNode[m] {
|
||
counts[a]++
|
||
}
|
||
}
|
||
rep, best := int64(0), 0
|
||
for a, c := range counts {
|
||
if c > best || (c == best && (rep == 0 || a < rep)) {
|
||
rep, best = a, c
|
||
}
|
||
}
|
||
if byAsset[rep] == nil {
|
||
byAsset[rep] = map[int64]bool{}
|
||
}
|
||
byAsset[rep][dID] = true
|
||
if rep != 0 {
|
||
assetSet[rep] = true
|
||
}
|
||
}
|
||
labels := map[int64]string{}
|
||
if t.as != nil && len(assetSet) > 0 {
|
||
ids := make([]int64, 0, len(assetSet))
|
||
for a := range assetSet {
|
||
ids = append(ids, a)
|
||
}
|
||
if assets, err := t.as.GetByIDs(ids); err == nil {
|
||
for _, a := range assets {
|
||
if v := assetValue(a); v != "" {
|
||
labels[a.ID] = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
index = make([]map[string]any, 0, len(byAsset))
|
||
for a, dset := range byAsset {
|
||
dids := make([]int64, 0, len(dset))
|
||
for d := range dset {
|
||
dids = append(dids, d)
|
||
}
|
||
sort.Slice(dids, func(i, j int) bool { return dids[i] < dids[j] })
|
||
entry := map[string]any{"digest_ids": dids}
|
||
if a == 0 {
|
||
entry["asset"] = "(未锚定资产)"
|
||
} else {
|
||
entry["asset_id"] = a
|
||
if l := labels[a]; l != "" {
|
||
entry["asset"] = l
|
||
} else {
|
||
entry["asset"] = fmt.Sprintf("#%d", a)
|
||
}
|
||
}
|
||
index = append(index, entry)
|
||
}
|
||
sort.Slice(index, func(i, j int) bool {
|
||
return fmt.Sprint(index[i]["asset"]) < fmt.Sprint(index[j]["asset"])
|
||
})
|
||
return digests, index
|
||
}
|
||
|
||
// digestMemberEntry builds the compact per-member view expand_digest returns —
|
||
// same shape as recent_facts / recent_done_intents (§6.1 middle level). store is
|
||
// the digest's OWNING store (the current task, or a read-only source task §2).
|
||
func (t *ToolSet) digestMemberEntry(store *db.ExplorationStore, id int64) map[string]any {
|
||
n, _ := store.GetNode(id)
|
||
if n == nil {
|
||
return map[string]any{"id": id, "missing": true}
|
||
}
|
||
m := compactNode(n)
|
||
m["state"] = n.State
|
||
var p map[string]any
|
||
if json.Unmarshal(n.Payload, &p) == nil {
|
||
if c, ok := p["confidence"].(string); ok && c != "" {
|
||
m["confidence"] = c
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// activeDigestBodies returns [{id, body, member_count}] for a store's active
|
||
// digests — the folded cold region as flat bodies (§6.1). Shared by the current
|
||
// task overview and the read-only related-task overview (§2 cross-task reuse).
|
||
func activeDigestBodies(store *db.ExplorationStore) []map[string]any {
|
||
ads, err := store.ActiveDigests()
|
||
if err != nil || len(ads) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]map[string]any, 0, len(ads))
|
||
for _, d := range ads {
|
||
var p struct {
|
||
Body string `json:"body"`
|
||
}
|
||
_ = json.Unmarshal(d.Payload, &p)
|
||
ms, _ := store.DigestMembers(d.ID)
|
||
out = append(out, map[string]any{"id": d.ID, "body": p.Body, "member_count": len(ms)})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// hiddenMembersFor returns a predicate telling whether a member is hidden (folded
|
||
// into an active digest AND still cold) in the given store — so a source task's
|
||
// overview folds exactly the way that task folds itself (§2 cross-task: "当前任务
|
||
// 什么展示逻辑,关联任务就什么逻辑"). A revived (now hot) covered member is NOT
|
||
// hidden (§6 render-time revival check). Returns a never-hidden predicate when the
|
||
// store has no digests.
|
||
func hiddenMembersFor(store *db.ExplorationStore) func(int64) bool {
|
||
covered, err := store.CoveredMembers()
|
||
if err != nil || len(covered) == 0 {
|
||
return func(int64) bool { return false }
|
||
}
|
||
var hot map[int64]bool
|
||
if cg, _, err := loadColdGraph(store); err == nil {
|
||
hot = cg.hotSet()
|
||
}
|
||
return func(id int64) bool { _, c := covered[id]; return c && !hot[id] }
|
||
}
|
||
|
||
// resolveDigest finds a digest node by id in the current task, else in a direct
|
||
// source task (read-only, §2). Returns the node, its owning store, and the source
|
||
// task id (0 = current task).
|
||
func (t *ToolSet) resolveDigest(id int64) (*db.Node, *db.ExplorationStore, int64) {
|
||
if n, _ := t.ts.GetNode(id); n != nil && n.Kind == db.KindDigest {
|
||
return n, t.ts, 0
|
||
}
|
||
srcs, _ := t.ts.DirectSourceStores()
|
||
for _, s := range srcs {
|
||
if n, _ := s.Store.GetNode(id); n != nil && n.Kind == db.KindDigest {
|
||
return n, s.Store, s.Task.TaskID
|
||
}
|
||
}
|
||
return nil, nil, 0
|
||
}
|
||
|
||
// expandDigest returns a digest's covered members as a compact list (§6.1). It is
|
||
// a distinct tool from node_detail because it returns a LIST of members, not one
|
||
// node's full detail.
|
||
func (t *ToolSet) expandDigest() actool.CoreTool {
|
||
return t.writeExpTool("expand_digest",
|
||
"展开一个 cold digest:返回它折叠的成员紧凑列表(id/summary/state/confidence),与概览 recent_facts/recent_done_intents 同形状。要某条完整细节/证据用 node_detail(member_id)。",
|
||
map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"id": map[string]any{"type": "integer", "description": "digest 节点 id(来自概览 cold_digests / cold_index / covered_members)"},
|
||
},
|
||
"required": []any{"id"},
|
||
},
|
||
func(ctx context.Context, raw json.RawMessage) (actool.Result, error) {
|
||
var in struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
_ = json.Unmarshal(raw, &in)
|
||
n, store, srcTaskID := t.resolveDigest(in.ID)
|
||
if n == nil {
|
||
return jsonResult(map[string]any{"error": fmt.Sprintf("#%d 不是 digest 节点(本任务或直接关联任务里都没找到)", in.ID)})
|
||
}
|
||
var p struct {
|
||
Body string `json:"body"`
|
||
}
|
||
_ = json.Unmarshal(n.Payload, &p)
|
||
members, _ := store.DigestMembers(in.ID)
|
||
list := make([]map[string]any, 0, len(members))
|
||
for _, m := range members {
|
||
entry := t.digestMemberEntry(store, m)
|
||
if srcTaskID > 0 { // 关联任务的成员:只读,带继承标记(§2)
|
||
entry["inherited"] = true
|
||
entry["source_task_id"] = srcTaskID
|
||
}
|
||
list = append(list, entry)
|
||
}
|
||
out := map[string]any{
|
||
"id": in.ID,
|
||
"state": n.State, // active / superseded
|
||
"body": p.Body,
|
||
"members": list,
|
||
}
|
||
if srcTaskID > 0 {
|
||
out["inherited"] = true
|
||
out["source_task_id"] = srcTaskID
|
||
}
|
||
return jsonResult(out)
|
||
})
|
||
}
|
||
|
||
// expandIndex returns the active digests under one asset (§6.2 level-0), each
|
||
// with its body + member count — so the planner can drill an asset directory down
|
||
// to its directions without reading every digest globally.
|
||
func (t *ToolSet) expandIndex() actool.CoreTool {
|
||
return t.writeExpTool("expand_index",
|
||
"展开冷区某个资产条目(来自概览 cold_index):返回该资产名下的 cold digest 列表(id/body/member_count)。再往下看某个 digest 的成员用 expand_digest(id)。",
|
||
map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"asset_id": map[string]any{"type": "integer", "description": "资产 id(来自概览 cold_index 的 asset_id;传 0 或省略取未锚定资产桶)"},
|
||
},
|
||
},
|
||
func(ctx context.Context, raw json.RawMessage) (actool.Result, error) {
|
||
var in struct {
|
||
AssetID int64 `json:"asset_id"`
|
||
}
|
||
_ = json.Unmarshal(raw, &in)
|
||
ads, _ := t.ts.ActiveDigests()
|
||
out := make([]map[string]any, 0)
|
||
for _, d := range ads {
|
||
members, _ := t.ts.DigestMembers(d.ID)
|
||
assetsByNode, _ := t.ts.NodeAssets(members)
|
||
match := in.AssetID == 0
|
||
for _, m := range members {
|
||
for _, a := range assetsByNode[m] {
|
||
if a == in.AssetID {
|
||
match = true
|
||
}
|
||
}
|
||
if match {
|
||
break
|
||
}
|
||
}
|
||
// asset_id==0 means "unanchored bucket": include only digests with no asset.
|
||
if in.AssetID == 0 {
|
||
anchored := false
|
||
for _, m := range members {
|
||
if len(assetsByNode[m]) > 0 {
|
||
anchored = true
|
||
break
|
||
}
|
||
}
|
||
match = !anchored
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
var p struct {
|
||
Body string `json:"body"`
|
||
}
|
||
_ = json.Unmarshal(d.Payload, &p)
|
||
out = append(out, map[string]any{"id": d.ID, "body": p.Body, "member_count": len(members)})
|
||
}
|
||
return jsonResult(map[string]any{"asset_id": in.AssetID, "digests": out})
|
||
})
|
||
}
|