rony-llm-agent/pkg/rag/autocapture.go

89 lines
3 KiB
Go
Raw Normal View History

package rag
import (
"context"
"fmt"
"strings"
"time"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
)
// DefaultCapturePrompt is the summarization instruction EpisodeCapture uses
// when Config doesn't provide one. Consumers localize it by passing their
// own (e.g. the Rony harness passes a Spanish prompt).
const DefaultCapturePrompt = "Summarize the following exchange between a user and an AI assistant " +
"in 1-2 sentences, in the past tense, focusing on what was asked and what was done or answered. " +
"Respond ONLY with the summary, no headers or extra commentary."
// captureMaxInputChars bounds how much of the turn is sent to the
// summarizing LLM. Auto-capture runs after every successful turn, so its
// cost must stay small and constant — the start of a long reply carries the
// gist; the tail of a truncated one rarely changes the 1-2 sentence summary.
const captureMaxInputChars = 6000
// EpisodeCapture implements Phase 2 §3.5 auto-capture: at the end of a
// successful turn, an LLM (ideally a small/local one — this runs on every
// turn) condenses the exchange into a 1-2 sentence event and stores it as
// episodic memory, so future sessions can recall "what happened" without the
// user ever having asked to save anything.
type EpisodeCapture struct {
Memory Memory
LLM llm.LLMClient
ProjectID string
// Prompt overrides DefaultCapturePrompt (e.g. for localization).
Prompt string
}
// Capture summarizes one finished turn and stores it as an episodic
// fragment. toolsUsed (may be empty) is recorded in metadata so a recalled
// episode also says how the work was done. Callers typically run this in a
// background goroutine with its own timeout — a capture failure should never
// block or break the turn that just finished.
func (c *EpisodeCapture) Capture(ctx context.Context, userInput, assistantReply string, toolsUsed ...string) error {
if c == nil || c.Memory == nil || c.LLM == nil {
return fmt.Errorf("episode capture: memory and llm are required")
}
if strings.TrimSpace(userInput) == "" || strings.TrimSpace(assistantReply) == "" {
return fmt.Errorf("episode capture: nothing to capture")
}
prompt := c.Prompt
if prompt == "" {
prompt = DefaultCapturePrompt
}
transcript := fmt.Sprintf("User: %s\n\nAssistant: %s", userInput, assistantReply)
if len(transcript) > captureMaxInputChars {
transcript = transcript[:captureMaxInputChars]
}
resp, err := c.LLM.Generate(ctx, llm.CompletionRequest{
Messages: []llm.Message{
{Role: llm.RoleSystem, Content: prompt},
{Role: llm.RoleUser, Content: transcript},
},
})
if err != nil {
return fmt.Errorf("episode capture: summarize: %w", err)
}
summary := strings.TrimSpace(resp.Content)
if summary == "" {
return fmt.Errorf("episode capture: empty summary")
}
metadata := map[string]string{
"date": time.Now().Format("2006-01-02"),
}
if len(toolsUsed) > 0 {
metadata["tools"] = strings.Join(toolsUsed, ",")
}
return c.Memory.Add(ctx, Fragment{
Content: summary,
Type: MemoryEpisodic,
ProjectID: c.ProjectID,
Metadata: metadata,
})
}