This commit is contained in:
Daniel Lim 2026-04-24 16:22:27 +08:00 committed by GitHub
commit 362bb55016
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 148 additions and 10 deletions

View file

@ -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,

View file

@ -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)
}

View file

@ -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

View file

@ -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
}

View file

@ -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()

View file

@ -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
}

View file

@ -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()

View file

@ -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.

View file

@ -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) {