feat(kbblock): fade-out per §9 #3 — seed + RenderFaded + Tick + Refresh
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) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sdkfade "git.hangman-lab.top/hzhang/Plexum-sdk-go/fade"
|
||||||
sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
sdkplugin "git.hangman-lab.top/hzhang/Plexum-sdk-go/plugin"
|
||||||
|
|
||||||
"git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/calendar"
|
"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
|
// RenderDynamicSubblock implements sdkplugin.DynamicBlockProvider. The
|
||||||
// host calls this once per turn per declared subblock name from this
|
// host calls this once per turn per declared subblock name from this
|
||||||
// plugin's manifest (DESIGN-DYNAMIC-BLOCK.md §2 / §3.3). For
|
// plugin's manifest (DESIGN-DYNAMIC-BLOCK.md §2 / §3.3). For
|
||||||
// "kb-block" we open the per-session kb-block.json and return its
|
// "kb-block" we open the per-session kb-block.json, Tick fade-out
|
||||||
// rendered body (host wraps in <kb-block>...</kb-block>). Empty block
|
// drops on entries that crossed the m% threshold (§9 #3), Save the
|
||||||
// → return "" → host omits the subblock for this turn.
|
// (possibly mutated) block, and return RenderFaded body for the host
|
||||||
|
// to wrap in <kb-block>...</kb-block>. Empty block → return "" → host
|
||||||
|
// omits the subblock for this turn.
|
||||||
//
|
//
|
||||||
// Side effect: stash agentID → sessionID into p.agentSession so the
|
// Side effect: stash agentID → sessionID into p.agentSession so the
|
||||||
// dynamic-kb-cache + dynamic-kb-evict tools can resolve the same
|
// dynamic-kb-cache + dynamic-kb-evict tools can resolve the same
|
||||||
// kb-block file when they fire later in the same turn.
|
// kb-block file when they fire later in the same turn.
|
||||||
func (p *harborForgePlugin) RenderDynamicSubblock(ctx context.Context, agentID, sessionID, name string) (string, error) {
|
func (p *harborForgePlugin) RenderDynamicSubblock(ctx context.Context, req sdkplugin.RenderDynamicSubblockRequest) (string, error) {
|
||||||
if agentID == "" || sessionID == "" {
|
if req.AgentID == "" || req.SessionID == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
// Cache the (agent, session) pair for tool dispatch this turn.
|
// 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" {
|
if req.Name != "kb-block" {
|
||||||
// Host shouldn't request unknown subblock names per manifest
|
|
||||||
// contract, but log defensively so operator notices wiring drift.
|
|
||||||
p.host.Log("warn", "dynamic-block render: unknown subblock name",
|
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
|
return "", nil
|
||||||
}
|
}
|
||||||
block, err := kbblock.Open(p.profileRoot, agentID, sessionID)
|
block, err := kbblock.Open(p.profileRoot, req.AgentID, req.SessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.host.Log("warn", "open kb-block", map[string]any{
|
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 "", 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) {
|
func (p *harborForgePlugin) CallTool(ctx context.Context, name string, input json.RawMessage) (sdkplugin.ToolResult, error) {
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ package kbblock
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdkfade "git.hangman-lab.top/hzhang/Plexum-sdk-go/fade"
|
||||||
)
|
)
|
||||||
|
|
||||||
const FileName = "kb-block.json"
|
const FileName = "kb-block.json"
|
||||||
@@ -47,6 +51,10 @@ type Entry struct {
|
|||||||
InsertSeq int `json:"insert_seq"` // per-session monotonic, for render order
|
InsertSeq int `json:"insert_seq"` // per-session monotonic, for render order
|
||||||
AddedAtTurn int `json:"added_at_turn"`
|
AddedAtTurn int `json:"added_at_turn"`
|
||||||
LastRefreshAtTurn int `json:"last_refresh_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.
|
// 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,
|
InsertSeq: b.NextSeq,
|
||||||
AddedAtTurn: atTurn,
|
AddedAtTurn: atTurn,
|
||||||
LastRefreshAtTurn: atTurn,
|
LastRefreshAtTurn: atTurn,
|
||||||
|
Seed: newSeed(),
|
||||||
}
|
}
|
||||||
b.NextSeq++
|
b.NextSeq++
|
||||||
b.Entries = append(b.Entries, e)
|
b.Entries = append(b.Entries, e)
|
||||||
@@ -223,13 +232,30 @@ func (b *Block) Save() error {
|
|||||||
// Render returns the inner body of the <kb-block> subblock — a flat
|
// Render returns the inner body of the <kb-block> subblock — a flat
|
||||||
// sequence of `<kb-fact id=N kb=<code> source=topic:<slug>>\n<content>
|
// sequence of `<kb-fact id=N kb=<code> source=topic:<slug>>\n<content>
|
||||||
// \n</kb-fact>` separated by single blank lines, ordered by InsertSeq
|
// \n</kb-fact>` 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
|
// The plugin's DynamicBlockProvider.RenderDynamicSubblock returns this
|
||||||
// to the Plexum host, which wraps in <kb-block>...</kb-block>.
|
// to the Plexum host, which wraps in <kb-block>...</kb-block>.
|
||||||
func (b *Block) Render() string {
|
func (b *Block) Render() string {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
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 {
|
if len(b.Entries) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -241,16 +267,81 @@ func (b *Block) Render() string {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
content := e.Content
|
||||||
|
if transform != nil {
|
||||||
|
content = transform(e)
|
||||||
|
}
|
||||||
fmt.Fprintf(&sb, "<kb-fact id=%d kb=%s", e.ID, e.KBCode)
|
fmt.Fprintf(&sb, "<kb-fact id=%d kb=%s", e.ID, e.KBCode)
|
||||||
if e.SourceTopic != "" {
|
if e.SourceTopic != "" {
|
||||||
fmt.Fprintf(&sb, " source=topic:%s", e.SourceTopic)
|
fmt.Fprintf(&sb, " source=topic:%s", e.SourceTopic)
|
||||||
}
|
}
|
||||||
sb.WriteString(">\n")
|
sb.WriteString(">\n")
|
||||||
sb.WriteString(e.Content)
|
sb.WriteString(content)
|
||||||
if !strings.HasSuffix(e.Content, "\n") {
|
if !strings.HasSuffix(content, "\n") {
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
sb.WriteString("</kb-fact>\n")
|
sb.WriteString("</kb-fact>\n")
|
||||||
}
|
}
|
||||||
return sb.String()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user