返回 DeepSeek-Reasonix
preview.go
根目录 / internal / agent / preview.go
1 package agent
2
3 import (
4 "encoding/json"
5 "regexp"
6 "strings"
7
8 "reasonix/internal/provider"
9 )
10
11 // TransientUserBlockTags names every block the host prepends to a user turn as
12 // runtime context rather than something the user typed. Previews, titles, and
13 // the rewind picker strip them; a tag missing from this list leaks raw markup
14 // into the UI, which is how <autoresearch-runtime> surfaced in session titles.
15 //
16 // This is the single source of truth: the strip regex is built from it, and
17 // hasLeadingInjectedBlock walks it. Anything that starts prepending a new block
18 // to user turns belongs here.
19 var TransientUserBlockTags = []string{
20 "response-language",
21 "reasoning-language",
22 "memory-update",
23 "background-jobs",
24 "active-goal",
25 "autoresearch-runtime",
26 "hook-context",
27 "capability-route",
28 "interrupted-turn-recovery",
29 }
30
31 var reTransientUserBlock = buildTransientUserBlockRE(TransientUserBlockTags)
32
33 // buildTransientUserBlockRE matches one leading transient block: an open tag
34 // (with optional attributes), its content, and its own closing tag. The
35 // alternation is generated so the open and close lists cannot drift apart —
36 // spelling them out twice by hand is what let tags go missing from one side.
37 func buildTransientUserBlockRE(tags []string) *regexp.Regexp {
38 alt := strings.Join(tags, "|")
39 return regexp.MustCompile(`(?s)^\s*<(?:` + alt + `)(?:\s+[^>]*)?>.*?</(?:` + alt + `)>\s*\n?`)
40 }
41
42 // stripTrailingDeliveryRuntime removes the exact delivery-runtime marker the
43 // agent appends to user turns in delivery mode (agent.go DeliveryRuntimeMarker).
44 // Unlike the prefix blocks it trails the user text, so preview/title derivation
45 // needs a suffix cut — leaving it produced session titles like
46 // "你是谁? <delivery-run…". The cut is byte-exact rather than a regex: a lazy
47 // pattern anchored at $ would swallow user prose between a literal
48 // "<delivery-runtime>" mention in the text and the real marker at the end.
49 // (The agent never appends the marker when the input already mentions the tag,
50 // so user messages discussing it carry no host suffix at all.)
51 func stripTrailingDeliveryRuntime(s string) string {
52 trimmed := strings.TrimRight(s, " \t\r\n")
53 if cut, ok := strings.CutSuffix(trimmed, DeliveryRuntimeMarker); ok {
54 return strings.TrimRight(cut, " \t\r\n")
55 }
56 return s
57 }
58
59 const memoryCompilerExecutionOpen = "<memory-compiler-execution>"
60
61 var reMemoryCompilerExecution = regexp.MustCompile(`(?s)<memory-compiler-execution>\s*(.*?)\s*</memory-compiler-execution>`)
62
63 // ContainsMemoryCompilerExecution reports whether content includes a Memory v5
64 // execution contract. The Memory v5 compiler was removed, but transcripts
65 // recorded by releases up to v1.17.x may still carry injected contracts in
66 // persisted user messages, so display paths keep unwrapping them. Callers that
67 // prepare user-facing or replayable text should unwrap the block before display
68 // and avoid treating the raw contract as user-authored.
69 func ContainsMemoryCompilerExecution(content string) bool {
70 return strings.Contains(content, memoryCompilerExecutionOpen)
71 }
72
73 // StripTransientUserBlocks removes controller-injected transient XML blocks
74 // from persisted user messages before deriving display text, previews, or
75 // titles. The blocks are sent in user turns so they never affect the stable
76 // prompt prefix, but they should not become user-facing text later.
77 //
78 // The legacy Memory v5 <memory-compiler-execution> block (written by releases
79 // up to v1.17.x before the compiler was removed) is handled differently from
80 // the prepended transient blocks: it did not prefix the user's prompt, it
81 // REPLACED the whole turn, keeping the user's text only in the contract's
82 // source_event field. Dropping it like a prefix block would leave an empty
83 // string, so we unwrap it to the original prompt instead — otherwise old
84 // sessions whose first turn was compiled would show a blank history/sidebar
85 // preview (#5307).
86 func StripTransientUserBlocks(content string) string {
87 s := unwrapMemoryCompilerExecution(content)
88 for {
89 next := reTransientUserBlock.ReplaceAllStringFunc(s, func(string) string {
90 return ""
91 })
92 if next == s {
93 break
94 }
95 s = next
96 }
97 s = stripTrailingDeliveryRuntime(s)
98 s = stripTrailingMemoryRecall(s)
99 return strings.TrimLeft(s, " \t\r\n")
100 }
101
102 func stripTrailingMemoryRecall(s string) string {
103 trimmed := strings.TrimRight(s, " \t\r\n")
104 const open = "<memory-recall>"
105 const close = "</memory-recall>"
106 if !strings.HasSuffix(trimmed, close) {
107 return s
108 }
109 if index := strings.LastIndex(trimmed, open); index >= 0 {
110 return strings.TrimRight(trimmed[:index], " \t\r\n")
111 }
112 return s
113 }
114
115 // unwrapMemoryCompilerExecution replaces a <memory-compiler-execution> contract
116 // with the user prompt it was compiled from (the contract's source_event), so
117 // display text and previews show what the user typed rather than the raw IR
118 // JSON or an empty string. Non-contract content is returned unchanged; a
119 // contract without a recoverable source_event collapses to empty, matching the
120 // prior "strip the block" behavior only as a last resort.
121 func unwrapMemoryCompilerExecution(content string) string {
122 // Unwrap to a fixpoint. A long goal loop (the #5342 bug) could re-compile an
123 // echoed contract many times, so source_event nests another full
124 // <memory-compiler-execution> block; each pass peels the outermost layer and
125 // exposes the next. A single (or fixed two) pass leaves raw contract JSON in
126 // the transcript (#5361). maxDepth bounds pathological accretion.
127 const maxDepth = 24
128 for range maxDepth {
129 if !ContainsMemoryCompilerExecution(content) {
130 return content
131 }
132 next := reMemoryCompilerExecution.ReplaceAllStringFunc(content, func(block string) string {
133 m := reMemoryCompilerExecution.FindStringSubmatch(block)
134 if len(m) < 2 {
135 return ""
136 }
137 return memoryCompilerSourceEvent(m[1])
138 })
139 if next == content {
140 break // no complete block matched (e.g. a dangling/truncated tag)
141 }
142 content = next
143 }
144 // Any residual open tag is a dangling/partial/unparseable block the strict
145 // regex can't complete; drop from the first open tag onward so raw contract
146 // JSON is never surfaced. The user's actual text precedes it.
147 if idx := strings.Index(content, memoryCompilerExecutionOpen); idx >= 0 {
148 content = strings.TrimRight(content[:idx], " \t\r\n")
149 }
150 return content
151 }
152
153 // memoryCompilerSourceEvent pulls the original user prompt out of a compiled
154 // execution contract's JSON body. The source_event lives under planner_ir; an
155 // older/looser shape may carry it at the top level, so both are checked.
156 // Returns "" when the body is not the expected JSON or carries no source_event.
157 func memoryCompilerSourceEvent(body string) string {
158 var contract struct {
159 SourceEvent string `json:"source_event"`
160 PlannerIR struct {
161 SourceEvent string `json:"source_event"`
162 } `json:"planner_ir"`
163 }
164 if err := json.Unmarshal([]byte(strings.TrimSpace(body)), &contract); err != nil {
165 return ""
166 }
167 if s := strings.TrimSpace(contract.PlannerIR.SourceEvent); s != "" {
168 return s
169 }
170 return strings.TrimSpace(contract.SourceEvent)
171 }
172
173 // UserPreviewText returns the user-authored part of a persisted user message.
174 func UserPreviewText(content string) string {
175 s := StripTransientUserBlocks(content)
176 s = HandoffTask(s)
177 s = StripTransientUserBlocks(s)
178 return strings.TrimSpace(s)
179 }
180
181 // pasteDisplayLabelPattern matches the standalone label desktop prepends to a
182 // pasted-text turn. It is UI chrome rather than user intent, so title and
183 // preview derivation may remove it without touching inline label mentions.
184 var pasteDisplayLabelPattern = regexp.MustCompile(`^\[(?:已粘贴文本|已貼上文字|Pasted text) #[0-9]+ · [0-9]+ (?:行|lines)\][ \t]*(?:\r?\n)?`)
185
186 // StripPasteDisplayLabel removes one leading desktop pasted-text label while
187 // preserving the remainder byte-for-byte.
188 func StripPasteDisplayLabel(content string) string {
189 return pasteDisplayLabelPattern.ReplaceAllString(content, "")
190 }
191
192 // UserMessageText returns the best user-authored view of a persisted user turn.
193 // New sessions carry the exact raw text explicitly; older sessions fall back to
194 // deterministic wrapper stripping.
195 func UserMessageText(msg provider.Message) string {
196 if msg.RawContent != "" {
197 return strings.TrimSpace(msg.RawContent)
198 }
199 return UserPreviewText(msg.Content)
200 }
201
202 // migrateLegacyProviderContent canonicalizes both historical user-turn shapes:
203 // legacy turns kept provider-visible text only in Content, while early Context
204 // Engine v2 builds inverted Content and ProviderContent. Canonical sessions
205 // keep provider-visible bytes in Content so previous releases replay them
206 // safely, with user-authored text in RawContent for current display/search.
207 func migrateLegacyProviderContent(msgs []provider.Message) []provider.Message {
208 var upgraded []provider.Message
209 for i, msg := range msgs {
210 if msg.Role != provider.RoleUser {
211 continue
212 }
213 switch {
214 case msg.ProviderContent != "":
215 if upgraded == nil {
216 upgraded = append([]provider.Message(nil), msgs...)
217 }
218 if upgraded[i].RawContent == "" {
219 upgraded[i].RawContent = msg.Content
220 }
221 upgraded[i].Content = msg.ProviderContent
222 upgraded[i].ProviderContent = ""
223 case msg.RawContent == "" && hasLegacyProviderWrapper(msg.Content):
224 if upgraded == nil {
225 upgraded = append([]provider.Message(nil), msgs...)
226 }
227 upgraded[i].RawContent = UserPreviewText(msg.Content)
228 }
229 }
230 if upgraded != nil {
231 return upgraded
232 }
233 return msgs
234 }
235
236 func hasLegacyProviderWrapper(content string) bool {
237 if ContainsMemoryCompilerExecution(content) || reTransientUserBlock.MatchString(content) {
238 return true
239 }
240 if stripTrailingDeliveryRuntime(content) != content {
241 return true
242 }
243 stripped := StripTransientUserBlocks(content)
244 return HandoffTask(stripped) != stripped
245 }
246
247 // SyntheticUserPrefixes lists the openings of host-injected user-role messages
248 // (readiness retries, stream recovery, goal-loop nudges, compaction folds).
249 // They are persisted with role "user" for provider-contract reasons but are not
250 // user-authored: previews, titles, and user-turn counts must skip them, and the
251 // chat UI never renders them as user bubbles. Keep in sync with the injection
252 // sites in internal/agent/agent.go, internal/agent/compact.go, and
253 // internal/control (plan approval, goal loop).
254 var SyntheticUserPrefixes = []string{
255 "<reasoning-language>",
256 "Plan approved — plan mode is off",
257 "Host final-answer readiness check failed",
258 "You are already in the executor phase",
259 "The previous assistant response was interrupted while a tool call",
260 "The previous assistant response was interrupted during streaming",
261 "The previous assistant response was interrupted before visible",
262 "The previous assistant response finished without any visible answer",
263 "<compaction-summary>",
264 "Summary of the later conversation (compacted from here on):",
265 "Summary of earlier conversation (compacted up to here):",
266 "Continue pursuing the active goal",
267 "The agent signaled goal completion and all tasks are marked done.",
268 "Goal signaled complete but issues remain:",
269 "No tool calls in recent turns.",
270 }
271
272 // IsSyntheticUserText reports whether a persisted user-role message is a
273 // host-injected synthetic turn rather than user-authored input.
274 func IsSyntheticUserText(content string) bool {
275 trimmed := strings.TrimSpace(StripTransientUserBlocks(content))
276 for _, prefix := range SyntheticUserPrefixes {
277 if strings.HasPrefix(trimmed, prefix) {
278 return true
279 }
280 }
281 return false
282 }
283
284 // IsUserAuthoredTurn reports whether a persisted user-role message counts as a
285 // visible user turn: not a host-injected synthetic message and not a mid-turn
286 // steer. Preview/title/turn-count derivations share this so a delivery
287 // readiness nudge can never become a session title or inflate turn counts.
288 func IsUserAuthoredTurn(content string) bool {
289 if strings.TrimSpace(StripTransientUserBlocks(content)) == "" {
290 return false
291 }
292 if IsSyntheticUserText(content) {
293 return false
294 }
295 if _, isSteer := SteerText(content); isSteer {
296 return false
297 }
298 return true
299 }
300
300 lines GO