From 5b19e4af593d15ed75837b12e85c0fc455a18ded Mon Sep 17 00:00:00 2001 From: k Date: Fri, 17 Apr 2026 16:53:49 +0900 Subject: [PATCH] feat(seahorse): make fresh tail size configurable --- config/config.example.json | 5 +++ pkg/agent/context_seahorse.go | 14 +++++-- pkg/agent/context_seahorse_test.go | 57 +++++++++++++++++++++++++++ pkg/seahorse/short_assembler.go | 4 +- pkg/seahorse/short_assembler_test.go | 32 +++++++++++++++ pkg/seahorse/short_compaction.go | 8 ++-- pkg/seahorse/short_compaction_test.go | 19 +++++++++ pkg/seahorse/short_engine.go | 9 +++++ pkg/seahorse/short_engine_test.go | 10 +++++ 9 files changed, 148 insertions(+), 10 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 858472488..13335a6bf 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -11,6 +11,11 @@ "summarize_message_threshold": 20, "summarize_token_percent": 75, "split_on_marker": false, + "context_manager": "legacy", + "context_manager_config": { + "_comment": "Used by the seahorse context manager", + "fresh_tail_size": 32 + }, "tool_feedback": { "enabled": false, "max_args_length": 300 diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go index c6e5b30ac..e52558d88 100644 --- a/pkg/agent/context_seahorse.go +++ b/pkg/agent/context_seahorse.go @@ -22,7 +22,7 @@ type seahorseContextManager struct { } // newSeahorseContextManager creates a seahorse-backed ContextManager. -func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager, error) { +func newSeahorseContextManager(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { if al == nil { return nil, fmt.Errorf("seahorse: AgentLoop is required") } @@ -32,13 +32,19 @@ func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager agent := al.registry.GetDefaultAgent() dbPath := agent.Workspace + "/sessions/seahorse.db" + seahorseConfig := seahorse.Config{DBPath: dbPath} + if len(cfg) > 0 { + if err := json.Unmarshal(cfg, &seahorseConfig); err != nil { + return nil, fmt.Errorf("seahorse: decode config: %w", err) + } + seahorseConfig.DBPath = dbPath + } + // Create CompleteFn from provider completeFn := providerToCompleteFn(agent.Provider, agent.Model) // Create engine - engine, err := seahorse.NewEngine(seahorse.Config{ - DBPath: dbPath, - }, completeFn) + engine, err := seahorse.NewEngine(seahorseConfig, completeFn) if err != nil { return nil, fmt.Errorf("seahorse: create engine: %w", err) } diff --git a/pkg/agent/context_seahorse_test.go b/pkg/agent/context_seahorse_test.go index e405ef944..cef304283 100644 --- a/pkg/agent/context_seahorse_test.go +++ b/pkg/agent/context_seahorse_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "fmt" "strings" "testing" @@ -46,6 +47,62 @@ func TestSeahorseCMRegistration(t *testing.T) { } } +func TestSeahorseContextManagerConfigFreshTailSize(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "seahorse", + ContextManagerConfig: json.RawMessage(`{"fresh_tail_size":2}`), + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "ok"}) + + seahorseCM, ok := al.contextManager.(*seahorseContextManager) + if !ok { + t.Fatal("expected seahorseContextManager") + } + + store := seahorseCM.engine.GetRetrieval().Store() + ctx := context.Background() + conv, err := store.GetOrCreateConversation(ctx, "fresh-tail-config") + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + for i := 0; i < 5; i++ { + msg, addErr := store.AddMessage(ctx, conv.ConversationID, "user", fmt.Sprintf("msg %d", i), 10) + if addErr != nil { + t.Fatalf("AddMessage %d: %v", i, addErr) + } + if appendErr := store.AppendContextMessage(ctx, conv.ConversationID, msg.ID); appendErr != nil { + t.Fatalf("AppendContextMessage %d: %v", i, appendErr) + } + } + + resp, err := seahorseCM.Assemble(ctx, &AssembleRequest{ + SessionKey: "fresh-tail-config", + Budget: 20, + MaxTokens: 0, + }) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + if len(resp.History) != 2 { + t.Fatalf("History = %d, want 2", len(resp.History)) + } + if resp.History[0].Content != "msg 3" { + t.Errorf("first history message = %q, want %q", resp.History[0].Content, "msg 3") + } +} + func TestProviderToSeahorseMessage(t *testing.T) { tests := []struct { name string diff --git a/pkg/seahorse/short_assembler.go b/pkg/seahorse/short_assembler.go index f0fd323ba..7acb8933e 100644 --- a/pkg/seahorse/short_assembler.go +++ b/pkg/seahorse/short_assembler.go @@ -54,8 +54,8 @@ func (a *Assembler) Assemble(ctx context.Context, convID int64, input AssembleIn resolved[i] = r } - // Split into evictable prefix and protected fresh tail - tailStart := len(resolved) - FreshTailCount + // Split into evictable prefix and protected fresh tail. + tailStart := len(resolved) - a.config.GetFreshTailSize() if tailStart < 0 { tailStart = 0 } diff --git a/pkg/seahorse/short_assembler_test.go b/pkg/seahorse/short_assembler_test.go index 88a05e64c..387799f3a 100644 --- a/pkg/seahorse/short_assembler_test.go +++ b/pkg/seahorse/short_assembler_test.go @@ -197,6 +197,38 @@ func TestAssemblerBudgetFitsAll(t *testing.T) { } } +func TestAssemblerUsesConfiguredFreshTailSize(t *testing.T) { + s, convID := setupAssemblerStore(t) + ctx := context.Background() + + msgs := make([]*Message, 6) + items := make([]ContextItem, 6) + for i := 0; i < 6; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "msg", 10) + msgs[i] = m + items[i] = ContextItem{ + Ordinal: (i + 1) * 100, + ItemType: "message", + MessageID: m.ID, + TokenCount: 10, + } + } + s.UpsertContextItems(ctx, convID, items) + + a := &Assembler{store: s, config: Config{FreshTailSize: 3}} + result, err := a.Assemble(ctx, convID, AssembleInput{Budget: 30}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + if len(result.Messages) != 3 { + t.Fatalf("Messages = %d, want 3", len(result.Messages)) + } + if result.Messages[0].ID != msgs[3].ID { + t.Errorf("first message ID = %d, want %d", result.Messages[0].ID, msgs[3].ID) + } +} + func TestAssemblerSummaryXMLFormat(t *testing.T) { s, convID := setupAssemblerStore(t) ctx := context.Background() diff --git a/pkg/seahorse/short_compaction.go b/pkg/seahorse/short_compaction.go index 30e290926..6ec34997b 100644 --- a/pkg/seahorse/short_compaction.go +++ b/pkg/seahorse/short_compaction.go @@ -167,7 +167,7 @@ func (e *CompactionEngine) CompactUntilUnder(ctx context.Context, convID int64, } // compactLeaf compresses the oldest contiguous message chunk into a leaf summary. -// When force is true, FreshTailCount protection is bypassed (used by CompactUntilUnder). +// When force is true, fresh tail protection is bypassed (used by CompactUntilUnder). func (e *CompactionEngine) compactLeaf(ctx context.Context, convID int64, force ...bool) (*string, error) { items, err := e.store.GetContextItems(ctx, convID) if err != nil { @@ -191,7 +191,7 @@ func (e *CompactionEngine) compactLeaf(ctx context.Context, convID int64, force // Calculate fresh tail boundary (bypass when forced) useForce := len(force) > 0 && force[0] - tailStartIdx := len(items) - FreshTailCount + tailStartIdx := len(items) - e.config.GetFreshTailSize() if useForce { tailStartIdx = len(items) // allow compacting everything } @@ -465,7 +465,7 @@ func (e *CompactionEngine) selectShallowestCondensationCandidate( } // Group by depth, find consecutive runs - tailStartIdx := len(items) - FreshTailCount + tailStartIdx := len(items) - e.config.GetFreshTailSize() if tailStartIdx < 0 { tailStartIdx = 0 } @@ -527,7 +527,7 @@ func (e *CompactionEngine) selectOldestChunkAtDepth( return nil, err } - tailStartIdx := len(items) - FreshTailCount + tailStartIdx := len(items) - e.config.GetFreshTailSize() if tailStartIdx < 0 { tailStartIdx = 0 } diff --git a/pkg/seahorse/short_compaction_test.go b/pkg/seahorse/short_compaction_test.go index ea7dcb52d..2ed0c639e 100644 --- a/pkg/seahorse/short_compaction_test.go +++ b/pkg/seahorse/short_compaction_test.go @@ -164,6 +164,25 @@ func TestCompactLeaf(t *testing.T) { } } +func TestCompactLeafUsesConfiguredFreshTailSize(t *testing.T) { + ce, s, convID := newTestCompactionEngine(t) + ce.config = Config{FreshTailSize: 2} + ctx := context.Background() + + for i := 0; i < 10; i++ { + m, _ := s.AddMessage(ctx, convID, "user", "message content for compaction test", 100) + s.AppendContextMessage(ctx, convID, m.ID) + } + + summaryID, err := ce.compactLeaf(ctx, convID) + if err != nil { + t.Fatalf("compactLeaf: %v", err) + } + if summaryID == nil { + t.Fatal("expected compactLeaf to compact with custom fresh tail size") + } +} + func TestCompactLeafNoCandidate(t *testing.T) { ce, _, convID := newTestCompactionEngine(t) ctx := context.Background() diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go index f584788ce..a2a1a205a 100644 --- a/pkg/seahorse/short_engine.go +++ b/pkg/seahorse/short_engine.go @@ -20,6 +20,15 @@ type Config struct { DBPath string `json:"dbPath"` IgnoreSessionPatterns []string `json:"ignoreSessionPatterns,omitempty"` StatelessSessionPatterns []string `json:"statelessSessionPatterns,omitempty"` + FreshTailSize int `json:"fresh_tail_size,omitempty"` +} + +// GetFreshTailSize returns the configured fresh tail size or the default. +func (c Config) GetFreshTailSize() int { + if c.FreshTailSize > 0 { + return c.FreshTailSize + } + return FreshTailCount } // CompleteFn is the LLM completion function type. diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index d64634fb7..2d9cdb0c6 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -172,6 +172,16 @@ func TestNewEngineWithPatterns(t *testing.T) { } } +func TestConfigGetFreshTailSize(t *testing.T) { + if got := (Config{}).GetFreshTailSize(); got != FreshTailCount { + t.Errorf("default fresh tail size = %d, want %d", got, FreshTailCount) + } + + if got := (Config{FreshTailSize: 7}).GetFreshTailSize(); got != 7 { + t.Errorf("configured fresh tail size = %d, want 7", got) + } +} + // --- Ingest --- func TestEngineIngest(t *testing.T) {