Files
ARTEX/agent/tools_digest.go
T
Autumn-27 5963281582 fix(agent): 探索图工具在无任务上下文时拒绝而非 nil 崩溃
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 契约
2026-09-14 13:56:56 -04:00

300 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
})
}