Files
kandev/apps/backend/internal/agent/runtime/lifecycle/session_history.go
T

318 lines
9.4 KiB
Go

// Package lifecycle provides session history management for ACP agents.
// This enables session context injection for agents that don't support session/load.
package lifecycle
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/common/logger"
)
// History entry type constants.
const (
historyEntryTypeToolCall = "tool_call"
resumeConversationMessageLimit = 50
)
// SessionHistoryManager stores and retrieves session history for context injection.
// This enables the fork_session pattern for ACP agents that don't support session/load.
//
// Session history is stored as JSONL files at: {baseDir}/{sessionID}.jsonl
// Each line is a HistoryEntry containing the event type, timestamp, and relevant content.
type SessionHistoryManager struct {
baseDir string
logger *logger.Logger
mu sync.RWMutex
}
// HistoryEntry represents a single entry in the session history.
type HistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"` // "user_message", "agent_message", "tool_call", "tool_result"
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolStatus string `json:"tool_status,omitempty"`
SessionID string `json:"session_id,omitempty"`
OperationID string `json:"operation_id,omitempty"`
}
// NewSessionHistoryManager creates a new SessionHistoryManager.
// The baseDir is the directory where session history files will be stored.
// If baseDir is empty, it defaults to dataDir+"/sessions".
func NewSessionHistoryManager(baseDir string, dataDir string, log *logger.Logger) (*SessionHistoryManager, error) {
if baseDir == "" {
baseDir = filepath.Join(dataDir, "sessions")
}
// Ensure directory exists
if err := os.MkdirAll(baseDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create session history directory: %w", err)
}
return &SessionHistoryManager{
baseDir: baseDir,
logger: log.WithFields(zap.String("component", "session-history")),
}, nil
}
// historyFilePath returns the path to the history file for a session.
func (m *SessionHistoryManager) historyFilePath(sessionID string) string {
// Sanitize session ID for use as filename
safeID := strings.ReplaceAll(sessionID, "/", "_")
safeID = strings.ReplaceAll(safeID, "\\", "_")
return filepath.Join(m.baseDir, safeID+".jsonl")
}
// AppendEntry appends a single history entry to the session's history file.
func (m *SessionHistoryManager) AppendEntry(sessionID string, entry HistoryEntry) error {
if sessionID == "" {
return fmt.Errorf("session ID is required")
}
m.mu.Lock()
defer m.mu.Unlock()
filePath := m.historyFilePath(sessionID)
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open history file: %w", err)
}
defer func() { _ = f.Close() }()
// Set timestamp if not already set
if entry.Timestamp.IsZero() {
entry.Timestamp = time.Now().UTC()
}
entry.SessionID = sessionID
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("failed to marshal history entry: %w", err)
}
if _, err := f.Write(append(data, '\n')); err != nil {
return fmt.Errorf("failed to write history entry: %w", err)
}
m.logger.Debug("appended history entry",
zap.String("session_id", sessionID),
zap.String("type", entry.Type))
return nil
}
// AppendUserMessage appends a user message to the session history.
func (m *SessionHistoryManager) AppendUserMessage(sessionID, message string) error {
return m.AppendEntry(sessionID, HistoryEntry{
Type: "user_message",
Role: "user",
Content: message,
})
}
// AppendAgentMessage appends an agent message to the session history.
func (m *SessionHistoryManager) AppendAgentMessage(sessionID, message string) error {
return m.AppendEntry(sessionID, HistoryEntry{
Type: "agent_message",
Role: "assistant",
Content: message,
})
}
// AppendToolCall appends a tool call to the session history.
func (m *SessionHistoryManager) AppendToolCall(sessionID string, event agentctl.AgentEvent) error {
return m.AppendEntry(sessionID, HistoryEntry{
Type: historyEntryTypeToolCall,
ToolCallID: event.ToolCallID,
ToolName: event.ToolName,
ToolStatus: event.ToolStatus,
})
}
// AppendToolResult appends a tool result to the session history.
func (m *SessionHistoryManager) AppendToolResult(sessionID string, event agentctl.AgentEvent) error {
return m.AppendEntry(sessionID, HistoryEntry{
Type: "tool_result",
ToolCallID: event.ToolCallID,
ToolName: event.ToolName,
ToolStatus: event.ToolStatus,
})
}
// ReadHistory reads all history entries for a session.
func (m *SessionHistoryManager) ReadHistory(sessionID string) ([]HistoryEntry, error) {
if sessionID == "" {
return nil, fmt.Errorf("session ID is required")
}
m.mu.RLock()
defer m.mu.RUnlock()
filePath := m.historyFilePath(sessionID)
f, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No history yet
}
return nil, fmt.Errorf("failed to open history file: %w", err)
}
defer func() { _ = f.Close() }()
var entries []HistoryEntry
scanner := bufio.NewScanner(f)
// Increase buffer size for long lines (tool results can be large)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024) // 1MB max line size
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var entry HistoryEntry
if err := json.Unmarshal(line, &entry); err != nil {
m.logger.Warn("failed to parse history entry, skipping",
zap.String("session_id", sessionID),
zap.Error(err))
continue
}
entries = append(entries, entry)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read history file: %w", err)
}
return entries, nil
}
// GenerateResumeContext generates a "RESUME CONTEXT" prompt from session history.
// This is injected into the first prompt when forking a session.
func (m *SessionHistoryManager) GenerateResumeContext(sessionID, newPrompt string) (string, error) {
entries, err := m.ReadHistory(sessionID)
if err != nil {
return newPrompt, err
}
if len(entries) == 0 {
return newPrompt, nil // No history to inject
}
conversation := selectResumeConversation(entries, resumeConversationMessageLimit)
if len(conversation) == 0 {
return newPrompt, nil
}
// Format history as conversation context. Tool activity and lifecycle
// events are deliberately filtered before the bounded window is selected;
// they must not displace conversation messages from fallback context.
var historyBuilder strings.Builder
for _, entry := range conversation {
switch entry.Type {
case "user_message":
fmt.Fprintf(&historyBuilder, "\n[USER]: %s\n", truncateForContext(entry.Content, 2000))
case "agent_message":
fmt.Fprintf(&historyBuilder, "\n[ASSISTANT]: %s\n", truncateForContext(entry.Content, 2000))
}
}
history := historyBuilder.String()
if history == "" {
return newPrompt, nil
}
// Build the resume context prompt
resumePrompt := fmt.Sprintf(`RESUME CONTEXT FOR CONTINUING TASK
=== EXECUTION HISTORY ===
The following is a summary of the previous conversation in this session:
%s
=== CURRENT REQUEST ===
%s
=== INSTRUCTIONS ===
You are continuing work on the above task. This is a continuation of a previous session.
Please continue from where the previous execution left off, taking into account all the context provided above.
Do not repeat work that was already completed. Build on the existing progress.
`, history, newPrompt)
m.logger.Info("generated resume context",
zap.String("session_id", sessionID),
zap.Int("history_entries", len(entries)),
zap.Int("context_length", len(resumePrompt)))
return resumePrompt, nil
}
func selectResumeConversation(entries []HistoryEntry, limit int) []HistoryEntry {
if limit <= 0 {
return nil
}
conversation := make([]HistoryEntry, 0, min(limit, len(entries)))
for _, entry := range entries {
if (entry.Type != "user_message" && entry.Type != "agent_message") || strings.TrimSpace(entry.Content) == "" {
continue
}
conversation = append(conversation, entry)
if len(conversation) > limit {
conversation = conversation[1:]
}
}
return conversation
}
// HasHistory checks if a session has any stored history.
func (m *SessionHistoryManager) HasHistory(sessionID string) bool {
if sessionID == "" {
return false
}
filePath := m.historyFilePath(sessionID)
info, err := os.Stat(filePath)
if err != nil {
return false
}
return info.Size() > 0
}
// DeleteHistory deletes the history file for a session.
func (m *SessionHistoryManager) DeleteHistory(sessionID string) error {
if sessionID == "" {
return fmt.Errorf("session ID is required")
}
m.mu.Lock()
defer m.mu.Unlock()
filePath := m.historyFilePath(sessionID)
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete history file: %w", err)
}
m.logger.Debug("deleted session history", zap.String("session_id", sessionID))
return nil
}
// truncateForContext truncates a string to a maximum length for context injection.
// This prevents the resume context from becoming too large.
func truncateForContext(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "... [truncated]"
}