253 lines
6.7 KiB
Go
253 lines
6.7 KiB
Go
package guardian
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"reasonix/internal/provider"
|
|
)
|
|
|
|
// TranscriptEntry is one simplified conversation entry for guardian review.
|
|
type TranscriptEntry struct {
|
|
Kind string // "user" | "assistant" | "tool"
|
|
Text string
|
|
}
|
|
|
|
// TranscriptCursor remembers which transcript entries have already been sent to
|
|
// the guardian session so subsequent reviews can send only the delta.
|
|
type TranscriptCursor struct {
|
|
HistoryVersion int // agent session RewriteVersion at cursor time
|
|
EntryCount int // how many entries have already been sent
|
|
}
|
|
|
|
const (
|
|
maxMessageEntryTokens = 2000 // per-entry cap for user/assistant
|
|
maxToolEntryTokens = 1000 // per-entry cap for tool call/result
|
|
maxMessageTranscript = 10000 // total token budget for user/assistant entries
|
|
maxToolTranscript = 10000 // total token budget for tool entries
|
|
maxRecentEntries = 40 // max non-user entries from the tail
|
|
)
|
|
|
|
// ExtractTranscript builds a compact transcript from the agent session messages
|
|
// suitable for guardian review. Returns entries in chronological order.
|
|
func ExtractTranscript(msgs []provider.Message) []TranscriptEntry {
|
|
var entries []TranscriptEntry
|
|
for _, m := range msgs {
|
|
switch m.Role {
|
|
case provider.RoleSystem:
|
|
// skip — guardian gets its own system prompt
|
|
continue
|
|
case provider.RoleUser:
|
|
if text := strings.TrimSpace(m.Content); text != "" {
|
|
entries = append(entries, TranscriptEntry{Kind: "user", Text: text})
|
|
}
|
|
case provider.RoleAssistant:
|
|
text := m.Content
|
|
if text == "" && len(m.ToolCalls) > 0 {
|
|
// assistant turn that only issued tool calls — include as "tool_calls"
|
|
for _, tc := range m.ToolCalls {
|
|
entries = append(entries, TranscriptEntry{
|
|
Kind: "tool",
|
|
Text: fmt.Sprintf("tool %s call: %s", tc.Name, firstRunesStr(tc.Arguments, 500)),
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
entries = append(entries, TranscriptEntry{Kind: "assistant", Text: text})
|
|
case provider.RoleTool:
|
|
text := strings.TrimSpace(m.Content)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
label := fmt.Sprintf("tool %s result", m.Name)
|
|
entries = append(entries, TranscriptEntry{Kind: "tool", Text: label + ": " + text})
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// renderTranscript selects and formats entries for guardian prompt inclusion.
|
|
// Returns the rendered transcript lines and an omission-note (non-empty when some
|
|
// entries were dropped due to budget constraints).
|
|
func renderTranscript(entries []TranscriptEntry) ([]string, string) {
|
|
if len(entries) == 0 {
|
|
return []string{"<no retained transcript entries>"}, ""
|
|
}
|
|
|
|
// Pre-compute rendered text and estimated token counts for every entry.
|
|
type rendered struct {
|
|
text string
|
|
index int
|
|
toks int
|
|
}
|
|
var all []rendered
|
|
for i, e := range entries {
|
|
tokCap := maxMessageEntryTokens
|
|
if e.Kind == "tool" {
|
|
tokCap = maxToolEntryTokens
|
|
}
|
|
text, _ := truncateText(e.Text, tokCap)
|
|
line := fmt.Sprintf("[%d] %s: %s", i+1, e.Kind, text)
|
|
toks := estimateTokens(line)
|
|
all = append(all, rendered{text: line, index: i, toks: toks})
|
|
}
|
|
|
|
// Select entries with user-anchored, tool-separated budgets.
|
|
included := make([]bool, len(entries))
|
|
msgToks := 0
|
|
toolToks := 0
|
|
|
|
// Find user entry indices.
|
|
var userIdx []int
|
|
for i, e := range entries {
|
|
if e.Kind == "user" {
|
|
userIdx = append(userIdx, i)
|
|
}
|
|
}
|
|
|
|
// Always keep the first user entry (anchor).
|
|
if len(userIdx) > 0 && userIdx[0] < len(all) {
|
|
first := userIdx[0]
|
|
included[first] = true
|
|
msgToks += all[first].toks
|
|
}
|
|
|
|
// Always keep the last user entry (anchor), if different.
|
|
if len(userIdx) > 1 && userIdx[len(userIdx)-1] != userIdx[0] {
|
|
last := userIdx[len(userIdx)-1]
|
|
if last < len(all) && !included[last] && msgToks+all[last].toks <= maxMessageTranscript {
|
|
included[last] = true
|
|
msgToks += all[last].toks
|
|
}
|
|
}
|
|
|
|
// Fill remaining message budget with user entries from newest to oldest.
|
|
for i := len(userIdx) - 1; i >= 0; i-- {
|
|
idx := userIdx[i]
|
|
if idx >= len(all) || included[idx] {
|
|
continue
|
|
}
|
|
if msgToks+all[idx].toks > maxMessageTranscript {
|
|
continue
|
|
}
|
|
included[idx] = true
|
|
msgToks += all[idx].toks
|
|
}
|
|
|
|
// Add recent non-user entries from newest to oldest.
|
|
recent := 0
|
|
for i := len(entries) - 1; i >= 0 && recent < maxRecentEntries; i-- {
|
|
if included[i] || entries[i].Kind == "user" {
|
|
continue
|
|
}
|
|
add := all[i].toks
|
|
if entries[i].Kind == "tool" {
|
|
if toolToks+add > maxToolTranscript {
|
|
continue
|
|
}
|
|
toolToks += add
|
|
} else {
|
|
if msgToks+add > maxMessageTranscript {
|
|
continue
|
|
}
|
|
msgToks += add
|
|
}
|
|
included[i] = true
|
|
recent++
|
|
}
|
|
|
|
// Build the result.
|
|
var lines []string
|
|
for i, r := range all {
|
|
if included[i] {
|
|
lines = append(lines, r.text)
|
|
}
|
|
}
|
|
omitted := false
|
|
for _, b := range included {
|
|
if !b {
|
|
omitted = true
|
|
break
|
|
}
|
|
}
|
|
if omitted {
|
|
return lines, "Some conversation entries were omitted."
|
|
}
|
|
return lines, ""
|
|
}
|
|
|
|
// FormatTranscript returns a complete guardian transcript prompt block.
|
|
func FormatTranscript(entries []TranscriptEntry) string {
|
|
lines, omission := renderTranscript(entries)
|
|
var b strings.Builder
|
|
b.WriteString(">>> TRANSCRIPT START\n")
|
|
for _, line := range lines {
|
|
b.WriteString(line)
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(">>> TRANSCRIPT END\n")
|
|
if omission != "" {
|
|
b.WriteByte('\n')
|
|
b.WriteString(omission)
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// truncateText trims text to roughly tokCap tokens, keeping head and tail.
|
|
// Returns the truncated text and whether truncation occurred.
|
|
func truncateText(content string, tokCap int) (string, bool) {
|
|
if content == "" {
|
|
return content, false
|
|
}
|
|
est := estimateTokens(content)
|
|
if est <= tokCap {
|
|
return content, false
|
|
}
|
|
// Simple truncation: keep head + tail with marker.
|
|
maxBytes := tokCap * 4 // rough byte estimate
|
|
if len(content) <= maxBytes {
|
|
return content, false
|
|
}
|
|
marker := "<truncated>"
|
|
avail := maxBytes - len(marker)
|
|
if avail <= 0 {
|
|
return marker, true
|
|
}
|
|
head := avail / 2
|
|
tail := avail - head
|
|
|
|
// Convert to runes for safe boundary alignment.
|
|
runes := []rune(content)
|
|
// Estimate how many runes fit in head/tail bytes (conservative: assume
|
|
// max 4 bytes per rune).
|
|
headRunes := head / 4
|
|
if headRunes > len(runes) {
|
|
headRunes = len(runes)
|
|
}
|
|
tailRunes := tail / 4
|
|
if tailRunes > len(runes)-headRunes {
|
|
tailRunes = len(runes) - headRunes
|
|
}
|
|
if tailRunes < 0 {
|
|
tailRunes = 0
|
|
}
|
|
return string(runes[:headRunes]) + marker + string(runes[len(runes)-tailRunes:]), true
|
|
}
|
|
|
|
// estimateTokens gives a rough token count for display purposes (not API-accurate).
|
|
func estimateTokens(s string) int {
|
|
bytes := len(s)
|
|
runes := utf8.RuneCountInString(s)
|
|
byBytes := (bytes + 3) / 4 // ~4 chars per token for English
|
|
if runes > byBytes {
|
|
return runes
|
|
}
|
|
return byBytes
|
|
}
|