mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
15a70ac45c
* feat(seahorse): implement short-term memory engine of seahorse Add pkg/seahorse/ module implementing a SQLite-backed DAG-based summary hierarchy for context management, ported from lossless-claw's LCM design: - types.go + short_constants.go: core types (Message, Summary, Conversation, ContextItem) and configuration constants (fanout, token targets, thresholds) - migration.go: idempotent DB schema with FTS5 trigram tokenizer for CJK - store.go: full SQLite CRUD (conversations, messages, summaries DAG, context_items with ordinal gap numbering, FTS5 search) - short_engine.go: Engine lifecycle (NewEngine, Ingest, Assemble, Compact), session pattern filtering (ignore/stateless glob→regex compilation), per-session mutex via sync.Map - short_assembler.go: budget-aware context assembly with fresh tail protection (32 messages), oldest-first eviction, summary XML formatting, RebuildContextItems - short_compaction.go: leaf compaction (messages→summary) and condensed compaction (summaries→higher-level summary), 3-level LLM escalation, CompactUntilUnder for emergency overflow - short_retrieval.go: lookupByID, FTS5/LIKE search, recursive expand with token cap - context_seahorse.go: agent.ContextManager adapter, registered as "seahorse", provider↔seahorse message type conversion (ToolCalls, tool_result) * fix(seahorse): correct 3 adapter bugs in context management - TokenCount: use full message (Content+ToolCalls+Media) instead of Content-only - Empty Content: rebuild Content from tool_result Parts when stored empty - Duplicate summaries: summaries only in Summary field, not in History messages - Grep: fix SearchResult.Snippet→Content for summaries - Schema: fix FTS5 SQL uses VIRTUAL TABLE not TEMP TABLE - TestFTS5SQLConstants: verify FTS5 SQL syntax correctness - Test: fix flaky TestCompactLeaf * fix(agent): ingest steering messages into seahorse SQLite Steering messages were only persisted to session JSONL but not ingested into seahorse SQLite, causing them to be missing from context assembly. Added `ts.ingestMessage(turnCtx, al, pm)` call in the steering message injection block alongside the existing JSONL persistence. Test: TestSeahorseSteeringMessageIngested verifies steering messages appear in seahorse SQLite DB after being processed. * fix(seahorse): address 3 blocking bugs from code review - Fix resequenceContextItemsTx scan error handling (store.go:850) Changed `return err` to `return scanErr` to properly propagate scan errors instead of returning nil (which silently corrupts data) - Fix sql.NullString for INTEGER column (store.go:847) Changed `mid` from sql.NullString to sql.NullInt64 since message_id is INTEGER in schema. Removed unnecessary strconv.ParseInt call. - Fix compactCondensed fallback deleting non-candidate items Added ReplaceContextItemsWithSummary method for per-item deletion when candidates are not contiguous in ordinal space. Optimized to use range deletion when candidates are consecutive. * fix(seahorse): pass Budget to Compact for correct condensed threshold Issue #4 from PR review: When Budget was not passed to seahorse.Compact, it defaulted to `tokensBefore * 0.75`, making `tokensBefore > budget` always true and causing condensed compaction to trigger unnecessarily. Changes: - context_seahorse.go: Forward Budget from CompactRequest to CompactInput - loop.go: Pass Budget (ContextWindow) in all 3 Compact calls - Add test verifying condensed is skipped when tokens < threshold - Fix lint issues in store.go and store_test.go * fix(seahorse): add mutex for assembler lazy initialization Issue #5 from PR review: The check-then-create pattern for e.assembler was a data race when multiple goroutines called Assemble() concurrently: if e.assembler == nil { e.assembler = &Assembler{...} } Changes: - Add assemblerMu sync.Mutex to Engine struct - Add initAssemblerOnce() using double-checked locking (same pattern as initCompactionOnce) - Add TestAssemblerLazyInitRace to verify thread-safety * fix(seahorse): handle non-consecutive depths in selectShallowestCondensationCandidate Issue #8 from PR review: the loop iterated depth 0, 1, 2... assuming consecutive keys, but break when key was missing caused deeper depths to never be checked. Fix: collect all existing depth keys, sort, then iterate in order. * fix(seahorse): wrap DeleteMessagesAfterID and appendContextItems in transactions - DeleteMessagesAfterID: wrap all DELETE operations in a transaction for atomicity, remove redundant manual FTS delete (handled by trigger) - appendContextItems: use transaction to fix read-then-write race condition - Add GetMaxOrdinalTx and resolveItemTokenCountTx for transaction-scoped queries - Remove unused resolveItemTokenCount function Fixes PR review issues 6 and 7. * fix(seahorse): derive readable content from Parts and cap CompactUntilUnder iterations - Derive readable content from MessageParts in AddMessageWithParts so FTS5 indexing and summary formatting can access tool call information - formatMessagesForSummary and truncateSummary now fall back to Parts when Content is empty, fixing blank summaries for Part-based messages - Add MaxCompactIterations (20) to prevent CompactUntilUnder infinite loops; exceeded iterations are logged as warnings
268 lines
7.5 KiB
Go
268 lines
7.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
|
"github.com/sipeed/picoclaw/pkg/seahorse"
|
|
"github.com/sipeed/picoclaw/pkg/session"
|
|
"github.com/sipeed/picoclaw/pkg/tokenizer"
|
|
)
|
|
|
|
// seahorseContextManager adapts seahorse.Engine to agent.ContextManager.
|
|
type seahorseContextManager struct {
|
|
engine *seahorse.Engine
|
|
sessions session.SessionStore // for startup bootstrap
|
|
}
|
|
|
|
// newSeahorseContextManager creates a seahorse-backed ContextManager.
|
|
func newSeahorseContextManager(_ json.RawMessage, al *AgentLoop) (ContextManager, error) {
|
|
if al == nil {
|
|
return nil, fmt.Errorf("seahorse: AgentLoop is required")
|
|
}
|
|
|
|
// Resolve workspace for DB path
|
|
// DB stores session data, so it goes in sessions/ directory
|
|
agent := al.registry.GetDefaultAgent()
|
|
dbPath := agent.Workspace + "/sessions/seahorse.db"
|
|
|
|
// Create CompleteFn from provider
|
|
completeFn := providerToCompleteFn(agent.Provider, agent.Model)
|
|
|
|
// Create engine
|
|
engine, err := seahorse.NewEngine(seahorse.Config{
|
|
DBPath: dbPath,
|
|
}, completeFn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("seahorse: create engine: %w", err)
|
|
}
|
|
|
|
mgr := &seahorseContextManager{
|
|
engine: engine,
|
|
sessions: agent.Sessions,
|
|
}
|
|
|
|
// Register seahorse tools with the agent's tool registry
|
|
retrieval := mgr.engine.GetRetrieval()
|
|
al.RegisterTool(seahorse.NewGrepTool(retrieval))
|
|
al.RegisterTool(seahorse.NewExpandTool(retrieval))
|
|
|
|
// Bootstrap all existing sessions at startup
|
|
if agent.Sessions != nil {
|
|
ctx := context.Background()
|
|
for _, sessionKey := range agent.Sessions.ListSessions() {
|
|
mgr.bootstrapSession(ctx, sessionKey)
|
|
}
|
|
}
|
|
|
|
return mgr, nil
|
|
}
|
|
|
|
// providerToCompleteFn wraps providers.LLMProvider as a seahorse.CompleteFn.
|
|
func providerToCompleteFn(provider providers.LLMProvider, model string) seahorse.CompleteFn {
|
|
return func(ctx context.Context, prompt string, opts seahorse.CompleteOptions) (string, error) {
|
|
resp, err := provider.Chat(
|
|
ctx,
|
|
[]providers.Message{{Role: "user", Content: prompt}},
|
|
nil, // no tools for summarization
|
|
model,
|
|
map[string]any{
|
|
"max_tokens": opts.MaxTokens,
|
|
"temperature": opts.Temperature,
|
|
"prompt_cache_key": "seahorse",
|
|
},
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return resp.Content, nil
|
|
}
|
|
}
|
|
|
|
// Assemble builds budget-aware context from seahorse SQLite.
|
|
func (m *seahorseContextManager) Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("seahorse assemble: nil request")
|
|
}
|
|
|
|
budget := req.Budget
|
|
if budget <= 0 {
|
|
budget = 100000
|
|
}
|
|
|
|
// Reserve space for model response (spec lines 1400-1410)
|
|
effectiveBudget := budget - req.MaxTokens
|
|
if effectiveBudget <= 0 {
|
|
// MaxTokens >= budget is a configuration problem
|
|
// Use 50% as minimum to avoid guaranteed overflow
|
|
logger.WarnCF("agent", "MaxTokens >= budget, using 50% fallback",
|
|
map[string]any{"budget": budget, "max_tokens": req.MaxTokens})
|
|
effectiveBudget = budget / 2
|
|
}
|
|
|
|
result, err := m.engine.Assemble(ctx, req.SessionKey, seahorse.AssembleInput{
|
|
Budget: effectiveBudget,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("seahorse assemble: %w", err)
|
|
}
|
|
|
|
history := seahorseToProviderMessages(result)
|
|
|
|
// Summary is already formatted as XML with system prompt addition by assembler
|
|
return &AssembleResponse{
|
|
History: history,
|
|
Summary: result.Summary,
|
|
}, nil
|
|
}
|
|
|
|
// Compact compresses conversation history via seahorse summarization.
|
|
func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactRequest) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
|
|
// For retry (LLM overflow), use aggressive CompactUntilUnder to guarantee
|
|
// context shrinks below budget (spec lines ~1410).
|
|
if req.Reason == ContextCompressReasonRetry && req.Budget > 0 {
|
|
_, err := m.engine.CompactUntilUnder(ctx, req.SessionKey, req.Budget)
|
|
return err
|
|
}
|
|
|
|
_, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{
|
|
Force: req.Reason == ContextCompressReasonRetry,
|
|
Budget: &req.Budget,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Ingest records a message into seahorse SQLite.
|
|
// All existing sessions are bootstrapped at startup, so this only ingests new messages.
|
|
func (m *seahorseContextManager) Ingest(ctx context.Context, req *IngestRequest) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
|
|
msg := providerToSeahorseMessage(req.Message)
|
|
_, err := m.engine.Ingest(ctx, req.SessionKey, []seahorse.Message{msg})
|
|
return err
|
|
}
|
|
|
|
// bootstrapSession reconciles JSONL session history into seahorse SQLite.
|
|
func (m *seahorseContextManager) bootstrapSession(ctx context.Context, sessionKey string) {
|
|
if m.sessions == nil {
|
|
return
|
|
}
|
|
|
|
history := m.sessions.GetHistory(sessionKey)
|
|
if len(history) == 0 {
|
|
return
|
|
}
|
|
|
|
// Convert provider messages to seahorse messages
|
|
msgs := make([]seahorse.Message, len(history))
|
|
for i, h := range history {
|
|
msgs[i] = providerToSeahorseMessage(h)
|
|
}
|
|
|
|
if err := m.engine.Bootstrap(ctx, sessionKey, msgs); err != nil {
|
|
logger.WarnCF("seahorse", "bootstrap", map[string]any{
|
|
"session": sessionKey,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// providerToSeahorseMessage converts a providers.Message to a seahorse.Message.
|
|
func providerToSeahorseMessage(msg protocoltypes.Message) seahorse.Message {
|
|
result := seahorse.Message{
|
|
Role: msg.Role,
|
|
Content: msg.Content,
|
|
ReasoningContent: msg.ReasoningContent,
|
|
TokenCount: tokenizer.EstimateMessageTokens(msg),
|
|
}
|
|
|
|
// Convert ToolCalls → MessageParts
|
|
for _, tc := range msg.ToolCalls {
|
|
part := seahorse.MessagePart{
|
|
Type: "tool_use",
|
|
Name: tc.Function.Name,
|
|
Arguments: tc.Function.Arguments,
|
|
ToolCallID: tc.ID,
|
|
}
|
|
result.Parts = append(result.Parts, part)
|
|
}
|
|
|
|
// Convert tool result
|
|
if msg.ToolCallID != "" {
|
|
part := seahorse.MessagePart{
|
|
Type: "tool_result",
|
|
ToolCallID: msg.ToolCallID,
|
|
Text: msg.Content,
|
|
}
|
|
result.Parts = append(result.Parts, part)
|
|
}
|
|
|
|
// Convert media attachments
|
|
for _, mediaURI := range msg.Media {
|
|
part := seahorse.MessagePart{
|
|
Type: "media",
|
|
MediaURI: mediaURI,
|
|
}
|
|
result.Parts = append(result.Parts, part)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// seahorseToProviderMessages converts a seahorse.AssembleResult to []providers.Message.
|
|
func seahorseToProviderMessages(result *seahorse.AssembleResult) []protocoltypes.Message {
|
|
messages := make([]protocoltypes.Message, 0, len(result.Messages))
|
|
|
|
// Convert assembled messages (which already include summary XML messages)
|
|
for _, msg := range result.Messages {
|
|
pm := protocoltypes.Message{
|
|
Role: msg.Role,
|
|
Content: msg.Content,
|
|
ReasoningContent: msg.ReasoningContent,
|
|
}
|
|
|
|
// Reconstruct ToolCalls from parts
|
|
for _, part := range msg.Parts {
|
|
if part.Type == "tool_use" {
|
|
pm.ToolCalls = append(pm.ToolCalls, protocoltypes.ToolCall{
|
|
ID: part.ToolCallID,
|
|
Type: "function", // Required by OpenAI-compatible APIs (GLM, etc.)
|
|
Function: &protocoltypes.FunctionCall{
|
|
Name: part.Name,
|
|
Arguments: part.Arguments,
|
|
},
|
|
})
|
|
}
|
|
if part.Type == "tool_result" {
|
|
pm.ToolCallID = part.ToolCallID
|
|
if pm.Content == "" && part.Text != "" {
|
|
pm.Content = part.Text
|
|
}
|
|
}
|
|
if part.Type == "media" && part.MediaURI != "" {
|
|
pm.Media = append(pm.Media, part.MediaURI)
|
|
}
|
|
}
|
|
|
|
messages = append(messages, pm)
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
func init() {
|
|
if err := RegisterContextManager("seahorse", newSeahorseContextManager); err != nil {
|
|
panic(fmt.Sprintf("register seahorse context manager: %v", err))
|
|
}
|
|
}
|