Files
ARTEX/agent/tools_digest.go
T
Autumn-27 eefd5ee2f2 fix(digest): 多智能体评审修正 — 资产索引收敛 + 60cap 保 hot + 文档同步
对照 cold-digest-spec 的对抗式评审(20 项发现)逐条核对实现,两处真实代码问题已修:

- 资产索引膨胀(评审 §6.2 高危):早前把一个 digest 落进它成员命中的【每个】asset 桶,
  跨资产 digest 会复制进几十个桶、顶层条目不降反升(真实图 asset_ids 约 144 个)。改为
  每个 digest 只归一个【代表资产】桶(成员锚定众数、并列取最小 id;无锚定 → 0 号兜底桶),
  条目数 ≤ digest 数。
- 60 兜底截断误砍活跃上下文(评审 §6.3 高危):hot(live 前沿祖先链 / live 意图下新事实)
  是活跃推理上下文,绝不能截。改为 hot 全保、60-cap 只约束【冷但未折】残余;冷区两侧
  均衡保留。graph_overview 每轮计算 render 期 hot 集(兼用于复活检查)。

其余 16 项评审发现经核对,本实现已由设计选择规避/处理:全量扫描维护(静默变冷/传递翻转
盖章)、签名含锚节点、minor+major 统一走同一 group()(§3.1 在两处都生效、major 不会拆散
同父组)、提交时活跃度复核(不折入复活成员)、content_version 列+状态变更 bump、渲染期索引
(无 KindIndex 陈旧)、cold_digests 与索引并列(body 仍在顶层)、singleton 只进 recent。

文档 §6.2 同步上述解决口径(body 归属、一 digest 一桶、singleton 归 recent)。
2026-09-09 12:04:17 -04:00

235 lines
7.4 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).
func (t *ToolSet) digestMemberEntry(id int64) map[string]any {
n, _ := t.ts.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
}
// 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 writeTool("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, _ := t.ts.GetNode(in.ID)
if n == nil || n.Kind != db.KindDigest {
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, _ := t.ts.DigestMembers(in.ID)
list := make([]map[string]any, 0, len(members))
for _, m := range members {
list = append(list, t.digestMemberEntry(m))
}
return jsonResult(map[string]any{
"id": in.ID,
"state": n.State, // active / superseded
"body": p.Body,
"members": list,
})
})
}
// 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 writeTool("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})
})
}