返回 DeepSeek-Reasonix
transcript.go
根目录 / internal / guardian / transcript.go
1 package guardian
2
3 import (
4 "fmt"
5 "slices"
6 "strings"
7 "unicode/utf8"
8
9 "reasonix/internal/provider"
10 )
11
12 // TranscriptEntry is one simplified conversation entry for guardian review.
13 type TranscriptEntry struct {
14 Kind string // "user" | "assistant" | "tool"
15 Text string
16 }
17
18 // TranscriptCursor remembers which transcript entries have already been sent to
19 // the guardian session so subsequent reviews can send only the delta.
20 type TranscriptCursor struct {
21 HistoryVersion int // agent session RewriteVersion at cursor time
22 EntryCount int // how many entries have already been sent
23 }
24
25 const (
26 maxMessageEntryTokens = 2000 // per-entry cap for user/assistant
27 maxToolEntryTokens = 1000 // per-entry cap for tool call/result
28 maxMessageTranscript = 10000 // total token budget for user/assistant entries
29 maxToolTranscript = 10000 // total token budget for tool entries
30 maxRecentEntries = 40 // max non-user entries from the tail
31 )
32
33 // ExtractTranscript builds a compact transcript from the agent session messages
34 // suitable for guardian review. Returns entries in chronological order.
35 func ExtractTranscript(msgs []provider.Message) []TranscriptEntry {
36 var entries []TranscriptEntry
37 for _, m := range msgs {
38 switch m.Role {
39 case provider.RoleSystem:
40 // skip — guardian gets its own system prompt
41 continue
42 case provider.RoleUser:
43 if text := strings.TrimSpace(m.Content); text != "" {
44 entries = append(entries, TranscriptEntry{Kind: "user", Text: text})
45 }
46 case provider.RoleAssistant:
47 text := m.Content
48 if text == "" && len(m.ToolCalls) > 0 {
49 // assistant turn that only issued tool calls — include as "tool_calls"
50 for _, tc := range m.ToolCalls {
51 entries = append(entries, TranscriptEntry{
52 Kind: "tool",
53 Text: fmt.Sprintf("tool %s call: %s", tc.Name, firstRunesStr(tc.Arguments, 500)),
54 })
55 }
56 continue
57 }
58 text = strings.TrimSpace(text)
59 if text == "" {
60 continue
61 }
62 entries = append(entries, TranscriptEntry{Kind: "assistant", Text: text})
63 case provider.RoleTool:
64 text := strings.TrimSpace(m.Content)
65 if text == "" {
66 continue
67 }
68 label := fmt.Sprintf("tool %s result", m.Name)
69 entries = append(entries, TranscriptEntry{Kind: "tool", Text: label + ": " + text})
70 }
71 }
72 return entries
73 }
74
75 // renderTranscript selects and formats entries for guardian prompt inclusion.
76 // Returns the rendered transcript lines and an omission-note (non-empty when some
77 // entries were dropped due to budget constraints).
78 func renderTranscript(entries []TranscriptEntry) ([]string, string) {
79 if len(entries) == 0 {
80 return []string{"<no retained transcript entries>"}, ""
81 }
82
83 // Pre-compute rendered text and estimated token counts for every entry.
84 type rendered struct {
85 text string
86 index int
87 toks int
88 }
89 var all []rendered
90 for i, e := range entries {
91 tokCap := maxMessageEntryTokens
92 if e.Kind == "tool" {
93 tokCap = maxToolEntryTokens
94 }
95 text, _ := truncateText(e.Text, tokCap)
96 line := fmt.Sprintf("[%d] %s: %s", i+1, e.Kind, text)
97 toks := estimateTokens(line)
98 all = append(all, rendered{text: line, index: i, toks: toks})
99 }
100
101 // Select entries with user-anchored, tool-separated budgets.
102 included := make([]bool, len(entries))
103 msgToks := 0
104 toolToks := 0
105
106 // Find user entry indices.
107 var userIdx []int
108 for i, e := range entries {
109 if e.Kind == "user" {
110 userIdx = append(userIdx, i)
111 }
112 }
113
114 // Always keep the first user entry (anchor).
115 if len(userIdx) > 0 && userIdx[0] < len(all) {
116 first := userIdx[0]
117 included[first] = true
118 msgToks += all[first].toks
119 }
120
121 // Always keep the last user entry (anchor), if different.
122 if len(userIdx) > 1 && userIdx[len(userIdx)-1] != userIdx[0] {
123 last := userIdx[len(userIdx)-1]
124 if last < len(all) && !included[last] && msgToks+all[last].toks <= maxMessageTranscript {
125 included[last] = true
126 msgToks += all[last].toks
127 }
128 }
129
130 // Fill remaining message budget with user entries from newest to oldest.
131 for _, v := range slices.Backward(userIdx) {
132 idx := v
133 if idx >= len(all) || included[idx] {
134 continue
135 }
136 if msgToks+all[idx].toks > maxMessageTranscript {
137 continue
138 }
139 included[idx] = true
140 msgToks += all[idx].toks
141 }
142
143 // Add recent non-user entries from newest to oldest.
144 recent := 0
145 for i := len(entries) - 1; i >= 0 && recent < maxRecentEntries; i-- {
146 if included[i] || entries[i].Kind == "user" {
147 continue
148 }
149 add := all[i].toks
150 if entries[i].Kind == "tool" {
151 if toolToks+add > maxToolTranscript {
152 continue
153 }
154 toolToks += add
155 } else {
156 if msgToks+add > maxMessageTranscript {
157 continue
158 }
159 msgToks += add
160 }
161 included[i] = true
162 recent++
163 }
164
165 // Build the result.
166 var lines []string
167 for i, r := range all {
168 if included[i] {
169 lines = append(lines, r.text)
170 }
171 }
172 omitted := false
173 for _, b := range included {
174 if !b {
175 omitted = true
176 break
177 }
178 }
179 if omitted {
180 return lines, "Some conversation entries were omitted."
181 }
182 return lines, ""
183 }
184
185 // FormatTranscript returns a complete guardian transcript prompt block.
186 func FormatTranscript(entries []TranscriptEntry) string {
187 lines, omission := renderTranscript(entries)
188 var b strings.Builder
189 b.WriteString(">>> TRANSCRIPT START\n")
190 for _, line := range lines {
191 b.WriteString(line)
192 b.WriteByte('\n')
193 }
194 b.WriteString(">>> TRANSCRIPT END\n")
195 if omission != "" {
196 b.WriteByte('\n')
197 b.WriteString(omission)
198 b.WriteByte('\n')
199 }
200 return b.String()
201 }
202
203 // truncateText trims text to roughly tokCap tokens, keeping head and tail.
204 // Returns the truncated text and whether truncation occurred.
205 func truncateText(content string, tokCap int) (string, bool) {
206 if content == "" {
207 return content, false
208 }
209 est := estimateTokens(content)
210 if est <= tokCap {
211 return content, false
212 }
213 // Simple truncation: keep head + tail with marker.
214 maxBytes := tokCap * 4 // rough byte estimate
215 if len(content) <= maxBytes {
216 return content, false
217 }
218 marker := "<truncated>"
219 avail := maxBytes - len(marker)
220 if avail <= 0 {
221 return marker, true
222 }
223 head := avail / 2
224 tail := avail - head
225
226 // Convert to runes for safe boundary alignment.
227 runes := []rune(content)
228 // Estimate how many runes fit in head/tail bytes (conservative: assume
229 // max 4 bytes per rune).
230 headRunes := min(head/4, len(runes))
231 tailRunes := max(min(tail/4, len(runes)-headRunes), 0)
232 return string(runes[:headRunes]) + marker + string(runes[len(runes)-tailRunes:]), true
233 }
234
235 // estimateTokens gives a rough token count for display purposes (not API-accurate).
236 func estimateTokens(s string) int {
237 bytes := len(s)
238 runes := utf8.RuneCountInString(s)
239 byBytes := (bytes + 3) / 4 // ~4 chars per token for English
240 if runes > byBytes {
241 return runes
242 }
243 return byBytes
244 }
245
245 lines GO