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