refactor(graph_overview): 概览各列表加数量上限,止住上下文膨胀

用"最新一窗 + 计数/pull 兜底"替换 hot/cold 拆分与 balancedCap:
- recent_facts 20、recent_done_intents 12、open_intents 30(按 priority)
- cold_digests 15,按最新成员时间(max member id)降序,溢出的更旧 digest
  只给裸 id(cold_digests_more),仍可 expand_digest 展开
- 新增 CountOpenIntents,frontier_open 报开放意图真实总数(与截断视图解耦)
- 删除 balancedCap/unfoldedCap/unfolded_truncated 及其测试

掉出窗口的节点不丢:facts/done_intents_total/frontier_open/findings_total
计数在,list_facts/list_findings/node_detail/expand_digest 可按需回捞
This commit is contained in:
Autumn-27 committed 2026-09-24 13:05:27 -04:00
1 parent 43d1743dc0
commit 3aac6754a8
4 files changed
+93 -99

No files matched your search

-35
View File
@@ -1,35 +0,0 @@
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")
}
})
}
}
+38 -64
View File
@@ -12,28 +12,6 @@ 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/
@@ -421,16 +399,17 @@ func (t *ToolSet) graphOverviewData() map[string]any {
// intent derived from it) must reappear this round — so `hidden` folds a member out
// only when it is covered AND still cold.
covered, _ := t.ts.CoveredMembers()
// Hot set at render time serves two §6 needs: (1) a covered member that revived
// (now hot) must reappear this round; (2) the 60-cap must never truncate hot
// (active-context) nodes — only the cold-but-unfolded region is cappable (§6.3).
// Computed every round (cheap for real graph sizes); nil map degrades safely.
// Render-time hot set (ancestor of a live intent / fact under a live intent).
// §6 revival check: a covered member that revived (now hot) must NOT stay folded
// — hidden() only folds a member out when it is covered AND still cold. Computed
// every round (cheap for real graph sizes); nil map degrades safely.
var hotAtRender map[int64]bool
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)
const openIntentsCap = 30
fr, _ := t.ts.Frontier(openIntentsCap) // priority DESC, id ASC —— 优先级最高的前 N 条;真实总数见 frontier_open
out["open_intents"] = compactIntents(fr, parentsOf, yieldsOf)
all, _ := t.ts.ListByKind(db.KindIntent, 300)
var running, recentDone []*db.Node
@@ -442,19 +421,22 @@ func (t *ToolSet) graphOverviewData() map[string]any {
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
recentDone = append(recentDone, n) // 最新在前(all 按 id 降序);折叠的已剔除,输出时截最新 N
}
}
out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
// recent_done_intents is finalized further below (after facts are known) so the
// 60-cap can balance the two lists and protect hot nodes (§6.3).
// done_intents_total:已结束意图(done/blocked/exhausted)总数,与 recent_done_intents
// 平行命名——后者只是它的最新窗口(≤15)截断视图。两键并排即自描述:"看到的是 N/总数",
// 平行命名——后者只是它的最新窗口截断视图。两键并排即自描述:"看到的是 N/总数",
// 让 planner 去重时别把"没显示"当成"没派过",无需在提示词里另行解释。
if dt, err := t.ts.CountFinishedIntents(); err == nil {
out["done_intents_total"] = dt
}
out["frontier_open"] = len(fr)
// frontier_open:开放意图真实总数(open_intents 只是其中优先级最高的前 N 条截断视图)。
if fo, err := t.ts.CountOpenIntents(); err == nil {
out["frontier_open"] = fo
} else {
out["frontier_open"] = len(fr)
}
// findings (confirmed vulns) and facts (worker exploration results) are
// now distinct node kinds. recent_facts surfaces fact summaries (esp.
// negative results) so the planner sees them in one call; full content
@@ -482,12 +464,17 @@ func (t *ToolSet) graphOverviewData() map[string]any {
findingList = append(findingList, m)
}
out["finding_list"] = findingList
// Build facts, split hot (active context — a fact under a live intent) from cold
// (not-yet-folded). Hidden (folded & still cold) ones are surfaced via cold_digests.
var recentFactsHot, recentFactsCold []map[string]any
// recent_facts:非折叠事实里最新的一窗(≤N,factNodes 按 id 降序即最新在前)。已折进
// digest 且仍冷的(hidden)走 cold_digests,不在此重复。每条 {id, summary, from_intent?,
// confidence?};evidence 等详情用 node_detail(id)。更早的用 list_facts 翻。
const recentFactsCap = 20
recentFacts := make([]map[string]any, 0, recentFactsCap)
for _, n := range factNodes {
if len(recentFacts) >= recentFactsCap {
break
}
if hidden(n.ID) {
continue // in a cold_digest and still cold — surfaced via cold_digests (§6.2)
continue // 已折进 digest 且仍冷 —— 见 cold_digests
}
m := compactNode(n)
if from := factFrom[n.ID]; from > 0 {
@@ -501,37 +488,24 @@ func (t *ToolSet) graphOverviewData() map[string]any {
m["confidence"] = c
}
}
if hotAtRender[n.ID] {
recentFactsHot = append(recentFactsHot, m)
} else {
recentFactsCold = append(recentFactsCold, m)
}
recentFacts = append(recentFacts, m)
}
// Split the settled intents the same way: hot = ancestor of a live intent (active
// context); cold = not-yet-folded settled.
var doneHot, doneCold []*db.Node
for _, n := range recentDone {
if hotAtRender[n.ID] {
doneHot = append(doneHot, n)
} else {
doneCold = append(doneCold, n)
}
out["recent_facts"] = recentFacts
// recent_done_intents:非折叠的已结束意图里最新的一窗(≤N,recentDone 已按 id 降序)。
// 更早的看 done_intents_total 计数 + node_detail(id)。
const recentDoneCap = 12
if len(recentDone) > recentDoneCap {
recentDone = recentDone[:recentDoneCap]
}
// §6.3 兜底截断:hot(活跃探索上下文——live 前沿的祖先链、live 意图下的新事实)【永不】
// 被截;60-cap 只约束【冷但未折】的残余(压缩滞后时才增长)。live(open/running) 另有字段、
// 同样不受影响。冷区两侧均衡保留:较少一侧全留、较多一侧填余额,各自保底 cap/2,绝不饿到 0。
const unfoldedCap = 60
keepColdDone, keepColdFacts, truncated := balancedCap(len(doneCold), len(recentFactsCold), unfoldedCap)
if truncated {
out["unfolded_truncated"] = true // 正常不触发;触发说明压缩滞后
}
out["recent_done_intents"] = append(
compactIntents(doneHot, parentsOf, yieldsOf),
compactIntents(doneCold[:keepColdDone], parentsOf, yieldsOf)...)
out["recent_facts"] = append(recentFactsHot, recentFactsCold[:keepColdFacts]...) // {id, summary, from_intent, confidence?};详情用 node_detail(id)
// cold-digest §6.1: the folded cold region as flat digest bodies.
if cds := activeDigestBodies(t.ts); len(cds) > 0 {
out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
// cold-digest §6.1: 折叠冷区的 digest body,按最新成员时间降序取前 N;被截的更旧 digest
// 只给裸 id(仍可 expand_digest 展开),避免冷区唯一出口被无限拉长。
const coldDigestsCap = 15
if cds, more := t.coldDigestsRecent(coldDigestsCap); len(cds) > 0 {
out["cold_digests"] = cds // [{id, body, member_count}] —— 直接读 body (§6.1)
if len(more) > 0 {
out["cold_digests_more"] = more // 被截断的更旧 digest 的 id;用 expand_digest(id) 展开
}
}
// the original task (root) so the planner always has it, not just the
// decomposed goals.
+45
View File
@@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
@@ -54,6 +55,50 @@ func activeDigestBodies(store *db.ExplorationStore) []map[string]any {
return out
}
// coldDigestsRecent returns the current task's active digests as flat bodies for
// graph_overview, ordered by the recency of their freshest member (max member id ≈
// latest cooled node — a digest near the live frontier is likelier relevant), and
// capped at `cap`. Overflow digest ids are returned separately (moreIDs) so they
// stay reachable via expand_digest even when not shown inline — cold_digests is the
// only exit for folded cold nodes.
func (t *ToolSet) coldDigestsRecent(cap int) (shown []map[string]any, moreIDs []int64) {
ads, err := t.ts.ActiveDigests()
if err != nil || len(ads) == 0 {
return nil, nil
}
type dg struct {
id int64
entry map[string]any
freshness int64 // max member id (ids are monotonic ≈ creation time)
}
items := make([]dg, 0, len(ads))
for _, d := range ads {
var p struct {
Body string `json:"body"`
}
_ = json.Unmarshal(d.Payload, &p)
ms, _ := t.ts.DigestMembers(d.ID) // sorted asc → last = freshest
var fresh int64
if len(ms) > 0 {
fresh = ms[len(ms)-1]
}
items = append(items, dg{
id: d.ID,
entry: map[string]any{"id": d.ID, "body": p.Body, "member_count": len(ms)},
freshness: fresh,
})
}
sort.Slice(items, func(i, j int) bool { return items[i].freshness > items[j].freshness })
for i, it := range items {
if i < cap {
shown = append(shown, it.entry)
} else {
moreIDs = append(moreIDs, it.id)
}
}
return shown, moreIDs
}
// 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: "当前任务
+10
View File
@@ -808,6 +808,16 @@ WHERE exploration_id=$1 AND kind='intent' AND state IN ('done','blocked','exhaus
return n, err
}
// CountOpenIntents counts this exploration's open intents — graph_overview's
// frontier_open, so the planner knows open_intents (capped at the top-N by
// priority) is a truncated view and there may be more claimable work.
func (s *ExplorationStore) CountOpenIntents() (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM exploration_nodes
WHERE exploration_id=$1 AND kind='intent' AND state='open'`, s.expID).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))