feat(digest): 探索图冷节点压缩(cold-digest)

把老且长期不活跃的意图/事实折叠成 digest 节点进 graph_overview 展示,
原始节点永久保留、按 id 可完整还原(存储无损、只压呈现、折叠可逆)。

- 数据模型:KindDigest 节点 + RelCovers 边(真相源);新列 content_version、
  cold_since_round(节点级)、round_no(任务级 planner 轮次);均带旧库迁移。
- 纯算法(coldgraph.go):冷热判定(逆向可达 + 任一活分支即热) + R=6 轮防抖 +
  连通分量分组(§3) + §3.1 同父兄弟归组(救"热 hub+扁平死叶") + §5.3 签名。
- 后台压缩(compaction.go):minor 折未覆盖冷块(tiered)、major 回源重压合并碎片;
  签名幂等复用 body;提交时活跃度复核(绝不覆盖已复活节点);60s 冷却 + 互斥。
- 概览折叠(§6):recent_* 剔除已折成员 + 渲染时复活检查;cold_digests + 按资产
  索引 cold_index(§6.2);expand_digest/expand_index 还原;covered_members 反查
  悬空血缘 id;60 兜底截断均衡保 live。
- planner 每轮 OnPlannerRound 维护戳记并触发后台压缩,绝不上热路径。

纯算法单测覆盖 §附/§3.1/§2.3/§5.3;已对 437 节点真实图验证:热 86、冷候选 324、
§3.1 分组 47 块 + 14 孤立冷点(对齐设计文档实测校验)。
This commit is contained in:
Autumn-27 committed 2026-09-09 11:52:15 -04:00
1 parent 801160930b
commit 4f088e519b
13 files changed
+1720 -15

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
package agent
import "testing"
func TestBalancedCap(t *testing.T) {
cases := []struct {
name string
done, facts, cap int
wantDone, wantFacts int
wantTrunc bool
}{
{"under cap", 15, 20, 60, 15, 20, false},
{"exactly cap", 30, 30, 60, 30, 30, false},
{"both large → even split", 86, 40, 60, 30, 30, true},
{"intents dominate, facts small kept whole", 86, 5, 60, 55, 5, true},
{"facts dominate, intents small kept whole", 3, 200, 60, 3, 57, true},
{"both huge → half/half", 500, 500, 60, 30, 30, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotDone, gotFacts, trunc := balancedCap(c.done, c.facts, c.cap)
if gotDone != c.wantDone || gotFacts != c.wantFacts || trunc != c.wantTrunc {
t.Fatalf("balancedCap(%d,%d,%d) = (%d,%d,%v), want (%d,%d,%v)",
c.done, c.facts, c.cap, gotDone, gotFacts, trunc, c.wantDone, c.wantFacts, c.wantTrunc)
}
if trunc && gotDone+gotFacts > c.cap {
t.Fatalf("kept %d+%d exceeds cap %d", gotDone, gotFacts, c.cap)
}
// never starve a non-empty side to zero when it had items
if trunc && c.facts > 0 && gotFacts == 0 {
t.Fatalf("facts starved to 0")
}
})
}
}
+330
View File
@@ -0,0 +1,330 @@
package agent
// cold-digest §2/§3: pure graph algorithms for cold-node compression.
//
// This file is deliberately free of any DB or LLM dependency so the hot/cold
// judgment, connectivity grouping (§3) and the same-parent singleton rescue
// (§3.1) can be unit-tested in isolation. Callers translate db.Node/db.Edge into
// the light cgNode/cgEdge structs and feed the per-node bookkeeping (cold_since
// stamps, content versions) alongside.
//
// Edge direction convention (matches db + agent/tools.go graphOverviewData):
// every edge From→To means From is the parent/upstream and To the child/
// downstream, for ALL relations (yields: intent→fact, derived_from/spawns:
// parent→child). "Downstream" therefore follows From→To.
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"github.com/Autumn-27/artex/db"
)
// cgNode is the minimal node view the cold-graph algorithms need.
type cgNode struct {
ID int64
Kind string
State string
}
// cgEdge is one exploration edge (From = parent/upstream, To = child/downstream).
type cgEdge struct {
From int64
Rel string
To int64
}
// coldParams are the tunable thresholds (cold-digest §7).
type coldParams struct {
R int // debounce: a node must be continuously inactive ≥R planner rounds (§7 R=6)
K int // min block size to fold; K=2 skips only degenerate singletons (§7 K=2)
}
func defaultColdParams() coldParams { return coldParams{R: 6, K: 2} }
// coldGraph is an in-memory adjacency view over real exploration edges. The
// derived view layer (kind=digest nodes, rel=covers edges) is filtered out at
// construction so it can never distort causal reachability or grouping (§2/§3).
type coldGraph struct {
nodes map[int64]cgNode
children map[int64][]int64 // From → [To] (downstream)
parents map[int64][]int64 // To → [From] (upstream)
}
func newColdGraph(nodes []cgNode, edges []cgEdge) *coldGraph {
g := &coldGraph{
nodes: make(map[int64]cgNode, len(nodes)),
children: map[int64][]int64{},
parents: map[int64][]int64{},
}
for _, n := range nodes {
g.nodes[n.ID] = n
}
for _, e := range edges {
if e.Rel == db.RelCovers { // derived view layer, not exploration causality (§2/§3)
continue
}
if _, ok := g.nodes[e.From]; !ok {
continue
}
if _, ok := g.nodes[e.To]; !ok {
continue
}
g.children[e.From] = append(g.children[e.From], e.To)
g.parents[e.To] = append(g.parents[e.To], e.From)
}
return g
}
// isLiveIntent reports whether a node is a not-yet-settled intent — the frontier
// that keeps its ancestors hot. paused counts as live (it may still resume);
// settled = done/blocked/exhausted/stopped.
func isLiveIntent(n cgNode) bool {
if n.Kind != db.KindIntent {
return false
}
switch n.State {
case "open", "running", "paused":
return true
}
return false
}
// foldableKind reports whether a node kind is eligible for folding at all (§2:
// only fact and settled intent; finding/goal/hint/begin/digest never fold).
func foldableKind(k string) bool { return k == db.KindIntent || k == db.KindFact }
// hotSet computes the hot nodes (§2 rule 1+2): a node is hot iff it can reach a
// live intent by going downstream (it is an ancestor of a live intent), OR it is
// a live intent, OR it is a direct child of a live intent (rule 1: an open/
// running intent's freshly produced facts stay hot). Everything else is cold-
// eligible. "Any live branch keeps the whole chain hot" falls out of ancestor
// marking. Iterative (no recursion) to tolerate deep chains and cycles.
func (g *coldGraph) hotSet() map[int64]bool {
hot := map[int64]bool{}
var stack []int64
for _, n := range g.nodes {
if isLiveIntent(n) {
stack = append(stack, n.ID)
}
}
// Walk upstream from every live intent, marking all ancestors hot.
for len(stack) > 0 {
id := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if hot[id] {
continue
}
hot[id] = true
stack = append(stack, g.parents[id]...)
}
// A live intent's direct children (its fresh facts) stay hot (rule 1).
for _, n := range g.nodes {
if isLiveIntent(n) {
for _, c := range g.children[n.ID] {
hot[c] = true
}
}
}
return hot
}
// structuralCold is the set of foldable nodes that are currently not hot — i.e.
// settled + blood-inactive (§2 rules 1+2), before the ≥R debounce is applied.
func (g *coldGraph) structuralCold(hot map[int64]bool) map[int64]bool {
cold := map[int64]bool{}
for id, n := range g.nodes {
if foldableKind(n.Kind) && !hot[id] {
cold[id] = true
}
}
return cold
}
// stampOp is one cold_since_round bookkeeping change (§2.3): Set=true stamps the
// round a node went cold; Set=false clears the stamp (the node revived / turned
// hot again).
type stampOp struct {
ID int64
Set bool
Round int64
}
// computeStampOps derives the cold_since_round updates for this round. It stamps
// a node the round it FIRST goes cold (empty→round_no) and clears the stamp when
// it is no longer cold. It never re-stamps an already-stamped cold node — that is
// what preserves "how long it has been cold" (§2.3: measure the round it turned
// cold, not the round it last turned hot). coldSince maps node id → stamp (nil =
// unstamped / hot).
func computeStampOps(structCold map[int64]bool, coldSince map[int64]*int64, roundNo int64) []stampOp {
var ops []stampOp
seen := map[int64]bool{}
for id := range structCold {
seen[id] = true
if coldSince[id] == nil {
ops = append(ops, stampOp{ID: id, Set: true, Round: roundNo})
}
}
// Clear stamps on nodes that are stamped but no longer cold (revived/hot).
for id, cs := range coldSince {
if cs != nil && !seen[id] {
ops = append(ops, stampOp{ID: id, Set: false})
}
}
sort.Slice(ops, func(i, j int) bool { return ops[i].ID < ops[j].ID })
return ops
}
// eligibleCold narrows structuralCold to nodes that have been continuously cold
// for ≥R rounds (§2.3 / §3②). A node with no stamp, or one that has not yet
// aged R rounds, is held in the hot region a while longer (bias to conservative).
func (g *coldGraph) eligibleCold(structCold map[int64]bool, coldSince map[int64]*int64, roundNo int64, p coldParams) map[int64]bool {
out := map[int64]bool{}
for id := range structCold {
if cs := coldSince[id]; cs != nil && roundNo-*cs >= int64(p.R) {
out[id] = true
}
}
return out
}
// block is a group of cold nodes to fold into one digest, plus the external
// parent nodes that anchor them (§3.1 "父作锚不作成员"): anchors are fed to the
// compressor as context but never become members / never get a covers edge.
type block struct {
Members []int64 // sorted; the nodes this digest covers
Anchors []int64 // sorted; external (non-member) parents, context only
}
// group partitions `set` into foldable blocks (§3 + §3.1). Two passes of
// union-find:
//
// rule ① connect cold nodes joined by a real exploration edge (§3);
// rule ② connect the LEFTOVER singletons that share a common direct parent
// (§3.1 — rescues the "hot hub + flat dead leaves" fan-out), without
// disturbing any already-formed ≥2 block.
//
// Only components of size ≥K survive (§3① skips degenerate singletons).
func (g *coldGraph) group(set map[int64]bool, p coldParams) []block {
uf := newUnionFind(set)
// rule ①: real cold↔cold edges.
for from := range set {
for _, to := range g.children[from] {
if set[to] {
uf.union(from, to)
}
}
}
// rule ②: leftover singletons sharing a common parent.
comps := uf.components()
byParent := map[int64][]int64{}
for _, ids := range comps {
if len(ids) != 1 {
continue // only rescue singletons; never re-shuffle ≥2 blocks
}
s := ids[0]
for _, par := range g.parents[s] {
if g.nodes[par].Kind == db.KindDigest { // anchor must be a real node, not a digest
continue
}
byParent[par] = append(byParent[par], s)
}
}
for _, sibs := range byParent {
if len(sibs) < 2 {
continue // a lone cold child under a parent stays a true singleton (§3①)
}
for i := 1; i < len(sibs); i++ {
uf.union(sibs[0], sibs[i])
}
}
// Emit surviving components as blocks, each with its external-parent anchors.
comps = uf.components()
var blocks []block
for _, ids := range comps {
if len(ids) < p.K {
continue
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
memberSet := make(map[int64]bool, len(ids))
for _, m := range ids {
memberSet[m] = true
}
anchorSet := map[int64]bool{}
for _, m := range ids {
for _, par := range g.parents[m] {
if memberSet[par] {
continue
}
pn, ok := g.nodes[par]
if !ok || pn.Kind == db.KindDigest {
continue
}
anchorSet[par] = true
}
}
anchors := make([]int64, 0, len(anchorSet))
for a := range anchorSet {
anchors = append(anchors, a)
}
sort.Slice(anchors, func(i, j int) bool { return anchors[i] < anchors[j] })
blocks = append(blocks, block{Members: ids, Anchors: anchors})
}
// Deterministic order: by smallest member id.
sort.Slice(blocks, func(i, j int) bool { return blocks[i].Members[0] < blocks[j].Members[0] })
return blocks
}
// blockSignature is the change-detection key (§5.3): a hash over the sorted
// member ids + each member's content_version, plus the anchor ids + versions
// (so an anchor's summary/state change also invalidates the cached body). A
// re-compaction whose block matches an existing active digest's signature
// reuses the stored body and skips the LLM entirely.
func blockSignature(b block, contentVer map[int64]int) string {
h := sha256.New()
for _, m := range b.Members {
fmt.Fprintf(h, "m:%d:%d;", m, contentVer[m])
}
for _, a := range b.Anchors {
fmt.Fprintf(h, "a:%d:%d;", a, contentVer[a])
}
return hex.EncodeToString(h.Sum(nil))
}
// --- union-find ---
type unionFind struct{ parent map[int64]int64 }
func newUnionFind(set map[int64]bool) *unionFind {
uf := &unionFind{parent: make(map[int64]int64, len(set))}
for id := range set {
uf.parent[id] = id
}
return uf
}
func (u *unionFind) find(x int64) int64 {
for u.parent[x] != x {
u.parent[x] = u.parent[u.parent[x]]
x = u.parent[x]
}
return x
}
func (u *unionFind) union(a, b int64) {
ra, rb := u.find(a), u.find(b)
if ra != rb {
u.parent[ra] = rb
}
}
func (u *unionFind) components() map[int64][]int64 {
out := map[int64][]int64{}
for id := range u.parent {
r := u.find(id)
out[r] = append(out[r], id)
}
return out
}
+176
View File
@@ -0,0 +1,176 @@
package agent
import (
"testing"
"github.com/Autumn-27/artex/db"
)
// helper: intent/fact node
func intent(id int64, state string) cgNode { return cgNode{ID: id, Kind: db.KindIntent, State: state} }
func fact(id int64) cgNode { return cgNode{ID: id, Kind: db.KindFact, State: "confirmed"} }
func yields(from, to int64) cgEdge { return cgEdge{From: from, Rel: db.RelYields, To: to} }
func derived(from, to int64) cgEdge { return cgEdge{From: from, Rel: db.RelDerivedFrom, To: to} }
func ptr(v int64) *int64 { return &v }
// §附 快照1: a→b→c, b→d, with d live (running) and c settled/inactive.
// Expected: d/b/a hot (b kept hot by the live b→d branch); c is a cold candidate
// but an isolated singleton → not folded.
func TestHotCold_AnyLiveBranchKeepsChainHot(t *testing.T) {
// a(intent) → b(intent) → c(fact); b → d(intent, running)
nodes := []cgNode{intent(1, "done"), intent(2, "done"), fact(3), intent(4, "running")}
edges := []cgEdge{derived(1, 2), yields(2, 3), derived(2, 4)}
g := newColdGraph(nodes, edges)
hot := g.hotSet()
for _, id := range []int64{1, 2, 4} {
if !hot[id] {
t.Fatalf("node %d should be hot (ancestor of / is live intent 4)", id)
}
}
if hot[3] {
t.Fatalf("node 3 (dead leaf c) should be cold")
}
structCold := g.structuralCold(hot)
if !structCold[3] || len(structCold) != 1 {
t.Fatalf("only node 3 should be structurally cold, got %v", structCold)
}
}
// §附 快照2: once d also finishes, a/b/c/d are all cold and connected → one block.
func TestHotCold_WholeChainFoldsWhenAllSettled(t *testing.T) {
nodes := []cgNode{intent(1, "done"), intent(2, "done"), fact(3), intent(4, "done")}
edges := []cgEdge{derived(1, 2), yields(2, 3), derived(2, 4)}
g := newColdGraph(nodes, edges)
hot := g.hotSet()
if len(hot) != 0 {
t.Fatalf("nothing should be hot once all settled, got %v", hot)
}
structCold := g.structuralCold(hot)
// all stamped R+ rounds ago
stamps := map[int64]*int64{1: ptr(1), 2: ptr(1), 3: ptr(1), 4: ptr(1)}
elig := g.eligibleCold(structCold, stamps, 100, defaultColdParams())
if len(elig) != 4 {
t.Fatalf("all 4 nodes should be eligible cold, got %d", len(elig))
}
blocks := g.group(elig, defaultColdParams())
if len(blocks) != 1 || len(blocks[0].Members) != 4 {
t.Fatalf("expected one 4-member block, got %+v", blocks)
}
}
// §2.3 debounce: a freshly-cooled node (stamp too recent) is not yet eligible.
func TestDebounce_RecentlyCooledNotEligible(t *testing.T) {
nodes := []cgNode{intent(1, "done"), fact(2)}
edges := []cgEdge{yields(1, 2)}
g := newColdGraph(nodes, edges)
structCold := g.structuralCold(g.hotSet())
stamps := map[int64]*int64{1: ptr(98), 2: ptr(98)} // cooled at round 98
elig := g.eligibleCold(structCold, stamps, 100, defaultColdParams())
if len(elig) != 0 {
t.Fatalf("nodes cooled only 2 rounds ago (<R=6) must not be eligible, got %v", elig)
}
elig = g.eligibleCold(structCold, stamps, 104, defaultColdParams()) // now 6 rounds
if len(elig) != 2 {
t.Fatalf("after R rounds both should be eligible, got %v", elig)
}
}
// §3.1: a SETTLED hub kept hot only by ancestry to a live descendant (the real
// grap.log shape — exhausted/done intents with one live branch), whose OTHER
// children are flat dead leaves. Those leaves share the hot hub as parent, have
// no cold↔cold edges, yet must group (not stay singletons). Note: were the hub
// itself live (running), rule 1 would force its facts hot — that is a different
// case; here the hub is exhausted and hot only via the 50→77 live branch.
func TestGrouping_SharedParentRescuesFlatFanout(t *testing.T) {
// hub(50) exhausted, hot via live descendant 77; dead cold facts 51..54.
nodes := []cgNode{intent(50, "exhausted"), intent(77, "running")}
edges := []cgEdge{derived(50, 77)}
for id := int64(51); id <= 54; id++ {
nodes = append(nodes, fact(id))
edges = append(edges, yields(50, id))
}
g := newColdGraph(nodes, edges)
hot := g.hotSet()
if !hot[50] || !hot[77] {
t.Fatalf("hub 50 (ancestor of live 77) and live 77 must be hot")
}
structCold := g.structuralCold(hot)
stamps := map[int64]*int64{}
for id := int64(51); id <= 54; id++ {
stamps[id] = ptr(1)
}
elig := g.eligibleCold(structCold, stamps, 100, defaultColdParams())
blocks := g.group(elig, defaultColdParams())
// rule① alone would leave 51..54 as 4 singletons; rule② groups them into 1.
if len(blocks) != 1 {
t.Fatalf("expected 1 shared-parent block, got %d: %+v", len(blocks), blocks)
}
if len(blocks[0].Members) != 4 {
t.Fatalf("block should hold all 4 dead leaves, got %v", blocks[0].Members)
}
// the hot hub is an anchor, never a member.
if len(blocks[0].Anchors) != 1 || blocks[0].Anchors[0] != 50 {
t.Fatalf("hub 50 should be the sole anchor, got %v", blocks[0].Anchors)
}
for _, m := range blocks[0].Members {
if m == 50 {
t.Fatalf("hub 50 must not be a member")
}
}
}
// §3①: a lone cold child under a hot parent stays an unfolded singleton.
func TestGrouping_LoneColdChildStaysSingleton(t *testing.T) {
nodes := []cgNode{intent(50, "running"), intent(77, "running"), fact(51)}
edges := []cgEdge{derived(50, 77), yields(50, 51)}
g := newColdGraph(nodes, edges)
structCold := g.structuralCold(g.hotSet())
elig := g.eligibleCold(structCold, map[int64]*int64{51: ptr(1)}, 100, defaultColdParams())
blocks := g.group(elig, defaultColdParams())
if len(blocks) != 0 {
t.Fatalf("a single cold leaf must not fold, got %+v", blocks)
}
}
// §5.3: signature is stable under reordering and changes when a member's
// content_version bumps.
func TestSignature_StableAndVersionSensitive(t *testing.T) {
b := block{Members: []int64{12, 28, 41}, Anchors: []int64{50}}
cv := map[int64]int{12: 0, 28: 0, 41: 0, 50: 0}
s1 := blockSignature(b, cv)
// same members, same versions → same signature
if s1 != blockSignature(block{Members: []int64{12, 28, 41}, Anchors: []int64{50}}, cv) {
t.Fatalf("signature must be deterministic")
}
// bump a member version → signature changes
cv2 := map[int64]int{12: 0, 28: 1, 41: 0, 50: 0}
if s1 == blockSignature(b, cv2) {
t.Fatalf("signature must change when a member content_version changes")
}
// bump anchor version → signature changes (anchor summary affects body)
cv3 := map[int64]int{12: 0, 28: 0, 41: 0, 50: 1}
if s1 == blockSignature(b, cv3) {
t.Fatalf("signature must change when an anchor content_version changes")
}
}
// computeStampOps: stamp on first cool, never re-stamp, clear on revival.
func TestStampOps(t *testing.T) {
structCold := map[int64]bool{1: true, 2: true}
coldSince := map[int64]*int64{2: ptr(5), 3: ptr(4)} // 2 already stamped; 3 stamped but revived
ops := computeStampOps(structCold, coldSince, 10)
got := map[int64]stampOp{}
for _, o := range ops {
got[o.ID] = o
}
if o, ok := got[1]; !ok || !o.Set || o.Round != 10 {
t.Fatalf("node 1 should be stamped at round 10, got %+v", got[1])
}
if _, ok := got[2]; ok {
t.Fatalf("node 2 already stamped, must not be re-stamped")
}
if o, ok := got[3]; !ok || o.Set {
t.Fatalf("node 3 revived (not cold) → stamp must be cleared, got %+v", got[3])
}
}
+541
View File
@@ -0,0 +1,541 @@
package agent
// cold-digest §4/§5/§7: the Compactor ties the pure algorithms (coldgraph.go)
// to the store (db/digest.go) and the LLM. It runs in two modes:
//
// maintain — cheap, synchronous, once per planner round: bump round_no,
// recompute hot/cold, stamp/clear cold_since_round (§2.3). This is
// the bookkeeping the planner does anyway; it never calls the LLM.
// minor/major — background, off the planner hot path (§7): group cold nodes
// and compress each ≥2 block into a digest via the LLM. minor folds
// only the not-yet-covered cold set (tiered append); major re-derives
// the whole grouping from source and merges fragments (§5.1/§5.2),
// reusing bodies whose signature is unchanged (§5.3).
//
// Concurrency: one compaction per task at a time (mutex), ≥cooldown between runs,
// and a commit-time liveness recheck drops any member that revived while the body
// was being generated so a digest never covers a hot node.
import (
"context"
"encoding/json"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
)
// Compactor performs background cold-node compaction for many explorations.
type Compactor struct {
prov llm.Provider
model string
params coldParams
n, m int // minor / major thresholds (§7 N=20, M=8)
cooldown time.Duration // min gap between compactions per task (§7 60s)
maxDur time.Duration // hard cap on one background compaction
mu sync.Mutex
running map[int64]bool
lastRun map[int64]time.Time
}
// NewCompactor builds a compactor. prov/model are used for the §4 body LLM call
// (same model the agent runs on, per §4). A nil Compactor is a safe no-op.
func NewCompactor(prov llm.Provider, model string) *Compactor {
return &Compactor{
prov: prov,
model: model,
params: defaultColdParams(),
n: 20,
m: 8,
cooldown: 60 * time.Second,
maxDur: 5 * time.Minute,
running: map[int64]bool{},
lastRun: map[int64]time.Time{},
}
}
// OnPlannerRound is the single entry the planner calls each wake-up. It bumps the
// round, maintains the cold stamps synchronously, then (if a threshold is hit and
// no compaction is running / cooling down) launches a background compaction that
// outlives this planner round.
func (c *Compactor) OnPlannerRound(ctx context.Context, ts *db.ExplorationStore) {
if c == nil || c.prov == nil || ts == nil {
return
}
round, uncompressed, activeDigests, err := c.maintain(ts)
if err != nil {
log.Printf("[compaction] maintain exp=%d: %v", ts.ID(), err)
return
}
needMinor := uncompressed >= c.n
needMajor := activeDigests >= c.m
if !needMinor && !needMajor {
return
}
if !c.tryStart(ts.ID()) {
return // already running, or within cooldown —派生态最终一致,下轮再压
}
go func() {
defer c.finish(ts.ID())
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.maxDur)
defer cancel()
if needMajor {
c.major(bg, ts)
} else {
c.minor(bg, ts)
}
}()
_ = round
}
// maintain bumps round_no, recomputes hot/cold over the whole graph, and applies
// the cold_since_round stamp/clear ops (§2.3). Returns the new round plus the
// counts that drive the trigger: how many eligible-cold nodes are not yet covered
// (minor) and how many active digests exist (major).
func (c *Compactor) maintain(ts *db.ExplorationStore) (round int64, uncompressed, activeDigests int, err error) {
round, err = ts.BumpRound()
if err != nil {
return
}
g, _, err := loadColdGraph(ts)
if err != nil {
return
}
stamps, err := ts.ColdStamps()
if err != nil {
return
}
hot := g.hotSet()
structCold := g.structuralCold(hot)
ops := computeStampOps(structCold, stamps, round)
if err = ts.ApplyStampOps(toDBStampOps(ops)); err != nil {
return
}
applyStampsInPlace(stamps, ops)
elig := g.eligibleCold(structCold, stamps, round, c.params)
covered, err := ts.CoveredMembers()
if err != nil {
return
}
for id := range elig {
if _, ok := covered[id]; !ok {
uncompressed++
}
}
ad, err := ts.ActiveDigests()
if err != nil {
return
}
activeDigests = len(ad)
return
}
// minor folds the not-yet-covered eligible-cold set into new digest segments
// (tiered append, §5). Existing digests are untouched.
func (c *Compactor) minor(ctx context.Context, ts *db.ExplorationStore) {
round, err := ts.RoundNo()
if err != nil {
return
}
g, nodeByID, err := loadColdGraph(ts)
if err != nil {
return
}
stamps, err := ts.ColdStamps()
if err != nil {
return
}
cvers, err := ts.ContentVersions()
if err != nil {
return
}
covered, err := ts.CoveredMembers()
if err != nil {
return
}
hot := g.hotSet()
elig := g.eligibleCold(g.structuralCold(hot), stamps, round, c.params)
uncompressed := map[int64]bool{}
for id := range elig {
if _, ok := covered[id]; !ok {
uncompressed[id] = true
}
}
blocks := g.group(uncompressed, c.params)
if len(blocks) == 0 {
return // this batch has no ≥2 connected/shared-parent block — nothing to fold (§7)
}
for _, b := range blocks {
c.foldBlock(ctx, ts, g, b, nodeByID, cvers, c.generationFor(b, nil, nil))
}
// A minor may have pushed the segment count over M → merge in the same run.
if ad, e := ts.ActiveDigests(); e == nil && len(ad) >= c.m {
c.major(ctx, ts)
}
}
// major re-derives the whole grouping from source over ALL eligible-cold nodes
// (§5.1 回源重压), then reconciles against the active digests by signature:
// unchanged blocks keep their digest (no LLM), stale digests are superseded, and
// new/changed blocks are compressed afresh. This is where tiered fragments of one
// direction merge and where "later became connected" blocks unify (§5.2).
func (c *Compactor) major(ctx context.Context, ts *db.ExplorationStore) {
round, err := ts.RoundNo()
if err != nil {
return
}
g, nodeByID, err := loadColdGraph(ts)
if err != nil {
return
}
stamps, err := ts.ColdStamps()
if err != nil {
return
}
cvers, err := ts.ContentVersions()
if err != nil {
return
}
active, err := ts.ActiveDigests()
if err != nil {
return
}
hot := g.hotSet()
elig := g.eligibleCold(g.structuralCold(hot), stamps, round, c.params)
blocks := g.group(elig, c.params)
bySig := map[string]*db.Node{}
genBySig := map[string]int{}
for _, d := range active {
sig, gen := digestSigGen(d)
bySig[sig] = d
genBySig[sig] = gen
}
desired := map[string]bool{}
var toCreate []block
for _, b := range blocks {
sig := blockSignature(b, cvers)
desired[sig] = true
if _, ok := bySig[sig]; ok {
continue // unchanged → reuse the existing digest, skip LLM (§5.3)
}
toCreate = append(toCreate, b)
}
// Supersede stale digests FIRST (atomic drop of their covers edges) so a member
// is never covered by both an old and a new digest (§5.1 one-member-one-digest).
var stale []int64
for _, d := range active {
sig, _ := digestSigGen(d)
if !desired[sig] {
stale = append(stale, d.ID)
}
}
if err := ts.SupersedeDigests(stale); err != nil {
log.Printf("[compaction] supersede exp=%d: %v", ts.ID(), err)
}
for _, b := range toCreate {
c.foldBlock(ctx, ts, g, b, nodeByID, cvers, c.generationFor(b, active, genBySig))
}
}
// foldBlock compresses one block and writes its digest — with a commit-time
// liveness recheck (§ concurrency): between grouping and write the graph may have
// changed, so any member that has since gone hot (revived) is dropped from the
// covers set. If the block dissolves below K it is skipped.
func (c *Compactor) foldBlock(ctx context.Context, ts *db.ExplorationStore, g *coldGraph, b block, nodeByID map[int64]*db.Node, cvers map[int64]int, generation int) {
body, err := c.compress(ctx, g, b, nodeByID)
if err != nil {
log.Printf("[compaction] compress exp=%d block=%v: %v", ts.ID(), b.Members, err)
return
}
// Re-read fresh state and drop any member that revived while we compressed.
fresh, _, err := loadColdGraph(ts)
if err != nil {
return
}
freshHot := fresh.hotSet()
members := make([]int64, 0, len(b.Members))
for _, mID := range b.Members {
if !freshHot[mID] {
members = append(members, mID)
}
}
if len(members) < c.params.K {
return // block revived out from under us — leave those nodes hot, don't fold
}
final := block{Members: members, Anchors: b.Anchors}
payload := digestPayload(body, final, nodeByID, generation, blockSignature(final, cvers))
if _, err := ts.AddDigest(payload, members); err != nil {
log.Printf("[compaction] add digest exp=%d: %v", ts.ID(), err)
}
}
// generationFor computes a digest's重摘代次 (§1): 1 for a fresh fold; for a major
// merge, max(generation) over the active digests that overlap this block's
// members, +1.
func (c *Compactor) generationFor(b block, active []*db.Node, genBySig map[string]int) int {
if len(active) == 0 {
return 1
}
memberSet := make(map[int64]bool, len(b.Members))
for _, m := range b.Members {
memberSet[m] = true
}
best := 0
for _, d := range active {
_, gen := digestSigGen(d)
for _, m := range digestMemberIDs(d) {
if memberSet[m] {
if gen > best {
best = gen
}
break
}
}
}
return best + 1
}
// tryStart acquires the per-task compaction lock, honoring the cooldown.
func (c *Compactor) tryStart(expID int64) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.running[expID] {
return false
}
if t, ok := c.lastRun[expID]; ok && time.Since(t) < c.cooldown {
return false
}
c.running[expID] = true
return true
}
func (c *Compactor) finish(expID int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.running[expID] = false
c.lastRun[expID] = time.Now()
}
// --- helpers: db ↔ coldgraph ---
// loadColdGraph reads the exploration's nodes + edges and builds the cold-graph
// view plus an id→node index (for summaries/payload during compression).
func loadColdGraph(ts *db.ExplorationStore) (*coldGraph, map[int64]*db.Node, error) {
// Compaction must see the WHOLE graph, not the default row caps — pass a very
// high limit so the LIMIT clause is effectively unbounded for real task sizes.
const allRows = 1 << 30
nodes, err := ts.Nodes(allRows)
if err != nil {
return nil, nil, err
}
edges, err := ts.Edges(allRows)
if err != nil {
return nil, nil, err
}
cgNodes := make([]cgNode, 0, len(nodes))
byID := make(map[int64]*db.Node, len(nodes))
for _, n := range nodes {
cgNodes = append(cgNodes, cgNode{ID: n.ID, Kind: n.Kind, State: n.State})
byID[n.ID] = n
}
cgEdges := make([]cgEdge, 0, len(edges))
for _, e := range edges {
cgEdges = append(cgEdges, cgEdge{From: e.From, Rel: e.Rel, To: e.To})
}
return newColdGraph(cgNodes, cgEdges), byID, nil
}
func toDBStampOps(ops []stampOp) []db.StampOp {
out := make([]db.StampOp, len(ops))
for i, o := range ops {
out[i] = db.StampOp{ID: o.ID, Set: o.Set, Round: o.Round}
}
return out
}
// applyStampsInPlace folds the just-applied ops into the in-memory stamp map so
// eligibility can be computed immediately without a re-read.
func applyStampsInPlace(stamps map[int64]*int64, ops []stampOp) {
for _, o := range ops {
if o.Set {
r := o.Round
stamps[o.ID] = &r
} else {
stamps[o.ID] = nil
}
}
}
// --- helpers: digest payload ---
// digestPayload builds the digest node payload (cold-digest §1): the body, the
// member ids split by kind (restore cache; source of truth is the covers edges),
// the anchor ids, the generation, and the change-detection signature.
func digestPayload(body string, b block, nodeByID map[int64]*db.Node, generation int, signature string) map[string]any {
var facts, intents []int64
for _, m := range b.Members {
if n := nodeByID[m]; n != nil && n.Kind == db.KindIntent {
intents = append(intents, m)
} else {
facts = append(facts, m)
}
}
return map[string]any{
"body": body,
"member_ids": map[string]any{"facts": facts, "intents": intents},
"anchor_ids": b.Anchors,
"generation": generation,
"signature": signature,
}
}
func digestSigGen(n *db.Node) (string, int) {
var p struct {
Signature string `json:"signature"`
Generation int `json:"generation"`
}
_ = json.Unmarshal(n.Payload, &p)
return p.Signature, p.Generation
}
func digestMemberIDs(n *db.Node) []int64 {
var p struct {
MemberIDs struct {
Facts []int64 `json:"facts"`
Intents []int64 `json:"intents"`
} `json:"member_ids"`
}
_ = json.Unmarshal(n.Payload, &p)
return append(append([]int64{}, p.MemberIDs.Facts...), p.MemberIDs.Intents...)
}
// --- helpers: compression input + LLM (§4) ---
func nodeSummary(n *db.Node) string {
if n == nil {
return ""
}
var p map[string]any
if json.Unmarshal(n.Payload, &p) == nil {
if s, ok := p["summary"].(string); ok {
return s
}
if t, ok := p["text"].(string); ok {
return t
}
}
return ""
}
func nodeConfidence(n *db.Node) string {
if n == nil {
return ""
}
var p map[string]any
if json.Unmarshal(n.Payload, &p) == nil {
if c, ok := p["confidence"].(string); ok {
return c
}
}
return ""
}
// buildCompressionInput renders the connected sub-graph for the §4 prompt:
// member nodes (summary + id + kind + state + confidence), the internal blood
// edges among members, and — for a §3.1 shared-parent group — the anchor parents
// as context ("共同父 #p"), which are NOT members.
func buildCompressionInput(g *coldGraph, b block, nodeByID map[int64]*db.Node) string {
memberSet := make(map[int64]bool, len(b.Members))
for _, m := range b.Members {
memberSet[m] = true
}
var sb strings.Builder
sb.WriteString("【成员节点(要压缩的)】:\n")
for _, m := range b.Members {
n := nodeByID[m]
kind := "fact"
if n != nil && n.Kind == db.KindIntent {
kind = "intent"
}
state := ""
if n != nil {
state = n.State
}
line := fmt.Sprintf("- #%d [%s/%s] %s", m, kind, state, nodeSummary(n))
if conf := nodeConfidence(n); conf != "" {
line += fmt.Sprintf(" (confidence=%s)", conf)
}
sb.WriteString(line + "\n")
}
// internal edges among members
var edgeLines []string
for _, m := range b.Members {
for _, to := range g.children[m] {
if memberSet[to] {
edgeLines = append(edgeLines, fmt.Sprintf("- #%d 产出/派生→ #%d", m, to))
}
}
}
if len(edgeLines) > 0 {
sb.WriteString("\n【成员之间的血缘边(父→子)】:\n")
sort.Strings(edgeLines)
sb.WriteString(strings.Join(edgeLines, "\n") + "\n")
}
if len(b.Anchors) > 0 {
sb.WriteString("\n【共同父 / 上下文锚(不是成员,只用于理解这些结果从哪个意图探出)】:\n")
for _, a := range b.Anchors {
n := nodeByID[a]
state := ""
if n != nil {
state = n.State
}
sb.WriteString(fmt.Sprintf("- #%d [%s] %s\n", a, state, nodeSummary(n)))
}
}
return sb.String()
}
// compress runs the §4 body LLM call on one block. Uses the same model the agent
// runs on; thinking disabled (a pure summarization step).
func (c *Compactor) compress(ctx context.Context, g *coldGraph, b block, nodeByID map[int64]*db.Node) (string, error) {
req := llm.CompletionRequest{
System: []string{compressionSystemPrompt},
Messages: []llm.Message{llm.UserText(buildCompressionInput(g, b, nodeByID))},
MaxTokens: 1500,
Thinking: "disabled",
}
msg, _, _, err := c.prov.Complete(ctx, req)
if err != nil {
return "", err
}
body := strings.TrimSpace(msg.Text())
if body == "" {
return "", fmt.Errorf("empty body from model")
}
return body, nil
}
// compressionSystemPrompt is the §4 body prompt.
const compressionSystemPrompt = `你在压缩一组【彼此关联】的探索节点,产出一段综合结论(body),供规划者快速掌握"这一片已经探明了什么"。
输入是一个连通子图:
- 节点:每条是一个意图或事实的 summary(一句话),带 id、类型(intent/fact)、state、confidence(若有)。
- 关系:节点之间的血缘边(A 派生自 B / A 产出 B),说明它们如何串联。
- 若节点间没有直接血缘边、但同属一个上游意图(会另给出该上游意图作为"共同父 #p"),则按"这个意图(#p)探到了什么"来综合它们——共同父只是上下文锚、不是要压缩的成员。
据此写一段 body:
1. 综合、不罗列:顺着关系把因果串起来(哪个事实催生哪个意图、哪条意图产出了哪个结论),讲成"这一片探索得出了什么",不要把每条 summary 抄一遍。
2. 保留区分度:彼此不同的结论分别说清,别揉成一句笼统的话。
3. 保留证据强度:带 confidence 的结论标出 observed / inferred;inferred 的否定/存疑结论要点明它只是推断、可复核,别写成定论。
4. 带上 id:每条结论后标注来源节点 id(如"…(#12,#28)"),让规划者能按 id 还原原节点。
5. 正向陈述、只写输入里有的:不脑补、不引入输入中没有的判断。
6. 长度随内容自适应:结论少就短,多且互不相同就写够——但整体显著短于所有输入 summary 的总和。
只输出 body 正文本身。`
+11
View File
@@ -35,6 +35,7 @@ type Planner struct {
injectConstraints func() bool // resolver: inject task operation constraints into system prompt? (nil = yes)
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
compactor *Compactor // cold-node compaction (§7); nil = disabled
// todos keeps ONE plan-scratchpad per task (keyed by exploration id) so the
// planner's multi-step plan survives across wake-ups — each Plan() is a fresh
@@ -50,6 +51,11 @@ func NewPlanner(prov llm.Provider, model, workDir string, tx *transcript.Store,
func (p *Planner) SetCompactionWindowResolver(fn func() int) { p.windowFn = fn }
// SetCompactor wires the cold-node compactor (cold-digest §7). Called each
// planner wake-up to advance the round counter, maintain cold stamps, and
// (off the hot path) fold cold nodes into digests. nil = feature disabled.
func (p *Planner) SetCompactor(c *Compactor) { p.compactor = c }
// SetNonStreaming wires a resolver deciding whether runs use the non-streaming
// model path (true = non-streaming). nil/unset = streaming (default).
func (p *Planner) SetNonStreaming(fn func() bool) { p.nonStreamingFn = fn }
@@ -326,6 +332,11 @@ func plannerSystem(goal, dataDir, workDir string) string {
// for time/heartbeat wakes). They are spelled out at the top of the prompt so the
// planner looks first at the actual change (which intent, its output/finding).
func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal string, triggers []TriggerEvent, emit func(db.Activity)) (met bool, reason string, err error) {
// cold-digest §2.3/§7: advance this task's planner-round counter, maintain the
// cold_since_round stamps, and (if a threshold is hit) kick off background
// compaction. Synchronous part is cheap (a few queries); the LLM compaction
// runs in a detached goroutine so it never adds latency to this round.
p.compactor.OnPlannerRound(ctx, ts)
tsx := NewToolSet(ts, "planner")
if as != nil {
tsx.SetAssetStore(as, as.Companies())
+75 -5
View File
@@ -12,6 +12,28 @@ import (
actool "github.com/Autumn-27/norma/tool"
)
// balancedCap decides how many done-intents and facts to keep when the combined
// unfolded settled/cold region exceeds cap (§6.3). The smaller side is kept whole;
// the larger side takes the remaining budget; neither is starved below cap/2.
// Returns the keep counts and whether truncation applied. Newest-first slices, so
// callers keep the head.
func balancedCap(done, facts, cap int) (keepDone, keepFacts int, truncated bool) {
if done+facts <= cap {
return done, facts, false
}
half := cap / 2
keepDone, keepFacts = done, facts
switch {
case done > half && facts > half:
keepDone, keepFacts = half, cap-half
case done > half:
keepDone = cap - facts // facts fit in ≤half; intents take the rest
default:
keepFacts = cap - done // intents fit in ≤half; facts take the rest
}
return keepDone, keepFacts, true
}
// compactIntents distills intents to {id, summary, state, asset_ids, parents,
// yields} so the planner sees both the direction and its LINEAGE — parents (the
// upstream nodes it derived from: facts/intents/findings) and yields (the facts/
@@ -396,6 +418,20 @@ func (t *ToolSet) graphOverviewData() map[string]any {
factFrom[e.To] = e.From
}
}
// cold-digest §6: members folded into an active digest are shown via cold_digests
// (below), not the flat recent_* lists. `covered` maps member id → its digest id.
// §6 render-time revival check: a covered member that has become hot again (a new
// intent derived from it) must reappear this round — so `hidden` folds a member out
// only when it is covered AND still cold. covered_members (built from hidden) lets a
// parents/yields id pointing into a live fold stay resolvable (§6.5 dangling lineage).
covered, _ := t.ts.CoveredMembers()
var hotAtRender map[int64]bool
if len(covered) > 0 {
if cg, _, err := loadColdGraph(t.ts); err == nil {
hotAtRender = cg.hotSet()
}
}
hidden := func(id int64) bool { _, c := covered[id]; return c && !hotAtRender[id] }
fr, _ := t.ts.Frontier(100)
out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
all, _ := t.ts.ListByKind(db.KindIntent, 300)
@@ -405,9 +441,10 @@ func (t *ToolSet) graphOverviewData() map[string]any {
case "running":
running = append(running, n)
case "done", "blocked", "exhausted":
if len(recentDone) < 15 {
recentDone = append(recentDone, n)
if hidden(n.ID) {
continue // in a cold_digest and still cold — shown via cold_digests (§6.2)
}
recentDone = append(recentDone, n) // §6: no 15-cap; folded ones are gone, cap applied below
}
}
out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
@@ -484,10 +521,10 @@ func (t *ToolSet) graphOverviewData() map[string]any {
findingList = append(findingList, m)
}
out["finding_list"] = findingList
recentFacts := make([]map[string]any, 0, 20)
recentFacts := make([]map[string]any, 0, len(factNodes))
for _, n := range factNodes {
if len(recentFacts) >= 20 {
break
if hidden(n.ID) {
continue // in a cold_digest and still cold — surfaced via cold_digests (§6.2)
}
m := compactNode(n)
if from := factFrom[n.ID]; from > 0 {
@@ -503,7 +540,38 @@ func (t *ToolSet) graphOverviewData() map[string]any {
}
recentFacts = append(recentFacts, m)
}
// §6.3 兜底截断:未折的 settled/cold 区(recent_done_intents + recent_facts)总量 >
// cap 时按最新优先截——live(open/running) 在独立字段、永不受影响。两边【均衡】保留:
// 较少的一侧全留,较多的一侧填剩余额度,各自至少保底 cap/2,绝不把某一侧饿到 0
// (深探索链下 hot 的 settled 意图很多,天真的"意图优先"会把 facts 挤没)。正常由压缩
// 阈值约束、不触发;触发即标注,说明压缩滞后。
const unfoldedCap = 60
if doneList, ok := out["recent_done_intents"].([]map[string]any); ok {
if keepDone, keepFacts, truncated := balancedCap(len(doneList), len(recentFacts), unfoldedCap); truncated {
out["recent_done_intents"] = doneList[:keepDone]
recentFacts = recentFacts[:keepFacts]
out["unfolded_truncated"] = true // 正常不触发;触发说明压缩滞后
}
}
out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id)
// cold-digest §6.1/§6.2: the folded cold region + the per-asset directory that
// collapses independent directions, plus the dangling-lineage resolver map.
if cds, cidx := t.coldDigestOverview(); len(cds) > 0 {
out["cold_digests"] = cds // [{id, body, member_count}] —— 直接读 body (§6.1)
out["cold_index"] = cidx // [{asset, asset_id, digest_ids}] —— 按资产收敛方向 (§6.2)
}
if len(covered) > 0 {
cm := make(map[string]int64, len(covered))
for member, dig := range covered {
if hotAtRender[member] {
continue // revived → shown live this round, not a dangling folded id
}
cm[strconv.FormatInt(member, 10)] = dig
}
if len(cm) > 0 {
out["covered_members"] = cm // 悬空血缘 id → 它属于哪个 digest;用 expand_digest 展开 (§6.5)
}
}
// the original task (root) so the planner always has it, not just the
// decomposed goals.
if description, goal, err := t.ts.Root(); err == nil {
@@ -2026,6 +2094,8 @@ func (t *ToolSet) listWorkerTraces() actool.CoreTool {
func (t *ToolSet) PlannerTools() []actool.CoreTool {
return []actool.CoreTool{
t.graphOverview(), t.listFindings(), t.listFacts(), t.nodeDetail(),
// cold-digest §6.1: restore folded cold nodes (digest body → members → detail).
t.expandDigest(), t.expandIndex(),
t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.listGoals(), t.addIntent(), t.proveGoal(), t.goalMet(),
t.killWorkTool(), t.steerWorkTool(),
// report_finding:规划态势研判时若自身已确证漏洞,可直接登记(与 worker 同工具)。
+228
View File
@@ -0,0 +1,228 @@
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 touching it. A digest with no anchored asset
// falls into the 0 bucket ("(未锚定资产)"). One digest may list under >1 asset.
byAsset := map[int64]map[int64]bool{} // asset id → set of digest ids
assetSet := map[int64]bool{}
for dID, ms := range memByDigest {
touched := map[int64]bool{}
for _, m := range ms {
for _, a := range assetsByNode[m] {
touched[a] = true
}
}
if len(touched) == 0 {
touched[0] = true
}
for a := range touched {
if byAsset[a] == nil {
byAsset[a] = map[int64]bool{}
}
byAsset[a][dID] = true
if a != 0 {
assetSet[a] = 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})
})
}
+3
View File
@@ -611,6 +611,8 @@ func splitLines(s string) []string {
func (t *ToolSet) WorkerTools() []actool.CoreTool {
return []actool.CoreTool{
t.listFindings(), t.listFacts(), t.nodeDetail(),
// cold-digest §6.1: a worker may hit a folded id in lineage — let it expand.
t.expandDigest(), t.expandIndex(),
t.addFinding(), t.recordFact(),
// cross-work retrieval
t.searchAllWorkerTraces(), t.listWorkerTraces(), t.getWorkerTrace(),
@@ -624,6 +626,7 @@ func (t *ToolSet) WorkerTools() []actool.CoreTool {
func (t *ToolSet) MainAgentTools() []actool.CoreTool {
return []actool.CoreTool{
t.graphOverview(), t.listFindings(), t.listFacts(), t.nodeDetail(),
t.expandDigest(), t.expandIndex(), // cold-digest §6.1
t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.addHint(), t.addIntent(),
// steer_work:人可对某条正在运行的意图(work)实时注入纠偏指令(不打断、不丢进展)。
t.steerWorkTool(),