From 9e24c52ae521245f25077f33aa6ae1aad54fc7cf Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 6 Jun 2026 00:26:04 +0100 Subject: [PATCH] =?UTF-8?q?feat(kbblock):=20fade-out=20per=20=C2=A79=20#3?= =?UTF-8?q?=20=E2=80=94=20seed=20+=20RenderFaded=20+=20Tick=20+=20Refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b. Wires fade-out for HarborForge kb-block entries using the new Plexum-sdk-go/fade primitives + RenderDynamicSubblockRequest's CurrentTurn field. internal/kbblock/kbblock.go: - Entry gains Seed int64 (json:"seed") for per-entry RNG seed - Add() generates a fresh Seed at insert time - renderLocked() refactored — Render() unchanged behaviour; RenderFaded(currentTurn, params) added — applies sdkfade.Fade per entry given (currentTurn - LastRefreshAtTurn) elapsed - Tick(currentTurn, params) drops entries whose underscore ratio crossed the m% threshold, returns dropped IDs - Refresh(ids, currentTurn) resets fade state (regenerates Seed + bumps LastRefreshAtTurn) — exposed for a future dynamic-kb-refresh tool (not in this commit) cmd/plexum-harborforge-plugin/main.go: - RenderDynamicSubblock signature now sdkplugin. RenderDynamicSubblockRequest (per SDK d6fdb9f) - Each turn: Tick → drop crossed-threshold entries (logs IDs dropped) → Save (only when something dropped) → RenderFaded returned. Uses sdkfade.DefaultFadeParams() (5/10/70 per §9 #3). Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/plexum-harborforge-plugin/main.go | 41 +++++++---- internal/kbblock/kbblock.go | 97 ++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/cmd/plexum-harborforge-plugin/main.go b/cmd/plexum-harborforge-plugin/main.go index 282e5a2..00ccda6 100644 --- a/cmd/plexum-harborforge-plugin/main.go +++ b/cmd/plexum-harborforge-plugin/main.go @@ -24,6 +24,7 @@ import ( "sync" "time" + sdkfade "git.hangman-lab.top/hzhang/Plexum-sdk-go/fade" sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin" "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar" @@ -215,35 +216,49 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er // RenderDynamicSubblock implements sdkplugin.DynamicBlockProvider. The // host calls this once per turn per declared subblock name from this // plugin's manifest (DESIGN-DYNAMIC-BLOCK.md §2 / §3.3). For -// "kb-block" we open the per-session kb-block.json and return its -// rendered body (host wraps in ...). Empty block -// → return "" → host omits the subblock for this turn. +// "kb-block" we open the per-session kb-block.json, Tick fade-out +// drops on entries that crossed the m% threshold (§9 #3), Save the +// (possibly mutated) block, and return RenderFaded body for the host +// to wrap in .... Empty block → return "" → host +// omits the subblock for this turn. // // Side effect: stash agentID → sessionID into p.agentSession so the // dynamic-kb-cache + dynamic-kb-evict tools can resolve the same // kb-block file when they fire later in the same turn. -func (p *harborForgePlugin) RenderDynamicSubblock(ctx context.Context, agentID, sessionID, name string) (string, error) { - if agentID == "" || sessionID == "" { +func (p *harborForgePlugin) RenderDynamicSubblock(ctx context.Context, req sdkplugin.RenderDynamicSubblockRequest) (string, error) { + if req.AgentID == "" || req.SessionID == "" { return "", nil } // Cache the (agent, session) pair for tool dispatch this turn. - p.agentSession.Store(agentID, sessionID) + p.agentSession.Store(req.AgentID, req.SessionID) - if name != "kb-block" { - // Host shouldn't request unknown subblock names per manifest - // contract, but log defensively so operator notices wiring drift. + if req.Name != "kb-block" { p.host.Log("warn", "dynamic-block render: unknown subblock name", - map[string]any{"agent": agentID, "session": sessionID, "name": name}) + map[string]any{"agent": req.AgentID, "session": req.SessionID, "name": req.Name}) return "", nil } - block, err := kbblock.Open(p.profileRoot, agentID, sessionID) + block, err := kbblock.Open(p.profileRoot, req.AgentID, req.SessionID) if err != nil { p.host.Log("warn", "open kb-block", map[string]any{ - "agent": agentID, "session": sessionID, "err": err.Error(), + "agent": req.AgentID, "session": req.SessionID, "err": err.Error(), }) return "", nil } - return block.Render(), nil + // Apply fade tick (drop entries that crossed the m% threshold). + // Uses DESIGN-DYNAMIC-BLOCK.md §9 #3 spec defaults (5/10/70). + fadeParams := sdkfade.DefaultFadeParams() + if tick := block.Tick(req.CurrentTurn, fadeParams); len(tick.FadedOut) > 0 { + p.host.Log("info", "kb-block fade tick", map[string]any{ + "agent": req.AgentID, + "session": req.SessionID, + "dropped": tick.FadedOut, + }) + if err := block.Save(); err != nil { + p.host.Log("warn", "save kb-block after tick", + map[string]any{"err": err.Error()}) + } + } + return block.RenderFaded(req.CurrentTurn, fadeParams), nil } func (p *harborForgePlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (sdkplugin.ToolResult, error) { diff --git a/internal/kbblock/kbblock.go b/internal/kbblock/kbblock.go index 5aed39a..b3d8e61 100644 --- a/internal/kbblock/kbblock.go +++ b/internal/kbblock/kbblock.go @@ -28,11 +28,15 @@ package kbblock import ( "encoding/json" "fmt" + "math/rand" "os" "path/filepath" "sort" "strings" "sync" + "time" + + sdkfade "git.hangman-lab.top/hzhang/Plexum-sdk-go/fade" ) const FileName = "kb-block.json" @@ -47,6 +51,10 @@ type Entry struct { InsertSeq int `json:"insert_seq"` // per-session monotonic, for render order AddedAtTurn int `json:"added_at_turn"` LastRefreshAtTurn int `json:"last_refresh_at_turn"` + // Seed is the per-entry RNG seed for deterministic fade-out + // (DESIGN-DYNAMIC-BLOCK.md §9 #3 / Plexum decision #30). Populated + // at Add() time; reproducible across restarts. + Seed int64 `json:"seed"` } // Block is the in-memory representation of kb-block.json. @@ -138,6 +146,7 @@ func (b *Block) Add(id int, kbCode, sourceTopic, content string, atTurn int) *En InsertSeq: b.NextSeq, AddedAtTurn: atTurn, LastRefreshAtTurn: atTurn, + Seed: newSeed(), } b.NextSeq++ b.Entries = append(b.Entries, e) @@ -223,13 +232,30 @@ func (b *Block) Save() error { // Render returns the inner body of the subblock — a flat // sequence of ` source=topic:>\n // \n` separated by single blank lines, ordered by InsertSeq -// (DESIGN-DYNAMIC-BLOCK.md §9 #4). Empty block returns "". +// (DESIGN-DYNAMIC-BLOCK.md §9 #4). Empty block returns "". No fade +// applied; use RenderFaded. // // The plugin's DynamicBlockProvider.RenderDynamicSubblock returns this // to the Plexum host, which wraps in .... func (b *Block) Render() string { b.mu.Lock() defer b.mu.Unlock() + return b.renderLocked(nil) +} + +// RenderFaded returns the body with sdkfade.Fade applied per entry +// given currentTurn + params (DESIGN-DYNAMIC-BLOCK.md §9 #3). The +// block itself is NOT mutated by render; pair with Tick to actually +// drop entries that crossed the m% drop threshold. +func (b *Block) RenderFaded(currentTurn int, params sdkfade.FadeParams) string { + b.mu.Lock() + defer b.mu.Unlock() + return b.renderLocked(func(e *Entry) string { + return sdkfade.Fade(e.Content, e.Seed, currentTurn-e.LastRefreshAtTurn, params).Rendered + }) +} + +func (b *Block) renderLocked(transform func(*Entry) string) string { if len(b.Entries) == 0 { return "" } @@ -241,16 +267,81 @@ func (b *Block) Render() string { if i > 0 { sb.WriteByte('\n') } + content := e.Content + if transform != nil { + content = transform(e) + } fmt.Fprintf(&sb, "\n") - sb.WriteString(e.Content) - if !strings.HasSuffix(e.Content, "\n") { + sb.WriteString(content) + if !strings.HasSuffix(content, "\n") { sb.WriteByte('\n') } sb.WriteString("\n") } return sb.String() } + +// TickResult summarizes one fade tick across the whole block. +type TickResult struct { + FadedOut []int // entry IDs auto-removed because fade crossed threshold +} + +// Tick advances fade by computing each entry's current rendered form +// at currentTurn and DROPPING entries whose underscore ratio crosses +// the m% threshold. Returns the IDs dropped so caller can log/report. +// Caller must Save() after Tick. +func (b *Block) Tick(currentTurn int, params sdkfade.FadeParams) TickResult { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.Entries) == 0 { + return TickResult{} + } + var faded []int + kept := b.Entries[:0] + for _, e := range b.Entries { + d := currentTurn - e.LastRefreshAtTurn + fr := sdkfade.Fade(e.Content, e.Seed, d, params) + if fr.ShouldDrop(params) { + faded = append(faded, e.ID) + continue + } + kept = append(kept, e) + } + b.Entries = kept + return TickResult{FadedOut: faded} +} + +// Refresh resets fade state for the given IDs (regenerates Seed + +// sets LastRefreshAtTurn = currentTurn). Returns IDs actually +// refreshed. +func (b *Block) Refresh(ids []int, currentTurn int) []int { + b.mu.Lock() + defer b.mu.Unlock() + want := map[int]bool{} + for _, id := range ids { + want[id] = true + } + var refreshed []int + for _, e := range b.Entries { + if want[e.ID] { + e.LastRefreshAtTurn = currentTurn + e.Seed = newSeed() + refreshed = append(refreshed, e.ID) + } + } + return refreshed +} + +// newSeed generates a fresh int64 seed for fade RNG. +var seedRng = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec +var seedMu sync.Mutex + +func newSeed() int64 { + seedMu.Lock() + defer seedMu.Unlock() + return seedRng.Int63() +}